Ops hardening plus two rules gaps close (rev 13)

Operational: auto-reboot for kernel patches (09:30 UTC), Caddy access
logs at /var/lib/caddy (self-rotating; the sandbox denies /var/log),
logrotate for the backup log, a restore drill proving a Spaces snapshot
boots 56/56 rooms clean, the about page's plain sentence on permanent
recording, and /wizwar-pulse to read it all weekly.

Rules: safes now smash — attacks aimed at the box accumulate on it and
the fifteenth point bursts it (widening, ungated). And SLOW DEATH's
bites pause for a victim holding an ABSORB — the card named by the FAQ,
not Absorb Spell — each soaking exactly one point, rev-gated at 13 with
the instant-bite legacy pinned. Bots weigh the soak against their life.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
This commit is contained in:
Eric Wagoner
2026-09-01 10:35:52 -04:00
co-authored by Claude Fable 5
parent a66b058c68
commit 92613d7386
11 changed files with 281 additions and 2 deletions
+30
View File
@@ -793,6 +793,13 @@
clearSelection();
return;
}
if (selectedCard && cardDef(selectedCard.cardId).cardType === "attack" &&
view.squareContents[cellKey(cell)]?.kind === "safe") {
// Brute force: attacks batter the safe itself (fifteen points bursts it).
dispatch(withMods({ type: "cast", instanceId: selectedCard.instanceId, target: { kind: "cell", cell } }));
clearSelection();
return;
}
if (selectedCard?.cardId === "relocate-sector") {
// Any board click (re-)picks the sector; the landing is chosen from the
// dashed ghost slots beyond the maze, since every on-board slot is taken.
@@ -1555,6 +1562,29 @@
{/if}
{/if}
{#if view?.slowDeathPending && view.slowDeathPending.playerId === view.you}
{@const sdAbsorbs = view.yourHand.filter((c) => c.cardId === "absorb")}
<div class="scrim attack-scrim" role="alertdialog" aria-label="slow death bites">
<div class="attack-notice">
<div class="attack-title">Slow Death bites for {view.slowDeathPending.points}!</div>
<div class="attack-power">
"ABSORB against SLOW DEATH will only stop the damage incurred by
one card" — each Absorb you spend soaks exactly one point.
</div>
<div class="attack-actions">
{#if sdAbsorbs.length > 0}
{@const firstAbsorb = sdAbsorbs[0]!}
<button class="stamp primary"
onclick={() => dispatch({ type: "slowDeathChoice", absorbInstanceId: firstAbsorb.instanceId })}>
Absorb a point ({sdAbsorbs.length} in hand)</button>
{/if}
<button class="hint-cancel" onclick={() => dispatch({ type: "slowDeathChoice" })}>
take {view.slowDeathPending.points === 1 ? "the point" : `all ${view.slowDeathPending.points}`}</button>
</div>
</div>
</div>
{/if}
{#if feedbackOpen && net.roomId}
<div class="scrim attack-scrim" role="button" tabindex="-1"
onclick={() => (feedbackOpen = false)} onkeydown={() => {}}>
+7
View File
@@ -158,6 +158,13 @@
the <a href="/?fpv">first-person workshop</a> walks the maze through
a wizard's own eyes (add <code>&demo=1</code> to watch a reel).
</p>
<h3>What the table remembers</h3>
<p>
Every game here is recorded permanently, move by move — table talk
included — so finished games can be replayed, shared, and studied.
Play under whatever name you like, but know that what you say and
do at the table becomes part of its lasting record.
</p>
<h3>Send word</h3>
<p>
Feedback, bug reports, and faint praise all welcome — ravens fly to
+4
View File
@@ -80,6 +80,10 @@ export function humanize(e: GameEvent): string | null {
case "lifeGained": return `${e.player} gains ${e.amount} life (${e.source}) — now ${e.lifeAfter}.`;
case "madDash": return `${e.player} MAD DASHES — every stride doubles: ${e.newAllowance} movement this turn!`;
case "safeOpened": return `${e.player}'s ${cardDef(e.withCardId).name} clicks the safe open — until turn's end.`;
case "safeDamaged": return `${e.attacker} batters the safe — ${e.amount} damage (${e.total}/15).`;
case "safeSmashed": return `💥 The safe BURSTS open under ${e.attacker}'s assault!`;
case "slowDeathWindow": return `Slow Death bites ${e.player} for ${e.points} — an Absorb hovers over the wound…`;
case "slowDeathAbsorbed": return `${e.player}'s Absorb soaks one point of the rot (${e.remaining} to go).`;
case "spellSustained": return `${spellName(e.cardId)} settles over ${e.target} (${e.turns} turn${e.turns === 1 ? "" : "s"}).`;
case "spellExpired": return `${spellName(e.cardId)} wears off ${e.target}.`;
case "teleported": return e.by === e.player