diff --git a/README.md b/README.md index a9e7325..d4edb58 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,11 @@ du mitten in eine Kette schiebst, ist von da an ein Redesign, und das CC-Guthabe Ziehst du einen Eintrag an den Anfang oder löst ihn heraus, belegt er einen eigenen Platz und kostet wieder ein CC-Paket. +Eine Ausnahme gibt es: **Hat die Kette eine Herkunft außerhalb**, ist auch ihr unterstes Glied +kein Kauf. Wer aus einem fremden Gruppen-CC herausredesignt, beginnt zwar eine eigene Kette - +bezahlt hat er sie trotzdem mit einem Redesign. Sonst stünde derselbe Platz zweimal im Konto: +einmal für den Beitritt, einmal für den eigenen CC. + 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 @@ -81,9 +86,9 @@ woanders steht. Ein Platz entsteht dabei nicht aus dem Nichts: Wird ein CC dadur muss er gedeckt sein wie jeder andere. Über *Bearbeiten* lassen sich Name, Beschreibung und der verknüpfte Forums-Antrag nachtragen - -die Thread-Nummer genügt, die volle Adresse geht auch. Über *Bezahlart* wird umgestellt, ob ein -Redesign aus einem gekauften Paket oder aus Kulanz kam; umgebucht wird über eine Gegenbuchung, -in der Historie bleibt beides sichtbar. +die Thread-Nummer genügt, die volle Adresse geht auch. Über *Bezahlart* wird umgestellt, woraus ein Eintrag +bezahlt wurde - aus einem CC-Paket, einem bezahlten Redesign oder aus Kulanz; 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 diff --git a/server/src/routes/characters.routes.ts b/server/src/routes/characters.routes.ts index d7481d5..eeda6d0 100644 --- a/server/src/routes/characters.routes.ts +++ b/server/src/routes/characters.routes.ts @@ -24,7 +24,7 @@ import { removeLink, moveInChain, detachFromChain, - changeRedesignKind, + changeFunding, addMemberToChain, } from '../services/characters.js'; import { parseThreadId } from '../lib/forum.js'; @@ -183,18 +183,19 @@ export async function characterRoutes(app: FastifyInstance): Promise { }); /** - * Nachträglich umstellen, woraus ein Redesign bezahlt wurde. Umgebucht - * wird im Kontobuch, nicht überschrieben. + * Nachträglich umstellen, woraus ein CC bezahlt wurde - aus einem Paket, + * einem bezahlten Redesign oder aus Kulanz. Umgebucht wird im Kontobuch, + * nicht überschrieben. */ - app.post('/api/characters/:id/redesign-kind', { preHandler: requireEditor }, async (request) => { + app.post('/api/characters/:id/funding', { preHandler: requireEditor }, async (request) => { const { id } = parseWith(idParam, request.params); const body = parseWith( z.object({ - useKind: z.enum([CreditKind.REDESIGN_PAID, CreditKind.REDESIGN_KULANZ]), + useKind: z.enum([CreditKind.CC, CreditKind.REDESIGN_PAID, CreditKind.REDESIGN_KULANZ]), }), request.body, ); - return changeRedesignKind(id, body.useKind, contextOf(request)); + return changeFunding(id, body.useKind, contextOf(request)); }); // --- Ketten --- diff --git a/server/src/services/characters.ts b/server/src/services/characters.ts index 906d8e7..6ee8950 100644 --- a/server/src/services/characters.ts +++ b/server/src/services/characters.ts @@ -731,24 +731,26 @@ export async function reviveDeadCharacter( /** - * Stellt nachträglich um, woraus ein Redesign bezahlt wurde. + * Stellt nachträglich um, woraus ein CC bezahlt wurde. * - * Ob ein Redesign aus einem gekauften Paket oder aus Kulanz kam, stellt sich - * manchmal erst später heraus - etwa wenn eine Absprache auftaucht, von der - * beim Eintragen niemand wusste. Umgebucht wird nicht durch Überschreiben, - * sondern wie jede Korrektur im Kontobuch: die alte Zeile wird - * gegengebucht, die neue kommt dazu. So bleibt sichtbar, dass hier etwas - * geändert wurde, und wann. + * Ob etwas aus einem gekauften Paket, einem bezahlten Redesign oder aus + * Kulanz kam, stellt sich manchmal erst später heraus - wenn eine Absprache + * auftaucht, von der beim Eintragen niemand wusste, oder wenn sich zeigt, + * dass ein Eintrag gar nicht der Anfang einer Kette war. + * + * Umgebucht wird nicht durch Überschreiben, sondern wie jede Korrektur im + * Kontobuch: die alte Zeile wird gegengebucht, die neue kommt dazu. So + * bleibt sichtbar, dass hier etwas geändert wurde, und wann. */ -export async function changeRedesignKind( +export async function changeFunding( characterId: number, - useKind: RedesignKind, + useKind: JoinKind, ctx: ActionContext, ): Promise { const character = await loadCharacter(characterId); - if (!REDESIGN_KINDS.includes(useKind)) { - throw badRequest('INVALID_KIND', 'Ein Redesign kostet bezahltes oder Kulanz-Redesign-Guthaben'); + if (!JOIN_KINDS.includes(useKind)) { + throw badRequest('INVALID_KIND', 'Ungültige Guthaben-Art'); } // Die Buchung, mit der dieser CC bezahlt wurde. Stornierte zählen nicht @@ -756,26 +758,28 @@ export async function changeRedesignKind( const buchung = await prisma.creditLedger.findFirst({ where: { characterId, + playerId: character.playerId, source: CreditSource.CONSUME, - kind: { in: [...REDESIGN_KINDS] }, reversedBy: null, }, orderBy: { id: 'desc' }, }); if (!buchung) { throw conflict( - 'NO_REDESIGN_BOOKING', - 'Für diesen CC ist kein offenes Redesign gebucht - hier gibt es nichts umzustellen', + 'NO_BOOKING', + 'Für diesen CC ist nichts gebucht - hier gibt es nichts umzustellen', ); } if (buchung.kind === useKind) { - throw conflict('SAME_KIND', 'Dieses Redesign ist bereits so gebucht'); + throw conflict('SAME_KIND', 'Dieser CC ist bereits so gebucht'); } const grund = - useKind === CreditKind.REDESIGN_KULANZ - ? 'Umgestellt auf Kulanz' - : 'Umgestellt auf bezahltes Redesign'; + useKind === CreditKind.CC + ? 'Umgestellt auf CC-Paket' + : useKind === CreditKind.REDESIGN_KULANZ + ? 'Umgestellt auf Kulanz' + : 'Umgestellt auf bezahltes Redesign'; await prisma.$transaction(async (tx) => { await tx.creditLedger.create({ @@ -809,7 +813,7 @@ export async function changeRedesignKind( }); await recordAudit(tx, ctx, { - action: 'character.redesign_kind_changed', + action: 'character.funding_changed', targetType: 'character', targetId: characterId, details: { name: character.name, from: buchung.kind, to: useKind }, @@ -936,19 +940,40 @@ async function verketteNeu( // Die Kette gehört dem, der unten steht: bei ihm fing sie an. 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, alles darüber sein Redesign. + // 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, index === 0, actorId); + await buchungFolgtPosition(tx, glied, kettenBesitzer, erstes && !hatHerkunft, actorId); } - if (glied.predecessorId !== vorheriges) { - daten.predecessor = vorheriges === null ? { disconnect: true } : { connect: { id: vorheriges } }; + if (erstes) { + // Am Anfang wird nur eine Verkettung gelöst, die innerhalb dieser + // Linie ins Leere zeigt. Eine Herkunft von aussen bleibt. + if (glied.predecessorId !== null && inDieserLinie.has(glied.predecessorId)) { + daten.predecessor = { disconnect: true }; + } + } else if (glied.predecessorId !== vorheriges) { + daten.predecessor = { connect: { id: vorheriges as number } }; } // Was einen Nachfolger hat, wurde durch ihn ersetzt - anders ist eine diff --git a/server/test/ketten.test.ts b/server/test/ketten.test.ts index ab222ec..3540a19 100644 --- a/server/test/ketten.test.ts +++ b/server/test/ketten.test.ts @@ -448,7 +448,7 @@ describe('Redesign nachträglich umbuchen', () => { assert.equal(zwei.balances.REDESIGN_PAID, 0, 'das bezahlte ist verbraucht'); await credits.bookKulanz(spieler, 1, 'Absprache aufgetaucht', ctx); - const danach = await characters.changeRedesignKind(zwei.character.id, 'REDESIGN_KULANZ', ctx); + const danach = await characters.changeFunding(zwei.character.id, 'REDESIGN_KULANZ', ctx); assert.equal(danach.balances.REDESIGN_PAID, 1, 'das bezahlte ist wieder frei'); assert.equal(danach.balances.REDESIGN_KULANZ, 0, 'dafür ist die Kulanz verbraucht'); @@ -473,7 +473,7 @@ describe('Redesign nachträglich umbuchen', () => { ctx, ); - const danach = await characters.changeRedesignKind(zwei.character.id, 'REDESIGN_PAID', ctx); + const danach = await characters.changeFunding(zwei.character.id, 'REDESIGN_PAID', ctx); assert.equal(danach.balances.REDESIGN_KULANZ, 1); assert.equal(danach.balances.REDESIGN_PAID, 0, 'das aus dem CC-Paket ist jetzt dran'); }); @@ -488,19 +488,26 @@ describe('Redesign nachträglich umbuchen', () => { ); await assert.rejects( - () => characters.changeRedesignKind(zwei.character.id, 'REDESIGN_PAID', ctx), + () => characters.changeFunding(zwei.character.id, 'REDESIGN_PAID', ctx), (error: Error & { code?: string }) => error.code === 'SAME_KIND', ); }); - it('weist einen CC ohne Redesign-Buchung ab', async () => { - const spieler = await spielerMitPaketen('Ohne Redesign', 1); + it('stellt einen gekauften CC auf Redesign um', async () => { + // Genau der Korrekturfall: ein Eintrag war gar nicht der Anfang einer + // Kette, sondern ging aus einem anderen hervor. + const spieler = await spielerMitPaketen('Umsteller', 1); const eins = await characters.createCharacter(spieler, { name: 'Direkt aus dem Paket' }, ctx); + await credits.bookRedesignPackage(spieler, ctx); + + const danach = await characters.changeFunding(eins.character.id, 'REDESIGN_PAID', ctx); + assert.equal(danach.balances.CC, 1, 'das CC-Guthaben kommt zurück'); + assert.equal(danach.balances.REDESIGN_PAID, 1, 'dafür geht ein Redesign weg'); await assert.rejects( - () => characters.changeRedesignKind(eins.character.id, 'REDESIGN_KULANZ', ctx), - (error: Error & { code?: string }) => error.code === 'NO_REDESIGN_BOOKING', - 'ein CC aus dem Paket ist kein Redesign', + () => characters.changeFunding(eins.character.id, 'REDESIGN_PAID', ctx), + (error: Error & { code?: string }) => error.code === 'SAME_KIND', + 'ein CC aus dem Paket ist schon als CC gebucht', ); }); }); @@ -718,3 +725,90 @@ describe('Die Stelle in der Kette bestimmt, was ein Eintrag kostet', () => { assert.deepEqual(await credits.getBalances(spieler), vorher, 'nichts gebucht, nichts geändert'); }); }); + +describe('Eine Kette mit Vorgeschichte ausserhalb', () => { + /** + * Der Fall aus dem Betrieb: Ein Spieler tritt einem fremden Gruppen-CC + * bei, steigt später per Redesign aus und führt seinen eigenen CC weiter. + * Dessen Kette beginnt nicht bei null - sie hat eine Herkunft, die + * woanders liegt. + */ + async function ausgestiegen() { + const dargon = await spielerMitPaketen('Aussteiger', 3); + const wirt = await spielerMitPaketen('Gruppen-Wirt', 1); + + await characters.createCharacter(dargon, { name: 'Platz eins' }, ctx); + await characters.createCharacter(dargon, { name: 'Platz zwei' }, ctx); + + const fremd = await characters.createCharacter(wirt, { name: 'Fremde Gruppe' }, ctx); + await characters.addMember(fremd.character.id, dargon, { useKind: 'CC' }, ctx); + + await credits.bookRedesignPackage(dargon, ctx); + const eigenes = await characters.redesignMembership( + fremd.character.id, + dargon, + { name: 'Eigener Strang', useKind: 'REDESIGN_PAID' }, + ctx, + ); + + await credits.bookRedesignPackage(dargon, ctx); + const weiter = await characters.redesignCharacter( + eigenes.character.id, + { name: 'Und weiter', useKind: 'REDESIGN_PAID' }, + ctx, + ); + + return { dargon, eigenes: eigenes.character, weiter: weiter.character }; + } + + it('zählt drei Plätze und drei verbrauchte Pakete', async () => { + const { dargon } = await ausgestiegen(); + + const stand = await credits.getBalances(dargon); + assert.equal(stand.granted.CC, 3, 'drei Pakete gekauft'); + assert.equal(stand.CC, 0, 'drei Plätze belegt, keiner zu viel'); + }); + + it('bucht das erste Glied einer hergeleiteten Kette nicht auf CC um', async () => { + // Es ist zwar das unterste seiner Kette, aber kein Kauf: es ging aus dem + // Gruppen-CC hervor. Wer das übersieht, bucht einen Platz doppelt. + const { dargon, eigenes, weiter } = await ausgestiegen(); + const vorher = await credits.getBalances(dargon); + + // Ein Verschieben innerhalb der Kette ordnet neu - und darf dabei nichts + // erfinden. + await characters.moveInChain( + weiter.id, + { lineageId: eigenes.lineageId, afterId: eigenes.id, forPlayerId: dargon }, + ctx, + ); + + assert.deepEqual( + await credits.getBalances(dargon), + vorher, + 'am Konto ändert sich dabei nichts', + ); + + const buchung = await db.prisma.creditLedger.findFirst({ + where: { characterId: eigenes.id, source: 'CONSUME', reversedBy: null }, + orderBy: { id: 'desc' }, + }); + assert.equal(buchung?.kind, 'REDESIGN_PAID', 'es bleibt ein Redesign'); + }); + + it('behält den Verweis auf die Herkunft', async () => { + const { dargon, eigenes, weiter } = await ausgestiegen(); + + await characters.moveInChain( + weiter.id, + { lineageId: eigenes.lineageId, afterId: eigenes.id, forPlayerId: dargon }, + ctx, + ); + + const danach = await db.prisma.character.findUniqueOrThrow({ where: { id: eigenes.id } }); + assert.ok( + danach.predecessorId, + 'sonst wäre nicht mehr zu sehen, woher die Kette kommt', + ); + }); +}); diff --git a/web/src/components/ChainView.tsx b/web/src/components/ChainView.tsx index 81721a5..3adf66d 100644 --- a/web/src/components/ChainView.tsx +++ b/web/src/components/ChainView.tsx @@ -527,16 +527,14 @@ function ChainEntry({ - {character.predecessorId !== null && ( - - )} + diff --git a/web/src/pages/PlayerDetail.tsx b/web/src/pages/PlayerDetail.tsx index ac9a725..3a210cf 100644 --- a/web/src/pages/PlayerDetail.tsx +++ b/web/src/pages/PlayerDetail.tsx @@ -538,7 +538,7 @@ export default function PlayerDetail() { onSubmit={(useKind) => void run( () => - api(`/api/characters/${dialog.character.id}/redesign-kind`, { + api(`/api/characters/${dialog.character.id}/funding`, { method: 'POST', body: { useKind }, }), @@ -1749,7 +1749,7 @@ function ProblemModal({ } /** - * Stellt um, woraus ein Redesign bezahlt wurde. + * Stellt um, woraus ein CC bezahlt wurde. * * Umgebucht wird im Kontobuch: die alte Zeile wird gegengebucht, die neue * kommt dazu. Überschrieben wird nichts - in der Historie bleibt sichtbar, @@ -1763,7 +1763,7 @@ function RedesignKindModal({ onSubmit, }: { character: Character; - balances: { REDESIGN_PAID: number; REDESIGN_KULANZ: number }; + balances: { CC: number; REDESIGN_PAID: number; REDESIGN_KULANZ: number }; busy: boolean; onClose: () => void; onSubmit: (useKind: CreditKind) => void; @@ -1773,14 +1773,15 @@ function RedesignKindModal({ return ( - Ob ein Redesign aus einem gekauften Paket oder aus Kulanz kam, stellt sich manchmal erst - später heraus. Umgebucht wird über eine Gegenbuchung - in der Historie bleibt beides - sichtbar. + Woraus ein CC bezahlt wurde, stellt sich manchmal erst später heraus - etwa wenn eine + Absprache auftaucht oder sich zeigt, dass ein Eintrag gar nicht der Anfang einer Kette + war. Umgebucht wird über eine Gegenbuchung; in der Historie bleibt beides sichtbar.
{( [ + ['CC', 'CC-Paket (eigener Platz)', balances.CC], ['REDESIGN_KULANZ', 'Kulanz-Redesign', balances.REDESIGN_KULANZ], ['REDESIGN_PAID', 'Bezahltes Redesign', balances.REDESIGN_PAID], ] as const