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 }; /** Ein Spieler mit einem CC-Paket im Rücken. */ async function spielerMitPaket(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; } before(async () => { database = await startTestDatabase(54336); 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: 'gruppenchef', passwordHash: 'x', role: 'ADMIN' }, }); ctx = { actorId: user.id, actorName: user.username }; }); after(async () => { await db.prisma.$disconnect(); await database.stop(); }); describe('Gruppen-CCs', () => { it('führt den Ersteller als erstes Mitglied', async () => { const playerId = await spielerMitPaket('Gründer'); const { character } = await characters.createCharacter(playerId, { name: 'Die Bande' }, ctx); const mitglieder = await characters.listMembers(character.id); assert.equal(mitglieder.length, 1); assert.equal(mitglieder[0]?.playerId, playerId); assert.equal(mitglieder[0]?.leftAt, null); }); it('kostet jeden Beitretenden ein eigenes Guthaben', async () => { const gruender = await spielerMitPaket('Anführer'); const zweiter = await spielerMitPaket('Mitstreiter'); const { character } = await characters.createCharacter(gruender, { name: 'Zweierbande' }, ctx); const vorher = await credits.getBalances(zweiter); const result = await characters.addMember(character.id, zweiter, { useKind: 'CC' }, ctx); assert.equal(result.memberCount, 2); assert.equal(result.balances.CC, vorher.CC - 1, 'sein eigenes CC-Guthaben ist weg'); // Der Gründer zahlt nicht doppelt. const gruenderStand = await credits.getBalances(gruender); assert.equal(gruenderStand.CC, 0, 'nur sein eigener CC, nichts zusätzlich'); }); it('lässt den Beitritt auch über ein Redesign zu, kostet dann aber den eigenen CC', async () => { const gruender = await spielerMitPaket('Chef'); const zweiter = await spielerMitPaket('Nachzügler'); const { character } = await characters.createCharacter(gruender, { name: 'Redesign-Bande' }, ctx); // Sein CC-Guthaben hat er schon anderweitig gebraucht, das Redesign nicht. const eigener = await characters.createCharacter(zweiter, { name: 'Eigener CC' }, ctx); const result = await characters.addMember( character.id, zweiter, { useKind: 'REDESIGN_PAID', predecessorId: eigener.character.id }, ctx, ); assert.equal(result.balances.REDESIGN_PAID, 0, 'das Redesign ist verbraucht'); assert.equal(result.memberCount, 2); // Ein Redesign ersetzt einen CC, es kommt keiner dazu. const aufgegeben = await db.prisma.character.findUniqueOrThrow({ where: { id: eigener.character.id }, }); assert.equal(aufgegeben.status, 'INACTIVE'); assert.equal(aufgegeben.inactiveReason, 'REWORKED'); const eigene = await characters.listCharacters(zweiter); assert.deepEqual( eigene.filter((c) => c.status === 'ACTIVE').map((c) => c.name), ['Redesign-Bande'], 'der alte CC ist weg, der Gruppen-CC an seiner Stelle', ); const beitritt = await db.prisma.characterMember.findFirstOrThrow({ where: { characterId: character.id, playerId: zweiter }, }); assert.equal(beitritt.predecessorId, eigener.character.id, 'die Kette bleibt nachvollziehbar'); }); it('verlangt für einen Beitritt über ein Redesign einen eigenen CC', async () => { const gruender = await spielerMitPaket('Chefin'); const zweiter = await spielerMitPaket('Ohne CC'); const { character } = await characters.createCharacter(gruender, { name: 'Geschlossene Runde' }, ctx); await assert.rejects( () => characters.addMember(character.id, zweiter, { useKind: 'REDESIGN_PAID' }, ctx), (error: Error & { code?: string }) => error.code === 'NEED_PREDECESSOR', 'ohne Vorgänger gäbe es nichts zu ersetzen', ); }); it('nimmt als Vorgänger auch einen längst inaktiven CC an', async () => { const gruender = await spielerMitPaket('Anführer'); const zweiter = await spielerMitPaket('Rückkehrer'); const { character } = await characters.createCharacter(gruender, { name: 'Zweite Chance' }, ctx); const alt = await characters.createCharacter(zweiter, { name: 'Alter CC' }, ctx); await characters.deactivateCharacter(alt.character.id, 'PLAYER_INACTIVITY', ctx); const result = await characters.addMember( character.id, zweiter, { useKind: 'REDESIGN_PAID', predecessorId: alt.character.id }, ctx, ); assert.equal(result.memberCount, 2); // Was schon inaktiv war, wird nicht ein zweites Mal aufgegeben. const unveraendert = await db.prisma.character.findUniqueOrThrow({ where: { id: alt.character.id }, }); assert.equal(unveraendert.inactiveReason, 'PLAYER_INACTIVITY'); }); it('lässt niemanden einen fremden CC aufgeben', async () => { const gruender = await spielerMitPaket('Wirt'); const fremder = await spielerMitPaket('Fremder'); const { character } = await characters.createCharacter(gruender, { name: 'Stammtisch' }, ctx); const fremdesCC = await characters.createCharacter(gruender, { name: 'Nicht deiner' }, ctx); await assert.rejects( () => characters.addMember( character.id, fremder, { useKind: 'REDESIGN_PAID', predecessorId: fremdesCC.character.id }, ctx, ), (error: Error & { code?: string }) => error.code === 'NOT_OWN_CHARACTER', ); }); it('verlangt für einen Platz aus dem CC-Guthaben keinen Vorgänger', async () => { const gruender = await spielerMitPaket('Gastgeber'); const zweiter = await spielerMitPaket('Gast'); const { character } = await characters.createCharacter(gruender, { name: 'Offene Tür' }, ctx); const eigener = await characters.createCharacter(zweiter, { name: 'Bleibt bestehen' }, ctx); await assert.rejects( () => characters.addMember( character.id, zweiter, { useKind: 'CC', predecessorId: eigener.character.id }, ctx, ), (error: Error & { code?: string }) => error.code === 'NO_PREDECESSOR_NEEDED', 'ein CC-Paket kauft einen Platz, es ersetzt nichts', ); }); it('nimmt denselben Spieler nicht zweimal auf', async () => { const gruender = await spielerMitPaket('Doppelt', 2); const { character } = await characters.createCharacter(gruender, { name: 'Einmalig' }, ctx); await assert.rejects( () => characters.addMember(character.id, gruender, { useKind: 'CC' }, ctx), (error: Error & { code?: string }) => error.code === 'ALREADY_MEMBER', ); }); it('trägt einen Spieler wieder aus, ohne Guthaben zurückzugeben', async () => { const gruender = await spielerMitPaket('Bleibt'); const aussteiger = await spielerMitPaket('Geht'); const { character } = await characters.createCharacter(gruender, { name: 'Wechselbande' }, ctx); await characters.addMember(character.id, aussteiger, { useKind: 'CC' }, ctx); const vorAustritt = await credits.getBalances(aussteiger); const result = await characters.removeMember(character.id, aussteiger, 'Gruppe verlassen', ctx); assert.equal(result.memberCount, 1); const nachAustritt = await credits.getBalances(aussteiger); assert.deepEqual(nachAustritt, vorAustritt, 'der Platz war bezahlt und bleibt es'); }); it('behält den CC nach dem Austritt in der Geschichte des Aussteigers', async () => { const gruender = await spielerMitPaket('Kern'); const kurz = await spielerMitPaket('Kurzzeitig'); const { character } = await characters.createCharacter(gruender, { name: 'Durchlauf' }, ctx); await characters.addMember(character.id, kurz, { useKind: 'CC' }, ctx); const waehrend = await characters.listCharacters(kurz); assert.ok(waehrend.some((c) => c.id === character.id), 'während der Mitgliedschaft sichtbar'); await characters.removeMember(character.id, kurz, undefined, ctx); // Er war darin, und das bleibt so. Dass er keinen Platz mehr belegt, // steht an der Mitgliedschaft, nicht daran, dass der CC verschwindet. const danach = await characters.listCharacters(kurz); const eintrag = danach.find((c) => c.id === character.id); assert.ok(eintrag, 'der CC gehört weiter zu seiner Geschichte'); assert.ok( eintrag?.members.find((m) => m.playerId === kurz)?.leftAt, 'seine Mitgliedschaft ist beendet', ); // Beim Gründer bleibt er stehen. const beimGruender = await characters.listCharacters(gruender); assert.ok(beimGruender.some((c) => c.id === character.id)); }); it('verweigert den Austritt des letzten Nutzers', async () => { const allein = await spielerMitPaket('Einzelgänger'); const { character } = await characters.createCharacter(allein, { name: 'Solo' }, ctx); await assert.rejects( () => characters.removeMember(character.id, allein, undefined, ctx), (error: Error & { code?: string }) => error.code === 'LAST_MEMBER', ); }); it('nimmt die Gruppe ins Redesign mit', async () => { const gruender = await spielerMitPaket('Alt-Chef'); const zweiter = await spielerMitPaket('Alt-Mitglied'); const { character } = await characters.createCharacter(gruender, { name: 'Vorher' }, ctx); await characters.addMember(character.id, zweiter, { useKind: 'CC' }, ctx); const result = await characters.redesignCharacter( character.id, { name: 'Nachher', useKind: 'REDESIGN_PAID' }, ctx, ); const neue = await characters.listMembers(result.character.id); assert.equal(neue.length, 2, 'beide sind weiterhin dabei'); assert.deepEqual( neue.map((m) => m.playerId).sort(), [gruender, zweiter].sort(), ); }); it('lässt an einem inaktiven CC nachtragen, was damals war', async () => { // Beitreten kann man einem stillgelegten CC nicht - eintragen, wer // damals dabei war, dagegen schon. Beim Nacherfassen geht es nicht um // jetzt, sondern um eine Geschichte, die längst gelaufen ist. const gruender = await spielerMitPaket('Inaktiv-Chef'); const damals = await spielerMitPaket('War damals dabei'); const { character } = await characters.createCharacter(gruender, { name: 'Geschlossen' }, ctx); await characters.deactivateCharacter(character.id, 'PLAYER_INACTIVITY', ctx); await characters.addMember( character.id, damals, { useKind: 'CC', joinedAt: new Date('2023-05-01'), leftAt: new Date('2023-09-01') }, ctx, ); const eintrag = await db.prisma.characterMember.findFirstOrThrow({ where: { characterId: character.id, playerId: damals }, }); assert.equal(eintrag.joinedAt.toISOString().slice(0, 10), '2023-05-01'); assert.ok(eintrag.leftAt, 'mit Ende, denn vorbei ist es'); // Ein stillgelegter CC belegt keinen Platz - auch nicht rückwirkend. const belegt = (await characters.listCharacters(damals)).filter( (c) => c.status === 'ACTIVE' && c.members.some((m) => m.playerId === damals && !m.leftAt), ); assert.deepEqual(belegt, []); }); }); describe('Tote und entzogene CCs', () => { it('lässt einen toten CC nicht auf dem normalen Weg zurückkehren', async () => { const playerId = await spielerMitPaket('Trauernder'); const { character } = await characters.createCharacter(playerId, { name: 'Gefallener' }, ctx); await characters.deactivateCharacter(character.id, 'DEAD', ctx, 'Im RP erschossen'); await assert.rejects( () => characters.reactivateFree(character.id, ctx), (error: Error & { code?: string }) => error.code === 'IS_DEAD', ); await assert.rejects( () => characters.reactivatePaid(character.id, 'CC', ctx), (error: Error & { code?: string }) => error.code === 'IS_DEAD', ); }); it('verlangt für den Tod eine Begründung', async () => { const playerId = await spielerMitPaket('Knapp'); const { character } = await characters.createCharacter(playerId, { name: 'Ohne Grund' }, ctx); await assert.rejects( () => characters.deactivateCharacter(character.id, 'DEAD', ctx), (error: Error & { code?: string }) => error.code === 'NEED_NOTE', ); }); it('holt einen toten CC auf Entscheidung zurück, mit Begründung', async () => { const playerId = await spielerMitPaket('Auferstanden'); const { character } = await characters.createCharacter(playerId, { name: 'Zurück' }, ctx); await characters.deactivateCharacter(character.id, 'DEAD', ctx, 'Im RP gestorben'); const vorher = await credits.getBalances(playerId); const result = await characters.reviveDeadCharacter( character.id, { reason: 'Rückholung nach Absprache mit der Leitung' }, ctx, ); assert.equal(result.character.status, 'ACTIVE'); assert.equal(result.character.inactiveReason, null); assert.deepEqual(result.balances, vorher, 'ohne Guthaben-Art kostet es nichts'); }); it('kann die Rückkehr auch kostenpflichtig machen', async () => { const playerId = await spielerMitPaket('Zahlt', 2); const { character } = await characters.createCharacter(playerId, { name: 'Teuer' }, ctx); await characters.deactivateCharacter(character.id, 'DEAD', ctx, 'Gestorben'); const vorher = await credits.getBalances(playerId); const result = await characters.reviveDeadCharacter( character.id, { reason: 'Gegen ein Guthaben zurückgeholt', useKind: 'CC' }, ctx, ); assert.equal(result.balances.CC, vorher.CC - 1); }); it('verlangt auch bei der Rückkehr eine Begründung', async () => { const playerId = await spielerMitPaket('Wortkarg'); const { character } = await characters.createCharacter(playerId, { name: 'Stumm' }, ctx); await characters.deactivateCharacter(character.id, 'DEAD', ctx, 'Gestorben'); await assert.rejects( () => characters.reviveDeadCharacter(character.id, { reason: ' ' }, ctx), (error: Error & { code?: string }) => error.code === 'NEED_REASON', ); }); it('ein entzogener CC kommt nicht gratis zurück, aber gegen Guthaben', async () => { const playerId = await spielerMitPaket('Entzogen', 2); const { character } = await characters.createCharacter(playerId, { name: 'Weggenommen' }, ctx); await characters.deactivateCharacter(character.id, 'NO_FREE_CLAIM', ctx, 'Regelverstoss'); await assert.rejects( () => characters.reactivateFree(character.id, ctx), (error: Error & { code?: string }) => error.code === 'NOT_FREE', ); const result = await characters.reactivatePaid(character.id, 'CC', ctx); assert.equal(result.character.status, 'ACTIVE'); }); }); describe('Slots: ein CC-Paket, ein gleichzeitig aktiver CC', () => { it('lässt einen redesignten CC nicht neben seinem Nachfolger zurückkehren', async () => { const spieler = await spielerMitPaket('Schlaufuchs'); const a = await characters.createCharacter(spieler, { name: 'CC A' }, ctx); await characters.redesignCharacter( a.character.id, { name: 'CC B', useKind: 'REDESIGN_PAID' }, ctx, ); // Ein weiteres Redesign-Paket kauft keinen Platz, nur einen Tausch. await credits.bookRedesignPackage(spieler, ctx); await assert.rejects( () => characters.reactivatePaid(a.character.id, 'REDESIGN_PAID', ctx), (error: Error & { code?: string }) => error.code === 'NO_FREE_SLOT', 'sonst stünden zwei aktive CCs auf einem einzigen Paket', ); const aktive = (await characters.listCharacters(spieler)).filter((c) => c.status === 'ACTIVE'); assert.equal(aktive.length, 1); }); it('lässt ihn zurückkehren, sobald der Nachfolger weg ist', async () => { const spieler = await spielerMitPaket('Geduldig'); const a = await characters.createCharacter(spieler, { name: 'Erster' }, ctx); const b = await characters.redesignCharacter( a.character.id, { name: 'Zweiter', useKind: 'REDESIGN_PAID' }, ctx, ); await credits.bookRedesignPackage(spieler, ctx); await characters.deactivateCharacter(b.character.id, 'OTHER', ctx, 'Platz gemacht'); const zurueck = await characters.reactivatePaid(a.character.id, 'REDESIGN_PAID', ctx); assert.equal(zurueck.character.status, 'ACTIVE'); }); it('lässt ihn mit einem zweiten CC-Paket auch neben dem Nachfolger zurückkehren', async () => { const spieler = await spielerMitPaket('Grossverdiener'); const a = await characters.createCharacter(spieler, { name: 'Alpha' }, ctx); await characters.redesignCharacter( a.character.id, { name: 'Beta', useKind: 'REDESIGN_PAID' }, ctx, ); // Das zweite Paket ist ein zweiter Platz - damit dürfen beide laufen. await credits.bookCcPackage(spieler, ctx); const zurueck = await characters.reactivatePaid(a.character.id, 'CC', ctx); assert.equal(zurueck.character.status, 'ACTIVE'); const aktive = (await characters.listCharacters(spieler)).filter((c) => c.status === 'ACTIVE'); assert.equal(aktive.length, 2, 'zwei Pakete, zwei Plätze'); }); it('lässt ein überzogenes Konto weiterhin zu', async () => { // Ein CC vor dem Paket einzutragen bleibt möglich; das Minus steht // sichtbar da. Gebucht ist der Platz trotzdem - das ist der Unterschied // zu einem CC, der ohne jede Buchung aktiv wird. const spieler = (await db.prisma.player.create({ data: { displayName: 'Vorab' } })).id; const ergebnis = await characters.createCharacter(spieler, { name: 'Vorab-CC' }, ctx); assert.equal(ergebnis.balances.CC, -1); assert.equal(ergebnis.overdrawn, true); assert.equal(ergebnis.character.status, 'ACTIVE'); }); it('hält die Regel auch bei einem Gruppen-CC ein', async () => { const gruender = await spielerMitPaket('Wirtin'); const gast = await spielerMitPaket('Gast mit Plan'); const { character } = await characters.createCharacter(gruender, { name: 'Die Runde' }, ctx); // Der Gast hat einen eigenen CC und gibt ihn für den Platz auf. const eigener = await characters.createCharacter(gast, { name: 'Eigener' }, ctx); await credits.bookRedesignPackage(gast, ctx); await characters.addMember( character.id, gast, { useKind: 'REDESIGN_PAID', predecessorId: eigener.character.id }, ctx, ); const belegt = (await characters.listCharacters(gast)).filter( (c) => c.status === 'ACTIVE' && c.members.some((m) => m.playerId === gast && !m.leftAt), ); assert.deepEqual( belegt.map((c) => c.name), ['Die Runde'], 'ein Platz, ein belegter Platz - der eigene ist dafür weg', ); }); }); describe('Aus einem Gruppen-CC herausredesignen', () => { /** Ein Gruppen-CC mit drei Nutzern, jeder mit eigenem Paket bezahlt. */ async function dreierGruppe(praefix: string) { const chef = await spielerMitPaket(`${praefix}-Chef`); const zwei = await spielerMitPaket(`${praefix}-Zwei`); const drei = await spielerMitPaket(`${praefix}-Drei`); const { character } = await characters.createCharacter(chef, { name: `${praefix}-Bande` }, ctx); await characters.addMember(character.id, zwei, { useKind: 'CC' }, ctx); await characters.addMember(character.id, drei, { useKind: 'CC' }, ctx); return { chef, zwei, drei, character }; } it('lässt ein Mitglied aussteigen, ohne die Gruppe anzutasten', async () => { const { zwei, drei, chef, character } = await dreierGruppe('Austritt'); const ergebnis = await characters.redesignMembership( character.id, zwei, { name: 'Eigener Weg', useKind: 'REDESIGN_PAID' }, ctx, ); assert.equal(ergebnis.character.name, 'Eigener Weg'); assert.equal(ergebnis.character.status, 'ACTIVE'); assert.equal(ergebnis.balances.REDESIGN_PAID, 0, 'das Redesign ist verbraucht'); // Die Gruppe läuft unberührt weiter. const gruppe = await db.prisma.character.findUniqueOrThrow({ where: { id: character.id } }); assert.equal(gruppe.status, 'ACTIVE'); const dabei = await db.prisma.characterMember.findMany({ where: { characterId: character.id, leftAt: null }, select: { playerId: true }, }); assert.deepEqual( dabei.map((m) => m.playerId).sort((a, b) => a - b), [chef, drei].sort((a, b) => a - b), 'die beiden anderen bleiben', ); }); it('hält fest, wohin der Ausgestiegene gewechselt ist', async () => { const { zwei, character } = await dreierGruppe('Spur'); const ergebnis = await characters.redesignMembership( character.id, zwei, { name: 'Nachfolger', useKind: 'REDESIGN_PAID' }, ctx, ); const mitglied = await db.prisma.characterMember.findFirstOrThrow({ where: { characterId: character.id, playerId: zwei }, }); assert.equal(mitglied.successorId, ergebnis.character.id); assert.equal( mitglied.inactiveReason, null, 'sein Platz wurde getauscht, nicht entzogen - es gibt nichts zurückzuholen', ); assert.equal(ergebnis.character.predecessorId, character.id, 'die Herkunft bleibt sichtbar'); }); it('erschleicht dabei keinen Platz', async () => { const { zwei, character } = await dreierGruppe('Platz'); await characters.redesignMembership( character.id, zwei, { name: 'Getauscht', useKind: 'REDESIGN_PAID' }, ctx, ); // Sichtbar ist beides - er war ja in der Gruppe. Belegt ist nur einer. const belegt = (await characters.listCharacters(zwei)).filter( (c) => c.status === 'ACTIVE' && c.members.some((m) => m.playerId === zwei && !m.leftAt), ); assert.deepEqual( belegt.map((c) => c.name), ['Getauscht'], 'ein Paket, ein belegter Platz - der Gruppenplatz ist dafür weg', ); }); it('behält den Gruppen-CC in der Geschichte des Ausgestiegenen', async () => { const { zwei, character } = await dreierGruppe('Geschichte'); const neuer = await characters.redesignMembership( character.id, zwei, { name: 'Eigenes Ding', useKind: 'REDESIGN_PAID' }, ctx, ); const seine = await characters.listCharacters(zwei); const gruppe = seine.find((c) => c.id === character.id); assert.ok(gruppe, 'er war in der Gruppe, und das bleibt sichtbar'); // Die Spur zeigt, wohin es von dort aus ging - daran hängt die Anzeige // den alten Eintrag unter die richtige Kette. const eigene = gruppe?.members.find((m) => m.playerId === zwei); assert.ok(eigene?.leftAt, 'seine Mitgliedschaft ist beendet'); assert.equal(eigene?.successorId, neuer.character.id); assert.equal(eigene?.successor?.name, 'Eigenes Ding'); }); it('weist den letzten Nutzer auf das gewöhnliche Redesign hin', async () => { const allein = await spielerMitPaket('Einzelgänger'); const { character } = await characters.createCharacter(allein, { name: 'Solo' }, ctx); await assert.rejects( () => characters.redesignMembership( character.id, allein, { name: 'Solo II', useKind: 'REDESIGN_PAID' }, ctx, ), (error: Error & { code?: string }) => error.code === 'LAST_MEMBER', ); }); it('lässt das Redesign der ganzen Gruppe unberührt weiter zu', async () => { const { chef, zwei, drei, character } = await dreierGruppe('Gemeinsam'); const neu = await characters.redesignCharacter( character.id, { name: 'Die Bande 2.0', useKind: 'REDESIGN_PAID' }, ctx, ); const dabei = await db.prisma.characterMember.findMany({ where: { characterId: neu.character.id, leftAt: null }, select: { playerId: true }, }); assert.deepEqual( dabei.map((m) => m.playerId).sort((a, b) => a - b), [chef, zwei, drei].sort((a, b) => a - b), 'wer dabei war, ist auch danach dabei', ); assert.equal(neu.predecessor.inactiveReason, 'REWORKED'); }); }); describe('Einzelne Mitglieder fallen aus', () => { async function paar(praefix: string) { const chef = await spielerMitPaket(`${praefix}-A`); const partner = await spielerMitPaket(`${praefix}-B`); const { character } = await characters.createCharacter(chef, { name: `${praefix}-Duo` }, ctx); await characters.addMember(character.id, partner, { useKind: 'CC' }, ctx); return { chef, partner, character }; } it('lässt einen einzelnen Nutzer sterben, während der CC weiterläuft', async () => { const { chef, partner, character } = await paar('Tod'); await characters.deactivateMember(character.id, partner, 'DEAD', ctx, 'Von SCP-096 erwischt'); const gruppe = await db.prisma.character.findUniqueOrThrow({ where: { id: character.id } }); assert.equal(gruppe.status, 'ACTIVE', 'der CC selbst lebt weiter'); const uebrig = await db.prisma.characterMember.findMany({ where: { characterId: character.id, leftAt: null }, }); assert.deepEqual(uebrig.map((m) => m.playerId), [chef]); const toter = await db.prisma.characterMember.findFirstOrThrow({ where: { characterId: character.id, playerId: partner }, }); assert.equal(toter.inactiveReason, 'DEAD'); assert.equal(toter.inactiveNote, 'Von SCP-096 erwischt'); }); it('zeigt dem Ausgefallenen seinen CC weiter an', async () => { const { partner, character } = await paar('Sichtbar'); await characters.deactivateMember(character.id, partner, 'PLAYER_INACTIVITY', ctx); const seine = await characters.listCharacters(partner); assert.ok( seine.some((c) => c.id === character.id), 'sonst könnte er nie zurückkehren', ); }); it('verlangt für einen Tod eine Begründung', async () => { const { partner, character } = await paar('Begruendung'); await assert.rejects( () => characters.deactivateMember(character.id, partner, 'DEAD', ctx), (error: Error & { code?: string }) => error.code === 'NEED_NOTE', ); }); it('holt einen geputzten Nutzer gratis zurück', async () => { const { partner, character } = await paar('Gratis'); await characters.deactivateMember(character.id, partner, 'PLAYER_INACTIVITY', ctx); const vorher = await credits.getBalances(partner); await characters.reactivateMember(character.id, partner, {}, ctx); const dabei = await db.prisma.characterMember.findFirstOrThrow({ where: { characterId: character.id, playerId: partner }, }); assert.equal(dabei.leftAt, null); assert.equal(dabei.inactiveReason, null); assert.deepEqual(await credits.getBalances(partner), vorher, 'gratis heißt gratis'); }); it('lässt einen entzogenen Platz nur gegen Guthaben zurückkommen', async () => { const { partner, character } = await paar('Entzogen'); await characters.deactivateMember(character.id, partner, 'NO_FREE_CLAIM', ctx, 'Regelverstoß'); await assert.rejects( () => characters.reactivateMember(character.id, partner, {}, ctx), (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: 'CC' }, ctx); assert.equal((await credits.getBalances(partner)).CC, vorher.CC - 1, 'bezahlt'); }); it('verlangt für die Rückkehr eines Gestorbenen eine Begründung', async () => { const { partner, character } = await paar('Rueckkehr'); await characters.deactivateMember(character.id, partner, 'DEAD', ctx, 'gestorben'); await assert.rejects( () => characters.reactivateMember(character.id, partner, {}, ctx), (error: Error & { code?: string }) => error.code === 'NEED_REASON', ); await characters.reactivateMember( character.id, partner, { reason: 'Klon aus der Lagerhalle' }, ctx, ); const dabei = await db.prisma.characterMember.findFirstOrThrow({ where: { characterId: character.id, playerId: partner }, }); assert.equal(dabei.leftAt, null); }); it('macht aus einem freiwilligen Austritt keine Rückkehr auf Kosten des Hauses', async () => { const { partner, character } = await paar('Freiwillig'); await characters.removeMember(character.id, partner, 'Keine Lust mehr', ctx); await assert.rejects( () => characters.reactivateMember(character.id, partner, {}, ctx), (error: Error & { code?: string }) => error.code === 'WAS_VOLUNTARY', 'wer freiwillig geht, tritt neu bei und zahlt neu', ); }); it('lässt den letzten Nutzer nicht einzeln ausfallen', async () => { const allein = await spielerMitPaket('Letzter'); const { character } = await characters.createCharacter(allein, { name: 'Alleingang' }, ctx); await assert.rejects( () => characters.deactivateMember(character.id, allein, 'PLAYER_INACTIVITY', ctx), (error: Error & { code?: string }) => error.code === 'LAST_MEMBER', 'dann ist der ganze CC betroffen', ); }); }); describe('Der eigene CC bleibt der eigene', () => { it('lässt den Besitzer sich ohne neue Buchung wieder eintragen', async () => { // Kommt beim Nacherfassen vor: Der Besitzer ist aus seinem eigenen CC // ausgetragen worden. Seine Buchung hängt trotzdem daran, seit er ihn // angelegt hat - ein zweites Mal zahlt er nicht. const besitzer = await spielerMitPaket('Selbst ausgetragen'); const zweiter = await spielerMitPaket('Bleibt drin'); const { character } = await characters.createCharacter(besitzer, { name: 'Seiner' }, ctx); await characters.addMember(character.id, zweiter, { useKind: 'CC' }, ctx); await characters.removeMember(character.id, besitzer, 'Versehen', ctx); const vorher = await credits.getBalances(besitzer); await characters.addMember(character.id, besitzer, {}, ctx); assert.deepEqual(await credits.getBalances(besitzer), vorher, 'kostet ihn nichts'); const dabei = await db.prisma.characterMember.findFirstOrThrow({ where: { characterId: character.id, playerId: besitzer }, }); assert.equal(dabei.leftAt, null, 'und er ist wieder dabei'); }); it('verlangt von einem Fremden weiterhin ein Guthaben', async () => { const besitzer = await spielerMitPaket('Wirt drei'); const fremder = await spielerMitPaket('Gast drei'); const { character } = await characters.createCharacter(besitzer, { name: 'Seins' }, ctx); await assert.rejects( () => characters.addMember(character.id, fremder, {}, ctx), (error: Error & { code?: string }) => error.code === 'NEED_KIND', ); }); }); describe('Ein Gruppen-CC zählt für jeden Nutzer einzeln', () => { /// Womit ein bestimmter Spieler diesen CC bezahlt hat. async function bezahltMit(characterId: number, playerId: number): Promise { const buchung = await db.prisma.creditLedger.findFirst({ where: { characterId, playerId, source: 'CONSUME', reversedBy: null }, orderBy: { id: 'desc' }, }); return buchung?.kind; } /// Die Liste dieses Spielers, von unten nach oben. async function liste(playerId: number): Promise { const eigene = await db.prisma.characterMember.findMany({ where: { playerId }, orderBy: [{ lineageId: 'asc' }, { chainPosition: 'asc' }], select: { lineageId: true, character: { select: { name: true } } }, }); return eigene.map((e) => `${e.lineageId.slice(0, 4)}:${e.character.name}`); } it('ist für den einen ein Platz und für den anderen ein Redesign', async () => { const wirt = await spielerMitPaket('Zaehlwirt'); const gast = await spielerMitPaket('Zaehlgast'); const gruppe = await characters.createCharacter(wirt, { name: 'Gemeinsames Werk' }, ctx); const eigener = await characters.createCharacter(gast, { name: 'Sein Erster' }, ctx); await credits.bookRedesignPackage(gast, ctx); await characters.addMember( gruppe.character.id, gast, { useKind: 'REDESIGN_PAID', predecessorId: eigener.character.id }, ctx, ); assert.equal(await bezahltMit(gruppe.character.id, wirt), 'CC', 'für den Wirt sein Platz'); assert.equal( await bezahltMit(gruppe.character.id, gast), 'REDESIGN_PAID', 'für den Gast das Redesign seines ersten CCs', ); assert.equal( await characters.richteBuchungenAus(), 0, 'beide Sichten sind in sich stimmig und bleiben unangetastet', ); // Die Rechnung des Gastes: eine Liste mit zwei Einträgen. const stand = await credits.getBalances(gast); assert.equal(stand.CC, 0, 'ein Platz, eine Liste'); assert.equal(stand.REDESIGN_PAID, 1, 'von zwei Redesigns eines verbraucht'); assert.deepEqual( (await liste(gast)).map((e) => e.split(':')[1]), ['Sein Erster', 'Gemeinsames Werk'], 'in seiner Liste steht der Gruppen-CC über seinem ersten', ); }); it('lässt jeden für sich verschieben, ohne den anderen anzufassen', async () => { const wirt = await spielerMitPaket('Schiebewirt', 2); const gast = await spielerMitPaket('Schiebegast', 2); const gruppe = await characters.createCharacter(wirt, { name: 'Die Bude' }, ctx); const eigener = await characters.createCharacter(gast, { name: 'Gasts Erster' }, ctx); await characters.addMember(gruppe.character.id, gast, { useKind: 'CC' }, ctx); assert.equal(await bezahltMit(gruppe.character.id, gast), 'CC', 'erst ein eigener Platz'); // Der Gast reiht den Gruppen-CC in seine Liste ein: für ihn wird daraus // ein Redesign seines ersten CCs. await credits.bookRedesignPackage(gast, ctx); await characters.moveInChain( gruppe.character.id, { lineageId: ( await db.prisma.characterMember.findFirstOrThrow({ where: { playerId: gast, characterId: eigener.character.id }, }) ).lineageId, afterId: eigener.character.id, forPlayerId: gast, }, ctx, ); assert.equal( await bezahltMit(gruppe.character.id, gast), 'REDESIGN_PAID', 'in seiner Liste ist es jetzt ein Redesign', ); assert.equal( await bezahltMit(gruppe.character.id, wirt), 'CC', 'beim Wirt ändert sich dadurch nichts', ); // Und der CC selbst bleibt aktiv - der Wirt spielt ihn weiter. const danach = await db.prisma.character.findUniqueOrThrow({ where: { id: gruppe.character.id }, }); assert.equal(danach.status, 'ACTIVE'); }); });