From 2d1c008c0ab9ed5ff463d471ed387986dbbac475 Mon Sep 17 00:00:00 2001 From: Harlekin Date: Mon, 21 Sep 2026 18:38:11 +0200 Subject: [PATCH] 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. --- README.md | 17 ++ server/src/routes/characters.routes.ts | 3 + server/src/services/characters.ts | 161 +++++++++++++++- server/test/ketten.test.ts | 246 +++++++++++++++++++++++-- web/src/components/ChainView.tsx | 32 +++- 5 files changed, 432 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 30b4fe2..dd9b879 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,19 @@ desselben Spielers. Ein nachgetragener CC wird also schlicht dorthin gezogen, wo *Herauslösen* stellt einen Eintrag wieder allein, wenn sich herausstellt, dass er gar nicht in diese Kette gehörte. +**Die Stelle in der Kette bestimmt, was ein Eintrag kostet.** Das unterste Glied ist der CC, der +gekauft wurde - es verbraucht ein CC-Paket. Alles darüber sind seine Redesigns und verbrauchen +Redesign-Guthaben. Wandert ein Eintrag, wandert seine Bedeutung mit: Ein eigenständiger CC, den +du mitten in eine Kette schiebst, ist von da an ein Redesign, und das CC-Guthaben kommt zurück. +Ziehst du einen Eintrag an den Anfang oder löst ihn heraus, belegt er einen eigenen Platz und +kostet wieder ein CC-Paket. + +Umgebucht wird über eine Gegenbuchung, nicht durch Überschreiben - in der Historie bleibt +sichtbar, was passiert ist. Reicht das Guthaben nicht, geht das Konto ins Minus und zeigt das an. +Zwischen bezahltem Redesign und Kulanz wird dabei **nicht** gewechselt: Das ist eine +Entscheidung, keine Folge der Reihenfolge, und steht unter *Bezahlart*. Einträge ganz ohne +Buchung bleiben unangetastet. + Eines folgt daraus von selbst: **Wer einen Nachfolger hat, wurde durch ihn ersetzt.** Anders ist eine Kette nicht zu lesen. Deshalb trägt jedes Glied außer dem obersten den Vermerk *Redesignt*, und wer ans Ende rückt, verliert ihn wieder - er stammte ja von einem Nachfolger, der jetzt @@ -72,6 +85,10 @@ die Thread-Nummer genügt, die volle Adresse geht auch. Über *Bezahlart* wird u Redesign aus einem gekauften Paket oder aus Kulanz kam; umgebucht wird über eine Gegenbuchung, in der Historie bleibt beides sichtbar. +Bei einem **Gruppen-CC** gilt das für die eigene Buchung: Wer ihn in seine Kette einreiht, stellt +damit seinen eigenen Beitritt um - die Buchung des Erstellers bleibt unberührt, für den ist es +sein eigener Platz in seiner eigenen Kette. Einreihen darf, wer an beidem beteiligt war. + Am Fuß einer Kette kann ein CC stehen, der jemand anderem gehört - der Gruppen-CC, aus dem sich der Spieler herausredesignt hat. Er war darin, und das bleibt sichtbar; verschieben oder entscheiden lässt sich daran nichts, denn es ist der Platz eines anderen. Frühere diff --git a/server/src/routes/characters.routes.ts b/server/src/routes/characters.routes.ts index 6b6144e..d7481d5 100644 --- a/server/src/routes/characters.routes.ts +++ b/server/src/routes/characters.routes.ts @@ -209,6 +209,9 @@ export async function characterRoutes(app: FastifyInstance): Promise { z.object({ lineageId: z.string().trim().min(1).max(64), afterId: z.number().int().positive().nullable().optional(), + /// Wessen Seite gerade geordnet wird - bei einem Gruppen-CC ist das + /// nicht zwingend sein Ersteller. + forPlayerId: z.number().int().positive().optional(), }), request.body, ); diff --git a/server/src/services/characters.ts b/server/src/services/characters.ts index 3104ef0..906d8e7 100644 --- a/server/src/services/characters.ts +++ b/server/src/services/characters.ts @@ -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 { +/** + * 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 { + 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 { 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 => { + 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, { diff --git a/server/test/ketten.test.ts b/server/test/ketten.test.ts index 6d69b61..ab222ec 100644 --- a/server/test/ketten.test.ts +++ b/server/test/ketten.test.ts @@ -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'); + }); +}); diff --git a/web/src/components/ChainView.tsx b/web/src/components/ChainView.tsx index 3725891..81721a5 100644 --- a/web/src/components/ChainView.tsx +++ b/web/src/components/ChainView.tsx @@ -1,5 +1,6 @@ import { useState } from 'react'; import MemberList from './MemberList'; +import Notice from './Notice'; import { ApiError, api } from '../lib/api'; import { REASON_LABEL, formatDate, rueckkehrArt } from '../lib/format'; import type { Character } from '../lib/types'; @@ -120,6 +121,9 @@ export default function ChainView({ }) { const [ziehe, setZiehe] = useState(null); const [ueber, setUeber] = useState(null); + // Eine Meldung zum Ziehen gehört neben die Ketten, nicht an den Seitenkopf: + // wer unten zieht, sieht dort oben nichts. + const [meldung, setMeldung] = useState(null); const { ketten, vergangen } = baueKetten(characters, playerId); @@ -147,24 +151,26 @@ export default function ChainView({ if (index === zielIndex || index === zielIndex - 1) return; } + setMeldung(null); try { await api(`/api/characters/${bewegt.id}/move`, { method: 'POST', - body: { lineageId, afterId }, + body: { lineageId, afterId, forPlayerId: playerId }, }); onMoved(); } catch (err) { - onError(err instanceof ApiError ? err.message : 'Verschieben fehlgeschlagen'); + setMeldung(err instanceof ApiError ? err.message : 'Verschieben fehlgeschlagen'); } }; const herausloesen = async (character: Character) => { if (!window.confirm(`"${character.name}" aus seiner Kette lösen und allein stellen?`)) return; + setMeldung(null); try { await api(`/api/characters/${character.id}/detach`, { method: 'POST', body: {} }); onMoved(); } catch (err) { - onError(err instanceof ApiError ? err.message : 'Herauslösen fehlgeschlagen'); + setMeldung(err instanceof ApiError ? err.message : 'Herauslösen fehlgeschlagen'); } }; @@ -194,13 +200,27 @@ export default function ChainView({

CCs ({characters.length})

- Jede Spalte ist ein Platz, von unten nach oben gelesen: unten der erste CC, darüber - seine Redesigns. - {canEdit && ' Einträge lassen sich mit der Maus an ihren Platz ziehen - auch mitten in eine Kette oder in eine andere.'} + Jede Spalte ist ein Platz, von unten nach oben gelesen: unten der gekaufte CC, + darüber seine Redesigns. + {canEdit && ( + <> + {' '} + Einträge lassen sich mit der Maus an ihren Platz ziehen - auch mitten in eine + Kette oder in eine andere. Die Stelle bestimmt die Buchung: was + unten steht, kostet ein CC-Paket, alles darüber ein Redesign. Beim Verschieben + wird entsprechend umgebucht. + + )}

+ {meldung && ( + setMeldung(null)}> + {meldung} + + )} +
{ketten.map((kette, index) => { const aktiv = kette.glieder.find((c) => c.status === 'ACTIVE');