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.
This commit is contained in:
2026-09-18 14:09:59 +02:00
parent 3ec0ff4df1
commit f775b0a594
8 changed files with 96 additions and 36 deletions
+9 -4
View File
@@ -18,9 +18,10 @@ Zwei Regeln sind fest verdrahtet:
1. **Ein CC-Paket erzeugt beide Guthaben.** Der inklusive Redesign wird automatisch mitgebucht, 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. 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 2. **Nur redesignte CCs kosten bei der Rückkehr.** Solange ein CC unverändert existiert, kommt er
entfernt, kommt er gratis zurück. Wurde er redesignt und existiert im Original nicht mehr, gratis zurück — gleich ob der Spieler inaktiv war oder die Einheit zeitweise entfernt wurde.
kostet die Reaktivierung ein Redesign- oder CC-Guthaben. 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 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 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. 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 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 ## Schnellstart
@@ -92,6 +92,7 @@ CREATE TABLE "Character" (
"loreText" TEXT, "loreText" TEXT,
"status" "CharacterStatus" NOT NULL DEFAULT 'ACTIVE', "status" "CharacterStatus" NOT NULL DEFAULT 'ACTIVE',
"inactiveReason" "InactiveReason", "inactiveReason" "InactiveReason",
"inactiveNote" TEXT,
"lineageId" TEXT NOT NULL, "lineageId" TEXT NOT NULL,
"predecessorId" INTEGER, "predecessorId" INTEGER,
"createdById" INTEGER, "createdById" INTEGER,
+2
View File
@@ -150,6 +150,8 @@ model Character {
loreText String? loreText String?
status CharacterStatus @default(ACTIVE) status CharacterStatus @default(ACTIVE)
inactiveReason InactiveReason? inactiveReason InactiveReason?
/// Klartext zum Grund, etwa "Einheit bis Maerz entfernt". Nur zur Anzeige.
inactiveNote String?
/// Alle CCs einer Redesign-Kette teilen sich die lineageId. /// Alle CCs einer Redesign-Kette teilen sich die lineageId.
lineageId String @default(uuid()) lineageId String @default(uuid())
+19 -14
View File
@@ -186,7 +186,11 @@ export async function deactivateCharacter(
const character = await prisma.$transaction(async (tx) => { const character = await prisma.$transaction(async (tx) => {
const updated = await tx.character.update({ const updated = await tx.character.update({
where: { id: characterId }, where: { id: characterId },
data: { status: CharacterStatus.INACTIVE, inactiveReason: reason }, data: {
status: CharacterStatus.INACTIVE,
inactiveReason: reason,
inactiveNote: note?.trim() || null,
},
}); });
await recordAudit(tx, ctx, { await recordAudit(tx, ctx, {
@@ -204,9 +208,10 @@ export async function deactivateCharacter(
} }
/** /**
* Gratis-Reaktivierung. Greift nur, wenn der CC ausschliesslich wegen * Gratis-Reaktivierung. Moeglich, solange der CC nicht redesignt wurde. Der
* Spieler-Inaktivitaet vom Server geputzt wurde: er existiert unveraendert * Grund des Verschwindens spielt dabei keine Rolle - ob Spieler-Inaktivitaet
* weiter und kostet deshalb kein Guthaben. * oder eine zeitweise entfernte Einheit: der CC existiert unveraendert weiter
* und kostet deshalb kein Guthaben.
*/ */
export async function reactivateFree( export async function reactivateFree(
characterId: number, characterId: number,
@@ -217,17 +222,17 @@ export async function reactivateFree(
if (existing.status === CharacterStatus.ACTIVE) { if (existing.status === CharacterStatus.ACTIVE) {
throw conflict('ALREADY_ACTIVE', 'Dieser CC ist bereits aktiv'); throw conflict('ALREADY_ACTIVE', 'Dieser CC ist bereits aktiv');
} }
if (existing.inactiveReason !== InactiveReason.PLAYER_INACTIVITY) { if (existing.inactiveReason === InactiveReason.REWORKED) {
throw conflict( throw conflict(
'NOT_FREE', '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 character = await prisma.$transaction(async (tx) => {
const updated = await tx.character.update({ const updated = await tx.character.update({
where: { id: characterId }, where: { id: characterId },
data: { status: CharacterStatus.ACTIVE, inactiveReason: null }, data: { status: CharacterStatus.ACTIVE, inactiveReason: null, inactiveNote: null },
}); });
await recordAudit(tx, ctx, { await recordAudit(tx, ctx, {
@@ -245,9 +250,9 @@ export async function reactivateFree(
} }
/** /**
* Bezahlte Reaktivierung fuer CCs, die im Original nicht mehr existieren * Bezahlte Reaktivierung, ausschliesslich fuer redesignte CCs: deren Original
* (reworkt oder aus sonstigem Grund inaktiv). Kostet ein Redesign- oder * existiert nicht mehr, es muss also neu erschaffen werden. Kostet ein
* ein CC-Guthaben, der Nutzer entscheidet welches. * Redesign- oder ein CC-Guthaben, der Nutzer entscheidet welches.
*/ */
export async function reactivatePaid( export async function reactivatePaid(
characterId: number, characterId: number,
@@ -260,10 +265,10 @@ export async function reactivatePaid(
if (existing.status === CharacterStatus.ACTIVE) { if (existing.status === CharacterStatus.ACTIVE) {
throw conflict('ALREADY_ACTIVE', 'Dieser CC ist bereits aktiv'); throw conflict('ALREADY_ACTIVE', 'Dieser CC ist bereits aktiv');
} }
if (existing.inactiveReason === InactiveReason.PLAYER_INACTIVITY) { if (existing.inactiveReason !== InactiveReason.REWORKED) {
throw conflict( throw conflict(
'IS_FREE', '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)) { if (!REVIVE_KINDS.includes(useKind)) {
@@ -273,7 +278,7 @@ export async function reactivatePaid(
const character = await prisma.$transaction(async (tx) => { const character = await prisma.$transaction(async (tx) => {
const updated = await tx.character.update({ const updated = await tx.character.update({
where: { id: characterId }, where: { id: characterId },
data: { status: CharacterStatus.ACTIVE, inactiveReason: null }, data: { status: CharacterStatus.ACTIVE, inactiveReason: null, inactiveNote: null },
}); });
await tx.creditLedger.create({ await tx.creditLedger.create({
@@ -389,7 +394,7 @@ export async function revertCharacter(
if (restorePredecessor && predecessor) { if (restorePredecessor && predecessor) {
await tx.character.update({ await tx.character.update({
where: { id: predecessor.id }, where: { id: predecessor.id },
data: { status: CharacterStatus.ACTIVE, inactiveReason: null }, data: { status: CharacterStatus.ACTIVE, inactiveReason: null, inactiveNote: null },
}); });
} }
+37 -7
View File
@@ -178,16 +178,46 @@ describe('Reaktivierung', () => {
assert.equal(result.balances.CC, before.CC - 1); 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'); const playerId = await freshPlayer('Gratis-Fall');
await credits.bookCcPackage(playerId, ctx); await credits.bookCcPackage(playerId, ctx);
const created = await characters.createCharacter(playerId, { name: 'Geputzt' }, ctx); await credits.bookCcPackage(playerId, ctx);
await characters.deactivateCharacter(created.character.id, 'PLAYER_INACTIVITY', ctx);
await assert.rejects( for (const reason of ['PLAYER_INACTIVITY', 'OTHER'] as const) {
() => characters.reactivatePaid(created.character.id, 'CC', ctx), const created = await characters.createCharacter(playerId, { name: `CC-${reason}` }, ctx);
(error: Error & { code?: string }) => error.code === 'IS_FREE', 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`,
);
}
}); });
}); });
+1 -1
View File
@@ -18,7 +18,7 @@ export const SOURCE_LABEL: Record<CreditSource, string> = {
export const REASON_LABEL: Record<InactiveReason, string> = { export const REASON_LABEL: Record<InactiveReason, string> = {
PLAYER_INACTIVITY: 'Spieler-Inaktivitaet', PLAYER_INACTIVITY: 'Spieler-Inaktivitaet',
REWORKED: 'Redesignt', REWORKED: 'Redesignt',
OTHER: 'Sonstiger Grund', OTHER: 'Anderer Grund',
}; };
export const PLAN_LABEL: Record<ImportPlan, string> = { export const PLAN_LABEL: Record<ImportPlan, string> = {
+1
View File
@@ -57,6 +57,7 @@ export interface Character {
loreText: string | null; loreText: string | null;
status: CharacterStatus; status: CharacterStatus;
inactiveReason: InactiveReason | null; inactiveReason: InactiveReason | null;
inactiveNote: string | null;
lineageId: string; lineageId: string;
predecessorId: number | null; predecessorId: number | null;
predecessor: CharacterRef | null; predecessor: CharacterRef | null;
+26 -10
View File
@@ -197,7 +197,8 @@ export default function PlayerDetail() {
) : ( ) : (
<ul className="cc-list"> <ul className="cc-list">
{inactive.map((character) => { {inactive.map((character) => {
const free = character.inactiveReason === 'PLAYER_INACTIVITY'; // Nur ein redesignter CC existiert im Original nicht mehr.
const free = character.inactiveReason !== 'REWORKED';
return ( return (
<li key={character.id} className="cc-item is-inactive"> <li key={character.id} className="cc-item is-inactive">
<div> <div>
@@ -213,6 +214,9 @@ export default function PlayerDetail() {
· Ersetzt durch {character.successors.map((s) => s.name).join(', ')} · Ersetzt durch {character.successors.map((s) => s.name).join(', ')}
</span> </span>
)} )}
{character.inactiveNote && (
<p className="lore">{character.inactiveNote}</p>
)}
<p className="muted small"> <p className="muted small">
{free {free
? 'Kommt gratis zurueck — kostet kein Guthaben.' ? 'Kommt gratis zurueck — kostet kein Guthaben.'
@@ -367,12 +371,12 @@ export default function PlayerDetail() {
character={dialog.character} character={dialog.character}
busy={busy} busy={busy}
onClose={() => setDialog({ type: 'none' })} onClose={() => setDialog({ type: 'none' })}
onSubmit={(reason) => onSubmit={(reason, note) =>
void run( void run(
() => () =>
api(`/api/characters/${dialog.character.id}/deactivate`, { api(`/api/characters/${dialog.character.id}/deactivate`, {
method: 'POST', method: 'POST',
body: { reason }, body: { reason, ...(note.trim() ? { note: note.trim() } : {}) },
}), }),
`"${dialog.character.name}" ist jetzt inaktiv.`, `"${dialog.character.name}" ist jetzt inaktiv.`,
) )
@@ -383,7 +387,7 @@ export default function PlayerDetail() {
{dialog.type === 'reactivatePaid' && ( {dialog.type === 'reactivatePaid' && (
<KindChoiceModal <KindChoiceModal
title={`"${dialog.character.name}" reaktivieren`} title={`"${dialog.character.name}" reaktivieren`}
hint="Dieser CC existiert im Original nicht mehr. Die Reaktivierung kostet ein Guthaben." hint="Dieser CC wurde redesignt und existiert im Original nicht mehr. Die Reaktivierung kostet ein Guthaben."
kinds={['REDESIGN_KULANZ', 'REDESIGN_PAID', 'CC']} kinds={['REDESIGN_KULANZ', 'REDESIGN_PAID', 'CC']}
balances={detail.balances} balances={detail.balances}
busy={busy} busy={busy}
@@ -617,20 +621,22 @@ function DeactivateModal({
character: Character; character: Character;
busy: boolean; busy: boolean;
onClose: () => void; onClose: () => 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 [reason, setReason] = useState<'PLAYER_INACTIVITY' | 'OTHER'>('PLAYER_INACTIVITY');
const [note, setNote] = useState('');
return ( return (
<Modal title={`"${character.name}" deaktivieren`} onClose={onClose}> <Modal title={`"${character.name}" deaktivieren`} onClose={onClose}>
<form <form
onSubmit={(event) => { onSubmit={(event) => {
event.preventDefault(); event.preventDefault();
onSubmit(reason); onSubmit(reason, note);
}} }}
> >
<Notice kind="info"> <Notice kind="info">
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.
</Notice> </Notice>
<div className="radio-group"> <div className="radio-group">
@@ -643,7 +649,7 @@ function DeactivateModal({
/> />
<span> <span>
<strong>Spieler-Inaktivitaet</strong> <strong>Spieler-Inaktivitaet</strong>
<span className="muted"> — vom Server geputzt, kommt gratis zurueck</span> <span className="muted"> — vom Server geputzt, weil der Spieler laenger weg war</span>
</span> </span>
</label> </label>
<label className="radio"> <label className="radio">
@@ -654,12 +660,22 @@ function DeactivateModal({
onChange={() => setReason('OTHER')} onChange={() => setReason('OTHER')}
/> />
<span> <span>
<strong>Sonstiger Grund</strong> <strong>Anderer Grund</strong>
<span className="muted"> — Reaktivierung kostet ein Guthaben</span> <span className="muted"> — etwa eine voruebergehend entfernte Einheit</span>
</span> </span>
</label> </label>
</div> </div>
<label htmlFor="deactivate-note">
Notiz {reason === 'OTHER' ? '(empfohlen)' : '(optional)'}
</label>
<input
id="deactivate-note"
value={note}
onChange={(event) => setNote(event.target.value)}
placeholder="z. B. Einheit bis Maerz entfernt"
/>
<div className="form-actions"> <div className="form-actions">
<button type="button" className="btn btn-ghost" onClick={onClose}> <button type="button" className="btn btn-ghost" onClick={onClose}>
Abbrechen Abbrechen