From f775b0a5944d9ef96a61d5fc755c32c3b426aa88 Mon Sep 17 00:00:00 2001 From: Harlekin Date: Fri, 18 Sep 2026 14:09:59 +0200 Subject: [PATCH] Reaktivierung haengt nur noch am Redesign, nicht am Grund Ein CC, der nicht redesignt wurde, existiert unveraendert weiter und kommt deshalb immer gratis zurueck - egal ob der Spieler inaktiv war oder die Einheit voruebergehend aus dem Spiel genommen wurde. Bisher kostete der Fall OTHER ein Guthaben, was die Regel unnoetig verkompliziert hat. Damit der zweite Fall brauchbar wird, haelt inactiveNote den Grund im Klartext fest ("Einheit bis Maerz entfernt"); die Notiz steht an der CC-Karte und wird beim Reaktivieren wieder geleert. --- README.md | 13 ++++-- .../20250101000000_init/migration.sql | 1 + server/prisma/schema.prisma | 2 + server/src/services/characters.ts | 33 ++++++++------ server/test/rules.test.ts | 44 ++++++++++++++++--- web/src/lib/format.ts | 2 +- web/src/lib/types.ts | 1 + web/src/pages/PlayerDetail.tsx | 36 ++++++++++----- 8 files changed, 96 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 7255e97..00f05b4 100644 --- a/README.md +++ b/README.md @@ -18,9 +18,10 @@ Zwei Regeln sind fest verdrahtet: 1. **Ein CC-Paket erzeugt beide Guthaben.** Der inklusive Redesign wird automatisch mitgebucht, in derselben Transaktion und mit gemeinsamer Gruppen-ID — ein Storno nimmt beide zurück. -2. **Inaktive CCs kosten unterschiedlich.** Wurde ein CC nur wegen Spieler-Inaktivität vom Server - entfernt, kommt er gratis zurück. Wurde er redesignt und existiert im Original nicht mehr, - kostet die Reaktivierung ein Redesign- oder CC-Guthaben. +2. **Nur redesignte CCs kosten bei der Rückkehr.** Solange ein CC unverändert existiert, kommt er + gratis zurück — gleich ob der Spieler inaktiv war oder die Einheit zeitweise entfernt wurde. + Wurde er dagegen redesignt, existiert das Original nicht mehr und die Reaktivierung kostet ein + Redesign- oder CC-Guthaben. Guthaben werden als **Kontobuch** geführt: jede Bewegung ist eine eigene Zeile, der Stand eines Topfes ist deren Summe. Das macht jeden Stand nachvollziehbar und Korrekturen sauber — storniert @@ -28,7 +29,11 @@ wird per Gegenbuchung, nie durch Löschen. Ein überzogenes Konto ist erlaubt (k Redesign vor dem zugehörigen Paket erfasst wird), wird aber rot markiert. Ein Redesign legt einen **neuen** CC-Datensatz an und verkettet ihn mit dem alten. So bleibt die -Kette „A → B → C" sichtbar und die Reaktivierungsregel greift automatisch richtig. +Kette „A → B → C" sichtbar und die Reaktivierungsregel greift automatisch richtig: Will man später +A zurück, ist A ein redesignter CC und kostet entsprechend. + +Warum ein CC inaktiv wurde, lässt sich beim Deaktivieren als Klartext festhalten („Einheit bis +März entfernt") — der Grund steht dann an der Karte, statt nur als Kategorie. ## Schnellstart diff --git a/server/prisma/migrations/20250101000000_init/migration.sql b/server/prisma/migrations/20250101000000_init/migration.sql index db25572..b681ba7 100644 --- a/server/prisma/migrations/20250101000000_init/migration.sql +++ b/server/prisma/migrations/20250101000000_init/migration.sql @@ -92,6 +92,7 @@ CREATE TABLE "Character" ( "loreText" TEXT, "status" "CharacterStatus" NOT NULL DEFAULT 'ACTIVE', "inactiveReason" "InactiveReason", + "inactiveNote" TEXT, "lineageId" TEXT NOT NULL, "predecessorId" INTEGER, "createdById" INTEGER, diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 87e07c0..d3ca915 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -150,6 +150,8 @@ model Character { loreText String? status CharacterStatus @default(ACTIVE) inactiveReason InactiveReason? + /// Klartext zum Grund, etwa "Einheit bis Maerz entfernt". Nur zur Anzeige. + inactiveNote String? /// Alle CCs einer Redesign-Kette teilen sich die lineageId. lineageId String @default(uuid()) diff --git a/server/src/services/characters.ts b/server/src/services/characters.ts index c7bb3fc..d87a22a 100644 --- a/server/src/services/characters.ts +++ b/server/src/services/characters.ts @@ -186,7 +186,11 @@ export async function deactivateCharacter( const character = await prisma.$transaction(async (tx) => { const updated = await tx.character.update({ where: { id: characterId }, - data: { status: CharacterStatus.INACTIVE, inactiveReason: reason }, + data: { + status: CharacterStatus.INACTIVE, + inactiveReason: reason, + inactiveNote: note?.trim() || null, + }, }); await recordAudit(tx, ctx, { @@ -204,9 +208,10 @@ export async function deactivateCharacter( } /** - * Gratis-Reaktivierung. Greift nur, wenn der CC ausschliesslich wegen - * Spieler-Inaktivitaet vom Server geputzt wurde: er existiert unveraendert - * weiter und kostet deshalb kein Guthaben. + * Gratis-Reaktivierung. Moeglich, solange der CC nicht redesignt wurde. Der + * Grund des Verschwindens spielt dabei keine Rolle - ob Spieler-Inaktivitaet + * oder eine zeitweise entfernte Einheit: der CC existiert unveraendert weiter + * und kostet deshalb kein Guthaben. */ export async function reactivateFree( characterId: number, @@ -217,17 +222,17 @@ export async function reactivateFree( if (existing.status === CharacterStatus.ACTIVE) { throw conflict('ALREADY_ACTIVE', 'Dieser CC ist bereits aktiv'); } - if (existing.inactiveReason !== InactiveReason.PLAYER_INACTIVITY) { + if (existing.inactiveReason === InactiveReason.REWORKED) { throw conflict( 'NOT_FREE', - 'Nur ein wegen Spieler-Inaktivitaet entfernter CC kommt gratis zurueck. Dieser CC braucht eine bezahlte Reaktivierung.', + 'Dieser CC wurde redesignt und existiert im Original nicht mehr. Seine Reaktivierung kostet ein Guthaben.', ); } const character = await prisma.$transaction(async (tx) => { const updated = await tx.character.update({ where: { id: characterId }, - data: { status: CharacterStatus.ACTIVE, inactiveReason: null }, + data: { status: CharacterStatus.ACTIVE, inactiveReason: null, inactiveNote: null }, }); await recordAudit(tx, ctx, { @@ -245,9 +250,9 @@ export async function reactivateFree( } /** - * Bezahlte Reaktivierung fuer CCs, die im Original nicht mehr existieren - * (reworkt oder aus sonstigem Grund inaktiv). Kostet ein Redesign- oder - * ein CC-Guthaben, der Nutzer entscheidet welches. + * Bezahlte Reaktivierung, ausschliesslich fuer redesignte CCs: deren Original + * existiert nicht mehr, es muss also neu erschaffen werden. Kostet ein + * Redesign- oder ein CC-Guthaben, der Nutzer entscheidet welches. */ export async function reactivatePaid( characterId: number, @@ -260,10 +265,10 @@ export async function reactivatePaid( if (existing.status === CharacterStatus.ACTIVE) { throw conflict('ALREADY_ACTIVE', 'Dieser CC ist bereits aktiv'); } - if (existing.inactiveReason === InactiveReason.PLAYER_INACTIVITY) { + if (existing.inactiveReason !== InactiveReason.REWORKED) { throw conflict( 'IS_FREE', - 'Dieser CC kommt gratis zurueck, eine Guthaben-Buchung waere falsch', + 'Dieser CC existiert unveraendert weiter und kommt gratis zurueck. Eine Guthaben-Buchung waere hier falsch.', ); } if (!REVIVE_KINDS.includes(useKind)) { @@ -273,7 +278,7 @@ export async function reactivatePaid( const character = await prisma.$transaction(async (tx) => { const updated = await tx.character.update({ where: { id: characterId }, - data: { status: CharacterStatus.ACTIVE, inactiveReason: null }, + data: { status: CharacterStatus.ACTIVE, inactiveReason: null, inactiveNote: null }, }); await tx.creditLedger.create({ @@ -389,7 +394,7 @@ export async function revertCharacter( if (restorePredecessor && predecessor) { await tx.character.update({ where: { id: predecessor.id }, - data: { status: CharacterStatus.ACTIVE, inactiveReason: null }, + data: { status: CharacterStatus.ACTIVE, inactiveReason: null, inactiveNote: null }, }); } diff --git a/server/test/rules.test.ts b/server/test/rules.test.ts index 6f45fc4..b3b3b0b 100644 --- a/server/test/rules.test.ts +++ b/server/test/rules.test.ts @@ -178,16 +178,46 @@ describe('Reaktivierung', () => { assert.equal(result.balances.CC, before.CC - 1); }); - it('eine bezahlte Reaktivierung wird bei Gratis-Faellen verweigert', async () => { + it('auch ein aus anderem Grund entfernter CC kommt gratis zurueck', async () => { + const playerId = await freshPlayer('Einheit-weg'); + await credits.bookCcPackage(playerId, ctx); + const created = await characters.createCharacter(playerId, { name: 'MTF-Operativer' }, ctx); + + await characters.deactivateCharacter( + created.character.id, + 'OTHER', + ctx, + 'Einheit bis Maerz entfernt', + ); + + const deactivated = await db.prisma.character.findUniqueOrThrow({ + where: { id: created.character.id }, + }); + assert.equal(deactivated.inactiveNote, 'Einheit bis Maerz entfernt', 'der Grund bleibt lesbar'); + + const before = await credits.getBalances(playerId); + const result = await characters.reactivateFree(created.character.id, ctx); + + assert.equal(result.character.status, 'ACTIVE'); + assert.equal(result.character.inactiveNote, null, 'die Notiz wird beim Zurueckholen geloescht'); + assert.deepEqual(result.balances, before, 'kein Guthaben wurde bewegt'); + }); + + it('eine bezahlte Reaktivierung wird bei jedem Gratis-Fall verweigert', async () => { const playerId = await freshPlayer('Gratis-Fall'); await credits.bookCcPackage(playerId, ctx); - const created = await characters.createCharacter(playerId, { name: 'Geputzt' }, ctx); - await characters.deactivateCharacter(created.character.id, 'PLAYER_INACTIVITY', ctx); + await credits.bookCcPackage(playerId, ctx); - await assert.rejects( - () => characters.reactivatePaid(created.character.id, 'CC', ctx), - (error: Error & { code?: string }) => error.code === 'IS_FREE', - ); + for (const reason of ['PLAYER_INACTIVITY', 'OTHER'] as const) { + const created = await characters.createCharacter(playerId, { name: `CC-${reason}` }, ctx); + await characters.deactivateCharacter(created.character.id, reason, ctx); + + await assert.rejects( + () => characters.reactivatePaid(created.character.id, 'CC', ctx), + (error: Error & { code?: string }) => error.code === 'IS_FREE', + `Grund ${reason} darf nichts kosten`, + ); + } }); }); diff --git a/web/src/lib/format.ts b/web/src/lib/format.ts index d6654de..a0734aa 100644 --- a/web/src/lib/format.ts +++ b/web/src/lib/format.ts @@ -18,7 +18,7 @@ export const SOURCE_LABEL: Record = { export const REASON_LABEL: Record = { PLAYER_INACTIVITY: 'Spieler-Inaktivitaet', REWORKED: 'Redesignt', - OTHER: 'Sonstiger Grund', + OTHER: 'Anderer Grund', }; export const PLAN_LABEL: Record = { diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 42176d2..be93dfe 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -57,6 +57,7 @@ export interface Character { loreText: string | null; status: CharacterStatus; inactiveReason: InactiveReason | null; + inactiveNote: string | null; lineageId: string; predecessorId: number | null; predecessor: CharacterRef | null; diff --git a/web/src/pages/PlayerDetail.tsx b/web/src/pages/PlayerDetail.tsx index 6cf765e..038168c 100644 --- a/web/src/pages/PlayerDetail.tsx +++ b/web/src/pages/PlayerDetail.tsx @@ -197,7 +197,8 @@ export default function PlayerDetail() { ) : (
    {inactive.map((character) => { - const free = character.inactiveReason === 'PLAYER_INACTIVITY'; + // Nur ein redesignter CC existiert im Original nicht mehr. + const free = character.inactiveReason !== 'REWORKED'; return (
  • @@ -213,6 +214,9 @@ export default function PlayerDetail() { · Ersetzt durch {character.successors.map((s) => s.name).join(', ')} )} + {character.inactiveNote && ( +

    {character.inactiveNote}

    + )}

    {free ? 'Kommt gratis zurueck — kostet kein Guthaben.' @@ -367,12 +371,12 @@ export default function PlayerDetail() { character={dialog.character} busy={busy} onClose={() => setDialog({ type: 'none' })} - onSubmit={(reason) => + onSubmit={(reason, note) => void run( () => api(`/api/characters/${dialog.character.id}/deactivate`, { method: 'POST', - body: { reason }, + body: { reason, ...(note.trim() ? { note: note.trim() } : {}) }, }), `"${dialog.character.name}" ist jetzt inaktiv.`, ) @@ -383,7 +387,7 @@ export default function PlayerDetail() { {dialog.type === 'reactivatePaid' && ( void; - onSubmit: (reason: 'PLAYER_INACTIVITY' | 'OTHER') => void; + onSubmit: (reason: 'PLAYER_INACTIVITY' | 'OTHER', note: string) => void; }) { const [reason, setReason] = useState<'PLAYER_INACTIVITY' | 'OTHER'>('PLAYER_INACTIVITY'); + const [note, setNote] = useState(''); return (

    { event.preventDefault(); - onSubmit(reason); + onSubmit(reason, note); }} > - Der Grund entscheidet, ob die spaetere Reaktivierung kostenlos ist. + Der CC bleibt unveraendert und kommt spaeter in beiden Faellen gratis zurueck. Nur ein + Redesign macht ihn kostenpflichtig — das laeuft aber ueber die Redesign-Aktion.
    @@ -643,7 +649,7 @@ function DeactivateModal({ /> Spieler-Inaktivitaet - — vom Server geputzt, kommt gratis zurueck + — vom Server geputzt, weil der Spieler laenger weg war
    + + setNote(event.target.value)} + placeholder="z. B. Einheit bis Maerz entfernt" + /> +