From 0d2b25ad74621ba3cf5ec8a3b58b101c6940b2c1 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Thu, 20 Aug 2026 11:15:19 -0400 Subject: [PATCH] Go Away routs 'as straight a line as possible' (rules rev 36) The card's line bends: a victim shoved against a wall moved zero squares and just lost the turn, because GO AWAY borrowed waterbolt's straight-line knockback. Its own rout now holds the heading while open, bends around walls, sidesteps when no ground can be gained, rolls the D4 only when options tie, and stops early only when truly cornered. Waterbolt's physical shove stays straight, as a shove should. Ledgers verified before deploy. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc --- packages/engine/src/game.ts | 68 ++++++++++++++++++- packages/engine/test/expansion-combat.test.ts | 34 +++++++++- packages/server/src/rooms.ts | 2 +- packages/web/src/local.svelte.ts | 2 +- 4 files changed, 102 insertions(+), 4 deletions(-) diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index fea4cf6..ccaa8af 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -934,7 +934,11 @@ const CARD_EFFECTS: Record onResolved: (ctx) => { if (ctx.fullyStopped || !ctx.defender.alive) return; const n = ctx.stack.numberValue ?? 1; - knockBack(ctx.state, ctx.events, ctx.attacker, ctx.defender, n); + if ((ctx.state.config.deckRev ?? 1) >= 36) { + goAwayRout(ctx.state, ctx.events, ctx.attacker, ctx.defender, n); + } else { + knockBack(ctx.state, ctx.events, ctx.attacker, ctx.defender, n); + } ctx.defender.lostTurns++; ctx.events.push({ type: "stunned", player: ctx.defender.id, turnsLost: 1 }); }, @@ -5669,6 +5673,68 @@ function resolveStack(state: GameState, events: GameEvent[]): void { } } +/** + * GO AWAY's rout (rules rev 36): "must move away a number of spaces equal + * to the NUMBER card played, in as straight a line as possible. Roll D4 + * for random direction, if necessary." The victim keeps moving away, + * holding their line while it is open, bending around walls when it is + * not, the die choosing among equal options. Cornered ends it early. + */ +function goAwayRout( + state: GameState, + events: GameEvent[], + attacker: PlayerState, + defender: PlayerState, + squares: number, +): void { + if (isLockedInPlace(state, defender.id)) return; + const from = defender.position; + const dist = (c: Cell) => + Math.abs(attacker.position.x - c.x) + Math.abs(attacker.position.y - c.y); + let heading: Side | null = null; + let moved = 0; + for (let i = 0; i < squares; i++) { + const view = boardView(state); + const here = dist(defender.position); + const options: { dir: Side; to: Cell; gain: number }[] = []; + for (const dir of SIDES) { + const step = stepTarget(view, defender.position, dir); + if (step.kind === "blocked") continue; + if (state.squareContents[cellKey(step.to)]?.kind === "stone") continue; + const gain = dist(step.to) - here; + if (gain > 0) options.push({ dir, to: step.to, gain }); + } + // No way to gain ground: sidestep once if possible, else cornered. + if (options.length === 0) { + for (const dir of SIDES) { + const step = stepTarget(view, defender.position, dir); + if (step.kind === "blocked") continue; + if (state.squareContents[cellKey(step.to)]?.kind === "stone") continue; + if (dist(step.to) === here) options.push({ dir, to: step.to, gain: 0 }); + } + if (options.length === 0) break; + } + // As straight a line as possible: hold the heading when it is open. + let pick: { dir: Side; to: Cell; gain: number } | undefined = + heading ? options.find((o) => o.dir === heading) : undefined; + if (!pick) { + if (options.length === 1) { + pick = options[0]!; + } else { + const [roll, rngNext] = rollDie(state.rng); + state.rng = rngNext; + pick = options[(roll - 1) % options.length]!; + } + } + heading = pick.dir; + defender.position = pick.to; + moved++; + } + if (moved > 0) { + events.push({ type: "knockedBack", player: defender.id, from, to: defender.position, squares: moved }); + } +} + function knockBack( state: GameState, events: GameEvent[], diff --git a/packages/engine/test/expansion-combat.test.ts b/packages/engine/test/expansion-combat.test.ts index a0734ee..08c83de 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, stepTarget } from "../src/board"; +import { cellKey, edgeKey, stepTarget } from "../src/board"; import type { CardInstance } from "../src/cards"; import { newExpansionGame as newGame, must, giveCard, toRound2, faceOff, castAt } from "./helpers"; @@ -492,3 +492,35 @@ describe("full reflection hands the swap meet choice to the reflector (rev 27)", expect(state.players.find((p) => p.id === attacker)!.hand.some((c) => c.cardId === "dagger")).toBe(true); }); }); + +describe("go away routs around walls (rules rev 36)", () => { + it("a victim against a wall bends the line instead of standing still", () => { + let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 36 }); + state = toRound2(state); + const { attacker, defender } = faceOff(state); + const a = state.players.find((p) => p.id === attacker)!; + const d = state.players.find((p) => p.id === defender)!; + // Attacker west of the victim, a wall hard against the victim's east: + // the straight line away is blocked from the first step. + a.position = { x: 1, y: 5 }; + d.position = { x: 2, y: 5 }; + state.edgeOverrides[edgeKey({ x: 1, y: 5 }, "E")] = "open"; + state.edgeOverrides[edgeKey({ x: 2, y: 5 }, "E")] = "wall"; + state.edgeOverrides[edgeKey({ x: 2, y: 5 }, "N")] = "open"; + state.edgeOverrides[edgeKey({ x: 2, y: 5 }, "S")] = "open"; + state.edgeOverrides[edgeKey({ x: 2, y: 4 }, "N")] = "open"; + state.edgeOverrides[edgeKey({ x: 2, y: 6 }, "S")] = "open"; + const ga = giveCard(state, attacker, "go-away"); + giveCard(state, attacker, "number-3", "N3", 1); + state = must(state, attacker, { + type: "cast", instanceId: ga.instanceId, numberInstanceIds: ["number-3#N3"], + target: { kind: "player", playerId: defender }, + }); + state = must(state, defender, { type: "pass" }); + const after = state.players.find((p) => p.id === defender)!; + const dist = Math.abs(after.position.x - 1) + Math.abs(after.position.y - 5); + // Three routed spaces: strictly farther than where they stood. + expect(dist).toBeGreaterThanOrEqual(3); + expect(after.lostTurns).toBe(1); + }); +}); diff --git a/packages/server/src/rooms.ts b/packages/server/src/rooms.ts index 4177419..be243a3 100644 --- a/packages/server/src/rooms.ts +++ b/packages/server/src/rooms.ts @@ -54,7 +54,7 @@ export interface Room { const rooms = new Map(); /** Rules revision new games are dealt under (stored games keep their own). */ -const RULES_REV = 35; +const RULES_REV = 36; const ROOM_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; diff --git a/packages/web/src/local.svelte.ts b/packages/web/src/local.svelte.ts index 73a779b..a03dd1a 100644 --- a/packages/web/src/local.svelte.ts +++ b/packages/web/src/local.svelte.ts @@ -136,7 +136,7 @@ class LocalGame { seed, sets: expansion ? ["basic", "expansion1"] : ["basic"], ...(colors ? { colors } : {}), - deckRev: 35, + deckRev: 36, }; const { state, events } = createGame(config); for (const e of events) {