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');