Credibility pass: comments say what the code can't, and the seams are gone
Engine: pit-rim exits live in one helper (pitRimExits) shared by the resolver and the automaton; chargeReach names the six-stride cap; dust sight is inDust + dustAtEnd; PushPending is exported once; the force-field counter builds its events in one list; drawUnderSlowDeath says what it draws under. Test groups are named for the rule they pin, not the revision that introduced it. Client: one smoothstep; one edgeScar per battle-scarred edge; one board-zone rule (which also seats the caption strip on phones); the compass names live in SIDE_NAMES; net.flash() is the one toast; MediaQuery replaces two hand-rolled matchMedia listeners; sprites carry their sizes as plain numbers and their CSS in one rule each; canvas helpers are formatted like the rest of the tree. Comments that told how a cel or a fallback used to look now say what it draws. The actions-over notice no longer blames a pickup when slime ended the turn. Server and ops: dataRoot() is the one data directory; headlineOf builds its deeds without a cast; the rollup and skills read the same way the code behaves. 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
85690f0e23
commit
6e49fc9020
@@ -7,7 +7,7 @@
|
||||
// BERSERKER for blood, the WORRIER for the shadows between the two.
|
||||
|
||||
import { cardDef, type CardInstance } from "./cards";
|
||||
import { cellKey, edgeKey, neighbor, opposite, stepTarget, SIDES, type Cell, type Side } from "./board";
|
||||
import { cellKey, edgeKey, neighbor, opposite, stepTarget, SIDES, type Cell, type Side, pitRimExits } from "./board";
|
||||
import { bentSightFor, sightedCellsFor, type GameView } from "./view";
|
||||
import { wallIgnoringDistance } from "./game";
|
||||
import type { AmbushTrigger, Command, PlayerId } from "./game";
|
||||
@@ -584,31 +584,19 @@ function wallBlastTarget(
|
||||
return { cell: best.cell, side: best.side };
|
||||
}
|
||||
|
||||
/** The sides a walker may leave a pit by, having entered it heading
|
||||
* `dir`: neighbouring squares first, a warp mouth on the rim only when
|
||||
* no square offers — the one reading legal under every deckRev. */
|
||||
function pitExits(view: GameView, pit: Cell, dir: Side): Side[] {
|
||||
const { floor, mouths } = pitRimExits(view.board, pit, dir, (c) => view.squareContents[cellKey(c)]?.kind === "stone");
|
||||
return floor.length > 0 ? floor : mouths;
|
||||
}
|
||||
|
||||
/**
|
||||
* BFS over walkable steps toward the nearest goal. Doors count as passable
|
||||
* when the clockwork can unlock them; the first such door is reported so the
|
||||
* key gets used before the boot.
|
||||
*/
|
||||
/** Can a wizard entering the pit at `pit` heading `dir` get off its rim
|
||||
* — the maze's own test: some side other than the way back with a cell
|
||||
* there, no wall between, and no stone on it. */
|
||||
/** The sides a walker may leave a pit by, having entered it heading
|
||||
* `dir`: neighbouring squares first; a warp mouth on the rim only when
|
||||
* no square offers, the reading every revision accepts. */
|
||||
function pitExits(view: GameView, pit: Cell, dir: Side): Side[] {
|
||||
const footing = (d: Side) => {
|
||||
if (d === opposite(dir)) return null;
|
||||
const t = stepTarget(view.board, pit, d);
|
||||
if (t.kind === "blocked" || view.squareContents[cellKey(t.to)]?.kind === "stone") return null;
|
||||
return t.kind;
|
||||
};
|
||||
const floor = SIDES.filter((d) => footing(d) === "step");
|
||||
return floor.length > 0 ? floor : SIDES.filter((d) => footing(d) === "warp");
|
||||
}
|
||||
|
||||
function pitLandable(view: GameView, pit: Cell, dir: Side): boolean {
|
||||
return pitExits(view, pit, dir).length > 0;
|
||||
}
|
||||
|
||||
export function pathToward(
|
||||
view: GameView,
|
||||
@@ -634,12 +622,12 @@ export function pathToward(
|
||||
const { to, viaDoor } = step;
|
||||
const k = cellKey(to);
|
||||
if (seen.has(k)) continue;
|
||||
if (view.squareContents[k]?.kind === "stone") continue;
|
||||
const hazard = view.squareContents[k]?.kind;
|
||||
if (hazard === "stone") continue;
|
||||
// A pit is entered by leaping to the square beyond it in the same
|
||||
// direction; with nothing to land on, the maze bounces the leaper
|
||||
// back and charges the stride. Goal or waypoint, that is no road.
|
||||
if (hazard === "pit" && !pitLandable(view, to, dir)) continue;
|
||||
if (hazard === "pit" && pitExits(view, to, dir).length === 0) continue;
|
||||
// Out of a pit only by one of its exits.
|
||||
if (view.squareContents[cellKey(c)]?.kind === "pit") {
|
||||
const entry = cameBy.get(cellKey(c))?.dir;
|
||||
@@ -766,7 +754,7 @@ function overLimit(view: GameView): number {
|
||||
* clogged hand never refreshes; only cards the brain has no play for are
|
||||
* shed (good counters and attacks are hoarded, as a human would). */
|
||||
function endTurnDrawing(view: GameView, tier: TierTraits, style?: AutomatonStyle): Command {
|
||||
const draw = drawUnderCurses(view, tier.draw);
|
||||
const draw = drawUnderSlowDeath(view, tier.draw);
|
||||
const deficit = draw - (handLimitOf(view) - view.yourHand.length);
|
||||
if (tier.sheds && deficit > 0) {
|
||||
const shed = [...view.yourHand]
|
||||
@@ -782,7 +770,7 @@ function endTurnDrawing(view: GameView, tier: TierTraits, style?: AutomatonStyle
|
||||
/** SLOW DEATH bleeds a point per card drawn: the clockwork draws only what
|
||||
* it can afford, keeping four life in hand, and nothing at all when it
|
||||
* cannot — a bare hand beats a bare grave. */
|
||||
export function drawUnderCurses(view: GameView, draw: number): number {
|
||||
export function drawUnderSlowDeath(view: GameView, draw: number): number {
|
||||
const slow = view.sustained.some((e) => e.cardId === "slow-death" && e.targetId === view.you && !e.data?.reversed);
|
||||
if (!slow) return draw;
|
||||
return Math.max(0, Math.min(draw, me(view).life - 4));
|
||||
|
||||
@@ -253,6 +253,24 @@ export function stepTarget(
|
||||
return { kind: "blocked", by: "wall" };
|
||||
}
|
||||
|
||||
/** The sides a walker may leave a pit by, having entered it heading
|
||||
* `entry`: the neighbouring squares that offer footing, and the warp
|
||||
* mouths on the rim, the way back excluded. `filled` names the squares
|
||||
* nothing can land on. */
|
||||
export function pitRimExits(
|
||||
board: AssembledBoard, pit: Cell, entry: Side, filled: (c: Cell) => boolean,
|
||||
): { floor: Side[]; mouths: Side[] } {
|
||||
const floor: Side[] = [];
|
||||
const mouths: Side[] = [];
|
||||
for (const d of SIDES) {
|
||||
if (d === opposite(entry)) continue;
|
||||
const t = stepTarget(board, pit, d);
|
||||
if (t.kind === "blocked" || filled(t.to)) continue;
|
||||
(t.kind === "step" ? floor : mouths).push(d);
|
||||
}
|
||||
return { floor, mouths };
|
||||
}
|
||||
|
||||
/**
|
||||
* Line of sight from the center of `from` to the center of `to`, blocked by
|
||||
* wall/door/firewall edges the segment crosses and by any `blockedCells`
|
||||
|
||||
+38
-48
@@ -27,7 +27,7 @@ import {
|
||||
bentSightThroughGap,
|
||||
neighbor,
|
||||
stepTarget,
|
||||
opposite,
|
||||
opposite, pitRimExits,
|
||||
} from "./board";
|
||||
import {
|
||||
buildDeck,
|
||||
@@ -80,6 +80,12 @@ export interface PlayerState {
|
||||
}
|
||||
|
||||
/** A duration spell in play. Expires at the START of the caster's turns. */
|
||||
/** 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). */
|
||||
export interface PushPending {
|
||||
giantId: PlayerId; pusheeId: PlayerId; direction: Side; over: boolean; exit?: Side; exits: Side[];
|
||||
}
|
||||
|
||||
export interface SustainedEffect {
|
||||
id: string;
|
||||
cardId: string;
|
||||
@@ -298,11 +304,7 @@ 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;
|
||||
pushPending: PushPending | 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). */
|
||||
@@ -2401,11 +2403,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
if ((state.config.deckRev ?? 1) < 10 || !target) return null;
|
||||
const caster = activePlayer(state);
|
||||
const legs = state.turn.movementAllowance - state.turn.movementUsed;
|
||||
// Rev 16: the charge is measured as far as the legs reach. Before it,
|
||||
// the search stopped at six steps and called anything past that
|
||||
// unreachable — a goat with eight legs refused a seven-step ram.
|
||||
const reach = (state.config.deckRev ?? 1) >= 16 ? legs : 6;
|
||||
const d = walkingDistance(state, caster.position, target.position, reach);
|
||||
const d = walkingDistance(state, caster.position, target.position, chargeReach(state, legs));
|
||||
if (d > legs) return "the goat charges on legs, not wings — you cannot walk that far this turn";
|
||||
return null;
|
||||
},
|
||||
@@ -2413,8 +2411,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
if (ctx.fullyStopped || !ctx.defender.alive || !ctx.attacker.alive) return;
|
||||
if ((ctx.state.config.deckRev ?? 1) >= 10) {
|
||||
const legs = ctx.state.turn.movementAllowance - ctx.state.turn.movementUsed;
|
||||
const reach = (ctx.state.config.deckRev ?? 1) >= 16 ? legs : 6;
|
||||
const d = walkingDistance(ctx.state, ctx.attacker.position, ctx.defender.position, reach);
|
||||
const d = walkingDistance(ctx.state, ctx.attacker.position, ctx.defender.position, chargeReach(ctx.state, legs));
|
||||
if (d === 0) return;
|
||||
if (d > legs) {
|
||||
// The target slipped beyond the charge (a counter-teleport, say):
|
||||
@@ -2903,10 +2900,8 @@ function waveForce(range: number, dist: number): number {
|
||||
|
||||
/** One line of a wave's sweep: the squares it reaches, nearest first, with
|
||||
* the force left at each. The victims are named before any is pushed
|
||||
* (rev 20). Before it the sweep, walking the way it pushed, could find a
|
||||
* victim it had just shoved and shove them again with the force left: a
|
||||
* wizard washed one square into a wall was crushed twice. Older games
|
||||
* replay that sweep. */
|
||||
* (rev 20); older games sweep square by square, so a wizard shoved along
|
||||
* the sweep's own line is caught and shoved again. */
|
||||
function sweepWave(
|
||||
state: GameState, events: GameEvent[], line: { cell: Cell; force: number }[], dir: Side, reason: string,
|
||||
): void {
|
||||
@@ -3724,9 +3719,16 @@ function attachSustained(
|
||||
});
|
||||
}
|
||||
|
||||
/** BFS steps between cells respecting walls (MENTAL FORCE's 3 moved spaces). */
|
||||
/** Steps along real corridors from one cell to another, searched out to
|
||||
* `limit` steps; beyond that the answer is Infinity. */
|
||||
/** How far a BUTT-HEAD charge is measured: as far as the goat's legs
|
||||
* reach (rev 16); older games search six steps and call the rest
|
||||
* unreachable. */
|
||||
function chargeReach(state: GameState, legs: number): number {
|
||||
return (state.config.deckRev ?? 1) >= 16 ? legs : 6;
|
||||
}
|
||||
|
||||
/** Steps along real corridors from one cell to another (MENTAL FORCE's
|
||||
* three moved spaces, a goat's charge), searched out to `limit` steps;
|
||||
* beyond that the answer is Infinity. */
|
||||
export function walkingDistance(state: GameState, from: Cell, to: Cell, limit = 6): number {
|
||||
if (cellKey(from) === cellKey(to)) return 0;
|
||||
const view = boardView(state);
|
||||
@@ -4254,6 +4256,7 @@ function doMove(prev: GameState, direction: Side, over = false, exit?: Side, sho
|
||||
|
||||
// BLIND (or a DUST CLOUD): "must roll direction on D4 if attempting to
|
||||
// move ... bumping into a wall counts as one space of movement."
|
||||
// A resumed push carries the direction its pushee chose: no stagger.
|
||||
if (isBlinded(state, p) && shove === undefined) {
|
||||
direction = SIDES[rollD4(state, events, p.id, "a blind stagger picks the direction") - 1]!;
|
||||
}
|
||||
@@ -4397,10 +4400,9 @@ function doMove(prev: GameState, direction: Side, over = false, exit?: Side, sho
|
||||
// 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.
|
||||
// hangs until they do. Older games (deckRev < 15) shove straight
|
||||
// ahead when that way is open; rounding a corner is allowed in every
|
||||
// game.
|
||||
const fork = (state.config.deckRev ?? 1) >= 15;
|
||||
let landing: Cell | null = null;
|
||||
const exits = SIDES.filter((d) => d !== opposite(direction) && canShoveTo(d) !== null);
|
||||
@@ -4461,19 +4463,12 @@ function doMove(prev: GameState, direction: Side, over = false, exit?: Side, sho
|
||||
let landingWarp = false;
|
||||
if (ledge) {
|
||||
const pitCell = p.position;
|
||||
const footing = (d: Side): { kind: "step" | "warp"; to: Cell } | null => {
|
||||
const t = stepTarget(view, pitCell, d);
|
||||
if (t.kind === "blocked" || state.squareContents[cellKey(t.to)]?.kind === "stone") return null;
|
||||
return t;
|
||||
};
|
||||
const ways = SIDES.filter((d) => d !== opposite(direction));
|
||||
const floor = ways.filter((d) => footing(d)?.kind === "step");
|
||||
const mouths = ways.filter((d) => footing(d)?.kind === "warp");
|
||||
const { floor, mouths } = pitRimExits(view, pitCell, direction,
|
||||
(c) => state.squareContents[cellKey(c)]?.kind === "stone");
|
||||
// A warp mouth on the rim is a way off it like any square, and may
|
||||
// always be named. Only what a bare step does is a matter of
|
||||
// revision: from rev 17 a square beside a mouth is a fork to name;
|
||||
// before it the step took the square, and takes it still, so the
|
||||
// recorded games replay as they were played.
|
||||
// before it a bare step takes the square.
|
||||
const exits = [...floor, ...mouths];
|
||||
const bare = (state.config.deckRev ?? 1) >= 17 || floor.length === 0 ? exits : floor;
|
||||
if (exits.length === 0) {
|
||||
@@ -4489,7 +4484,7 @@ function doMove(prev: GameState, direction: Side, over = false, exit?: Side, sho
|
||||
p.position = from;
|
||||
return err(`the rim leads more than one way — click the square to land on: ${exits.join(" or ")}`);
|
||||
}
|
||||
const foot = footing(landing)!;
|
||||
const foot = stepTarget(view, pitCell, landing) as { kind: "step" | "warp"; to: Cell };
|
||||
landingCell = foot.to;
|
||||
landingWarp = foot.kind === "warp";
|
||||
}
|
||||
@@ -6074,16 +6069,13 @@ function doCounteract(
|
||||
// opponent from entering the space you occupy or casting spells on or
|
||||
// past you. Lasts only until the end of the opponent's turn." The
|
||||
// field faces every side, not one: this table's simplification.
|
||||
const events: GameEvent[] = [{ type: "counteractionPlayed", player: playerId, card, cardId: card.cardId, against }];
|
||||
if (card.cardId === "force-field" && (state.config.deckRev ?? 1) >= 21) {
|
||||
const against = state.players.findIndex((q) => q.id === stack.attackerId);
|
||||
attachSustained(state, [], "force-field", playerId, playerId, PERMANENT_TURNS, { against });
|
||||
const facing = state.players.findIndex((q) => q.id === stack.attackerId);
|
||||
attachSustained(state, events, "force-field", playerId, playerId, PERMANENT_TURNS, { against: facing });
|
||||
}
|
||||
stack.waitingOn = stack.attackerId;
|
||||
return {
|
||||
ok: true,
|
||||
state,
|
||||
events: [{ type: "counteractionPlayed", player: playerId, card, cardId: card.cardId, against }],
|
||||
};
|
||||
return { ok: true, state, events };
|
||||
}
|
||||
|
||||
if (playerId === stack.attackerId) {
|
||||
@@ -6844,8 +6836,8 @@ function checkVictory(state: GameState, events: GameEvent[]): void {
|
||||
return owner !== null && owner !== p.id && ownerPlayer?.alive === true;
|
||||
});
|
||||
// Rev 21: LIFESAVER — "immune to the effects of losing both of your
|
||||
// treasures to other players' home bases." The card was cast and
|
||||
// remembered but never asked here; older games eliminated its holder.
|
||||
// treasures to other players' home bases." Older games eliminate its
|
||||
// holder.
|
||||
const saved = (state.config.deckRev ?? 1) >= 21 && sustainedOn(state, p.id, "lifesaver").length > 0;
|
||||
if (lost && !saved) {
|
||||
p.alive = false;
|
||||
@@ -7178,10 +7170,8 @@ function doEndTurn(prev: GameState, draw: number): CommandResult {
|
||||
state.openSafes = [];
|
||||
|
||||
// A FORCE FIELD raised against this wizard lasts only until their turn ends.
|
||||
{
|
||||
const idx = state.players.findIndex((q) => q.id === p.id);
|
||||
state.sustained = state.sustained.filter((f) => !(f.cardId === "force-field" && f.data.against === idx));
|
||||
}
|
||||
const seat = state.players.indexOf(p);
|
||||
state.sustained = state.sustained.filter((f) => !(f.cardId === "force-field" && f.data.against === seat));
|
||||
events.push({ type: "turnEnded", player: p.id });
|
||||
|
||||
if (p.extraTurns > 0) {
|
||||
|
||||
+11
-10
@@ -9,6 +9,7 @@ import {
|
||||
LOS_BLOCKING_CONTENT,
|
||||
type AmbushState,
|
||||
type CastStack,
|
||||
type PushPending,
|
||||
type CreatureState,
|
||||
type GameState,
|
||||
type PlayerId,
|
||||
@@ -93,7 +94,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"];
|
||||
pushPending: PushPending | null;
|
||||
slowDeathPending: { playerId: PlayerId; points: number } | null;
|
||||
/** YOUR armed ambushes. Other players' ambushes are invisible. */
|
||||
yourAmbushes: AmbushState[];
|
||||
@@ -229,7 +230,7 @@ export function sightedCellsFor(view: GameView, from?: Cell): Set<string> {
|
||||
const eye = from ?? me.position;
|
||||
const { board, blockers } = sightBasis(view);
|
||||
// In a dust cloud a wizard sees only their own square (rev 19).
|
||||
if (dustAtEnd(view, eye, eye, true)) {
|
||||
if (inDust(view, eye)) {
|
||||
out.add(`${eye.x},${eye.y}`);
|
||||
return out;
|
||||
}
|
||||
@@ -333,15 +334,15 @@ export function traceSightFor(view: GameView, from: Cell, to: Cell): SightTrace
|
||||
return traceSight(board, from, to, blockers);
|
||||
}
|
||||
|
||||
/** Rev 19: DUST CLOUD blinds its occupant and hides them from LOS spells;
|
||||
* a square sees itself still. `standing` asks only whether the viewer's
|
||||
* own square is dust. */
|
||||
function dustAtEnd(view: GameView, from: Cell, to: Cell, standing = false): boolean {
|
||||
if (view.deckRev < 19) return false;
|
||||
const dust = (c: Cell) => view.squareContents[`${c.x},${c.y}`]?.kind === "dust";
|
||||
if (standing) return dust(from);
|
||||
/** Rev 19: DUST CLOUD blinds its occupant. Older games see through it. */
|
||||
function inDust(view: GameView, cell: Cell): boolean {
|
||||
return view.deckRev >= 19 && view.squareContents[`${cell.x},${cell.y}`]?.kind === "dust";
|
||||
}
|
||||
|
||||
/** Dust at either end kills a sight line; a square sees itself still. */
|
||||
function dustAtEnd(view: GameView, from: Cell, to: Cell): boolean {
|
||||
if (from.x === to.x && from.y === to.y) return false;
|
||||
return dust(from) || dust(to);
|
||||
return inDust(view, from) || inDust(view, to);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from "../src/game";
|
||||
import { cellKey, edgeKey } from "../src/board";
|
||||
import { sightedCellsFor, viewFor } from "../src/view";
|
||||
import { automatonCommand, automatonFallback, drawUnderCurses, pathToward, type AutomatonStyle, type AutomatonTier } from "../src/automaton";
|
||||
import { automatonCommand, automatonFallback, drawUnderSlowDeath, pathToward, type AutomatonStyle, type AutomatonTier } from "../src/automaton";
|
||||
import { pushSustained } from "./helpers";
|
||||
|
||||
/** Whose input does the maze want right now? */
|
||||
@@ -1129,14 +1129,14 @@ describe("the clockwork under a curse, and before a pit", () => {
|
||||
const state = botsTurn();
|
||||
const bot = state.players.find((p) => p.id === "bot")!;
|
||||
const plain = viewFor(state, "bot");
|
||||
expect(drawUnderCurses(plain, 2)).toBe(2);
|
||||
expect(drawUnderSlowDeath(plain, 2)).toBe(2);
|
||||
pushSustained(state, { id: "sd", cardId: "slow-death", casterId: "human", targetId: "bot", remainingTurns: 1e9 });
|
||||
bot.life = 7;
|
||||
expect(drawUnderCurses(viewFor(state, "bot"), 2)).toBe(2);
|
||||
expect(drawUnderSlowDeath(viewFor(state, "bot"), 2)).toBe(2);
|
||||
bot.life = 5;
|
||||
expect(drawUnderCurses(viewFor(state, "bot"), 2)).toBe(1);
|
||||
expect(drawUnderSlowDeath(viewFor(state, "bot"), 2)).toBe(1);
|
||||
bot.life = 4;
|
||||
expect(drawUnderCurses(viewFor(state, "bot"), 2)).toBe(0);
|
||||
expect(drawUnderSlowDeath(viewFor(state, "bot"), 2)).toBe(0);
|
||||
});
|
||||
|
||||
it("raises no SHADOW while bleeding, nor with little blood to spare", () => {
|
||||
@@ -1152,7 +1152,7 @@ describe("the clockwork under a curse, and before a pit", () => {
|
||||
for (let guard = 0; guard < 30 && actingSeat(state) === "bot"; guard++) {
|
||||
const view = viewFor(state, "bot");
|
||||
const cmd = automatonCommand(view, "berserker", "archmage") ?? automatonFallback(view, "archmage");
|
||||
expect(JSON.stringify(cmd)).not.toContain("shadow#T");
|
||||
expect(cmd.type === "cast" && cmd.instanceId === "shadow#T").toBe(false);
|
||||
const r = applyCommand(state, "bot", cmd);
|
||||
if (!r.ok) break;
|
||||
state = r.state;
|
||||
|
||||
@@ -396,7 +396,7 @@ describe("big man", () => {
|
||||
/** 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") {
|
||||
function pushSite(state: GameState, want: "corner" | "fork" | "straight-fork") {
|
||||
const view = boardView(state);
|
||||
for (const key of Object.keys(view.cells)) {
|
||||
const [x, y] = key.split(",").map(Number) as [number, number];
|
||||
@@ -407,6 +407,7 @@ describe("big man", () => {
|
||||
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 };
|
||||
if (want === "straight-fork" && exits.length >= 2 && exits.includes(side)) return { at: { x, y }, side, mid: t1.to, exits };
|
||||
}
|
||||
}
|
||||
throw new Error(`no ${want} on this board`);
|
||||
@@ -453,15 +454,15 @@ describe("big man", () => {
|
||||
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", () => {
|
||||
it("a pre-rev-15 game shoves straight ahead when it can and 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)) {
|
||||
const fork = pushSite(state, "straight-fork");
|
||||
{
|
||||
giant.position = { ...fork.at };
|
||||
victim.position = { ...fork.mid };
|
||||
const r = applyCommand(state, giant.id, { type: "move", direction: fork.side });
|
||||
|
||||
@@ -813,7 +813,7 @@ describe("the goat charges on legs, not wings (rev 10)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("butt-head's charge reaches as far as its legs (K4T3)", () => {
|
||||
describe("butt-head's charge reaches as far as its legs (rev 16)", () => {
|
||||
/** Two empty cells seven or eight corridor steps apart on the seed-42 board. */
|
||||
function longRun(state: GameState): { from: Cell; to: Cell; steps: number } {
|
||||
const view = boardView(state);
|
||||
|
||||
@@ -561,7 +561,7 @@ describe("the boobytrap keeps its secret", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("a pit on the board's rim (M4Q7)", () => {
|
||||
describe("a pit on the board's rim (rev 17)", () => {
|
||||
/** A square on the outer rim with a warp mouth on one side, entered from
|
||||
* the square opposite the mouth; its other neighbouring squares listed. */
|
||||
function rimPitSite(state: GameState) {
|
||||
@@ -637,7 +637,7 @@ describe("a pit on the board's rim (M4Q7)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("a wave names its victims before it pushes (LK97)", () => {
|
||||
describe("a wave names its victims before it pushes (rev 20)", () => {
|
||||
/** A wall between two squares, a wizard on its near side with one open
|
||||
* square behind them and a wall beyond it, and a caster's square beside. */
|
||||
function site(state: GameState) {
|
||||
@@ -692,7 +692,6 @@ describe("spells cast at a slime wait in the gel", () => {
|
||||
expect(cast.state.slimeTraps[cellKey(spot.cell)]?.length).toBe(1);
|
||||
// Nobody is hurt yet; the victim walks in on their own turn.
|
||||
state = must(cast.state, caster.id, { type: "endTurn", draw: 2 });
|
||||
victim.position = { ...caster.position };
|
||||
const v = state.players.find((p) => p.id === victim.id)!;
|
||||
v.position = { ...caster.position };
|
||||
const walk = applyCommand(state, victim.id, { type: "move", direction: spot.side });
|
||||
|
||||
Reference in New Issue
Block a user