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
+215 -50
View File
@@ -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<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
* 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({
// 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,
},
});
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,
});
}
})
: 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<void> {
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,
});
+4 -6
View File
@@ -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 () => {
+83
View File
@@ -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<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',
);
});
});