Files
CC-Manager/server/test/gruppen.test.ts
T
Harlekin e57bfbabc7 Der eigene CC steht in der eigenen Kette
Ein CC landete unter "Frueher genutzt", obwohl er dem Spieler gehoert - seine
Mitgliedschaft daran war beendet, und danach allein richtete sich die
Einordnung. Damit stand sein eigener CC in einem Abschnitt, der ausdruecklich
fuer fremde gedacht ist, und liess sich aus der Ferne kaum in eine Kette
ziehen.

Jetzt entscheidet bei einem eigenen CC der Besitz: Wem er gehoert, dem gehoert
er, auch wenn die Mitgliedschaft beendet wurde. Es bleibt seine Geschichte.
Bei fremden CCs entscheidet weiterhin die Mitgliedschaft.

Zwei Dinge fielen dabei auf:

- Beim Ziehen scrollt der Browser nicht von selbst. Wer einen Eintrag von
  ganz unten in eine Kette weiter oben bringen wollte, sah das Ziel gar
  nicht. Jetzt scrollt die Seite, solange am Rand gezogen wird.
- Der Besitzer konnte sich an seinem eigenen CC nicht ohne neue Buchung
  wieder eintragen, obwohl seine Buchung seit dem Anlegen daran haengt. Fuer
  ihn ist die Guthaben-Angabe jetzt freigestellt; von einem Fremden wird sie
  weiterhin verlangt.
2026-09-21 19:53:29 +02:00

772 lines
31 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',
);
});
});