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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 } } }, }); }