From 1f4034b3972cf9d456ed1f076c88748248b6c89c Mon Sep 17 00:00:00 2001 From: Harlekin Date: Tue, 22 Sep 2026 12:21:53 +0200 Subject: [PATCH] Jede Liste gehoert einem Spieler, CC-Guthaben wird umgewandelt Ein Gruppen-CC wird fuer jeden Nutzer einzeln gerechnet: fuer den einen kann er der gekaufte Platz sein, fuer den anderen ein Redesign seines vorherigen CCs. Dafuer haengt die Liste jetzt an der Mitgliedschaft statt am CC, und verschieben ordnet nur die eigene Liste. Ein Redesign wird nie aus dem CC-Topf bezahlt. Wer sein CC-Guthaben dafuer einsetzt, wandelt es um: ein Platz weniger, ein bezahltes Redesign mehr. Damit entfaellt die Ausnahme, die sich bisher durch Slot-Pruefung und Kettenrechnung zog. Beim Start werden Buchungen einmalig an die Listen angeglichen. --- .../migration.sql | 5 + .../migration.sql | 4 + .../migration.sql | 65 ++ server/prisma/schema.prisma | 18 +- server/src/index.ts | 11 + server/src/routes/characters.routes.ts | 12 +- server/src/services/backup.ts | 21 +- server/src/services/characters.ts | 839 ++++++++++++------ server/src/services/credits.ts | 59 +- server/src/services/forum.ts | 6 +- server/test/gruppen.test.ts | 103 +++ server/test/ketten.test.ts | 323 ++++++- web/src/components/ChainView.tsx | 5 +- web/src/components/ThreadModals.tsx | 29 +- web/src/lib/format.ts | 1 + web/src/lib/types.ts | 1 + web/src/pages/PlayerDetail.tsx | 10 +- 17 files changed, 1172 insertions(+), 340 deletions(-) create mode 100644 server/prisma/migrations/20250115000000_guthaben_umwandeln/migration.sql create mode 100644 server/prisma/migrations/20250116000000_bezahlart_folgt_der_kette/migration.sql create mode 100644 server/prisma/migrations/20250117000000_kette_je_nutzer/migration.sql diff --git a/server/prisma/migrations/20250115000000_guthaben_umwandeln/migration.sql b/server/prisma/migrations/20250115000000_guthaben_umwandeln/migration.sql new file mode 100644 index 0000000..3397252 --- /dev/null +++ b/server/prisma/migrations/20250115000000_guthaben_umwandeln/migration.sql @@ -0,0 +1,5 @@ +-- Ein CC-Guthaben, das fuer ein Redesign eingesetzt wird, wird umgewandelt +-- statt als Ausnahme gebucht. Der neue Wert steht allein in dieser Migration: +-- Postgres laesst einen frisch angelegten Enum-Wert in derselben Transaktion +-- noch nicht verwenden. +ALTER TYPE "CreditSource" ADD VALUE IF NOT EXISTS 'CONVERSION' BEFORE 'CONSUME'; diff --git a/server/prisma/migrations/20250116000000_bezahlart_folgt_der_kette/migration.sql b/server/prisma/migrations/20250116000000_bezahlart_folgt_der_kette/migration.sql new file mode 100644 index 0000000..9fb3830 --- /dev/null +++ b/server/prisma/migrations/20250116000000_bezahlart_folgt_der_kette/migration.sql @@ -0,0 +1,4 @@ +-- Ob ein Eintrag ein Platz oder ein Redesign ist, entscheidet seine Stelle in +-- der Kette - nicht eine Markierung an der Buchung. Wer sein CC-Guthaben fuer +-- ein Redesign einsetzt, wandelt es um, statt eine Ausnahme zu buchen. +ALTER TABLE "CreditLedger" DROP COLUMN IF EXISTS "fundingPinned"; diff --git a/server/prisma/migrations/20250117000000_kette_je_nutzer/migration.sql b/server/prisma/migrations/20250117000000_kette_je_nutzer/migration.sql new file mode 100644 index 0000000..8795cfc --- /dev/null +++ b/server/prisma/migrations/20250117000000_kette_je_nutzer/migration.sql @@ -0,0 +1,65 @@ +-- Ein Gruppen-CC wird fuer jeden Nutzer einzeln gerechnet: fuer den einen ist +-- er der gekaufte Platz, fuer den anderen ein Redesign seines vorherigen CCs. +-- Deshalb wandert die Liste vom CC an die Mitgliedschaft. +ALTER TABLE "CharacterMember" ADD COLUMN IF NOT EXISTS "lineageId" TEXT; +ALTER TABLE "CharacterMember" ADD COLUMN IF NOT EXISTS "chainPosition" INTEGER NOT NULL DEFAULT 0; + +-- Wer den CC angelegt hat, bringt seine bisherige Kette mit. +UPDATE "CharacterMember" m +SET "lineageId" = c."lineageId", "chainPosition" = c."chainPosition" +FROM "Character" c +WHERE c.id = m."characterId" AND c."playerId" = m."playerId" AND m."lineageId" IS NULL; + +-- Die uebrigen haengen an einem Eintrag, der schon zugeordnet ist: entweder am +-- CC, den sie dafuer aufgegeben haben, oder an einem frueheren Glied derselben +-- Kette, in der sie mitgezogen wurden. Beides kann mehrere Stufen tief gehen, +-- deshalb die Schleife. +DO $$ +DECLARE + ueber_vorgaenger INTEGER; + ueber_kette INTEGER; + geaendert INTEGER := 1; +BEGIN + WHILE geaendert > 0 LOOP + UPDATE "CharacterMember" m + SET "lineageId" = v."lineageId", "chainPosition" = v."chainPosition" + 10 + FROM "CharacterMember" v + WHERE v."characterId" = m."predecessorId" + AND v."playerId" = m."playerId" + AND v."lineageId" IS NOT NULL + AND m."lineageId" IS NULL; + GET DIAGNOSTICS ueber_vorgaenger = ROW_COUNT; + + UPDATE "CharacterMember" m + SET "lineageId" = q."lineageId", + "chainPosition" = q."chainPosition" + (q."cc_pos" - q."von_pos") + FROM ( + SELECT DISTINCT ON (offen.id) + offen.id AS mitglied_id, + v."lineageId", + v."chainPosition", + cm."chainPosition" AS cc_pos, + cv."chainPosition" AS von_pos + FROM "CharacterMember" offen + JOIN "Character" cm ON cm.id = offen."characterId" + JOIN "Character" cv + ON cv."lineageId" = cm."lineageId" AND cv."chainPosition" < cm."chainPosition" + JOIN "CharacterMember" v ON v."characterId" = cv.id AND v."playerId" = offen."playerId" + WHERE offen."lineageId" IS NULL AND v."lineageId" IS NOT NULL + ORDER BY offen.id, cv."chainPosition" DESC + ) q + WHERE m.id = q.mitglied_id; + GET DIAGNOSTICS ueber_kette = ROW_COUNT; + + geaendert := ueber_vorgaenger + ueber_kette; + END LOOP; +END $$; + +-- Alles Uebrige beginnt eine eigene Liste: ein Platz, der fuer sich steht. +UPDATE "CharacterMember" +SET "lineageId" = gen_random_uuid()::text, "chainPosition" = 0 +WHERE "lineageId" IS NULL; + +ALTER TABLE "CharacterMember" ALTER COLUMN "lineageId" SET NOT NULL; + +CREATE INDEX IF NOT EXISTS "CharacterMember_lineageId_idx" ON "CharacterMember"("lineageId"); diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index d9af245..f21fa30 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -30,6 +30,9 @@ enum CreditSource { CC_GRANT KULANZ ADJUSTMENT + /// Ein CC-Guthaben, das in ein Redesign-Guthaben umgewandelt wurde. + /// Immer ein Paar: eine Zeile ab im CC-Topf, eine Zeile zu im Redesign-Topf. + CONVERSION CONSUME REVERSAL } @@ -227,11 +230,6 @@ model CreditLedger { /// hier eingetippt hat. occurredAt DateTime @default(now()) - /// Von Hand so entschieden. Das Ausrichten der Buchungen an den Ketten - /// lässt solche Zeilen in Ruhe - sonst würde jede Ausnahme beim nächsten - /// Umsortieren wieder eingeebnet. - fundingPinned Boolean @default(false) - @@index([playerId]) @@index([playerId, kind]) @@index([groupId]) @@ -473,6 +471,15 @@ model CharacterMember { paidWith CreditKind? note String? + /// Die Liste dieses Spielers, in der der CC für ihn steht, und seine + /// Stelle darin. + /// + /// Ein Gruppen-CC wird für jeden Nutzer einzeln gerechnet: Für den einen + /// kann er der gekaufte Platz sein, für den anderen ein Redesign seines + /// vorherigen CCs. Deshalb hängt die Kette am Nutzer, nicht am CC. + lineageId String @default(uuid()) + chainPosition Int @default(0) + /// Der eigene CC, den der Spieler für diesen Platz aufgegeben hat. Nur /// gesetzt, wenn der Beitritt über ein Redesign lief - dann gilt dieselbe /// Regel wie sonst: ein Redesign ersetzt einen CC, es kommt keiner dazu. @@ -491,6 +498,7 @@ model CharacterMember { @@unique([characterId, playerId]) @@index([playerId]) @@index([characterId]) + @@index([lineageId]) } /// Wann ein wiederkehrender Lauf zuletzt lief. Der Zeitplan steht im Code, diff --git a/server/src/index.ts b/server/src/index.ts index daabc94..d3d7f0e 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -2,6 +2,7 @@ import { buildApp } from './app.js'; import { bootstrap } from './bootstrap.js'; import { cleanupExpiredSessions } from './auth/session.js'; import { cleanupOldAuditEntries } from './services/audit.js'; +import { richteBuchungenAus } from './services/characters.js'; import { startSchedule } from './services/schedule.js'; import { prisma } from './db.js'; import { env } from './env.js'; @@ -23,6 +24,16 @@ async function main(): Promise { warn: (msg) => app.log.warn(msg), }); + // Am Anfang einer Kette steht ein gekaufter Platz, darüber die Redesigns. + // Der Lauf gleicht Buchungen an, die dem noch widersprechen - und tut + // nichts, wenn alles passt. + try { + const angeglichen = await richteBuchungenAus(); + if (angeglichen > 0) app.log.info(`${angeglichen} Buchungen an die Ketten angeglichen`); + } catch (error) { + app.log.warn({ err: error }, 'Angleichen der Buchungen fehlgeschlagen'); + } + const cleanup = setInterval(() => { cleanupExpiredSessions() .then((count) => { diff --git a/server/src/routes/characters.routes.ts b/server/src/routes/characters.routes.ts index 3e3f251..a9d256c 100644 --- a/server/src/routes/characters.routes.ts +++ b/server/src/routes/characters.routes.ts @@ -245,10 +245,18 @@ export async function characterRoutes(app: FastifyInstance): Promise { return moveInChain(id, body, contextOf(request)); }); - /** Löst einen CC aus seiner Kette und stellt ihn allein. */ + /** Löst einen CC aus einer Liste und stellt ihn allein. */ app.post('/api/characters/:id/detach', { preHandler: requireEditor }, async (request) => { const { id } = parseWith(idParam, request.params); - return detachFromChain(id, contextOf(request)); + const body = parseWith( + z.object({ + /// Wessen Liste gerade geordnet wird - bei einem Gruppen-CC ist das + /// nicht zwingend sein Ersteller. + forPlayerId: z.number().int().positive().optional(), + }), + request.body ?? {}, + ); + return detachFromChain(id, contextOf(request), body.forPlayerId); }); // --- Gruppen-CCs --- diff --git a/server/src/services/backup.ts b/server/src/services/backup.ts index fafe6c9..c06e254 100644 --- a/server/src/services/backup.ts +++ b/server/src/services/backup.ts @@ -147,6 +147,10 @@ export async function exportAll(): Promise { cc_name: m.character.name, bezeichnung: kennung(m.character.playerId).spieler, bezahlt_mit: m.paidWith ?? '', + // Die Liste hängt am Nutzer: derselbe Gruppen-CC steht bei jedem an + // einer anderen Stelle. + kette: m.lineageId, + kette_position: String(m.chainPosition), zeitpunkt: datum(m.joinedAt), cc_grund: m.inactiveReason ?? '', cc_notiz: m.inactiveNote ?? '', @@ -380,7 +384,13 @@ async function spieleZeileEin( // Der Besitzer ist immer auch Mitglied; eigene MITGLIED-Zeilen // ergänzen die übrigen. await prisma.characterMember.create({ - data: { characterId: erstellt.id, playerId, createdById: ctx.actorId }, + data: { + characterId: erstellt.id, + playerId, + lineageId: erstellt.lineageId, + chainPosition: erstellt.chainPosition, + createdById: ctx.actorId, + }, }); return true; } @@ -413,6 +423,15 @@ async function spieleZeileEin( inactiveReason: (zeile.cc_grund?.trim() || null) as never, inactiveNote: zeile.cc_notiz?.trim() || null, leftAt: zeile.wert ? new Date(zeile.wert) : null, + // Aus einer Sicherung von vor der Umstellung kommt keine Liste + // mit; dann beginnt der Eintrag eine eigene, und der Abgleich + // beim Start ordnet den Rest. + ...(zeile.kette?.trim() + ? { + lineageId: zeile.kette.trim(), + chainPosition: Number(zeile.kette_position) || 0, + } + : {}), ...(zeile.zeitpunkt ? { joinedAt: new Date(zeile.zeitpunkt) } : {}), note: zeile.bemerkung?.trim() || null, createdById: ctx.actorId, diff --git a/server/src/services/characters.ts b/server/src/services/characters.ts index 99dfd51..832cc1e 100644 --- a/server/src/services/characters.ts +++ b/server/src/services/characters.ts @@ -10,7 +10,7 @@ import { } from '@prisma/client'; import { prisma } from '../db.js'; import { badRequest, conflict, notFound } from '../errors.js'; -import { getBalances, type Balances } from './credits.js'; +import { getBalances, wandleCcInRedesign, type Balances } from './credits.js'; import { recordAudit, type ActionContext } from './audit.js'; export interface CharacterResult { @@ -108,6 +108,8 @@ export async function createCharacter( 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: { @@ -115,11 +117,14 @@ export async function createCharacter( name: input.name, loreText: input.loreText ?? null, status: CharacterStatus.ACTIVE, - lineageId: randomUUID(), + lineageId, createdById: ctx.actorId, // Der Ersteller ist zugleich das erste Mitglied. Die Zugehörigkeit - // läuft durchgehend über diese Tabelle, auch bei Einzel-CCs. - members: { create: [{ playerId, createdById: ctx.actorId }] }, + // 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 }], + }, }, }); @@ -172,13 +177,11 @@ export async function redesignCharacter( if (old.status !== CharacterStatus.ACTIVE) { throw conflict('NOT_ACTIVE', 'Nur ein aktiver CC kann redesignt werden'); } - // Der Regelfall ist ein Redesign-Guthaben. Ausnahmsweise lässt sich auch - // ein CC-Paket dafür einsetzen - dann gilt die Entscheidung als von Hand - // getroffen und bleibt beim Umsortieren stehen. + // 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 ausnahme = input.useKind === CreditKind.CC; const result = await prisma.$transaction(async (tx) => { const predecessor = await tx.character.update({ @@ -190,7 +193,7 @@ export async function redesignCharacter( // 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 }, + select: { playerId: true, paidWith: true, lineageId: true, chainPosition: true }, }); const created = await tx.character.create({ @@ -205,28 +208,29 @@ export async function redesignCharacter( 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 tx.creditLedger.create({ - data: { - playerId: old.playerId, - kind: input.useKind, - amount: -1, - source: CreditSource.CONSUME, - groupId: randomUUID(), - characterId: created.id, - note: input.note ?? `Redesign: ${old.name} -> ${created.name}`, - createdById: ctx.actorId, - ...(ausnahme ? { fundingPinned: true } : {}), - }, + 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, }); await assertSlotsReichen(tx, old.playerId, 'Dieses Redesign'); @@ -389,17 +393,13 @@ export async function reactivatePaid( data: { status: CharacterStatus.ACTIVE, inactiveReason: null, inactiveNote: null }, }); - await tx.creditLedger.create({ - data: { - playerId: updated.playerId, - kind: useKind, - amount: -1, - source: CreditSource.CONSUME, - groupId: randomUUID(), - characterId: updated.id, - note: note ?? `Reaktivierung: ${updated.name}`, - createdById: ctx.actorId, - }, + await bucheVerbrauch(tx, { + playerId: updated.playerId, + characterId: updated.id, + kind: useKind, + redesign: updated.predecessorId !== null, + note: note ?? `Reaktivierung: ${updated.name}`, + actorId: ctx.actorId, }); await assertSlotsReichen(tx, updated.playerId, 'Die Rückkehr dieses CCs'); @@ -679,7 +679,27 @@ export async function listCharacters(playerId: number) { } } - return ccs.map((cc) => ({ ...cc, fundedWith: artNach.get(cc.id) ?? null })); + // 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) ?? null, + }; + }) + .sort( + (a, b) => + a.lineageId.localeCompare(b.lineageId) || + a.chainPosition - b.chainPosition || + a.createdAt.getTime() - b.createdAt.getTime(), + ); } @@ -721,17 +741,13 @@ export async function reviveDeadCharacter( }); if (input.useKind !== undefined) { - await tx.creditLedger.create({ - data: { - playerId: updated.playerId, - kind: input.useKind, - amount: -1, - source: CreditSource.CONSUME, - groupId: randomUUID(), - characterId: updated.id, - note: `Rückkehr nach Tod: ${input.reason}`, - createdById: ctx.actorId, - }, + await bucheVerbrauch(tx, { + playerId: updated.playerId, + characterId: updated.id, + kind: input.useKind, + redesign: updated.predecessorId !== null, + note: `Rückkehr nach Tod: ${input.reason}`, + actorId: ctx.actorId, }); } @@ -759,16 +775,19 @@ export async function reviveDeadCharacter( /** - * Stellt nachträglich um, woraus ein CC bezahlt wurde. + * Stellt um, woraus ein Eintrag bezahlt wurde. * - * Ob etwas aus einem gekauften Paket, einem bezahlten Redesign oder aus - * Kulanz kam, stellt sich manchmal erst später heraus - wenn eine Absprache - * auftaucht, von der beim Eintragen niemand wusste, oder wenn sich zeigt, - * dass ein Eintrag gar nicht der Anfang einer Kette war. + * Zwei Dinge lassen sich hier entscheiden: ob ein Redesign aus dem bezahlten + * Topf oder aus Kulanz kommt - und ob dafür ausnahmsweise ein CC-Guthaben + * eingesetzt wird. Das Letzte ist keine eigene Bezahlart, sondern eine + * Umwandlung: ein Platz weniger, ein Redesign mehr. * - * Umgebucht wird nicht durch Überschreiben, sondern 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. + * Ob ein Eintrag überhaupt ein Redesign ist, steht nicht zur Wahl - das + * entscheidet seine Stelle in der Kette. + * + * 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, @@ -798,37 +817,61 @@ export async function changeFunding( 'Für diesen CC ist nichts gebucht - hier gibt es nichts umzustellen', ); } - if (buchung.kind === useKind) { + + const redesign = await istRedesignPosition(prisma, characterId, buchung.playerId); + if (!redesign && useKind !== CreditKind.CC) { + throw conflict( + 'CHAIN_START', + 'Dieser Eintrag steht am Anfang einer Kette und bucht einen eigenen Platz. ' + + 'Erst über einem anderen Eintrag wird daraus ein Redesign.', + ); + } + + // Ein CC-Guthaben für ein Redesign wird umgewandelt, nicht anders gebucht. + const willUmwandeln = redesign && useKind === CreditKind.CC; + const zielArt = willUmwandeln ? CreditKind.REDESIGN_PAID : useKind; + const hatUmwandlung = + (await prisma.creditLedger.count({ + where: { + characterId, + playerId: buchung.playerId, + source: CreditSource.CONVERSION, + reversedBy: null, + }, + })) > 0; + + if (buchung.kind === zielArt && willUmwandeln === hatUmwandlung) { throw conflict('SAME_KIND', 'Dieser CC ist bereits so gebucht'); } - const grund = - useKind === CreditKind.CC + const grund = willUmwandeln + ? 'CC-Guthaben umgewandelt' + : useKind === CreditKind.CC ? 'Umgestellt auf CC-Paket' : useKind === CreditKind.REDESIGN_KULANZ ? 'Umgestellt auf Kulanz' : 'Umgestellt auf bezahltes Redesign'; await prisma.$transaction(async (tx) => { - await tx.creditLedger.create({ - data: { + await storniere(tx, buchung, `${grund}: ${character.name}`, ctx.actorId); + + if (willUmwandeln && !hatUmwandlung) { + await wandleCcInRedesign(tx, { playerId: buchung.playerId, - kind: buchung.kind, - amount: -buchung.amount, - source: CreditSource.REVERSAL, - groupId: buchung.groupId, characterId, - reversalOfId: buchung.id, - note: `${grund}: ${character.name}`, - createdById: ctx.actorId, + actorId: ctx.actorId, + note: `CC-Guthaben als Redesign eingesetzt: ${character.name}`, occurredAt: buchung.occurredAt, - }, - }); + }); + } + if (!willUmwandeln && hatUmwandlung) { + await nimmUmwandlungZurueck(tx, characterId, buchung.playerId, ctx.actorId); + } await tx.creditLedger.create({ data: { playerId: buchung.playerId, - kind: useKind, + kind: zielArt, amount: buchung.amount, source: CreditSource.CONSUME, groupId: randomUUID(), @@ -837,8 +880,6 @@ export async function changeFunding( createdById: ctx.actorId, // Bezahlt wurde damals, nicht heute. occurredAt: buchung.occurredAt, - // Von Hand gewählt - das Ausrichten an den Ketten lässt es stehen. - fundingPinned: true, }, }); @@ -846,7 +887,7 @@ export async function changeFunding( action: 'character.funding_changed', targetType: 'character', targetId: characterId, - details: { name: character.name, from: buchung.kind, to: useKind }, + details: { name: character.name, from: buchung.kind, to: zielArt, umgewandelt: willUmwandeln }, }); }); @@ -857,15 +898,134 @@ export async function changeFunding( // --- Ketten --- /** - * Setzt die Verkettung einer Linie neu auf, nach der Reihenfolge der - * Positionen. + * Steht dieser Eintrag für diesen Spieler über einem Vorgänger? * - * Die Kette 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. + * 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. + * + * Ein Platz am Anfang einer Kette kommt aus dem CC-Topf, alles darüber aus + * einem Redesign-Topf. Wer sein CC-Guthaben für ein Redesign einsetzen will, + * wandelt es hier um: ein Platz weniger, ein bezahltes Redesign mehr. Der + * Eintrag selbst ist danach ein Redesign wie jedes andere - dadurch bleibt + * die Rechnung überall dieselbe, ohne Sonderfall. + */ +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. + redesign: boolean; + note: string; + actorId: number | null; + occurredAt?: Date | undefined; + }, +): Promise { + const ausCcTopf = params.kind === CreditKind.CC; + const umwandeln = ausCcTopf && params.redesign; + + if (umwandeln) { + await wandleCcInRedesign(tx, { + playerId: params.playerId, + characterId: params.characterId, + actorId: params.actorId, + note: `CC-Guthaben als Redesign eingesetzt: ${params.note}`, + occurredAt: params.occurredAt ?? null, + }); + } + + await tx.creditLedger.create({ + data: { + playerId: params.playerId, + kind: umwandeln ? CreditKind.REDESIGN_PAID : 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, + }, + }); +} + +/** + * Nimmt die Umwandlung zurück, aus der ein Eintrag bezahlt wurde. + * + * Wer ein CC-Guthaben in ein Redesign getauscht hat und den Eintrag später + * an den Anfang einer Kette stellt, bekommt den Tausch zurück: der Eintrag + * bucht dann wieder einen eigenen Platz. + */ +async function nimmUmwandlungZurueck( + tx: Prisma.TransactionClient, + characterId: number, + playerId: number, + actorId: number | null, +): Promise { + const zeilen = await tx.creditLedger.findMany({ + where: { characterId, playerId, source: CreditSource.CONVERSION, reversedBy: null }, + }); + for (const zeile of zeilen) { + await storniere(tx, zeile, 'Umwandlung zurückgenommen: bucht wieder einen Platz', actorId); + } +} + /** * Bucht ein Kettenglied auf die Art um, die seine Stelle in der Kette * verlangt. @@ -898,7 +1058,7 @@ async function buchungFolgtPosition( sollCC: boolean, actorId: number | null, ): Promise { - const offen = await tx.creditLedger.findFirst({ + const offene = await tx.creditLedger.findMany({ where: { characterId: glied.id, playerId: kettenBesitzer, @@ -907,39 +1067,33 @@ async function buchungFolgtPosition( }, orderBy: { id: 'desc' }, }); + const offen = offene[0]; if (!offen) return false; - // Wer die Bezahlart von Hand gewählt hat, meinte es so - etwa wenn ein - // Redesign ausnahmsweise aus einem CC-Paket bezahlt wurde. - if (offen.fundingPinned) return false; + // 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; - const istCC = offen.kind === CreditKind.CC; - if (istCC === sollCC) return false; - - const neueArt = sollCC ? CreditKind.CC : CreditKind.REDESIGN_PAID; + // 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); + + if (sollCC) { + // Wer sein CC-Guthaben einmal für dieses Redesign umgewandelt hat, + // bekommt den Tausch zurück - hier bucht der Eintrag wieder einen Platz. + await nimmUmwandlungZurueck(tx, glied.id, offen.playerId, actorId); + } await tx.creditLedger.create({ data: { playerId: offen.playerId, - kind: offen.kind, - amount: -offen.amount, - source: CreditSource.REVERSAL, - groupId: offen.groupId, - characterId: glied.id, - reversalOfId: offen.id, - note: `${grund} - Storno: ${glied.name}`, - createdById: actorId, - occurredAt: offen.occurredAt, - }, - }); - - await tx.creditLedger.create({ - data: { - playerId: offen.playerId, - kind: neueArt, + kind: sollCC ? CreditKind.CC : CreditKind.REDESIGN_PAID, amount: offen.amount, source: CreditSource.CONSUME, groupId: randomUUID(), @@ -954,38 +1108,55 @@ async function buchungFolgtPosition( 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.character.findMany({ - where: { lineageId }, - orderBy: [{ chainPosition: 'asc' }, { createdAt: 'asc' }, { id: 'asc' }], + const glieder = await tx.characterMember.findMany({ + where: { lineageId, playerId }, + orderBy: [{ chainPosition: 'asc' }, { joinedAt: 'asc' }, { id: 'asc' }], select: { id: true, - name: true, - playerId: true, + characterId: true, + chainPosition: true, predecessorId: true, - status: true, + leftAt: true, inactiveReason: true, + character: { + select: { id: true, name: true, playerId: true, status: true, inactiveReason: true }, + }, }, }); - // Die Kette gehört dem, der unten steht: bei ihm fing sie an. - const kettenBesitzer = glieder[0]?.playerId ?? null; - const inDieserLinie = new Set(glieder.map((g) => g.id)); + const inDieserLinie = new Set(glieder.map((g) => g.characterId)); let vorheriges: number | null = null; for (const [index, glied] of glieder.entries()) { const letztes = index === glieder.length - 1; const erstes = index === 0; - const daten: Prisma.CharacterUpdateInput = {}; + const cc = glied.character; + const daten: Prisma.CharacterMemberUpdateInput = {}; if (erstes) { // Am Anfang wird nur eine Verkettung gelöst, die innerhalb dieser - // Linie ins Leere zeigt. Eine Herkunft von aussen bleibt: sie ist das - // Einzige, was noch zeigt, woher die Kette kommt. + // 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 }; } @@ -993,45 +1164,106 @@ async function verketteNeu( daten.predecessor = { connect: { id: vorheriges as number } }; } - // Wer nach dem Ordnen keinen Vorgänger hat, ist der Anfang einer Kette - // und damit ein gekaufter CC; wer einen hat, ist dessen Redesign. Ob - // der Vorgänger in derselben Linie geführt wird, spielt keine Rolle - - // die Kette ist die Kette. + // 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 (kettenBesitzer !== null) { - await buchungFolgtPosition(tx, glied, kettenBesitzer, vorgaengerDanach === null, actorId); - } + await buchungFolgtPosition(tx, cc, playerId, vorgaengerDanach === null, actorId); // Was einen Nachfolger hat, wurde durch ihn ersetzt - anders ist eine - // Kette nicht zu lesen. Ein Glied mittendrin kann also nicht aktiv sein - // und auch nicht "geputzt": es ist redesignt, Punkt. - if (!letztes && glied.inactiveReason !== InactiveReason.REWORKED) { - daten.status = CharacterStatus.INACTIVE; - daten.inactiveReason = InactiveReason.REWORKED; - daten.inactiveNote = null; - } + // 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; - // 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 && glied.inactiveReason === InactiveReason.REWORKED) { - daten.status = CharacterStatus.ACTIVE; - daten.inactiveReason = null; - daten.inactiveNote = null; + 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.character.update({ where: { id: glied.id }, data: daten }); + await tx.characterMember.update({ where: { id: glied.id }, data: daten }); } - vorheriges = glied.id; + vorheriges = glied.characterId; } } +/** + * Gleicht die Buchungen eines Spielers an seine Listen an. + * + * 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. + */ +async function gleicheAn( + tx: Prisma.TransactionClient, + playerId: number, + actorId: number | null, +): Promise<{ geprueft: number; umgebucht: number }> { + const mitgliedschaften = await tx.characterMember.findMany({ + where: { playerId }, + select: { + predecessorId: true, + character: { select: { id: true, name: true } }, + }, + }); + + let umgebucht = 0; + for (const eintrag of mitgliedschaften) { + const geaendert = await buchungFolgtPosition( + tx, + eintrag.character, + playerId, + eintrag.predecessorId === null, + actorId, + ); + if (geaendert) umgebucht += 1; + } + + return { geprueft: mitgliedschaften.length, umgebucht }; +} + /** * Rechnet alle Buchungen eines Spielers anhand der Ketten nach. * @@ -1049,42 +1281,44 @@ export async function rebuildFunding( playerId: number, ctx: ActionContext, ): Promise<{ geprueft: number; umgebucht: number; balances: Balances }> { - const mitgliedschaften = await prisma.characterMember.findMany({ - where: { playerId }, - select: { character: { select: { id: true, name: true, predecessorId: true } } }, - }); - - let umgebucht = 0; + let ergebnis = { geprueft: 0, umgebucht: 0 }; await prisma.$transaction(async (tx) => { - for (const { character } of mitgliedschaften) { - // Kein Vorgänger heisst: hier fing eine Kette an, also ein gekaufter - // CC. Sonst ist es das Redesign seines Vorgängers. - const geaendert = await buchungFolgtPosition( - tx, - character, - playerId, - character.predecessorId === null, - ctx.actorId, - ); - if (geaendert) umgebucht += 1; - } + ergebnis = await gleicheAn(tx, playerId, ctx.actorId); - if (umgebucht > 0) { + if (ergebnis.umgebucht > 0) { await recordAudit(tx, ctx, { action: 'character.funding_rebuilt', targetType: 'player', targetId: playerId, - details: { geprueft: mitgliedschaften.length, umgebucht }, + details: { geprueft: ergebnis.geprueft, umgebucht: ergebnis.umgebucht }, }); } }); - return { - geprueft: mitgliedschaften.length, - umgebucht, - balances: await getBalances(playerId), - }; + 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; } /** @@ -1183,71 +1417,61 @@ export async function settleWithKulanz( return { umgebucht: kandidaten.length, balances: await getBalances(playerId) }; } -/** - * Verschiebt einen CC innerhalb seiner Kette oder in eine andere. - * - * Gedacht für das Nachtragen von Lücken: ein Redesign, das erst Jahre später - * auftaucht, gehört mitten in eine bestehende Kette - und nicht ans Ende, - * bloß weil es zuletzt eingetragen wurde. Am Status ändert das Verschieben - * nichts; wer aktiv war, bleibt aktiv. - */ export async function moveInChain( characterId: number, input: { lineageId: string; afterId?: number | null; - /// Wessen Geschichte hier geordnet wird. Bei einem Gruppen-CC ist das - /// nicht zwingend der Ersteller. + /// 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 zielGlieder = await prisma.character.findMany({ - where: { lineageId: input.lineageId }, - orderBy: [{ chainPosition: 'asc' }, { id: 'asc' }], - select: { id: true, playerId: true, chainPosition: true }, - }); + const spieler = input.forPlayerId ?? character.playerId; /** - * Wer hier ordnen darf. + * Geordnet wird die eigene Liste. * - * Nicht der Besitz entscheidet, sondern die Beteiligung: Ein Gruppen-CC - * gehört dem, der ihn angelegt hat, aber seine Geschichte gehört allen, - * die darin steckten. Wer seinen eigenen CC aufgegeben hat, um - * dazuzukommen, ordnet damit seine eigene Kette - und muss den Gruppen-CC - * dort einreihen können. - * - * Verlangt wird deshalb, dass derselbe Spieler an beidem beteiligt ist: - * am verschobenen CC und an der Kette, in die er soll. Sonst würden fremde - * Geschichten vermischt. + * 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 beteiligt = async (playerId: number, ids: number[]): Promise => { - if (ids.length === 0) return true; - const treffer = await prisma.characterMember.findFirst({ - where: { playerId, characterId: { in: ids } }, - select: { id: true }, - }); - return treffer !== null; - }; - - const kandidat = input.forPlayerId ?? character.playerId; - const darfOrdnen = - (await beteiligt(kandidat, [characterId])) && - (await beteiligt(kandidat, zielGlieder.map((g) => g.id))); - - if (!darfOrdnen) { + const mitglied = await prisma.characterMember.findUnique({ + where: { characterId_playerId: { characterId, playerId: spieler } }, + select: { id: true, lineageId: true }, + }); + if (!mitglied) { throw conflict( 'FOREIGN_CHAIN', - 'Diese Kette gehört zu einer anderen Geschichte - verschieben kann nur, wer an beidem beteiligt war', + '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.id === input.afterId); + : 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'); } @@ -1255,32 +1479,33 @@ export async function moveInChain( throw badRequest('SELF_REFERENCE', 'Ein CC kann nicht hinter sich selbst stehen'); } - const alteLinie = character.lineageId; + 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.character.updateMany({ + await tx.characterMember.updateMany({ where: { lineageId: input.lineageId, + playerId: spieler, chainPosition: { gte: ab }, - id: { not: characterId }, + id: { not: mitglied.id }, }, data: { chainPosition: { increment: 10 } }, }); - await tx.character.update({ - where: { id: characterId }, + await tx.characterMember.update({ + where: { id: mitglied.id }, data: { lineageId: input.lineageId, chainPosition: ab }, }); - await verketteNeu(tx, input.lineageId, ctx.actorId); - if (alteLinie !== input.lineageId) await verketteNeu(tx, alteLinie, ctx.actorId); + 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, character.playerId, 'Dieses Verschieben'); + await assertSlotsReichen(tx, spieler, 'Dieses Verschieben'); await recordAudit(tx, ctx, { action: 'character.moved_in_chain', @@ -1288,6 +1513,7 @@ export async function moveInChain( targetId: characterId, details: { name: character.name, + forPlayerId: spieler, fromLineage: alteLinie, toLineage: input.lineageId, afterId: input.afterId ?? null, @@ -1299,43 +1525,49 @@ export async function moveInChain( } /** - * Löst einen CC aus seiner Kette und stellt ihn allein - für den Fall, dass + * 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 Linie ist er zugleich Anfang und Ende. Eine + // 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.character.update({ - where: { id: characterId }, - data: { - lineageId: neueLinie, - chainPosition: 0, - predecessorId: null, - ...(character.inactiveReason === InactiveReason.REWORKED - ? { status: CharacterStatus.ACTIVE, inactiveReason: null, inactiveNote: null } - : {}), - }, + await tx.characterMember.update({ + where: { id: mitglied.id }, + data: { lineageId: neueLinie, chainPosition: 0, predecessorId: null }, }); - // Beide Seiten: die verlassene Kette rückt zusammen, und der - // Herausgelöste steht allein - also am Anfang seiner eigenen Kette. - await verketteNeu(tx, character.lineageId, ctx.actorId); - await verketteNeu(tx, neueLinie, ctx.actorId); - await assertSlotsReichen(tx, character.playerId, 'Dieses Herauslösen'); + // 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, fromLineage: character.lineageId }, + details: { name: character.name, forPlayerId: spieler, fromLineage: mitglied.lineageId }, }); }); @@ -1505,7 +1737,20 @@ async function ladeAufgegebenenCC( * Sein Platz wurde einmal gebucht und ist derselbe geblieben. */ async function warSchonInDerKette(character: Character, playerId: number): Promise { - const treffer = await prisma.characterMember.findFirst({ + 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: { @@ -1513,9 +1758,44 @@ async function warSchonInDerKette(character: Character, playerId: number): Promi chainPosition: { lt: character.chainPosition }, }, }, - select: { id: true }, + orderBy: { character: { chainPosition: 'desc' } }, + select: { lineageId: true, chainPosition: true }, }); - return treffer !== null; +} + +/** + * 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 }; } /** @@ -1583,6 +1863,7 @@ export async function addMember( // 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); await prisma.$transaction(async (tx) => { if (vorgaenger !== null && vorgaenger.status === CharacterStatus.ACTIVE) { @@ -1608,6 +1889,8 @@ export async function addMember( 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, }, @@ -1619,6 +1902,8 @@ export async function addMember( 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 } : {}), @@ -1628,22 +1913,18 @@ export async function addMember( } if (input.useKind !== undefined) { - await tx.creditLedger.create({ - data: { - playerId, - kind: input.useKind, - amount: -1, - source: CreditSource.CONSUME, - groupId: randomUUID(), - characterId, - note: - input.note ?? - (vorgaenger - ? `Beitritt zu "${character.name}" anstelle von "${vorgaenger.name}"` - : `Beitritt zu "${character.name}"`), - createdById: ctx.actorId, - ...(input.joinedAt ? { occurredAt: input.joinedAt } : {}), - }, + await bucheVerbrauch(tx, { + playerId, + characterId, + kind: input.useKind, + redesign: vorgaenger !== null, + note: + input.note ?? + (vorgaenger + ? `Beitritt zu "${character.name}" anstelle von "${vorgaenger.name}"` + : `Beitritt zu "${character.name}"`), + actorId: ctx.actorId, + occurredAt: input.joinedAt, }); } @@ -1724,17 +2005,30 @@ export async function redesignMembership( } 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, - // Eine eigene Linie: der Gruppen-CC lebt weiter, hier beginnt ein - // neuer Strang. Woher er kommt, steht an der Mitgliedschaft. + lineageId: mitglied.lineageId, + chainPosition: mitglied.chainPosition + 10, predecessorId: gruppe.id, createdById: ctx.actorId, - members: { create: [{ playerId, paidWith: input.useKind, createdById: ctx.actorId }] }, + members: { + create: [ + { + playerId, + paidWith: input.useKind, + lineageId: mitglied.lineageId, + chainPosition: mitglied.chainPosition + 10, + predecessorId: gruppe.id, + createdById: ctx.actorId, + }, + ], + }, }, }); @@ -1750,18 +2044,13 @@ export async function redesignMembership( }, }); - await tx.creditLedger.create({ - data: { - playerId, - kind: input.useKind, - amount: -1, - source: CreditSource.CONSUME, - groupId: randomUUID(), - characterId: erstellt.id, - note: input.note ?? `Redesign aus "${gruppe.name}" heraus: ${erstellt.name}`, - createdById: ctx.actorId, - ...(input.useKind === CreditKind.CC ? { fundingPinned: true } : {}), - }, + 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'); @@ -1914,17 +2203,13 @@ export async function reactivateMember( }); if (input.useKind !== undefined) { - await tx.creditLedger.create({ - data: { - playerId, - kind: input.useKind, - amount: -1, - source: CreditSource.CONSUME, - groupId: randomUUID(), - characterId, - note: input.reason ?? `Rückkehr zu "${gruppe.name}"`, - createdById: ctx.actorId, - }, + await bucheVerbrauch(tx, { + playerId, + characterId, + kind: input.useKind, + redesign: mitglied.predecessorId !== null, + note: input.reason ?? `Rückkehr zu "${gruppe.name}"`, + actorId: ctx.actorId, }); } diff --git a/server/src/services/credits.ts b/server/src/services/credits.ts index 479e924..75a45ae 100644 --- a/server/src/services/credits.ts +++ b/server/src/services/credits.ts @@ -57,7 +57,7 @@ export async function getBalances(playerId: number): Promise { kind: true, amount: true, source: true, - reversalOf: { select: { source: true } }, + reversalOf: { select: { source: true, amount: true } }, }, }); @@ -67,9 +67,16 @@ export async function getBalances(playerId: number): Promise { for (const eintrag of eintraege) { sums[eintrag.kind] = (sums[eintrag.kind] ?? 0) + eintrag.amount; - const zweck = - eintrag.source === CreditSource.REVERSAL ? eintrag.reversalOf?.source : eintrag.source; - if (zweck !== CreditSource.CONSUME) { + // Ein Storno zählt dorthin, wohin die stornierte Zeile zählte. + const bezug = + eintrag.source === CreditSource.REVERSAL ? (eintrag.reversalOf ?? eintrag) : eintrag; + // Eine Umwandlung ist auf der einen Seite Verbrauch, auf der anderen + // Zugang: wer ein CC-Guthaben in ein Redesign tauscht, hat das CC-Paket + // gekauft und aufgebraucht - "0 von 3" statt "0 von 2". + const istVerbrauch = + bezug.source === CreditSource.CONSUME || + (bezug.source === CreditSource.CONVERSION && bezug.amount < 0); + if (!istVerbrauch) { granted[eintrag.kind] = (granted[eintrag.kind] ?? 0) + eintrag.amount; } } @@ -181,6 +188,50 @@ export async function bookCcPackage( return { groupId, balances: await getBalances(playerId) }; } +/** + * Wandelt ein CC-Guthaben in ein bezahltes Redesign um. + * + * Ein Redesign wird nie aus dem CC-Topf bezahlt. Wer sein CC-Guthaben dafür + * einsetzen will, tauscht es vorher: ein Platz weniger, ein Redesign mehr. + * Danach ist der Eintrag ein Redesign wie jedes andere - keine Ausnahme, die + * sich durch Slot-Prüfung, Kettenrechnung und Anzeige ziehen müsste. + * + * Läuft immer in der Transaktion des Vorgangs mit, der die Umwandlung + * auslöst: beides gilt zusammen oder gar nicht. + */ +export async function wandleCcInRedesign( + tx: Prisma.TransactionClient, + params: { + playerId: number; + /// Der Eintrag, für den das Guthaben eingesetzt wird. Daran hängt die + /// Umwandlung, damit sie sich zurücknehmen lässt, wenn der Eintrag + /// später doch einen eigenen Platz bekommt. + characterId: number; + actorId: number | null; + note: string; + /// Wann der Vorgang war, zu dem die Umwandlung gehört. + occurredAt?: Date | null; + }, +): Promise { + const groupId = randomUUID(); + const gemeinsam = { + playerId: params.playerId, + characterId: params.characterId, + source: CreditSource.CONVERSION, + groupId, + note: params.note, + createdById: params.actorId, + ...(params.occurredAt ? { occurredAt: params.occurredAt } : {}), + }; + + await tx.creditLedger.create({ + data: { ...gemeinsam, kind: CreditKind.CC, amount: -1 }, + }); + await tx.creditLedger.create({ + data: { ...gemeinsam, kind: CreditKind.REDESIGN_PAID, amount: 1 }, + }); +} + /** * Vergibt einen CC-Platz von Hand, ohne das Redesign aus einem Paket. * diff --git a/server/src/services/forum.ts b/server/src/services/forum.ts index 0eb5735..1136a25 100644 --- a/server/src/services/forum.ts +++ b/server/src/services/forum.ts @@ -760,7 +760,7 @@ export async function classifyThread( if (target.inactiveReason === 'REWORKED') { const result = await reactivatePaid( input.characterId, - input.useKind ?? 'REDESIGN_KULANZ', + input.useKind ?? 'REDESIGN_PAID', ctx, note, ); @@ -793,8 +793,8 @@ export async function classifyThread( if (input.predecessorId === undefined) { throw badRequest('NO_PREDECESSOR', 'Bitte angeben, welcher CC redesignt wurde'); } - // Ein Redesign kann nicht aus dem CC-Topf bezahlt werden. - const redesignKind = input.useKind === 'REDESIGN_PAID' ? 'REDESIGN_PAID' : 'REDESIGN_KULANZ'; + // Kulanz wird bewusst vergeben. Ohne Angabe gilt der Regelfall. + const redesignKind = input.useKind === 'REDESIGN_KULANZ' ? 'REDESIGN_KULANZ' : 'REDESIGN_PAID'; const result = await redesignCharacter( input.predecessorId, { name, loreText: input.loreText, useKind: redesignKind, note }, diff --git a/server/test/gruppen.test.ts b/server/test/gruppen.test.ts index b615d81..1f0b02d 100644 --- a/server/test/gruppen.test.ts +++ b/server/test/gruppen.test.ts @@ -769,3 +769,106 @@ describe('Der eigene CC bleibt der eigene', () => { ); }); }); + +describe('Ein Gruppen-CC zählt für jeden Nutzer einzeln', () => { + /// Womit ein bestimmter Spieler diesen CC bezahlt hat. + async function bezahltMit(characterId: number, playerId: number): Promise { + const buchung = await db.prisma.creditLedger.findFirst({ + where: { characterId, playerId, source: 'CONSUME', reversedBy: null }, + orderBy: { id: 'desc' }, + }); + return buchung?.kind; + } + + /// Die Liste dieses Spielers, von unten nach oben. + async function liste(playerId: number): Promise { + const eigene = await db.prisma.characterMember.findMany({ + where: { playerId }, + orderBy: [{ lineageId: 'asc' }, { chainPosition: 'asc' }], + select: { lineageId: true, character: { select: { name: true } } }, + }); + return eigene.map((e) => `${e.lineageId.slice(0, 4)}:${e.character.name}`); + } + + it('ist für den einen ein Platz und für den anderen ein Redesign', async () => { + const wirt = await spielerMitPaket('Zaehlwirt'); + const gast = await spielerMitPaket('Zaehlgast'); + const gruppe = await characters.createCharacter(wirt, { name: 'Gemeinsames Werk' }, ctx); + const eigener = await characters.createCharacter(gast, { name: 'Sein Erster' }, ctx); + + await credits.bookRedesignPackage(gast, ctx); + await characters.addMember( + gruppe.character.id, + gast, + { useKind: 'REDESIGN_PAID', predecessorId: eigener.character.id }, + ctx, + ); + + assert.equal(await bezahltMit(gruppe.character.id, wirt), 'CC', 'für den Wirt sein Platz'); + assert.equal( + await bezahltMit(gruppe.character.id, gast), + 'REDESIGN_PAID', + 'für den Gast das Redesign seines ersten CCs', + ); + + assert.equal( + await characters.richteBuchungenAus(), + 0, + 'beide Sichten sind in sich stimmig und bleiben unangetastet', + ); + + // Die Rechnung des Gastes: eine Liste mit zwei Einträgen. + const stand = await credits.getBalances(gast); + assert.equal(stand.CC, 0, 'ein Platz, eine Liste'); + assert.equal(stand.REDESIGN_PAID, 1, 'von zwei Redesigns eines verbraucht'); + assert.deepEqual( + (await liste(gast)).map((e) => e.split(':')[1]), + ['Sein Erster', 'Gemeinsames Werk'], + 'in seiner Liste steht der Gruppen-CC über seinem ersten', + ); + }); + + it('lässt jeden für sich verschieben, ohne den anderen anzufassen', async () => { + const wirt = await spielerMitPaket('Schiebewirt', 2); + const gast = await spielerMitPaket('Schiebegast', 2); + const gruppe = await characters.createCharacter(wirt, { name: 'Die Bude' }, ctx); + const eigener = await characters.createCharacter(gast, { name: 'Gasts Erster' }, ctx); + + await characters.addMember(gruppe.character.id, gast, { useKind: 'CC' }, ctx); + assert.equal(await bezahltMit(gruppe.character.id, gast), 'CC', 'erst ein eigener Platz'); + + // Der Gast reiht den Gruppen-CC in seine Liste ein: für ihn wird daraus + // ein Redesign seines ersten CCs. + await credits.bookRedesignPackage(gast, ctx); + await characters.moveInChain( + gruppe.character.id, + { + lineageId: ( + await db.prisma.characterMember.findFirstOrThrow({ + where: { playerId: gast, characterId: eigener.character.id }, + }) + ).lineageId, + afterId: eigener.character.id, + forPlayerId: gast, + }, + ctx, + ); + + assert.equal( + await bezahltMit(gruppe.character.id, gast), + 'REDESIGN_PAID', + 'in seiner Liste ist es jetzt ein Redesign', + ); + assert.equal( + await bezahltMit(gruppe.character.id, wirt), + 'CC', + 'beim Wirt ändert sich dadurch nichts', + ); + + // Und der CC selbst bleibt aktiv - der Wirt spielt ihn weiter. + const danach = await db.prisma.character.findUniqueOrThrow({ + where: { id: gruppe.character.id }, + }); + assert.equal(danach.status, 'ACTIVE'); + }); +}); diff --git a/server/test/ketten.test.ts b/server/test/ketten.test.ts index ab6fb15..a421062 100644 --- a/server/test/ketten.test.ts +++ b/server/test/ketten.test.ts @@ -1,5 +1,8 @@ import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; import { after, before, describe, it } from 'node:test'; +import { fileURLToPath } from 'node:url'; import { startTestDatabase, type TestDatabase } from './harness.js'; type Characters = typeof import('../src/services/characters.js'); @@ -43,13 +46,23 @@ async function spielerMitPaketen(name: string, pakete = 1) { * Eine Kette, wie sie angezeigt würde. Massgeblich ist die Linie, nicht der * Besitzer: ein Gruppen-CC gehört einem anderen und steht trotzdem darin. */ -async function kette(_playerId: number, lineageId: string) { - const glieder = await db.prisma.character.findMany({ - where: { lineageId }, +async function kette(playerId: number, lineageId: string) { + const glieder = await db.prisma.characterMember.findMany({ + where: { playerId, lineageId }, orderBy: [{ chainPosition: 'asc' }, { id: 'asc' }], - select: { id: true, name: true, predecessorId: true, chainPosition: true }, + select: { + characterId: true, + chainPosition: true, + predecessorId: true, + character: { select: { name: true } }, + }, }); - return glieder; + return glieder.map((glied) => ({ + id: glied.characterId, + name: glied.character.name, + predecessorId: glied.predecessorId, + chainPosition: glied.chainPosition, + })); } /** Prüft, dass Positionen und Vorgänger-Verweise dasselbe sagen. */ @@ -493,22 +506,22 @@ describe('Redesign nachträglich umbuchen', () => { ); }); - it('stellt einen gekauften CC auf Redesign um', async () => { - // Genau der Korrekturfall: ein Eintrag war gar nicht der Anfang einer - // Kette, sondern ging aus einem anderen hervor. + it('macht aus einem Kettenanfang kein Redesign', async () => { + // Ob ein Eintrag ein Redesign ist, entscheidet seine Stelle in der Kette. + // Sonst ginge die Rechnung "eine Kette = ein CC-Paket" nicht mehr auf. const spieler = await spielerMitPaketen('Umsteller', 1); const eins = await characters.createCharacter(spieler, { name: 'Direkt aus dem Paket' }, ctx); await credits.bookRedesignPackage(spieler, ctx); - const danach = await characters.changeFunding(eins.character.id, 'REDESIGN_PAID', ctx); - assert.equal(danach.balances.CC, 1, 'das CC-Guthaben kommt zurück'); - assert.equal(danach.balances.REDESIGN_PAID, 1, 'dafür geht ein Redesign weg'); - await assert.rejects( () => characters.changeFunding(eins.character.id, 'REDESIGN_PAID', ctx), - (error: Error & { code?: string }) => error.code === 'SAME_KIND', - 'ein CC aus dem Paket ist schon als CC gebucht', + (error: Error & { code?: string }) => error.code === 'CHAIN_START', + 'dafür wird er eingereiht, nicht umgebucht', ); + + const stand = await credits.getBalances(spieler); + assert.equal(stand.CC, 0, 'der Platz bleibt gebucht'); + assert.equal(stand.REDESIGN_PAID, 2, 'und das Redesign unangetastet'); }); }); @@ -1045,8 +1058,16 @@ describe('Kulanz gegen ein Minus verrechnen', () => { }); }); -describe('Ein Redesign ausnahmsweise aus dem CC-Topf', () => { - it('nimmt ein CC-Guthaben an', async () => { +describe('Ein CC-Guthaben für ein Redesign einsetzen', () => { + async function verbrauchtAls(characterId: number): Promise { + const buchung = await db.prisma.creditLedger.findFirst({ + where: { characterId, source: 'CONSUME', reversedBy: null }, + orderBy: { id: 'desc' }, + }); + return buchung?.kind; + } + + it('wandelt es um, statt eine Ausnahme zu buchen', async () => { const spieler = await spielerMitPaketen('Ausnahmefall', 2); const eins = await characters.createCharacter(spieler, { name: 'Basis' }, ctx); @@ -1057,13 +1078,26 @@ describe('Ein Redesign ausnahmsweise aus dem CC-Topf', () => { ctx, ); - assert.equal(zwei.balances.CC, vorher.CC - 1, 'bezahlt aus dem CC-Topf'); - assert.equal(zwei.balances.REDESIGN_PAID, vorher.REDESIGN_PAID, 'die Redesigns bleiben'); + assert.equal(zwei.balances.CC, vorher.CC - 1, 'der Platz ist weg'); + assert.equal( + zwei.balances.REDESIGN_PAID, + vorher.REDESIGN_PAID, + 'das eingetauschte Redesign wird sofort wieder verbraucht', + ); + assert.equal( + await verbrauchtAls(zwei.character.id), + 'REDESIGN_PAID', + 'der Eintrag selbst ist ein Redesign wie jedes andere', + ); + assert.equal( + zwei.balances.granted.REDESIGN_PAID, + vorher.granted.REDESIGN_PAID + 1, + 'und das umgewandelte Guthaben ist als erhalten ausgewiesen', + ); + assert.equal(zwei.balances.granted.CC, vorher.granted.CC, 'die Käufe bleiben, wie sie waren'); }); - it('lässt sich vom Ausrichten an den Ketten nicht überschreiben', async () => { - // Ohne Merker würde die Automatik beim nächsten Umsortieren daraus - // wieder ein gewöhnliches Redesign machen. + it('bleibt beim Ausrichten an den Ketten stehen', async () => { const spieler = await spielerMitPaketen('Bleibt so', 2); const eins = await characters.createCharacter(spieler, { name: 'Erstes' }, ctx); const zwei = await characters.redesignCharacter( @@ -1072,16 +1106,32 @@ describe('Ein Redesign ausnahmsweise aus dem CC-Topf', () => { ctx, ); - await characters.rebuildFunding(spieler, ctx); + const vorher = await credits.getBalances(spieler); + const lauf = await characters.rebuildFunding(spieler, ctx); - const buchung = await db.prisma.creditLedger.findFirst({ - where: { characterId: zwei.character.id, source: 'CONSUME', reversedBy: null }, - orderBy: { id: 'desc' }, - }); - assert.equal(buchung?.kind, 'CC', 'die bewusste Entscheidung bleibt stehen'); + assert.equal(lauf.umgebucht, 0, 'es passt bereits alles'); + assert.equal(await verbrauchtAls(zwei.character.id), 'REDESIGN_PAID'); + assert.deepEqual(lauf.balances, vorher, 'und am Guthaben ändert sich nichts'); }); - it('merkt sich auch eine von Hand geänderte Bezahlart', async () => { + it('gibt den Tausch zurück, wenn der Eintrag wieder allein steht', async () => { + const spieler = await spielerMitPaketen('Rueckzieher', 2); + const eins = await characters.createCharacter(spieler, { name: 'A' }, ctx); + const zwei = await characters.redesignCharacter( + eins.character.id, + { name: 'B', useKind: 'CC' }, + ctx, + ); + + await characters.detachFromChain(zwei.character.id, ctx); + + assert.equal(await verbrauchtAls(zwei.character.id), 'CC', 'allein steht er für sich'); + const stand = await credits.getBalances(spieler); + assert.equal(stand.CC, 0, 'beide Plätze sind belegt'); + assert.equal(stand.REDESIGN_PAID, 2, 'die Redesigns sind wieder frei'); + }); + + it('lässt sich auch nachträglich einsetzen', async () => { const spieler = await spielerMitPaketen('Von Hand', 2); const eins = await characters.createCharacter(spieler, { name: 'A' }, ctx); const zwei = await characters.redesignCharacter( @@ -1090,13 +1140,222 @@ describe('Ein Redesign ausnahmsweise aus dem CC-Topf', () => { ctx, ); - await characters.changeFunding(zwei.character.id, 'CC', ctx); - await characters.rebuildFunding(spieler, ctx); + const danach = await characters.changeFunding(zwei.character.id, 'CC', ctx); + assert.equal(await verbrauchtAls(zwei.character.id), 'REDESIGN_PAID'); + assert.equal(danach.balances.CC, 0, 'der Platz ist eingetauscht'); + assert.equal(danach.balances.REDESIGN_PAID, 2, 'das Redesign dafür wieder frei'); + + // Und zurück: der Tausch wird rückgängig gemacht. + const zurueck = await characters.changeFunding(zwei.character.id, 'REDESIGN_PAID', ctx); + assert.equal(zurueck.balances.CC, 1, 'der Platz kommt zurück'); + assert.equal(zurueck.balances.REDESIGN_PAID, 1, 'und das Redesign wird wieder verbraucht'); + }); +}); + +describe('Buchungen an die Ketten angleichen', () => { + it('bucht den Anfang einer Kette auf einen Platz', async () => { + // Der Fall aus dem Bestand: drei Ketten, aber nur zwei gebuchte Plätze - + // bei einer war der unterste Eintrag als Redesign gebucht. + const spieler = await spielerMitPaketen('Schieflage', 2); + const eins = await characters.createCharacter(spieler, { name: 'Kettenfuss' }, ctx); + await db.prisma.creditLedger.updateMany({ + where: { characterId: eins.character.id, source: 'CONSUME' }, + data: { kind: 'REDESIGN_PAID' }, + }); + + const abweichend = await db.prisma.creditLedger.findMany({ + where: { source: 'CONSUME', reversedBy: null }, + select: { kind: true, playerId: true, character: { select: { name: true, playerId: true, predecessorId: true } } }, + }); + for (const e of abweichend) { + const cc = e.character; + if (!cc) continue; + const soll = cc.playerId === e.playerId ? cc.predecessorId === null : null; + if (soll !== null && (e.kind === 'CC') !== soll) { + console.log('ABWEICHUNG', cc.name, e.kind, 'predecessor', cc.predecessorId, 'spieler', e.playerId, 'besitzer', cc.playerId); + } + } + const angeglichen = await characters.richteBuchungenAus(); + assert.equal(angeglichen, 1, 'genau diese eine Buchung passte nicht'); + const buchung = await db.prisma.creditLedger.findFirst({ - where: { characterId: zwei.character.id, source: 'CONSUME', reversedBy: null }, + where: { characterId: eins.character.id, source: 'CONSUME', reversedBy: null }, orderBy: { id: 'desc' }, + }); + assert.equal(buchung?.kind, 'CC', 'wer unten steht, hat einen Platz gekauft'); + + const stand = await credits.getBalances(spieler); + assert.equal(stand.CC, 1, 'ein Platz belegt, einer frei'); + assert.equal(stand.REDESIGN_PAID, 2, 'das Redesign ist wieder frei'); + }); + + it('lässt den zweiten Lauf alles, wie es ist', async () => { + const spieler = await spielerMitPaketen('Ruhig', 1); + const eins = await characters.createCharacter(spieler, { name: 'Unten' }, ctx); + await characters.redesignCharacter( + eins.character.id, + { name: 'Oben', useKind: 'REDESIGN_PAID' }, + ctx, + ); + + assert.equal(await characters.richteBuchungenAus(), 0, 'es passt bereits alles'); + const vorher = await db.prisma.creditLedger.count({ where: { playerId: spieler } }); + await characters.richteBuchungenAus(); + assert.equal( + await db.prisma.creditLedger.count({ where: { playerId: spieler } }), + vorher, + 'und es entstehen keine Zeilen aus dem Nichts', + ); + }); + + it('erkennt den aufgegebenen CC eines Mitglieds', async () => { + // Für ein Mitglied steht der Vorgänger an der Mitgliedschaft, nicht am + // Gruppen-CC. Sonst würde sein Redesign hier zu einem Platz. + const gruender = await spielerMitPaketen('Wirt', 1); + const gast = await spielerMitPaketen('Gast', 1); + const gruppe = await characters.createCharacter(gruender, { name: 'Stammtisch' }, ctx); + const eigener = await characters.createCharacter(gast, { name: 'Eigener' }, ctx); + + await credits.bookRedesignPackage(gast, ctx); + await characters.addMember( + gruppe.character.id, + gast, + { useKind: 'REDESIGN_PAID', predecessorId: eigener.character.id }, + ctx, + ); + + const vorher = await credits.getBalances(gast); + assert.equal(await characters.richteBuchungenAus(), 0, 'sein Beitritt war ein Redesign'); + assert.deepEqual(await credits.getBalances(gast), vorher); + }); +}); + +describe('Die Umstellung auf Listen je Nutzer', () => { + const MIGRATION = join( + dirname(fileURLToPath(import.meta.url)), + '..', + 'prisma', + 'migrations', + '20250117000000_kette_je_nutzer', + 'migration.sql', + ); + + /** + * Die Migration noch einmal anwenden. + * + * Jeder Schritt greift nur, wo noch keine Liste steht - so lässt sich der + * Weg nachstellen, den der Bestand beim Aktualisieren nimmt. Zerlegt wird + * an Semikolons, wobei ein DO-Block am Stück bleibt. + */ + async function migrationAusfuehren(): Promise { + const sql = readFileSync(MIGRATION, 'utf8'); + let puffer = ''; + for (const teil of sql.split(';')) { + puffer += `${teil};`; + if ((puffer.match(/\$\$/g) ?? []).length % 2 !== 0) continue; + if (puffer.replace(/;/g, '').trim()) await db.prisma.$executeRawUnsafe(puffer); + puffer = ''; + } + } + + it('ordnet einen Bestand ohne Listen wieder richtig zu', async () => { + const wirt = await spielerMitPaketen('Altbestand-Wirt', 1); + const gast = await spielerMitPaketen('Altbestand-Gast', 2); + + // Eine Kette des Wirts, in der der Gast ab dem zweiten Glied mitzieht. + const eins = await characters.createCharacter(wirt, { name: 'Alt-Eins' }, ctx); + const zwei = await characters.redesignCharacter( + eins.character.id, + { name: 'Alt-Zwei', useKind: 'REDESIGN_PAID' }, + ctx, + ); + const drei = await characters.redesignCharacter( + zwei.character.id, + { name: 'Alt-Drei', useKind: 'REDESIGN_PAID' }, + ctx, + ); + + const eigener = await characters.createCharacter(gast, { name: 'Gasts Alter' }, ctx); + await credits.bookRedesignPackage(gast, ctx); + await characters.addMemberToChain( + zwei.character.id, + gast, + { useKind: 'REDESIGN_PAID', predecessorId: eigener.character.id }, + ctx, + ); + + const vorher = new Map( + ( + await db.prisma.characterMember.findMany({ + select: { id: true, lineageId: true, chainPosition: true }, + }) + ).map((m) => [m.id, m]), + ); + + // Zurück auf den Stand vor der Umstellung. + await db.prisma.$executeRawUnsafe( + 'ALTER TABLE "CharacterMember" ALTER COLUMN "lineageId" DROP NOT NULL', + ); + await db.prisma.$executeRawUnsafe( + 'UPDATE "CharacterMember" SET "lineageId" = NULL, "chainPosition" = 0', + ); + + await migrationAusfuehren(); + + const danach = await db.prisma.characterMember.findMany({ + where: { playerId: gast }, + orderBy: { chainPosition: 'asc' }, + select: { characterId: true, lineageId: true, character: { select: { name: true } } }, + }); + + assert.deepEqual( + danach.map((m) => m.character.name), + ['Gasts Alter', 'Alt-Zwei', 'Alt-Drei'], + 'der Gast führt seinen alten CC und die Glieder, ab denen er dabei war', + ); + assert.equal( + new Set(danach.map((m) => m.lineageId)).size, + 1, + 'und zwar in einer einzigen Liste', + ); + + // Für den Wirt steht die Kette wieder so, wie sie war. + const wirtDanach = await db.prisma.characterMember.findMany({ + where: { playerId: wirt }, + orderBy: { chainPosition: 'asc' }, + select: { id: true, lineageId: true, chainPosition: true }, }); - assert.equal(buchung?.kind, 'CC'); + for (const m of wirtDanach) { + assert.deepEqual( + { lineageId: m.lineageId, chainPosition: m.chainPosition }, + { + lineageId: vorher.get(m.id)?.lineageId, + chainPosition: vorher.get(m.id)?.chainPosition, + }, + ); + } + + assert.equal( + await characters.richteBuchungenAus(), + 0, + 'und die Buchungen passen danach zu den Listen', + ); + + // Der Gast zahlt für seinen Einstieg ein Redesign, nicht einen Platz. + assert.equal( + ( + await db.prisma.creditLedger.findFirst({ + where: { + characterId: zwei.character.id, + playerId: gast, + source: 'CONSUME', + reversedBy: null, + }, + }) + )?.kind, + 'REDESIGN_PAID', + ); + assert.ok(drei.character.id > 0); }); }); diff --git a/web/src/components/ChainView.tsx b/web/src/components/ChainView.tsx index fa8ef21..bb8781b 100644 --- a/web/src/components/ChainView.tsx +++ b/web/src/components/ChainView.tsx @@ -219,7 +219,10 @@ export default function ChainView({ if (!ja) return; setMeldung(null); try { - await api(`/api/characters/${character.id}/detach`, { method: 'POST', body: {} }); + await api(`/api/characters/${character.id}/detach`, { + method: 'POST', + body: { forPlayerId: playerId }, + }); onMoved(); } catch (err) { setMeldung(err instanceof ApiError ? err.message : 'Herauslösen fehlgeschlagen'); diff --git a/web/src/components/ThreadModals.tsx b/web/src/components/ThreadModals.tsx index be8d117..89067c7 100644 --- a/web/src/components/ThreadModals.tsx +++ b/web/src/components/ThreadModals.tsx @@ -26,7 +26,10 @@ export function ClassifyModal({ const [name, setName] = useState(thread.title); const [predecessorId, setPredecessorId] = useState(null); const [reviveId, setReviveId] = useState(null); - const [useKind, setUseKind] = useState<'REDESIGN_KULANZ' | 'REDESIGN_PAID'>('REDESIGN_KULANZ'); + // Der Regelfall ist das bezahlte Redesign. Kulanz ist die Ausnahme und + // wird bewusst vergeben - sonst entstehen Kulanz-Buchungen, die niemand + // gewollt hat. + const [useKind, setUseKind] = useState<'REDESIGN_KULANZ' | 'REDESIGN_PAID'>('REDESIGN_PAID'); const [characters, setCharacters] = useState([]); const [inactive, setInactive] = useState([]); const [busy, setBusy] = useState(false); @@ -167,19 +170,19 @@ export function ClassifyModal({ setUseKind('REDESIGN_KULANZ')} + checked={useKind === 'REDESIGN_PAID'} + onChange={() => setUseKind('REDESIGN_PAID')} /> - Kulanz-Redesign + Bezahltes Redesign @@ -213,19 +216,19 @@ export function ClassifyModal({ setUseKind('REDESIGN_KULANZ')} + checked={useKind === 'REDESIGN_PAID'} + onChange={() => setUseKind('REDESIGN_PAID')} /> - Kulanz-Redesign + Bezahltes Redesign diff --git a/web/src/lib/format.ts b/web/src/lib/format.ts index 0014484..42bb410 100644 --- a/web/src/lib/format.ts +++ b/web/src/lib/format.ts @@ -13,6 +13,7 @@ export const SOURCE_LABEL: Record = { CC_GRANT: 'CC-Guthaben (von Hand)', KULANZ: 'Kulanz', ADJUSTMENT: 'Korrektur', + CONVERSION: 'Umwandlung', CONSUME: 'Verbrauch', REVERSAL: 'Storno', }; diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 00db94b..ec81524 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -6,6 +6,7 @@ export type CreditSource = | 'CC_GRANT' | 'KULANZ' | 'ADJUSTMENT' + | 'CONVERSION' | 'CONSUME' | 'REVERSAL'; export type CharacterStatus = 'ACTIVE' | 'INACTIVE'; diff --git a/web/src/pages/PlayerDetail.tsx b/web/src/pages/PlayerDetail.tsx index 528011c..d1061f7 100644 --- a/web/src/pages/PlayerDetail.tsx +++ b/web/src/pages/PlayerDetail.tsx @@ -601,7 +601,7 @@ export default function PlayerDetail() { submitLabel="Redesign durchführen" hint={`"${dialog.character.name}" wird inaktiv (redesignt), der neue CC tritt an seine Stelle.`} busy={busy} - withKind={['REDESIGN_KULANZ', 'REDESIGN_PAID', 'CC']} + withKind={['REDESIGN_PAID', 'REDESIGN_KULANZ', 'CC']} balances={detail.balances} onClose={() => setDialog({ type: 'none' })} onSubmit={(values) => @@ -666,7 +666,7 @@ export default function PlayerDetail() { submitLabel="Redesign durchführen" hint={`${playerName} steigt aus "${dialog.character.name}" aus und bekommt dafür einen eigenen CC. Die übrigen Nutzer bleiben unberührt. Ein Redesign tauscht einen Platz, es kommt keiner hinzu.`} busy={busy} - withKind={['REDESIGN_KULANZ', 'REDESIGN_PAID', 'CC']} + withKind={['REDESIGN_PAID', 'REDESIGN_KULANZ', 'CC']} balances={detail.balances} onClose={() => setDialog({ type: 'none' })} onSubmit={(values) => @@ -1069,6 +1069,12 @@ function CharacterFormModal({ ))} + {useKind === 'CC' && ( +

+ Ein CC-Guthaben für ein Redesign wird umgewandelt: ein Platz weniger, ein + bezahltes Redesign mehr. +

+ )} )}