Die Stelle in der Kette bestimmt die Buchung

Bisher blieb eine Buchung, wie sie beim Anlegen entstand - auch wenn der
Eintrag danach an eine ganz andere Stelle wanderte. Damit stand ein CC, der
in eine Kette geschoben wurde, weiter als gekauftes Paket im Konto, obwohl er
inzwischen ein Redesign war.

Jetzt folgt die Buchung der Position: Das unterste Glied ist der gekaufte CC
und verbraucht ein CC-Paket, alles darueber sind Redesigns. Wandert ein
Eintrag, wandert seine Bedeutung mit - in beide Richtungen. Wer mitten in eine
Kette rutscht, wird zum Redesign und gibt sein CC-Guthaben zurueck; wer an den
Anfang kommt oder herausgeloest wird, belegt einen eigenen Platz und kostet
wieder ein Paket.

Umgebucht wird ueber eine Gegenbuchung, nicht durch Ueberschreiben. Reicht das
Guthaben nicht, geht das Konto ins Minus und zeigt es an - wie bei jedem
Vorab-CC. Zwischen bezahltem Redesign und Kulanz wird nicht gewechselt: das
ist eine Entscheidung, keine Folge der Reihenfolge. Eintraege ohne Buchung
bleiben unangetastet.

Dabei zaehlt die Buchung dessen, dem die Kette gehoert - der Spieler, der
unten steht. Bei einem fremden Gruppen-CC in seiner Kette ist das seine
Beitrittsbuchung; die des Erstellers bleibt, denn fuer den ist es sein eigener
Platz in seiner eigenen Kette.

Damit liess sich der Gruppen-CC ueberhaupt erst einreihen: Der Besitzer-Check
fragte nach dem Ersteller, und der ist bei einem Gruppen-CC ein anderer als
der, dessen Seite man gerade ordnet. Jetzt entscheidet die Beteiligung - wer
an beidem beteiligt war, darf einreihen, ein Unbeteiligter nicht. Fehler beim
Ziehen stehen ausserdem neben den Ketten statt am Seitenkopf: wer unten zieht,
sieht dort oben nichts.
This commit is contained in:
2026-09-21 18:38:11 +02:00
parent 2b78a972bc
commit 2d1c008c0a
5 changed files with 432 additions and 27 deletions
+235 -11
View File
@@ -39,10 +39,13 @@ async function spielerMitPaketen(name: string, pakete = 1) {
return player.id;
}
/** Die Kette eines Spielers, wie sie angezeigt würde. */
async function kette(playerId: number, lineageId: string) {
/**
* 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: { playerId, lineageId },
where: { lineageId },
orderBy: [{ chainPosition: 'asc' }, { id: 'asc' }],
select: { id: true, name: true, predecessorId: true, chainPosition: true },
});
@@ -189,9 +192,10 @@ describe('Lücken in einer Redesign-Kette schliessen', () => {
assert.equal(allein.predecessorId, null, 'allein heisst ohne Vorgänger');
});
it('lässt ein Redesign nicht ohne eigenen Platz allein stehen', async () => {
// Ein Redesign hat keine eigene CC-Buchung - es tauscht einen Platz aus.
// Allein gestellt braucht es einen, und der muss gedeckt sein.
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);
@@ -201,10 +205,20 @@ describe('Lücken in einer Redesign-Kette schliessen', () => {
ctx,
);
await assert.rejects(
() => characters.detachFromChain(zwei.character.id, ctx),
(error: Error & { code?: string }) => error.code === 'NO_FREE_SLOT',
'sonst stünden zwei aktive CCs auf einem Paket',
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',
);
});
@@ -237,7 +251,7 @@ describe('Lücken in einer Redesign-Kette schliessen', () => {
assert.equal(a.inactiveReason, 'REWORKED', 'A hat jetzt einen Nachfolger');
});
it('lässt keinen CC in die Kette eines anderen Spielers', async () => {
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);
@@ -254,6 +268,84 @@ describe('Lücken in einer Redesign-Kette schliessen', () => {
);
});
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);
@@ -494,3 +586,135 @@ describe('Angaben eines CCs nachtragen', () => {
);
});
});
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');
});
});