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.
873 lines
35 KiB
TypeScript
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');
|
|
});
|
|
});
|