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
+151 -10
View File
@@ -832,18 +832,121 @@ export async function changeRedesignKind(
* Umsortieren wird er deshalb aus den Positionen neu abgeleitet - dann können
* beide nicht auseinanderlaufen.
*/
async function verketteNeu(tx: Prisma.TransactionClient, lineageId: string): Promise<void> {
/**
* Bucht ein Kettenglied auf die Art um, die seine Stelle in der Kette
* verlangt.
*
* Die Regel steckt in der Kette selbst: **Das unterste Glied ist der CC, der
* gekauft wurde - alles darüber sind seine Redesigns.** Wandert ein Eintrag,
* wandert damit auch seine Bedeutung: Ein eigenständiger CC, der mitten in
* eine fremde Kette geschoben wird, ist von da an ein Redesign; wer
* umgekehrt an den Anfang rückt oder allein steht, belegt einen eigenen
* Platz und kostet ein CC-Paket.
*
* Umgebucht wird wie jede Korrektur im Kontobuch: die alte Zeile wird
* gegengebucht, die neue kommt dazu. Wo gar keine Buchung steht, bleibt es
* dabei - wer ohne Buchung nachgetragen hat, wollte das so.
*
* Zwischen bezahltem Redesign und Kulanz wird nicht gewechselt: das ist eine
* Entscheidung, keine Folge der Reihenfolge. Sie steht unter "Bezahlart".
*/
async function buchungFolgtPosition(
tx: Prisma.TransactionClient,
glied: { id: number; name: string },
/**
* Wessen Buchung angefasst wird: der Spieler, dem die Kette gehört - nicht
* der Ersteller des einzelnen Gliedes. Bei einem fremden Gruppen-CC in
* seiner Kette ist das seine Beitrittsbuchung, und nur die geht ihn etwas
* an. Die des Erstellers bleibt unberührt; für den ist es sein eigener
* Platz in seiner eigenen Kette.
*/
kettenBesitzer: number,
sollCC: boolean,
actorId: number | null,
): Promise<boolean> {
const offen = await tx.creditLedger.findFirst({
where: {
characterId: glied.id,
playerId: kettenBesitzer,
source: CreditSource.CONSUME,
reversedBy: null,
},
orderBy: { id: 'desc' },
});
if (!offen) return false;
const istCC = offen.kind === CreditKind.CC;
if (istCC === sollCC) return false;
const neueArt = sollCC ? CreditKind.CC : CreditKind.REDESIGN_PAID;
const grund = sollCC
? `Steht jetzt am Anfang der Kette: aus einem CC-Paket bezahlt`
: `Steht jetzt in einer Kette: als Redesign gebucht`;
await tx.creditLedger.create({
data: {
playerId: offen.playerId,
kind: offen.kind,
amount: -offen.amount,
source: CreditSource.REVERSAL,
groupId: offen.groupId,
characterId: glied.id,
reversalOfId: offen.id,
note: `${grund} - Storno: ${glied.name}`,
createdById: actorId,
occurredAt: offen.occurredAt,
},
});
await tx.creditLedger.create({
data: {
playerId: offen.playerId,
kind: neueArt,
amount: offen.amount,
source: CreditSource.CONSUME,
groupId: randomUUID(),
characterId: glied.id,
note: offen.note ?? glied.name,
createdById: actorId,
// Bezahlt wurde damals, nicht beim Umsortieren.
occurredAt: offen.occurredAt,
},
});
return true;
}
async function verketteNeu(
tx: Prisma.TransactionClient,
lineageId: string,
actorId: number | null,
): Promise<void> {
const glieder = await tx.character.findMany({
where: { lineageId },
orderBy: [{ chainPosition: 'asc' }, { createdAt: 'asc' }, { id: 'asc' }],
select: { id: true, predecessorId: true, status: true, inactiveReason: true },
select: {
id: true,
name: true,
playerId: true,
predecessorId: true,
status: true,
inactiveReason: true,
},
});
// Die Kette gehört dem, der unten steht: bei ihm fing sie an.
const kettenBesitzer = glieder[0]?.playerId ?? null;
let vorheriges: number | null = null;
for (const [index, glied] of glieder.entries()) {
const letztes = index === glieder.length - 1;
const daten: Prisma.CharacterUpdateInput = {};
// Das unterste Glied ist der gekaufte CC, alles darüber sein Redesign.
if (kettenBesitzer !== null) {
await buchungFolgtPosition(tx, glied, kettenBesitzer, index === 0, actorId);
}
if (glied.predecessorId !== vorheriges) {
daten.predecessor = vorheriges === null ? { disconnect: true } : { connect: { id: vorheriges } };
}
@@ -883,20 +986,55 @@ async function verketteNeu(tx: Prisma.TransactionClient, lineageId: string): Pro
*/
export async function moveInChain(
characterId: number,
input: { lineageId: string; afterId?: number | null },
input: {
lineageId: string;
afterId?: number | null;
/// Wessen Geschichte hier geordnet wird. Bei einem Gruppen-CC ist das
/// nicht zwingend der Ersteller.
forPlayerId?: number | undefined;
},
ctx: ActionContext,
): Promise<{ lineageId: string }> {
const character = await loadCharacter(characterId);
// Die Ziel-Linie muss dem Spieler gehören, sonst wanderten CCs zwischen
// Spielern - dafür gibt es hier keinen Weg und keinen Grund.
const zielGlieder = await prisma.character.findMany({
where: { lineageId: input.lineageId },
orderBy: [{ chainPosition: 'asc' }, { id: 'asc' }],
select: { id: true, playerId: true, chainPosition: true },
});
if (zielGlieder.length > 0 && zielGlieder.some((g) => g.playerId !== character.playerId)) {
throw conflict('FOREIGN_CHAIN', 'Diese Kette gehört einem anderen Spieler');
/**
* Wer hier ordnen darf.
*
* Nicht der Besitz entscheidet, sondern die Beteiligung: Ein Gruppen-CC
* gehört dem, der ihn angelegt hat, aber seine Geschichte gehört allen,
* die darin steckten. Wer seinen eigenen CC aufgegeben hat, um
* dazuzukommen, ordnet damit seine eigene Kette - und muss den Gruppen-CC
* dort einreihen können.
*
* Verlangt wird deshalb, dass derselbe Spieler an beidem beteiligt ist:
* am verschobenen CC und an der Kette, in die er soll. Sonst würden fremde
* Geschichten vermischt.
*/
const beteiligt = async (playerId: number, ids: number[]): Promise<boolean> => {
if (ids.length === 0) return true;
const treffer = await prisma.characterMember.findFirst({
where: { playerId, characterId: { in: ids } },
select: { id: true },
});
return treffer !== null;
};
const kandidat = input.forPlayerId ?? character.playerId;
const darfOrdnen =
(await beteiligt(kandidat, [characterId])) &&
(await beteiligt(kandidat, zielGlieder.map((g) => g.id)));
if (!darfOrdnen) {
throw conflict(
'FOREIGN_CHAIN',
'Diese Kette gehört zu einer anderen Geschichte - verschieben kann nur, wer an beidem beteiligt war',
);
}
const vorgaenger =
@@ -929,8 +1067,8 @@ export async function moveInChain(
data: { lineageId: input.lineageId, chainPosition: ab },
});
await verketteNeu(tx, input.lineageId);
if (alteLinie !== input.lineageId) await verketteNeu(tx, alteLinie);
await verketteNeu(tx, input.lineageId, ctx.actorId);
if (alteLinie !== input.lineageId) await verketteNeu(tx, alteLinie, ctx.actorId);
// Das Neuordnen kann einen CC ans Ende rücken und damit wieder aktiv
// machen. Ein Platz entsteht dadurch nicht: er muss gedeckt sein wie
@@ -980,7 +1118,10 @@ export async function detachFromChain(
},
});
await verketteNeu(tx, character.lineageId);
// Beide Seiten: die verlassene Kette rückt zusammen, und der
// Herausgelöste steht allein - also am Anfang seiner eigenen Kette.
await verketteNeu(tx, character.lineageId, ctx.actorId);
await verketteNeu(tx, neueLinie, ctx.actorId);
await assertSlotsReichen(tx, character.playerId, 'Dieses Herauslösen');
await recordAudit(tx, ctx, {