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.
This commit is contained in:
2026-09-22 14:31:29 +02:00
parent 367f1aace4
commit b93ff2fb0e
3 changed files with 308 additions and 62 deletions
+221 -56
View File
@@ -421,6 +421,112 @@ export async function reactivateFree(
return { character, balances, overdrawn: isOverdrawn(balances) }; 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<Character> {
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 * Bezahlte Reaktivierung, ausschließlich für redesignte CCs: deren Original
* existiert nicht mehr, es muss also neu erschaffen werden. Kostet ein * 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 character = await prisma.$transaction(async (tx) => {
const updated = await tx.character.update({ const neu = await erschaffeNeu(
where: { id: characterId }, tx,
data: { existing,
status: CharacterStatus.ACTIVE, useKind,
inactiveReason: null, ctx,
inactiveNote: null, note ?? `Rückkehr: ${existing.name}`,
}, );
});
await bucheVerbrauch(tx, { await assertSlotsReichen(tx, neu.playerId, "Die Rückkehr dieses CCs");
playerId: updated.playerId,
characterId: updated.id,
kind: useKind,
redesign: null,
note: note ?? `Reaktivierung: ${updated.name}`,
actorId: ctx.actorId,
});
await assertSlotsReichen(tx, updated.playerId, "Die Rückkehr dieses CCs");
await recordAudit(tx, ctx, { await recordAudit(tx, ctx, {
action: "character.reactivate_paid", action: "character.reactivate_paid",
targetType: "character", targetType: "character",
targetId: characterId, targetId: neu.id,
details: { details: {
playerId: updated.playerId, playerId: neu.playerId,
name: updated.name, name: neu.name,
useKind, useKind,
previousId: existing.id,
previousReason: existing.inactiveReason, previousReason: existing.inactiveReason,
}, },
}); });
return updated; return neu;
}); });
const balances = await getBalances(character.playerId); const balances = await getBalances(character.playerId);
@@ -838,37 +935,39 @@ export async function reviveDeadCharacter(
} }
const character = await prisma.$transaction(async (tx) => { const character = await prisma.$transaction(async (tx) => {
const updated = await tx.character.update({ // Kostet die Rückkehr etwas, ist sie ein weiteres Glied: derselbe CC,
where: { id: characterId }, // noch einmal. Gratis kommt er dagegen unverändert zurück - dann hat
data: { // sich an der Liste nichts geändert.
status: CharacterStatus.ACTIVE, const updated =
inactiveReason: null, input.useKind === undefined
inactiveNote: null, ? await tx.character.update({
}, where: { id: characterId },
}); data: {
status: CharacterStatus.ACTIVE,
if (input.useKind !== undefined) { inactiveReason: null,
await bucheVerbrauch(tx, { inactiveNote: null,
playerId: updated.playerId, },
characterId: updated.id, })
kind: input.useKind, : await erschaffeNeu(
redesign: null, tx,
note: `Rückkehr nach Tod: ${input.reason}`, existing,
actorId: ctx.actorId, input.useKind,
}); ctx,
} `Rückkehr nach Tod: ${input.reason}`,
);
await assertSlotsReichen(tx, updated.playerId, "Die Rückkehr dieses CCs"); await assertSlotsReichen(tx, updated.playerId, "Die Rückkehr dieses CCs");
await recordAudit(tx, ctx, { await recordAudit(tx, ctx, {
action: "character.revive_dead", action: "character.revive_dead",
targetType: "character", targetType: "character",
targetId: characterId, targetId: updated.id,
details: { details: {
playerId: updated.playerId, playerId: updated.playerId,
name: updated.name, name: updated.name,
reason: input.reason, reason: input.reason,
useKind: input.useKind ?? null, useKind: input.useKind ?? null,
previousId: existing.id,
previousNote: existing.inactiveNote, previousNote: existing.inactiveNote,
}, },
}); });
@@ -906,7 +1005,7 @@ export async function changeFunding(
// Die Buchung, mit der dieser Spieler seinen Platz bezahlt hat. Bei einem // Die Buchung, mit der dieser Spieler seinen Platz bezahlt hat. Bei einem
// Gruppen-CC hat jeder Nutzer seine eigene; stornierte zählen nicht mehr. // 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: { where: {
characterId, characterId,
playerId: spieler, playerId: spieler,
@@ -915,6 +1014,7 @@ export async function changeFunding(
}, },
orderBy: { id: "desc" }, orderBy: { id: "desc" },
}); });
const buchung = offene[0];
if (!buchung) { if (!buchung) {
throw conflict( throw conflict(
"NO_BOOKING", "NO_BOOKING",
@@ -938,7 +1038,9 @@ export async function changeFunding(
"Redesign um, dann steht es hier zur Verfügung.", "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"); throw conflict("SAME_KIND", "Dieser CC ist bereits so gebucht");
} }
@@ -948,7 +1050,9 @@ export async function changeFunding(
: "Umgestellt auf bezahltes Redesign"; : "Umgestellt auf bezahltes Redesign";
await prisma.$transaction(async (tx) => { 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({ await tx.creditLedger.create({
data: { data: {
@@ -1041,6 +1145,21 @@ async function bucheVerbrauch(
): Promise<void> { ): Promise<void> {
const ausCcTopf = params.kind === CreditKind.CC; 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) { if (params.redesign === true && ausCcTopf) {
throw conflict( throw conflict(
"CONVERT_FIRST", "CONVERT_FIRST",
@@ -1165,18 +1284,38 @@ async function buchungFolgtPosition(
return true; return true;
} }
// Steht schon etwas da, das zur Stelle passt, bleibt alles, wie es ist. // Ein Glied kostet genau einmal. Steht mehr als eine Buchung daran, ist
// An einem Eintrag kann mehr als eine Buchung hängen - eine Rückkehr // eine davon zu viel - die überzähligen werden zurückgenommen, sonst
// kostet zusätzlich, und das soll sie auch. // zahlt jemand doppelt für denselben Eintrag.
if (offene.some((eintrag) => (eintrag.kind === CreditKind.CC) === sollCC)) // Es bleibt die älteste passende: sie gehört zu dem Vorgang, aus dem der
return false; // 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: let geaendert = false;
// das Guthaben kommt zurück, dafür geht ein Redesign weg. Umgekehrt for (const eintrag of offene) {
// genauso. // 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 const grund = sollCC
? "Steht jetzt am Anfang der Kette: aus einem CC-Paket bezahlt" ? "Steht jetzt am Anfang der Liste: aus einem CC-Paket bezahlt"
: "Steht jetzt in einer Kette: als Redesign gebucht"; : "Steht jetzt in einer Liste: als Redesign gebucht";
await storniere(tx, offen, `${grund} - Storno: ${glied.name}`, actorId); 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, { await recordAudit(tx, ctx, {
action: "character.member_deactivated", action: "character.member_deactivated",
targetType: "character", targetType: "character",
@@ -2436,7 +2599,9 @@ export async function reactivateMember(
playerId, playerId,
characterId, characterId,
kind: input.useKind, 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}"`, note: input.reason ?? `Rückkehr zu "${gruppe.name}"`,
actorId: ctx.actorId, actorId: ctx.actorId,
}); });
+4 -6
View File
@@ -682,14 +682,12 @@ describe('Einzelne Mitglieder fallen aus', () => {
(error: Error & { code?: string }) => error.code === 'NEED_KIND', (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); 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( assert.equal((await credits.getBalances(partner)).CC, vorher.CC - 1, 'bezahlt');
(await credits.getBalances(partner)).REDESIGN_PAID,
vorher.REDESIGN_PAID - 1,
'bezahlt',
);
}); });
it('verlangt für die Rückkehr eines Gestorbenen eine Begründung', async () => { it('verlangt für die Rückkehr eines Gestorbenen eine Begründung', async () => {
+83
View File
@@ -1383,3 +1383,86 @@ describe('Die Umstellung auf Listen je Nutzer', () => {
assert.ok(drei.character.id > 0); 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<string[]> {
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',
);
});
});