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', 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. await characters.createCharacter(zweiter, { name: 'Eigener CC' }, ctx); const result = await characters.addMember( character.id, zweiter, { useKind: 'REDESIGN_PAID' }, ctx, ); assert.equal(result.balances.REDESIGN_PAID, 0, 'das Redesign ist verbraucht'); assert.equal(result.memberCount, 2); }); 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'); }); });