Die Rechnung auf eine Regel bringen, plus Knopf zum Nachrechnen

Die Regel lautet: so viele CC-Pakete wie Ketten, so viele Redesigns wie
Eintraege minus Ketten. Anders gesagt - wer keinen Vorgaenger hat, ist der
Anfang einer Kette und damit ein gekaufter CC; wer einen hat, ist dessen
Redesign. In welcher Linie ein Eintrag gefuehrt wird, spielt keine Rolle.

Ich hatte das mit einer Sonderbehandlung fuer "Herkunft ausserhalb der Linie"
gebaut. Die faellt weg: Sie war ein Umweg um dieselbe Regel und hat sie an
einer Stelle verfehlt. Jetzt steht sie direkt im Code, und der Fall mit dem
fremden Gruppen-CC folgt von selbst daraus - dessen eigener Strang hat einen
Vorgaenger, ist also kein Kauf.

Fuer Bestaende, die aus einer Zeit stammen, in der eine Buchung blieb, wo sie
beim Anlegen entstand, gibt es "Buchungen nachrechnen" auf der Spielerseite.
Es richtet alle Buchungen eines Spielers nach dieser Regel aus, fasst nur an,
was nicht passt, bucht ueber Gegenbuchungen um und laesst Kulanz-
Entscheidungen stehen.

Vier Tests halten die Rechnung fest, einer davon genau in der Form, in der
sie formuliert wurde: Ketten zaehlen, Eintraege zaehlen, Buchungen zaehlen,
vergleichen.
This commit is contained in:
2026-09-21 19:26:00 +02:00
parent 46e339fdc4
commit f0213fe4bc
5 changed files with 273 additions and 27 deletions
+10
View File
@@ -26,6 +26,7 @@ import {
detachFromChain,
changeFunding,
addMemberToChain,
rebuildFunding,
} from '../services/characters.js';
import { parseThreadId } from '../lib/forum.js';
import { linkThreadToCharacter } from '../services/forum.js';
@@ -198,6 +199,15 @@ export async function characterRoutes(app: FastifyInstance): Promise<void> {
return changeFunding(id, body.useKind, contextOf(request));
});
/**
* Rechnet die Buchungen eines Spielers anhand seiner Ketten nach: jede
* Kette beginnt mit einem gekauften CC, alles Weitere sind Redesigns.
*/
app.post('/api/players/:id/credits/rebuild', { preHandler: requireEditor }, async (request) => {
const { id } = parseWith(idParam, request.params);
return rebuildFunding(id, contextOf(request));
});
// --- Ketten ---
/**
+71 -19
View File
@@ -942,33 +942,16 @@ async function verketteNeu(
const kettenBesitzer = glieder[0]?.playerId ?? null;
const inDieserLinie = new Set(glieder.map((g) => g.id));
/**
* Eine Herkunft ausserhalb dieser Kette.
*
* Wer aus einem fremden Gruppen-CC herausredesignt, beginnt eine eigene
* Kette - bezahlt hat er sie trotzdem mit einem Redesign, nicht mit einem
* Paket. Ihr unterstes Glied ist deshalb kein Kauf, und der Verweis auf
* die Herkunft bleibt stehen: er ist das Einzige, was noch zeigt, woher
* die Kette kommt.
*/
const herkunft = glieder[0]?.predecessorId ?? null;
const hatHerkunft = herkunft !== null && !inDieserLinie.has(herkunft);
let vorheriges: number | null = null;
for (const [index, glied] of glieder.entries()) {
const letztes = index === glieder.length - 1;
const erstes = index === 0;
const daten: Prisma.CharacterUpdateInput = {};
// Das unterste Glied ist der gekaufte CC - es sei denn, ihm ging etwas
// voraus. Alles darüber sind seine Redesigns.
if (kettenBesitzer !== null) {
await buchungFolgtPosition(tx, glied, kettenBesitzer, erstes && !hatHerkunft, actorId);
}
if (erstes) {
// Am Anfang wird nur eine Verkettung gelöst, die innerhalb dieser
// Linie ins Leere zeigt. Eine Herkunft von aussen bleibt.
// Linie ins Leere zeigt. Eine Herkunft von aussen bleibt: sie ist das
// Einzige, was noch zeigt, woher die Kette kommt.
if (glied.predecessorId !== null && inDieserLinie.has(glied.predecessorId)) {
daten.predecessor = { disconnect: true };
}
@@ -976,6 +959,20 @@ async function verketteNeu(
daten.predecessor = { connect: { id: vorheriges as number } };
}
// Wer nach dem Ordnen keinen Vorgänger hat, ist der Anfang einer Kette
// und damit ein gekaufter CC; wer einen hat, ist dessen Redesign. Ob
// der Vorgänger in derselben Linie geführt wird, spielt keine Rolle -
// die Kette ist die Kette.
const vorgaengerDanach = erstes
? inDieserLinie.has(glied.predecessorId ?? -1)
? null
: glied.predecessorId
: vorheriges;
if (kettenBesitzer !== null) {
await buchungFolgtPosition(tx, glied, kettenBesitzer, vorgaengerDanach === null, actorId);
}
// Was einen Nachfolger hat, wurde durch ihn ersetzt - anders ist eine
// Kette nicht zu lesen. Ein Glied mittendrin kann also nicht aktiv sein
// und auch nicht "geputzt": es ist redesignt, Punkt.
@@ -1001,6 +998,61 @@ async function verketteNeu(
}
}
/**
* Rechnet alle Buchungen eines Spielers anhand der Ketten nach.
*
* Die Regel ist schlicht: **Jede Kette beginnt mit einem gekauften CC, alles
* Weitere darin sind Redesigns.** Bei drei Ketten mit zusammen sechzehn
* Einträgen sind das drei CC-Pakete und dreizehn Redesigns.
*
* Gebraucht wird das, wenn ein Bestand aus einer Zeit stammt, in der eine
* Buchung noch blieb, wo sie beim Anlegen entstand - oder wenn beim
* Nacherfassen viel umsortiert wurde. Angefasst wird nur, was nicht passt,
* und umgebucht wird wie sonst auch: über eine Gegenbuchung. Zwischen
* bezahltem Redesign und Kulanz wird nichts gewechselt.
*/
export async function rebuildFunding(
playerId: number,
ctx: ActionContext,
): Promise<{ geprueft: number; umgebucht: number; balances: Balances }> {
const mitgliedschaften = await prisma.characterMember.findMany({
where: { playerId },
select: { character: { select: { id: true, name: true, predecessorId: true } } },
});
let umgebucht = 0;
await prisma.$transaction(async (tx) => {
for (const { character } of mitgliedschaften) {
// Kein Vorgänger heisst: hier fing eine Kette an, also ein gekaufter
// CC. Sonst ist es das Redesign seines Vorgängers.
const geaendert = await buchungFolgtPosition(
tx,
character,
playerId,
character.predecessorId === null,
ctx.actorId,
);
if (geaendert) umgebucht += 1;
}
if (umgebucht > 0) {
await recordAudit(tx, ctx, {
action: 'character.funding_rebuilt',
targetType: 'player',
targetId: playerId,
details: { geprueft: mitgliedschaften.length, umgebucht },
});
}
});
return {
geprueft: mitgliedschaften.length,
umgebucht,
balances: await getBalances(playerId),
};
}
/**
* Verschiebt einen CC innerhalb seiner Kette oder in eine andere.
*