From 367f1aace4080e7f61a246878b10264ce673c456 Mon Sep 17 00:00:00 2001 From: Harlekin Date: Tue, 22 Sep 2026 13:01:52 +0200 Subject: [PATCH] Jedes Glied einer Liste kostet, auch in einem Gruppen-CC Bisher zahlte nur, wer neu einstieg: wer in einer Kette mitgezogen wurde oder einen Gruppen-CC mitbenutzte, stand darin ohne eigene Buchung. Damit ging die Rechnung nicht auf - verbrauchte Redesigns sind alle Glieder aller Listen minus die Anzahl der Listen, und das gilt fuer jeden Nutzer einzeln. Jetzt bucht jedes Glied: das unterste einen Platz, jedes weitere ein Redesign. Wo eine Buchung fehlt, traegt der Abgleich sie nach, und das Redesign eines Gruppen-CCs kostet jeden Nutzer sein eigenes. Am Pfeil stehen alle offenen Buchungen eines Eintrags, nicht nur die juengste - sonst bleibt eine zweite unsichtbar. --- server/src/services/characters.ts | 855 ++++++++++++++++++++---------- server/test/ketten.test.ts | 31 +- server/test/nacherfassung.test.ts | 14 +- web/src/components/ChainView.tsx | 15 +- web/src/lib/types.ts | 3 +- 5 files changed, 613 insertions(+), 305 deletions(-) diff --git a/server/src/services/characters.ts b/server/src/services/characters.ts index 3555df0..24bda27 100644 --- a/server/src/services/characters.ts +++ b/server/src/services/characters.ts @@ -1,4 +1,4 @@ -import { randomUUID } from 'node:crypto'; +import { randomUUID } from "node:crypto"; import { CharacterStatus, CreditKind, @@ -7,11 +7,11 @@ import { 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'; +} 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; @@ -22,13 +22,22 @@ export interface CharacterResult { } /// Diese Gründe lassen den CC unverändert bestehen - er kommt gratis zurück. -const FREE_REASONS = [InactiveReason.PLAYER_INACTIVITY, InactiveReason.OTHER] as const; +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; +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; +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; @@ -36,7 +45,11 @@ 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; + return ( + balances.CC < 0 || + balances.REDESIGN_PAID < 0 || + balances.REDESIGN_KULANZ < 0 + ); } /** @@ -65,10 +78,18 @@ async function assertSlotsReichen( const [eintraege, belegt] = await Promise.all([ tx.creditLedger.findMany({ where: { playerId, kind: CreditKind.CC }, - select: { amount: true, source: true, reversalOf: { select: { source: true } } }, + select: { + amount: true, + source: true, + reversalOf: { select: { source: true } }, + }, }), tx.characterMember.count({ - where: { playerId, leftAt: null, character: { status: CharacterStatus.ACTIVE } }, + where: { + playerId, + leftAt: null, + character: { status: CharacterStatus.ACTIVE }, + }, }), ]); @@ -78,35 +99,44 @@ async function assertSlotsReichen( let gebuchtePlaetze = 0; for (const eintrag of eintraege) { const zweck = - eintrag.source === CreditSource.REVERSAL ? eintrag.reversalOf?.source : eintrag.source; + eintrag.source === CreditSource.REVERSAL + ? eintrag.reversalOf?.source + : eintrag.source; if (zweck === CreditSource.CONSUME) gebuchtePlaetze -= eintrag.amount; } if (belegt > gebuchtePlaetze) { throw conflict( - 'NO_FREE_SLOT', + "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.', + "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'); + 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 }, + 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 player = await prisma.player.findUnique({ + where: { id: playerId }, + select: { id: true }, + }); + if (!player) throw notFound("Spieler"); const lineageId = randomUUID(); @@ -123,7 +153,9 @@ export async function createCharacter( // 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 }], + create: [ + { playerId, lineageId, chainPosition: 0, createdById: ctx.actorId }, + ], }, }, }); @@ -141,11 +173,11 @@ export async function createCharacter( }, }); - await assertSlotsReichen(tx, playerId, 'Ein neuer CC'); + await assertSlotsReichen(tx, playerId, "Ein neuer CC"); await recordAudit(tx, ctx, { - action: 'character.create', - targetType: 'character', + action: "character.create", + targetType: "character", targetId: created.id, details: { playerId, name: created.name }, }); @@ -175,25 +207,33 @@ export async function redesignCharacter( const old = await loadCharacter(characterId); if (old.status !== CharacterStatus.ACTIVE) { - throw conflict('NOT_ACTIVE', 'Nur ein aktiver CC kann redesignt werden'); + 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'); + 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 }, + 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 }, + select: { + playerId: true, + paidWith: true, + lineageId: true, + chainPosition: true, + }, }); const created = await tx.character.create({ @@ -233,11 +273,26 @@ export async function redesignCharacter( actorId: ctx.actorId, }); - await assertSlotsReichen(tx, old.playerId, 'Dieses Redesign'); + // 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', + action: "character.redesign", + targetType: "character", targetId: created.id, details: { playerId: old.playerId, @@ -269,16 +324,19 @@ export async function deactivateCharacter( ): Promise { const existing = await loadCharacter(characterId); if (existing.status === CharacterStatus.INACTIVE) { - throw conflict('ALREADY_INACTIVE', 'Dieser CC ist bereits inaktiv'); + 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', + "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'); + throw badRequest( + "NEED_NOTE", + "Für einen Tod im Rollenspiel wird eine Begründung gebraucht", + ); } const character = await prisma.$transaction(async (tx) => { @@ -292,8 +350,8 @@ export async function deactivateCharacter( }); await recordAudit(tx, ctx, { - action: 'character.deactivate', - targetType: 'character', + action: "character.deactivate", + targetType: "character", targetId: characterId, details: { reason, note: note ?? null, name: updated.name }, }); @@ -318,32 +376,40 @@ export async function reactivateFree( const existing = await loadCharacter(characterId); if (existing.status === CharacterStatus.ACTIVE) { - throw conflict('ALREADY_ACTIVE', 'Dieser CC ist bereits aktiv'); + 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.', + "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])) { + 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.', + "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 }, + data: { + status: CharacterStatus.ACTIVE, + inactiveReason: null, + inactiveNote: null, + }, }); - await assertSlotsReichen(tx, updated.playerId, 'Die Rückkehr dieses CCs'); + await assertSlotsReichen(tx, updated.playerId, "Die Rückkehr dieses CCs"); await recordAudit(tx, ctx, { - action: 'character.reactivate_free', - targetType: 'character', + action: "character.reactivate_free", + targetType: "character", targetId: characterId, details: { playerId: updated.playerId, name: updated.name }, }); @@ -369,28 +435,39 @@ export async function reactivatePaid( const existing = await loadCharacter(characterId); if (existing.status === CharacterStatus.ACTIVE) { - throw conflict('ALREADY_ACTIVE', 'Dieser CC ist bereits aktiv'); + 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.', + "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])) { + 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.', + "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'); + 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 }, + data: { + status: CharacterStatus.ACTIVE, + inactiveReason: null, + inactiveNote: null, + }, }); await bucheVerbrauch(tx, { @@ -402,11 +479,11 @@ export async function reactivatePaid( actorId: ctx.actorId, }); - await assertSlotsReichen(tx, updated.playerId, 'Die Rückkehr dieses CCs'); + await assertSlotsReichen(tx, updated.playerId, "Die Rückkehr dieses CCs"); await recordAudit(tx, ctx, { - action: 'character.reactivate_paid', - targetType: 'character', + action: "character.reactivate_paid", + targetType: "character", targetId: characterId, details: { playerId: updated.playerId, @@ -449,8 +526,8 @@ export async function updateCharacter( }); await recordAudit(tx, ctx, { - action: 'character.update', - targetType: 'character', + action: "character.update", + targetType: "character", targetId: characterId, details: { name: geaendert.name, vorher: vorher.name }, }); @@ -477,21 +554,21 @@ export async function setCharacterThread( ctx: ActionContext, ): Promise { const character = await loadCharacter(characterId); - const { parseThreadId } = await import('../lib/forum.js'); - const { linkThreadToCharacter } = await import('./forum.js'); + 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 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', + "INVALID_THREAD", + "Das ist keine Thread-Nummer und keine Forums-Adresse", ); } if (bisher?.threadId === threadId) return; @@ -507,15 +584,17 @@ export async function setCharacterThread( if (threadId === null) { await recordAudit(prisma, ctx, { - action: 'character.thread_unlinked', - targetType: 'character', + 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 } }); + const vorhanden = await prisma.forumThread.findUnique({ + where: { threadId }, + }); if (vorhanden) { await prisma.forumThread.update({ where: { threadId }, @@ -529,8 +608,8 @@ export async function setCharacterThread( }); await recordAudit(prisma, ctx, { - action: 'character.thread_linked', - targetType: 'character', + action: "character.thread_linked", + targetType: "character", targetId: characterId, details: { name: character.name, threadId }, }); @@ -539,7 +618,12 @@ export async function setCharacterThread( // 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); + await linkThreadToCharacter( + threadId, + characterId, + ForumClassification.NEW_CC, + ctx, + ); } /** @@ -556,12 +640,12 @@ export async function revertCharacter( where: { id: characterId }, include: { successors: { select: { id: true } }, predecessor: true }, }); - if (!existing) throw notFound('CC'); + 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.', + "HAS_SUCCESSORS", + "Dieser CC hat Nachfolger in der Redesign-Kette. Bitte zuerst den jüngsten CC zurücknehmen.", ); } @@ -596,29 +680,37 @@ export async function revertCharacter( if (restorePredecessor && predecessor) { await tx.character.update({ where: { id: predecessor.id }, - data: { status: CharacterStatus.ACTIVE, inactiveReason: null, inactiveNote: null }, + data: { + status: CharacterStatus.ACTIVE, + inactiveReason: null, + inactiveNote: null, + }, }); } await tx.character.delete({ where: { id: characterId } }); await recordAudit(tx, ctx, { - action: 'character.revert', - targetType: 'character', + 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, + restoredPredecessorId: restorePredecessor + ? (predecessor?.id ?? null) + : null, }, }); }); return { balances: await getBalances(existing.playerId), - restoredPredecessorId: restorePredecessor ? predecessor?.id ?? null : null, + restoredPredecessorId: restorePredecessor + ? (predecessor?.id ?? null) + : null, }; } @@ -635,21 +727,25 @@ export async function revertCharacter( export async function listCharacters(playerId: number) { const ccs = await prisma.character.findMany({ where: { members: { some: { playerId } } }, - orderBy: [{ lineageId: 'asc' }, { chainPosition: 'asc' }, { createdAt: 'asc' }], + 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' }, + orderBy: { threadId: "desc" }, }, links: { select: { id: true, label: true, url: true }, - orderBy: [{ createdAt: 'asc' }, { id: 'asc' }], + orderBy: [{ createdAt: "asc" }, { id: "asc" }], }, members: { - orderBy: [{ leftAt: 'asc' }, { joinedAt: 'asc' }], + orderBy: [{ leftAt: "asc" }, { joinedAt: "asc" }], include: { player: { select: { id: true, displayName: true } }, successor: { select: { id: true, name: true } }, @@ -669,14 +765,18 @@ export async function listCharacters(playerId: number) { reversedBy: null, }, select: { characterId: true, kind: true }, - orderBy: { id: 'desc' }, + orderBy: { id: "desc" }, }); - const artNach = new Map(); + // 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 && !artNach.has(buchung.characterId)) { - artNach.set(buchung.characterId, buchung.kind); - } + 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 @@ -690,8 +790,9 @@ export async function listCharacters(playerId: number) { 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) ?? null, + predecessor: + eigene?.predecessorId === cc.predecessorId ? cc.predecessor : null, + fundedWith: artNach.get(cc.id) ?? [], }; }) .sort( @@ -702,7 +803,6 @@ export async function listCharacters(playerId: number) { ); } - /** * Bringt einen im Rollenspiel gestorbenen CC zurück. Anders als die übrigen * Wege ist das eine Ausnahmeentscheidung: sie bleibt Administratoren @@ -719,25 +819,32 @@ export async function reviveDeadCharacter( const existing = await loadCharacter(characterId); if (existing.status === CharacterStatus.ACTIVE) { - throw conflict('ALREADY_ACTIVE', 'Dieser CC ist bereits aktiv'); + 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', + "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'); + 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'); + 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 }, + data: { + status: CharacterStatus.ACTIVE, + inactiveReason: null, + inactiveNote: null, + }, }); if (input.useKind !== undefined) { @@ -751,11 +858,11 @@ export async function reviveDeadCharacter( }); } - await assertSlotsReichen(tx, updated.playerId, 'Die Rückkehr dieses CCs'); + await assertSlotsReichen(tx, updated.playerId, "Die Rückkehr dieses CCs"); await recordAudit(tx, ctx, { - action: 'character.revive_dead', - targetType: 'character', + action: "character.revive_dead", + targetType: "character", targetId: characterId, details: { playerId: updated.playerId, @@ -773,7 +880,6 @@ export async function reviveDeadCharacter( return { character, balances, overdrawn: isOverdrawn(balances) }; } - /** * Stellt um, woraus ein Eintrag bezahlt wurde. * @@ -795,7 +901,7 @@ export async function changeFunding( const spieler = forPlayerId ?? character.playerId; if (!JOIN_KINDS.includes(useKind)) { - throw badRequest('INVALID_KIND', 'Ungültige Guthaben-Art'); + throw badRequest("INVALID_KIND", "Ungültige Guthaben-Art"); } // Die Buchung, mit der dieser Spieler seinen Platz bezahlt hat. Bei einem @@ -807,39 +913,39 @@ export async function changeFunding( source: CreditSource.CONSUME, reversedBy: null, }, - orderBy: { id: 'desc' }, + 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.', + "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.', + "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.', + "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'); + throw conflict("SAME_KIND", "Dieser CC ist bereits so gebucht"); } const grund = useKind === CreditKind.REDESIGN_KULANZ - ? 'Umgestellt auf Kulanz' - : 'Umgestellt auf bezahltes Redesign'; + ? "Umgestellt auf Kulanz" + : "Umgestellt auf bezahltes Redesign"; await prisma.$transaction(async (tx) => { await storniere(tx, buchung, `${grund}: ${character.name}`, ctx.actorId); @@ -860,10 +966,15 @@ export async function changeFunding( }); await recordAudit(tx, ctx, { - action: 'character.funding_changed', - targetType: 'character', + action: "character.funding_changed", + targetType: "character", targetId: characterId, - details: { name: character.name, playerId: spieler, from: buchung.kind, to: useKind }, + details: { + name: character.name, + playerId: spieler, + from: buchung.kind, + to: useKind, + }, }); }); @@ -932,16 +1043,16 @@ async function bucheVerbrauch( 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.', + "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.', + "CHAIN_START", + "Dieser Eintrag steht am Anfang einer Liste und bucht einen eigenen Platz. Dafür wird ein " + + "CC-Guthaben gebraucht, kein Redesign.", ); } @@ -1011,13 +1122,11 @@ async function storniere( */ async function buchungFolgtPosition( tx: Prisma.TransactionClient, - glied: { id: number; name: string }, + glied: { id: number; name: string; createdAt?: Date }, /** - * Wessen Buchung angefasst wird: der Spieler, dem die Kette gehört - nicht - * der Ersteller des einzelnen Gliedes. Bei einem fremden Gruppen-CC in - * seiner Kette ist das seine Beitrittsbuchung, und nur die geht ihn etwas - * an. Die des Erstellers bleibt unberührt; für den ist es sein eigener - * Platz in seiner eigenen Kette. + * 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, @@ -1030,22 +1139,44 @@ async function buchungFolgtPosition( source: CreditSource.CONSUME, reversedBy: null, }, - orderBy: { id: 'desc' }, + orderBy: { id: "desc" }, }); const offen = offene[0]; - if (!offen) return false; + + // 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; + 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'; + ? "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); @@ -1086,10 +1217,10 @@ async function verketteNeu( lineageId: string, playerId: number, actorId: number | null, -): Promise { +): Promise { const glieder = await tx.characterMember.findMany({ where: { lineageId, playerId }, - orderBy: [{ chainPosition: 'asc' }, { joinedAt: 'asc' }, { id: 'asc' }], + orderBy: [{ chainPosition: "asc" }, { joinedAt: "asc" }, { id: "asc" }], select: { id: true, characterId: true, @@ -1098,13 +1229,21 @@ async function verketteNeu( leftAt: true, inactiveReason: true, character: { - select: { id: true, name: true, playerId: true, status: true, inactiveReason: true }, + 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; @@ -1116,7 +1255,10 @@ async function verketteNeu( // 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)) { + if ( + glied.predecessorId !== null && + inDieserLinie.has(glied.predecessorId) + ) { daten.predecessor = { disconnect: true }; } } else if (glied.predecessorId !== vorheriges) { @@ -1133,7 +1275,9 @@ async function verketteNeu( : glied.predecessorId : vorheriges; - await buchungFolgtPosition(tx, cc, playerId, vorgaengerDanach === null, actorId); + 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. @@ -1164,7 +1308,9 @@ async function verketteNeu( ccDaten.lineageId = lineageId; ccDaten.chainPosition = glied.chainPosition; ccDaten.predecessor = - vorgaengerDanach === null ? { disconnect: true } : { connect: { id: vorgaengerDanach } }; + vorgaengerDanach === null + ? { disconnect: true } + : { connect: { id: vorgaengerDanach } }; await tx.character.update({ where: { id: cc.id }, data: ccDaten }); } else { @@ -1185,42 +1331,35 @@ async function verketteNeu( } vorheriges = glied.characterId; } + + return umgebucht; } /** - * Gleicht die Buchungen eines Spielers an seine Listen an. + * Gleicht die Listen eines Spielers durch. * - * Kein Vorgänger heisst: hier fing eine Liste an, also ein gekaufter Platz. - * Alles darüber ist ein Redesign. Weil die Liste am Nutzer hängt, gilt das - * auch für einen Gruppen-CC, den zwei Spieler an ganz verschiedenen Stellen - * ihrer Geschichte führen. + * 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 mitgliedschaften = await tx.characterMember.findMany({ + const listen = await tx.characterMember.findMany({ where: { playerId }, - select: { - predecessorId: true, - character: { select: { id: true, name: true } }, - }, + select: { lineageId: true }, + distinct: ['lineageId'], }); let umgebucht = 0; - for (const eintrag of mitgliedschaften) { - const geaendert = await buchungFolgtPosition( - tx, - eintrag.character, - playerId, - eintrag.predecessorId === null, - actorId, - ); - if (geaendert) umgebucht += 1; + for (const liste of listen) { + umgebucht += await verketteNeu(tx, liste.lineageId, playerId, actorId); } - return { geprueft: mitgliedschaften.length, umgebucht }; + return { geprueft: listen.length, umgebucht }; } /** @@ -1247,8 +1386,8 @@ export async function rebuildFunding( if (ergebnis.umgebucht > 0) { await recordAudit(tx, ctx, { - action: 'character.funding_rebuilt', - targetType: 'player', + action: "character.funding_rebuilt", + targetType: "player", targetId: playerId, details: { geprueft: ergebnis.geprueft, umgebucht: ergebnis.umgebucht }, }); @@ -1266,7 +1405,9 @@ export async function rebuildFunding( * bereits passt, bleibt unangetastet - deshalb hinterlässt der Lauf im * Kontobuch nichts, sobald alles stimmt. */ -export async function richteBuchungenAus(actorId: number | null = null): Promise { +export async function richteBuchungenAus( + actorId: number | null = null, +): Promise { const spieler = await prisma.player.findMany({ select: { id: true } }); let umgebucht = 0; @@ -1304,10 +1445,10 @@ export async function settleWithKulanz( if (moeglich === 0) { throw conflict( - 'NOTHING_TO_SETTLE', + "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', + ? "Im bezahlten Redesign-Topf steht kein Minus - hier gibt es nichts auszugleichen" + : "Es ist keine Kulanz übrig, mit der sich das Minus ausgleichen liesse", ); } @@ -1321,15 +1462,15 @@ export async function settleWithKulanz( source: CreditSource.CONSUME, reversedBy: null, }, - orderBy: [{ occurredAt: 'asc' }, { id: 'asc' }], + 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', + "NOTHING_TO_SETTLE", + "Es gibt keine bezahlten Redesigns, die sich auf Kulanz umstellen liessen", ); } @@ -1344,7 +1485,7 @@ export async function settleWithKulanz( groupId: buchung.groupId, characterId: buchung.characterId, reversalOfId: buchung.id, - note: `Auf Kulanz umgestellt - Storno: ${buchung.character?.name ?? ''}`.trim(), + note: `Auf Kulanz umgestellt - Storno: ${buchung.character?.name ?? ""}`.trim(), createdById: ctx.actorId, occurredAt: buchung.occurredAt, }, @@ -1366,14 +1507,17 @@ export async function settleWithKulanz( } await recordAudit(tx, ctx, { - action: 'credit.settled_with_kulanz', - targetType: 'player', + action: "credit.settled_with_kulanz", + targetType: "player", targetId: playerId, details: { umgebucht: kandidaten.length }, }); }); - return { umgebucht: kandidaten.length, balances: await getBalances(playerId) }; + return { + umgebucht: kandidaten.length, + balances: await getBalances(playerId), + }; } export async function moveInChain( @@ -1404,14 +1548,14 @@ export async function moveInChain( }); if (!mitglied) { throw conflict( - 'FOREIGN_CHAIN', - 'Dieser CC gehört zu einer anderen Geschichte - einordnen kann ihn nur, wer ihn nutzt', + "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' }], + orderBy: [{ chainPosition: "asc" }, { id: "asc" }], select: { id: true, characterId: true, chainPosition: true }, }); @@ -1421,8 +1565,8 @@ export async function moveInChain( }); if (fremde > 0) { throw conflict( - 'FOREIGN_CHAIN', - 'Diese Liste gehört zu einer anderen Geschichte - verschieben kann nur, wer an beidem beteiligt war', + "FOREIGN_CHAIN", + "Diese Liste gehört zu einer anderen Geschichte - verschieben kann nur, wer an beidem beteiligt war", ); } } @@ -1432,10 +1576,16 @@ export async function moveInChain( ? 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'); + 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'); + throw badRequest( + "SELF_REFERENCE", + "Ein CC kann nicht hinter sich selbst stehen", + ); } const alteLinie = mitglied.lineageId; @@ -1459,16 +1609,17 @@ export async function moveInChain( }); await verketteNeu(tx, input.lineageId, spieler, ctx.actorId); - if (alteLinie !== input.lineageId) await verketteNeu(tx, alteLinie, 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 assertSlotsReichen(tx, spieler, "Dieses Verschieben"); await recordAudit(tx, ctx, { - action: 'character.moved_in_chain', - targetType: 'character', + action: "character.moved_in_chain", + targetType: "character", targetId: characterId, details: { name: character.name, @@ -1502,8 +1653,8 @@ export async function detachFromChain( }); if (!mitglied) { throw conflict( - 'FOREIGN_CHAIN', - 'Dieser CC gehört zu einer anderen Geschichte - herauslösen kann ihn nur, wer ihn nutzt', + "FOREIGN_CHAIN", + "Dieser CC gehört zu einer anderen Geschichte - herauslösen kann ihn nur, wer ihn nutzt", ); } @@ -1520,13 +1671,17 @@ export async function detachFromChain( // 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 assertSlotsReichen(tx, spieler, "Dieses Herauslösen"); await recordAudit(tx, ctx, { - action: 'character.detached', - targetType: 'character', + action: "character.detached", + targetType: "character", targetId: characterId, - details: { name: character.name, forPlayerId: spieler, fromLineage: mitglied.lineageId }, + details: { + name: character.name, + forPlayerId: spieler, + fromLineage: mitglied.lineageId, + }, }); }); @@ -1537,7 +1692,7 @@ export async function detachFromChain( /// Nur diese Schemata. Ein "javascript:"-Link in einer Oberfläche, die /// mehrere Leute bedienen, wäre ein offenes Scheunentor. -const ERLAUBTE_SCHEMATA = ['http:', 'https:']; +const ERLAUBTE_SCHEMATA = ["http:", "https:"]; function pruefeUrl(roh: string): string { const wert = roh.trim(); @@ -1545,10 +1700,16 @@ function pruefeUrl(roh: string): string { try { parsed = new URL(wert); } catch { - throw badRequest('INVALID_URL', 'Das ist keine gültige Adresse - bitte mit http:// oder https://'); + 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://'); + throw badRequest( + "INVALID_URL", + "Erlaubt sind nur Adressen mit http:// oder https://", + ); } return parsed.toString(); } @@ -1565,15 +1726,21 @@ export async function addLink( ) { const character = await loadCharacter(characterId); const label = input.label.trim(); - if (!label) throw badRequest('NO_LABEL', 'Für den Verweis wird ein Name gebraucht'); + 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 }, + data: { + characterId, + label, + url: pruefeUrl(input.url), + createdById: ctx.actorId, + }, }); await recordAudit(prisma, ctx, { - action: 'character.link_added', - targetType: 'character', + action: "character.link_added", + targetType: "character", targetId: characterId, details: { name: character.name, label, url: created.url }, }); @@ -1586,12 +1753,14 @@ export async function updateLink( input: { label?: string | undefined; url?: string | undefined }, ctx: ActionContext, ) { - const vorhanden = await prisma.characterLink.findUnique({ where: { id: linkId } }); - if (!vorhanden) throw notFound('Verweis'); + 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'); + throw badRequest("NO_LABEL", "Für den Verweis wird ein Name gebraucht"); } const updated = await prisma.characterLink.update({ @@ -1603,8 +1772,8 @@ export async function updateLink( }); await recordAudit(prisma, ctx, { - action: 'character.link_updated', - targetType: 'character', + action: "character.link_updated", + targetType: "character", targetId: vorhanden.characterId, details: { label: updated.label, url: updated.url }, }); @@ -1612,15 +1781,20 @@ export async function updateLink( 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'); +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', + action: "character.link_removed", + targetType: "character", targetId: vorhanden.characterId, details: { label: vorhanden.label, url: vorhanden.url }, }); @@ -1630,11 +1804,18 @@ export async function removeLink(linkId: number, ctx: ActionContext): Promise { - if (input.useKind === undefined || !JOIN_REDESIGN_KINDS.includes(input.useKind)) { + 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', + "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', + "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'); + 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 } } }, + include: { + members: { where: { playerId, leftAt: null }, select: { id: true } }, + }, }); - if (!vorgaenger) throw notFound('Aufgegebener CC'); + 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); + (vorgaenger.status !== CharacterStatus.ACTIVE && + vorgaenger.playerId === playerId); if (!gehoertIhm) { - throw conflict('NOT_OWN_CHARACTER', 'Dieser CC gehört dem Spieler nicht'); + throw conflict("NOT_OWN_CHARACTER", "Dieser CC gehört dem Spieler nicht"); } return vorgaenger; @@ -1695,7 +1889,10 @@ async function ladeAufgegebenenCC( * 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 { +async function warSchonInDerKette( + character: Character, + playerId: number, +): Promise { return (await fruehereNutzung(character, playerId)) !== null; } @@ -1717,7 +1914,7 @@ async function fruehereNutzung( chainPosition: { lt: character.chainPosition }, }, }, - orderBy: { character: { chainPosition: 'desc' } }, + orderBy: { character: { chainPosition: "desc" } }, select: { lineageId: true, chainPosition: true }, }); } @@ -1741,17 +1938,26 @@ async function platzInSeinerListe( select: { lineageId: true, chainPosition: true }, }); if (vorher) { - return { lineageId: vorher.lineageId, chainPosition: vorher.chainPosition + 10 }; + return { + lineageId: vorher.lineageId, + chainPosition: vorher.chainPosition + 10, + }; } } if (character.playerId === playerId) { - return { lineageId: character.lineageId, chainPosition: character.chainPosition }; + 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: frueher.lineageId, + chainPosition: frueher.chainPosition + 10, + }; } return { lineageId: randomUUID(), chainPosition: 0 }; @@ -1795,40 +2001,63 @@ export async function addMember( // 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) { + if ( + !historisch && + !schonInDerKette && + !istBesitzer && + input.useKind === undefined + ) { throw badRequest( - 'NEED_KIND', - 'Ein Platz an einem laufenden CC kostet ein Guthaben - bitte angeben, welches', + "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'); + 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'); + 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 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'); + 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); - const platz = await platzInSeinerListe(character, playerId, vorgaenger?.id ?? null); + 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 }, + 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. @@ -1871,12 +2100,32 @@ export async function addMember( }); } - if (input.useKind !== undefined) { + // 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, - redesign: vorgaenger !== null, + kind: + input.useKind ?? + (redesignPosition ? CreditKind.REDESIGN_PAID : CreditKind.CC), + redesign: redesignPosition, note: input.note ?? (vorgaenger @@ -1887,11 +2136,11 @@ export async function addMember( }); } - await assertSlotsReichen(tx, playerId, 'Der Beitritt'); + await assertSlotsReichen(tx, playerId, "Der Beitritt"); await recordAudit(tx, ctx, { - action: 'character.member_added', - targetType: 'character', + action: "character.member_added", + targetType: "character", targetId: characterId, details: { playerId, @@ -1938,19 +2187,20 @@ export async function redesignMembership( const gruppe = await loadCharacter(characterId); if (gruppe.status !== CharacterStatus.ACTIVE) { - throw conflict('NOT_ACTIVE', 'Nur ein aktiver CC kann redesignt werden'); + 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'); + 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'); + 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'); + throw notFound("Mitgliedschaft"); } const verbleibend = await prisma.characterMember.count({ @@ -1958,8 +2208,8 @@ export async function redesignMembership( }); if (verbleibend <= 1) { throw conflict( - 'LAST_MEMBER', - 'Dieser Spieler ist der letzte Nutzer. Dann ist es ein Redesign des ganzen CCs, kein Austritt.', + "LAST_MEMBER", + "Dieser Spieler ist der letzte Nutzer. Dann ist es ein Redesign des ganzen CCs, kein Austritt.", ); } @@ -2008,15 +2258,16 @@ export async function redesignMembership( characterId: erstellt.id, kind: input.useKind, redesign: true, - note: input.note ?? `Redesign aus "${gruppe.name}" heraus: ${erstellt.name}`, + note: + input.note ?? `Redesign aus "${gruppe.name}" heraus: ${erstellt.name}`, actorId: ctx.actorId, }); - await assertSlotsReichen(tx, playerId, 'Dieses Redesign'); + await assertSlotsReichen(tx, playerId, "Dieses Redesign"); await recordAudit(tx, ctx, { - action: 'character.membership_redesigned', - targetType: 'character', + action: "character.membership_redesigned", + targetType: "character", targetId: erstellt.id, details: { playerId, @@ -2032,7 +2283,12 @@ export async function redesignMembership( }); const balances = await getBalances(playerId); - return { character: neuerCC, balances, overdrawn: isOverdrawn(balances), leftGroup: true }; + return { + character: neuerCC, + balances, + overdrawn: isOverdrawn(balances), + leftGroup: true, + }; } /** @@ -2053,12 +2309,15 @@ export async function deactivateMember( ): Promise<{ memberCount: number }> { if (reason === InactiveReason.REWORKED) { throw badRequest( - 'USE_REDESIGN', - 'Ein Rework entsteht über das Redesign des Anteils, nicht über eine Deaktivierung', + "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'); + throw badRequest( + "NEED_NOTE", + "Für einen Tod im Rollenspiel wird eine Begründung gebraucht", + ); } const mitglied = await prisma.characterMember.findUnique({ @@ -2066,7 +2325,7 @@ export async function deactivateMember( include: { character: { select: { name: true } } }, }); if (!mitglied || mitglied.leftAt !== null) { - throw notFound('Mitgliedschaft'); + throw notFound("Mitgliedschaft"); } const verbleibend = await prisma.characterMember.count({ @@ -2074,8 +2333,8 @@ export async function deactivateMember( }); if (verbleibend <= 1) { throw conflict( - 'LAST_MEMBER', - 'Das ist der letzte Nutzer dieses CCs. Dann ist der ganze CC betroffen, nicht nur eine Mitgliedschaft.', + "LAST_MEMBER", + "Das ist der letzte Nutzer dieses CCs. Dann ist der ganze CC betroffen, nicht nur eine Mitgliedschaft.", ); } @@ -2090,10 +2349,15 @@ export async function deactivateMember( }); await recordAudit(tx, ctx, { - action: 'character.member_deactivated', - targetType: 'character', + action: "character.member_deactivated", + targetType: "character", targetId: characterId, - details: { playerId, name: mitglied.character.name, reason, note: note ?? null }, + details: { + playerId, + name: mitglied.character.name, + reason, + note: note ?? null, + }, }); }); @@ -2114,15 +2378,18 @@ export async function reactivateMember( ): 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'); + 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) throw notFound("Mitgliedschaft"); if (mitglied.leftAt === null) { - throw conflict('ALREADY_MEMBER', 'Dieser Spieler nutzt den CC bereits'); + throw conflict("ALREADY_MEMBER", "Dieser Spieler nutzt den CC bereits"); } const grund = mitglied.inactiveReason; @@ -2132,8 +2399,8 @@ export async function reactivateMember( // 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', + "NEED_REASON", + "Für die Rückkehr eines Gestorbenen wird eine Begründung gebraucht", ); } @@ -2141,18 +2408,21 @@ export async function reactivateMember( // 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.', + "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'); + 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'); + throw badRequest("INVALID_KIND", "Ungültige Guthaben-Art"); } await prisma.$transaction(async (tx) => { @@ -2172,11 +2442,11 @@ export async function reactivateMember( }); } - await assertSlotsReichen(tx, playerId, 'Die Rückkehr'); + await assertSlotsReichen(tx, playerId, "Die Rückkehr"); await recordAudit(tx, ctx, { - action: 'character.member_reactivated', - targetType: 'character', + action: "character.member_reactivated", + targetType: "character", targetId: characterId, details: { playerId, @@ -2218,8 +2488,11 @@ export async function addMemberToChain( const einstieg = await loadCharacter(characterId); const kette = await prisma.character.findMany({ - where: { lineageId: einstieg.lineageId, chainPosition: { gte: einstieg.chainPosition } }, - orderBy: [{ chainPosition: 'asc' }, { id: 'asc' }], + where: { + lineageId: einstieg.lineageId, + chainPosition: { gte: einstieg.chainPosition }, + }, + orderBy: [{ chainPosition: "asc" }, { id: "asc" }], select: { id: true, name: true }, }); @@ -2236,13 +2509,19 @@ export async function addMemberToChain( for (const [index, glied] of kette.entries()) { if (vorhandene.has(glied.id)) continue; - // Nur der Einstieg kostet. Alles Weitere ist dieselbe Mitgliedschaft, - // durch die Kette fortgeschrieben. + // 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 ? { useKind: input.useKind } : {}), + ...(index === 0 + ? { + ...(input.useKind !== undefined + ? { useKind: input.useKind } + : {}), + } + : { useKind: CreditKind.REDESIGN_PAID }), ...(index === 0 && input.predecessorId !== undefined ? { predecessorId: input.predecessorId } : {}), @@ -2256,7 +2535,9 @@ export async function addMemberToChain( const letztes = kette[kette.length - 1]; const memberCount = letztes - ? await prisma.characterMember.count({ where: { characterId: letztes.id, leftAt: null } }) + ? await prisma.characterMember.count({ + where: { characterId: letztes.id, leftAt: null }, + }) : 0; return { added, memberCount }; @@ -2278,7 +2559,7 @@ export async function removeMember( include: { character: { select: { name: true } } }, }); if (!mitglied || mitglied.leftAt !== null) { - throw notFound('Mitgliedschaft'); + throw notFound("Mitgliedschaft"); } const verbleibend = await prisma.characterMember.count({ @@ -2286,8 +2567,8 @@ export async function removeMember( }); if (verbleibend <= 1) { throw conflict( - 'LAST_MEMBER', - 'Das ist der letzte Nutzer dieses CCs. Statt ihn auszutragen, den CC deaktivieren.', + "LAST_MEMBER", + "Das ist der letzte Nutzer dieses CCs. Statt ihn auszutragen, den CC deaktivieren.", ); } @@ -2298,8 +2579,8 @@ export async function removeMember( }); await recordAudit(tx, ctx, { - action: 'character.member_removed', - targetType: 'character', + action: "character.member_removed", + targetType: "character", targetId: characterId, details: { playerId, name: mitglied.character.name, note: note ?? null }, }); @@ -2312,7 +2593,7 @@ export async function removeMember( export async function listMembers(characterId: number) { return prisma.characterMember.findMany({ where: { characterId }, - orderBy: [{ leftAt: 'asc' }, { joinedAt: 'asc' }], + orderBy: [{ leftAt: "asc" }, { joinedAt: "asc" }], include: { player: { select: { id: true, displayName: true, steamId: true } }, successor: { select: { id: true, name: true } }, diff --git a/server/test/ketten.test.ts b/server/test/ketten.test.ts index a949db7..5c0e2c6 100644 --- a/server/test/ketten.test.ts +++ b/server/test/ketten.test.ts @@ -716,8 +716,9 @@ describe('Die Stelle in der Kette bestimmt, was ein Eintrag kostet', () => { ); }); - it('lässt einen Eintrag ohne Buchung in Ruhe', async () => { - // Nacherfassung ohne Guthaben-Angabe - da gibt es nichts umzustellen. + it('trägt eine fehlende Buchung nach', async () => { + // Jedes Glied einer Liste kostet: das unterste ein Paket, jedes weitere + // ein Redesign. Wo nichts gebucht ist, fehlt es in der Rechnung. const spieler = await spielerMitPaketen('Ohne Buchung', 1); const eins = await characters.createCharacter(spieler, { name: 'Mit Buchung' }, ctx); @@ -735,7 +736,19 @@ describe('Die Stelle in der Kette bestimmt, was ein Eintrag kostet', () => { ctx, ); - assert.deepEqual(await credits.getBalances(spieler), vorher, 'nichts gebucht, nichts geändert'); + const danach = await credits.getBalances(spieler); + assert.equal(danach.CC, vorher.CC, 'der Platz bleibt, wie er war'); + assert.equal( + danach.REDESIGN_PAID, + vorher.REDESIGN_PAID - 1, + 'das zweite Glied kostet ein Redesign', + ); + + // Und allein gestellt kostet er wieder einen eigenen Platz. + await characters.detachFromChain(ohne.id, ctx); + const allein = await credits.getBalances(spieler); + assert.equal(allein.CC, vorher.CC - 1, 'eine eigene Liste, ein eigenes Paket'); + assert.equal(allein.REDESIGN_PAID, vorher.REDESIGN_PAID, 'das Redesign ist wieder frei'); }); }); @@ -1345,11 +1358,13 @@ describe('Die Umstellung auf Listen je Nutzer', () => { ); } - assert.equal( - await characters.richteBuchungenAus(), - 0, - 'und die Buchungen passen danach zu den Listen', - ); + for (const wer of [wirt, gast]) { + assert.equal( + (await characters.rebuildFunding(wer, ctx)).umgebucht, + 0, + 'und die Buchungen passen danach zu den Listen', + ); + } // Der Gast zahlt für seinen Einstieg ein Redesign, nicht einen Platz. assert.equal( diff --git a/server/test/nacherfassung.test.ts b/server/test/nacherfassung.test.ts index d73f506..3174481 100644 --- a/server/test/nacherfassung.test.ts +++ b/server/test/nacherfassung.test.ts @@ -107,16 +107,20 @@ describe('Nachträglich aufräumen, was längst passiert ist', () => { assert.deepEqual(await mitglieder(alpha), ['Spieler A', 'Spieler B']); }); - it('zieht ihn durch die Kette, ohne ein zweites Mal zu buchen', async () => { + it('zieht ihn durch die Kette und bucht jedes Glied einzeln', async () => { + // Für ihn ist jedes Glied ein eigener Eintrag in seiner Liste: der + // unterste kostet den Platz, jedes weitere ein Redesign. const vorher = await credits.getBalances(a); const ergebnis = await characters.addMemberToChain(beta, a, {}, ctx); assert.equal(ergebnis.added, 2, 'Beta und Gamma kommen dazu'); - assert.deepEqual( - await credits.getBalances(a), - vorher, - 'sein Platz wurde einmal bezahlt und ist derselbe geblieben', + const danach = await credits.getBalances(a); + assert.equal(danach.CC, vorher.CC, 'sein Platz wurde einmal bezahlt'); + assert.equal( + danach.REDESIGN_PAID, + vorher.REDESIGN_PAID - 2, + 'für Beta und Gamma je ein Redesign', ); assert.deepEqual(await mitglieder(gamma), ['Spieler A', 'Spieler B']); }); diff --git a/web/src/components/ChainView.tsx b/web/src/components/ChainView.tsx index bb8781b..914b622 100644 --- a/web/src/components/ChainView.tsx +++ b/web/src/components/ChainView.tsx @@ -409,16 +409,23 @@ const UEBERGANG: Record UEBERGANG[art]) + .filter((art): art is (typeof UEBERGANG)[string] => art !== undefined); return (
- {art && ( - + {arten.map((art, i) => ( + 0 ? { transform: `translate(${0.75 + i * 1.6}rem, -50%)` } : undefined} + title={art.titel} + > {art.zeichen} - )} + ))}
); } diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index ec81524..013f31b 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -151,7 +151,8 @@ export interface Character { forumThreads?: ThreadRef[]; links?: CharacterLink[]; /// Womit dieser Eintrag bezahlt wurde, aus Sicht des betrachteten Spielers. - fundedWith?: CreditKind | null; + /// Mehrere, wenn zusätzlich gebucht wurde - etwa bei einer Rückkehr. + fundedWith?: CreditKind[]; members?: CharacterMember[]; createdAt: string; }