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. +

+ )} )}