Bei einem CC mit mehreren Nutzern gibt es zwei Ebenen, und bisher liess sich nur die obere bedienen. Jetzt beide: Ein Spieler kann seinen Anteil redesignen und steigt damit aus - er bekommt einen eigenen CC, die Gruppe laeuft unberuehrt weiter. Fuer ihn ist es ein Tausch wie jeder andere: der Gruppenplatz geht weg, der eigene CC kommt an seine Stelle, es kommt keiner hinzu. Wohin er gewechselt ist, bleibt an der Mitgliedschaft festgehalten und steht bei den frueheren Nutzern. Das Redesign der ganzen Gruppe, bei dem alle mitwandern, gibt es unveraendert daneben. Ebenso koennen einzelne Nutzer ausfallen, ohne die uebrigen zu beruehren: bei einem Trio kann einer im Rollenspiel sterben, waehrend die anderen weiterspielen, und ebenso koennen alle nacheinander wegfallen. Grund und Begruendung haengen jetzt auch an der Mitgliedschaft. Die Rueckkehr folgt denselben Regeln wie beim eigenen CC - geputzt gratis, entzogen gegen Guthaben, gestorben nur durch einen Administrator mit Begruendung. Wer ausgefallen ist, sieht seinen CC weiter auf der Spielerseite; sonst gaebe es nichts, worueber er zurueckkehren koennte. Ein freiwilliger Austritt bleibt davon getrennt: dort gibt es nichts zurueckzuholen, ein Wiedereintritt kostet neu. Der GExtension-Abgleich hat denselben Ladebalken wie der Forumslauf bekommen, fuer alle Spieler wie fuer einzelne, samt Protokoll. Beide teilen sich jetzt dieselbe Anzeige. Dabei aufgefallen: Zeitplan und Handklick konnten denselben Abgleich doppelt starten und haetten dieselben Profile zweimal abgerufen. Die Sperre sitzt jetzt im Lauf selbst statt im Starter, damit sie fuer beide Wege gilt.
688 lines
27 KiB
TypeScript
688 lines
27 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import { after, before, describe, it } from 'node:test';
|
|
import { startTestDatabase, type TestDatabase } from './harness.js';
|
|
|
|
type Characters = typeof import('../src/services/characters.js');
|
|
type Credits = typeof import('../src/services/credits.js');
|
|
type Db = typeof import('../src/db.js');
|
|
|
|
let database: TestDatabase;
|
|
let characters: Characters;
|
|
let credits: Credits;
|
|
let db: Db;
|
|
let ctx: { actorId: number; actorName: string };
|
|
|
|
/** Ein Spieler mit einem CC-Paket im Rücken. */
|
|
async function spielerMitPaket(name: string, pakete = 1) {
|
|
const player = await db.prisma.player.create({ data: { displayName: name } });
|
|
for (let i = 0; i < pakete; i += 1) {
|
|
await credits.bookCcPackage(player.id, ctx);
|
|
}
|
|
return player.id;
|
|
}
|
|
|
|
before(async () => {
|
|
database = await startTestDatabase(54336);
|
|
process.env.DATABASE_URL = database.url;
|
|
process.env.SESSION_SECRET = 'test-secret-that-is-long-enough-000000';
|
|
process.env.NODE_ENV = 'test';
|
|
|
|
db = await import('../src/db.js');
|
|
characters = await import('../src/services/characters.js');
|
|
credits = await import('../src/services/credits.js');
|
|
|
|
const user = await db.prisma.user.create({
|
|
data: { username: 'gruppenchef', passwordHash: 'x', role: 'ADMIN' },
|
|
});
|
|
ctx = { actorId: user.id, actorName: user.username };
|
|
});
|
|
|
|
after(async () => {
|
|
await db.prisma.$disconnect();
|
|
await database.stop();
|
|
});
|
|
|
|
describe('Gruppen-CCs', () => {
|
|
it('führt den Ersteller als erstes Mitglied', async () => {
|
|
const playerId = await spielerMitPaket('Gründer');
|
|
const { character } = await characters.createCharacter(playerId, { name: 'Die Bande' }, ctx);
|
|
|
|
const mitglieder = await characters.listMembers(character.id);
|
|
assert.equal(mitglieder.length, 1);
|
|
assert.equal(mitglieder[0]?.playerId, playerId);
|
|
assert.equal(mitglieder[0]?.leftAt, null);
|
|
});
|
|
|
|
it('kostet jeden Beitretenden ein eigenes Guthaben', async () => {
|
|
const gruender = await spielerMitPaket('Anführer');
|
|
const zweiter = await spielerMitPaket('Mitstreiter');
|
|
const { character } = await characters.createCharacter(gruender, { name: 'Zweierbande' }, ctx);
|
|
|
|
const vorher = await credits.getBalances(zweiter);
|
|
const result = await characters.addMember(character.id, zweiter, { useKind: 'CC' }, ctx);
|
|
|
|
assert.equal(result.memberCount, 2);
|
|
assert.equal(result.balances.CC, vorher.CC - 1, 'sein eigenes CC-Guthaben ist weg');
|
|
|
|
// Der Gründer zahlt nicht doppelt.
|
|
const gruenderStand = await credits.getBalances(gruender);
|
|
assert.equal(gruenderStand.CC, 0, 'nur sein eigener CC, nichts zusätzlich');
|
|
});
|
|
|
|
it('lässt den Beitritt auch über ein Redesign zu, kostet dann aber den eigenen CC', async () => {
|
|
const gruender = await spielerMitPaket('Chef');
|
|
const zweiter = await spielerMitPaket('Nachzügler');
|
|
const { character } = await characters.createCharacter(gruender, { name: 'Redesign-Bande' }, ctx);
|
|
|
|
// Sein CC-Guthaben hat er schon anderweitig gebraucht, das Redesign nicht.
|
|
const eigener = await characters.createCharacter(zweiter, { name: 'Eigener CC' }, ctx);
|
|
const result = await characters.addMember(
|
|
character.id,
|
|
zweiter,
|
|
{ useKind: 'REDESIGN_PAID', predecessorId: eigener.character.id },
|
|
ctx,
|
|
);
|
|
|
|
assert.equal(result.balances.REDESIGN_PAID, 0, 'das Redesign ist verbraucht');
|
|
assert.equal(result.memberCount, 2);
|
|
|
|
// Ein Redesign ersetzt einen CC, es kommt keiner dazu.
|
|
const aufgegeben = await db.prisma.character.findUniqueOrThrow({
|
|
where: { id: eigener.character.id },
|
|
});
|
|
assert.equal(aufgegeben.status, 'INACTIVE');
|
|
assert.equal(aufgegeben.inactiveReason, 'REWORKED');
|
|
|
|
const eigene = await characters.listCharacters(zweiter);
|
|
assert.deepEqual(
|
|
eigene.filter((c) => c.status === 'ACTIVE').map((c) => c.name),
|
|
['Redesign-Bande'],
|
|
'der alte CC ist weg, der Gruppen-CC an seiner Stelle',
|
|
);
|
|
|
|
const beitritt = await db.prisma.characterMember.findFirstOrThrow({
|
|
where: { characterId: character.id, playerId: zweiter },
|
|
});
|
|
assert.equal(beitritt.predecessorId, eigener.character.id, 'die Kette bleibt nachvollziehbar');
|
|
});
|
|
|
|
it('verlangt für einen Beitritt über ein Redesign einen eigenen CC', async () => {
|
|
const gruender = await spielerMitPaket('Chefin');
|
|
const zweiter = await spielerMitPaket('Ohne CC');
|
|
const { character } = await characters.createCharacter(gruender, { name: 'Geschlossene Runde' }, ctx);
|
|
|
|
await assert.rejects(
|
|
() => characters.addMember(character.id, zweiter, { useKind: 'REDESIGN_PAID' }, ctx),
|
|
(error: Error & { code?: string }) => error.code === 'NEED_PREDECESSOR',
|
|
'ohne Vorgänger gäbe es nichts zu ersetzen',
|
|
);
|
|
});
|
|
|
|
it('nimmt als Vorgänger auch einen längst inaktiven CC an', async () => {
|
|
const gruender = await spielerMitPaket('Anführer');
|
|
const zweiter = await spielerMitPaket('Rückkehrer');
|
|
const { character } = await characters.createCharacter(gruender, { name: 'Zweite Chance' }, ctx);
|
|
|
|
const alt = await characters.createCharacter(zweiter, { name: 'Alter CC' }, ctx);
|
|
await characters.deactivateCharacter(alt.character.id, 'PLAYER_INACTIVITY', ctx);
|
|
|
|
const result = await characters.addMember(
|
|
character.id,
|
|
zweiter,
|
|
{ useKind: 'REDESIGN_PAID', predecessorId: alt.character.id },
|
|
ctx,
|
|
);
|
|
assert.equal(result.memberCount, 2);
|
|
|
|
// Was schon inaktiv war, wird nicht ein zweites Mal aufgegeben.
|
|
const unveraendert = await db.prisma.character.findUniqueOrThrow({
|
|
where: { id: alt.character.id },
|
|
});
|
|
assert.equal(unveraendert.inactiveReason, 'PLAYER_INACTIVITY');
|
|
});
|
|
|
|
it('lässt niemanden einen fremden CC aufgeben', async () => {
|
|
const gruender = await spielerMitPaket('Wirt');
|
|
const fremder = await spielerMitPaket('Fremder');
|
|
const { character } = await characters.createCharacter(gruender, { name: 'Stammtisch' }, ctx);
|
|
const fremdesCC = await characters.createCharacter(gruender, { name: 'Nicht deiner' }, ctx);
|
|
|
|
await assert.rejects(
|
|
() =>
|
|
characters.addMember(
|
|
character.id,
|
|
fremder,
|
|
{ useKind: 'REDESIGN_PAID', predecessorId: fremdesCC.character.id },
|
|
ctx,
|
|
),
|
|
(error: Error & { code?: string }) => error.code === 'NOT_OWN_CHARACTER',
|
|
);
|
|
});
|
|
|
|
it('verlangt für einen Platz aus dem CC-Guthaben keinen Vorgänger', async () => {
|
|
const gruender = await spielerMitPaket('Gastgeber');
|
|
const zweiter = await spielerMitPaket('Gast');
|
|
const { character } = await characters.createCharacter(gruender, { name: 'Offene Tür' }, ctx);
|
|
const eigener = await characters.createCharacter(zweiter, { name: 'Bleibt bestehen' }, ctx);
|
|
|
|
await assert.rejects(
|
|
() =>
|
|
characters.addMember(
|
|
character.id,
|
|
zweiter,
|
|
{ useKind: 'CC', predecessorId: eigener.character.id },
|
|
ctx,
|
|
),
|
|
(error: Error & { code?: string }) => error.code === 'NO_PREDECESSOR_NEEDED',
|
|
'ein CC-Paket kauft einen Platz, es ersetzt nichts',
|
|
);
|
|
});
|
|
|
|
it('nimmt denselben Spieler nicht zweimal auf', async () => {
|
|
const gruender = await spielerMitPaket('Doppelt', 2);
|
|
const { character } = await characters.createCharacter(gruender, { name: 'Einmalig' }, ctx);
|
|
|
|
await assert.rejects(
|
|
() => characters.addMember(character.id, gruender, { useKind: 'CC' }, ctx),
|
|
(error: Error & { code?: string }) => error.code === 'ALREADY_MEMBER',
|
|
);
|
|
});
|
|
|
|
it('trägt einen Spieler wieder aus, ohne Guthaben zurückzugeben', async () => {
|
|
const gruender = await spielerMitPaket('Bleibt');
|
|
const aussteiger = await spielerMitPaket('Geht');
|
|
const { character } = await characters.createCharacter(gruender, { name: 'Wechselbande' }, ctx);
|
|
await characters.addMember(character.id, aussteiger, { useKind: 'CC' }, ctx);
|
|
|
|
const vorAustritt = await credits.getBalances(aussteiger);
|
|
const result = await characters.removeMember(character.id, aussteiger, 'Gruppe verlassen', ctx);
|
|
|
|
assert.equal(result.memberCount, 1);
|
|
const nachAustritt = await credits.getBalances(aussteiger);
|
|
assert.deepEqual(nachAustritt, vorAustritt, 'der Platz war bezahlt und bleibt es');
|
|
});
|
|
|
|
it('zeigt den CC nach dem Austritt nicht mehr beim Aussteiger', async () => {
|
|
const gruender = await spielerMitPaket('Kern');
|
|
const kurz = await spielerMitPaket('Kurzzeitig');
|
|
const { character } = await characters.createCharacter(gruender, { name: 'Durchlauf' }, ctx);
|
|
await characters.addMember(character.id, kurz, { useKind: 'CC' }, ctx);
|
|
|
|
const waehrend = await characters.listCharacters(kurz);
|
|
assert.ok(waehrend.some((c) => c.id === character.id), 'während der Mitgliedschaft sichtbar');
|
|
|
|
await characters.removeMember(character.id, kurz, undefined, ctx);
|
|
|
|
const danach = await characters.listCharacters(kurz);
|
|
assert.equal(danach.some((c) => c.id === character.id), false, 'danach nicht mehr');
|
|
|
|
// Beim Gründer bleibt er stehen.
|
|
const beimGruender = await characters.listCharacters(gruender);
|
|
assert.ok(beimGruender.some((c) => c.id === character.id));
|
|
});
|
|
|
|
it('verweigert den Austritt des letzten Nutzers', async () => {
|
|
const allein = await spielerMitPaket('Einzelgänger');
|
|
const { character } = await characters.createCharacter(allein, { name: 'Solo' }, ctx);
|
|
|
|
await assert.rejects(
|
|
() => characters.removeMember(character.id, allein, undefined, ctx),
|
|
(error: Error & { code?: string }) => error.code === 'LAST_MEMBER',
|
|
);
|
|
});
|
|
|
|
it('nimmt die Gruppe ins Redesign mit', async () => {
|
|
const gruender = await spielerMitPaket('Alt-Chef');
|
|
const zweiter = await spielerMitPaket('Alt-Mitglied');
|
|
const { character } = await characters.createCharacter(gruender, { name: 'Vorher' }, ctx);
|
|
await characters.addMember(character.id, zweiter, { useKind: 'CC' }, ctx);
|
|
|
|
const result = await characters.redesignCharacter(
|
|
character.id,
|
|
{ name: 'Nachher', useKind: 'REDESIGN_PAID' },
|
|
ctx,
|
|
);
|
|
|
|
const neue = await characters.listMembers(result.character.id);
|
|
assert.equal(neue.length, 2, 'beide sind weiterhin dabei');
|
|
assert.deepEqual(
|
|
neue.map((m) => m.playerId).sort(),
|
|
[gruender, zweiter].sort(),
|
|
);
|
|
});
|
|
|
|
it('lässt niemanden einem inaktiven CC beitreten', async () => {
|
|
const gruender = await spielerMitPaket('Inaktiv-Chef');
|
|
const spaet = await spielerMitPaket('Zu spät');
|
|
const { character } = await characters.createCharacter(gruender, { name: 'Geschlossen' }, ctx);
|
|
await characters.deactivateCharacter(character.id, 'PLAYER_INACTIVITY', ctx);
|
|
|
|
await assert.rejects(
|
|
() => characters.addMember(character.id, spaet, { useKind: 'CC' }, ctx),
|
|
(error: Error & { code?: string }) => error.code === 'NOT_ACTIVE',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('Tote und entzogene CCs', () => {
|
|
it('lässt einen toten CC nicht auf dem normalen Weg zurückkehren', async () => {
|
|
const playerId = await spielerMitPaket('Trauernder');
|
|
const { character } = await characters.createCharacter(playerId, { name: 'Gefallener' }, ctx);
|
|
await characters.deactivateCharacter(character.id, 'DEAD', ctx, 'Im RP erschossen');
|
|
|
|
await assert.rejects(
|
|
() => characters.reactivateFree(character.id, ctx),
|
|
(error: Error & { code?: string }) => error.code === 'IS_DEAD',
|
|
);
|
|
await assert.rejects(
|
|
() => characters.reactivatePaid(character.id, 'CC', ctx),
|
|
(error: Error & { code?: string }) => error.code === 'IS_DEAD',
|
|
);
|
|
});
|
|
|
|
it('verlangt für den Tod eine Begründung', async () => {
|
|
const playerId = await spielerMitPaket('Knapp');
|
|
const { character } = await characters.createCharacter(playerId, { name: 'Ohne Grund' }, ctx);
|
|
|
|
await assert.rejects(
|
|
() => characters.deactivateCharacter(character.id, 'DEAD', ctx),
|
|
(error: Error & { code?: string }) => error.code === 'NEED_NOTE',
|
|
);
|
|
});
|
|
|
|
it('holt einen toten CC auf Entscheidung zurück, mit Begründung', async () => {
|
|
const playerId = await spielerMitPaket('Auferstanden');
|
|
const { character } = await characters.createCharacter(playerId, { name: 'Zurück' }, ctx);
|
|
await characters.deactivateCharacter(character.id, 'DEAD', ctx, 'Im RP gestorben');
|
|
|
|
const vorher = await credits.getBalances(playerId);
|
|
const result = await characters.reviveDeadCharacter(
|
|
character.id,
|
|
{ reason: 'Rückholung nach Absprache mit der Leitung' },
|
|
ctx,
|
|
);
|
|
|
|
assert.equal(result.character.status, 'ACTIVE');
|
|
assert.equal(result.character.inactiveReason, null);
|
|
assert.deepEqual(result.balances, vorher, 'ohne Guthaben-Art kostet es nichts');
|
|
});
|
|
|
|
it('kann die Rückkehr auch kostenpflichtig machen', async () => {
|
|
const playerId = await spielerMitPaket('Zahlt', 2);
|
|
const { character } = await characters.createCharacter(playerId, { name: 'Teuer' }, ctx);
|
|
await characters.deactivateCharacter(character.id, 'DEAD', ctx, 'Gestorben');
|
|
|
|
const vorher = await credits.getBalances(playerId);
|
|
const result = await characters.reviveDeadCharacter(
|
|
character.id,
|
|
{ reason: 'Gegen ein Guthaben zurückgeholt', useKind: 'CC' },
|
|
ctx,
|
|
);
|
|
|
|
assert.equal(result.balances.CC, vorher.CC - 1);
|
|
});
|
|
|
|
it('verlangt auch bei der Rückkehr eine Begründung', async () => {
|
|
const playerId = await spielerMitPaket('Wortkarg');
|
|
const { character } = await characters.createCharacter(playerId, { name: 'Stumm' }, ctx);
|
|
await characters.deactivateCharacter(character.id, 'DEAD', ctx, 'Gestorben');
|
|
|
|
await assert.rejects(
|
|
() => characters.reviveDeadCharacter(character.id, { reason: ' ' }, ctx),
|
|
(error: Error & { code?: string }) => error.code === 'NEED_REASON',
|
|
);
|
|
});
|
|
|
|
it('ein entzogener CC kommt nicht gratis zurück, aber gegen Guthaben', async () => {
|
|
const playerId = await spielerMitPaket('Entzogen', 2);
|
|
const { character } = await characters.createCharacter(playerId, { name: 'Weggenommen' }, ctx);
|
|
await characters.deactivateCharacter(character.id, 'NO_FREE_CLAIM', ctx, 'Regelverstoss');
|
|
|
|
await assert.rejects(
|
|
() => characters.reactivateFree(character.id, ctx),
|
|
(error: Error & { code?: string }) => error.code === 'NOT_FREE',
|
|
);
|
|
|
|
const result = await characters.reactivatePaid(character.id, 'CC', ctx);
|
|
assert.equal(result.character.status, 'ACTIVE');
|
|
});
|
|
});
|
|
|
|
describe('Slots: ein CC-Paket, ein gleichzeitig aktiver CC', () => {
|
|
it('lässt einen redesignten CC nicht neben seinem Nachfolger zurückkehren', async () => {
|
|
const spieler = await spielerMitPaket('Schlaufuchs');
|
|
const a = await characters.createCharacter(spieler, { name: 'CC A' }, ctx);
|
|
await characters.redesignCharacter(
|
|
a.character.id,
|
|
{ name: 'CC B', useKind: 'REDESIGN_PAID' },
|
|
ctx,
|
|
);
|
|
|
|
// Ein weiteres Redesign-Paket kauft keinen Platz, nur einen Tausch.
|
|
await credits.bookRedesignPackage(spieler, ctx);
|
|
|
|
await assert.rejects(
|
|
() => characters.reactivatePaid(a.character.id, 'REDESIGN_PAID', ctx),
|
|
(error: Error & { code?: string }) => error.code === 'NO_FREE_SLOT',
|
|
'sonst stünden zwei aktive CCs auf einem einzigen Paket',
|
|
);
|
|
|
|
const aktive = (await characters.listCharacters(spieler)).filter((c) => c.status === 'ACTIVE');
|
|
assert.equal(aktive.length, 1);
|
|
});
|
|
|
|
it('lässt ihn zurückkehren, sobald der Nachfolger weg ist', async () => {
|
|
const spieler = await spielerMitPaket('Geduldig');
|
|
const a = await characters.createCharacter(spieler, { name: 'Erster' }, ctx);
|
|
const b = await characters.redesignCharacter(
|
|
a.character.id,
|
|
{ name: 'Zweiter', useKind: 'REDESIGN_PAID' },
|
|
ctx,
|
|
);
|
|
await credits.bookRedesignPackage(spieler, ctx);
|
|
|
|
await characters.deactivateCharacter(b.character.id, 'OTHER', ctx, 'Platz gemacht');
|
|
|
|
const zurueck = await characters.reactivatePaid(a.character.id, 'REDESIGN_PAID', ctx);
|
|
assert.equal(zurueck.character.status, 'ACTIVE');
|
|
});
|
|
|
|
it('lässt ihn mit einem zweiten CC-Paket auch neben dem Nachfolger zurückkehren', async () => {
|
|
const spieler = await spielerMitPaket('Grossverdiener');
|
|
const a = await characters.createCharacter(spieler, { name: 'Alpha' }, ctx);
|
|
await characters.redesignCharacter(
|
|
a.character.id,
|
|
{ name: 'Beta', useKind: 'REDESIGN_PAID' },
|
|
ctx,
|
|
);
|
|
|
|
// Das zweite Paket ist ein zweiter Platz - damit dürfen beide laufen.
|
|
await credits.bookCcPackage(spieler, ctx);
|
|
const zurueck = await characters.reactivatePaid(a.character.id, 'CC', ctx);
|
|
|
|
assert.equal(zurueck.character.status, 'ACTIVE');
|
|
const aktive = (await characters.listCharacters(spieler)).filter((c) => c.status === 'ACTIVE');
|
|
assert.equal(aktive.length, 2, 'zwei Pakete, zwei Plätze');
|
|
});
|
|
|
|
it('lässt ein überzogenes Konto weiterhin zu', async () => {
|
|
// Ein CC vor dem Paket einzutragen bleibt möglich; das Minus steht
|
|
// sichtbar da. Gebucht ist der Platz trotzdem - das ist der Unterschied
|
|
// zu einem CC, der ohne jede Buchung aktiv wird.
|
|
const spieler = (await db.prisma.player.create({ data: { displayName: 'Vorab' } })).id;
|
|
const ergebnis = await characters.createCharacter(spieler, { name: 'Vorab-CC' }, ctx);
|
|
|
|
assert.equal(ergebnis.balances.CC, -1);
|
|
assert.equal(ergebnis.overdrawn, true);
|
|
assert.equal(ergebnis.character.status, 'ACTIVE');
|
|
});
|
|
|
|
it('hält die Regel auch bei einem Gruppen-CC ein', async () => {
|
|
const gruender = await spielerMitPaket('Wirtin');
|
|
const gast = await spielerMitPaket('Gast mit Plan');
|
|
const { character } = await characters.createCharacter(gruender, { name: 'Die Runde' }, ctx);
|
|
|
|
// Der Gast hat einen eigenen CC und gibt ihn für den Platz auf.
|
|
const eigener = await characters.createCharacter(gast, { name: 'Eigener' }, ctx);
|
|
await credits.bookRedesignPackage(gast, ctx);
|
|
await characters.addMember(
|
|
character.id,
|
|
gast,
|
|
{ useKind: 'REDESIGN_PAID', predecessorId: eigener.character.id },
|
|
ctx,
|
|
);
|
|
|
|
const aktive = (await characters.listCharacters(gast)).filter((c) => c.status === 'ACTIVE');
|
|
assert.deepEqual(
|
|
aktive.map((c) => c.name),
|
|
['Die Runde'],
|
|
'ein Platz, ein aktiver CC - der eigene ist dafür weg',
|
|
);
|
|
});
|
|
});
|
|
|
|
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,
|
|
);
|
|
|
|
const aktive = (await characters.listCharacters(zwei)).filter((c) => c.status === 'ACTIVE');
|
|
assert.deepEqual(
|
|
aktive.map((c) => c.name),
|
|
['Getauscht'],
|
|
'ein Paket, ein aktiver CC - der Gruppenplatz ist dafür weg',
|
|
);
|
|
});
|
|
|
|
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',
|
|
);
|
|
});
|
|
});
|