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:
co-authored by
Claude Fable 5
parent
a66b058c68
commit
92613d7386
+100
-2
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user