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('zeigt den CC nach dem Austritt nicht mehr beim Aussteiger', 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); const danach = await characters.listCharacters(kurz); assert.equal(danach.some((c) => c.id === character.id), false, 'danach nicht mehr'); // 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 niemanden einem inaktiven CC beitreten', async () => { const gruender = await spielerMitPaket('Inaktiv-Chef'); const spaet = await spielerMitPaket('Zu spät'); const { character } = await characters.createCharacter(gruender, { name: 'Geschlossen' }, ctx); await characters.deactivateCharacter(character.id, 'PLAYER_INACTIVITY', ctx); await assert.rejects( () => characters.addMember(character.id, spaet, { useKind: 'CC' }, ctx), (error: Error & { code?: string }) => error.code === 'NOT_ACTIVE', ); }); }); 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 aktive = (await characters.listCharacters(gast)).filter((c) => c.status === 'ACTIVE'); assert.deepEqual( aktive.map((c) => c.name), ['Die Runde'], 'ein Platz, ein aktiver CC - der eigene ist dafür weg', ); }); });