Files
CC-Manager/server/test/gruppen.test.ts
T
Harlekin e78a57e35e Tote CCs, Entzug und Gruppen-CCs
Bisher entschied allein das Redesign darueber, ob eine Rueckkehr etwas kostet.
Es fehlten zwei Faelle: ein Entzug ohne Anspruch auf kostenlose Rueckkehr, und
der Tod im Rollenspiel. Ein toter CC laesst sich weder gratis noch gegen
Guthaben auf dem normalen Weg zurueckholen - das entscheidet ein Administrator
ueber einen eigenen Weg, mit Pflicht-Begruendung, und waehlt dabei, ob ein
Guthaben fliesst. Schon das Eintragen des Todes verlangt eine Notiz.

Dazu koennen sich mehrere Spieler einen CC teilen. Die Zugehoerigkeit laeuft
ab jetzt durchgehend ueber eine eigene Tabelle, auch bei Einzel-CCs; die
Migration traegt bestehende CCs als erstes Mitglied ihres Erstellers nach.
Ohne das waere nach dem Update kein CC mehr beim Spieler aufgetaucht.

Fuer jeden Mitnutzer gelten dieselben Regeln wie fuer einen eigenen CC: der
Platz kostet ein eigenes Guthaben, wahlweise aus dem CC-Paket oder ueber ein
Redesign. Ein Austritt gibt nichts zurueck, denn bis dahin wurde der CC
genutzt; fuer eine Fehleingabe ist das Storno der richtige Weg. Der letzte
Nutzer laesst sich nicht austragen - dann wird der CC deaktiviert. Ein
Redesign nimmt die Gruppe mit.
2026-09-20 23:03:22 +02:00

259 lines
10 KiB
TypeScript

import assert from 'node:assert/strict';
import { after, before, describe, it } from 'node:test';
import { startTestDatabase, type TestDatabase } from './harness.js';
type Characters = typeof import('../src/services/characters.js');
type Credits = typeof import('../src/services/credits.js');
type Db = typeof import('../src/db.js');
let database: TestDatabase;
let characters: Characters;
let credits: Credits;
let db: Db;
let ctx: { actorId: number; actorName: string };
/** 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');
});
});