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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc
This commit is contained in:
Eric Wagoner
2026-08-20 11:15:19 -04:00
co-authored by Claude Fable 5
parent 76dd019d5d
commit 0d2b25ad74
4 changed files with 102 additions and 4 deletions
+66
View File
@@ -934,7 +934,11 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
onResolved: (ctx) => {
if (ctx.fullyStopped || !ctx.defender.alive) return;
const n = ctx.stack.numberValue ?? 1;
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[],
+33 -1
View File
@@ -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);
});
});
+1 -1
View File
@@ -54,7 +54,7 @@ export interface Room {
const rooms = new Map<string, Room>();
/** 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";
+1 -1
View File
@@ -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) {