import { randomUUID } from "node:crypto"; import { CharacterStatus, CreditKind, CreditSource, ForumClassification, InactiveReason, type Character, type Prisma, } 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 ); } /** * Die Slot-Regel. * * Ein CC-Paket ist ein Platz für einen gleichzeitig aktiven CC. Wer zwei * Pakete hat, führt zwei CCs gleichzeitig - egal, wie viele Redesigns * dazwischen liegen. Ein Redesign **tauscht** einen CC aus, es schafft keinen * zusätzlichen Platz; fünf Millionen Redesigns auf ein Paket bleiben ein CC. * * Geprüft wird nicht der Kontostand, sondern die Deckung: **jeder aktive CC * braucht eine eigene Buchung im CC-Topf.** Das ist der Unterschied zwischen * einem bewusst überzogenen Konto - eine Buchung ist da, das Guthaben fehlt * noch, und das Minus steht sichtbar auf der Spielerseite - und einem CC, der * einfach zusätzlich aktiv wird, ohne dass irgendwo ein Platz dafür gebucht * wurde. Das Erste ist gewollt, das Zweite wäre ein Platz aus dem Nichts. * * Geprüft wird an einer Stelle, statt an jedem Weg einzeln: erschaffen, * redesignen, reaktivieren, zurückholen, einem Gruppen-CC beitreten. */ async function assertSlotsReichen( tx: Prisma.TransactionClient, playerId: number, was: string, ): Promise { const [eintraege, belegt] = await Promise.all([ tx.creditLedger.findMany({ where: { playerId, kind: CreditKind.CC }, select: { amount: true, source: true, reversalOf: { select: { source: true } }, }, }), tx.characterMember.count({ where: { playerId, leftAt: null, character: { status: CharacterStatus.ACTIVE }, }, }), ]); // Verbräuche zählen, die nicht storniert wurden. Ein Storno trägt die Art // der Zeile, die es zurücknimmt - nur so lässt sich ein zurückgenommener // Platz von einem zurückgenommenen Paket unterscheiden. let gebuchtePlaetze = 0; for (const eintrag of eintraege) { const zweck = eintrag.source === CreditSource.REVERSAL ? eintrag.reversalOf?.source : eintrag.source; if (zweck === CreditSource.CONSUME) gebuchtePlaetze -= eintrag.amount; } if (belegt > gebuchtePlaetze) { throw conflict( "NO_FREE_SLOT", `${was} ist nicht möglich: Der Platz dieses CCs ist inzwischen von einem anderen belegt. ` + "Ein CC-Paket ist ein Platz für einen gleichzeitig aktiven CC, und ein Redesign tauscht " + "einen CC aus, statt einen Platz hinzuzufügen. Entweder den anderen CC deaktivieren - " + "oder ein CC-Guthaben einsetzen, das einen eigenen Platz bucht.", ); } } 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 lineageId = randomUUID(); 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, createdById: ctx.actorId, // Der Ersteller ist zugleich das erste Mitglied. Die Zugehörigkeit // läuft durchgehend über diese Tabelle, auch bei Einzel-CCs - und // daran hängt seine Liste: hier beginnt eine neue. members: { create: [ { playerId, lineageId, chainPosition: 0, 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 assertSlotsReichen(tx, playerId, "Ein neuer CC"); 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: JoinKind; 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"); } // Der Regelfall ist ein Redesign-Guthaben. Wer ausnahmsweise sein // CC-Guthaben einsetzt, wandelt es um - siehe bucheVerbrauch(). if (!JOIN_KINDS.includes(input.useKind)) { throw badRequest("INVALID_KIND", "Ungültige Guthaben-Art für ein Redesign"); } 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, lineageId: true, chainPosition: true, }, }); const created = await tx.character.create({ data: { playerId: old.playerId, name: input.name, loreText: input.loreText ?? null, status: CharacterStatus.ACTIVE, lineageId: old.lineageId, // Zehnerschritte, damit ein später gefundenes Zwischenglied noch // dazwischen passt, ohne die ganze Kette anzufassen. chainPosition: old.chainPosition + 10, predecessorId: old.id, createdById: ctx.actorId, // Für jeden Nutzer rückt der neue CC in seiner eigenen Liste über // den alten - auch bei einem Gruppen-CC, der bei dem einen der // gekaufte Platz ist und beim anderen ein Redesign. members: { create: bisherige.map((mitglied) => ({ playerId: mitglied.playerId, paidWith: mitglied.paidWith, lineageId: mitglied.lineageId, chainPosition: mitglied.chainPosition + 10, predecessorId: old.id, createdById: ctx.actorId, })), }, }, }); await bucheVerbrauch(tx, { playerId: old.playerId, characterId: created.id, kind: input.useKind, redesign: true, note: input.note ?? `Redesign: ${old.name} -> ${created.name}`, actorId: ctx.actorId, }); // Bei einem Gruppen-CC zählt der Eintrag für jeden Nutzer einzeln: in // seiner Liste steht ab jetzt ein Glied mehr, und das kostet ihn ein // Redesign wie jedes andere. for (const mitglied of bisherige) { if (mitglied.playerId === old.playerId) continue; await bucheVerbrauch(tx, { playerId: mitglied.playerId, characterId: created.id, kind: CreditKind.REDESIGN_PAID, redesign: true, note: `Redesign des Gruppen-CCs: ${old.name} -> ${created.name}`, actorId: ctx.actorId, }); } await assertSlotsReichen(tx, old.playerId, "Dieses Redesign"); 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 assertSlotsReichen(tx, updated.playerId, "Die Rückkehr dieses CCs"); 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 bucheVerbrauch(tx, { playerId: updated.playerId, characterId: updated.id, kind: useKind, redesign: null, note: note ?? `Reaktivierung: ${updated.name}`, actorId: ctx.actorId, }); await assertSlotsReichen(tx, updated.playerId, "Die Rückkehr dieses CCs"); 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) }; } /** * Ändert die Angaben eines CCs: Name, Beschreibung und den Antrag, aus dem * er hervorging. * * Der Antrag wird über seine Thread-Nummer oder die volle Adresse angegeben; * ein leeres Feld löst die bestehende Verknüpfung. Kennt der Abgleich den * Thread noch nicht, wird er vorgemerkt - sonst stünde er beim nächsten Lauf * als offener Antrag da, obwohl er längst eingetragen ist. */ export async function updateCharacter( characterId: number, input: { name?: string; loreText?: string | null; threadRef?: string | null }, ctx: ActionContext, ): Promise { const vorher = await loadCharacter(characterId); const updated = await prisma.$transaction(async (tx) => { const geaendert = 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: geaendert.name, vorher: vorher.name }, }); return geaendert; }); if (input.threadRef !== undefined) { await setCharacterThread(characterId, input.threadRef, ctx); } return updated; } /** * Hängt einen Forums-Antrag an einen CC oder löst ihn wieder. * * Angenommen wird die Thread-Nummer, der Pfad oder die volle Adresse - beim * Eintippen von Hand landet mal das eine, mal das andere im Feld. */ export async function setCharacterThread( characterId: number, ref: string | null, ctx: ActionContext, ): Promise { const character = await loadCharacter(characterId); const { parseThreadId } = await import("../lib/forum.js"); const { linkThreadToCharacter } = await import("./forum.js"); const bisher = await prisma.forumThread.findFirst({ where: { linkedCharacterId: characterId }, select: { threadId: true }, }); const eingabe = ref?.trim() ?? ""; const threadId = eingabe.length === 0 ? null : parseThreadId(eingabe); if (eingabe.length > 0 && threadId === null) { throw badRequest( "INVALID_THREAD", "Das ist keine Thread-Nummer und keine Forums-Adresse", ); } if (bisher?.threadId === threadId) return; // Die alte Verknüpfung fällt weg. Der Thread bleibt als entschieden // vermerkt: er wurde ja bearbeitet, nur hängt er jetzt an keinem CC mehr. if (bisher) { await prisma.forumThread.update({ where: { threadId: bisher.threadId }, data: { linkedCharacterId: null }, }); } if (threadId === null) { await recordAudit(prisma, ctx, { action: "character.thread_unlinked", targetType: "character", targetId: characterId, details: { name: character.name, threadId: bisher?.threadId ?? null }, }); return; } const vorhanden = await prisma.forumThread.findUnique({ where: { threadId }, }); if (vorhanden) { await prisma.forumThread.update({ where: { threadId }, data: { linkedCharacterId: characterId, needsClassification: false, classifiedById: ctx.actorId, classifiedAt: vorhanden.classifiedAt ?? new Date(), playerId: vorhanden.playerId ?? character.playerId, }, }); await recordAudit(prisma, ctx, { action: "character.thread_linked", targetType: "character", targetId: characterId, details: { name: character.name, threadId }, }); return; } // Unbekannter Thread: über denselben Weg vormerken wie bei einem von Hand // eingetragenen Antrag, damit er nicht als offen wieder auftaucht. await linkThreadToCharacter( threadId, characterId, ForumClassification.NEW_CC, ctx, ); } /** * 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. * * Mit dabei ist **jede** Mitgliedschaft, auch eine beendete. Wer aus einem * Gruppen-CC herausredesignt hat oder ausgetreten ist, war trotzdem darin - * und das gehört zu seiner Geschichte. Ob daraus ein Anspruch folgt, ist eine * andere Frage und steht am Grund der Mitgliedschaft; hier geht es darum, was * überhaupt zu sehen ist. */ export async function listCharacters(playerId: number) { const ccs = await prisma.character.findMany({ where: { members: { some: { playerId } } }, orderBy: [ { lineageId: "asc" }, { chainPosition: "asc" }, { createdAt: "asc" }, ], 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" }, }, links: { select: { id: true, label: true, url: true }, orderBy: [{ createdAt: "asc" }, { id: "asc" }], }, members: { orderBy: [{ leftAt: "asc" }, { joinedAt: "asc" }], include: { player: { select: { id: true, displayName: true } }, successor: { select: { id: true, name: true } }, }, }, }, }); // Womit dieser Spieler den jeweiligen Eintrag bezahlt hat. In der Kette // steht das am Übergang: der Pfeil zum nächsten Glied trägt die Art, mit // der dieses Glied bezahlt wurde. const buchungen = await prisma.creditLedger.findMany({ where: { characterId: { in: ccs.map((c) => c.id) }, playerId, source: CreditSource.CONSUME, reversedBy: null, }, select: { characterId: true, kind: true }, orderBy: { id: "desc" }, }); // Alle offenen, nicht nur die jüngste: an einem Eintrag kann mehr als eine // hängen - eine Rückkehr etwa kostet zusätzlich. Wer nur die letzte zeigt, // versteckt die andere. const artNach = new Map(); for (const buchung of buchungen) { if (buchung.characterId === null) continue; const bisher = artNach.get(buchung.characterId) ?? []; bisher.push(buchung.kind); artNach.set(buchung.characterId, bisher); } // Die Liste gehört dem Spieler: Ein Gruppen-CC steht bei jedem Nutzer an // dessen eigener Stelle. Gezeigt wird deshalb seine Sicht, nicht die des // Erstellers. return ccs .map((cc) => { const eigene = cc.members.find((m) => m.playerId === playerId); return { ...cc, lineageId: eigene?.lineageId ?? cc.lineageId, chainPosition: eigene?.chainPosition ?? cc.chainPosition, predecessorId: eigene?.predecessorId ?? null, predecessor: eigene?.predecessorId === cc.predecessorId ? cc.predecessor : null, fundedWith: artNach.get(cc.id) ?? [], }; }) .sort( (a, b) => a.lineageId.localeCompare(b.lineageId) || a.chainPosition - b.chainPosition || a.createdAt.getTime() - b.createdAt.getTime(), ); } /** * 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 bucheVerbrauch(tx, { playerId: updated.playerId, characterId: updated.id, kind: input.useKind, redesign: null, note: `Rückkehr nach Tod: ${input.reason}`, actorId: ctx.actorId, }); } await assertSlotsReichen(tx, updated.playerId, "Die Rückkehr dieses CCs"); 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) }; } /** * Stellt um, woraus ein Eintrag bezahlt wurde. * * Zu entscheiden bleibt eines: ob ein Redesign aus dem bezahlten Topf kommt * oder aus Kulanz. Ob ein Eintrag überhaupt ein Redesign ist, steht nicht * zur Wahl - das entscheidet seine Stelle in der Liste. * * Geändert wird wie jede Korrektur im Kontobuch: die alte Zeile wird * gegengebucht, die neue kommt dazu. So bleibt sichtbar, dass hier etwas * geändert wurde, und wann. */ export async function changeFunding( characterId: number, useKind: JoinKind, ctx: ActionContext, forPlayerId?: number, ): Promise { const character = await loadCharacter(characterId); const spieler = forPlayerId ?? character.playerId; if (!JOIN_KINDS.includes(useKind)) { throw badRequest("INVALID_KIND", "Ungültige Guthaben-Art"); } // Die Buchung, mit der dieser Spieler seinen Platz bezahlt hat. Bei einem // Gruppen-CC hat jeder Nutzer seine eigene; stornierte zählen nicht mehr. const buchung = await prisma.creditLedger.findFirst({ where: { characterId, playerId: spieler, source: CreditSource.CONSUME, reversedBy: null, }, orderBy: { id: "desc" }, }); if (!buchung) { throw conflict( "NO_BOOKING", "Für diesen Spieler ist an diesem CC nichts gebucht - hier gibt es nichts umzustellen. " + "Sein Platz hängt weiter unten in der Liste.", ); } const redesign = await istRedesignPosition(prisma, characterId, spieler); if (!redesign && useKind !== CreditKind.CC) { throw conflict( "CHAIN_START", "Dieser Eintrag steht am Anfang einer Liste und bucht einen eigenen Platz. " + "Erst über einem anderen Eintrag wird daraus ein Redesign.", ); } if (redesign && useKind === CreditKind.CC) { throw conflict( "CONVERT_FIRST", "Ein Redesign wird nicht aus dem CC-Topf bezahlt. Wandle das CC-Guthaben oben in ein " + "Redesign um, dann steht es hier zur Verfügung.", ); } if (buchung.kind === useKind) { throw conflict("SAME_KIND", "Dieser CC ist bereits so gebucht"); } const grund = useKind === CreditKind.REDESIGN_KULANZ ? "Umgestellt auf Kulanz" : "Umgestellt auf bezahltes Redesign"; await prisma.$transaction(async (tx) => { await storniere(tx, buchung, `${grund}: ${character.name}`, ctx.actorId); await tx.creditLedger.create({ data: { playerId: spieler, kind: useKind, amount: buchung.amount, source: CreditSource.CONSUME, groupId: randomUUID(), characterId, note: buchung.note ?? `Redesign: ${character.name}`, createdById: ctx.actorId, // Bezahlt wurde damals, nicht heute. occurredAt: buchung.occurredAt, }, }); await recordAudit(tx, ctx, { action: "character.funding_changed", targetType: "character", targetId: characterId, details: { name: character.name, playerId: spieler, from: buchung.kind, to: useKind, }, }); }); const balances = await getBalances(spieler); return { character, balances, overdrawn: isOverdrawn(balances) }; } // --- Ketten --- /** * Steht dieser Eintrag für diesen Spieler über einem Vorgänger? * * Für den Besitzer steht das am CC selbst, für ein Mitglied an seiner * Mitgliedschaft: wer einem Gruppen-CC beitritt, gibt dafür einen eigenen CC * auf, und welcher das war, steht dort. Daran hängt alles Weitere - am * Anfang einer Kette wird ein Platz gekauft, darüber wird redesignt. */ async function istRedesignPosition( db: Prisma.TransactionClient | typeof prisma, characterId: number, playerId: number, ): Promise { const character = await db.character.findUnique({ where: { id: characterId }, select: { playerId: true, predecessorId: true }, }); if (!character) return false; if (character.playerId === playerId) return character.predecessorId !== null; const mitglied = await db.characterMember.findUnique({ where: { characterId_playerId: { characterId, playerId } }, select: { predecessorId: true }, }); return (mitglied?.predecessorId ?? null) !== null; } /** * Bucht, was ein Eintrag kostet. * * Am Anfang einer Liste steht ein gekaufter Platz, alles darüber ein * Redesign. Die Stelle entscheidet, nicht die Wahl - sonst ginge die * Rechnung "eine Liste, ein CC-Paket" nicht mehr auf. Wer sein CC-Guthaben * für ein Redesign einsetzen will, wandelt es vorher um; danach ist es ein * Redesign wie jedes andere. */ async function bucheVerbrauch( tx: Prisma.TransactionClient, params: { playerId: number; characterId: number; kind: JoinKind; /** * Steht der Eintrag über einem Vorgänger? Dann ist es ein Redesign. * * `null` bei einer Buchung, die neben einer bestehenden steht - eine * Rückkehr etwa kostet zusätzlich, und was sie kostet, entscheidet der * Fall, nicht die Stelle in der Liste. */ redesign: boolean | null; note: string; actorId: number | null; occurredAt?: Date | undefined; }, ): Promise { const ausCcTopf = params.kind === CreditKind.CC; if (params.redesign === true && ausCcTopf) { throw conflict( "CONVERT_FIRST", "Ein Redesign wird nicht aus dem CC-Topf bezahlt. Wandle das CC-Guthaben oben in ein " + "Redesign um, dann steht es hier zur Verfügung.", ); } if (params.redesign === false && !ausCcTopf) { throw conflict( "CHAIN_START", "Dieser Eintrag steht am Anfang einer Liste und bucht einen eigenen Platz. Dafür wird ein " + "CC-Guthaben gebraucht, kein Redesign.", ); } await tx.creditLedger.create({ data: { playerId: params.playerId, kind: params.kind, amount: -1, source: CreditSource.CONSUME, groupId: randomUUID(), characterId: params.characterId, note: params.note, createdById: params.actorId, ...(params.occurredAt ? { occurredAt: params.occurredAt } : {}), }, }); } /** Nimmt eine Buchung zurück: die Gegenbuchung trägt Art und Gruppe der alten. */ async function storniere( tx: Prisma.TransactionClient, buchung: { id: number; playerId: number; kind: CreditKind; amount: number; groupId: string; characterId: number | null; occurredAt: Date; }, note: string, actorId: number | null, ): Promise { await tx.creditLedger.create({ data: { playerId: buchung.playerId, kind: buchung.kind, amount: -buchung.amount, source: CreditSource.REVERSAL, groupId: buchung.groupId, characterId: buchung.characterId, reversalOfId: buchung.id, note, createdById: actorId, occurredAt: buchung.occurredAt, }, }); } /** * Bucht ein Kettenglied auf die Art um, die seine Stelle in der Kette * verlangt. * * Die Regel steckt in der Kette selbst: **Das unterste Glied ist der CC, der * gekauft wurde - alles darüber sind seine Redesigns.** Wandert ein Eintrag, * wandert damit auch seine Bedeutung: Ein eigenständiger CC, der mitten in * eine fremde Kette geschoben wird, ist von da an ein Redesign; wer * umgekehrt an den Anfang rückt oder allein steht, belegt einen eigenen * Platz und kostet ein CC-Paket. * * Umgebucht wird wie jede Korrektur im Kontobuch: die alte Zeile wird * gegengebucht, die neue kommt dazu. Wo gar keine Buchung steht, bleibt es * dabei - wer ohne Buchung nachgetragen hat, wollte das so. * * Zwischen bezahltem Redesign und Kulanz wird nicht gewechselt: das ist eine * Entscheidung, keine Folge der Reihenfolge. Sie steht unter "Bezahlart". */ async function buchungFolgtPosition( tx: Prisma.TransactionClient, glied: { id: number; name: string; createdAt?: Date }, /** * Wessen Buchung angefasst wird: der Spieler, dessen Liste geordnet wird - * nicht der Ersteller des einzelnen Gliedes. Bei einem Gruppen-CC hat * jeder Nutzer seine eigene Buchung, und nur die geht ihn etwas an. */ kettenBesitzer: number, sollCC: boolean, actorId: number | null, ): Promise { const offene = await tx.creditLedger.findMany({ where: { characterId: glied.id, playerId: kettenBesitzer, source: CreditSource.CONSUME, reversedBy: null, }, orderBy: { id: "desc" }, }); const offen = offene[0]; // Jedes Glied einer Liste kostet etwas: das unterste ein CC-Paket, jedes // weitere ein Redesign. Fehlt die Buchung ganz, wird sie hier nachgetragen // - sonst ginge die Rechnung "eine Liste, ein Paket" nicht auf. if (!offen) { await tx.creditLedger.create({ data: { playerId: kettenBesitzer, kind: sollCC ? CreditKind.CC : CreditKind.REDESIGN_PAID, amount: -1, source: CreditSource.CONSUME, groupId: randomUUID(), characterId: glied.id, note: sollCC ? `Anfang einer Liste: ${glied.name}` : `Redesign in einer Liste: ${glied.name}`, createdById: actorId, ...(glied.createdAt ? { occurredAt: glied.createdAt } : {}), }, }); return true; } // Steht schon etwas da, das zur Stelle passt, bleibt alles, wie es ist. // An einem Eintrag kann mehr als eine Buchung hängen - eine Rückkehr // kostet zusätzlich, und das soll sie auch. if (offene.some((eintrag) => (eintrag.kind === CreditKind.CC) === sollCC)) return false; // Ein Eintrag, der in eine Kette rückt, braucht kein eigenes Paket mehr: // das Guthaben kommt zurück, dafür geht ein Redesign weg. Umgekehrt // genauso. const grund = sollCC ? "Steht jetzt am Anfang der Kette: aus einem CC-Paket bezahlt" : "Steht jetzt in einer Kette: als Redesign gebucht"; await storniere(tx, offen, `${grund} - Storno: ${glied.name}`, actorId); await tx.creditLedger.create({ data: { playerId: offen.playerId, kind: sollCC ? CreditKind.CC : CreditKind.REDESIGN_PAID, amount: offen.amount, source: CreditSource.CONSUME, groupId: randomUUID(), characterId: glied.id, note: offen.note ?? glied.name, createdById: actorId, // Bezahlt wurde damals, nicht beim Umsortieren. occurredAt: offen.occurredAt, }, }); return true; } /** * Setzt eine Liste neu auf, nach der Reihenfolge der Positionen. * * Eine Liste gehört einem Spieler: sie beginnt mit dem CC, für den er ein * Paket eingesetzt hat, und darüber stehen dessen Redesigns. Ein Gruppen-CC * steht in der Liste jedes Nutzers an dessen eigener Stelle - für den einen * kann er der gekaufte Platz sein, für den anderen ein Redesign. * * Die Liste steht doppelt in den Daten: als Position und als Verweis auf den * Vorgänger. Die Position lässt sich verschieben, ohne alles anzufassen; der * Vorgänger-Verweis ist das, woran der Rest der Anwendung hängt. Nach jedem * Umsortieren wird er deshalb aus den Positionen neu abgeleitet - dann können * beide nicht auseinanderlaufen. */ async function verketteNeu( tx: Prisma.TransactionClient, lineageId: string, playerId: number, actorId: number | null, ): Promise { const glieder = await tx.characterMember.findMany({ where: { lineageId, playerId }, orderBy: [{ chainPosition: "asc" }, { joinedAt: "asc" }, { id: "asc" }], select: { id: true, characterId: true, chainPosition: true, predecessorId: true, leftAt: true, inactiveReason: true, character: { select: { id: true, name: true, playerId: true, status: true, inactiveReason: true, createdAt: true, }, }, }, }); const inDieserLinie = new Set(glieder.map((g) => g.characterId)); let umgebucht = 0; let vorheriges: number | null = null; for (const [index, glied] of glieder.entries()) { const letztes = index === glieder.length - 1; const erstes = index === 0; const cc = glied.character; const daten: Prisma.CharacterMemberUpdateInput = {}; if (erstes) { // Am Anfang wird nur eine Verkettung gelöst, die innerhalb dieser // Liste ins Leere zeigt. Eine Herkunft von aussen bleibt: sie ist das // Einzige, was noch zeigt, woher die Liste kommt. if ( glied.predecessorId !== null && inDieserLinie.has(glied.predecessorId) ) { daten.predecessor = { disconnect: true }; } } else if (glied.predecessorId !== vorheriges) { daten.predecessor = { connect: { id: vorheriges as number } }; } // Wer nach dem Ordnen keinen Vorgänger hat, steht am Anfang einer Liste // und hat dafür ein Paket eingesetzt; wer einen hat, ist dessen // Redesign. Ob der Vorgänger in derselben Liste geführt wird, spielt // keine Rolle - die Kette ist die Kette. const vorgaengerDanach = erstes ? inDieserLinie.has(glied.predecessorId ?? -1) ? null : glied.predecessorId : vorheriges; if (await buchungFolgtPosition(tx, cc, playerId, vorgaengerDanach === null, actorId)) { umgebucht += 1; } // Was einen Nachfolger hat, wurde durch ihn ersetzt - anders ist eine // Liste nicht zu lesen. Beim eigenen CC heisst das: er ist redesignt. // Bei einem Gruppen-CC eines anderen endet nur die eigene Nutzung; die // Gruppe läuft unberührt weiter. const eigener = cc.playerId === playerId; if (eigener) { const ccDaten: Prisma.CharacterUpdateInput = {}; if (!letztes && cc.inactiveReason !== InactiveReason.REWORKED) { ccDaten.status = CharacterStatus.INACTIVE; ccDaten.inactiveReason = InactiveReason.REWORKED; ccDaten.inactiveNote = null; } // Umgekehrt: Wer ans Ende rückt, ist von niemandem mehr ersetzt // worden. Die Markierung wäre dann schlicht falsch - sie stammt von // einem Nachfolger, der inzwischen woanders steht. if (letztes && cc.inactiveReason === InactiveReason.REWORKED) { ccDaten.status = CharacterStatus.ACTIVE; ccDaten.inactiveReason = null; ccDaten.inactiveNote = null; } // Die Liste des Besitzers steht auch am CC: daran hängt, was von // aussen auf die Kette zeigt. ccDaten.lineageId = lineageId; ccDaten.chainPosition = glied.chainPosition; ccDaten.predecessor = vorgaengerDanach === null ? { disconnect: true } : { connect: { id: vorgaengerDanach } }; await tx.character.update({ where: { id: cc.id }, data: ccDaten }); } else { if (!letztes && glied.leftAt === null) { daten.leftAt = new Date(); daten.inactiveReason = InactiveReason.REWORKED; daten.inactiveNote = null; } if (letztes && glied.inactiveReason === InactiveReason.REWORKED) { daten.leftAt = null; daten.inactiveReason = null; daten.inactiveNote = null; } } if (Object.keys(daten).length > 0) { await tx.characterMember.update({ where: { id: glied.id }, data: daten }); } vorheriges = glied.characterId; } return umgebucht; } /** * Gleicht die Listen eines Spielers durch. * * Massgeblich ist die Reihenfolge: Das unterste Glied einer Liste ist der * gekaufte Platz, alles darüber sind Redesigns. Daraus ergeben sich die * Vorgänger-Verweise, die Buchungen und was als ersetzt gilt - dieselbe * Rechnung wie beim Verschieben, nur über alle Listen auf einmal. */ async function gleicheAn( tx: Prisma.TransactionClient, playerId: number, actorId: number | null, ): Promise<{ geprueft: number; umgebucht: number }> { const listen = await tx.characterMember.findMany({ where: { playerId }, select: { lineageId: true }, distinct: ['lineageId'], }); let umgebucht = 0; for (const liste of listen) { umgebucht += await verketteNeu(tx, liste.lineageId, playerId, actorId); } return { geprueft: listen.length, umgebucht }; } /** * Rechnet alle Buchungen eines Spielers anhand der Ketten nach. * * Die Regel ist schlicht: **Jede Kette beginnt mit einem gekauften CC, alles * Weitere darin sind Redesigns.** Bei drei Ketten mit zusammen sechzehn * Einträgen sind das drei CC-Pakete und dreizehn Redesigns. * * Gebraucht wird das, wenn ein Bestand aus einer Zeit stammt, in der eine * Buchung noch blieb, wo sie beim Anlegen entstand - oder wenn beim * Nacherfassen viel umsortiert wurde. Angefasst wird nur, was nicht passt, * und umgebucht wird wie sonst auch: über eine Gegenbuchung. Zwischen * bezahltem Redesign und Kulanz wird nichts gewechselt. */ export async function rebuildFunding( playerId: number, ctx: ActionContext, ): Promise<{ geprueft: number; umgebucht: number; balances: Balances }> { let ergebnis = { geprueft: 0, umgebucht: 0 }; await prisma.$transaction(async (tx) => { ergebnis = await gleicheAn(tx, playerId, ctx.actorId); if (ergebnis.umgebucht > 0) { await recordAudit(tx, ctx, { action: "character.funding_rebuilt", targetType: "player", targetId: playerId, details: { geprueft: ergebnis.geprueft, umgebucht: ergebnis.umgebucht }, }); } }); return { ...ergebnis, balances: await getBalances(playerId) }; } /** * Gleicht die Buchungen aller Spieler an die Ketten an. * * Läuft beim Start mit: Buchungen aus der Zeit vor dieser Regel oder aus * einer Ausnahme, die es nicht mehr gibt, werden einmalig geradegezogen. Was * bereits passt, bleibt unangetastet - deshalb hinterlässt der Lauf im * Kontobuch nichts, sobald alles stimmt. */ export async function richteBuchungenAus( actorId: number | null = null, ): Promise { const spieler = await prisma.player.findMany({ select: { id: true } }); let umgebucht = 0; for (const { id } of spieler) { await prisma.$transaction(async (tx) => { const ergebnis = await gleicheAn(tx, id, actorId); umgebucht += ergebnis.umgebucht; }); } return umgebucht; } /** * Begleicht ein Minus im Redesign-Topf mit vorhandener Kulanz. * * Kommt vor, wenn nachträglich klar wird, dass ein Teil der Redesigns * Kulanz war: Dann stehen dort Guthaben herum, während der bezahlte Topf im * Minus hängt. Statt einzeln Übergang für Übergang umzustellen, wird hier so * viel umgebucht, wie beide Seiten hergeben. * * Welche Übergänge es trifft, spielt inhaltlich keine Rolle - am Ende zählt, * wie viel woraus bezahlt wurde. Genommen werden die ältesten zuerst, damit * das Ergebnis wiederholbar ist und nicht vom Zufall abhängt. */ export async function settleWithKulanz( playerId: number, ctx: ActionContext, ): Promise<{ umgebucht: number; balances: Balances }> { const stand = await getBalances(playerId); const schulden = Math.max(0, -stand.REDESIGN_PAID); const kulanz = Math.max(0, stand.REDESIGN_KULANZ); const moeglich = Math.min(schulden, kulanz); if (moeglich === 0) { throw conflict( "NOTHING_TO_SETTLE", schulden === 0 ? "Im bezahlten Redesign-Topf steht kein Minus - hier gibt es nichts auszugleichen" : "Es ist keine Kulanz übrig, mit der sich das Minus ausgleichen liesse", ); } // Die ältesten bezahlten Redesigns zuerst: wer nachträglich Kulanz // zugestanden bekommt, bekommt sie für das, was am längsten zurückliegt. const kandidaten = await prisma.creditLedger.findMany({ where: { playerId, kind: CreditKind.REDESIGN_PAID, amount: { lt: 0 }, source: CreditSource.CONSUME, reversedBy: null, }, orderBy: [{ occurredAt: "asc" }, { id: "asc" }], take: moeglich, include: { character: { select: { id: true, name: true } } }, }); if (kandidaten.length === 0) { throw conflict( "NOTHING_TO_SETTLE", "Es gibt keine bezahlten Redesigns, die sich auf Kulanz umstellen liessen", ); } await prisma.$transaction(async (tx) => { for (const buchung of kandidaten) { await tx.creditLedger.create({ data: { playerId, kind: buchung.kind, amount: -buchung.amount, source: CreditSource.REVERSAL, groupId: buchung.groupId, characterId: buchung.characterId, reversalOfId: buchung.id, note: `Auf Kulanz umgestellt - Storno: ${buchung.character?.name ?? ""}`.trim(), createdById: ctx.actorId, occurredAt: buchung.occurredAt, }, }); await tx.creditLedger.create({ data: { playerId, kind: CreditKind.REDESIGN_KULANZ, amount: buchung.amount, source: CreditSource.CONSUME, groupId: randomUUID(), characterId: buchung.characterId, note: buchung.note ?? buchung.character?.name ?? null, createdById: ctx.actorId, occurredAt: buchung.occurredAt, }, }); } await recordAudit(tx, ctx, { action: "credit.settled_with_kulanz", targetType: "player", targetId: playerId, details: { umgebucht: kandidaten.length }, }); }); return { umgebucht: kandidaten.length, balances: await getBalances(playerId), }; } export async function moveInChain( characterId: number, input: { lineageId: string; afterId?: number | null; /// Wessen Liste hier geordnet wird. Bei einem Gruppen-CC ist das nicht /// zwingend der Ersteller. forPlayerId?: number | undefined; }, ctx: ActionContext, ): Promise<{ lineageId: string }> { const character = await loadCharacter(characterId); const spieler = input.forPlayerId ?? character.playerId; /** * Geordnet wird die eigene Liste. * * Nicht der Besitz entscheidet, sondern die Nutzung: Ein Gruppen-CC gehört * dem, der ihn angelegt hat, steht aber in der Liste jedes Nutzers an * dessen eigener Stelle. Wer ihn nutzt, ordnet damit seine eigene * Geschichte - und die eines anderen rührt er nicht an. */ const mitglied = await prisma.characterMember.findUnique({ where: { characterId_playerId: { characterId, playerId: spieler } }, select: { id: true, lineageId: true }, }); if (!mitglied) { throw conflict( "FOREIGN_CHAIN", "Dieser CC gehört zu einer anderen Geschichte - einordnen kann ihn nur, wer ihn nutzt", ); } const zielGlieder = await prisma.characterMember.findMany({ where: { lineageId: input.lineageId, playerId: spieler }, orderBy: [{ chainPosition: "asc" }, { id: "asc" }], select: { id: true, characterId: true, chainPosition: true }, }); if (zielGlieder.length === 0) { const fremde = await prisma.characterMember.count({ where: { lineageId: input.lineageId, playerId: { not: spieler } }, }); if (fremde > 0) { throw conflict( "FOREIGN_CHAIN", "Diese Liste gehört zu einer anderen Geschichte - verschieben kann nur, wer an beidem beteiligt war", ); } } const vorgaenger = input.afterId === undefined || input.afterId === null ? null : zielGlieder.find((g) => g.characterId === input.afterId); if (input.afterId != null && !vorgaenger) { throw badRequest( "NOT_IN_CHAIN", "Der genannte Vorgänger liegt nicht in dieser Kette", ); } if (input.afterId === characterId) { throw badRequest( "SELF_REFERENCE", "Ein CC kann nicht hinter sich selbst stehen", ); } const alteLinie = mitglied.lineageId; await prisma.$transaction(async (tx) => { // Erst Platz schaffen: alles hinter der Einfügestelle rückt auf. const ab = vorgaenger ? vorgaenger.chainPosition + 1 : 0; await tx.characterMember.updateMany({ where: { lineageId: input.lineageId, playerId: spieler, chainPosition: { gte: ab }, id: { not: mitglied.id }, }, data: { chainPosition: { increment: 10 } }, }); await tx.characterMember.update({ where: { id: mitglied.id }, data: { lineageId: input.lineageId, chainPosition: ab }, }); await verketteNeu(tx, input.lineageId, spieler, ctx.actorId); if (alteLinie !== input.lineageId) await verketteNeu(tx, alteLinie, spieler, ctx.actorId); // Das Neuordnen kann einen CC ans Ende rücken und damit wieder aktiv // machen. Ein Platz entsteht dadurch nicht: er muss gedeckt sein wie // jeder andere. await assertSlotsReichen(tx, spieler, "Dieses Verschieben"); await recordAudit(tx, ctx, { action: "character.moved_in_chain", targetType: "character", targetId: characterId, details: { name: character.name, forPlayerId: spieler, fromLineage: alteLinie, toLineage: input.lineageId, afterId: input.afterId ?? null, }, }); }); return { lineageId: input.lineageId }; } /** * Löst einen CC aus einer Liste und stellt ihn allein - für den Fall, dass * sich herausstellt, dass er gar nicht dorthin gehörte. */ export async function detachFromChain( characterId: number, ctx: ActionContext, forPlayerId?: number, ): Promise<{ lineageId: string }> { const character = await loadCharacter(characterId); const spieler = forPlayerId ?? character.playerId; const neueLinie = randomUUID(); const mitglied = await prisma.characterMember.findUnique({ where: { characterId_playerId: { characterId, playerId: spieler } }, select: { id: true, lineageId: true }, }); if (!mitglied) { throw conflict( "FOREIGN_CHAIN", "Dieser CC gehört zu einer anderen Geschichte - herauslösen kann ihn nur, wer ihn nutzt", ); } await prisma.$transaction(async (tx) => { // Allein in einer neuen Liste ist er zugleich Anfang und Ende. Eine // Redesign-Markierung stammte von einem Nachfolger, der jetzt woanders // steht - sie wäre nur noch falsch. await tx.characterMember.update({ where: { id: mitglied.id }, data: { lineageId: neueLinie, chainPosition: 0, predecessorId: null }, }); // Beide Seiten: die verlassene Liste rückt zusammen, und der // Herausgelöste steht allein - also am Anfang seiner eigenen. await verketteNeu(tx, mitglied.lineageId, spieler, ctx.actorId); await verketteNeu(tx, neueLinie, spieler, ctx.actorId); await assertSlotsReichen(tx, spieler, "Dieses Herauslösen"); await recordAudit(tx, ctx, { action: "character.detached", targetType: "character", targetId: characterId, details: { name: character.name, forPlayerId: spieler, fromLineage: mitglied.lineageId, }, }); }); return { lineageId: neueLinie }; } // --- Verweise an einem CC --- /// Nur diese Schemata. Ein "javascript:"-Link in einer Oberfläche, die /// mehrere Leute bedienen, wäre ein offenes Scheunentor. const ERLAUBTE_SCHEMATA = ["http:", "https:"]; function pruefeUrl(roh: string): string { const wert = roh.trim(); let parsed: URL; try { parsed = new URL(wert); } catch { throw badRequest( "INVALID_URL", "Das ist keine gültige Adresse - bitte mit http:// oder https://", ); } if (!ERLAUBTE_SCHEMATA.includes(parsed.protocol)) { throw badRequest( "INVALID_URL", "Erlaubt sind nur Adressen mit http:// oder https://", ); } return parsed.toString(); } /** * Hängt einen Verweis an einen CC: das Steam-Addon des Erstellers, ein * Modell, eine Absprache. Der Name wird in der Liste zum Link, damit dort * steht, worum es geht, statt einer nackten Adresse. */ export async function addLink( characterId: number, input: { label: string; url: string }, ctx: ActionContext, ) { const character = await loadCharacter(characterId); const label = input.label.trim(); if (!label) throw badRequest("NO_LABEL", "Für den Verweis wird ein Name gebraucht"); const created = await prisma.characterLink.create({ data: { characterId, label, url: pruefeUrl(input.url), createdById: ctx.actorId, }, }); await recordAudit(prisma, ctx, { action: "character.link_added", targetType: "character", targetId: characterId, details: { name: character.name, label, url: created.url }, }); return created; } export async function updateLink( linkId: number, input: { label?: string | undefined; url?: string | undefined }, ctx: ActionContext, ) { const vorhanden = await prisma.characterLink.findUnique({ where: { id: linkId }, }); if (!vorhanden) throw notFound("Verweis"); const label = input.label?.trim(); if (input.label !== undefined && !label) { throw badRequest("NO_LABEL", "Für den Verweis wird ein Name gebraucht"); } const updated = await prisma.characterLink.update({ where: { id: linkId }, data: { ...(label ? { label } : {}), ...(input.url !== undefined ? { url: pruefeUrl(input.url) } : {}), }, }); await recordAudit(prisma, ctx, { action: "character.link_updated", targetType: "character", targetId: vorhanden.characterId, details: { label: updated.label, url: updated.url }, }); return updated; } export async function removeLink( linkId: number, ctx: ActionContext, ): Promise { const vorhanden = await prisma.characterLink.findUnique({ where: { id: linkId }, }); if (!vorhanden) throw notFound("Verweis"); await prisma.characterLink.delete({ where: { id: linkId } }); await recordAudit(prisma, ctx, { action: "character.link_removed", targetType: "character", targetId: vorhanden.characterId, details: { label: vorhanden.label, url: vorhanden.url }, }); } // --- 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 | undefined; predecessorId?: number | undefined }, playerId: number, characterId: number, /// Wer weiter unten in derselben Kette schon dabei war, setzt dort fort - /// sein Vorgänger steht in seiner Liste, nicht in der Angabe. schonInDerKette = false, ): Promise { if ( input.useKind === undefined || !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) { if (schonInDerKette) return null; 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; } /** * Ob der Spieler in einem früheren Glied derselben Kette geführt wird. * * Für die Nacherfassung entscheidend: Wer bei einem CC dabei war, der später * redesignt wurde, ist beim Nachfolger dabei, ohne ein zweites Mal zu zahlen. * Sein Platz wurde einmal gebucht und ist derselbe geblieben. */ async function warSchonInDerKette( character: Character, playerId: number, ): Promise { return (await fruehereNutzung(character, playerId)) !== null; } /** * Der jüngste Eintrag, den der Spieler in der Kette dieses CCs schon nutzt. * * Daran hängt zweierlei: dass sein Platz längst bezahlt ist, und wo der CC * in seiner eigenen Liste steht - nämlich darüber. */ async function fruehereNutzung( character: Character, playerId: number, ): Promise<{ lineageId: string; chainPosition: number } | null> { return prisma.characterMember.findFirst({ where: { playerId, character: { lineageId: character.lineageId, chainPosition: { lt: character.chainPosition }, }, }, orderBy: { character: { chainPosition: "desc" } }, select: { lineageId: true, chainPosition: true }, }); } /** * Wo ein CC in der Liste eines Spielers landet. * * Wer einen eigenen CC dafür aufgibt, reiht sich darüber ein. Wer schon * weiter unten in derselben Kette steckte, setzt dort fort. Der Ersteller * bleibt, wo sein CC steht. Alle anderen beginnen eine eigene Liste - ein * Platz, der für sich steht. */ async function platzInSeinerListe( character: Character, playerId: number, vorgaengerId: number | null, ): Promise<{ lineageId: string; chainPosition: number }> { if (vorgaengerId !== null) { const vorher = await prisma.characterMember.findUnique({ where: { characterId_playerId: { characterId: vorgaengerId, playerId } }, select: { lineageId: true, chainPosition: true }, }); if (vorher) { return { lineageId: vorher.lineageId, chainPosition: vorher.chainPosition + 10, }; } } if (character.playerId === playerId) { return { lineageId: character.lineageId, chainPosition: character.chainPosition, }; } const frueher = await fruehereNutzung(character, playerId); if (frueher) { return { lineageId: frueher.lineageId, chainPosition: frueher.chainPosition + 10, }; } return { lineageId: randomUUID(), chainPosition: 0 }; } /** * 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: { /// Fehlt bei einer reinen Nacherfassung an einem inaktiven CC: dort wird /// eingetragen, was war, und die Buchung steht längst im Konto. useKind?: JoinKind | undefined; predecessorId?: number | undefined; note?: string | undefined; /// Zeitraum, wenn nachgetragen wird statt jetzt beizutreten. joinedAt?: Date | undefined; leftAt?: Date | undefined; }, ctx: ActionContext, ): Promise { const character = await loadCharacter(characterId); const historisch = character.status !== CharacterStatus.ACTIVE; // Wer weiter unten in derselben Kette schon dabei war, setzt hier nur // fort: sein Platz wurde damals bezahlt, und ein Redesign tauscht einen // Platz aus, statt einen zweiten zu verlangen. Genau das tut ein Redesign // im laufenden Betrieb auch - es nimmt die Gruppe mit. const schonInDerKette = await warSchonInDerKette(character, playerId); // Ebenso der Besitzer an seinem eigenen CC: Seine Buchung hängt daran, // seit er ihn angelegt hat. Wer sich dort wieder einträgt - etwa weil er // sich versehentlich selbst ausgetragen hat -, zahlt nicht ein zweites // Mal für denselben Platz. const istBesitzer = character.playerId === playerId; // Sonst gilt: an einem laufenden CC kostet ein Platz etwas. An einem // inaktiven wird nachgetragen, was einmal war - dort ist die Angabe // freigestellt, weil die Buchung längst im Konto stehen kann. if ( !historisch && !schonInDerKette && !istBesitzer && input.useKind === undefined ) { throw badRequest( "NEED_KIND", "Ein Platz an einem laufenden CC kostet ein Guthaben - bitte angeben, welches", ); } if (input.useKind !== undefined && !JOIN_KINDS.includes(input.useKind)) { throw badRequest( "INVALID_KIND", "Ungültige Guthaben-Art für einen Beitritt", ); } if (input.leftAt && input.joinedAt && input.leftAt < input.joinedAt) { throw badRequest("BAD_RANGE", "Das Ende liegt vor dem Anfang"); } 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, schonInDerKette, ); const platz = await platzInSeinerListe( character, playerId, vorgaenger?.id ?? null, ); 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: input.leftAt ?? null, joinedAt: input.joinedAt ?? new Date(), paidWith: input.useKind ?? null, predecessorId: vorgaenger?.id ?? null, lineageId: platz.lineageId, chainPosition: platz.chainPosition, note: input.note ?? null, createdById: ctx.actorId, }, }); } else { await tx.characterMember.create({ data: { characterId, playerId, paidWith: input.useKind ?? null, predecessorId: vorgaenger?.id ?? null, lineageId: platz.lineageId, chainPosition: platz.chainPosition, note: input.note ?? null, createdById: ctx.actorId, ...(input.joinedAt ? { joinedAt: input.joinedAt } : {}), ...(input.leftAt ? { leftAt: input.leftAt } : {}), }, }); } // Wer einen eigenen CC aufgibt oder weiter unten in derselben Kette // schon dabei war, steht hier über einem Vorgänger. Ohne Angabe ergibt // sich die Art aus dieser Stelle - gebucht wird in jedem Fall, denn // jedes Glied einer Liste kostet etwas. const redesignPosition = vorgaenger !== null || schonInDerKette; // Wer hier schon bezahlt hat, zahlt nicht noch einmal - etwa der // Besitzer, der sich versehentlich ausgetragen hatte. const schonBezahlt = await tx.creditLedger.findFirst({ where: { characterId, playerId, source: CreditSource.CONSUME, reversedBy: null, }, select: { id: true }, }); if (schonBezahlt === null) { await bucheVerbrauch(tx, { playerId, characterId, kind: input.useKind ?? (redesignPosition ? CreditKind.REDESIGN_PAID : CreditKind.CC), redesign: redesignPosition, note: input.note ?? (vorgaenger ? `Beitritt zu "${character.name}" anstelle von "${vorgaenger.name}"` : `Beitritt zu "${character.name}"`), actorId: ctx.actorId, occurredAt: input.joinedAt, }); } await assertSlotsReichen(tx, playerId, "Der Beitritt"); await recordAudit(tx, ctx, { action: "character.member_added", targetType: "character", targetId: characterId, details: { playerId, name: character.name, useKind: input.useKind ?? null, nachgetragen: historisch, 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 }; } /** * Redesignt den Anteil eines Spielers an einem Gruppen-CC. * * Er steigt damit aus und bekommt einen eigenen CC an dessen Stelle; die * Gruppe läuft unberührt weiter. Für ihn ist es ein Redesign wie jedes * andere: ein Platz wird getauscht, keiner kommt hinzu. * * Ist er der letzte Nutzer, gibt es keine Gruppe mehr, die weiterlaufen * könnte - dann ist es das gewöhnliche Redesign des ganzen CCs, und der läuft * über redesignCharacter(). Ein Gruppen-Redesign, bei dem alle mitwandern, * ebenfalls: das ist die Sache des ganzen CCs, nicht die eines Mitglieds. */ export async function redesignMembership( characterId: number, playerId: number, input: { name: string; loreText?: string | undefined; useKind: JoinKind; note?: string | undefined; }, ctx: ActionContext, ): Promise { const gruppe = await loadCharacter(characterId); if (gruppe.status !== CharacterStatus.ACTIVE) { throw conflict("NOT_ACTIVE", "Nur ein aktiver CC kann redesignt werden"); } if (!JOIN_KINDS.includes(input.useKind)) { throw badRequest("INVALID_KIND", "Ungültige Guthaben-Art für ein Redesign"); } const name = input.name.trim(); if (!name) throw badRequest("NO_NAME", "Für den neuen CC wird ein Name gebraucht"); const mitglied = await prisma.characterMember.findUnique({ where: { characterId_playerId: { characterId, playerId } }, }); 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", "Dieser Spieler ist der letzte Nutzer. Dann ist es ein Redesign des ganzen CCs, kein Austritt.", ); } const neuerCC = await prisma.$transaction(async (tx) => { // In seiner Liste tritt der neue CC an die Stelle des Gruppen-CCs; für // die übrigen Nutzer ändert sich nichts, der Gruppen-CC lebt weiter. const erstellt = await tx.character.create({ data: { playerId, name, loreText: input.loreText ?? null, status: CharacterStatus.ACTIVE, lineageId: mitglied.lineageId, chainPosition: mitglied.chainPosition + 10, predecessorId: gruppe.id, createdById: ctx.actorId, members: { create: [ { playerId, paidWith: input.useKind, lineageId: mitglied.lineageId, chainPosition: mitglied.chainPosition + 10, predecessorId: gruppe.id, createdById: ctx.actorId, }, ], }, }, }); // Kein Grund für eine Rückkehr: sein Platz wurde getauscht, nicht // entzogen. Wohin er gegangen ist, steht im Nachfolger - und genau daran // hängt auch, dass der Gruppen-CC nicht länger als seiner gilt. await tx.characterMember.update({ where: { id: mitglied.id }, data: { leftAt: new Date(), successorId: erstellt.id, note: input.note ?? `Anteil redesignt zu "${erstellt.name}"`, }, }); await bucheVerbrauch(tx, { playerId, characterId: erstellt.id, kind: input.useKind, redesign: true, note: input.note ?? `Redesign aus "${gruppe.name}" heraus: ${erstellt.name}`, actorId: ctx.actorId, }); await assertSlotsReichen(tx, playerId, "Dieses Redesign"); await recordAudit(tx, ctx, { action: "character.membership_redesigned", targetType: "character", targetId: erstellt.id, details: { playerId, from: gruppe.name, fromId: gruppe.id, to: erstellt.name, useKind: input.useKind, remaining: verbleibend - 1, }, }); return erstellt; }); const balances = await getBalances(playerId); return { character: neuerCC, balances, overdrawn: isOverdrawn(balances), leftGroup: true, }; } /** * Setzt eine einzelne Mitgliedschaft inaktiv - der CC selbst bleibt, wie er * ist. Bei einem Gruppen-CC kann genau ein Spieler wegfallen, ohne dass die * anderen etwas davon merken: einer stirbt im Rollenspiel, einer wird * geputzt, die übrigen spielen weiter. * * Für die Rückkehr gelten dieselben Regeln wie bei einem eigenen CC - gratis, * bezahlt oder nur durch einen Administrator. */ export async function deactivateMember( characterId: number, playerId: number, reason: InactiveReason, ctx: ActionContext, note?: string, ): Promise<{ memberCount: number }> { if (reason === InactiveReason.REWORKED) { throw badRequest( "USE_REDESIGN", "Ein Rework entsteht über das Redesign des Anteils, 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 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. Dann ist der ganze CC betroffen, nicht nur eine Mitgliedschaft.", ); } await prisma.$transaction(async (tx) => { await tx.characterMember.update({ where: { id: mitglied.id }, data: { leftAt: new Date(), inactiveReason: reason, inactiveNote: note?.trim() || null, }, }); await recordAudit(tx, ctx, { action: "character.member_deactivated", targetType: "character", targetId: characterId, details: { playerId, name: mitglied.character.name, reason, note: note ?? null, }, }); }); return { memberCount: verbleibend - 1 }; } /** * Holt eine einzelne Mitgliedschaft zurück. Die Regeln sind dieselben wie bei * einem eigenen CC: was unverändert weiterbesteht, kommt gratis zurück; was * ersetzt oder entzogen wurde, kostet; und über einen im Rollenspiel * Gestorbenen entscheidet ein Administrator. */ export async function reactivateMember( characterId: number, playerId: number, input: { useKind?: ReviveKind | undefined; reason?: string | undefined }, ctx: ActionContext, ): Promise<{ memberCount: number }> { const gruppe = await loadCharacter(characterId); if (gruppe.status !== CharacterStatus.ACTIVE) { throw conflict( "NOT_ACTIVE", "Dieser CC ist selbst inaktiv - erst ihn zurückholen", ); } const mitglied = await prisma.characterMember.findUnique({ where: { characterId_playerId: { characterId, playerId } }, }); if (!mitglied) throw notFound("Mitgliedschaft"); if (mitglied.leftAt === null) { throw conflict("ALREADY_MEMBER", "Dieser Spieler nutzt den CC bereits"); } const grund = mitglied.inactiveReason; // Dass hierfür ein Administrator nötig ist, sichert die Route ab - so wie // bei einem im Rollenspiel gestorbenen CC. Hier steht nur, was der Vorgang // selbst verlangt. if (grund === InactiveReason.DEAD && !input.reason?.trim()) { throw badRequest( "NEED_REASON", "Für die Rückkehr eines Gestorbenen wird eine Begründung gebraucht", ); } // Ein Austritt ohne Grund war freiwillig: der Platz ist weg und muss neu // bezahlt werden. Dafür gibt es den gewöhnlichen Beitritt. if (grund === null) { throw conflict( "WAS_VOLUNTARY", "Dieser Spieler ist ausgetreten, nicht ausgefallen. Eine Rückkehr ist ein neuer Beitritt und kostet ein Guthaben.", ); } const kostenpflichtig = grund === InactiveReason.REWORKED || grund === InactiveReason.NO_FREE_CLAIM; if (kostenpflichtig && input.useKind === undefined) { throw badRequest( "NEED_KIND", "Diese Rückkehr kostet ein Guthaben - bitte angeben, welches", ); } if (input.useKind !== undefined && !REVIVE_KINDS.includes(input.useKind)) { throw badRequest("INVALID_KIND", "Ungültige Guthaben-Art"); } await prisma.$transaction(async (tx) => { await tx.characterMember.update({ where: { id: mitglied.id }, data: { leftAt: null, inactiveReason: null, inactiveNote: null }, }); if (input.useKind !== undefined) { await bucheVerbrauch(tx, { playerId, characterId, kind: input.useKind, redesign: null, note: input.reason ?? `Rückkehr zu "${gruppe.name}"`, actorId: ctx.actorId, }); } await assertSlotsReichen(tx, playerId, "Die Rückkehr"); await recordAudit(tx, ctx, { action: "character.member_reactivated", targetType: "character", targetId: characterId, details: { playerId, name: gruppe.name, previousReason: grund, useKind: input.useKind ?? null, reason: input.reason ?? null, }, }); }); const memberCount = await prisma.characterMember.count({ where: { characterId, leftAt: null }, }); return { memberCount }; } /** * Trägt einen Spieler ab einem Kettenglied in die gesamte weitere Kette ein. * * Für die Nacherfassung: Stellt sich heraus, dass jemand schon beim dritten * von sechs Gliedern dabei war, wäre es Unfug, ihn viermal einzeln * einzutragen. Gebucht wird genau einmal - beim Einstieg -, denn ein * Redesign tauscht seinen Platz aus, statt einen weiteren zu verlangen. * * Wer schon irgendwo in der Kette steht, wird dort nicht angerührt. */ export async function addMemberToChain( characterId: number, playerId: number, input: { useKind?: JoinKind | undefined; predecessorId?: number | undefined; note?: string | undefined; joinedAt?: Date | undefined; }, ctx: ActionContext, ): Promise<{ added: number; memberCount: number }> { const einstieg = await loadCharacter(characterId); const kette = await prisma.character.findMany({ where: { lineageId: einstieg.lineageId, chainPosition: { gte: einstieg.chainPosition }, }, orderBy: [{ chainPosition: "asc" }, { id: "asc" }], select: { id: true, name: true }, }); const vorhandene = new Set( ( await prisma.characterMember.findMany({ where: { playerId, characterId: { in: kette.map((c) => c.id) } }, select: { characterId: true }, }) ).map((m) => m.characterId), ); let added = 0; for (const [index, glied] of kette.entries()) { if (vorhandene.has(glied.id)) continue; // Jedes Glied zählt für ihn wie ein eigener CC: der Einstieg kostet, // was er gewählt hat, jedes weitere Glied ein Redesign. await addMember( glied.id, playerId, { ...(index === 0 ? { ...(input.useKind !== undefined ? { useKind: input.useKind } : {}), } : { useKind: CreditKind.REDESIGN_PAID }), ...(index === 0 && input.predecessorId !== undefined ? { predecessorId: input.predecessorId } : {}), ...(input.note !== undefined ? { note: input.note } : {}), ...(input.joinedAt !== undefined ? { joinedAt: input.joinedAt } : {}), }, ctx, ); added += 1; } const letztes = kette[kette.length - 1]; const memberCount = letztes ? await prisma.characterMember.count({ where: { characterId: letztes.id, leftAt: null }, }) : 0; return { added, 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 } }, successor: { select: { id: true, name: true } }, predecessor: { select: { id: true, name: true } }, }, }); }