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:
co-authored by
Claude Fable 5.1
parent
c6913ea1bc
commit
e724267b0a
@@ -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))) };
|
||||
|
||||
+78
-12
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 ??
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1677,6 +1677,25 @@
|
||||
{/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}
|
||||
{@const sdPoints = view.slowDeathPending.points}
|
||||
{@const sdAbsorb = view.yourHand.find((c) => c.cardId === "absorb")}
|
||||
@@ -2611,6 +2630,8 @@
|
||||
</div>
|
||||
{:else if view.stack}
|
||||
<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}
|
||||
<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}
|
||||
|
||||
@@ -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!`;
|
||||
|
||||
Reference in New Issue
Block a user