Ein Spieler mit drei Paketen und drei Plaetzen stand bei "-1 von 3". Der dritte Platz kam aus einem fremden Gruppen-CC: beigetreten mit einem CC-Guthaben, spaeter per Redesign ausgestiegen und als eigener CC weitergefuehrt. Damit beginnt zwar eine neue Kette, aber kein neuer Platz - bezahlt ist er laengst, naemlich mit dem Beitritt. Die Automatik sah nur "unterstes Glied" und buchte es auf CC-Paket um. Der Platz stand damit zweimal im Konto, einmal fuer den Beitritt und einmal fuer den eigenen CC. Jetzt zaehlt, ob der Kette etwas vorausging: Zeigt ihr erstes Glied auf einen Vorgaenger ausserhalb, ist auch dieses Glied ein Redesign. Dabei fiel ein zweiter Fehler auf: Das Neuverketten loeschte genau diesen Verweis, weil es die Verkettung innerhalb der Linie normalisierte. Damit ging die einzige Spur zur Herkunft verloren - und mit ihr der Eintrag "war dabei" am Fuss der Kette. Ein Vorgaenger ausserhalb bleibt jetzt stehen. Fuer die Korrektur bereits falsch gebuchter Eintraege kann "Bezahlart" jetzt auch zwischen CC-Paket und Redesign wechseln, nicht nur zwischen bezahlt und Kulanz. Der Knopf steht an jedem Eintrag, nicht nur an Redesigns.
815 lines
31 KiB
TypeScript
815 lines
31 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import { after, before, describe, it } from 'node:test';
|
|
import { startTestDatabase, type TestDatabase } from './harness.js';
|
|
|
|
type Characters = typeof import('../src/services/characters.js');
|
|
type Credits = typeof import('../src/services/credits.js');
|
|
type Db = typeof import('../src/db.js');
|
|
|
|
let database: TestDatabase;
|
|
let characters: Characters;
|
|
let credits: Credits;
|
|
let db: Db;
|
|
let ctx: { actorId: number; actorName: string };
|
|
|
|
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.character.findMany({
|
|
where: { lineageId },
|
|
orderBy: [{ chainPosition: 'asc' }, { id: 'asc' }],
|
|
select: { id: true, name: true, predecessorId: true, chainPosition: true },
|
|
});
|
|
return glieder;
|
|
}
|
|
|
|
/** 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, 1, 'Absprache aufgetaucht', ctx);
|
|
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, 1, 'Erstmal Kulanz', ctx);
|
|
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('stellt einen gekauften CC auf Redesign um', async () => {
|
|
// Genau der Korrekturfall: ein Eintrag war gar nicht der Anfang einer
|
|
// Kette, sondern ging aus einem anderen hervor.
|
|
const spieler = await spielerMitPaketen('Umsteller', 1);
|
|
const eins = await characters.createCharacter(spieler, { name: 'Direkt aus dem Paket' }, ctx);
|
|
await credits.bookRedesignPackage(spieler, ctx);
|
|
|
|
const danach = await characters.changeFunding(eins.character.id, 'REDESIGN_PAID', ctx);
|
|
assert.equal(danach.balances.CC, 1, 'das CC-Guthaben kommt zurück');
|
|
assert.equal(danach.balances.REDESIGN_PAID, 1, 'dafür geht ein Redesign weg');
|
|
|
|
await assert.rejects(
|
|
() => characters.changeFunding(eins.character.id, 'REDESIGN_PAID', ctx),
|
|
(error: Error & { code?: string }) => error.code === 'SAME_KIND',
|
|
'ein CC aus dem Paket ist schon als CC gebucht',
|
|
);
|
|
});
|
|
});
|
|
|
|
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, 1, 'Absprache', ctx);
|
|
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, 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('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',
|
|
);
|
|
});
|
|
});
|