Files
CC-Manager/server/test/gruppen.test.ts
T
Harlekin dbde5b7d11 Slots durchsetzen, Abgleiche nach Zeitplan, Kaufdatum im Kontobuch
Ein CC-Paket ist ein Platz fuer einen gleichzeitig aktiven CC - ein Redesign
tauscht einen CC aus, statt einen Platz hinzuzufuegen. Beim Gruppen-Beitritt
galt das seit dem letzten Stand, an anderer Stelle aber noch nicht: ein
redesignter CC liess sich mit einem Redesign-Guthaben zurueckholen, waehrend
sein Nachfolger aktiv blieb. Zwei aktive CCs auf ein einziges Paket.

Geprueft wird das jetzt an einer Stelle statt an jedem Weg einzeln, fuer
Erschaffen, Redesign, Reaktivierung, Rueckkehr und Beitritt. Der Massstab ist
die Deckung, nicht der Kontostand: jeder aktive CC braucht eine eigene Buchung
im CC-Topf. Ein bewusst ueberzogenes Konto bleibt damit moeglich - die Buchung
ist da, nur das Guthaben fehlt, und das Minus steht sichtbar auf der
Spielerseite.

Beide Abgleiche laufen jetzt von allein: die Kaeufe alle zwei Stunden, bei
Spielern ohne Kauf in den letzten drei Jahren alle sechs, das Forum einmal
nachts. Das spart den Grossteil der Anfragen, denn ruhende Konten sind mit der
Zeit die Mehrheit. Gebucht wird dabei nichts - neue Pakete landen in der
Vorschau, neue Antraege in der Liste. Der Stand steht in der Datenbank, damit
ein Neustart nicht alles erneut anstoesst. Unter Verwaltung steht, wann
zuletzt gelaufen wurde; auf der Uebersicht, wie viele Pakete auf eine Buchung
warten und bei wem.

Ausserdem:

- Die Guthaben-Historie zeigt bei Importen den Kauf im Shop, nicht den
  Zeitpunkt der Eingabe hier. Bereits gebuchte Importe werden nachgetragen.
- Forums-Antraege tragen ihr Datum, im Forum ausgelesen statt geraten.
- Die Hinweisbox ueber den Antraegen beim Spieler faellt weg.
- Die Aktionsspalte der Nutzerverwaltung war per display:flex aus dem
  Tabellenlayout gefallen, daher die versetzte Trennlinie.
- Die Zeitzone steht jetzt im Compose-Stack, sonst laege "nachts um vier"
  nach UTC und im Sommer zwei Stunden daneben.
2026-09-21 12:35:36 +02:00

443 lines
18 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, 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',
);
});
});