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, ctx, { note: 'Absprache aufgetaucht' }); 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, ctx, { note: 'Erstmal Kulanz' }); 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, ctx, { note: 'Absprache' }); 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, fremd: fremd.character, 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('lässt auch den fremden Gruppen-CC selbst einreihen', async () => { // Beim Nacherfassen muss sich einordnen lassen, was einem nicht gehört: // Erst am Ende steht fest, welches Redesign wohin führte. const { dargon, fremd, eigenes } = await ausgestiegen(); await characters.moveInChain( fremd.id, { lineageId: eigenes.lineageId, afterId: null, forPlayerId: dargon }, ctx, ); const glieder = await kette(dargon, eigenes.lineageId); assert.deepEqual( glieder.map((g) => g.name), ['Fremde Gruppe', 'Eigener Strang', 'Und weiter'], 'der Gruppen-CC steht jetzt am Anfang der Kette', ); verkettungStimmt(glieder); // Sein Platz bleibt einer: die Beitrittsbuchung wird zum Kauf, das // Herausredesignen zum Redesign - nicht beides zum Kauf. const stand = await credits.getBalances(dargon); assert.equal(stand.CC, 0, 'drei Pakete, drei Plätze'); }); 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', ); }); }); describe('Die Rechnung geht auf', () => { /** * Die Regel in einem Satz: **So viele CC-Pakete wie Ketten, so viele * Redesigns wie Einträge minus Ketten.** Bei drei Ketten mit zusammen * sechzehn Einträgen sind das drei Pakete und dreizehn Redesigns. */ async function zaehle(playerId: number) { const eintraege = await db.prisma.characterMember.findMany({ where: { playerId }, select: { character: { select: { predecessorId: true } } }, }); const ketten = eintraege.filter((e) => e.character.predecessorId === null).length; return { eintraege: eintraege.length, ketten }; } async function verbraeuche(playerId: number) { const zeilen = await db.prisma.creditLedger.findMany({ where: { playerId, source: 'CONSUME', reversedBy: null }, select: { kind: true }, }); return { cc: zeilen.filter((z) => z.kind === 'CC').length, redesign: zeilen.filter((z) => z.kind !== 'CC').length, }; } it('bucht so viele Pakete wie Ketten und den Rest als Redesign', async () => { const spieler = await spielerMitPaketen('Rechenbeispiel', 3); // Drei Ketten mit 3, 2 und 1 Eintrag: zusammen sechs. const ketten: number[] = []; for (const [name, laenge] of [ ['Kette A', 3], ['Kette B', 2], ['Kette C', 1], ] as const) { const erster = await characters.createCharacter(spieler, { name: `${name} 1` }, ctx); let letzter = erster.character.id; for (let i = 2; i <= laenge; i += 1) { await credits.bookRedesignPackage(spieler, ctx); const naechster = await characters.redesignCharacter( letzter, { name: `${name} ${i}`, useKind: 'REDESIGN_PAID' }, ctx, ); letzter = naechster.character.id; } ketten.push(erster.character.id); } const struktur = await zaehle(spieler); assert.equal(struktur.ketten, 3); assert.equal(struktur.eintraege, 6); const gebucht = await verbraeuche(spieler); assert.equal(gebucht.cc, struktur.ketten, 'so viele Pakete wie Ketten'); assert.equal( gebucht.redesign, struktur.eintraege - struktur.ketten, 'und der Rest sind Redesigns', ); }); it('rechnet einen verbogenen Bestand wieder gerade', async () => { const spieler = await spielerMitPaketen('Verbogen', 2); const eins = await characters.createCharacter(spieler, { name: 'Anfang' }, ctx); await credits.bookRedesignPackage(spieler, ctx); const zwei = await characters.redesignCharacter( eins.character.id, { name: 'Fortsetzung', useKind: 'REDESIGN_PAID' }, ctx, ); // So sah es aus, bevor die Buchung der Kette folgte: das zweite Glied // stand als eigener Kauf im Konto. const falsch = await db.prisma.creditLedger.findFirstOrThrow({ where: { characterId: zwei.character.id, source: 'CONSUME', reversedBy: null }, }); await db.prisma.creditLedger.update({ where: { id: falsch.id }, data: { kind: 'CC' } }); const vorher = await verbraeuche(spieler); assert.equal(vorher.cc, 2, 'zwei Käufe bei nur einer Kette - das kann nicht stimmen'); const ergebnis = await characters.rebuildFunding(spieler, ctx); assert.equal(ergebnis.umgebucht, 1); const struktur = await zaehle(spieler); const gebucht = await verbraeuche(spieler); assert.equal(gebucht.cc, struktur.ketten, 'jetzt passt es wieder'); assert.equal(gebucht.redesign, struktur.eintraege - struktur.ketten); }); it('fasst nichts an, wenn schon alles stimmt', async () => { const spieler = await spielerMitPaketen('Sauber', 1); const eins = await characters.createCharacter(spieler, { name: 'Passt' }, ctx); await credits.bookRedesignPackage(spieler, ctx); await characters.redesignCharacter( eins.character.id, { name: 'Passt auch', useKind: 'REDESIGN_PAID' }, ctx, ); const vorher = await credits.getBalances(spieler); const ergebnis = await characters.rebuildFunding(spieler, ctx); assert.equal(ergebnis.umgebucht, 0); assert.deepEqual(ergebnis.balances, vorher); }); it('lässt eine Kulanz-Entscheidung auch beim Nachrechnen stehen', async () => { const spieler = await spielerMitPaketen('Kulanz bleibt', 1); const eins = await characters.createCharacter(spieler, { name: 'Start' }, ctx); await credits.bookKulanz(spieler, ctx, { note: 'Absprache' }); const zwei = await characters.redesignCharacter( eins.character.id, { name: 'Aus Kulanz', useKind: 'REDESIGN_KULANZ' }, ctx, ); await characters.rebuildFunding(spieler, ctx); const buchung = await db.prisma.creditLedger.findFirst({ where: { characterId: zwei.character.id, source: 'CONSUME', reversedBy: null }, orderBy: { id: 'desc' }, }); assert.equal(buchung?.kind, 'REDESIGN_KULANZ'); }); }); describe('Kulanz gegen ein Minus verrechnen', () => { /** Ein Spieler mit Redesigns im Minus und Kulanz auf der Seite. */ async function schieflage() { const spieler = await spielerMitPaketen('Schieflage', 1); const erster = await characters.createCharacter(spieler, { name: 'Anfang' }, ctx); // Vier Redesigns, aber nur eines bezahlt - der Topf geht ins Minus. let letzter = erster.character.id; for (let i = 2; i <= 5; i += 1) { const naechster = await characters.redesignCharacter( letzter, { name: `Stufe ${i}`, useKind: 'REDESIGN_PAID' }, ctx, ); letzter = naechster.character.id; } const stand = await credits.getBalances(spieler); assert.equal(stand.REDESIGN_PAID, -3, 'vier verbraucht, eines aus dem Paket'); return spieler; } it('gleicht so viel aus, wie beide Seiten hergeben', async () => { const spieler = await schieflage(); for (let i = 0; i < 2; i += 1) { await credits.bookKulanz(spieler, ctx, { note: 'Nachträglich zugestanden' }); } const ergebnis = await characters.settleWithKulanz(spieler, ctx); assert.equal(ergebnis.umgebucht, 2, 'zwei Kulanzen, zwei Übergänge'); assert.equal(ergebnis.balances.REDESIGN_PAID, -1, 'das Minus schrumpft um zwei'); assert.equal(ergebnis.balances.REDESIGN_KULANZ, 0, 'die Kulanz ist aufgebraucht'); assert.equal( ergebnis.balances.redesignTotal, -1, 'unterm Strich bleibt dasselbe - nur anders verteilt', ); }); it('nimmt die ältesten Übergänge zuerst', async () => { const spieler = await schieflage(); await credits.bookKulanz(spieler, ctx, { note: 'Eine Geste' }); await characters.settleWithKulanz(spieler, ctx); const kulanzZeile = await db.prisma.creditLedger.findFirstOrThrow({ where: { playerId: spieler, kind: 'REDESIGN_KULANZ', amount: { lt: 0 } }, include: { character: { select: { name: true } } }, }); assert.equal( kulanzZeile.character?.name, 'Stufe 2', 'wer nachträglich Kulanz bekommt, bekommt sie für das Älteste', ); }); it('meldet sich, wenn es nichts auszugleichen gibt', async () => { const spieler = await spielerMitPaketen('Im Plus', 1); await credits.bookKulanz(spieler, ctx, { note: 'Übrig' }); await assert.rejects( () => characters.settleWithKulanz(spieler, ctx), (error: Error & { code?: string }) => error.code === 'NOTHING_TO_SETTLE', ); }); it('meldet sich, wenn keine Kulanz da ist', async () => { const spieler = await schieflage(); await assert.rejects( () => characters.settleWithKulanz(spieler, ctx), (error: Error & { code?: string }) => error.code === 'NOTHING_TO_SETTLE', ); }); }); describe('Ein Redesign ausnahmsweise aus dem CC-Topf', () => { it('nimmt ein CC-Guthaben an', async () => { const spieler = await spielerMitPaketen('Ausnahmefall', 2); const eins = await characters.createCharacter(spieler, { name: 'Basis' }, ctx); const vorher = await credits.getBalances(spieler); const zwei = await characters.redesignCharacter( eins.character.id, { name: 'Aus dem CC-Topf', useKind: 'CC' }, ctx, ); assert.equal(zwei.balances.CC, vorher.CC - 1, 'bezahlt aus dem CC-Topf'); assert.equal(zwei.balances.REDESIGN_PAID, vorher.REDESIGN_PAID, 'die Redesigns bleiben'); }); it('lässt sich vom Ausrichten an den Ketten nicht überschreiben', async () => { // Ohne Merker würde die Automatik beim nächsten Umsortieren daraus // wieder ein gewöhnliches Redesign machen. const spieler = await spielerMitPaketen('Bleibt so', 2); const eins = await characters.createCharacter(spieler, { name: 'Erstes' }, ctx); const zwei = await characters.redesignCharacter( eins.character.id, { name: 'Zweites', useKind: 'CC' }, ctx, ); await characters.rebuildFunding(spieler, ctx); const buchung = await db.prisma.creditLedger.findFirst({ where: { characterId: zwei.character.id, source: 'CONSUME', reversedBy: null }, orderBy: { id: 'desc' }, }); assert.equal(buchung?.kind, 'CC', 'die bewusste Entscheidung bleibt stehen'); }); it('merkt sich auch eine von Hand geänderte Bezahlart', async () => { const spieler = await spielerMitPaketen('Von Hand', 2); const eins = await characters.createCharacter(spieler, { name: 'A' }, ctx); const zwei = await characters.redesignCharacter( eins.character.id, { name: 'B', useKind: 'REDESIGN_PAID' }, ctx, ); await characters.changeFunding(zwei.character.id, 'CC', ctx); await characters.rebuildFunding(spieler, ctx); const buchung = await db.prisma.creditLedger.findFirst({ where: { characterId: zwei.character.id, source: 'CONSUME', reversedBy: null }, orderBy: { id: 'desc' }, }); assert.equal(buchung?.kind, 'CC'); }); });