Bisher zahlte nur, wer neu einstieg: wer in einer Kette mitgezogen wurde oder einen Gruppen-CC mitbenutzte, stand darin ohne eigene Buchung. Damit ging die Rechnung nicht auf - verbrauchte Redesigns sind alle Glieder aller Listen minus die Anzahl der Listen, und das gilt fuer jeden Nutzer einzeln. Jetzt bucht jedes Glied: das unterste einen Platz, jedes weitere ein Redesign. Wo eine Buchung fehlt, traegt der Abgleich sie nach, und das Redesign eines Gruppen-CCs kostet jeden Nutzer sein eigenes. Am Pfeil stehen alle offenen Buchungen eines Eintrags, nicht nur die juengste - sonst bleibt eine zweite unsichtbar.
1386 lines
52 KiB
TypeScript
1386 lines
52 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import { readFileSync } from 'node:fs';
|
|
import { dirname, join } from 'node:path';
|
|
import { after, before, describe, it } from 'node:test';
|
|
import { fileURLToPath } from 'node:url';
|
|
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 };
|
|
|
|
before(async () => {
|
|
database = await startTestDatabase(54340);
|
|
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: 'ketten', passwordHash: 'x', role: 'ADMIN' },
|
|
});
|
|
ctx = { actorId: user.id, actorName: user.username };
|
|
});
|
|
|
|
after(async () => {
|
|
await db.prisma.$disconnect();
|
|
await database.stop();
|
|
});
|
|
|
|
async function spielerMitPaketen(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;
|
|
}
|
|
|
|
/**
|
|
* Eine Kette, wie sie angezeigt würde. Massgeblich ist die Linie, nicht der
|
|
* Besitzer: ein Gruppen-CC gehört einem anderen und steht trotzdem darin.
|
|
*/
|
|
async function kette(playerId: number, lineageId: string) {
|
|
const glieder = await db.prisma.characterMember.findMany({
|
|
where: { playerId, lineageId },
|
|
orderBy: [{ chainPosition: 'asc' }, { id: 'asc' }],
|
|
select: {
|
|
characterId: true,
|
|
chainPosition: true,
|
|
predecessorId: true,
|
|
character: { select: { name: true } },
|
|
},
|
|
});
|
|
return glieder.map((glied) => ({
|
|
id: glied.characterId,
|
|
name: glied.character.name,
|
|
predecessorId: glied.predecessorId,
|
|
chainPosition: glied.chainPosition,
|
|
}));
|
|
}
|
|
|
|
/** Prüft, dass Positionen und Vorgänger-Verweise dasselbe sagen. */
|
|
function verkettungStimmt(glieder: { id: number; predecessorId: number | null }[]): void {
|
|
let erwartet: number | null = null;
|
|
for (const glied of glieder) {
|
|
assert.equal(
|
|
glied.predecessorId,
|
|
erwartet,
|
|
'die Verkettung muss der Reihenfolge folgen, sonst laufen beide Sichten auseinander',
|
|
);
|
|
erwartet = glied.id;
|
|
}
|
|
}
|
|
|
|
describe('Lücken in einer Redesign-Kette schliessen', () => {
|
|
it('legt Redesigns hintereinander ab', async () => {
|
|
const spieler = await spielerMitPaketen('Kettenbauer', 1);
|
|
const eins = await characters.createCharacter(spieler, { name: 'CC 1' }, ctx);
|
|
await credits.bookRedesignPackage(spieler, ctx);
|
|
const zwei = await characters.redesignCharacter(
|
|
eins.character.id,
|
|
{ name: 'Redesign 2', useKind: 'REDESIGN_PAID' },
|
|
ctx,
|
|
);
|
|
|
|
const glieder = await kette(spieler, zwei.character.lineageId);
|
|
assert.deepEqual(glieder.map((g) => g.name), ['CC 1', 'Redesign 2']);
|
|
verkettungStimmt(glieder);
|
|
});
|
|
|
|
it('schiebt ein später gefundenes Redesign mitten hinein', async () => {
|
|
const spieler = await spielerMitPaketen('Nachtraeger', 2);
|
|
const eins = await characters.createCharacter(spieler, { name: 'CC 1' }, ctx);
|
|
await credits.bookRedesignPackage(spieler, ctx);
|
|
const drei = await characters.redesignCharacter(
|
|
eins.character.id,
|
|
{ name: 'Redesign 3', useKind: 'REDESIGN_PAID' },
|
|
ctx,
|
|
);
|
|
|
|
// Redesign 2 taucht erst jetzt auf - als eigener CC, noch ohne Kette.
|
|
const zwei = await characters.createCharacter(spieler, { name: 'Redesign 2' }, ctx);
|
|
assert.notEqual(zwei.character.lineageId, drei.character.lineageId);
|
|
|
|
await characters.moveInChain(
|
|
zwei.character.id,
|
|
{ lineageId: drei.character.lineageId, afterId: eins.character.id },
|
|
ctx,
|
|
);
|
|
|
|
const glieder = await kette(spieler, drei.character.lineageId);
|
|
assert.deepEqual(
|
|
glieder.map((g) => g.name),
|
|
['CC 1', 'Redesign 2', 'Redesign 3'],
|
|
'die Lücke ist geschlossen, ohne die übrigen anzufassen',
|
|
);
|
|
verkettungStimmt(glieder);
|
|
});
|
|
|
|
it('setzt einen Eintrag auch an den Anfang', async () => {
|
|
const spieler = await spielerMitPaketen('Vorspann', 2);
|
|
const b = await characters.createCharacter(spieler, { name: 'B' }, ctx);
|
|
const a = await characters.createCharacter(spieler, { name: 'A' }, ctx);
|
|
|
|
await characters.moveInChain(
|
|
a.character.id,
|
|
{ lineageId: b.character.lineageId, afterId: null },
|
|
ctx,
|
|
);
|
|
|
|
const glieder = await kette(spieler, b.character.lineageId);
|
|
assert.deepEqual(glieder.map((g) => g.name), ['A', 'B']);
|
|
verkettungStimmt(glieder);
|
|
assert.equal(glieder[0]?.predecessorId, null, 'der Erste hat keinen Vorgänger');
|
|
});
|
|
|
|
it('holt einen Eintrag aus einer Kette in eine andere', async () => {
|
|
const spieler = await spielerMitPaketen('Umsortierer', 3);
|
|
const ersteKette = await characters.createCharacter(spieler, { name: 'Strang A' }, ctx);
|
|
const zweiteKette = await characters.createCharacter(spieler, { name: 'Strang B' }, ctx);
|
|
const wanderer = await characters.createCharacter(spieler, { name: 'Wanderer' }, ctx);
|
|
|
|
await characters.moveInChain(
|
|
wanderer.character.id,
|
|
{ lineageId: ersteKette.character.lineageId, afterId: ersteKette.character.id },
|
|
ctx,
|
|
);
|
|
assert.equal((await kette(spieler, ersteKette.character.lineageId)).length, 2);
|
|
|
|
// Doch die andere Kette.
|
|
await characters.moveInChain(
|
|
wanderer.character.id,
|
|
{ lineageId: zweiteKette.character.lineageId, afterId: zweiteKette.character.id },
|
|
ctx,
|
|
);
|
|
|
|
const erste = await kette(spieler, ersteKette.character.lineageId);
|
|
const zweite = await kette(spieler, zweiteKette.character.lineageId);
|
|
assert.deepEqual(erste.map((g) => g.name), ['Strang A'], 'die alte Kette schliesst sich');
|
|
assert.deepEqual(zweite.map((g) => g.name), ['Strang B', 'Wanderer']);
|
|
verkettungStimmt(erste);
|
|
verkettungStimmt(zweite);
|
|
});
|
|
|
|
it('holt einen falsch einsortierten Eintrag wieder heraus', async () => {
|
|
// Zwei eigenständige CCs, einer davon versehentlich in die Kette des
|
|
// anderen gezogen. Beide sind aus einem eigenen Paket bezahlt.
|
|
const spieler = await spielerMitPaketen('Loeser', 2);
|
|
const eins = await characters.createCharacter(spieler, { name: 'Bleibt' }, ctx);
|
|
const zwei = await characters.createCharacter(spieler, { name: 'Gehoert woanders hin' }, ctx);
|
|
|
|
await characters.moveInChain(
|
|
zwei.character.id,
|
|
{ lineageId: eins.character.lineageId, afterId: eins.character.id },
|
|
ctx,
|
|
);
|
|
// In der Kette gilt: wer einen Nachfolger hat, wurde ersetzt.
|
|
const eingereiht = await db.prisma.character.findUniqueOrThrow({
|
|
where: { id: eins.character.id },
|
|
});
|
|
assert.equal(eingereiht.inactiveReason, 'REWORKED');
|
|
|
|
const { lineageId } = await characters.detachFromChain(zwei.character.id, ctx);
|
|
assert.notEqual(lineageId, eins.character.lineageId);
|
|
|
|
const alte = await kette(spieler, eins.character.lineageId);
|
|
assert.deepEqual(alte.map((g) => g.name), ['Bleibt']);
|
|
|
|
// Und ohne Nachfolger ist "redesignt" hinfällig.
|
|
const zurueck = await db.prisma.character.findUniqueOrThrow({
|
|
where: { id: eins.character.id },
|
|
});
|
|
assert.equal(zurueck.status, 'ACTIVE');
|
|
assert.equal(zurueck.inactiveReason, null);
|
|
|
|
const allein = await db.prisma.character.findUniqueOrThrow({
|
|
where: { id: zwei.character.id },
|
|
});
|
|
assert.equal(allein.predecessorId, null, 'allein heisst ohne Vorgänger');
|
|
});
|
|
|
|
it('macht aus einem herausgelösten Redesign einen eigenen Platz - und zeigt das Minus', async () => {
|
|
// Allein gestellt ist ein Redesign kein Redesign mehr, sondern ein
|
|
// eigener CC. Also wird es auch als solcher gebucht. Reicht das Guthaben
|
|
// nicht, geht das Konto ins Minus - sichtbar, wie bei jedem Vorab-CC.
|
|
const spieler = await spielerMitPaketen('Knapp', 1);
|
|
const eins = await characters.createCharacter(spieler, { name: 'Original' }, ctx);
|
|
await credits.bookRedesignPackage(spieler, ctx);
|
|
const zwei = await characters.redesignCharacter(
|
|
eins.character.id,
|
|
{ name: 'Neue Fassung', useKind: 'REDESIGN_PAID' },
|
|
ctx,
|
|
);
|
|
|
|
await characters.detachFromChain(zwei.character.id, ctx);
|
|
|
|
const verbrauch = await db.prisma.creditLedger.findFirst({
|
|
where: { characterId: zwei.character.id, source: 'CONSUME', reversedBy: null },
|
|
orderBy: { id: 'desc' },
|
|
});
|
|
assert.equal(verbrauch?.kind, 'CC', 'ein eigener Platz kostet ein CC-Paket');
|
|
|
|
const stand = await credits.getBalances(spieler);
|
|
assert.equal(stand.CC, -1, 'dafür fehlt jetzt eines - und das steht sichtbar da');
|
|
assert.equal(
|
|
stand.REDESIGN_PAID,
|
|
2,
|
|
'das verbrauchte Redesign kommt zurück - eines aus dem CC-Paket, eines gekauft',
|
|
);
|
|
});
|
|
|
|
it('räumt die Redesign-Markierung ab, wenn der Nachfolger wegzieht', async () => {
|
|
// Genau der Fall aus dem Betrieb: das Redesign gehörte in eine andere
|
|
// Kette. Was zurückbleibt, ist von niemandem mehr ersetzt worden.
|
|
const spieler = await spielerMitPaketen('Umsortiert', 2);
|
|
const platzA = await characters.createCharacter(spieler, { name: 'Platz A' }, ctx);
|
|
const platzB = await characters.createCharacter(spieler, { name: 'Platz B' }, ctx);
|
|
|
|
await credits.bookRedesignPackage(spieler, ctx);
|
|
const redesign = await characters.redesignCharacter(
|
|
platzB.character.id,
|
|
{ name: 'Redesign von B', useKind: 'REDESIGN_PAID' },
|
|
ctx,
|
|
);
|
|
|
|
// B ist jetzt redesignt. Nun stellt sich heraus: das Redesign gehört zu A.
|
|
await characters.moveInChain(
|
|
redesign.character.id,
|
|
{ lineageId: platzA.character.lineageId, afterId: platzA.character.id },
|
|
ctx,
|
|
);
|
|
|
|
const b = await db.prisma.character.findUniqueOrThrow({ where: { id: platzB.character.id } });
|
|
assert.equal(b.status, 'ACTIVE', 'ohne Nachfolger ist B wieder der aktuelle CC');
|
|
assert.equal(b.inactiveReason, null);
|
|
|
|
const a = await db.prisma.character.findUniqueOrThrow({ where: { id: platzA.character.id } });
|
|
assert.equal(a.inactiveReason, 'REWORKED', 'A hat jetzt einen Nachfolger');
|
|
});
|
|
|
|
it('lässt keinen CC in die Kette eines Unbeteiligten', async () => {
|
|
const einer = await spielerMitPaketen('Einer', 1);
|
|
const anderer = await spielerMitPaketen('Anderer', 1);
|
|
const seiner = await characters.createCharacter(einer, { name: 'Seiner' }, ctx);
|
|
const fremder = await characters.createCharacter(anderer, { name: 'Fremder' }, ctx);
|
|
|
|
await assert.rejects(
|
|
() =>
|
|
characters.moveInChain(
|
|
fremder.character.id,
|
|
{ lineageId: seiner.character.lineageId, afterId: seiner.character.id },
|
|
ctx,
|
|
),
|
|
(error: Error & { code?: string }) => error.code === 'FOREIGN_CHAIN',
|
|
);
|
|
});
|
|
|
|
it('reiht einen Gruppen-CC in die Kette eines seiner Nutzer ein', async () => {
|
|
// Ein Gruppen-CC gehört dem, der ihn angelegt hat. Seine Geschichte
|
|
// gehört allen, die darin steckten: wer seinen eigenen CC aufgegeben
|
|
// hat, um dazuzukommen, ordnet damit seine eigene Kette.
|
|
const wirt = await spielerMitPaketen('Gruppen-Wirt', 2);
|
|
const gast = await spielerMitPaketen('Gruppen-Gast', 2);
|
|
|
|
const gruppe = await characters.createCharacter(wirt, { name: 'Die Werkstatt' }, ctx);
|
|
await characters.addMember(gruppe.character.id, gast, { useKind: 'CC' }, ctx);
|
|
|
|
const eigener = await characters.createCharacter(gast, { name: 'Sein Vorläufer' }, ctx);
|
|
|
|
await characters.moveInChain(
|
|
gruppe.character.id,
|
|
{
|
|
lineageId: eigener.character.lineageId,
|
|
afterId: eigener.character.id,
|
|
forPlayerId: gast,
|
|
},
|
|
ctx,
|
|
);
|
|
|
|
const glieder = await kette(gast, eigener.character.lineageId);
|
|
assert.deepEqual(
|
|
glieder.map((g) => g.name),
|
|
['Sein Vorläufer', 'Die Werkstatt'],
|
|
'der Gruppen-CC steht jetzt hinter seinem Vorläufer',
|
|
);
|
|
verkettungStimmt(glieder);
|
|
|
|
// Und der Vorläufer gilt damit als ersetzt.
|
|
const vorlaeufer = await db.prisma.character.findUniqueOrThrow({
|
|
where: { id: eigener.character.id },
|
|
});
|
|
assert.equal(vorlaeufer.inactiveReason, 'REWORKED');
|
|
});
|
|
|
|
it('lässt einen Unbeteiligten auch dann nicht heran', async () => {
|
|
const wirt = await spielerMitPaketen('Wirt zwei', 1);
|
|
const gast = await spielerMitPaketen('Gast zwei', 1);
|
|
const fremder = await spielerMitPaketen('Ganz fremd', 1);
|
|
|
|
const gruppe = await characters.createCharacter(wirt, { name: 'Geschlossen' }, ctx);
|
|
const fremdesCC = await characters.createCharacter(fremder, { name: 'Nicht dabei' }, ctx);
|
|
|
|
await assert.rejects(
|
|
() =>
|
|
characters.moveInChain(
|
|
fremdesCC.character.id,
|
|
{
|
|
lineageId: gruppe.character.lineageId,
|
|
afterId: gruppe.character.id,
|
|
forPlayerId: fremder,
|
|
},
|
|
ctx,
|
|
),
|
|
(error: Error & { code?: string }) => error.code === 'FOREIGN_CHAIN',
|
|
'wer an der Gruppe nie beteiligt war, ordnet dort auch nichts',
|
|
);
|
|
|
|
// Auch nicht, wenn er sich als jemand anderes ausgibt: der Gast war zwar
|
|
// an der Gruppe beteiligt, nicht aber am fremden CC.
|
|
await characters.addMember(gruppe.character.id, gast, { useKind: 'CC' }, ctx);
|
|
await assert.rejects(
|
|
() =>
|
|
characters.moveInChain(
|
|
fremdesCC.character.id,
|
|
{
|
|
lineageId: gruppe.character.lineageId,
|
|
afterId: gruppe.character.id,
|
|
forPlayerId: gast,
|
|
},
|
|
ctx,
|
|
),
|
|
(error: Error & { code?: string }) => error.code === 'FOREIGN_CHAIN',
|
|
);
|
|
});
|
|
|
|
it('lässt einen CC nicht hinter sich selbst stellen', async () => {
|
|
const spieler = await spielerMitPaketen('Schlange', 1);
|
|
const cc = await characters.createCharacter(spieler, { name: 'Beisst sich' }, ctx);
|
|
|
|
await assert.rejects(
|
|
() =>
|
|
characters.moveInChain(
|
|
cc.character.id,
|
|
{ lineageId: cc.character.lineageId, afterId: cc.character.id },
|
|
ctx,
|
|
),
|
|
(error: Error & { code?: string }) => error.code === 'SELF_REFERENCE',
|
|
);
|
|
});
|
|
|
|
it('macht aus einem eingereihten CC einen redesignten', async () => {
|
|
const spieler = await spielerMitPaketen('Unberuehrt', 2);
|
|
const aktiv = await characters.createCharacter(spieler, { name: 'Aktiv' }, ctx);
|
|
const ziel = await characters.createCharacter(spieler, { name: 'Ziel' }, ctx);
|
|
|
|
await characters.moveInChain(
|
|
aktiv.character.id,
|
|
{ lineageId: ziel.character.lineageId, afterId: ziel.character.id },
|
|
ctx,
|
|
);
|
|
|
|
const danach = await db.prisma.character.findUniqueOrThrow({
|
|
where: { id: aktiv.character.id },
|
|
});
|
|
assert.equal(danach.status, 'ACTIVE', 'am Ende der Kette bleibt er der aktuelle');
|
|
|
|
const davor = await db.prisma.character.findUniqueOrThrow({ where: { id: ziel.character.id } });
|
|
assert.equal(davor.inactiveReason, 'REWORKED', 'wer einen Nachfolger bekommt, ist ersetzt');
|
|
});
|
|
});
|
|
|
|
describe('Verweise an einem CC', () => {
|
|
it('hängt einen Verweis an und gibt ihn mit aus', async () => {
|
|
const spieler = await spielerMitPaketen('Verweiser', 1);
|
|
const cc = await characters.createCharacter(spieler, { name: 'Mit Addon' }, ctx);
|
|
|
|
await characters.addLink(
|
|
cc.character.id,
|
|
{ label: 'Ersteller-Addon', url: 'https://steamcommunity.com/sharedfiles/?id=42' },
|
|
ctx,
|
|
);
|
|
|
|
const liste = await characters.listCharacters(spieler);
|
|
const gefunden = liste.find((c) => c.id === cc.character.id);
|
|
assert.deepEqual(
|
|
gefunden?.links.map((l) => l.label),
|
|
['Ersteller-Addon'],
|
|
);
|
|
});
|
|
|
|
it('weist eine Adresse ab, die keine ist', async () => {
|
|
const spieler = await spielerMitPaketen('Vorsichtig', 1);
|
|
const cc = await characters.createCharacter(spieler, { name: 'Sauber' }, ctx);
|
|
|
|
for (const url of ['javascript:alert(1)', 'nur-text', 'file:///etc/passwd']) {
|
|
await assert.rejects(
|
|
() => characters.addLink(cc.character.id, { label: 'Test', url }, ctx),
|
|
(error: Error & { code?: string }) => error.code === 'INVALID_URL',
|
|
`"${url}" hätte abgewiesen werden müssen`,
|
|
);
|
|
}
|
|
});
|
|
|
|
it('lässt sich ändern und entfernen', async () => {
|
|
const spieler = await spielerMitPaketen('Pfleger', 1);
|
|
const cc = await characters.createCharacter(spieler, { name: 'Gepflegt' }, ctx);
|
|
const link = await characters.addLink(
|
|
cc.character.id,
|
|
{ label: 'Alt', url: 'https://example.org/alt' },
|
|
ctx,
|
|
);
|
|
|
|
const geaendert = await characters.updateLink(
|
|
link.id,
|
|
{ label: 'Neu', url: 'https://example.org/neu' },
|
|
ctx,
|
|
);
|
|
assert.equal(geaendert.label, 'Neu');
|
|
assert.equal(geaendert.url, 'https://example.org/neu');
|
|
|
|
await characters.removeLink(link.id, ctx);
|
|
assert.equal(await db.prisma.characterLink.count({ where: { characterId: cc.character.id } }), 0);
|
|
});
|
|
});
|
|
|
|
describe('Redesign nachträglich umbuchen', () => {
|
|
it('stellt von bezahlt auf Kulanz um, ohne die Geschichte zu verbiegen', async () => {
|
|
const spieler = await spielerMitPaketen('Umbucher', 1);
|
|
const eins = await characters.createCharacter(spieler, { name: 'Erste Fassung' }, ctx);
|
|
const zwei = await characters.redesignCharacter(
|
|
eins.character.id,
|
|
{ name: 'Zweite Fassung', useKind: 'REDESIGN_PAID' },
|
|
ctx,
|
|
);
|
|
assert.equal(zwei.balances.REDESIGN_PAID, 0, 'das bezahlte ist verbraucht');
|
|
|
|
await credits.bookKulanz(spieler, ctx, { note: 'Absprache aufgetaucht' });
|
|
const danach = await characters.changeFunding(zwei.character.id, 'REDESIGN_KULANZ', ctx);
|
|
|
|
assert.equal(danach.balances.REDESIGN_PAID, 1, 'das bezahlte ist wieder frei');
|
|
assert.equal(danach.balances.REDESIGN_KULANZ, 0, 'dafür ist die Kulanz verbraucht');
|
|
|
|
// Korrigiert wird durch Gegenbuchung, nicht durch Überschreiben.
|
|
const zeilen = await db.prisma.creditLedger.findMany({
|
|
where: { characterId: zwei.character.id },
|
|
orderBy: { id: 'asc' },
|
|
});
|
|
assert.equal(zeilen.length, 3, 'Verbrauch, Storno, neuer Verbrauch');
|
|
assert.equal(zeilen[1]?.source, 'REVERSAL');
|
|
assert.equal(zeilen[2]?.kind, 'REDESIGN_KULANZ');
|
|
});
|
|
|
|
it('und wieder zurück', async () => {
|
|
const spieler = await spielerMitPaketen('Hin und her', 1);
|
|
const eins = await characters.createCharacter(spieler, { name: 'A' }, ctx);
|
|
await credits.bookKulanz(spieler, ctx, { note: 'Erstmal Kulanz' });
|
|
const zwei = await characters.redesignCharacter(
|
|
eins.character.id,
|
|
{ name: 'B', useKind: 'REDESIGN_KULANZ' },
|
|
ctx,
|
|
);
|
|
|
|
const danach = await characters.changeFunding(zwei.character.id, 'REDESIGN_PAID', ctx);
|
|
assert.equal(danach.balances.REDESIGN_KULANZ, 1);
|
|
assert.equal(danach.balances.REDESIGN_PAID, 0, 'das aus dem CC-Paket ist jetzt dran');
|
|
});
|
|
|
|
it('weist dieselbe Art ab', async () => {
|
|
const spieler = await spielerMitPaketen('Unveraendert', 1);
|
|
const eins = await characters.createCharacter(spieler, { name: 'X' }, ctx);
|
|
const zwei = await characters.redesignCharacter(
|
|
eins.character.id,
|
|
{ name: 'Y', useKind: 'REDESIGN_PAID' },
|
|
ctx,
|
|
);
|
|
|
|
await assert.rejects(
|
|
() => characters.changeFunding(zwei.character.id, 'REDESIGN_PAID', ctx),
|
|
(error: Error & { code?: string }) => error.code === 'SAME_KIND',
|
|
);
|
|
});
|
|
|
|
it('macht aus einem Kettenanfang kein Redesign', async () => {
|
|
// Ob ein Eintrag ein Redesign ist, entscheidet seine Stelle in der Kette.
|
|
// Sonst ginge die Rechnung "eine Kette = ein CC-Paket" nicht mehr auf.
|
|
const spieler = await spielerMitPaketen('Umsteller', 1);
|
|
const eins = await characters.createCharacter(spieler, { name: 'Direkt aus dem Paket' }, ctx);
|
|
await credits.bookRedesignPackage(spieler, ctx);
|
|
|
|
await assert.rejects(
|
|
() => characters.changeFunding(eins.character.id, 'REDESIGN_PAID', ctx),
|
|
(error: Error & { code?: string }) => error.code === 'CHAIN_START',
|
|
'dafür wird er eingereiht, nicht umgebucht',
|
|
);
|
|
|
|
const stand = await credits.getBalances(spieler);
|
|
assert.equal(stand.CC, 0, 'der Platz bleibt gebucht');
|
|
assert.equal(stand.REDESIGN_PAID, 2, 'und das Redesign unangetastet');
|
|
});
|
|
});
|
|
|
|
describe('Angaben eines CCs nachtragen', () => {
|
|
it('ändert Namen und Beschreibung', async () => {
|
|
const spieler = await spielerMitPaketen('Korrigierer', 1);
|
|
const cc = await characters.createCharacter(spieler, { name: 'Tipfehler' }, ctx);
|
|
|
|
await characters.updateCharacter(
|
|
cc.character.id,
|
|
{ name: 'Tippfehler behoben', loreText: 'Jetzt mit Beschreibung' },
|
|
ctx,
|
|
);
|
|
|
|
const danach = await db.prisma.character.findUniqueOrThrow({ where: { id: cc.character.id } });
|
|
assert.equal(danach.name, 'Tippfehler behoben');
|
|
assert.equal(danach.loreText, 'Jetzt mit Beschreibung');
|
|
});
|
|
|
|
it('hängt einen Antrag an, den der Abgleich schon kennt', async () => {
|
|
const spieler = await spielerMitPaketen('Verknuepfer', 1);
|
|
const cc = await characters.createCharacter(spieler, { name: 'Mit Antrag' }, ctx);
|
|
|
|
await db.prisma.forumThread.create({
|
|
data: {
|
|
threadId: 7771,
|
|
title: 'Sein Antrag',
|
|
url: 'https://forum.example.org/index.php?thread/7771-sein-antrag/',
|
|
currentBoard: 'CC_ARCHIV',
|
|
},
|
|
});
|
|
|
|
await characters.updateCharacter(
|
|
cc.character.id,
|
|
{ threadRef: 'https://forum.example.org/index.php?thread/7771-sein-antrag/' },
|
|
ctx,
|
|
);
|
|
|
|
const thread = await db.prisma.forumThread.findUniqueOrThrow({ where: { threadId: 7771 } });
|
|
assert.equal(thread.linkedCharacterId, cc.character.id);
|
|
assert.equal(thread.needsClassification, false, 'er ist damit erledigt');
|
|
});
|
|
|
|
it('merkt einen unbekannten Antrag vor', async () => {
|
|
const spieler = await spielerMitPaketen('Vormerker', 1);
|
|
const cc = await characters.createCharacter(spieler, { name: 'Noch ungelesen' }, ctx);
|
|
|
|
// Nur die Nummer, und der Abgleich kennt den Thread nicht.
|
|
await characters.updateCharacter(cc.character.id, { threadRef: '8888' }, ctx);
|
|
|
|
const thread = await db.prisma.forumThread.findUniqueOrThrow({ where: { threadId: 8888 } });
|
|
assert.equal(thread.linkedCharacterId, cc.character.id);
|
|
assert.equal(
|
|
thread.needsClassification,
|
|
false,
|
|
'sonst stünde er beim nächsten Lauf als offener Antrag da',
|
|
);
|
|
});
|
|
|
|
it('löst eine Verknüpfung wieder', async () => {
|
|
const spieler = await spielerMitPaketen('Loeser2', 1);
|
|
const cc = await characters.createCharacter(spieler, { name: 'Doch nicht' }, ctx);
|
|
await characters.updateCharacter(cc.character.id, { threadRef: '9999' }, ctx);
|
|
|
|
await characters.updateCharacter(cc.character.id, { threadRef: null }, ctx);
|
|
|
|
const thread = await db.prisma.forumThread.findUniqueOrThrow({ where: { threadId: 9999 } });
|
|
assert.equal(thread.linkedCharacterId, null);
|
|
|
|
const liste = await characters.listCharacters(spieler);
|
|
const eintrag = liste.find((c) => c.id === cc.character.id);
|
|
assert.deepEqual(eintrag?.forumThreads, [], 'am CC hängt kein Antrag mehr');
|
|
});
|
|
|
|
it('weist eine Eingabe ab, die keine Thread-Angabe ist', async () => {
|
|
const spieler = await spielerMitPaketen('Vertipper', 1);
|
|
const cc = await characters.createCharacter(spieler, { name: 'Sauber' }, ctx);
|
|
|
|
await assert.rejects(
|
|
() => characters.updateCharacter(cc.character.id, { threadRef: 'keine ahnung' }, ctx),
|
|
(error: Error & { code?: string }) => error.code === 'INVALID_THREAD',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('Die Stelle in der Kette bestimmt, was ein Eintrag kostet', () => {
|
|
/** Offene Verbrauchsbuchung eines CCs. */
|
|
async function verbrauch(characterId: number) {
|
|
return db.prisma.creditLedger.findFirst({
|
|
where: { characterId, source: 'CONSUME', reversedBy: null },
|
|
orderBy: { id: 'desc' },
|
|
});
|
|
}
|
|
|
|
it('macht aus einem eigenständigen CC ein Redesign, sobald er eingereiht wird', async () => {
|
|
const spieler = await spielerMitPaketen('Einreiher', 2);
|
|
const erster = await characters.createCharacter(spieler, { name: 'Der Erste' }, ctx);
|
|
const zweiter = await characters.createCharacter(spieler, { name: 'Der Zweite' }, ctx);
|
|
|
|
// Beide sind für sich gekauft.
|
|
assert.equal((await verbrauch(erster.character.id))?.kind, 'CC');
|
|
assert.equal((await verbrauch(zweiter.character.id))?.kind, 'CC');
|
|
|
|
await credits.bookRedesignPackage(spieler, ctx);
|
|
const vorher = await credits.getBalances(spieler);
|
|
|
|
await characters.moveInChain(
|
|
zweiter.character.id,
|
|
{ lineageId: erster.character.lineageId, afterId: erster.character.id },
|
|
ctx,
|
|
);
|
|
|
|
assert.equal(
|
|
(await verbrauch(zweiter.character.id))?.kind,
|
|
'REDESIGN_PAID',
|
|
'über einem anderen Eintrag ist es ein Redesign',
|
|
);
|
|
|
|
const danach = await credits.getBalances(spieler);
|
|
assert.equal(danach.CC, vorher.CC + 1, 'das CC-Guthaben kommt zurück');
|
|
assert.equal(danach.REDESIGN_PAID, vorher.REDESIGN_PAID - 1, 'dafür geht ein Redesign weg');
|
|
});
|
|
|
|
it('und wieder zurück, sobald er allein steht', async () => {
|
|
const spieler = await spielerMitPaketen('Herausloeser', 2);
|
|
const erster = await characters.createCharacter(spieler, { name: 'Bleibt unten' }, ctx);
|
|
const zweiter = await characters.createCharacter(spieler, { name: 'Wandert' }, ctx);
|
|
|
|
await characters.moveInChain(
|
|
zweiter.character.id,
|
|
{ lineageId: erster.character.lineageId, afterId: erster.character.id },
|
|
ctx,
|
|
);
|
|
assert.equal((await verbrauch(zweiter.character.id))?.kind, 'REDESIGN_PAID');
|
|
|
|
await characters.detachFromChain(zweiter.character.id, ctx);
|
|
|
|
assert.equal(
|
|
(await verbrauch(zweiter.character.id))?.kind,
|
|
'CC',
|
|
'allein heisst eigener Platz, und der kostet ein CC-Paket',
|
|
);
|
|
});
|
|
|
|
it('bucht das neue unterste Glied auf CC um', async () => {
|
|
// Ein Redesign wird an den Anfang gezogen - es ist damit der gekaufte CC.
|
|
const spieler = await spielerMitPaketen('Umkehrer', 2);
|
|
const eins = await characters.createCharacter(spieler, { name: 'War unten' }, ctx);
|
|
const zwei = await characters.redesignCharacter(
|
|
eins.character.id,
|
|
{ name: 'War oben', useKind: 'REDESIGN_PAID' },
|
|
ctx,
|
|
);
|
|
assert.equal((await verbrauch(zwei.character.id))?.kind, 'REDESIGN_PAID');
|
|
|
|
await characters.moveInChain(
|
|
zwei.character.id,
|
|
{ lineageId: eins.character.lineageId, afterId: null },
|
|
ctx,
|
|
);
|
|
|
|
assert.equal((await verbrauch(zwei.character.id))?.kind, 'CC', 'unten steht der gekaufte CC');
|
|
assert.equal(
|
|
(await verbrauch(eins.character.id))?.kind,
|
|
'REDESIGN_PAID',
|
|
'und der andere ist nach oben gerückt',
|
|
);
|
|
});
|
|
|
|
it('lässt eine Kulanz-Entscheidung unangetastet', async () => {
|
|
const spieler = await spielerMitPaketen('Kulanzfall', 2);
|
|
const eins = await characters.createCharacter(spieler, { name: 'Basis' }, ctx);
|
|
await credits.bookKulanz(spieler, ctx, { note: 'Absprache' });
|
|
const zwei = await characters.redesignCharacter(
|
|
eins.character.id,
|
|
{ name: 'Aus Kulanz', useKind: 'REDESIGN_KULANZ' },
|
|
ctx,
|
|
);
|
|
|
|
// Ein Verschieben innerhalb der Kette ändert an der Stelle nichts.
|
|
const dritter = await characters.createCharacter(spieler, { name: 'Kommt dazu' }, ctx);
|
|
await characters.moveInChain(
|
|
dritter.character.id,
|
|
{ lineageId: eins.character.lineageId, afterId: zwei.character.id },
|
|
ctx,
|
|
);
|
|
|
|
assert.equal(
|
|
(await verbrauch(zwei.character.id))?.kind,
|
|
'REDESIGN_KULANZ',
|
|
'bezahlt oder Kulanz ist eine Entscheidung, keine Folge der Reihenfolge',
|
|
);
|
|
});
|
|
|
|
it('trägt eine fehlende Buchung nach', async () => {
|
|
// Jedes Glied einer Liste kostet: das unterste ein Paket, jedes weitere
|
|
// ein Redesign. Wo nichts gebucht ist, fehlt es in der Rechnung.
|
|
const spieler = await spielerMitPaketen('Ohne Buchung', 1);
|
|
const eins = await characters.createCharacter(spieler, { name: 'Mit Buchung' }, ctx);
|
|
|
|
const ohne = await db.prisma.character.create({
|
|
data: { playerId: spieler, name: 'Ohne Buchung', status: 'ACTIVE' },
|
|
});
|
|
await db.prisma.characterMember.create({
|
|
data: { characterId: ohne.id, playerId: spieler },
|
|
});
|
|
|
|
const vorher = await credits.getBalances(spieler);
|
|
await characters.moveInChain(
|
|
ohne.id,
|
|
{ lineageId: eins.character.lineageId, afterId: eins.character.id },
|
|
ctx,
|
|
);
|
|
|
|
const danach = await credits.getBalances(spieler);
|
|
assert.equal(danach.CC, vorher.CC, 'der Platz bleibt, wie er war');
|
|
assert.equal(
|
|
danach.REDESIGN_PAID,
|
|
vorher.REDESIGN_PAID - 1,
|
|
'das zweite Glied kostet ein Redesign',
|
|
);
|
|
|
|
// Und allein gestellt kostet er wieder einen eigenen Platz.
|
|
await characters.detachFromChain(ohne.id, ctx);
|
|
const allein = await credits.getBalances(spieler);
|
|
assert.equal(allein.CC, vorher.CC - 1, 'eine eigene Liste, ein eigenes Paket');
|
|
assert.equal(allein.REDESIGN_PAID, vorher.REDESIGN_PAID, 'das Redesign ist wieder frei');
|
|
});
|
|
});
|
|
|
|
describe('Eine Kette mit Vorgeschichte ausserhalb', () => {
|
|
/**
|
|
* Der Fall aus dem Betrieb: Ein Spieler tritt einem fremden Gruppen-CC
|
|
* bei, steigt später per Redesign aus und führt seinen eigenen CC weiter.
|
|
* Dessen Kette beginnt nicht bei null - sie hat eine Herkunft, die
|
|
* woanders liegt.
|
|
*/
|
|
async function ausgestiegen() {
|
|
const dargon = await spielerMitPaketen('Aussteiger', 3);
|
|
const wirt = await spielerMitPaketen('Gruppen-Wirt', 1);
|
|
|
|
await characters.createCharacter(dargon, { name: 'Platz eins' }, ctx);
|
|
await characters.createCharacter(dargon, { name: 'Platz zwei' }, ctx);
|
|
|
|
const fremd = await characters.createCharacter(wirt, { name: 'Fremde Gruppe' }, ctx);
|
|
await characters.addMember(fremd.character.id, dargon, { useKind: 'CC' }, ctx);
|
|
|
|
await credits.bookRedesignPackage(dargon, ctx);
|
|
const eigenes = await characters.redesignMembership(
|
|
fremd.character.id,
|
|
dargon,
|
|
{ name: 'Eigener Strang', useKind: 'REDESIGN_PAID' },
|
|
ctx,
|
|
);
|
|
|
|
await credits.bookRedesignPackage(dargon, ctx);
|
|
const weiter = await characters.redesignCharacter(
|
|
eigenes.character.id,
|
|
{ name: 'Und weiter', useKind: 'REDESIGN_PAID' },
|
|
ctx,
|
|
);
|
|
|
|
return { dargon, fremd: fremd.character, eigenes: eigenes.character, weiter: weiter.character };
|
|
}
|
|
|
|
it('zählt drei Plätze und drei verbrauchte Pakete', async () => {
|
|
const { dargon } = await ausgestiegen();
|
|
|
|
const stand = await credits.getBalances(dargon);
|
|
assert.equal(stand.granted.CC, 3, 'drei Pakete gekauft');
|
|
assert.equal(stand.CC, 0, 'drei Plätze belegt, keiner zu viel');
|
|
});
|
|
|
|
it('bucht das erste Glied einer hergeleiteten Kette nicht auf CC um', async () => {
|
|
// Es ist zwar das unterste seiner Kette, aber kein Kauf: es ging aus dem
|
|
// Gruppen-CC hervor. Wer das übersieht, bucht einen Platz doppelt.
|
|
const { dargon, eigenes, weiter } = await ausgestiegen();
|
|
const vorher = await credits.getBalances(dargon);
|
|
|
|
// Ein Verschieben innerhalb der Kette ordnet neu - und darf dabei nichts
|
|
// erfinden.
|
|
await characters.moveInChain(
|
|
weiter.id,
|
|
{ lineageId: eigenes.lineageId, afterId: eigenes.id, forPlayerId: dargon },
|
|
ctx,
|
|
);
|
|
|
|
assert.deepEqual(
|
|
await credits.getBalances(dargon),
|
|
vorher,
|
|
'am Konto ändert sich dabei nichts',
|
|
);
|
|
|
|
const buchung = await db.prisma.creditLedger.findFirst({
|
|
where: { characterId: eigenes.id, source: 'CONSUME', reversedBy: null },
|
|
orderBy: { id: 'desc' },
|
|
});
|
|
assert.equal(buchung?.kind, 'REDESIGN_PAID', 'es bleibt ein Redesign');
|
|
});
|
|
|
|
it('lässt auch den fremden Gruppen-CC selbst einreihen', async () => {
|
|
// Beim Nacherfassen muss sich einordnen lassen, was einem nicht gehört:
|
|
// Erst am Ende steht fest, welches Redesign wohin führte.
|
|
const { dargon, fremd, eigenes } = await ausgestiegen();
|
|
|
|
await characters.moveInChain(
|
|
fremd.id,
|
|
{ lineageId: eigenes.lineageId, afterId: null, forPlayerId: dargon },
|
|
ctx,
|
|
);
|
|
|
|
const glieder = await kette(dargon, eigenes.lineageId);
|
|
assert.deepEqual(
|
|
glieder.map((g) => g.name),
|
|
['Fremde Gruppe', 'Eigener Strang', 'Und weiter'],
|
|
'der Gruppen-CC steht jetzt am Anfang der Kette',
|
|
);
|
|
verkettungStimmt(glieder);
|
|
|
|
// Sein Platz bleibt einer: die Beitrittsbuchung wird zum Kauf, das
|
|
// Herausredesignen zum Redesign - nicht beides zum Kauf.
|
|
const stand = await credits.getBalances(dargon);
|
|
assert.equal(stand.CC, 0, 'drei Pakete, drei Plätze');
|
|
});
|
|
|
|
it('behält den Verweis auf die Herkunft', async () => {
|
|
const { dargon, eigenes, weiter } = await ausgestiegen();
|
|
|
|
await characters.moveInChain(
|
|
weiter.id,
|
|
{ lineageId: eigenes.lineageId, afterId: eigenes.id, forPlayerId: dargon },
|
|
ctx,
|
|
);
|
|
|
|
const danach = await db.prisma.character.findUniqueOrThrow({ where: { id: eigenes.id } });
|
|
assert.ok(
|
|
danach.predecessorId,
|
|
'sonst wäre nicht mehr zu sehen, woher die Kette kommt',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('Die Rechnung geht auf', () => {
|
|
/**
|
|
* Die Regel in einem Satz: **So viele CC-Pakete wie Ketten, so viele
|
|
* Redesigns wie Einträge minus Ketten.** Bei drei Ketten mit zusammen
|
|
* sechzehn Einträgen sind das drei Pakete und dreizehn Redesigns.
|
|
*/
|
|
async function zaehle(playerId: number) {
|
|
const eintraege = await db.prisma.characterMember.findMany({
|
|
where: { playerId },
|
|
select: { character: { select: { predecessorId: true } } },
|
|
});
|
|
|
|
const ketten = eintraege.filter((e) => e.character.predecessorId === null).length;
|
|
return { eintraege: eintraege.length, ketten };
|
|
}
|
|
|
|
async function verbraeuche(playerId: number) {
|
|
const zeilen = await db.prisma.creditLedger.findMany({
|
|
where: { playerId, source: 'CONSUME', reversedBy: null },
|
|
select: { kind: true },
|
|
});
|
|
return {
|
|
cc: zeilen.filter((z) => z.kind === 'CC').length,
|
|
redesign: zeilen.filter((z) => z.kind !== 'CC').length,
|
|
};
|
|
}
|
|
|
|
it('bucht so viele Pakete wie Ketten und den Rest als Redesign', async () => {
|
|
const spieler = await spielerMitPaketen('Rechenbeispiel', 3);
|
|
|
|
// Drei Ketten mit 3, 2 und 1 Eintrag: zusammen sechs.
|
|
const ketten: number[] = [];
|
|
for (const [name, laenge] of [
|
|
['Kette A', 3],
|
|
['Kette B', 2],
|
|
['Kette C', 1],
|
|
] as const) {
|
|
const erster = await characters.createCharacter(spieler, { name: `${name} 1` }, ctx);
|
|
let letzter = erster.character.id;
|
|
for (let i = 2; i <= laenge; i += 1) {
|
|
await credits.bookRedesignPackage(spieler, ctx);
|
|
const naechster = await characters.redesignCharacter(
|
|
letzter,
|
|
{ name: `${name} ${i}`, useKind: 'REDESIGN_PAID' },
|
|
ctx,
|
|
);
|
|
letzter = naechster.character.id;
|
|
}
|
|
ketten.push(erster.character.id);
|
|
}
|
|
|
|
const struktur = await zaehle(spieler);
|
|
assert.equal(struktur.ketten, 3);
|
|
assert.equal(struktur.eintraege, 6);
|
|
|
|
const gebucht = await verbraeuche(spieler);
|
|
assert.equal(gebucht.cc, struktur.ketten, 'so viele Pakete wie Ketten');
|
|
assert.equal(
|
|
gebucht.redesign,
|
|
struktur.eintraege - struktur.ketten,
|
|
'und der Rest sind Redesigns',
|
|
);
|
|
});
|
|
|
|
it('rechnet einen verbogenen Bestand wieder gerade', async () => {
|
|
const spieler = await spielerMitPaketen('Verbogen', 2);
|
|
const eins = await characters.createCharacter(spieler, { name: 'Anfang' }, ctx);
|
|
await credits.bookRedesignPackage(spieler, ctx);
|
|
const zwei = await characters.redesignCharacter(
|
|
eins.character.id,
|
|
{ name: 'Fortsetzung', useKind: 'REDESIGN_PAID' },
|
|
ctx,
|
|
);
|
|
|
|
// So sah es aus, bevor die Buchung der Kette folgte: das zweite Glied
|
|
// stand als eigener Kauf im Konto.
|
|
const falsch = await db.prisma.creditLedger.findFirstOrThrow({
|
|
where: { characterId: zwei.character.id, source: 'CONSUME', reversedBy: null },
|
|
});
|
|
await db.prisma.creditLedger.update({ where: { id: falsch.id }, data: { kind: 'CC' } });
|
|
|
|
const vorher = await verbraeuche(spieler);
|
|
assert.equal(vorher.cc, 2, 'zwei Käufe bei nur einer Kette - das kann nicht stimmen');
|
|
|
|
const ergebnis = await characters.rebuildFunding(spieler, ctx);
|
|
assert.equal(ergebnis.umgebucht, 1);
|
|
|
|
const struktur = await zaehle(spieler);
|
|
const gebucht = await verbraeuche(spieler);
|
|
assert.equal(gebucht.cc, struktur.ketten, 'jetzt passt es wieder');
|
|
assert.equal(gebucht.redesign, struktur.eintraege - struktur.ketten);
|
|
});
|
|
|
|
it('fasst nichts an, wenn schon alles stimmt', async () => {
|
|
const spieler = await spielerMitPaketen('Sauber', 1);
|
|
const eins = await characters.createCharacter(spieler, { name: 'Passt' }, ctx);
|
|
await credits.bookRedesignPackage(spieler, ctx);
|
|
await characters.redesignCharacter(
|
|
eins.character.id,
|
|
{ name: 'Passt auch', useKind: 'REDESIGN_PAID' },
|
|
ctx,
|
|
);
|
|
|
|
const vorher = await credits.getBalances(spieler);
|
|
const ergebnis = await characters.rebuildFunding(spieler, ctx);
|
|
|
|
assert.equal(ergebnis.umgebucht, 0);
|
|
assert.deepEqual(ergebnis.balances, vorher);
|
|
});
|
|
|
|
it('lässt eine Kulanz-Entscheidung auch beim Nachrechnen stehen', async () => {
|
|
const spieler = await spielerMitPaketen('Kulanz bleibt', 1);
|
|
const eins = await characters.createCharacter(spieler, { name: 'Start' }, ctx);
|
|
await credits.bookKulanz(spieler, ctx, { note: 'Absprache' });
|
|
const zwei = await characters.redesignCharacter(
|
|
eins.character.id,
|
|
{ name: 'Aus Kulanz', useKind: 'REDESIGN_KULANZ' },
|
|
ctx,
|
|
);
|
|
|
|
await characters.rebuildFunding(spieler, ctx);
|
|
|
|
const buchung = await db.prisma.creditLedger.findFirst({
|
|
where: { characterId: zwei.character.id, source: 'CONSUME', reversedBy: null },
|
|
orderBy: { id: 'desc' },
|
|
});
|
|
assert.equal(buchung?.kind, 'REDESIGN_KULANZ');
|
|
});
|
|
});
|
|
|
|
describe('Kulanz gegen ein Minus verrechnen', () => {
|
|
/** Ein Spieler mit Redesigns im Minus und Kulanz auf der Seite. */
|
|
async function schieflage() {
|
|
const spieler = await spielerMitPaketen('Schieflage', 1);
|
|
const erster = await characters.createCharacter(spieler, { name: 'Anfang' }, ctx);
|
|
|
|
// Vier Redesigns, aber nur eines bezahlt - der Topf geht ins Minus.
|
|
let letzter = erster.character.id;
|
|
for (let i = 2; i <= 5; i += 1) {
|
|
const naechster = await characters.redesignCharacter(
|
|
letzter,
|
|
{ name: `Stufe ${i}`, useKind: 'REDESIGN_PAID' },
|
|
ctx,
|
|
);
|
|
letzter = naechster.character.id;
|
|
}
|
|
|
|
const stand = await credits.getBalances(spieler);
|
|
assert.equal(stand.REDESIGN_PAID, -3, 'vier verbraucht, eines aus dem Paket');
|
|
return spieler;
|
|
}
|
|
|
|
it('gleicht so viel aus, wie beide Seiten hergeben', async () => {
|
|
const spieler = await schieflage();
|
|
for (let i = 0; i < 2; i += 1) {
|
|
await credits.bookKulanz(spieler, ctx, { note: 'Nachträglich zugestanden' });
|
|
}
|
|
|
|
const ergebnis = await characters.settleWithKulanz(spieler, ctx);
|
|
|
|
assert.equal(ergebnis.umgebucht, 2, 'zwei Kulanzen, zwei Übergänge');
|
|
assert.equal(ergebnis.balances.REDESIGN_PAID, -1, 'das Minus schrumpft um zwei');
|
|
assert.equal(ergebnis.balances.REDESIGN_KULANZ, 0, 'die Kulanz ist aufgebraucht');
|
|
assert.equal(
|
|
ergebnis.balances.redesignTotal,
|
|
-1,
|
|
'unterm Strich bleibt dasselbe - nur anders verteilt',
|
|
);
|
|
});
|
|
|
|
it('nimmt die ältesten Übergänge zuerst', async () => {
|
|
const spieler = await schieflage();
|
|
await credits.bookKulanz(spieler, ctx, { note: 'Eine Geste' });
|
|
|
|
await characters.settleWithKulanz(spieler, ctx);
|
|
|
|
const kulanzZeile = await db.prisma.creditLedger.findFirstOrThrow({
|
|
where: { playerId: spieler, kind: 'REDESIGN_KULANZ', amount: { lt: 0 } },
|
|
include: { character: { select: { name: true } } },
|
|
});
|
|
assert.equal(
|
|
kulanzZeile.character?.name,
|
|
'Stufe 2',
|
|
'wer nachträglich Kulanz bekommt, bekommt sie für das Älteste',
|
|
);
|
|
});
|
|
|
|
it('meldet sich, wenn es nichts auszugleichen gibt', async () => {
|
|
const spieler = await spielerMitPaketen('Im Plus', 1);
|
|
await credits.bookKulanz(spieler, ctx, { note: 'Übrig' });
|
|
|
|
await assert.rejects(
|
|
() => characters.settleWithKulanz(spieler, ctx),
|
|
(error: Error & { code?: string }) => error.code === 'NOTHING_TO_SETTLE',
|
|
);
|
|
});
|
|
|
|
it('meldet sich, wenn keine Kulanz da ist', async () => {
|
|
const spieler = await schieflage();
|
|
|
|
await assert.rejects(
|
|
() => characters.settleWithKulanz(spieler, ctx),
|
|
(error: Error & { code?: string }) => error.code === 'NOTHING_TO_SETTLE',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('Ein CC-Guthaben für ein Redesign einsetzen', () => {
|
|
async function verbrauchtAls(characterId: number): Promise<string | undefined> {
|
|
const buchung = await db.prisma.creditLedger.findFirst({
|
|
where: { characterId, source: 'CONSUME', reversedBy: null },
|
|
orderBy: { id: 'desc' },
|
|
});
|
|
return buchung?.kind;
|
|
}
|
|
|
|
it('geht nur über eine Umwandlung, nicht direkt am CC', async () => {
|
|
const spieler = await spielerMitPaketen('Direktzahler', 2);
|
|
const eins = await characters.createCharacter(spieler, { name: 'Basis' }, ctx);
|
|
|
|
await assert.rejects(
|
|
() =>
|
|
characters.redesignCharacter(eins.character.id, { name: 'Zweites', useKind: 'CC' }, ctx),
|
|
(error: Error & { code?: string }) => error.code === 'CONVERT_FIRST',
|
|
'ein Redesign wird nicht aus dem CC-Topf bezahlt',
|
|
);
|
|
});
|
|
|
|
it('tauscht einen Platz gegen ein bezahltes Redesign', async () => {
|
|
const spieler = await spielerMitPaketen('Umwandler', 2);
|
|
const vorher = await credits.getBalances(spieler);
|
|
|
|
const danach = await credits.convertCcToRedesign(spieler, ctx);
|
|
|
|
assert.equal(danach.balances.CC, vorher.CC - 1, 'ein Platz weniger');
|
|
assert.equal(danach.balances.REDESIGN_PAID, vorher.REDESIGN_PAID + 1, 'ein Redesign mehr');
|
|
assert.equal(
|
|
danach.balances.granted.CC,
|
|
vorher.granted.CC,
|
|
'gekauft wurde deswegen nicht weniger - der Platz ist verbraucht',
|
|
);
|
|
assert.equal(
|
|
danach.balances.granted.REDESIGN_PAID,
|
|
vorher.granted.REDESIGN_PAID + 1,
|
|
'und das eingetauschte Redesign ist erhalten worden',
|
|
);
|
|
});
|
|
|
|
it('setzt das umgewandelte Redesign ein wie jedes andere', async () => {
|
|
const spieler = await spielerMitPaketen('Einsetzer', 2);
|
|
const eins = await characters.createCharacter(spieler, { name: 'Erster' }, ctx);
|
|
|
|
// Beide Redesigns aus den Paketen erst verbrauchen.
|
|
const zwei = await characters.redesignCharacter(
|
|
eins.character.id,
|
|
{ name: 'Zweiter', useKind: 'REDESIGN_PAID' },
|
|
ctx,
|
|
);
|
|
const drei = await characters.redesignCharacter(
|
|
zwei.character.id,
|
|
{ name: 'Dritter', useKind: 'REDESIGN_PAID' },
|
|
ctx,
|
|
);
|
|
assert.equal((await credits.getBalances(spieler)).REDESIGN_PAID, 0);
|
|
|
|
await credits.convertCcToRedesign(spieler, ctx);
|
|
const vier = await characters.redesignCharacter(
|
|
drei.character.id,
|
|
{ name: 'Vierter', useKind: 'REDESIGN_PAID' },
|
|
ctx,
|
|
);
|
|
|
|
assert.equal(await verbrauchtAls(vier.character.id), 'REDESIGN_PAID');
|
|
const stand = await credits.getBalances(spieler);
|
|
assert.equal(stand.REDESIGN_PAID, 0, 'das umgewandelte ist aufgebraucht');
|
|
assert.equal(stand.CC, 0, 'und der Platz dafür weg');
|
|
});
|
|
|
|
it('lässt sich zurücknehmen wie jede andere Handeingabe', async () => {
|
|
const spieler = await spielerMitPaketen('Reumuetig', 1);
|
|
const vorher = await credits.getBalances(spieler);
|
|
|
|
await credits.convertCcToRedesign(spieler, ctx);
|
|
const danach = await credits.removeLastGrant(spieler, 'CONVERSION', ctx);
|
|
|
|
assert.equal(danach.balances.CC, vorher.CC, 'der Platz ist wieder da');
|
|
assert.equal(danach.balances.REDESIGN_PAID, vorher.REDESIGN_PAID, 'das Redesign wieder weg');
|
|
});
|
|
|
|
it('lässt die Bezahlart eines Redesigns nicht auf den CC-Topf stellen', async () => {
|
|
const spieler = await spielerMitPaketen('Umsteller zwei', 1);
|
|
const eins = await characters.createCharacter(spieler, { name: 'A' }, ctx);
|
|
const zwei = await characters.redesignCharacter(
|
|
eins.character.id,
|
|
{ name: 'B', useKind: 'REDESIGN_PAID' },
|
|
ctx,
|
|
);
|
|
|
|
await assert.rejects(
|
|
() => characters.changeFunding(zwei.character.id, 'CC', ctx),
|
|
(error: Error & { code?: string }) => error.code === 'CONVERT_FIRST',
|
|
);
|
|
|
|
// Zwischen bezahlt und Kulanz bleibt die Wahl.
|
|
await credits.bookKulanz(spieler, ctx);
|
|
const umgestellt = await characters.changeFunding(zwei.character.id, 'REDESIGN_KULANZ', ctx);
|
|
assert.equal(await verbrauchtAls(zwei.character.id), 'REDESIGN_KULANZ');
|
|
assert.equal(umgestellt.balances.REDESIGN_KULANZ, 0, 'die Kulanz ist verbraucht');
|
|
});
|
|
});
|
|
|
|
describe('Buchungen an die Ketten angleichen', () => {
|
|
it('bucht den Anfang einer Kette auf einen Platz', async () => {
|
|
// Der Fall aus dem Bestand: drei Ketten, aber nur zwei gebuchte Plätze -
|
|
// bei einer war der unterste Eintrag als Redesign gebucht.
|
|
const spieler = await spielerMitPaketen('Schieflage', 2);
|
|
const eins = await characters.createCharacter(spieler, { name: 'Kettenfuss' }, ctx);
|
|
await db.prisma.creditLedger.updateMany({
|
|
where: { characterId: eins.character.id, source: 'CONSUME' },
|
|
data: { kind: 'REDESIGN_PAID' },
|
|
});
|
|
|
|
const abweichend = await db.prisma.creditLedger.findMany({
|
|
where: { source: 'CONSUME', reversedBy: null },
|
|
select: { kind: true, playerId: true, character: { select: { name: true, playerId: true, predecessorId: true } } },
|
|
});
|
|
for (const e of abweichend) {
|
|
const cc = e.character;
|
|
if (!cc) continue;
|
|
const soll = cc.playerId === e.playerId ? cc.predecessorId === null : null;
|
|
if (soll !== null && (e.kind === 'CC') !== soll) {
|
|
console.log('ABWEICHUNG', cc.name, e.kind, 'predecessor', cc.predecessorId, 'spieler', e.playerId, 'besitzer', cc.playerId);
|
|
}
|
|
}
|
|
const angeglichen = await characters.richteBuchungenAus();
|
|
assert.equal(angeglichen, 1, 'genau diese eine Buchung passte nicht');
|
|
|
|
const buchung = await db.prisma.creditLedger.findFirst({
|
|
where: { characterId: eins.character.id, source: 'CONSUME', reversedBy: null },
|
|
orderBy: { id: 'desc' },
|
|
});
|
|
assert.equal(buchung?.kind, 'CC', 'wer unten steht, hat einen Platz gekauft');
|
|
|
|
const stand = await credits.getBalances(spieler);
|
|
assert.equal(stand.CC, 1, 'ein Platz belegt, einer frei');
|
|
assert.equal(stand.REDESIGN_PAID, 2, 'das Redesign ist wieder frei');
|
|
});
|
|
|
|
it('lässt den zweiten Lauf alles, wie es ist', async () => {
|
|
const spieler = await spielerMitPaketen('Ruhig', 1);
|
|
const eins = await characters.createCharacter(spieler, { name: 'Unten' }, ctx);
|
|
await characters.redesignCharacter(
|
|
eins.character.id,
|
|
{ name: 'Oben', useKind: 'REDESIGN_PAID' },
|
|
ctx,
|
|
);
|
|
|
|
assert.equal(await characters.richteBuchungenAus(), 0, 'es passt bereits alles');
|
|
const vorher = await db.prisma.creditLedger.count({ where: { playerId: spieler } });
|
|
await characters.richteBuchungenAus();
|
|
assert.equal(
|
|
await db.prisma.creditLedger.count({ where: { playerId: spieler } }),
|
|
vorher,
|
|
'und es entstehen keine Zeilen aus dem Nichts',
|
|
);
|
|
});
|
|
|
|
it('erkennt den aufgegebenen CC eines Mitglieds', async () => {
|
|
// Für ein Mitglied steht der Vorgänger an der Mitgliedschaft, nicht am
|
|
// Gruppen-CC. Sonst würde sein Redesign hier zu einem Platz.
|
|
const gruender = await spielerMitPaketen('Wirt', 1);
|
|
const gast = await spielerMitPaketen('Gast', 1);
|
|
const gruppe = await characters.createCharacter(gruender, { name: 'Stammtisch' }, ctx);
|
|
const eigener = await characters.createCharacter(gast, { name: 'Eigener' }, ctx);
|
|
|
|
await credits.bookRedesignPackage(gast, ctx);
|
|
await characters.addMember(
|
|
gruppe.character.id,
|
|
gast,
|
|
{ useKind: 'REDESIGN_PAID', predecessorId: eigener.character.id },
|
|
ctx,
|
|
);
|
|
|
|
const vorher = await credits.getBalances(gast);
|
|
assert.equal(await characters.richteBuchungenAus(), 0, 'sein Beitritt war ein Redesign');
|
|
assert.deepEqual(await credits.getBalances(gast), vorher);
|
|
});
|
|
});
|
|
|
|
describe('Die Umstellung auf Listen je Nutzer', () => {
|
|
const MIGRATION = join(
|
|
dirname(fileURLToPath(import.meta.url)),
|
|
'..',
|
|
'prisma',
|
|
'migrations',
|
|
'20250117000000_kette_je_nutzer',
|
|
'migration.sql',
|
|
);
|
|
|
|
/**
|
|
* Die Migration noch einmal anwenden.
|
|
*
|
|
* Jeder Schritt greift nur, wo noch keine Liste steht - so lässt sich der
|
|
* Weg nachstellen, den der Bestand beim Aktualisieren nimmt. Zerlegt wird
|
|
* an Semikolons, wobei ein DO-Block am Stück bleibt.
|
|
*/
|
|
async function migrationAusfuehren(): Promise<void> {
|
|
const sql = readFileSync(MIGRATION, 'utf8');
|
|
let puffer = '';
|
|
for (const teil of sql.split(';')) {
|
|
puffer += `${teil};`;
|
|
if ((puffer.match(/\$\$/g) ?? []).length % 2 !== 0) continue;
|
|
if (puffer.replace(/;/g, '').trim()) await db.prisma.$executeRawUnsafe(puffer);
|
|
puffer = '';
|
|
}
|
|
}
|
|
|
|
it('ordnet einen Bestand ohne Listen wieder richtig zu', async () => {
|
|
const wirt = await spielerMitPaketen('Altbestand-Wirt', 1);
|
|
const gast = await spielerMitPaketen('Altbestand-Gast', 2);
|
|
|
|
// Eine Kette des Wirts, in der der Gast ab dem zweiten Glied mitzieht.
|
|
const eins = await characters.createCharacter(wirt, { name: 'Alt-Eins' }, ctx);
|
|
const zwei = await characters.redesignCharacter(
|
|
eins.character.id,
|
|
{ name: 'Alt-Zwei', useKind: 'REDESIGN_PAID' },
|
|
ctx,
|
|
);
|
|
const drei = await characters.redesignCharacter(
|
|
zwei.character.id,
|
|
{ name: 'Alt-Drei', useKind: 'REDESIGN_PAID' },
|
|
ctx,
|
|
);
|
|
|
|
const eigener = await characters.createCharacter(gast, { name: 'Gasts Alter' }, ctx);
|
|
await credits.bookRedesignPackage(gast, ctx);
|
|
await characters.addMemberToChain(
|
|
zwei.character.id,
|
|
gast,
|
|
{ useKind: 'REDESIGN_PAID', predecessorId: eigener.character.id },
|
|
ctx,
|
|
);
|
|
|
|
const vorher = new Map(
|
|
(
|
|
await db.prisma.characterMember.findMany({
|
|
select: { id: true, lineageId: true, chainPosition: true },
|
|
})
|
|
).map((m) => [m.id, m]),
|
|
);
|
|
|
|
// Zurück auf den Stand vor der Umstellung.
|
|
await db.prisma.$executeRawUnsafe(
|
|
'ALTER TABLE "CharacterMember" ALTER COLUMN "lineageId" DROP NOT NULL',
|
|
);
|
|
await db.prisma.$executeRawUnsafe(
|
|
'UPDATE "CharacterMember" SET "lineageId" = NULL, "chainPosition" = 0',
|
|
);
|
|
|
|
await migrationAusfuehren();
|
|
|
|
const danach = await db.prisma.characterMember.findMany({
|
|
where: { playerId: gast },
|
|
orderBy: { chainPosition: 'asc' },
|
|
select: { characterId: true, lineageId: true, character: { select: { name: true } } },
|
|
});
|
|
|
|
assert.deepEqual(
|
|
danach.map((m) => m.character.name),
|
|
['Gasts Alter', 'Alt-Zwei', 'Alt-Drei'],
|
|
'der Gast führt seinen alten CC und die Glieder, ab denen er dabei war',
|
|
);
|
|
assert.equal(
|
|
new Set(danach.map((m) => m.lineageId)).size,
|
|
1,
|
|
'und zwar in einer einzigen Liste',
|
|
);
|
|
|
|
// Für den Wirt steht die Kette wieder so, wie sie war.
|
|
const wirtDanach = await db.prisma.characterMember.findMany({
|
|
where: { playerId: wirt },
|
|
orderBy: { chainPosition: 'asc' },
|
|
select: { id: true, lineageId: true, chainPosition: true },
|
|
});
|
|
for (const m of wirtDanach) {
|
|
assert.deepEqual(
|
|
{ lineageId: m.lineageId, chainPosition: m.chainPosition },
|
|
{
|
|
lineageId: vorher.get(m.id)?.lineageId,
|
|
chainPosition: vorher.get(m.id)?.chainPosition,
|
|
},
|
|
);
|
|
}
|
|
|
|
for (const wer of [wirt, gast]) {
|
|
assert.equal(
|
|
(await characters.rebuildFunding(wer, ctx)).umgebucht,
|
|
0,
|
|
'und die Buchungen passen danach zu den Listen',
|
|
);
|
|
}
|
|
|
|
// Der Gast zahlt für seinen Einstieg ein Redesign, nicht einen Platz.
|
|
assert.equal(
|
|
(
|
|
await db.prisma.creditLedger.findFirst({
|
|
where: {
|
|
characterId: zwei.character.id,
|
|
playerId: gast,
|
|
source: 'CONSUME',
|
|
reversedBy: null,
|
|
},
|
|
})
|
|
)?.kind,
|
|
'REDESIGN_PAID',
|
|
);
|
|
assert.ok(drei.character.id > 0);
|
|
});
|
|
});
|