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
+12
View File
@@ -1274,6 +1274,17 @@ export function automatonCommand(
const tier = TIERS[tierName] ?? TIERS.archmage;
if (view.phase !== "playing") return null;
if (view.slowDeathPending) {
if (view.slowDeathPending.playerId !== you) return null;
// Soak only when the bites reach toward the floor — the card is worth
// more against a fireball than one point of rot.
const absorb = inHand(view, "absorb");
const pts = view.slowDeathPending.points;
if (absorb && me(view).life - pts <= 4) {
return { type: "slowDeathChoice", absorbInstanceId: absorb.instanceId };
}
return { type: "slowDeathChoice" };
}
if (view.wardPending) {
// A Ward is free damage on a thief of OUR gold: always spring it.
return view.wardPending.ownerId === you ? { type: "wardChoice", play: true } : null;
@@ -1803,6 +1814,7 @@ export function automatonCommand(
export function automatonFallback(view: GameView, tierName: AutomatonTier = "archmage"): Command {
const you = view.you;
if (view.wardPending?.ownerId === you) return { type: "wardChoice", play: false };
if (view.slowDeathPending?.playerId === you) return { type: "slowDeathChoice" };
if (view.pendingDiscard === you) {
return { type: "discard", instanceIds: worstCards(view, Math.max(1, overLimit(view))) };
}
+100 -2
View File
@@ -238,7 +238,7 @@ export interface CastParams {
}
/** The revision new games are dealt under; GameConfig.deckRev pins it per game. */
export const CURRENT_RULES_REV = 12;
export const CURRENT_RULES_REV = 13;
export interface GameConfig {
playerIds: PlayerId[];
@@ -284,6 +284,9 @@ export interface GameConfig {
* number riding the cast fuels it, and numbers or POWER RUN points
* played under the dash double; older games doubled only the base
* allowance and consumed the rider without effect.
* Rev 13: SLOW DEATH's bites pause for a victim holding ABSORB each
* absorb soaks exactly one point (FAQ: "only stop the damage incurred
* by one card"); older games bit instantly.
*/
deckRev?: number;
}
@@ -301,6 +304,9 @@ export interface GameState {
/** A treasure was just grabbed and its owner holds WARD:
* the table waits while they choose to play it "at that time" or not. */
wardPending: { ownerId: PlayerId; takerId: PlayerId } | null;
/** SLOW DEATH's bites hang while the victim decides whether an ABSORB
* soaks a point ("only stop the damage incurred by one card"). */
slowDeathPending: { playerId: PlayerId; points: number } | null;
/** CHAOS is landing: each queued player may play FULL SHIELD to sit out. */
chaosPending: { casterId: PlayerId; excluded: PlayerId[]; queue: PlayerId[] } | null;
/** Permanent door-lock changes, by edge key. */
@@ -745,6 +751,10 @@ export type GameEvent =
| { type: "lifeTraded"; player: PlayerId; points: number; newAllowance: number }
| { type: "madDash"; player: PlayerId; newAllowance: number }
| { type: "safeOpened"; player: PlayerId; cell: Cell; withCardId: string }
| { type: "slowDeathWindow"; player: PlayerId; points: number }
| { type: "slowDeathAbsorbed"; player: PlayerId; remaining: number }
| { type: "safeDamaged"; attacker: PlayerId; cell: Cell; amount: number; total: number }
| { type: "safeSmashed"; attacker: PlayerId; cell: Cell }
| { type: "trapSprung"; player: PlayerId; cardId?: string }
| { type: "died"; player: PlayerId; killedBy: PlayerId | null }
| { type: "handTaken"; from: PlayerId; to: PlayerId; count: number }
@@ -786,6 +796,7 @@ export type Command =
| { type: "testIllusion"; cell: Cell; side: Side }
| { type: "armWard" }
| { type: "wardChoice"; play: boolean }
| { type: "slowDeathChoice"; absorbInstanceId?: string }
| { type: "warpStep" }
| { type: "moveCreature"; creatureId: string; direction: Side }
| { type: "creatureWarpStep"; creatureId: string }
@@ -3807,6 +3818,7 @@ export function createGame(config: GameConfig): { state: GameState; events: Game
wallDamage: {},
slimeTraps: {},
wardPending: null,
slowDeathPending: null,
chaosPending: null,
doorStates: {},
openDoorEdges: [],
@@ -3886,6 +3898,13 @@ function applyCommandInner(state: GameState, playerId: PlayerId, command: Comman
return doWardChoice(state, command.play);
}
// SLOW DEATH's window: the bites hang while the victim weighs an absorb.
if (state.slowDeathPending) {
if (playerId !== state.slowDeathPending.playerId) return err("waiting on the slow death's victim");
if (command.type !== "slowDeathChoice") return err("soak a point with an Absorb or take the bites");
return doSlowDeathChoice(state, playerId, command.absorbInstanceId);
}
if (state.pendingDiscard) {
if (playerId !== state.pendingDiscard) return err("waiting for another player to discard");
if (command.type !== "discard") return err("you must discard down to the hand limit first");
@@ -4006,6 +4025,7 @@ function applyCommandInner(state: GameState, playerId: PlayerId, command: Comman
case "testIllusion": return doTestIllusion(state, command.cell, command.side);
case "armWard": return doArmWard();
case "wardChoice": return err("no grab is hanging on your Ward");
case "slowDeathChoice": return err("no slow death bites are hanging");
case "warpStep": return doWarpStep(state);
case "moveCreature": return doMoveCreature(state, command.creatureId, command.direction);
case "creatureWarpStep": return doCreatureWarpStep(state, command.creatureId);
@@ -5221,6 +5241,46 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
}
return { ok: true, state, events };
}
// SAFE: "To smash it open takes fifteen points of damage" — attacks
// aimed at the box accumulate on it; the fifteenth point bursts it,
// leaving whatever it guarded on the open floor.
if (cmd.target?.kind === "cell" &&
state.squareContents[cellKey(cmd.target.cell)]?.kind === "safe") {
const cell = cmd.target.cell;
if (effect.sameSquare && cellKey(cell) !== cellKey(caster.position)) {
return err("you must be in the same square");
}
if (effect.requiresLos && !(mods.aroundCorner
? bentLos(state, caster, caster.position, cell)
: castSight(state, caster, cmd, cell))) {
return err("no line of sight to the safe");
}
const wandEvents: GameEvent[] = [];
{
const werr = spendWandCharge(state, caster, wandEvents);
if (werr) return err(werr);
}
const dmg = effect.baseDamage(mods.magnitude.numberValue, cmd.params ?? null) * (2 ** mods.amplifies.length);
if (dmg <= 0) return err("that spell would not dent the safe");
consumeCast(state, caster, inHand, mods, effect.keepInHand ?? false);
if (state.turn.attackUsed) state.turn.secondAttackUsed = true;
state.turn.attackUsed = true;
state.lastSpellUsed[caster.id] = inHand.cardId;
const events: GameEvent[] = [...wandEvents, {
type: "spellCast", caster: caster.id, card: inHand, cardId: inHand.cardId,
numberCards: mods.numbers, numberValue: mods.magnitude.numberValue,
from: caster.position, target: null, targetCell: { ...cell },
}];
const box = state.squareContents[cellKey(cell)]!;
box.damage += dmg;
events.push({ type: "safeDamaged", attacker: caster.id, cell: { ...cell }, amount: dmg, total: box.damage });
if (box.damage >= 15) {
delete state.squareContents[cellKey(cell)];
state.openSafes = state.openSafes.filter((k) => k !== cellKey(cell));
events.push({ type: "safeSmashed", attacker: caster.id, cell: { ...cell } });
}
return { ok: true, state, events };
}
// FILL SQUARE WITH SLIME: "Spells cast at the slime get stuck there, and
// affect anyone in the slime or entering it later on." A 5-point WATERBOLT
// washes the slime away instead.
@@ -6642,13 +6702,51 @@ function applySlowDeathOnDraw(state: GameState, events: GameEvent[], p: PlayerSt
p.life += amount;
events.push({ type: "lifeGained", player: p.id, amount, source: "slow death (reversed)", lifeAfter: p.life });
}
for (let i = 0; i < cardsDrawn * (curses.length - blessed); i++) {
const bites = cardsDrawn * (curses.length - blessed);
if (bites === 0) return;
// Rev 13, per the FAQ: ABSORB "will only stop the damage incurred by one
// card" — one point, one bite. A victim holding an ABSORB gets the choice
// before the bites land. Older games bit instantly and replay so.
if ((state.config.deckRev ?? 1) >= 13 && p.hand.some((c) => c.cardId === "absorb")) {
state.slowDeathPending = { playerId: p.id, points: bites };
events.push({ type: "slowDeathWindow", player: p.id, points: bites });
return;
}
for (let i = 0; i < bites; i++) {
if (!p.alive) break;
applyDamage(state, events, p, 1, "slow death", null);
}
checkVictory(state, events);
}
/** The victim's answer to hanging SLOW DEATH bites: each ABSORB soaks
* exactly one point ("the damage incurred by one card" is one bite);
* declining, or running out of absorbs, takes the rest. */
function doSlowDeathChoice(prev: GameState, playerId: PlayerId, absorbInstanceId?: string): CommandResult {
const state = clone(prev);
const pending = state.slowDeathPending!;
const p = state.players.find((q) => q.id === playerId)!;
const events: GameEvent[] = [];
if (absorbInstanceId) {
const card = p.hand.find((c) => c.instanceId === absorbInstanceId);
if (!card || card.cardId !== "absorb") return err("that is not an Absorb in your hand");
takeFromHand(p, absorbInstanceId);
state.discard.push(card);
pending.points -= 1;
events.push({ type: "slowDeathAbsorbed", player: p.id, remaining: pending.points });
if (pending.points > 0 && p.hand.some((c) => c.cardId === "absorb")) {
return { ok: true, state, events };
}
}
state.slowDeathPending = null;
for (let i = 0; i < pending.points; i++) {
if (!p.alive) break;
applyDamage(state, events, p, 1, "slow death", null);
}
checkVictory(state, events);
return { ok: true, state, events };
}
function drawOne(state: GameState, events: GameEvent[]): CardInstance | null {
if (state.deck.length === 0) {
const [reshuffled, rngNext] = shuffle(state.rng, state.discard);
+2
View File
@@ -88,6 +88,7 @@ export interface GameView {
chaosPending: { casterId: PlayerId; queue: PlayerId[] } | null;
/** A grab hangs while the treasure's owner decides their Ward. */
wardPending: { ownerId: PlayerId; takerId: PlayerId } | null;
slowDeathPending: { playerId: PlayerId; points: number } | null;
/** YOUR armed ambushes. Other players' ambushes are invisible. */
yourAmbushes: AmbushState[];
/** Once the game is finished, every hand goes face-up on the table. */
@@ -171,6 +172,7 @@ export function viewFor(state: GameState, playerId: PlayerId): GameView {
? { casterId: state.chaosPending.casterId, queue: [...state.chaosPending.queue] }
: null,
wardPending: state.wardPending ? { ...state.wardPending } : null,
slowDeathPending: state.slowDeathPending ? { ...state.slowDeathPending } : null,
yourAmbushes: state.ambushes
.filter((a) => a.ownerId === playerId)
.map((a) => ({ ...a, numbers: [...a.numbers] })),