diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index cb78dd9..cb9b60b 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 = 15; +export const CURRENT_RULES_REV = 16; /** 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 @@ -258,6 +258,7 @@ export const RULES_REVISIONS: { rev: number; note: string }[] = [ { 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." }, + { rev: 16, note: "BUTT-HEAD's charge is measured as far as the goat's legs reach. Older games searched only six steps of corridor and refused a longer ram as unreachable, even with the legs to make it." }, ]; export interface GameConfig { @@ -2361,7 +2362,11 @@ const CARD_EFFECTS: Record if ((state.config.deckRev ?? 1) < 10 || !target) return null; const caster = activePlayer(state); const legs = state.turn.movementAllowance - state.turn.movementUsed; - const d = walkingDistance(state, caster.position, target.position); + // 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); if (d > legs) return "the goat charges on legs, not wings — you cannot walk that far this turn"; return null; }, @@ -2369,7 +2374,8 @@ const CARD_EFFECTS: Record 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 d = walkingDistance(ctx.state, ctx.attacker.position, ctx.defender.position); + const reach = (ctx.state.config.deckRev ?? 1) >= 16 ? legs : 6; + const d = walkingDistance(ctx.state, ctx.attacker.position, ctx.defender.position, reach); if (d === 0) return; if (d > legs) { // The target slipped beyond the charge (a counter-teleport, say): @@ -3677,7 +3683,9 @@ function attachSustained( } /** BFS steps between cells respecting walls (MENTAL FORCE's 3 moved spaces). */ -function walkingDistance(state: GameState, from: Cell, to: Cell): number { +/** Steps along real corridors from one cell to another, 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); const seen = new Map([[cellKey(from), 0]]); @@ -3685,7 +3693,7 @@ function walkingDistance(state: GameState, from: Cell, to: Cell): number { while (queue.length > 0) { const cur = queue.shift()!; const d = seen.get(cellKey(cur))!; - if (d >= 6) break; + if (d >= limit) break; for (const side of SIDES) { const step = stepTarget(view, cur, side); if (step.kind === "blocked") continue; diff --git a/packages/engine/test/expansion-combat.test.ts b/packages/engine/test/expansion-combat.test.ts index 5a13fd1..0468730 100644 --- a/packages/engine/test/expansion-combat.test.ts +++ b/packages/engine/test/expansion-combat.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { applyCommand, activePlayer, boardView, createGame, gameLos, sustainedOn } from "../src/game"; -import { cellKey, edgeKey, stepTarget } from "../src/board"; +import { applyCommand, activePlayer, boardView, createGame, gameLos, sustainedOn, walkingDistance, type GameState } from "../src/game"; +import { cellKey, edgeKey, stepTarget, type Cell } from "../src/board"; import type { CardInstance } from "../src/cards"; import { newExpansionGame as newGame, must, drain, giveCard, toRound2, faceOff, castAt } from "./helpers"; @@ -812,3 +812,34 @@ describe("the goat charges on legs, not wings (rev 10)", () => { expect(rams).toBeGreaterThan(0); }); }); + +describe("butt-head's charge reaches as far as its legs (K4T3)", () => { + /** 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); + const cells = Object.keys(view.cells).map((k) => { const [x, y] = k.split(",").map(Number) as [number, number]; return { x, y }; }) + .filter((c) => !state.squareContents[cellKey(c)] && !view.homes.some((h) => cellKey(h) === cellKey(c))); + for (const from of cells) for (const to of cells) { + const d = walkingDistance(state, from, to, 12); + if (d === 7 || d === 8) return { from, to, steps: d }; + } + throw new Error("no seven-step corridor on this board"); + } + for (const [deckRev, ok] of [[16, true], [15, false]] as const) { + it(`a goat with eight legs and a target seven or eight corridor steps off: rev ${deckRev} ${ok ? "charges" : "refuses"}`, () => { + let { state } = createGame({ playerIds: ["goat", "mark"], seed: 42, sets: ["basic", "expansion1"], deckRev }); + state = toRound2(state); + const goat = activePlayer(state); + const mark = state.players.find((p) => p.id !== goat.id)!; + const run = longRun(state); + goat.position = { ...run.from }; + mark.position = { ...run.to }; + state.turn.movementAllowance = 8; + state.turn.movementUsed = 0; + const card = giveCard(state, goat.id, "butt-head"); + const r = applyCommand(state, goat.id, { type: "cast", instanceId: card.instanceId, target: { kind: "player", playerId: mark.id } }); + expect(r.ok).toBe(ok); + if (!r.ok) expect(r.error).toMatch(/legs, not wings/); + }); + } +});