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
+27 -1
View File
@@ -201,6 +201,10 @@
peekCard = null;
peekCreatureId = null;
if (youMustRespond || youMustShield) {
if (youMustRespond && card.cardId === "teleport") {
selectedCard = card; // then click the escape square
return;
}
dispatch({ type: "counteract", instanceId: card.instanceId });
return;
}
@@ -317,6 +321,11 @@
function clickCell(cell: { x: number; y: number }) {
if (!view) return;
if (youMustRespond && selectedCard?.cardId === "teleport") {
dispatch({ type: "counteract", instanceId: selectedCard.instanceId, params: { cell } });
clearSelection();
return;
}
if (!isYourTurn) { peekAt(cell); return; }
if (pendingCellFor && selectedCard) {
// Stage 2 of teleport-opponent: destination chosen.
@@ -428,6 +437,21 @@
return;
}
}
// BIG MAN: clicking two squares away over a pit/tacks/ooze leaps it.
if (view.sustained.some((e) => e.cardId === "big-man" && e.targetId === view!.you)) {
for (const side of SIDES) {
const mid = { x: me.position.x + (side === "E" ? 1 : side === "W" ? -1 : 0),
y: me.position.y + (side === "S" ? 1 : side === "N" ? -1 : 0) };
const far = { x: me.position.x + (side === "E" ? 2 : side === "W" ? -2 : 0),
y: me.position.y + (side === "S" ? 2 : side === "N" ? -2 : 0) };
const hazard = view.squareContents[cellKey(mid)]?.kind;
if (cellKey(far) === cellKey(cell) &&
(hazard === "pit" || hazard === "tacks" || hazard === "ooze")) {
dispatch({ type: "move", direction: side, over: true });
return;
}
}
}
// The click claimed no action — offer the square's card instead.
peekAt(cell);
}
@@ -1162,7 +1186,9 @@
{/if}
{#if youMustRespond && view.stack}
<span class="hint-alert">
{#if view.stack.defenderId === view.you}
{#if view.stack.defenderId === view.you && selectedCard?.cardId === "teleport"}
Escaping by teleport — click a square within four spaces.
{:else if view.stack.defenderId === view.you}
{view.stack.attackerId} attacks with
{attackingCreature ? `the ${cardDef(attackingCreature.kind).name}` : view.stack.attackCard ? cardDef(view.stack.attackCard.cardId).name : "a punch"} —
tap a counteraction card, or
+5 -1
View File
@@ -101,7 +101,7 @@ class LocalGame {
seed,
sets: expansion ? ["basic", "expansion1"] : ["basic"],
...(colors ? { colors } : {}),
deckRev: 4,
deckRev: 5,
};
const { state, events } = createGame(config);
this.config = config;
@@ -118,6 +118,9 @@ class LocalGame {
this.tallyId = crypto.randomUUID();
this.activeMs = 0;
this.lastMoveAt = Date.now();
// iOS evicts unprotected origin storage under pressure; ask for durability
// so a saved hotseat game survives more than a forced reload.
navigator.storage?.persist?.().catch(() => {});
net.reportHotseat({ id: this.tallyId, stage: "started", players: cleaned.length });
this.persist();
return null;
@@ -152,6 +155,7 @@ class LocalGame {
this.gameState = current;
this.active = true;
this.viewerId = null;
navigator.storage?.persist?.().catch(() => {});
this.handoffTo = actorId(current);
return true;
} catch (e) {
+6
View File
@@ -145,6 +145,12 @@ export function humanize(e: GameEvent): string | null {
case "objectPickedUp": return `${e.player} picks up the ${cardDef(e.card.cardId).name} — actions over.`;
case "wardSet": return e.armed ? "Your ward is set — the next thief bleeds." : "Your ward stands down.";
case "chaosShielded": return `${e.player} raises a FULL SHIELD and sits out the chaos.`;
case "pushed": return e.player
? `${e.by} shoves ${e.player} down the corridor!`
: `${e.by} shoves the beast down the corridor!`;
case "spellTrapped": return `${e.caster} casts ${spellName(e.cardId)} into the slime — it sticks, waiting.`;
case "slimeTrapSprung": return `The slime releases its ${spellName(e.cardId)} at ${e.victim}!`;
case "slimeWashed": return `The wave washes the slime away.`;
case "wallDamaged": {
const what = e.needed === 15 ? "door" : "wall";
return `${e.player} batters the ${what} with ${e.source === "punch" ? "bare fists" : cardDef(e.source).name}${e.total}/${e.needed}.`;