Vier Punkte aus dem Betrieb. Ein toter CC liess sich nicht zurueckholen. Der Knopf dafuer wurde nie angezeigt: bei Grund "Im RP gestorben" landete die Seite im Zweig "Bezahlt reaktivieren", den der Server zu Recht abweist - ein Toter kehrt nur durch einen Administrator zurueck. Der Dialog dafuer war fertig gebaut und wurde schlicht nie geoeffnet. Jetzt steht an einem toten CC "Zurueckholen", fuer Editoren stattdessen der Hinweis, dass das ein Administrator entscheidet. Der Abgleich eines einzelnen Spielers fragte nur dessen hinterlegte SteamID ab. Guthaben, das unter einem frueheren Konto gekauft wurde, blieb damit unsichtbar - obwohl das Konto hinterlegt war und der Sammellauf es beruecksichtigt hat. Jetzt werden alle Kennungen des Spielers abgefragt. Ein Beitritt zu einem Gruppen-CC ueber ein Redesign kostete nur das Guthaben. Damit entstand aus einem Redesign ein zusaetzlicher CC, was die Regel gerade nicht hergibt: ein Redesign ersetzt einen CC. Jetzt muss angegeben werden, welcher eigene CC dafuer aufgegeben wird; ein aktiver wird dabei inaktiv gesetzt, ein bereits inaktiver bleibt unveraendert. Ueber ein CC-Paket kommt weiterhin ein Platz hinzu, ohne Vorgaenger. Welcher CC aufgegeben wurde, haengt am Beitritt und bleibt nachvollziehbar. Faelle, die sich beim Eintragen nicht klaeren lassen - ein Antrag ohne auffindbares Paket, ein fehlender GExtension-Eintrag, widerspruechliche Angaben - koennen als Spieler angelegt und dabei als Problem vorgemerkt werden, mit Notiz. Auch nachtraeglich setzbar und wieder abraeumbar. Die Markierung ist folgenlos: sie sperrt nichts und veraendert weder Guthaben noch CCs. Die Uebersicht kennzeichnet betroffene Spieler und laesst sich darauf eingrenzen. Nebenbei: die Spielerliste gab die Markierung nicht mit aus, aufgefallen beim Test.
790 lines
25 KiB
TypeScript
790 lines
25 KiB
TypeScript
import { randomUUID } from 'node:crypto';
|
|
import {
|
|
CharacterStatus,
|
|
CreditKind,
|
|
CreditSource,
|
|
InactiveReason,
|
|
type Character,
|
|
} from '@prisma/client';
|
|
import { prisma } from '../db.js';
|
|
import { badRequest, conflict, notFound } from '../errors.js';
|
|
import { getBalances, type Balances } from './credits.js';
|
|
import { recordAudit, type ActionContext } from './audit.js';
|
|
|
|
export interface CharacterResult {
|
|
character: Character;
|
|
balances: Balances;
|
|
// Zeigt an, dass ein Topf nach der Buchung im Minus steht. Die Aktion wird
|
|
// trotzdem ausgeführt, das Ergebnis gehört aber sichtbar gemacht.
|
|
overdrawn: boolean;
|
|
}
|
|
|
|
/// Diese Gründe lassen den CC unverändert bestehen - er kommt gratis zurück.
|
|
const FREE_REASONS = [InactiveReason.PLAYER_INACTIVITY, InactiveReason.OTHER] as const;
|
|
|
|
/// Hier ist der CC entweder ersetzt oder entzogen: die Rückkehr kostet ein Guthaben.
|
|
const PAID_REASONS = [InactiveReason.REWORKED, InactiveReason.NO_FREE_CLAIM] as const;
|
|
|
|
// Für ein Redesign zählen nur die beiden Redesign-Töpfe.
|
|
const REDESIGN_KINDS = [CreditKind.REDESIGN_PAID, CreditKind.REDESIGN_KULANZ] as const;
|
|
// Beim Wiederbeleben eines reworkten CCs darf zusätzlich CC-Guthaben gezogen werden.
|
|
const REVIVE_KINDS = [CreditKind.CC, ...REDESIGN_KINDS] as const;
|
|
|
|
export type RedesignKind = (typeof REDESIGN_KINDS)[number];
|
|
export type ReviveKind = (typeof REVIVE_KINDS)[number];
|
|
|
|
function isOverdrawn(balances: Balances): boolean {
|
|
return balances.CC < 0 || balances.REDESIGN_PAID < 0 || balances.REDESIGN_KULANZ < 0;
|
|
}
|
|
|
|
async function loadCharacter(id: number): Promise<Character> {
|
|
const character = await prisma.character.findUnique({ where: { id } });
|
|
if (!character) throw notFound('CC');
|
|
return character;
|
|
}
|
|
|
|
/** Neuer CC. Kostet ein CC-Guthaben, Kulanz ist dafür nicht einsetzbar. */
|
|
export async function createCharacter(
|
|
playerId: number,
|
|
input: { name: string; loreText?: string | undefined; note?: string | undefined },
|
|
ctx: ActionContext,
|
|
): Promise<CharacterResult> {
|
|
const player = await prisma.player.findUnique({ where: { id: playerId }, select: { id: true } });
|
|
if (!player) throw notFound('Spieler');
|
|
|
|
const character = await prisma.$transaction(async (tx) => {
|
|
const created = await tx.character.create({
|
|
data: {
|
|
playerId,
|
|
name: input.name,
|
|
loreText: input.loreText ?? null,
|
|
status: CharacterStatus.ACTIVE,
|
|
lineageId: randomUUID(),
|
|
createdById: ctx.actorId,
|
|
// Der Ersteller ist zugleich das erste Mitglied. Die Zugehörigkeit
|
|
// läuft durchgehend über diese Tabelle, auch bei Einzel-CCs.
|
|
members: { create: [{ playerId, createdById: ctx.actorId }] },
|
|
},
|
|
});
|
|
|
|
await tx.creditLedger.create({
|
|
data: {
|
|
playerId,
|
|
kind: CreditKind.CC,
|
|
amount: -1,
|
|
source: CreditSource.CONSUME,
|
|
groupId: randomUUID(),
|
|
characterId: created.id,
|
|
note: input.note ?? `Neuer CC: ${created.name}`,
|
|
createdById: ctx.actorId,
|
|
},
|
|
});
|
|
|
|
await recordAudit(tx, ctx, {
|
|
action: 'character.create',
|
|
targetType: 'character',
|
|
targetId: created.id,
|
|
details: { playerId, name: created.name },
|
|
});
|
|
|
|
return created;
|
|
});
|
|
|
|
const balances = await getBalances(playerId);
|
|
return { character, balances, overdrawn: isOverdrawn(balances) };
|
|
}
|
|
|
|
/**
|
|
* Redesign als Lineage: der alte CC geht auf INACTIVE/REWORKED, der neue
|
|
* entsteht als eigener Datensatz in derselben Kette. Damit greift später
|
|
* automatisch die Regel, dass ein reworkter CC nur bezahlt zurückkommt.
|
|
*/
|
|
export async function redesignCharacter(
|
|
characterId: number,
|
|
input: {
|
|
name: string;
|
|
loreText?: string | undefined;
|
|
useKind: RedesignKind;
|
|
note?: string | undefined;
|
|
},
|
|
ctx: ActionContext,
|
|
): Promise<CharacterResult & { predecessor: Character }> {
|
|
const old = await loadCharacter(characterId);
|
|
|
|
if (old.status !== CharacterStatus.ACTIVE) {
|
|
throw conflict('NOT_ACTIVE', 'Nur ein aktiver CC kann redesignt werden');
|
|
}
|
|
if (!REDESIGN_KINDS.includes(input.useKind)) {
|
|
throw badRequest('INVALID_KIND', 'Ein Redesign kostet bezahltes oder Kulanz-Redesign-Guthaben');
|
|
}
|
|
|
|
const result = await prisma.$transaction(async (tx) => {
|
|
const predecessor = await tx.character.update({
|
|
where: { id: old.id },
|
|
data: { status: CharacterStatus.INACTIVE, inactiveReason: InactiveReason.REWORKED },
|
|
});
|
|
|
|
// Ein Gruppen-CC bleibt nach dem Redesign eine Gruppe: alle, die zum
|
|
// Zeitpunkt des Redesigns dabei waren, sind es auch danach.
|
|
const bisherige = await tx.characterMember.findMany({
|
|
where: { characterId: old.id, leftAt: null },
|
|
select: { playerId: true, paidWith: true },
|
|
});
|
|
|
|
const created = await tx.character.create({
|
|
data: {
|
|
playerId: old.playerId,
|
|
name: input.name,
|
|
loreText: input.loreText ?? null,
|
|
status: CharacterStatus.ACTIVE,
|
|
lineageId: old.lineageId,
|
|
predecessorId: old.id,
|
|
createdById: ctx.actorId,
|
|
members: {
|
|
create: bisherige.map((mitglied) => ({
|
|
playerId: mitglied.playerId,
|
|
paidWith: mitglied.paidWith,
|
|
createdById: ctx.actorId,
|
|
})),
|
|
},
|
|
},
|
|
});
|
|
|
|
await tx.creditLedger.create({
|
|
data: {
|
|
playerId: old.playerId,
|
|
kind: input.useKind,
|
|
amount: -1,
|
|
source: CreditSource.CONSUME,
|
|
groupId: randomUUID(),
|
|
characterId: created.id,
|
|
note: input.note ?? `Redesign: ${old.name} -> ${created.name}`,
|
|
createdById: ctx.actorId,
|
|
},
|
|
});
|
|
|
|
await recordAudit(tx, ctx, {
|
|
action: 'character.redesign',
|
|
targetType: 'character',
|
|
targetId: created.id,
|
|
details: {
|
|
playerId: old.playerId,
|
|
predecessorId: old.id,
|
|
from: old.name,
|
|
to: created.name,
|
|
useKind: input.useKind,
|
|
},
|
|
});
|
|
|
|
return { created, predecessor };
|
|
});
|
|
|
|
const balances = await getBalances(old.playerId);
|
|
return {
|
|
character: result.created,
|
|
predecessor: result.predecessor,
|
|
balances,
|
|
overdrawn: isOverdrawn(balances),
|
|
};
|
|
}
|
|
|
|
/** Deaktivierung ohne Guthaben-Effekt, etwa beim Serverputz wegen Inaktivität. */
|
|
export async function deactivateCharacter(
|
|
characterId: number,
|
|
reason: InactiveReason,
|
|
ctx: ActionContext,
|
|
note?: string,
|
|
): Promise<CharacterResult> {
|
|
const existing = await loadCharacter(characterId);
|
|
if (existing.status === CharacterStatus.INACTIVE) {
|
|
throw conflict('ALREADY_INACTIVE', 'Dieser CC ist bereits inaktiv');
|
|
}
|
|
if (reason === InactiveReason.REWORKED) {
|
|
throw badRequest(
|
|
'USE_REDESIGN',
|
|
'Ein Rework entsteht über die Redesign-Aktion, nicht über eine Deaktivierung',
|
|
);
|
|
}
|
|
if (reason === InactiveReason.DEAD && !note?.trim()) {
|
|
throw badRequest('NEED_NOTE', 'Für einen Tod im Rollenspiel wird eine Begründung gebraucht');
|
|
}
|
|
|
|
const character = await prisma.$transaction(async (tx) => {
|
|
const updated = await tx.character.update({
|
|
where: { id: characterId },
|
|
data: {
|
|
status: CharacterStatus.INACTIVE,
|
|
inactiveReason: reason,
|
|
inactiveNote: note?.trim() || null,
|
|
},
|
|
});
|
|
|
|
await recordAudit(tx, ctx, {
|
|
action: 'character.deactivate',
|
|
targetType: 'character',
|
|
targetId: characterId,
|
|
details: { reason, note: note ?? null, name: updated.name },
|
|
});
|
|
|
|
return updated;
|
|
});
|
|
|
|
const balances = await getBalances(character.playerId);
|
|
return { character, balances, overdrawn: isOverdrawn(balances) };
|
|
}
|
|
|
|
/**
|
|
* Gratis-Reaktivierung. Möglich, solange der CC unverändert weiterexistiert -
|
|
* ob er wegen Spieler-Inaktivität geputzt oder die Einheit zeitweise entfernt
|
|
* wurde, spielt keine Rolle. Ausgeschlossen sind ein Rework, ein Entzug ohne
|
|
* Anspruch und der Tod im Rollenspiel.
|
|
*/
|
|
export async function reactivateFree(
|
|
characterId: number,
|
|
ctx: ActionContext,
|
|
): Promise<CharacterResult> {
|
|
const existing = await loadCharacter(characterId);
|
|
|
|
if (existing.status === CharacterStatus.ACTIVE) {
|
|
throw conflict('ALREADY_ACTIVE', 'Dieser CC ist bereits aktiv');
|
|
}
|
|
if (existing.inactiveReason === InactiveReason.DEAD) {
|
|
throw conflict(
|
|
'IS_DEAD',
|
|
'Dieser CC ist im Rollenspiel gestorben. Seine Rückkehr entscheidet ein Administrator.',
|
|
);
|
|
}
|
|
if (!FREE_REASONS.includes(existing.inactiveReason as (typeof FREE_REASONS)[number])) {
|
|
throw conflict(
|
|
'NOT_FREE',
|
|
'Dieser CC hat keinen Anspruch auf kostenlose Rückkehr. Seine Reaktivierung kostet ein Guthaben.',
|
|
);
|
|
}
|
|
|
|
const character = await prisma.$transaction(async (tx) => {
|
|
const updated = await tx.character.update({
|
|
where: { id: characterId },
|
|
data: { status: CharacterStatus.ACTIVE, inactiveReason: null, inactiveNote: null },
|
|
});
|
|
|
|
await recordAudit(tx, ctx, {
|
|
action: 'character.reactivate_free',
|
|
targetType: 'character',
|
|
targetId: characterId,
|
|
details: { playerId: updated.playerId, name: updated.name },
|
|
});
|
|
|
|
return updated;
|
|
});
|
|
|
|
const balances = await getBalances(character.playerId);
|
|
return { character, balances, overdrawn: isOverdrawn(balances) };
|
|
}
|
|
|
|
/**
|
|
* Bezahlte Reaktivierung, ausschließlich für redesignte CCs: deren Original
|
|
* existiert nicht mehr, es muss also neu erschaffen werden. Kostet ein
|
|
* Redesign- oder ein CC-Guthaben, der Nutzer entscheidet welches.
|
|
*/
|
|
export async function reactivatePaid(
|
|
characterId: number,
|
|
useKind: ReviveKind,
|
|
ctx: ActionContext,
|
|
note?: string,
|
|
): Promise<CharacterResult> {
|
|
const existing = await loadCharacter(characterId);
|
|
|
|
if (existing.status === CharacterStatus.ACTIVE) {
|
|
throw conflict('ALREADY_ACTIVE', 'Dieser CC ist bereits aktiv');
|
|
}
|
|
if (existing.inactiveReason === InactiveReason.DEAD) {
|
|
throw conflict(
|
|
'IS_DEAD',
|
|
'Ein im Rollenspiel gestorbener CC kehrt nur durch einen Administrator zurück.',
|
|
);
|
|
}
|
|
if (!PAID_REASONS.includes(existing.inactiveReason as (typeof PAID_REASONS)[number])) {
|
|
throw conflict(
|
|
'IS_FREE',
|
|
'Dieser CC existiert unverändert weiter und kommt gratis zurück. Eine Guthaben-Buchung wäre hier falsch.',
|
|
);
|
|
}
|
|
if (!REVIVE_KINDS.includes(useKind)) {
|
|
throw badRequest('INVALID_KIND', 'Ungültige Guthaben-Art für eine Reaktivierung');
|
|
}
|
|
|
|
const character = await prisma.$transaction(async (tx) => {
|
|
const updated = await tx.character.update({
|
|
where: { id: characterId },
|
|
data: { status: CharacterStatus.ACTIVE, inactiveReason: null, inactiveNote: null },
|
|
});
|
|
|
|
await tx.creditLedger.create({
|
|
data: {
|
|
playerId: updated.playerId,
|
|
kind: useKind,
|
|
amount: -1,
|
|
source: CreditSource.CONSUME,
|
|
groupId: randomUUID(),
|
|
characterId: updated.id,
|
|
note: note ?? `Reaktivierung: ${updated.name}`,
|
|
createdById: ctx.actorId,
|
|
},
|
|
});
|
|
|
|
await recordAudit(tx, ctx, {
|
|
action: 'character.reactivate_paid',
|
|
targetType: 'character',
|
|
targetId: characterId,
|
|
details: {
|
|
playerId: updated.playerId,
|
|
name: updated.name,
|
|
useKind,
|
|
previousReason: existing.inactiveReason,
|
|
},
|
|
});
|
|
|
|
return updated;
|
|
});
|
|
|
|
const balances = await getBalances(character.playerId);
|
|
return { character, balances, overdrawn: isOverdrawn(balances) };
|
|
}
|
|
|
|
export async function updateCharacter(
|
|
characterId: number,
|
|
input: { name?: string; loreText?: string | null },
|
|
ctx: ActionContext,
|
|
): Promise<Character> {
|
|
await loadCharacter(characterId);
|
|
|
|
return prisma.$transaction(async (tx) => {
|
|
const updated = await tx.character.update({
|
|
where: { id: characterId },
|
|
data: {
|
|
...(input.name !== undefined ? { name: input.name } : {}),
|
|
...(input.loreText !== undefined ? { loreText: input.loreText } : {}),
|
|
},
|
|
});
|
|
|
|
await recordAudit(tx, ctx, {
|
|
action: 'character.update',
|
|
targetType: 'character',
|
|
targetId: characterId,
|
|
details: { name: updated.name },
|
|
});
|
|
|
|
return updated;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Nimmt einen irrtümlich angelegten CC komplett zurück: Verbrauchsbuchungen
|
|
* werden gegengebucht und ein durch das Redesign deaktivierter Vorgänger
|
|
* kehrt in den aktiven Zustand zurück.
|
|
*/
|
|
export async function revertCharacter(
|
|
characterId: number,
|
|
reason: string,
|
|
ctx: ActionContext,
|
|
): Promise<{ balances: Balances; restoredPredecessorId: number | null }> {
|
|
const existing = await prisma.character.findUnique({
|
|
where: { id: characterId },
|
|
include: { successors: { select: { id: true } }, predecessor: true },
|
|
});
|
|
if (!existing) throw notFound('CC');
|
|
|
|
if (existing.successors.length > 0) {
|
|
throw conflict(
|
|
'HAS_SUCCESSORS',
|
|
'Dieser CC hat Nachfolger in der Redesign-Kette. Bitte zuerst den jüngsten CC zurücknehmen.',
|
|
);
|
|
}
|
|
|
|
const consumed = await prisma.creditLedger.findMany({
|
|
where: { characterId, source: CreditSource.CONSUME },
|
|
include: { reversedBy: { select: { id: true } } },
|
|
});
|
|
const open = consumed.filter((entry) => entry.reversedBy === null);
|
|
|
|
const predecessor = existing.predecessor;
|
|
const restorePredecessor =
|
|
predecessor !== null &&
|
|
predecessor.status === CharacterStatus.INACTIVE &&
|
|
predecessor.inactiveReason === InactiveReason.REWORKED;
|
|
|
|
await prisma.$transaction(async (tx) => {
|
|
for (const entry of open) {
|
|
await tx.creditLedger.create({
|
|
data: {
|
|
playerId: entry.playerId,
|
|
kind: entry.kind,
|
|
amount: -entry.amount,
|
|
source: CreditSource.REVERSAL,
|
|
groupId: entry.groupId,
|
|
note: `Rücknahme "${existing.name}": ${reason}`,
|
|
reversalOfId: entry.id,
|
|
createdById: ctx.actorId,
|
|
},
|
|
});
|
|
}
|
|
|
|
if (restorePredecessor && predecessor) {
|
|
await tx.character.update({
|
|
where: { id: predecessor.id },
|
|
data: { status: CharacterStatus.ACTIVE, inactiveReason: null, inactiveNote: null },
|
|
});
|
|
}
|
|
|
|
await tx.character.delete({ where: { id: characterId } });
|
|
|
|
await recordAudit(tx, ctx, {
|
|
action: 'character.revert',
|
|
targetType: 'character',
|
|
targetId: characterId,
|
|
details: {
|
|
playerId: existing.playerId,
|
|
name: existing.name,
|
|
reason,
|
|
reversedLedgerIds: open.map((e) => e.id),
|
|
restoredPredecessorId: restorePredecessor ? predecessor?.id ?? null : null,
|
|
},
|
|
});
|
|
});
|
|
|
|
return {
|
|
balances: await getBalances(existing.playerId),
|
|
restoredPredecessorId: restorePredecessor ? predecessor?.id ?? null : null,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Die CCs eines Spielers. Maßgeblich ist die Mitgliedschaft, nicht wer den CC
|
|
* angelegt hat - sonst fehlten Gruppen-CCs, denen der Spieler beigetreten ist.
|
|
*/
|
|
export async function listCharacters(playerId: number) {
|
|
return prisma.character.findMany({
|
|
where: { members: { some: { playerId, leftAt: null } } },
|
|
orderBy: [{ status: 'asc' }, { createdAt: 'desc' }],
|
|
include: {
|
|
predecessor: { select: { id: true, name: true } },
|
|
successors: { select: { id: true, name: true } },
|
|
// Der verknüpfte Antrag macht den Namen zum Link ins Forum.
|
|
forumThreads: {
|
|
select: { threadId: true, title: true, url: true },
|
|
orderBy: { threadId: 'desc' },
|
|
},
|
|
members: {
|
|
orderBy: [{ leftAt: 'asc' }, { joinedAt: 'asc' }],
|
|
include: { player: { select: { id: true, displayName: true } } },
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
|
|
/**
|
|
* Bringt einen im Rollenspiel gestorbenen CC zurück. Anders als die übrigen
|
|
* Wege ist das eine Ausnahmeentscheidung: sie bleibt Administratoren
|
|
* vorbehalten, verlangt eine Begründung und hält beides im Protokoll fest.
|
|
*
|
|
* Ob dabei ein Guthaben fließt, entscheidet der Administrator - ein Tod ist
|
|
* weder automatisch ein Rework noch ein Serverputz.
|
|
*/
|
|
export async function reviveDeadCharacter(
|
|
characterId: number,
|
|
input: { reason: string; useKind?: ReviveKind | undefined },
|
|
ctx: ActionContext,
|
|
): Promise<CharacterResult> {
|
|
const existing = await loadCharacter(characterId);
|
|
|
|
if (existing.status === CharacterStatus.ACTIVE) {
|
|
throw conflict('ALREADY_ACTIVE', 'Dieser CC ist bereits aktiv');
|
|
}
|
|
if (existing.inactiveReason !== InactiveReason.DEAD) {
|
|
throw conflict(
|
|
'NOT_DEAD',
|
|
'Dieser CC ist nicht als im Rollenspiel gestorben vermerkt - bitte den normalen Weg nutzen',
|
|
);
|
|
}
|
|
if (!input.reason.trim()) {
|
|
throw badRequest('NEED_REASON', 'Für die Rückkehr eines toten CCs wird eine Begründung gebraucht');
|
|
}
|
|
if (input.useKind !== undefined && !REVIVE_KINDS.includes(input.useKind)) {
|
|
throw badRequest('INVALID_KIND', 'Ungültige Guthaben-Art');
|
|
}
|
|
|
|
const character = await prisma.$transaction(async (tx) => {
|
|
const updated = await tx.character.update({
|
|
where: { id: characterId },
|
|
data: { status: CharacterStatus.ACTIVE, inactiveReason: null, inactiveNote: null },
|
|
});
|
|
|
|
if (input.useKind !== undefined) {
|
|
await tx.creditLedger.create({
|
|
data: {
|
|
playerId: updated.playerId,
|
|
kind: input.useKind,
|
|
amount: -1,
|
|
source: CreditSource.CONSUME,
|
|
groupId: randomUUID(),
|
|
characterId: updated.id,
|
|
note: `Rückkehr nach Tod: ${input.reason}`,
|
|
createdById: ctx.actorId,
|
|
},
|
|
});
|
|
}
|
|
|
|
await recordAudit(tx, ctx, {
|
|
action: 'character.revive_dead',
|
|
targetType: 'character',
|
|
targetId: characterId,
|
|
details: {
|
|
playerId: updated.playerId,
|
|
name: updated.name,
|
|
reason: input.reason,
|
|
useKind: input.useKind ?? null,
|
|
previousNote: existing.inactiveNote,
|
|
},
|
|
});
|
|
|
|
return updated;
|
|
});
|
|
|
|
const balances = await getBalances(character.playerId);
|
|
return { character, balances, overdrawn: isOverdrawn(balances) };
|
|
}
|
|
|
|
|
|
// --- Gruppen-CCs ---
|
|
|
|
/// Ein Platz in einem CC kostet ein CC-Guthaben oder ein Redesign - je nachdem,
|
|
/// worüber der Spieler hinzukommt.
|
|
const JOIN_KINDS = [CreditKind.CC, CreditKind.REDESIGN_PAID, CreditKind.REDESIGN_KULANZ] as const;
|
|
export type JoinKind = (typeof JOIN_KINDS)[number];
|
|
|
|
/// Beitritte, die auf einem Redesign beruhen und deshalb einen CC kosten.
|
|
const JOIN_REDESIGN_KINDS: JoinKind[] = [CreditKind.REDESIGN_PAID, CreditKind.REDESIGN_KULANZ];
|
|
|
|
/**
|
|
* Prüft den CC, den ein Spieler für seinen Platz aufgibt.
|
|
*
|
|
* Über ein Redesign gibt es keinen zusätzlichen CC, sondern einen anderen:
|
|
* ohne einen eigenen CC als Grundlage gibt es nichts zu ersetzen. Aktiv muss
|
|
* dieser nicht sein - auch ein längst inaktiver CC lässt sich redesignen.
|
|
* Ein CC-Guthaben dagegen kauft einen Platz und verlangt keinen Vorgänger.
|
|
*/
|
|
async function ladeAufgegebenenCC(
|
|
input: { useKind: JoinKind; predecessorId?: number | undefined },
|
|
playerId: number,
|
|
characterId: number,
|
|
): Promise<Character | null> {
|
|
if (!JOIN_REDESIGN_KINDS.includes(input.useKind)) {
|
|
if (input.predecessorId !== undefined) {
|
|
throw badRequest(
|
|
'NO_PREDECESSOR_NEEDED',
|
|
'Ein Platz aus dem CC-Guthaben ersetzt keinen bestehenden CC',
|
|
);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
if (input.predecessorId === undefined) {
|
|
throw badRequest(
|
|
'NEED_PREDECESSOR',
|
|
'Ein Beitritt über ein Redesign ersetzt einen eigenen CC - bitte angeben, welchen',
|
|
);
|
|
}
|
|
if (input.predecessorId === characterId) {
|
|
throw conflict('SAME_CHARACTER', 'Ein CC kann nicht für sich selbst aufgegeben werden');
|
|
}
|
|
|
|
const vorgaenger = await prisma.character.findUnique({
|
|
where: { id: input.predecessorId },
|
|
include: { members: { where: { playerId, leftAt: null }, select: { id: true } } },
|
|
});
|
|
if (!vorgaenger) throw notFound('Aufgegebener CC');
|
|
|
|
// Aufgeben kann nur, wem der CC gehört. Bei einem aktiven CC zählt die
|
|
// Mitgliedschaft, bei einem inaktiven der Eintrag als Inhaber - dort ist
|
|
// die Mitgliedschaft beim Ausscheiden beendet worden.
|
|
const gehoertIhm =
|
|
vorgaenger.members.length > 0 ||
|
|
(vorgaenger.status !== CharacterStatus.ACTIVE && vorgaenger.playerId === playerId);
|
|
if (!gehoertIhm) {
|
|
throw conflict('NOT_OWN_CHARACTER', 'Dieser CC gehört dem Spieler nicht');
|
|
}
|
|
|
|
return vorgaenger;
|
|
}
|
|
|
|
/**
|
|
* Nimmt einen weiteren Spieler in einen CC auf. Für ihn gilt dasselbe wie für
|
|
* einen eigenen CC: der Platz kostet ein Guthaben. Ob über ein CC-Paket oder
|
|
* ein Redesign, entscheidet der Nutzer - beide Wege kommen vor.
|
|
*/
|
|
export async function addMember(
|
|
characterId: number,
|
|
playerId: number,
|
|
input: { useKind: JoinKind; predecessorId?: number | undefined; note?: string | undefined },
|
|
ctx: ActionContext,
|
|
): Promise<CharacterResult & { memberCount: number }> {
|
|
const character = await loadCharacter(characterId);
|
|
|
|
if (character.status !== CharacterStatus.ACTIVE) {
|
|
throw conflict('NOT_ACTIVE', 'Einem inaktiven CC kann niemand beitreten');
|
|
}
|
|
if (!JOIN_KINDS.includes(input.useKind)) {
|
|
throw badRequest('INVALID_KIND', 'Ungültige Guthaben-Art für einen Beitritt');
|
|
}
|
|
|
|
const player = await prisma.player.findUnique({ where: { id: playerId }, select: { id: true } });
|
|
if (!player) throw notFound('Spieler');
|
|
|
|
const vorhanden = await prisma.characterMember.findUnique({
|
|
where: { characterId_playerId: { characterId, playerId } },
|
|
});
|
|
if (vorhanden && vorhanden.leftAt === null) {
|
|
throw conflict('ALREADY_MEMBER', 'Dieser Spieler nutzt den CC bereits');
|
|
}
|
|
|
|
// Ein Redesign ersetzt einen CC, es kommt nie einer dazu. Wer über ein
|
|
// Redesign beitritt, gibt dafür einen eigenen CC auf - genau wie bei jedem
|
|
// anderen Redesign. Nur ein CC-Guthaben kauft einen zusätzlichen Platz.
|
|
const vorgaenger = await ladeAufgegebenenCC(input, playerId, characterId);
|
|
|
|
await prisma.$transaction(async (tx) => {
|
|
if (vorgaenger !== null && vorgaenger.status === CharacterStatus.ACTIVE) {
|
|
await tx.character.update({
|
|
where: { id: vorgaenger.id },
|
|
data: { status: CharacterStatus.INACTIVE, inactiveReason: InactiveReason.REWORKED },
|
|
});
|
|
// Der Spieler nutzt den aufgegebenen CC nicht mehr; bei einem Gruppen-CC
|
|
// laufen die übrigen Mitglieder dort unberührt weiter.
|
|
await tx.characterMember.updateMany({
|
|
where: { characterId: vorgaenger.id, playerId, leftAt: null },
|
|
data: { leftAt: new Date() },
|
|
});
|
|
}
|
|
|
|
if (vorhanden) {
|
|
// Wiedereintritt: der alte Eintrag wird fortgeschrieben, damit die
|
|
// Zugehörigkeit nicht doppelt in der Liste steht.
|
|
await tx.characterMember.update({
|
|
where: { id: vorhanden.id },
|
|
data: {
|
|
leftAt: null,
|
|
joinedAt: new Date(),
|
|
paidWith: input.useKind,
|
|
predecessorId: vorgaenger?.id ?? null,
|
|
note: input.note ?? null,
|
|
createdById: ctx.actorId,
|
|
},
|
|
});
|
|
} else {
|
|
await tx.characterMember.create({
|
|
data: {
|
|
characterId,
|
|
playerId,
|
|
paidWith: input.useKind,
|
|
predecessorId: vorgaenger?.id ?? null,
|
|
note: input.note ?? null,
|
|
createdById: ctx.actorId,
|
|
},
|
|
});
|
|
}
|
|
|
|
await tx.creditLedger.create({
|
|
data: {
|
|
playerId,
|
|
kind: input.useKind,
|
|
amount: -1,
|
|
source: CreditSource.CONSUME,
|
|
groupId: randomUUID(),
|
|
characterId,
|
|
note:
|
|
input.note ??
|
|
(vorgaenger
|
|
? `Beitritt zu "${character.name}" anstelle von "${vorgaenger.name}"`
|
|
: `Beitritt zu "${character.name}"`),
|
|
createdById: ctx.actorId,
|
|
},
|
|
});
|
|
|
|
await recordAudit(tx, ctx, {
|
|
action: 'character.member_added',
|
|
targetType: 'character',
|
|
targetId: characterId,
|
|
details: {
|
|
playerId,
|
|
name: character.name,
|
|
useKind: input.useKind,
|
|
predecessorId: vorgaenger?.id ?? null,
|
|
predecessorName: vorgaenger?.name ?? null,
|
|
},
|
|
});
|
|
});
|
|
|
|
const [balances, memberCount] = await Promise.all([
|
|
getBalances(playerId),
|
|
prisma.characterMember.count({ where: { characterId, leftAt: null } }),
|
|
]);
|
|
|
|
return { character, balances, overdrawn: isOverdrawn(balances), memberCount };
|
|
}
|
|
|
|
/**
|
|
* Trägt einen Spieler wieder aus. Das verbrauchte Guthaben bleibt verbraucht -
|
|
* wer aussteigt, hat den CC bis dahin genutzt. Für eine Fehleingabe ist die
|
|
* Rücknahme der Buchung der richtige Weg, nicht der Austritt.
|
|
*/
|
|
export async function removeMember(
|
|
characterId: number,
|
|
playerId: number,
|
|
note: string | undefined,
|
|
ctx: ActionContext,
|
|
): Promise<{ memberCount: number }> {
|
|
const mitglied = await prisma.characterMember.findUnique({
|
|
where: { characterId_playerId: { characterId, playerId } },
|
|
include: { character: { select: { name: true } } },
|
|
});
|
|
if (!mitglied || mitglied.leftAt !== null) {
|
|
throw notFound('Mitgliedschaft');
|
|
}
|
|
|
|
const verbleibend = await prisma.characterMember.count({
|
|
where: { characterId, leftAt: null },
|
|
});
|
|
if (verbleibend <= 1) {
|
|
throw conflict(
|
|
'LAST_MEMBER',
|
|
'Das ist der letzte Nutzer dieses CCs. Statt ihn auszutragen, den CC deaktivieren.',
|
|
);
|
|
}
|
|
|
|
await prisma.$transaction(async (tx) => {
|
|
await tx.characterMember.update({
|
|
where: { id: mitglied.id },
|
|
data: { leftAt: new Date(), note: note ?? mitglied.note },
|
|
});
|
|
|
|
await recordAudit(tx, ctx, {
|
|
action: 'character.member_removed',
|
|
targetType: 'character',
|
|
targetId: characterId,
|
|
details: { playerId, name: mitglied.character.name, note: note ?? null },
|
|
});
|
|
});
|
|
|
|
return { memberCount: verbleibend - 1 };
|
|
}
|
|
|
|
/** Alle, die einen CC nutzen oder genutzt haben. */
|
|
export async function listMembers(characterId: number) {
|
|
return prisma.characterMember.findMany({
|
|
where: { characterId },
|
|
orderBy: [{ leftAt: 'asc' }, { joinedAt: 'asc' }],
|
|
include: { player: { select: { id: true, displayName: true, steamId: true } } },
|
|
});
|
|
}
|