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) {