Files
CC-Manager/server/test/ketten.test.ts
T
Harlekin 1b643cb758 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.
2026-09-21 19:07:29 +02:00

815 lines
31 KiB
TypeScript

import assert from 'node:assert/strict';
import { after, before, describe, it } from 'node:test';
import { startTestDatabase, type TestDatabase } from './harness.js';
type Characters = typeof import('../src/services/characters.js');
type Credits = typeof import('../src/services/credits.js');
type Db = typeof import('../src/db.js');
let database: TestDatabase;
let characters: Characters;
let credits: Credits;
let db: Db;
let ctx: { actorId: number; actorName: string };
before(async () => {
database = await startTestDatabase(54340);
process.env.DATABASE_URL = database.url;
process.env.SESSION_SECRET = 'test-secret-that-is-long-enough-000000';
process.env.NODE_ENV = 'test';
db = await import('../src/db.js');
characters = await import('../src/services/characters.js');
credits = await import('../src/services/credits.js');
const user = await db.prisma.user.create({
data: { username: 'ketten', passwordHash: 'x', role: 'ADMIN' },
});
ctx = { actorId: user.id, actorName: user.username };
});
after(async () => {
await db.prisma.$disconnect();
await database.stop();
});
async function spielerMitPaketen(name: string, pakete = 1) {
const player = await db.prisma.player.create({ data: { displayName: name } });
for (let i = 0; i < pakete; i += 1) await credits.bookCcPackage(player.id, ctx);
return player.id;
}
/**
* 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 },
orderBy: [{ chainPosition: 'asc' }, { id: 'asc' }],
select: { id: true, name: true, predecessorId: true, chainPosition: true },
});
return glieder;
}
/** Prüft, dass Positionen und Vorgänger-Verweise dasselbe sagen. */
function verkettungStimmt(glieder: { id: number; predecessorId: number | null }[]): void {
let erwartet: number | null = null;
for (const glied of glieder) {
assert.equal(
glied.predecessorId,
erwartet,
'die Verkettung muss der Reihenfolge folgen, sonst laufen beide Sichten auseinander',
);
erwartet = glied.id;
}
}
describe('Lücken in einer Redesign-Kette schliessen', () => {
it('legt Redesigns hintereinander ab', async () => {
const spieler = await spielerMitPaketen('Kettenbauer', 1);
const eins = await characters.createCharacter(spieler, { name: 'CC 1' }, ctx);
await credits.bookRedesignPackage(spieler, ctx);
const zwei = await characters.redesignCharacter(
eins.character.id,
{ name: 'Redesign 2', useKind: 'REDESIGN_PAID' },
ctx,
);
const glieder = await kette(spieler, zwei.character.lineageId);
assert.deepEqual(glieder.map((g) => g.name), ['CC 1', 'Redesign 2']);
verkettungStimmt(glieder);
});
it('schiebt ein später gefundenes Redesign mitten hinein', async () => {
const spieler = await spielerMitPaketen('Nachtraeger', 2);
const eins = await characters.createCharacter(spieler, { name: 'CC 1' }, ctx);
await credits.bookRedesignPackage(spieler, ctx);
const drei = await characters.redesignCharacter(
eins.character.id,
{ name: 'Redesign 3', useKind: 'REDESIGN_PAID' },
ctx,
);
// Redesign 2 taucht erst jetzt auf - als eigener CC, noch ohne Kette.
const zwei = await characters.createCharacter(spieler, { name: 'Redesign 2' }, ctx);
assert.notEqual(zwei.character.lineageId, drei.character.lineageId);
await characters.moveInChain(
zwei.character.id,
{ lineageId: drei.character.lineageId, afterId: eins.character.id },
ctx,
);
const glieder = await kette(spieler, drei.character.lineageId);
assert.deepEqual(
glieder.map((g) => g.name),
['CC 1', 'Redesign 2', 'Redesign 3'],
'die Lücke ist geschlossen, ohne die übrigen anzufassen',
);
verkettungStimmt(glieder);
});
it('setzt einen Eintrag auch an den Anfang', async () => {
const spieler = await spielerMitPaketen('Vorspann', 2);
const b = await characters.createCharacter(spieler, { name: 'B' }, ctx);
const a = await characters.createCharacter(spieler, { name: 'A' }, ctx);
await characters.moveInChain(
a.character.id,
{ lineageId: b.character.lineageId, afterId: null },
ctx,
);
const glieder = await kette(spieler, b.character.lineageId);
assert.deepEqual(glieder.map((g) => g.name), ['A', 'B']);
verkettungStimmt(glieder);
assert.equal(glieder[0]?.predecessorId, null, 'der Erste hat keinen Vorgänger');
});
it('holt einen Eintrag aus einer Kette in eine andere', async () => {
const spieler = await spielerMitPaketen('Umsortierer', 3);
const ersteKette = await characters.createCharacter(spieler, { name: 'Strang A' }, ctx);
const zweiteKette = await characters.createCharacter(spieler, { name: 'Strang B' }, ctx);
const wanderer = await characters.createCharacter(spieler, { name: 'Wanderer' }, ctx);
await characters.moveInChain(
wanderer.character.id,
{ lineageId: ersteKette.character.lineageId, afterId: ersteKette.character.id },
ctx,
);
assert.equal((await kette(spieler, ersteKette.character.lineageId)).length, 2);
// Doch die andere Kette.
await characters.moveInChain(
wanderer.character.id,
{ lineageId: zweiteKette.character.lineageId, afterId: zweiteKette.character.id },
ctx,
);
const erste = await kette(spieler, ersteKette.character.lineageId);
const zweite = await kette(spieler, zweiteKette.character.lineageId);
assert.deepEqual(erste.map((g) => g.name), ['Strang A'], 'die alte Kette schliesst sich');
assert.deepEqual(zweite.map((g) => g.name), ['Strang B', 'Wanderer']);
verkettungStimmt(erste);
verkettungStimmt(zweite);
});
it('holt einen falsch einsortierten Eintrag wieder heraus', async () => {
// Zwei eigenständige CCs, einer davon versehentlich in die Kette des
// anderen gezogen. Beide sind aus einem eigenen Paket bezahlt.
const spieler = await spielerMitPaketen('Loeser', 2);
const eins = await characters.createCharacter(spieler, { name: 'Bleibt' }, ctx);
const zwei = await characters.createCharacter(spieler, { name: 'Gehoert woanders hin' }, ctx);
await characters.moveInChain(
zwei.character.id,
{ lineageId: eins.character.lineageId, afterId: eins.character.id },
ctx,
);
// In der Kette gilt: wer einen Nachfolger hat, wurde ersetzt.
const eingereiht = await db.prisma.character.findUniqueOrThrow({
where: { id: eins.character.id },
});
assert.equal(eingereiht.inactiveReason, 'REWORKED');
const { lineageId } = await characters.detachFromChain(zwei.character.id, ctx);
assert.notEqual(lineageId, eins.character.lineageId);
const alte = await kette(spieler, eins.character.lineageId);
assert.deepEqual(alte.map((g) => g.name), ['Bleibt']);
// Und ohne Nachfolger ist "redesignt" hinfällig.
const zurueck = await db.prisma.character.findUniqueOrThrow({
where: { id: eins.character.id },
});
assert.equal(zurueck.status, 'ACTIVE');
assert.equal(zurueck.inactiveReason, null);
const allein = await db.prisma.character.findUniqueOrThrow({
where: { id: zwei.character.id },
});
assert.equal(allein.predecessorId, null, 'allein heisst ohne Vorgänger');
});
it('macht aus einem herausgelösten Redesign einen eigenen Platz - und zeigt das Minus', async () => {
// Allein gestellt ist ein Redesign kein Redesign mehr, sondern ein
// eigener CC. Also wird es auch als solcher gebucht. Reicht das Guthaben
// nicht, geht das Konto ins Minus - sichtbar, wie bei jedem Vorab-CC.
const spieler = await spielerMitPaketen('Knapp', 1);
const eins = await characters.createCharacter(spieler, { name: 'Original' }, ctx);
await credits.bookRedesignPackage(spieler, ctx);
const zwei = await characters.redesignCharacter(
eins.character.id,
{ name: 'Neue Fassung', useKind: 'REDESIGN_PAID' },
ctx,
);
await characters.detachFromChain(zwei.character.id, ctx);
const verbrauch = await db.prisma.creditLedger.findFirst({
where: { characterId: zwei.character.id, source: 'CONSUME', reversedBy: null },
orderBy: { id: 'desc' },
});
assert.equal(verbrauch?.kind, 'CC', 'ein eigener Platz kostet ein CC-Paket');
const stand = await credits.getBalances(spieler);
assert.equal(stand.CC, -1, 'dafür fehlt jetzt eines - und das steht sichtbar da');
assert.equal(
stand.REDESIGN_PAID,
2,
'das verbrauchte Redesign kommt zurück - eines aus dem CC-Paket, eines gekauft',
);
});
it('räumt die Redesign-Markierung ab, wenn der Nachfolger wegzieht', async () => {
// Genau der Fall aus dem Betrieb: das Redesign gehörte in eine andere
// Kette. Was zurückbleibt, ist von niemandem mehr ersetzt worden.
const spieler = await spielerMitPaketen('Umsortiert', 2);
const platzA = await characters.createCharacter(spieler, { name: 'Platz A' }, ctx);
const platzB = await characters.createCharacter(spieler, { name: 'Platz B' }, ctx);
await credits.bookRedesignPackage(spieler, ctx);
const redesign = await characters.redesignCharacter(
platzB.character.id,
{ name: 'Redesign von B', useKind: 'REDESIGN_PAID' },
ctx,
);
// B ist jetzt redesignt. Nun stellt sich heraus: das Redesign gehört zu A.
await characters.moveInChain(
redesign.character.id,
{ lineageId: platzA.character.lineageId, afterId: platzA.character.id },
ctx,
);
const b = await db.prisma.character.findUniqueOrThrow({ where: { id: platzB.character.id } });
assert.equal(b.status, 'ACTIVE', 'ohne Nachfolger ist B wieder der aktuelle CC');
assert.equal(b.inactiveReason, null);
const a = await db.prisma.character.findUniqueOrThrow({ where: { id: platzA.character.id } });
assert.equal(a.inactiveReason, 'REWORKED', 'A hat jetzt einen Nachfolger');
});
it('lässt keinen CC in die Kette eines Unbeteiligten', async () => {
const einer = await spielerMitPaketen('Einer', 1);
const anderer = await spielerMitPaketen('Anderer', 1);
const seiner = await characters.createCharacter(einer, { name: 'Seiner' }, ctx);
const fremder = await characters.createCharacter(anderer, { name: 'Fremder' }, ctx);
await assert.rejects(
() =>
characters.moveInChain(
fremder.character.id,
{ lineageId: seiner.character.lineageId, afterId: seiner.character.id },
ctx,
),
(error: Error & { code?: string }) => error.code === 'FOREIGN_CHAIN',
);
});
it('reiht einen Gruppen-CC in die Kette eines seiner Nutzer ein', async () => {
// Ein Gruppen-CC gehört dem, der ihn angelegt hat. Seine Geschichte
// gehört allen, die darin steckten: wer seinen eigenen CC aufgegeben
// hat, um dazuzukommen, ordnet damit seine eigene Kette.
const wirt = await spielerMitPaketen('Gruppen-Wirt', 2);
const gast = await spielerMitPaketen('Gruppen-Gast', 2);
const gruppe = await characters.createCharacter(wirt, { name: 'Die Werkstatt' }, ctx);
await characters.addMember(gruppe.character.id, gast, { useKind: 'CC' }, ctx);
const eigener = await characters.createCharacter(gast, { name: 'Sein Vorläufer' }, ctx);
await characters.moveInChain(
gruppe.character.id,
{
lineageId: eigener.character.lineageId,
afterId: eigener.character.id,
forPlayerId: gast,
},
ctx,
);
const glieder = await kette(gast, eigener.character.lineageId);
assert.deepEqual(
glieder.map((g) => g.name),
['Sein Vorläufer', 'Die Werkstatt'],
'der Gruppen-CC steht jetzt hinter seinem Vorläufer',
);
verkettungStimmt(glieder);
// Und der Vorläufer gilt damit als ersetzt.
const vorlaeufer = await db.prisma.character.findUniqueOrThrow({
where: { id: eigener.character.id },
});
assert.equal(vorlaeufer.inactiveReason, 'REWORKED');
});
it('lässt einen Unbeteiligten auch dann nicht heran', async () => {
const wirt = await spielerMitPaketen('Wirt zwei', 1);
const gast = await spielerMitPaketen('Gast zwei', 1);
const fremder = await spielerMitPaketen('Ganz fremd', 1);
const gruppe = await characters.createCharacter(wirt, { name: 'Geschlossen' }, ctx);
const fremdesCC = await characters.createCharacter(fremder, { name: 'Nicht dabei' }, ctx);
await assert.rejects(
() =>
characters.moveInChain(
fremdesCC.character.id,
{
lineageId: gruppe.character.lineageId,
afterId: gruppe.character.id,
forPlayerId: fremder,
},
ctx,
),
(error: Error & { code?: string }) => error.code === 'FOREIGN_CHAIN',
'wer an der Gruppe nie beteiligt war, ordnet dort auch nichts',
);
// Auch nicht, wenn er sich als jemand anderes ausgibt: der Gast war zwar
// an der Gruppe beteiligt, nicht aber am fremden CC.
await characters.addMember(gruppe.character.id, gast, { useKind: 'CC' }, ctx);
await assert.rejects(
() =>
characters.moveInChain(
fremdesCC.character.id,
{
lineageId: gruppe.character.lineageId,
afterId: gruppe.character.id,
forPlayerId: gast,
},
ctx,
),
(error: Error & { code?: string }) => error.code === 'FOREIGN_CHAIN',
);
});
it('lässt einen CC nicht hinter sich selbst stellen', async () => {
const spieler = await spielerMitPaketen('Schlange', 1);
const cc = await characters.createCharacter(spieler, { name: 'Beisst sich' }, ctx);
await assert.rejects(
() =>
characters.moveInChain(
cc.character.id,
{ lineageId: cc.character.lineageId, afterId: cc.character.id },
ctx,
),
(error: Error & { code?: string }) => error.code === 'SELF_REFERENCE',
);
});
it('macht aus einem eingereihten CC einen redesignten', async () => {
const spieler = await spielerMitPaketen('Unberuehrt', 2);
const aktiv = await characters.createCharacter(spieler, { name: 'Aktiv' }, ctx);
const ziel = await characters.createCharacter(spieler, { name: 'Ziel' }, ctx);
await characters.moveInChain(
aktiv.character.id,
{ lineageId: ziel.character.lineageId, afterId: ziel.character.id },
ctx,
);
const danach = await db.prisma.character.findUniqueOrThrow({
where: { id: aktiv.character.id },
});
assert.equal(danach.status, 'ACTIVE', 'am Ende der Kette bleibt er der aktuelle');
const davor = await db.prisma.character.findUniqueOrThrow({ where: { id: ziel.character.id } });
assert.equal(davor.inactiveReason, 'REWORKED', 'wer einen Nachfolger bekommt, ist ersetzt');
});
});
describe('Verweise an einem CC', () => {
it('hängt einen Verweis an und gibt ihn mit aus', async () => {
const spieler = await spielerMitPaketen('Verweiser', 1);
const cc = await characters.createCharacter(spieler, { name: 'Mit Addon' }, ctx);
await characters.addLink(
cc.character.id,
{ label: 'Ersteller-Addon', url: 'https://steamcommunity.com/sharedfiles/?id=42' },
ctx,
);
const liste = await characters.listCharacters(spieler);
const gefunden = liste.find((c) => c.id === cc.character.id);
assert.deepEqual(
gefunden?.links.map((l) => l.label),
['Ersteller-Addon'],
);
});
it('weist eine Adresse ab, die keine ist', async () => {
const spieler = await spielerMitPaketen('Vorsichtig', 1);
const cc = await characters.createCharacter(spieler, { name: 'Sauber' }, ctx);
for (const url of ['javascript:alert(1)', 'nur-text', 'file:///etc/passwd']) {
await assert.rejects(
() => characters.addLink(cc.character.id, { label: 'Test', url }, ctx),
(error: Error & { code?: string }) => error.code === 'INVALID_URL',
`"${url}" hätte abgewiesen werden müssen`,
);
}
});
it('lässt sich ändern und entfernen', async () => {
const spieler = await spielerMitPaketen('Pfleger', 1);
const cc = await characters.createCharacter(spieler, { name: 'Gepflegt' }, ctx);
const link = await characters.addLink(
cc.character.id,
{ label: 'Alt', url: 'https://example.org/alt' },
ctx,
);
const geaendert = await characters.updateLink(
link.id,
{ label: 'Neu', url: 'https://example.org/neu' },
ctx,
);
assert.equal(geaendert.label, 'Neu');
assert.equal(geaendert.url, 'https://example.org/neu');
await characters.removeLink(link.id, ctx);
assert.equal(await db.prisma.characterLink.count({ where: { characterId: cc.character.id } }), 0);
});
});
describe('Redesign nachträglich umbuchen', () => {
it('stellt von bezahlt auf Kulanz um, ohne die Geschichte zu verbiegen', async () => {
const spieler = await spielerMitPaketen('Umbucher', 1);
const eins = await characters.createCharacter(spieler, { name: 'Erste Fassung' }, ctx);
const zwei = await characters.redesignCharacter(
eins.character.id,
{ name: 'Zweite Fassung', useKind: 'REDESIGN_PAID' },
ctx,
);
assert.equal(zwei.balances.REDESIGN_PAID, 0, 'das bezahlte ist verbraucht');
await credits.bookKulanz(spieler, 1, 'Absprache aufgetaucht', 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');
// Korrigiert wird durch Gegenbuchung, nicht durch Überschreiben.
const zeilen = await db.prisma.creditLedger.findMany({
where: { characterId: zwei.character.id },
orderBy: { id: 'asc' },
});
assert.equal(zeilen.length, 3, 'Verbrauch, Storno, neuer Verbrauch');
assert.equal(zeilen[1]?.source, 'REVERSAL');
assert.equal(zeilen[2]?.kind, 'REDESIGN_KULANZ');
});
it('und wieder zurück', async () => {
const spieler = await spielerMitPaketen('Hin und her', 1);
const eins = await characters.createCharacter(spieler, { name: 'A' }, ctx);
await credits.bookKulanz(spieler, 1, 'Erstmal Kulanz', ctx);
const zwei = await characters.redesignCharacter(
eins.character.id,
{ name: 'B', useKind: 'REDESIGN_KULANZ' },
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');
});
it('weist dieselbe Art ab', async () => {
const spieler = await spielerMitPaketen('Unveraendert', 1);
const eins = await characters.createCharacter(spieler, { name: 'X' }, ctx);
const zwei = await characters.redesignCharacter(
eins.character.id,
{ name: 'Y', useKind: 'REDESIGN_PAID' },
ctx,
);
await assert.rejects(
() => characters.changeFunding(zwei.character.id, 'REDESIGN_PAID', ctx),
(error: Error & { code?: string }) => error.code === 'SAME_KIND',
);
});
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.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',
);
});
});
describe('Angaben eines CCs nachtragen', () => {
it('ändert Namen und Beschreibung', async () => {
const spieler = await spielerMitPaketen('Korrigierer', 1);
const cc = await characters.createCharacter(spieler, { name: 'Tipfehler' }, ctx);
await characters.updateCharacter(
cc.character.id,
{ name: 'Tippfehler behoben', loreText: 'Jetzt mit Beschreibung' },
ctx,
);
const danach = await db.prisma.character.findUniqueOrThrow({ where: { id: cc.character.id } });
assert.equal(danach.name, 'Tippfehler behoben');
assert.equal(danach.loreText, 'Jetzt mit Beschreibung');
});
it('hängt einen Antrag an, den der Abgleich schon kennt', async () => {
const spieler = await spielerMitPaketen('Verknuepfer', 1);
const cc = await characters.createCharacter(spieler, { name: 'Mit Antrag' }, ctx);
await db.prisma.forumThread.create({
data: {
threadId: 7771,
title: 'Sein Antrag',
url: 'https://forum.example.org/index.php?thread/7771-sein-antrag/',
currentBoard: 'CC_ARCHIV',
},
});
await characters.updateCharacter(
cc.character.id,
{ threadRef: 'https://forum.example.org/index.php?thread/7771-sein-antrag/' },
ctx,
);
const thread = await db.prisma.forumThread.findUniqueOrThrow({ where: { threadId: 7771 } });
assert.equal(thread.linkedCharacterId, cc.character.id);
assert.equal(thread.needsClassification, false, 'er ist damit erledigt');
});
it('merkt einen unbekannten Antrag vor', async () => {
const spieler = await spielerMitPaketen('Vormerker', 1);
const cc = await characters.createCharacter(spieler, { name: 'Noch ungelesen' }, ctx);
// Nur die Nummer, und der Abgleich kennt den Thread nicht.
await characters.updateCharacter(cc.character.id, { threadRef: '8888' }, ctx);
const thread = await db.prisma.forumThread.findUniqueOrThrow({ where: { threadId: 8888 } });
assert.equal(thread.linkedCharacterId, cc.character.id);
assert.equal(
thread.needsClassification,
false,
'sonst stünde er beim nächsten Lauf als offener Antrag da',
);
});
it('löst eine Verknüpfung wieder', async () => {
const spieler = await spielerMitPaketen('Loeser2', 1);
const cc = await characters.createCharacter(spieler, { name: 'Doch nicht' }, ctx);
await characters.updateCharacter(cc.character.id, { threadRef: '9999' }, ctx);
await characters.updateCharacter(cc.character.id, { threadRef: null }, ctx);
const thread = await db.prisma.forumThread.findUniqueOrThrow({ where: { threadId: 9999 } });
assert.equal(thread.linkedCharacterId, null);
const liste = await characters.listCharacters(spieler);
const eintrag = liste.find((c) => c.id === cc.character.id);
assert.deepEqual(eintrag?.forumThreads, [], 'am CC hängt kein Antrag mehr');
});
it('weist eine Eingabe ab, die keine Thread-Angabe ist', async () => {
const spieler = await spielerMitPaketen('Vertipper', 1);
const cc = await characters.createCharacter(spieler, { name: 'Sauber' }, ctx);
await assert.rejects(
() => characters.updateCharacter(cc.character.id, { threadRef: 'keine ahnung' }, ctx),
(error: Error & { code?: string }) => error.code === 'INVALID_THREAD',
);
});
});
describe('Die Stelle in der Kette bestimmt, was ein Eintrag kostet', () => {
/** Offene Verbrauchsbuchung eines CCs. */
async function verbrauch(characterId: number) {
return db.prisma.creditLedger.findFirst({
where: { characterId, source: 'CONSUME', reversedBy: null },
orderBy: { id: 'desc' },
});
}
it('macht aus einem eigenständigen CC ein Redesign, sobald er eingereiht wird', async () => {
const spieler = await spielerMitPaketen('Einreiher', 2);
const erster = await characters.createCharacter(spieler, { name: 'Der Erste' }, ctx);
const zweiter = await characters.createCharacter(spieler, { name: 'Der Zweite' }, ctx);
// Beide sind für sich gekauft.
assert.equal((await verbrauch(erster.character.id))?.kind, 'CC');
assert.equal((await verbrauch(zweiter.character.id))?.kind, 'CC');
await credits.bookRedesignPackage(spieler, ctx);
const vorher = await credits.getBalances(spieler);
await characters.moveInChain(
zweiter.character.id,
{ lineageId: erster.character.lineageId, afterId: erster.character.id },
ctx,
);
assert.equal(
(await verbrauch(zweiter.character.id))?.kind,
'REDESIGN_PAID',
'über einem anderen Eintrag ist es ein Redesign',
);
const danach = await credits.getBalances(spieler);
assert.equal(danach.CC, vorher.CC + 1, 'das CC-Guthaben kommt zurück');
assert.equal(danach.REDESIGN_PAID, vorher.REDESIGN_PAID - 1, 'dafür geht ein Redesign weg');
});
it('und wieder zurück, sobald er allein steht', async () => {
const spieler = await spielerMitPaketen('Herausloeser', 2);
const erster = await characters.createCharacter(spieler, { name: 'Bleibt unten' }, ctx);
const zweiter = await characters.createCharacter(spieler, { name: 'Wandert' }, ctx);
await characters.moveInChain(
zweiter.character.id,
{ lineageId: erster.character.lineageId, afterId: erster.character.id },
ctx,
);
assert.equal((await verbrauch(zweiter.character.id))?.kind, 'REDESIGN_PAID');
await characters.detachFromChain(zweiter.character.id, ctx);
assert.equal(
(await verbrauch(zweiter.character.id))?.kind,
'CC',
'allein heisst eigener Platz, und der kostet ein CC-Paket',
);
});
it('bucht das neue unterste Glied auf CC um', async () => {
// Ein Redesign wird an den Anfang gezogen - es ist damit der gekaufte CC.
const spieler = await spielerMitPaketen('Umkehrer', 2);
const eins = await characters.createCharacter(spieler, { name: 'War unten' }, ctx);
const zwei = await characters.redesignCharacter(
eins.character.id,
{ name: 'War oben', useKind: 'REDESIGN_PAID' },
ctx,
);
assert.equal((await verbrauch(zwei.character.id))?.kind, 'REDESIGN_PAID');
await characters.moveInChain(
zwei.character.id,
{ lineageId: eins.character.lineageId, afterId: null },
ctx,
);
assert.equal((await verbrauch(zwei.character.id))?.kind, 'CC', 'unten steht der gekaufte CC');
assert.equal(
(await verbrauch(eins.character.id))?.kind,
'REDESIGN_PAID',
'und der andere ist nach oben gerückt',
);
});
it('lässt eine Kulanz-Entscheidung unangetastet', async () => {
const spieler = await spielerMitPaketen('Kulanzfall', 2);
const eins = await characters.createCharacter(spieler, { name: 'Basis' }, ctx);
await credits.bookKulanz(spieler, 1, 'Absprache', ctx);
const zwei = await characters.redesignCharacter(
eins.character.id,
{ name: 'Aus Kulanz', useKind: 'REDESIGN_KULANZ' },
ctx,
);
// Ein Verschieben innerhalb der Kette ändert an der Stelle nichts.
const dritter = await characters.createCharacter(spieler, { name: 'Kommt dazu' }, ctx);
await characters.moveInChain(
dritter.character.id,
{ lineageId: eins.character.lineageId, afterId: zwei.character.id },
ctx,
);
assert.equal(
(await verbrauch(zwei.character.id))?.kind,
'REDESIGN_KULANZ',
'bezahlt oder Kulanz ist eine Entscheidung, keine Folge der Reihenfolge',
);
});
it('lässt einen Eintrag ohne Buchung in Ruhe', async () => {
// Nacherfassung ohne Guthaben-Angabe - da gibt es nichts umzustellen.
const spieler = await spielerMitPaketen('Ohne Buchung', 1);
const eins = await characters.createCharacter(spieler, { name: 'Mit Buchung' }, ctx);
const ohne = await db.prisma.character.create({
data: { playerId: spieler, name: 'Ohne Buchung', status: 'ACTIVE' },
});
await db.prisma.characterMember.create({
data: { characterId: ohne.id, playerId: spieler },
});
const vorher = await credits.getBalances(spieler);
await characters.moveInChain(
ohne.id,
{ lineageId: eins.character.lineageId, afterId: eins.character.id },
ctx,
);
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',
);
});
});