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,
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
@@ -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,
+2
View File
@@ -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())
+19 -14
View File
@@ -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 },
});
}
+37 -7
View File
@@ -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`,
);
}
});
});
+1 -1
View File
@@ -18,7 +18,7 @@ export const SOURCE_LABEL: Record<CreditSource, string> = {
export const REASON_LABEL: Record<InactiveReason, string> = {
PLAYER_INACTIVITY: 'Spieler-Inaktivitaet',
REWORKED: 'Redesignt',
OTHER: 'Sonstiger Grund',
OTHER: 'Anderer Grund',
};
export const PLAN_LABEL: Record<ImportPlan, string> = {
+1
View File
@@ -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;
+26 -10
View File
@@ -197,7 +197,8 @@ export default function PlayerDetail() {
) : (
<ul className="cc-list">
{inactive.map((character) => {
const free = character.inactiveReason === 'PLAYER_INACTIVITY';
// Nur ein redesignter CC existiert im Original nicht mehr.
const free = character.inactiveReason !== 'REWORKED';
return (
<li key={character.id} className="cc-item is-inactive">
<div>
@@ -213,6 +214,9 @@ export default function PlayerDetail() {
· Ersetzt durch {character.successors.map((s) => s.name).join(', ')}
</span>
)}
{character.inactiveNote && (
<p className="lore">{character.inactiveNote}</p>
)}
<p className="muted small">
{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' && (
<KindChoiceModal
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']}
balances={detail.balances}
busy={busy}
@@ -617,20 +621,22 @@ function DeactivateModal({
character: Character;
busy: boolean;
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 [note, setNote] = useState('');
return (
<Modal title={`"${character.name}" deaktivieren`} onClose={onClose}>
<form
onSubmit={(event) => {
event.preventDefault();
onSubmit(reason);
onSubmit(reason, note);
}}
>
<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>
<div className="radio-group">
@@ -643,7 +649,7 @@ function DeactivateModal({
/>
<span>
<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>
</label>
<label className="radio">
@@ -654,12 +660,22 @@ function DeactivateModal({
onChange={() => setReason('OTHER')}
/>
<span>
<strong>Sonstiger Grund</strong>
<span className="muted"> — Reaktivierung kostet ein Guthaben</span>
<strong>Anderer Grund</strong>
<span className="muted"> — etwa eine voruebergehend entfernte Einheit</span>
</span>
</label>
</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">
<button type="button" className="btn btn-ghost" onClick={onClose}>
Abbrechen