diff --git a/packages/engine/src/automaton.ts b/packages/engine/src/automaton.ts index 61d08dd..150be90 100644 --- a/packages/engine/src/automaton.ts +++ b/packages/engine/src/automaton.ts @@ -1337,6 +1337,13 @@ export function automatonCommand( // A Ward is free damage on a thief of OUR gold: always spring it. 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) { 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 { const you = view.you; 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.pendingDiscard === you) { return { type: "discard", instanceIds: worstCards(view, Math.max(1, overLimit(view))) }; diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index db1e01c..cb78dd9 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -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 = 14; +export const CURRENT_RULES_REV = 15; /** 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 @@ -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: 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: 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 { @@ -288,6 +289,11 @@ 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; + /** 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: * the draw's total is one blow — ABSORB soaks up to three of it, * BLUNT halves it rounding up (table ruling). */ @@ -708,6 +714,8 @@ export type GameEvent = | { type: "swapFizzled"; player: PlayerId } | { type: "wardSprung"; 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: "spellTrapped"; caster: PlayerId; cell: Cell; cardId: string } | { type: "slimeTrapSprung"; cell: Cell; cardId: string; victim: PlayerId } @@ -785,6 +793,8 @@ export type Command = | { type: "testIllusion"; cell: Cell; side: Side } | { type: "armWard" } | { 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: "warpStep" } | { type: "moveCreature"; creatureId: string; direction: Side } @@ -3807,6 +3817,7 @@ export function createGame(config: GameConfig): { state: GameState; events: Game wallDamage: {}, slimeTraps: {}, wardPending: null, + pushPending: null, slowDeathPending: null, chaosPending: null, doorStates: {}, @@ -3887,6 +3898,13 @@ function applyCommandInner(state: GameState, playerId: PlayerId, command: Comman 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. if (state.slowDeathPending) { 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 "armWard": return doArmWard(); 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 "warpStep": return doWarpStep(state); 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 }; } -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); if (blocked) return err(blocked); 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 // 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]!; } @@ -4301,24 +4320,59 @@ function doMove(prev: GameState, direction: Side, over = false, exit?: Side): Co 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) { + const canShoveTo = (d: Side) => { + const t = stepTarget(view, p.position, d); + return t.kind === "step" && + state.squareContents[cellKey(t.to)]?.kind !== "stone" && + !state.players.some((o) => o.alive && cellKey(o.position) === cellKey(t.to) && + sustainedOn(state, o.id, "big-man").length > 0) + ? 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; return err("no room to push them onward"); } for (const o of pushPlayers) { 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 }); } for (const c of pushCreatures) { 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 }); } } @@ -4718,6 +4772,18 @@ function touchesEdge(position: Cell, cell: Cell, side: Side): boolean { 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. */ function doWardChoice(prev: GameState, play: boolean): CommandResult { const state = clone(prev); diff --git a/packages/engine/src/view.ts b/packages/engine/src/view.ts index 51e66a2..05ff16d 100644 --- a/packages/engine/src/view.ts +++ b/packages/engine/src/view.ts @@ -91,6 +91,7 @@ export interface GameView { chaosPending: { casterId: PlayerId; queue: PlayerId[] } | null; /** A grab hangs while the treasure's owner decides their Ward. */ wardPending: { ownerId: PlayerId; takerId: PlayerId } | null; + pushPending: GameState["pushPending"]; slowDeathPending: { playerId: PlayerId; points: number } | null; /** YOUR armed ambushes. Other players' ambushes are invisible. */ yourAmbushes: AmbushState[]; @@ -176,6 +177,7 @@ export function viewFor(state: GameState, playerId: PlayerId): GameView { ? { casterId: state.chaosPending.casterId, queue: [...state.chaosPending.queue] } : null, wardPending: state.wardPending ? { ...state.wardPending } : null, + pushPending: state.pushPending ? { ...state.pushPending, exits: [...state.pushPending.exits] } : null, slowDeathPending: state.slowDeathPending ? { ...state.slowDeathPending } : null, yourAmbushes: state.ambushes .filter((a) => a.ownerId === playerId) diff --git a/packages/engine/test/automaton.test.ts b/packages/engine/test/automaton.test.ts index 45100f1..243800f 100644 --- a/packages/engine/test/automaton.test.ts +++ b/packages/engine/test/automaton.test.ts @@ -15,6 +15,7 @@ import { pushSustained } from "./helpers"; function actingSeat(state: GameState): PlayerId { return ( state.wardPending?.ownerId ?? + state.pushPending?.pusheeId ?? state.slowDeathPending?.playerId ?? state.stack?.waitingOn ?? state.pendingDiscard ?? diff --git a/packages/engine/test/creatures.test.ts b/packages/engine/test/creatures.test.ts index 4bd294f..fbd44c4 100644 --- a/packages/engine/test/creatures.test.ts +++ b/packages/engine/test/creatures.test.ts @@ -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", () => { diff --git a/packages/server/src/rooms.ts b/packages/server/src/rooms.ts index a1528ba..09942e0 100644 --- a/packages/server/src/rooms.ts +++ b/packages/server/src/rooms.ts @@ -395,6 +395,7 @@ function actingSeat(room: Room): PlayerId | null { if (!s || s.phase !== "playing") return null; return ( s.wardPending?.ownerId ?? + s.pushPending?.pusheeId ?? s.slowDeathPending?.playerId ?? s.stack?.waitingOn ?? s.pendingDiscard ?? @@ -466,11 +467,12 @@ function turnFacts(s: GameState | null): { waitKind: "counteract" | "discard" | "interrupt" | 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?.outOfTurnWindow?.playerId ?? null; const waitKind = s == null ? null : s.wardPending != null ? "counteract" as const + : s.pushPending != null ? "counteract" as const : s.slowDeathPending != null ? "counteract" as const : s.stack?.waitingOn != null ? "counteract" as const : s.pendingDiscard != null ? "discard" as const diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index 31a50e3..4a3bfd9 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -1677,6 +1677,25 @@ {/if} {/if} + {#if view?.pushPending && view.pushPending.pusheeId === view.you} +
+
+
{view.pushPending.giantId} bears down on you!
+
+ The giant fills the corridor and shoves you ahead of him. The way + forks here — which way do you go? +
+
+ {#each view.pushPending.exits as d (d)} + + {/each} +
+
+
+ {/if} + {#if view?.slowDeathPending && view.slowDeathPending.playerId === view.you} {@const sdPoints = view.slowDeathPending.points} {@const sdAbsorb = view.yourHand.find((c) => c.cardId === "absorb")} @@ -2611,6 +2630,8 @@ {:else if view.stack}
Waiting on {view.stack.waitingOn}…
+ {:else if view.pushPending && view.pushPending.pusheeId !== view.you} +
Waiting on {view.pushPending.pusheeId} to choose which way they are pushed…
{:else if view.outOfTurnWindow?.playerId === view.you}
Your interruption — pick an attack card and a target, or
{:else if isYourTurn} diff --git a/packages/web/src/net.svelte.ts b/packages/web/src/net.svelte.ts index b83fc39..1d66542 100644 --- a/packages/web/src/net.svelte.ts +++ b/packages/web/src/net.svelte.ts @@ -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 "extraTurnGranted": return `${e.player} speeds up — extra turn banked.`; 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 ? `${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!`;