Wer sich aus einem Gruppen-CC herausredesignt hat, war trotzdem darin - der CC verschwand aber aus seiner Liste. Jetzt steht er am Fuss der Kette, in die der Spieler von dort aus gewechselt ist, gekennzeichnet als "war dabei" und mit dem Hinweis, wem er gehoert und wohin es weiterging. Verschieben oder entscheiden laesst sich daran nichts: es ist der Platz eines anderen. Frueheres, das zu keinem heutigen Platz fuehrt, steht unter "Frueher genutzt". Dasselbe gilt fuer einen gewoehnlichen Austritt. Neu eingetragene CCs tauchten manchmal erst nach einem Neuladen auf. Die Antworten der Schnittstelle trugen keinen Cache-Header, und ohne Angabe darf ein Browser selbst entscheiden, wie lange er eine GET-Antwort aufhebt - dann steht nach dem Anlegen die alte Liste auf dem Schirm. Sie tragen jetzt no-store, was nebenbei verhindert, dass Spielerdaten im Cache liegen bleiben. Ausserdem: die Pfeile zwischen den Kettengliedern sind groesser und tragen die Akzentfarbe - sie sind das Einzige, was die Leserichtung verraet. Und "Zurueknehmen" heisst jetzt "Loeschen", weil genau das passiert.
721 lines
28 KiB
TypeScript
721 lines
28 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 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 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',
|
|
);
|
|
});
|
|
});
|