Ketten mit Vorgeschichte: unterstes Glied ist nicht immer ein Kauf

Ein Spieler mit drei Paketen und drei Plaetzen stand bei "-1 von 3". Der
dritte Platz kam aus einem fremden Gruppen-CC: beigetreten mit einem
CC-Guthaben, spaeter per Redesign ausgestiegen und als eigener CC
weitergefuehrt. Damit beginnt zwar eine neue Kette, aber kein neuer Platz -
bezahlt ist er laengst, naemlich mit dem Beitritt.

Die Automatik sah nur "unterstes Glied" und buchte es auf CC-Paket um. Der
Platz stand damit zweimal im Konto, einmal fuer den Beitritt und einmal fuer
den eigenen CC. Jetzt zaehlt, ob der Kette etwas vorausging: Zeigt ihr erstes
Glied auf einen Vorgaenger ausserhalb, ist auch dieses Glied ein Redesign.

Dabei fiel ein zweiter Fehler auf: Das Neuverketten loeschte genau diesen
Verweis, weil es die Verkettung innerhalb der Linie normalisierte. Damit ging
die einzige Spur zur Herkunft verloren - und mit ihr der Eintrag "war dabei"
am Fuss der Kette. Ein Vorgaenger ausserhalb bleibt jetzt stehen.

Fuer die Korrektur bereits falsch gebuchter Eintraege kann "Bezahlart" jetzt
auch zwischen CC-Paket und Redesign wechseln, nicht nur zwischen bezahlt und
Kulanz. Der Knopf steht an jedem Eintrag, nicht nur an Redesigns.
This commit is contained in:
2026-09-21 19:07:29 +02:00
parent f7b48a9891
commit 1b643cb758
6 changed files with 180 additions and 56 deletions
+102 -8
View File
@@ -448,7 +448,7 @@ describe('Redesign nachträglich umbuchen', () => {
assert.equal(zwei.balances.REDESIGN_PAID, 0, 'das bezahlte ist verbraucht');
await credits.bookKulanz(spieler, 1, 'Absprache aufgetaucht', ctx);
const danach = await characters.changeRedesignKind(zwei.character.id, 'REDESIGN_KULANZ', ctx);
const danach = await characters.changeFunding(zwei.character.id, 'REDESIGN_KULANZ', ctx);
assert.equal(danach.balances.REDESIGN_PAID, 1, 'das bezahlte ist wieder frei');
assert.equal(danach.balances.REDESIGN_KULANZ, 0, 'dafür ist die Kulanz verbraucht');
@@ -473,7 +473,7 @@ describe('Redesign nachträglich umbuchen', () => {
ctx,
);
const danach = await characters.changeRedesignKind(zwei.character.id, 'REDESIGN_PAID', ctx);
const danach = await characters.changeFunding(zwei.character.id, 'REDESIGN_PAID', ctx);
assert.equal(danach.balances.REDESIGN_KULANZ, 1);
assert.equal(danach.balances.REDESIGN_PAID, 0, 'das aus dem CC-Paket ist jetzt dran');
});
@@ -488,19 +488,26 @@ describe('Redesign nachträglich umbuchen', () => {
);
await assert.rejects(
() => characters.changeRedesignKind(zwei.character.id, 'REDESIGN_PAID', ctx),
() => characters.changeFunding(zwei.character.id, 'REDESIGN_PAID', ctx),
(error: Error & { code?: string }) => error.code === 'SAME_KIND',
);
});
it('weist einen CC ohne Redesign-Buchung ab', async () => {
const spieler = await spielerMitPaketen('Ohne Redesign', 1);
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.
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.changeRedesignKind(eins.character.id, 'REDESIGN_KULANZ', ctx),
(error: Error & { code?: string }) => error.code === 'NO_REDESIGN_BOOKING',
'ein CC aus dem Paket ist kein Redesign',
() => 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',
);
});
});
@@ -718,3 +725,90 @@ describe('Die Stelle in der Kette bestimmt, was ein Eintrag kostet', () => {
assert.deepEqual(await credits.getBalances(spieler), vorher, 'nichts gebucht, nichts geändert');
});
});
describe('Eine Kette mit Vorgeschichte ausserhalb', () => {
/**
* Der Fall aus dem Betrieb: Ein Spieler tritt einem fremden Gruppen-CC
* bei, steigt später per Redesign aus und führt seinen eigenen CC weiter.
* Dessen Kette beginnt nicht bei null - sie hat eine Herkunft, die
* woanders liegt.
*/
async function ausgestiegen() {
const dargon = await spielerMitPaketen('Aussteiger', 3);
const wirt = await spielerMitPaketen('Gruppen-Wirt', 1);
await characters.createCharacter(dargon, { name: 'Platz eins' }, ctx);
await characters.createCharacter(dargon, { name: 'Platz zwei' }, ctx);
const fremd = await characters.createCharacter(wirt, { name: 'Fremde Gruppe' }, ctx);
await characters.addMember(fremd.character.id, dargon, { useKind: 'CC' }, ctx);
await credits.bookRedesignPackage(dargon, ctx);
const eigenes = await characters.redesignMembership(
fremd.character.id,
dargon,
{ name: 'Eigener Strang', useKind: 'REDESIGN_PAID' },
ctx,
);
await credits.bookRedesignPackage(dargon, ctx);
const weiter = await characters.redesignCharacter(
eigenes.character.id,
{ name: 'Und weiter', useKind: 'REDESIGN_PAID' },
ctx,
);
return { dargon, eigenes: eigenes.character, weiter: weiter.character };
}
it('zählt drei Plätze und drei verbrauchte Pakete', async () => {
const { dargon } = await ausgestiegen();
const stand = await credits.getBalances(dargon);
assert.equal(stand.granted.CC, 3, 'drei Pakete gekauft');
assert.equal(stand.CC, 0, 'drei Plätze belegt, keiner zu viel');
});
it('bucht das erste Glied einer hergeleiteten Kette nicht auf CC um', async () => {
// Es ist zwar das unterste seiner Kette, aber kein Kauf: es ging aus dem
// Gruppen-CC hervor. Wer das übersieht, bucht einen Platz doppelt.
const { dargon, eigenes, weiter } = await ausgestiegen();
const vorher = await credits.getBalances(dargon);
// Ein Verschieben innerhalb der Kette ordnet neu - und darf dabei nichts
// erfinden.
await characters.moveInChain(
weiter.id,
{ lineageId: eigenes.lineageId, afterId: eigenes.id, forPlayerId: dargon },
ctx,
);
assert.deepEqual(
await credits.getBalances(dargon),
vorher,
'am Konto ändert sich dabei nichts',
);
const buchung = await db.prisma.creditLedger.findFirst({
where: { characterId: eigenes.id, source: 'CONSUME', reversedBy: null },
orderBy: { id: 'desc' },
});
assert.equal(buchung?.kind, 'REDESIGN_PAID', 'es bleibt ein Redesign');
});
it('behält den Verweis auf die Herkunft', async () => {
const { dargon, eigenes, weiter } = await ausgestiegen();
await characters.moveInChain(
weiter.id,
{ lineageId: eigenes.lineageId, afterId: eigenes.id, forPlayerId: dargon },
ctx,
);
const danach = await db.prisma.character.findUniqueOrThrow({ where: { id: eigenes.id } });
assert.ok(
danach.predecessorId,
'sonst wäre nicht mehr zu sehen, woher die Kette kommt',
);
});
});