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" + /> +