Ein Gruppen-CC wird fuer jeden Nutzer einzeln gerechnet: fuer den einen kann er der gekaufte Platz sein, fuer den anderen ein Redesign seines vorherigen CCs. Dafuer haengt die Liste jetzt an der Mitgliedschaft statt am CC, und verschieben ordnet nur die eigene Liste. Ein Redesign wird nie aus dem CC-Topf bezahlt. Wer sein CC-Guthaben dafuer einsetzt, wandelt es um: ein Platz weniger, ein bezahltes Redesign mehr. Damit entfaellt die Ausnahme, die sich bisher durch Slot-Pruefung und Kettenrechnung zog. Beim Start werden Buchungen einmalig an die Listen angeglichen.
1362 lines
51 KiB
TypeScript
1362 lines
51 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('lässt einen Eintrag ohne Buchung in Ruhe', async () => {
|
|
// Nacherfassung ohne Guthaben-Angabe - da gibt es nichts umzustellen.
|
|
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,
|
|
);
|
|
|
|
assert.deepEqual(await credits.getBalances(spieler), vorher, 'nichts gebucht, nichts geändert');
|
|
});
|
|
});
|
|
|
|
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('wandelt es um, statt eine Ausnahme zu buchen', async () => {
|
|
const spieler = await spielerMitPaketen('Ausnahmefall', 2);
|
|
const eins = await characters.createCharacter(spieler, { name: 'Basis' }, ctx);
|
|
|
|
const vorher = await credits.getBalances(spieler);
|
|
const zwei = await characters.redesignCharacter(
|
|
eins.character.id,
|
|
{ name: 'Aus dem CC-Topf', useKind: 'CC' },
|
|
ctx,
|
|
);
|
|
|
|
assert.equal(zwei.balances.CC, vorher.CC - 1, 'der Platz ist weg');
|
|
assert.equal(
|
|
zwei.balances.REDESIGN_PAID,
|
|
vorher.REDESIGN_PAID,
|
|
'das eingetauschte Redesign wird sofort wieder verbraucht',
|
|
);
|
|
assert.equal(
|
|
await verbrauchtAls(zwei.character.id),
|
|
'REDESIGN_PAID',
|
|
'der Eintrag selbst ist ein Redesign wie jedes andere',
|
|
);
|
|
assert.equal(
|
|
zwei.balances.granted.REDESIGN_PAID,
|
|
vorher.granted.REDESIGN_PAID + 1,
|
|
'und das umgewandelte Guthaben ist als erhalten ausgewiesen',
|
|
);
|
|
assert.equal(zwei.balances.granted.CC, vorher.granted.CC, 'die Käufe bleiben, wie sie waren');
|
|
});
|
|
|
|
it('bleibt beim Ausrichten an den Ketten stehen', async () => {
|
|
const spieler = await spielerMitPaketen('Bleibt so', 2);
|
|
const eins = await characters.createCharacter(spieler, { name: 'Erstes' }, ctx);
|
|
const zwei = await characters.redesignCharacter(
|
|
eins.character.id,
|
|
{ name: 'Zweites', useKind: 'CC' },
|
|
ctx,
|
|
);
|
|
|
|
const vorher = await credits.getBalances(spieler);
|
|
const lauf = await characters.rebuildFunding(spieler, ctx);
|
|
|
|
assert.equal(lauf.umgebucht, 0, 'es passt bereits alles');
|
|
assert.equal(await verbrauchtAls(zwei.character.id), 'REDESIGN_PAID');
|
|
assert.deepEqual(lauf.balances, vorher, 'und am Guthaben ändert sich nichts');
|
|
});
|
|
|
|
it('gibt den Tausch zurück, wenn der Eintrag wieder allein steht', async () => {
|
|
const spieler = await spielerMitPaketen('Rueckzieher', 2);
|
|
const eins = await characters.createCharacter(spieler, { name: 'A' }, ctx);
|
|
const zwei = await characters.redesignCharacter(
|
|
eins.character.id,
|
|
{ name: 'B', useKind: 'CC' },
|
|
ctx,
|
|
);
|
|
|
|
await characters.detachFromChain(zwei.character.id, ctx);
|
|
|
|
assert.equal(await verbrauchtAls(zwei.character.id), 'CC', 'allein steht er für sich');
|
|
const stand = await credits.getBalances(spieler);
|
|
assert.equal(stand.CC, 0, 'beide Plätze sind belegt');
|
|
assert.equal(stand.REDESIGN_PAID, 2, 'die Redesigns sind wieder frei');
|
|
});
|
|
|
|
it('lässt sich auch nachträglich einsetzen', async () => {
|
|
const spieler = await spielerMitPaketen('Von Hand', 2);
|
|
const eins = await characters.createCharacter(spieler, { name: 'A' }, ctx);
|
|
const zwei = await characters.redesignCharacter(
|
|
eins.character.id,
|
|
{ name: 'B', useKind: 'REDESIGN_PAID' },
|
|
ctx,
|
|
);
|
|
|
|
const danach = await characters.changeFunding(zwei.character.id, 'CC', ctx);
|
|
|
|
assert.equal(await verbrauchtAls(zwei.character.id), 'REDESIGN_PAID');
|
|
assert.equal(danach.balances.CC, 0, 'der Platz ist eingetauscht');
|
|
assert.equal(danach.balances.REDESIGN_PAID, 2, 'das Redesign dafür wieder frei');
|
|
|
|
// Und zurück: der Tausch wird rückgängig gemacht.
|
|
const zurueck = await characters.changeFunding(zwei.character.id, 'REDESIGN_PAID', ctx);
|
|
assert.equal(zurueck.balances.CC, 1, 'der Platz kommt zurück');
|
|
assert.equal(zurueck.balances.REDESIGN_PAID, 1, 'und das Redesign wird wieder 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,
|
|
},
|
|
);
|
|
}
|
|
|
|
assert.equal(
|
|
await characters.richteBuchungenAus(),
|
|
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);
|
|
});
|
|
});
|