Five fidelity gaps closed: teleport escapes, slime traps, the Big Man

moves like a giant, and the boards are diagram-verified

TELEPORT as a counteraction (official FAQ: "the attack has no chance
of hitting you"): the defender names an escape square within four
spaces, the escape resolves before anything lands, and an ANTI-ANTI
pins their boots to the floor. Additive — no revision gate needed.

FILL SQUARE WITH SLIME holds spells now: an attack cast at the slime
sticks in the gel (leaving the discard pile), springs once at whoever
is inside or next enters, and counteractions against the trapped
blast cannot touch its caster — reflections vanish into the ooze. A
five-point waterbolt washes the slime and its cargo away, and the
waterwall waves clear slime from their path.

BIG MAN, under rules rev 5, finally moves like the card says: he
pushes players and monsters down the corridor ahead of him (stuck or
unpushable occupants block his advance), steps over a pit, tacks, or
killer ooze for two movement points without ever entering the square
(click two cells beyond the hazard), and monsters may not enter his
square. All gated so stored games replay under their own rules.

The boards were never wrong — the rulebook's Set-Up Diagram photo
confirms every pairing the code already had: 2p crossed, 3p stair
with the Aisle Warp arc, 4p/6p straight-across, 5p plus with all four
corner arcs. The stale TODOs are gone, replaced by tests pinning each
letter pair, and relocation's "only opposite board edges connect"
(which discards the aisle warp) is pinned too. Wall of Fire vs
Waterbolt turned out to be implemented and tested all along — its
TODO comment was the only thing wrong.

Also: hotseat saves now request durable storage (iOS evicts
unprotected origins under pressure).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-16 14:17:37 -04:00
co-authored by Claude Fable 5
parent 28bc91c3ac
commit ed9585aa59
11 changed files with 779 additions and 21 deletions
+198 -13
View File
@@ -178,7 +178,7 @@ export interface CastStack {
powerAttackPoints: number;
params: CastParams | null;
kind: "spell" | "physical";
counters: { player: PlayerId; card: CardInstance; nullified: boolean }[];
counters: { player: PlayerId; card: CardInstance; nullified: boolean; cell?: Cell }[];
waitingOn: PlayerId;
/** CHAOS only: the defender's FULL SHIELD sat them out rather than stopping it. */
defenderShielded?: boolean;
@@ -186,6 +186,8 @@ export interface CastStack {
creatureId?: string;
/** The wraith's touch also steals a random card if damage lands. */
creatureTouch?: "wraith" | "claw";
/** A spell freed from slime: counteractions cannot touch its caster. */
trapped?: boolean;
}
export interface CastParams {
@@ -212,7 +214,9 @@ export interface GameConfig {
* Absent = original. Rev 2: LIFESAVER leaves two-player decks ("Not
* applicable in a 2-player game."). Rev 3: WARD springs only when armed,
* and CHAOS honors FULL SHIELD sit-outs and refuses REFLECTIONS. Rev 4:
* creature blows open a counteraction window like any attack.
* creature blows open a counteraction window like any attack. Rev 5:
* BIG MAN pushes occupants ahead, steps over floor hazards for 2 points,
* and bars monsters from his square.
*/
deckRev?: number;
}
@@ -225,6 +229,8 @@ export interface GameState {
edgeOverrides: Record<string, EdgeState>;
/** Accumulated attack damage per edge: a wall falls at 20, a door at 15. */
wallDamage: Record<string, number>;
/** Spells stuck in slime, waiting for the next visitor (by cell key). */
slimeTraps: Record<string, { card: CardInstance; casterId: PlayerId; numberValue: number | null; amplifyFactor: number }[]>;
/** Players whose WARD is set to spring (rules rev 3+; their secret). */
wardArmed: PlayerId[];
/** CHAOS is landing: each queued player may play FULL SHIELD to sit out. */
@@ -536,6 +542,10 @@ export type GameEvent =
| { type: "wardSprung"; owner: PlayerId; victim: PlayerId }
| { type: "wardSet"; player: PlayerId; armed: boolean; visibleTo: PlayerId }
| { type: "chaosShielded"; player: PlayerId }
| { type: "spellTrapped"; caster: PlayerId; cell: Cell; cardId: string }
| { type: "slimeTrapSprung"; cell: Cell; cardId: string; victim: PlayerId }
| { type: "slimeWashed"; cell: Cell }
| { type: "pushed"; by: PlayerId; player?: PlayerId; creatureId?: string; from: Cell; to: Cell }
| { type: "curseRemoved"; caster: PlayerId; target: PlayerId; cardId: string }
| { type: "objectEnchanted"; caster: PlayerId; cardId: string }
| { type: "warpTokensPlaced"; caster: PlayerId; a: Cell; b: Cell }
@@ -585,7 +595,7 @@ export type CastTarget =
| { kind: "cell"; cell: Cell };
export type Command =
| { type: "move"; direction: Side }
| { type: "move"; direction: Side; over?: boolean }
| { type: "playNumberForMovement"; instanceId: string; addInstanceId?: string }
| { type: "punch"; targetId: PlayerId }
| { type: "punchWall"; cell: Cell; side: Side }
@@ -616,7 +626,7 @@ export type Command =
}
| { type: "setAmbush"; instanceId: string; trigger: AmbushTrigger; spellInstanceId: string; numberInstanceIds?: string[] }
| { type: "cancelAmbush"; ambushId: string }
| { type: "counteract"; instanceId: string }
| { type: "counteract"; instanceId: string; params?: { cell?: Cell } }
| { type: "pass" }
| { type: "pickUpTreasure" }
| { type: "pickUpObject"; instanceId: string }
@@ -1181,8 +1191,8 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
},
"wall-of-fire": {
kind: "neutral",
// Neutral use: a burning barrier for [duration] turns. (Counteraction
// use vs WATERBOLT: TODO.)
// Neutral use: a burning barrier for [duration] turns. (Its counteraction
// use against WATERBOLT lives in doCounteract and the resolution pipe.)
resolve: (state, events, caster, cmd, magnitude) => {
if (!cmd.target || cmd.target.kind !== "edge") return "wall of fire targets a corridor edge";
const { cell, side } = cmd.target;
@@ -2293,6 +2303,11 @@ function waveFromEdge(state: GameState, events: GameEvent[], cell: Cell, side: S
destroyCreature(state, events, c, reason);
}
}
if (state.squareContents[cellKey(probe)]?.kind === "slime") {
delete state.squareContents[cellKey(probe)];
delete state.slimeTraps[cellKey(probe)];
events.push({ type: "slimeWashed", cell: probe });
}
probe = neighbor(probe, dir);
}
}
@@ -2526,6 +2541,11 @@ function doMoveCreature(prev: GameState, creatureId: string, direction: Side): C
} else {
const content = state.squareContents[cellKey(target.to)];
if (content?.kind === "stone") return err("that square is solid stone");
if ((state.config.deckRev ?? 1) >= 5 &&
state.players.some((o) => o.alive && cellKey(o.position) === cellKey(target.to) &&
sustainedOn(state, o.id, "big-man").length > 0)) {
return err("a giant fills that corridor");
}
creature.position = target.to;
}
creature.movementUsed++;
@@ -2569,6 +2589,32 @@ function doMoveCreature(prev: GameState, creatureId: string, direction: Side): C
return { ok: true, state, events };
}
/** A spell stuck in slime "goes off only once" at whoever is in the gel. */
function springSlimeTrap(state: GameState, events: GameEvent[], victim: PlayerState): void {
if (state.stack) return; // one thing at a time; the next visitor springs it
const key = cellKey(victim.position);
const traps = state.slimeTraps[key];
if (!traps || traps.length === 0) return;
const trap = traps.shift()!;
if (traps.length === 0) delete state.slimeTraps[key];
state.discard.push(trap.card);
events.push({ type: "slimeTrapSprung", cell: victim.position, cardId: trap.card.cardId, victim: victim.id });
state.stack = {
attackerId: trap.casterId,
defenderId: victim.id,
attackCard: trap.card,
numberValue: trap.numberValue,
amplifyFactor: trap.amplifyFactor,
extendFactor: 1,
powerAttackPoints: 0,
params: null,
kind: (CARD_EFFECTS[trap.card.cardId] as AttackEffect | undefined)?.physical ? "physical" : "spell",
counters: [],
waitingOn: victim.id,
trapped: true,
};
}
/** Rules rev 4: a creature's blow opens a counteraction window like any attack. */
function openCreatureStack(
state: GameState,
@@ -2719,6 +2765,7 @@ function remapState(
state.illusionWalls = remapRecord(state.illusionWalls, mapEdgeKey);
state.openDoorEdges = state.openDoorEdges.map(mapEdgeKey);
state.squareContents = remapRecord(state.squareContents, mapCellKey);
state.slimeTraps = remapRecord(state.slimeTraps, mapCellKey);
state.groundObjects = remapRecord(state.groundObjects, mapCellKey);
for (const fx of state.sustained) {
if (fx.edge) fx.edge = mapEdgeKey(fx.edge);
@@ -3008,6 +3055,7 @@ export function createGame(config: GameConfig): { state: GameState; events: Game
board,
edgeOverrides: {},
wallDamage: {},
slimeTraps: {},
wardArmed: [],
chaosPending: null,
doorStates: {},
@@ -3082,7 +3130,7 @@ export function applyCommand(state: GameState, playerId: PlayerId, command: Comm
if (state.stack) {
if (playerId !== state.stack.waitingOn) return err("waiting for another player's response");
if (command.type === "counteract") return doCounteract(state, playerId, command.instanceId);
if (command.type === "counteract") return doCounteract(state, playerId, command.instanceId, command.params);
if (command.type === "pass") return doPass(state, playerId);
return err("an attack is being resolved — counteract or pass");
}
@@ -3186,7 +3234,7 @@ export function applyCommand(state: GameState, playerId: PlayerId, command: Comm
}
switch (command.type) {
case "move": return doMove(state, command.direction);
case "move": return doMove(state, command.direction, command.over === true);
case "playNumberForMovement": return doPlayNumberForMovement(state, command.instanceId, command.addInstanceId);
case "punch": return doPunch(state, command.targetId);
case "punchWall": return doPunchWall(state, command.cell, command.side);
@@ -3242,7 +3290,7 @@ function blindBump(state: GameState, events: GameEvent[], p: PlayerState, direct
return { ok: true, state, events };
}
function doMove(prev: GameState, direction: Side): CommandResult {
function doMove(prev: GameState, direction: Side, over = false): CommandResult {
const blocked = requireActionsAvailable(prev);
if (blocked) return err(blocked);
if (prev.turn.movementUsed >= prev.turn.movementAllowance) return err("no movement left");
@@ -3279,6 +3327,24 @@ function doMove(prev: GameState, direction: Side): CommandResult {
}
const view = boardView(state);
// BIG MAN: "Using 2 movement points, you can step over a PIT, TACKS, or
// KILLER OOZE" (rules rev 5). The giant strides the hazard square without
// entering it — one point charged here, then the normal step below lands
// him beyond it with every usual arrival effect (and the second point).
if (over) {
const big = (state.config.deckRev ?? 1) >= 5 && sustainedOn(state, p.id, "big-man").length > 0;
if (!big) return err("only a giant steps over hazards");
if (isBlinded(state, p)) return err("you cannot leap what you cannot see");
if (state.turn.movementAllowance - state.turn.movementUsed < 2) return err("stepping over costs 2 movement");
const hop1 = stepTarget(view, p.position, direction);
if (hop1.kind !== "step") return err("nothing to step over that way");
const hazard = state.squareContents[cellKey(hop1.to)]?.kind;
if (hazard !== "pit" && hazard !== "tacks" && hazard !== "ooze") {
return err("you may step over a pit, tacks, or killer ooze");
}
state.turn.movementUsed++;
p.position = hop1.to;
}
const target = stepTarget(view, p.position, direction);
const misted = isMisted(state, p.id);
@@ -3332,6 +3398,41 @@ function doMove(prev: GameState, direction: Side): CommandResult {
return err("a giant fills that corridor");
}
}
// BIG MAN pushes: "You can push monsters or other players (in an adjacent
// square) down the corridor ahead of you as you move" (rules rev 5). No
// room to shove them onward means no way forward for the giant either.
if ((state.config.deckRev ?? 1) >= 5 && via === "step" &&
sustainedOn(state, p.id, "big-man").length > 0) {
const here = cellKey(p.position);
const pushPlayers = state.players.filter((o) => o.id !== p.id && o.alive && cellKey(o.position) === here);
const pushCreatures = state.creatures.filter((c) => cellKey(c.position) === here);
if (pushPlayers.length > 0 || pushCreatures.length > 0) {
if (pushPlayers.some((o) => isLockedInPlace(state, o.id))) {
p.position = from;
return err("someone there is stuck fast and cannot be pushed");
}
const shove = stepTarget(view, p.position, direction);
const shoveOk =
shove.kind === "step" &&
state.squareContents[cellKey(shove.to)]?.kind !== "stone" &&
!state.players.some((o) => o.alive && cellKey(o.position) === cellKey(shove.to) &&
sustainedOn(state, o.id, "big-man").length > 0);
if (!shoveOk) {
p.position = from;
return err("no room to push them onward");
}
for (const o of pushPlayers) {
const oFrom = o.position;
o.position = { ...shove.to };
events.push({ type: "pushed", by: p.id, player: o.id, from: oFrom, to: o.position });
}
for (const c of pushCreatures) {
const cFrom = c.position;
c.position = { ...shove.to };
events.push({ type: "pushed", by: p.id, creatureId: c.id, from: cFrom, to: c.position });
}
}
}
// FEAR: no one moves within 3 spaces of the fearsome one.
for (const other of state.players) {
if (other.id === p.id || !other.alive) continue;
@@ -3452,10 +3553,12 @@ function doMove(prev: GameState, direction: Side): CommandResult {
}
checkVictory(state, events);
}
// FILL SQUARE WITH SLIME: entering ends your turn's actions.
// FILL SQUARE WITH SLIME: entering ends your turn's actions — and any
// spell stuck in the gel goes off at the visitor.
if (content?.kind === "slime" && p.alive && !misted) {
events.push({ type: "stuckInSlime", player: p.id, at: p.position });
state.turn.actionsEnded = true;
springSlimeTrap(state, events, p);
}
// IDIOT lifts when the victim reaches their own treasure.
if (sustainedOn(state, p.id, "idiot").length > 0) {
@@ -4005,6 +4108,47 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
}
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.
if (cmd.target?.kind === "cell" &&
state.squareContents[cellKey(cmd.target.cell)]?.kind === "slime") {
const cell = cmd.target.cell;
if (effect.requiresLos && !casterLos(state, caster, caster.position, cell)) {
return err("no line of sight to the slime");
}
const wandEvents: GameEvent[] = [];
{
const werr = spendWandCharge(state, caster, wandEvents);
if (werr) return err(werr);
}
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 events2: GameEvent[] = [...wandEvents];
if (inHand.cardId === "waterbolt" && mods.magnitude.power >= 5) {
delete state.squareContents[cellKey(cell)];
delete state.slimeTraps[cellKey(cell)];
events2.push({ type: "slimeWashed", cell });
return { ok: true, state, events: events2 };
}
// The card leaves the discard and lives in the gel.
const di = state.discard.findIndex((c) => c.instanceId === inHand.instanceId);
if (di !== -1) state.discard.splice(di, 1);
const key = cellKey(cell);
state.slimeTraps[key] = [...(state.slimeTraps[key] ?? []), {
card: inHand, casterId: caster.id,
numberValue: mods.magnitude.numberValue,
amplifyFactor: 2 ** mods.amplifies.length,
}];
events2.push({ type: "spellTrapped", caster: caster.id, cell, cardId: inHand.cardId });
// "affect anyone in the slime": a current occupant springs it at once.
const occupant = state.players.find((p) => p.alive && p.id !== caster.id && cellKey(p.position) === key);
if (occupant) springSlimeTrap(state, events2, occupant);
return { ok: true, state, events: events2 };
}
// "Any attack against an inanimate object counts as your one attack for
// the turn." A wall or door soaks the spell's damage; no counteractions.
if (cmd.target?.kind === "edge") {
@@ -4357,7 +4501,7 @@ function checkAmbushes(
}
}
function doCounteract(prev: GameState, playerId: PlayerId, instanceId: string): CommandResult {
function doCounteract(prev: GameState, playerId: PlayerId, instanceId: string, params?: { cell?: Cell }): CommandResult {
const state = clone(prev);
const stack = state.stack!;
const player = state.players.find((p) => p.id === playerId)!;
@@ -4404,6 +4548,31 @@ function doCounteract(prev: GameState, playerId: PlayerId, instanceId: string):
return { ok: true, state, events };
}
// TELEPORT: "If you use this spell as a Counteraction, the attack has no
// chance of hitting you" (official FAQ). The escape happens at resolution,
// so an ANTI-ANTI can still pin your boots to the floor.
if (card.cardId === "teleport") {
const to = params?.cell;
if (!to) return err("teleport needs a destination cell");
if (isLockedInPlace(state, playerId)) return err("you are locked in place");
const view = boardView(state);
if (!view.cells[cellKey(to)]) return err("destination is off the board");
if (state.squareContents[cellKey(to)]?.kind === "stone") return err("that square is solid stone");
if (wallIgnoringDistance(view, player.position, to) > 4) {
return err("teleport reaches at most four spaces");
}
takeFromHand(player, instanceId);
state.discard.push(card);
stack.counters.push({ player: playerId, card, nullified: false, cell: to });
stack.waitingOn = stack.attackerId;
state.lastSpellUsed[playerId] = card.cardId;
return {
ok: true,
state,
events: [{ type: "counteractionPlayed", player: playerId, card, cardId: card.cardId, against: stack.attackCard?.cardId ?? "punch" }],
};
}
// WALL OF FIRE: "As a COUNTERACTION, it will stop a WATERBOLT."
if (card.cardId === "wall-of-fire") {
if (stack.attackCard?.cardId !== "waterbolt") {
@@ -4582,6 +4751,12 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
pipe.fullyStopped = true;
continue;
}
if (counter.card.cardId === "teleport") {
pipe.damage = 0;
pipe.duration = 0;
pipe.fullyStopped = true;
continue;
}
if (stack.creatureId &&
(counter.card.cardId === "reflection" || counter.card.cardId === "full-reflection")) {
// "REFLECTIONs used on the wraith's touch will damage the wraith."
@@ -4598,13 +4773,23 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
if (ce && ce.kind === "counter") ce.apply(pipe);
}
// A surviving teleport counter whisks the defender away before anything lands.
const escape = stack.counters.find((c) => !c.nullified && c.card.cardId === "teleport" && c.cell);
if (escape) {
const from = defender.position;
defender.position = { ...escape.cell! };
events.push({ type: "teleported", player: defender.id, from, to: defender.position, by: defender.id, cardId: "teleport" });
}
let damageDealt = 0;
const attackingCreature = stack.creatureId
? state.creatures.find((c) => c.id === stack.creatureId)
: undefined;
if (pipe.redirected) {
if (pipe.damage > 0) {
if (attackingCreature) {
if (stack.trapped) {
// "Counteractions against 'trapped' attacks do not affect the caster."
} else if (attackingCreature) {
damageCreature(state, events, attackingCreature, pipe.damage, "reflected touch");
} else {
applyDamage(state, events, attacker, pipe.damage, `${attackId} (reflected)`, defender.id);
@@ -4634,7 +4819,7 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
state.discard.push(card!);
events.push({ type: "cardsDiscarded", player: defender.id, cards: [card!] });
}
if (pipe.reflectedDamage > 0) {
if (pipe.reflectedDamage > 0 && !stack.trapped) {
if (attackingCreature) {
damageCreature(state, events, attackingCreature, pipe.reflectedDamage, "reflected touch");
} else {