BIG MAN pushes around corners; at a fork the pushed wizard chooses

Eric, a giant on his first turn, could only shove a wizard in a
straight line: the engine shoved in the giant's own direction or
refused. The FAQ says otherwise — "If Big Man pushes someone to an
intersection, then the other player gets to decide which direction to
go." Now a pushed wizard leaves by any open side but the way the giant
came. One open side is taken. Several are theirs to choose: the stride
hangs in a pushPending window like the Ward's, the pushed wizard
answers with pushChoice (a dialog offers the ways; an automaton steps
out of the giant's line), and the stride resumes with the answer — a
blinded giant's roll is not rolled twice.

Rounding a corner was a refusal before, so every game may do it now.
Only the case that used to resolve — straight ahead open — keeps its
old reading in games dealt before rev 15; from rev 15 the pushed
wizard chooses there too. Tests pin the corner, the fork's window and
its refusals, and the older reading; 84 ledgers replay.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
This commit is contained in:
Eric Wagoner
2026-09-04 17:19:34 -04:00
co-authored by Claude Fable 5.1
parent c6913ea1bc
commit e724267b0a
8 changed files with 216 additions and 25 deletions
+8
View File
@@ -1337,6 +1337,13 @@ export function automatonCommand(
// A Ward is free damage on a thief of OUR gold: always spring it. // A Ward is free damage on a thief of OUR gold: always spring it.
return view.wardPending.ownerId === you ? { type: "wardChoice", play: true } : null; return view.wardPending.ownerId === you ? { type: "wardChoice", play: true } : null;
} }
if (view.pushPending) {
if (view.pushPending.pusheeId !== you) return null;
// Shoved to a fork: step out of the giant's line so he cannot keep
// pushing; failing that, the first way open.
const { direction, exits } = view.pushPending;
return { type: "pushChoice", direction: exits.find((d) => d !== direction) ?? exits[0]! };
}
if (view.pendingDiscard === you) { if (view.pendingDiscard === you) {
return { type: "discard", instanceIds: worstCards(view, Math.max(1, overLimit(view)), style) }; return { type: "discard", instanceIds: worstCards(view, Math.max(1, overLimit(view)), style) };
} }
@@ -1866,6 +1873,7 @@ export function automatonCommand(
export function automatonFallback(view: GameView, tierName: AutomatonTier = "archmage"): Command { export function automatonFallback(view: GameView, tierName: AutomatonTier = "archmage"): Command {
const you = view.you; const you = view.you;
if (view.wardPending?.ownerId === you) return { type: "wardChoice", play: false }; if (view.wardPending?.ownerId === you) return { type: "wardChoice", play: false };
if (view.pushPending?.pusheeId === you) return { type: "pushChoice", direction: view.pushPending.exits[0]! };
if (view.slowDeathPending?.playerId === you) return { type: "slowDeathChoice" }; if (view.slowDeathPending?.playerId === you) return { type: "slowDeathChoice" };
if (view.pendingDiscard === you) { if (view.pendingDiscard === you) {
return { type: "discard", instanceIds: worstCards(view, Math.max(1, overLimit(view))) }; return { type: "discard", instanceIds: worstCards(view, Math.max(1, overLimit(view))) };
+78 -12
View File
@@ -238,7 +238,7 @@ export interface CastParams {
} }
/** The revision new games are dealt under; GameConfig.deckRev pins it per game. */ /** The revision new games are dealt under; GameConfig.deckRev pins it per game. */
export const CURRENT_RULES_REV = 14; export const CURRENT_RULES_REV = 15;
/** Every rulings revision since the baseline, newest last the entries a /** Every rulings revision since the baseline, newest last the entries a
* game's deckRev freezes it before or after. Shown to players as the house * game's deckRev freezes it before or after. Shown to players as the house
@@ -257,6 +257,7 @@ export const RULES_REVISIONS: { rev: number; note: string }[] = [
{ rev: 12, note: "MAD DASH doubles \"NUMBER cards and other add-ons\" too — a 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: 12, note: "MAD DASH doubles \"NUMBER cards and other add-ons\" too — a 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, note: "SLOW DEATH's per-draw bites land as ONE blow that pauses for a victim holding ABSORB or BLUNT — countered against the total (absorb soaks up to 3, blunt halves rounding up); older games bit instantly." }, { rev: 13, note: "SLOW DEATH's per-draw bites land as ONE blow that pauses for a victim holding ABSORB or BLUNT — countered against the total (absorb soaks up to 3, blunt halves rounding up); older games bit instantly." },
{ rev: 14, note: "Crossing a pit on a 2, 3, or 4 means edging around its rim: the walker lands on an open square beside the pit — the only one if there is one, otherwise the one they name by clicking it — and a pit with no way off cannot be entered. Older games bounced the walker back and charged the stride." }, { rev: 14, note: "Crossing a pit on a 2, 3, or 4 means edging around its rim: the walker lands on an open square beside the pit — the only one if there is one, otherwise the one they name by clicking it — and a pit with no way off cannot be entered. Older games bounced the walker back and charged the stride." },
{ rev: 15, note: "BIG MAN at a fork: a pushed wizard leaves by any open side but the way the giant came — the only one if there is one, otherwise the side they choose, the giant's stride hanging until they do (FAQ: at an intersection the other player decides). Older games shove straight ahead whenever that way is open; rounding a corner was never possible before and is allowed in every game." },
]; ];
export interface GameConfig { export interface GameConfig {
@@ -288,6 +289,11 @@ export interface GameState {
/** A treasure was just grabbed and its owner holds WARD: /** A treasure was just grabbed and its owner holds WARD:
* the table waits while they choose to play it "at that time" or not. */ * the table waits while they choose to play it "at that time" or not. */
wardPending: { ownerId: PlayerId; takerId: PlayerId } | null; wardPending: { ownerId: PlayerId; takerId: PlayerId } | null;
/** BIG MAN has pushed a wizard to a fork: the giant's stride hangs while
* the pushed one picks which way to go (rev 15). */
pushPending: {
giantId: PlayerId; pusheeId: PlayerId; direction: Side; over: boolean; exit?: Side; exits: Side[];
} | null;
/** SLOW DEATH's bites hang while the victim weighs counteractions: /** SLOW DEATH's bites hang while the victim weighs counteractions:
* the draw's total is one blow ABSORB soaks up to three of it, * the draw's total is one blow ABSORB soaks up to three of it,
* BLUNT halves it rounding up (table ruling). */ * BLUNT halves it rounding up (table ruling). */
@@ -708,6 +714,8 @@ export type GameEvent =
| { type: "swapFizzled"; player: PlayerId } | { type: "swapFizzled"; player: PlayerId }
| { type: "wardSprung"; owner: PlayerId; victim: PlayerId } | { type: "wardSprung"; owner: PlayerId; victim: PlayerId }
| { type: "wardWindow"; owner: PlayerId; victim: PlayerId } | { type: "wardWindow"; owner: PlayerId; victim: PlayerId }
| { type: "pushWindow"; giant: PlayerId; pushee: PlayerId; exits: Side[] }
| { type: "pushChosen"; pushee: PlayerId; direction: Side }
| { type: "chaosShielded"; player: PlayerId } | { type: "chaosShielded"; player: PlayerId }
| { type: "spellTrapped"; caster: PlayerId; cell: Cell; cardId: string } | { type: "spellTrapped"; caster: PlayerId; cell: Cell; cardId: string }
| { type: "slimeTrapSprung"; cell: Cell; cardId: string; victim: PlayerId } | { type: "slimeTrapSprung"; cell: Cell; cardId: string; victim: PlayerId }
@@ -785,6 +793,8 @@ export type Command =
| { type: "testIllusion"; cell: Cell; side: Side } | { type: "testIllusion"; cell: Cell; side: Side }
| { type: "armWard" } | { type: "armWard" }
| { type: "wardChoice"; play: boolean } | { type: "wardChoice"; play: boolean }
/** The pushed wizard's answer at a fork: the side they go. */
| { type: "pushChoice"; direction: Side }
| { type: "slowDeathChoice"; counterInstanceId?: string } | { type: "slowDeathChoice"; counterInstanceId?: string }
| { type: "warpStep" } | { type: "warpStep" }
| { type: "moveCreature"; creatureId: string; direction: Side } | { type: "moveCreature"; creatureId: string; direction: Side }
@@ -3807,6 +3817,7 @@ export function createGame(config: GameConfig): { state: GameState; events: Game
wallDamage: {}, wallDamage: {},
slimeTraps: {}, slimeTraps: {},
wardPending: null, wardPending: null,
pushPending: null,
slowDeathPending: null, slowDeathPending: null,
chaosPending: null, chaosPending: null,
doorStates: {}, doorStates: {},
@@ -3887,6 +3898,13 @@ function applyCommandInner(state: GameState, playerId: PlayerId, command: Comman
return doWardChoice(state, command.play); return doWardChoice(state, command.play);
} }
// BIG MAN's fork: the stride hangs while the pushed wizard picks a way.
if (state.pushPending) {
if (playerId !== state.pushPending.pusheeId) return err("waiting on the pushed wizard to choose a way");
if (command.type !== "pushChoice") return err("choose which way you are pushed");
return doPushChoice(state, command.direction);
}
// SLOW DEATH's window: the bites hang while the victim weighs an absorb. // SLOW DEATH's window: the bites hang while the victim weighs an absorb.
if (state.slowDeathPending) { if (state.slowDeathPending) {
if (playerId !== state.slowDeathPending.playerId) return err("waiting on the slow death's victim"); if (playerId !== state.slowDeathPending.playerId) return err("waiting on the slow death's victim");
@@ -4014,6 +4032,7 @@ function applyCommandInner(state: GameState, playerId: PlayerId, command: Comman
case "testIllusion": return doTestIllusion(state, command.cell, command.side); case "testIllusion": return doTestIllusion(state, command.cell, command.side);
case "armWard": return doArmWard(); case "armWard": return doArmWard();
case "wardChoice": return err("no grab is hanging on your Ward"); case "wardChoice": return err("no grab is hanging on your Ward");
case "pushChoice": return err("nobody is pushing you");
case "slowDeathChoice": return err("no slow death bites are hanging"); case "slowDeathChoice": return err("no slow death bites are hanging");
case "warpStep": return doWarpStep(state); case "warpStep": return doWarpStep(state);
case "moveCreature": return doMoveCreature(state, command.creatureId, command.direction); case "moveCreature": return doMoveCreature(state, command.creatureId, command.direction);
@@ -4157,7 +4176,7 @@ function blindBump(state: GameState, events: GameEvent[], p: PlayerState, direct
return { ok: true, state, events }; return { ok: true, state, events };
} }
function doMove(prev: GameState, direction: Side, over = false, exit?: Side): CommandResult { function doMove(prev: GameState, direction: Side, over = false, exit?: Side, shove?: Side): CommandResult {
const blocked = requireActionsAvailable(prev); const blocked = requireActionsAvailable(prev);
if (blocked) return err(blocked); if (blocked) return err(blocked);
if (prev.turn.movementUsed >= prev.turn.movementAllowance) return err("no movement left"); if (prev.turn.movementUsed >= prev.turn.movementAllowance) return err("no movement left");
@@ -4171,7 +4190,7 @@ function doMove(prev: GameState, direction: Side, over = false, exit?: Side): Co
// BLIND (or a DUST CLOUD): "must roll direction on D4 if attempting to // BLIND (or a DUST CLOUD): "must roll direction on D4 if attempting to
// move ... bumping into a wall counts as one space of movement." // move ... bumping into a wall counts as one space of movement."
if (isBlinded(state, p)) { if (isBlinded(state, p) && shove === undefined) {
direction = SIDES[rollD4(state, events, p.id, "a blind stagger picks the direction") - 1]!; direction = SIDES[rollD4(state, events, p.id, "a blind stagger picks the direction") - 1]!;
} }
@@ -4301,24 +4320,59 @@ function doMove(prev: GameState, direction: Side, over = false, exit?: Side): Co
p.position = from; p.position = from;
return err("someone there is stuck fast and cannot be pushed"); return err("someone there is stuck fast and cannot be pushed");
} }
const shove = stepTarget(view, p.position, direction); const canShoveTo = (d: Side) => {
const shoveOk = const t = stepTarget(view, p.position, d);
shove.kind === "step" && return t.kind === "step" &&
state.squareContents[cellKey(shove.to)]?.kind !== "stone" && state.squareContents[cellKey(t.to)]?.kind !== "stone" &&
!state.players.some((o) => o.alive && cellKey(o.position) === cellKey(shove.to) && !state.players.some((o) => o.alive && cellKey(o.position) === cellKey(t.to) &&
sustainedOn(state, o.id, "big-man").length > 0); sustainedOn(state, o.id, "big-man").length > 0)
if (!shoveOk) { ? t.to : null;
};
// Rev 15 (FAQ): "If Big Man pushes someone to an intersection, then
// the other player gets to decide which direction to go." The
// pushed one leaves by any open side but the way the giant came;
// one open side is taken, several are theirs to choose — the stride
// hangs until they do. Older games shoved straight ahead only.
// A game dealt before rev 15 shoves straight ahead when it can, as it
// always did; the corner and the fork were refusals before, so every
// game may round a corner now.
const fork = (state.config.deckRev ?? 1) >= 15;
let landing: Cell | null = null;
const exits = SIDES.filter((d) => d !== opposite(direction) && canShoveTo(d) !== null);
if (exits.length === 0) {
p.position = from;
return err("no room to push them onward");
}
if (fork || !exits.includes(direction)) {
const chosen = shove ?? (exits.length === 1 ? exits[0]! : null);
if (chosen === null) {
// Hang the stride: nothing has changed but the question.
const pushee = pushPlayers[0];
if (pushee) {
const held = clone(prev);
held.pushPending = { giantId: p.id, pusheeId: pushee.id, direction, over, ...(exit ? { exit } : {}), exits };
return { ok: true, state: held, events: [{ type: "pushWindow", giant: p.id, pushee: pushee.id, exits }] };
}
landing = canShoveTo(exits.includes(direction) ? direction : exits[0]!);
} else {
if (!exits.includes(chosen)) { p.position = from; return err(`no way to push them ${chosen} — the corridor leads ${exits.join(" or ")}`); }
landing = canShoveTo(chosen);
}
} else {
landing = canShoveTo(direction);
}
if (!landing) {
p.position = from; p.position = from;
return err("no room to push them onward"); return err("no room to push them onward");
} }
for (const o of pushPlayers) { for (const o of pushPlayers) {
const oFrom = o.position; const oFrom = o.position;
o.position = { ...shove.to }; o.position = { ...landing };
events.push({ type: "pushed", by: p.id, player: o.id, from: oFrom, to: o.position }); events.push({ type: "pushed", by: p.id, player: o.id, from: oFrom, to: o.position });
} }
for (const c of pushCreatures) { for (const c of pushCreatures) {
const cFrom = c.position; const cFrom = c.position;
c.position = { ...shove.to }; c.position = { ...landing };
events.push({ type: "pushed", by: p.id, creatureId: c.id, from: cFrom, to: c.position }); events.push({ type: "pushed", by: p.id, creatureId: c.id, from: cFrom, to: c.position });
} }
} }
@@ -4718,6 +4772,18 @@ function touchesEdge(position: Cell, cell: Cell, side: Side): boolean {
return cellKey(position) === cellKey(cell) || cellKey(position) === cellKey(neighbor(cell, side)); return cellKey(position) === cellKey(cell) || cellKey(position) === cellKey(neighbor(cell, side));
} }
/** The pushed wizard's answer at a fork: the giant's hanging stride
* resumes, shoving them the way they chose. */
function doPushChoice(prev: GameState, direction: Side): CommandResult {
const pp = prev.pushPending!;
if (!pp.exits.includes(direction)) return err(`the corridor leads ${pp.exits.join(" or ")}`);
const state = clone(prev);
state.pushPending = null;
const r = doMove(state, pp.direction, pp.over, pp.exit, direction);
if (!r.ok) return r;
return { ok: true, state: r.state, events: [{ type: "pushChosen", pushee: pp.pusheeId, direction }, ...r.events] };
}
/** The owner's answer to a hanging grab: spring the WARD, or let them go. */ /** The owner's answer to a hanging grab: spring the WARD, or let them go. */
function doWardChoice(prev: GameState, play: boolean): CommandResult { function doWardChoice(prev: GameState, play: boolean): CommandResult {
const state = clone(prev); const state = clone(prev);
+2
View File
@@ -91,6 +91,7 @@ export interface GameView {
chaosPending: { casterId: PlayerId; queue: PlayerId[] } | null; chaosPending: { casterId: PlayerId; queue: PlayerId[] } | null;
/** A grab hangs while the treasure's owner decides their Ward. */ /** A grab hangs while the treasure's owner decides their Ward. */
wardPending: { ownerId: PlayerId; takerId: PlayerId } | null; wardPending: { ownerId: PlayerId; takerId: PlayerId } | null;
pushPending: GameState["pushPending"];
slowDeathPending: { playerId: PlayerId; points: number } | null; slowDeathPending: { playerId: PlayerId; points: number } | null;
/** YOUR armed ambushes. Other players' ambushes are invisible. */ /** YOUR armed ambushes. Other players' ambushes are invisible. */
yourAmbushes: AmbushState[]; yourAmbushes: AmbushState[];
@@ -176,6 +177,7 @@ export function viewFor(state: GameState, playerId: PlayerId): GameView {
? { casterId: state.chaosPending.casterId, queue: [...state.chaosPending.queue] } ? { casterId: state.chaosPending.casterId, queue: [...state.chaosPending.queue] }
: null, : null,
wardPending: state.wardPending ? { ...state.wardPending } : null, wardPending: state.wardPending ? { ...state.wardPending } : null,
pushPending: state.pushPending ? { ...state.pushPending, exits: [...state.pushPending.exits] } : null,
slowDeathPending: state.slowDeathPending ? { ...state.slowDeathPending } : null, slowDeathPending: state.slowDeathPending ? { ...state.slowDeathPending } : null,
yourAmbushes: state.ambushes yourAmbushes: state.ambushes
.filter((a) => a.ownerId === playerId) .filter((a) => a.ownerId === playerId)
+1
View File
@@ -15,6 +15,7 @@ import { pushSustained } from "./helpers";
function actingSeat(state: GameState): PlayerId { function actingSeat(state: GameState): PlayerId {
return ( return (
state.wardPending?.ownerId ?? state.wardPending?.ownerId ??
state.pushPending?.pusheeId ??
state.slowDeathPending?.playerId ?? state.slowDeathPending?.playerId ??
state.stack?.waitingOn ?? state.stack?.waitingOn ??
state.pendingDiscard ?? state.pendingDiscard ??
+101 -12
View File
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
type CreatureState, applyCommand, activePlayer, boardView, creatureAt, type GameState, type PlayerId, createGame } from "../src/game"; type CreatureState, applyCommand, activePlayer, boardView, creatureAt, type GameState, type PlayerId, createGame } from "../src/game";
import { cellKey, edgeKey, SIDES, stepTarget, type Cell, type Side } from "../src/board"; import { cellKey, edgeKey, neighbor, opposite, SIDES, stepTarget, type Cell, type Side } from "../src/board";
import type { CardInstance } from "../src/cards"; import type { CardInstance } from "../src/cards";
import { newExpansionGame as newGame, must, drain, giveCard, toRound2, emptyNeighborCell, pushSustained } from "./helpers"; import { newExpansionGame as newGame, must, drain, giveCard, toRound2, emptyNeighborCell, pushSustained } from "./helpers";
@@ -392,23 +392,112 @@ describe("big man", () => {
expect(cellKey(state.players.find((p) => p.id === rig.giant.id)!.position)).toBe(cellKey(run.mid)); expect(cellKey(state.players.find((p) => p.id === rig.giant.id)!.position)).toBe(cellKey(run.mid));
}); });
/** From a giant's square: a step into a square whose exits (all sides but
* the way back) number exactly `want` a corner (1, not straight) or a
* fork (2+). Returns the giant's start, the pushee's square, and the exits. */
function pushSite(state: GameState, want: "corner" | "fork") {
const view = boardView(state);
for (const key of Object.keys(view.cells)) {
const [x, y] = key.split(",").map(Number) as [number, number];
if (view.homes.some((h) => cellKey(h) === key)) continue;
for (const side of SIDES) {
const t1 = stepTarget(view, { x, y }, side);
if (t1.kind !== "step") continue;
const exits = SIDES.filter((d) => d !== opposite(side) && stepTarget(view, t1.to, d).kind === "step");
if (want === "corner" && exits.length === 1 && exits[0] !== side) return { at: { x, y }, side, mid: t1.to, exits };
if (want === "fork" && exits.length >= 2) return { at: { x, y }, side, mid: t1.to, exits };
}
}
throw new Error(`no ${want} on this board`);
}
it("pushes a wizard around a corner when that is the only way (rev 15)", () => {
const rig = bigRig();
let state = rig.state;
const site = pushSite(state, "corner");
const giant = state.players.find((p) => p.id === rig.giant.id)!;
const victim = state.players.find((p) => p.id !== rig.giant.id)!;
giant.position = { ...site.at };
victim.position = { ...site.mid };
state = must(state, rig.giant.id, { type: "move", direction: site.side });
const after = state.players.find((p) => p.id !== rig.giant.id)!;
expect(cellKey(after.position)).toBe(cellKey(neighbor(site.mid, site.exits[0]!)));
expect(cellKey(state.players.find((p) => p.id === rig.giant.id)!.position)).toBe(cellKey(site.mid));
});
it("at a fork the pushed wizard chooses, and the stride hangs until they do", () => {
const rig = bigRig();
let state = rig.state;
const site = pushSite(state, "fork");
const giant = state.players.find((p) => p.id === rig.giant.id)!;
const victim = state.players.find((p) => p.id !== rig.giant.id)!;
giant.position = { ...site.at };
victim.position = { ...site.mid };
const used = state.turn.movementUsed;
const r = applyCommand(state, rig.giant.id, { type: "move", direction: site.side });
if (!r.ok) throw new Error(r.error);
state = r.state;
expect(state.pushPending?.pusheeId).toBe(victim.id);
expect(state.turn.movementUsed).toBe(used);
expect(cellKey(state.players.find((p) => p.id === rig.giant.id)!.position)).toBe(cellKey(site.at));
// Only the pushed wizard may answer, and only with pushChoice, and only an open way.
expect(applyCommand(state, rig.giant.id, { type: "move", direction: site.side }).ok).toBe(false);
expect(applyCommand(state, victim.id, { type: "pass" }).ok).toBe(false);
expect(applyCommand(state, victim.id, { type: "pushChoice", direction: opposite(site.side) }).ok).toBe(false);
const pick = site.exits[site.exits.length - 1]!;
state = must(state, victim.id, { type: "pushChoice", direction: pick });
expect(state.pushPending).toBeNull();
expect(cellKey(state.players.find((p) => p.id !== rig.giant.id)!.position)).toBe(cellKey(neighbor(site.mid, pick)));
expect(cellKey(state.players.find((p) => p.id === rig.giant.id)!.position)).toBe(cellKey(site.mid));
expect(state.turn.movementUsed).toBe(used + 1);
});
it("an older game shoves straight ahead when it can, and now rounds a corner when it cannot", () => {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 14 });
state = toRound2(state);
const giant = activePlayer(state);
state.sustained.push({ id: "fx-big", cardId: "big-man", casterId: giant.id, targetId: giant.id, remainingTurns: 9, data: {} });
const victim = state.players.find((p) => p.id !== giant.id)!;
// A fork where straight ahead is open: no window, straight it is.
const fork = pushSite(state, "fork");
if (fork.exits.includes(fork.side)) {
giant.position = { ...fork.at };
victim.position = { ...fork.mid };
const r = applyCommand(state, giant.id, { type: "move", direction: fork.side });
if (!r.ok) throw new Error(r.error);
expect(r.state.pushPending).toBeNull();
expect(cellKey(r.state.players.find((p) => p.id !== giant.id)!.position)).toBe(cellKey(neighbor(fork.mid, fork.side)));
}
// A corner: refused before, rounded now.
const corner = pushSite(state, "corner");
giant.position = { ...corner.at };
victim.position = { ...corner.mid };
const r = applyCommand(state, giant.id, { type: "move", direction: corner.side });
if (!r.ok) throw new Error(r.error);
expect(cellKey(r.state.players.find((p) => p.id !== giant.id)!.position)).toBe(cellKey(neighbor(corner.mid, corner.exits[0]!)));
});
it("no room to shove means no way forward", () => { it("no room to shove means no way forward", () => {
const rig = bigRig(); const rig = bigRig();
const state = rig.state; const state = rig.state;
const victim = state.players.find((p) => p.id !== rig.giant.id)!; const victim = state.players.find((p) => p.id !== rig.giant.id)!;
const view = boardView(state); const view = boardView(state);
// Find a step whose far side is walled: victim there cannot be pushed. // A dead-end pocket beside some square: a victim there cannot be pushed anywhere.
for (const side of SIDES) { const giant = state.players.find((p) => p.id === rig.giant.id)!;
const t1 = stepTarget(view, rig.giant.position, side); for (const key of Object.keys(view.cells)) {
if (t1.kind !== "step") continue; const [x, y] = key.split(",").map(Number) as [number, number];
const t2 = stepTarget(view, t1.to, side); for (const side of SIDES) {
if (t2.kind === "step") continue; const t1 = stepTarget(view, { x, y }, side);
victim.position = { ...t1.to }; if (t1.kind !== "step") continue;
const r = applyCommand(state, rig.giant.id, { type: "move", direction: side }); if (SIDES.some((d) => d !== opposite(side) && stepTarget(view, t1.to, d).kind === "step")) continue;
expect(r.ok).toBe(false); giant.position = { x, y };
return; victim.position = { ...t1.to };
const r = applyCommand(state, rig.giant.id, { type: "move", direction: side });
expect(r.ok).toBe(false);
return;
}
} }
throw new Error("setup: seed offered no walled pocket beside the home"); throw new Error("setup: no dead-end pocket on this board");
}); });
it("a shrunk wizard slips between the giant's boots", () => { it("a shrunk wizard slips between the giant's boots", () => {
+3 -1
View File
@@ -395,6 +395,7 @@ function actingSeat(room: Room): PlayerId | null {
if (!s || s.phase !== "playing") return null; if (!s || s.phase !== "playing") return null;
return ( return (
s.wardPending?.ownerId ?? s.wardPending?.ownerId ??
s.pushPending?.pusheeId ??
s.slowDeathPending?.playerId ?? s.slowDeathPending?.playerId ??
s.stack?.waitingOn ?? s.stack?.waitingOn ??
s.pendingDiscard ?? s.pendingDiscard ??
@@ -466,11 +467,12 @@ function turnFacts(s: GameState | null): {
waitKind: "counteract" | "discard" | "interrupt" | null; waitKind: "counteract" | "discard" | "interrupt" | null;
} { } {
const active = s && s.phase === "playing" ? s.players[s.turn.activeIndex]!.id : null; const active = s && s.phase === "playing" ? s.players[s.turn.activeIndex]!.id : null;
const waitingOn = s?.wardPending?.ownerId ?? s?.slowDeathPending?.playerId ?? const waitingOn = s?.wardPending?.ownerId ?? s?.pushPending?.pusheeId ?? s?.slowDeathPending?.playerId ??
s?.stack?.waitingOn ?? s?.pendingDiscard ?? s?.chaosPending?.queue[0] ?? s?.stack?.waitingOn ?? s?.pendingDiscard ?? s?.chaosPending?.queue[0] ??
s?.outOfTurnWindow?.playerId ?? null; s?.outOfTurnWindow?.playerId ?? null;
const waitKind = s == null ? null const waitKind = s == null ? null
: s.wardPending != null ? "counteract" as const : s.wardPending != null ? "counteract" as const
: s.pushPending != null ? "counteract" as const
: s.slowDeathPending != null ? "counteract" as const : s.slowDeathPending != null ? "counteract" as const
: s.stack?.waitingOn != null ? "counteract" as const : s.stack?.waitingOn != null ? "counteract" as const
: s.pendingDiscard != null ? "discard" as const : s.pendingDiscard != null ? "discard" as const
+21
View File
@@ -1677,6 +1677,25 @@
{/if} {/if}
{/if} {/if}
{#if view?.pushPending && view.pushPending.pusheeId === view.you}
<div class="scrim attack-scrim" role="alertdialog" aria-label="you are pushed">
<div class="attack-notice">
<div class="attack-title">{view.pushPending.giantId} bears down on you!</div>
<div class="attack-power">
The giant fills the corridor and shoves you ahead of him. The way
forks here — which way do you go?
</div>
<div class="attack-actions">
{#each view.pushPending.exits as d (d)}
<button class="stamp primary" onclick={() => dispatch({ type: "pushChoice", direction: d })}>
{d === "N" ? "North" : d === "S" ? "South" : d === "E" ? "East" : "West"}
</button>
{/each}
</div>
</div>
</div>
{/if}
{#if view?.slowDeathPending && view.slowDeathPending.playerId === view.you} {#if view?.slowDeathPending && view.slowDeathPending.playerId === view.you}
{@const sdPoints = view.slowDeathPending.points} {@const sdPoints = view.slowDeathPending.points}
{@const sdAbsorb = view.yourHand.find((c) => c.cardId === "absorb")} {@const sdAbsorb = view.yourHand.find((c) => c.cardId === "absorb")}
@@ -2611,6 +2630,8 @@
</div> </div>
{:else if view.stack} {:else if view.stack}
<div class="slip">Waiting on {view.stack.waitingOn}</div> <div class="slip">Waiting on {view.stack.waitingOn}</div>
{:else if view.pushPending && view.pushPending.pusheeId !== view.you}
<div class="slip">Waiting on {view.pushPending.pusheeId} to choose which way they are pushed…</div>
{:else if view.outOfTurnWindow?.playerId === view.you} {:else if view.outOfTurnWindow?.playerId === view.you}
<div class="slip urgent">Your interruption — pick an attack card and a target, or <button class="stamp tiny" onclick={pass}>waste it</button></div> <div class="slip urgent">Your interruption — pick an attack card and a target, or <button class="stamp tiny" onclick={pass}>waste it</button></div>
{:else if isYourTurn} {:else if isYourTurn}
+2
View File
@@ -65,6 +65,8 @@ export function humanize(e: GameEvent): string | null {
case "warpOpened": return `The outer wall breaches clean through — a new warp opens across the maze!`; case "warpOpened": return `The outer wall breaches clean through — a new warp opens across the maze!`;
case "extraTurnGranted": return `${e.player} speeds up — extra turn banked.`; case "extraTurnGranted": return `${e.player} speeds up — extra turn banked.`;
case "wardWindow": return `The grab hangs in the air — ${e.owner} clutches something…`; case "wardWindow": return `The grab hangs in the air — ${e.owner} clutches something…`;
case "pushWindow": return `${e.giant} bears down on ${e.pushee} at a fork — which way will they go?`;
case "pushChosen": return `${e.pushee} breaks ${e.direction === "N" ? "north" : e.direction === "S" ? "south" : e.direction === "E" ? "east" : "west"}.`;
case "treasureTorn": return e.toFloor case "treasureTorn": return e.toFloor
? `${e.attacker} TEARS the treasure from ${e.defender}'s arms — it tumbles to the floor!` ? `${e.attacker} TEARS the treasure from ${e.defender}'s arms — it tumbles to the floor!`
: `${e.attacker} TEARS the treasure from ${e.defender}'s arms!`; : `${e.attacker} TEARS the treasure from ${e.defender}'s arms!`;