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
+101 -12
View File
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import {
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 { 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));
});
/** 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", () => {
const rig = bigRig();
const state = rig.state;
const victim = state.players.find((p) => p.id !== rig.giant.id)!;
const view = boardView(state);
// Find a step whose far side is walled: victim there cannot be pushed.
for (const side of SIDES) {
const t1 = stepTarget(view, rig.giant.position, side);
if (t1.kind !== "step") continue;
const t2 = stepTarget(view, t1.to, side);
if (t2.kind === "step") continue;
victim.position = { ...t1.to };
const r = applyCommand(state, rig.giant.id, { type: "move", direction: side });
expect(r.ok).toBe(false);
return;
// A dead-end pocket beside some square: a victim there cannot be pushed anywhere.
const giant = state.players.find((p) => p.id === rig.giant.id)!;
for (const key of Object.keys(view.cells)) {
const [x, y] = key.split(",").map(Number) as [number, number];
for (const side of SIDES) {
const t1 = stepTarget(view, { x, y }, side);
if (t1.kind !== "step") continue;
if (SIDES.some((d) => d !== opposite(side) && stepTarget(view, t1.to, d).kind === "step")) continue;
giant.position = { x, y };
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", () => {