Files
Harlekin b93ff2fb0e Ein Eintrag kostet genau einmal, die Rueckkehr ist ein weiteres Glied
An einem Eintrag konnten zwei Buchungen haengen - etwa Kulanz und ein
gekauftes Redesign nebeneinander -, und aufloesen liess sich das
nirgends. Jetzt raeumt sowohl das Nachrechnen als auch das Umstellen der
Bezahlart auf: es bleibt die aelteste passende, der Rest wird storniert.

Die Rueckkehr eines redesignten oder gestorbenen CCs bucht deshalb nicht
mehr zusaetzlich, sondern erschafft ihn noch einmal: derselbe CC als
weiteres Glied ueber dem alten, und das kostet ein Redesign wie jedes
andere. Mit einem CC-Guthaben beginnt er stattdessen eine eigene Liste
und kann neben seinem Nachfolger laufen.

Ein entzogener Platz nimmt seine Buchung mit - sonst waere die Rueckkehr
umsonst oder stuende als zweite Buchung am selben Eintrag.
2026-09-22 14:31:29 +02:00

873 lines
35 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('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<string | undefined> {
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<string[]> {
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');
});
});