From b93ff2fb0eb0efb6644a0cf35ae18ba84020a926 Mon Sep 17 00:00:00 2001 From: Harlekin Date: Tue, 22 Sep 2026 14:31:29 +0200 Subject: [PATCH] Ein Eintrag kostet genau einmal, die Rueckkehr ist ein weiteres Glied An einem Eintrag konnten zwei Buchungen haengen - etwa Kulanz und ein gekauftes Redesign nebeneinander -, und aufloesen liess sich das nirgends. Jetzt raeumt sowohl das Nachrechnen als auch das Umstellen der Bezahlart auf: es bleibt die aelteste passende, der Rest wird storniert. Die Rueckkehr eines redesignten oder gestorbenen CCs bucht deshalb nicht mehr zusaetzlich, sondern erschafft ihn noch einmal: derselbe CC als weiteres Glied ueber dem alten, und das kostet ein Redesign wie jedes andere. Mit einem CC-Guthaben beginnt er stattdessen eine eigene Liste und kann neben seinem Nachfolger laufen. Ein entzogener Platz nimmt seine Buchung mit - sonst waere die Rueckkehr umsonst oder stuende als zweite Buchung am selben Eintrag. --- server/src/services/characters.ts | 277 ++++++++++++++++++++++++------ server/test/gruppen.test.ts | 10 +- server/test/ketten.test.ts | 83 +++++++++ 3 files changed, 308 insertions(+), 62 deletions(-) diff --git a/server/src/services/characters.ts b/server/src/services/characters.ts index 24bda27..cde1750 100644 --- a/server/src/services/characters.ts +++ b/server/src/services/characters.ts @@ -421,6 +421,112 @@ export async function reactivateFree( return { character, balances, overdrawn: isOverdrawn(balances) }; } +/** + * Holt einen CC zurück, indem er noch einmal erschaffen wird. + * + * Ein redesignter oder gestorbener CC existiert im Original nicht mehr. + * Seine Rückkehr ist deshalb ein weiteres Glied in der Liste: derselbe CC, + * noch einmal - und das kostet wie jedes andere Glied. Der alte Eintrag + * bleibt stehen, damit sichtbar bleibt, was war. + * + * Wer dafür ein CC-Guthaben einsetzt, bekommt keinen Nachfolger, sondern + * einen zweiten Platz: der neue Eintrag beginnt eine eigene Liste und kann + * damit neben seinem Nachfolger laufen. + */ +async function erschaffeNeu( + tx: Prisma.TransactionClient, + alt: Character, + useKind: ReviveKind, + ctx: ActionContext, + note: string, +): Promise { + const eigenerPlatz = useKind === CreditKind.CC; + + const bisherige = await tx.characterMember.findMany({ + where: { characterId: alt.id, leftAt: null }, + select: { + playerId: true, + paidWith: true, + lineageId: true, + chainPosition: true, + }, + }); + + const eigeneListe = randomUUID(); + const platzVon = (mitglied: { lineageId: string; chainPosition: number }) => + eigenerPlatz + ? { lineageId: randomUUID(), chainPosition: 0 } + : { lineageId: mitglied.lineageId, chainPosition: mitglied.chainPosition + 10 }; + + const besitzer = bisherige.find((m) => m.playerId === alt.playerId); + const platz = besitzer + ? platzVon(besitzer) + : { lineageId: eigeneListe, chainPosition: 0 }; + + const neu = await tx.character.create({ + data: { + playerId: alt.playerId, + name: alt.name, + loreText: alt.loreText, + status: CharacterStatus.ACTIVE, + lineageId: platz.lineageId, + chainPosition: platz.chainPosition, + ...(eigenerPlatz ? {} : { predecessorId: alt.id }), + createdById: ctx.actorId, + members: { + create: bisherige.map((mitglied) => { + const eigen = platzVon(mitglied); + return { + playerId: mitglied.playerId, + paidWith: mitglied.paidWith, + lineageId: + mitglied.playerId === alt.playerId ? platz.lineageId : eigen.lineageId, + chainPosition: + mitglied.playerId === alt.playerId + ? platz.chainPosition + : eigen.chainPosition, + ...(eigenerPlatz ? {} : { predecessorId: alt.id }), + createdById: ctx.actorId, + }; + }), + }, + }, + }); + + // Der alte Eintrag ist damit ersetzt - so wie bei jedem Redesign. + await tx.character.update({ + where: { id: alt.id }, + data: { + status: CharacterStatus.INACTIVE, + inactiveReason: InactiveReason.REWORKED, + }, + }); + + await bucheVerbrauch(tx, { + playerId: alt.playerId, + characterId: neu.id, + kind: useKind, + redesign: !eigenerPlatz, + note, + actorId: ctx.actorId, + }); + + // Bei einem Gruppen-CC zählt der Eintrag für jeden Nutzer einzeln. + for (const mitglied of bisherige) { + if (mitglied.playerId === alt.playerId) continue; + await bucheVerbrauch(tx, { + playerId: mitglied.playerId, + characterId: neu.id, + kind: eigenerPlatz ? CreditKind.CC : CreditKind.REDESIGN_PAID, + redesign: !eigenerPlatz, + note, + actorId: ctx.actorId, + }); + } + + return neu; +} + /** * Bezahlte Reaktivierung, ausschließlich für redesignte CCs: deren Original * existiert nicht mehr, es muss also neu erschaffen werden. Kostet ein @@ -461,39 +567,30 @@ export async function reactivatePaid( } const character = await prisma.$transaction(async (tx) => { - const updated = await tx.character.update({ - where: { id: characterId }, - data: { - status: CharacterStatus.ACTIVE, - inactiveReason: null, - inactiveNote: null, - }, - }); + const neu = await erschaffeNeu( + tx, + existing, + useKind, + ctx, + note ?? `Rückkehr: ${existing.name}`, + ); - await bucheVerbrauch(tx, { - playerId: updated.playerId, - characterId: updated.id, - kind: useKind, - redesign: null, - note: note ?? `Reaktivierung: ${updated.name}`, - actorId: ctx.actorId, - }); - - await assertSlotsReichen(tx, updated.playerId, "Die Rückkehr dieses CCs"); + await assertSlotsReichen(tx, neu.playerId, "Die Rückkehr dieses CCs"); await recordAudit(tx, ctx, { action: "character.reactivate_paid", targetType: "character", - targetId: characterId, + targetId: neu.id, details: { - playerId: updated.playerId, - name: updated.name, + playerId: neu.playerId, + name: neu.name, useKind, + previousId: existing.id, previousReason: existing.inactiveReason, }, }); - return updated; + return neu; }); const balances = await getBalances(character.playerId); @@ -838,37 +935,39 @@ export async function reviveDeadCharacter( } const character = await prisma.$transaction(async (tx) => { - const updated = await tx.character.update({ - where: { id: characterId }, - data: { - status: CharacterStatus.ACTIVE, - inactiveReason: null, - inactiveNote: null, - }, - }); - - if (input.useKind !== undefined) { - await bucheVerbrauch(tx, { - playerId: updated.playerId, - characterId: updated.id, - kind: input.useKind, - redesign: null, - note: `Rückkehr nach Tod: ${input.reason}`, - actorId: ctx.actorId, - }); - } + // Kostet die Rückkehr etwas, ist sie ein weiteres Glied: derselbe CC, + // noch einmal. Gratis kommt er dagegen unverändert zurück - dann hat + // sich an der Liste nichts geändert. + const updated = + input.useKind === undefined + ? await tx.character.update({ + where: { id: characterId }, + data: { + status: CharacterStatus.ACTIVE, + inactiveReason: null, + inactiveNote: null, + }, + }) + : await erschaffeNeu( + tx, + existing, + input.useKind, + ctx, + `Rückkehr nach Tod: ${input.reason}`, + ); await assertSlotsReichen(tx, updated.playerId, "Die Rückkehr dieses CCs"); await recordAudit(tx, ctx, { action: "character.revive_dead", targetType: "character", - targetId: characterId, + targetId: updated.id, details: { playerId: updated.playerId, name: updated.name, reason: input.reason, useKind: input.useKind ?? null, + previousId: existing.id, previousNote: existing.inactiveNote, }, }); @@ -906,7 +1005,7 @@ export async function changeFunding( // Die Buchung, mit der dieser Spieler seinen Platz bezahlt hat. Bei einem // Gruppen-CC hat jeder Nutzer seine eigene; stornierte zählen nicht mehr. - const buchung = await prisma.creditLedger.findFirst({ + const offene = await prisma.creditLedger.findMany({ where: { characterId, playerId: spieler, @@ -915,6 +1014,7 @@ export async function changeFunding( }, orderBy: { id: "desc" }, }); + const buchung = offene[0]; if (!buchung) { throw conflict( "NO_BOOKING", @@ -938,7 +1038,9 @@ export async function changeFunding( "Redesign um, dann steht es hier zur Verfügung.", ); } - if (buchung.kind === useKind) { + // Bei mehr als einer Buchung ist das Umstellen zugleich das Aufräumen: + // ein Eintrag kostet genau einmal. + if (offene.length === 1 && buchung.kind === useKind) { throw conflict("SAME_KIND", "Dieser CC ist bereits so gebucht"); } @@ -948,7 +1050,9 @@ export async function changeFunding( : "Umgestellt auf bezahltes Redesign"; await prisma.$transaction(async (tx) => { - await storniere(tx, buchung, `${grund}: ${character.name}`, ctx.actorId); + for (const eintrag of offene) { + await storniere(tx, eintrag, `${grund}: ${character.name}`, ctx.actorId); + } await tx.creditLedger.create({ data: { @@ -1041,6 +1145,21 @@ async function bucheVerbrauch( ): Promise { const ausCcTopf = params.kind === CreditKind.CC; + // Ein Eintrag kostet genau einmal. Steht seine Buchung schon da - etwa bei + // der Rückkehr eines CCs, der einmal bezahlt wurde -, kommt keine zweite + // dazu. Ob der Platz für zwei gleichzeitig aktive CCs reicht, entscheidet + // die Slot-Prüfung. + const schonBezahlt = await tx.creditLedger.findFirst({ + where: { + characterId: params.characterId, + playerId: params.playerId, + source: CreditSource.CONSUME, + reversedBy: null, + }, + select: { id: true }, + }); + if (schonBezahlt !== null) return; + if (params.redesign === true && ausCcTopf) { throw conflict( "CONVERT_FIRST", @@ -1165,18 +1284,38 @@ async function buchungFolgtPosition( return true; } - // Steht schon etwas da, das zur Stelle passt, bleibt alles, wie es ist. - // An einem Eintrag kann mehr als eine Buchung hängen - eine Rückkehr - // kostet zusätzlich, und das soll sie auch. - if (offene.some((eintrag) => (eintrag.kind === CreditKind.CC) === sollCC)) - return false; + // Ein Glied kostet genau einmal. Steht mehr als eine Buchung daran, ist + // eine davon zu viel - die überzähligen werden zurückgenommen, sonst + // zahlt jemand doppelt für denselben Eintrag. + // Es bleibt die älteste passende: sie gehört zu dem Vorgang, aus dem der + // Eintrag entstanden ist. Was später dazukam, ist die Dublette. + const passend = offene + .filter((eintrag) => (eintrag.kind === CreditKind.CC) === sollCC) + .sort((a, b) => a.id - b.id); + const behalten = passend[0] ?? null; - // 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. + let geaendert = false; + for (const eintrag of offene) { + // Die eine, die bleibt, und - wenn keine passt - die jüngste, die + // gleich umgebucht wird. + if (eintrag.id === (behalten?.id ?? offen.id)) continue; + await storniere( + tx, + eintrag, + `Doppelt gebucht - Storno: ${glied.name}`, + actorId, + ); + geaendert = true; + } + + if (behalten !== null) return geaendert; + + // Keine passende dabei: Ein Eintrag, der in eine Liste 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 Liste: aus einem CC-Paket bezahlt" + : "Steht jetzt in einer Liste: als Redesign gebucht"; await storniere(tx, offen, `${grund} - Storno: ${glied.name}`, actorId); @@ -2348,6 +2487,30 @@ export async function deactivateMember( }, }); + // Ein entzogener Platz ist weg - seine Buchung auch. Nur so kostet die + // Rückkehr wieder etwas, ohne dass am selben Eintrag zwei Buchungen + // stehen. Wer geputzt wurde oder im Rollenspiel gestorben ist, behält + // seine Buchung: sein Platz ist bezahlt, die Rückkehr entscheidet ein + // Administrator. + if (reason === InactiveReason.NO_FREE_CLAIM) { + const offene = await tx.creditLedger.findMany({ + where: { + characterId, + playerId, + source: CreditSource.CONSUME, + reversedBy: null, + }, + }); + for (const eintrag of offene) { + await storniere( + tx, + eintrag, + `Platz nicht mehr genutzt - Storno: ${mitglied.character.name}`, + ctx.actorId, + ); + } + } + await recordAudit(tx, ctx, { action: "character.member_deactivated", targetType: "character", @@ -2436,7 +2599,9 @@ export async function reactivateMember( playerId, characterId, kind: input.useKind, - redesign: null, + // Auch hier entscheidet die Stelle in seiner Liste: steht der CC + // dort unten, braucht die Rückkehr einen Platz. + redesign: mitglied.predecessorId !== null, note: input.reason ?? `Rückkehr zu "${gruppe.name}"`, actorId: ctx.actorId, }); diff --git a/server/test/gruppen.test.ts b/server/test/gruppen.test.ts index 1f0b02d..fe4a60b 100644 --- a/server/test/gruppen.test.ts +++ b/server/test/gruppen.test.ts @@ -682,14 +682,12 @@ describe('Einzelne Mitglieder fallen aus', () => { (error: Error & { code?: string }) => error.code === 'NEED_KIND', ); + // Sein Platz war entzogen, also kostet die Rückkehr wieder einen - in + // seiner Liste steht der CC ganz unten. const vorher = await credits.getBalances(partner); - await characters.reactivateMember(character.id, partner, { useKind: 'REDESIGN_PAID' }, ctx); + await characters.reactivateMember(character.id, partner, { useKind: 'CC' }, ctx); - assert.equal( - (await credits.getBalances(partner)).REDESIGN_PAID, - vorher.REDESIGN_PAID - 1, - 'bezahlt', - ); + assert.equal((await credits.getBalances(partner)).CC, vorher.CC - 1, 'bezahlt'); }); it('verlangt für die Rückkehr eines Gestorbenen eine Begründung', async () => { diff --git a/server/test/ketten.test.ts b/server/test/ketten.test.ts index 5c0e2c6..cef49cc 100644 --- a/server/test/ketten.test.ts +++ b/server/test/ketten.test.ts @@ -1383,3 +1383,86 @@ describe('Die Umstellung auf Listen je Nutzer', () => { assert.ok(drei.character.id > 0); }); }); + +describe('Zwei Buchungen an einem Eintrag', () => { + /// Die offenen Verbrauchsbuchungen eines Spielers an einem CC. + async function offene(characterId: number, playerId: number): Promise { + const zeilen = await db.prisma.creditLedger.findMany({ + where: { characterId, playerId, source: 'CONSUME', reversedBy: null }, + orderBy: { id: 'asc' }, + select: { kind: true }, + }); + return zeilen.map((z) => z.kind); + } + + /** Der Fall aus dem Bestand: an einem Redesign hängen Kulanz und Kauf. */ + async function doppeltGebucht(name: string) { + const spieler = await spielerMitPaketen(name, 1); + const eins = await characters.createCharacter(spieler, { name: `${name}-Anfang` }, ctx); + await credits.bookRedesignPackage(spieler, ctx); + const zwei = await characters.redesignCharacter( + eins.character.id, + { name: `${name}-Zweites`, useKind: 'REDESIGN_PAID' }, + ctx, + ); + + await credits.bookKulanz(spieler, ctx); + await db.prisma.creditLedger.create({ + data: { + playerId: spieler, + kind: 'REDESIGN_KULANZ', + amount: -1, + source: 'CONSUME', + characterId: zwei.character.id, + }, + }); + + assert.deepEqual(await offene(zwei.character.id, spieler), [ + 'REDESIGN_PAID', + 'REDESIGN_KULANZ', + ]); + return { spieler, zwei: zwei.character.id }; + } + + it('räumt das Nachrechnen wieder auf', async () => { + const { spieler, zwei } = await doppeltGebucht('Doppelt'); + const vorher = await credits.getBalances(spieler); + + const ergebnis = await characters.rebuildFunding(spieler, ctx); + + assert.equal(ergebnis.umgebucht, 1, 'eine Buchung war zu viel'); + assert.deepEqual( + await offene(zwei, spieler), + ['REDESIGN_PAID'], + 'ein Eintrag kostet genau einmal', + ); + assert.equal( + ergebnis.balances.REDESIGN_KULANZ, + vorher.REDESIGN_KULANZ + 1, + 'die Kulanz ist wieder frei', + ); + }); + + it('lässt sich auch über die Bezahlart auflösen', async () => { + const { spieler, zwei } = await doppeltGebucht('Umstellen'); + + const vorher = await credits.getBalances(spieler); + const danach = await characters.changeFunding(zwei, 'REDESIGN_KULANZ', ctx); + + assert.deepEqual( + await offene(zwei, spieler), + ['REDESIGN_KULANZ'], + 'umstellen ersetzt alles, was dort stand', + ); + assert.equal( + danach.balances.REDESIGN_PAID, + vorher.REDESIGN_PAID + 1, + 'das bezahlte Redesign ist wieder frei', + ); + assert.equal( + danach.balances.REDESIGN_KULANZ, + vorher.REDESIGN_KULANZ, + 'die Kulanz bleibt verbraucht - nur eben einmal', + ); + }); +});