Files
CC-Manager/server/test/gruppen.test.ts
T
Harlekin 1f4034b397 Jede Liste gehoert einem Spieler, CC-Guthaben wird umgewandelt
Ein Gruppen-CC wird fuer jeden Nutzer einzeln gerechnet: fuer den einen
kann er der gekaufte Platz sein, fuer den anderen ein Redesign seines
vorherigen CCs. Dafuer haengt die Liste jetzt an der Mitgliedschaft
statt am CC, und verschieben ordnet nur die eigene Liste.

Ein Redesign wird nie aus dem CC-Topf bezahlt. Wer sein CC-Guthaben
dafuer einsetzt, wandelt es um: ein Platz weniger, ein bezahltes
Redesign mehr. Damit entfaellt die Ausnahme, die sich bisher durch
Slot-Pruefung und Kettenrechnung zog.

Beim Start werden Buchungen einmalig an die Listen angeglichen.
2026-09-22 12:21:53 +02:00

875 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',
);
const vorher = await credits.getBalances(partner);
await characters.reactivateMember(character.id, partner, { useKind: 'REDESIGN_PAID' }, ctx);
assert.equal(
(await credits.getBalances(partner)).REDESIGN_PAID,
vorher.REDESIGN_PAID - 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');
});
});