diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index 0cb8e01..005f6a9 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -247,7 +247,7 @@ export interface CastParams { } /** The revision new games are dealt under; GameConfig.deckRev pins it per game. */ -export const CURRENT_RULES_REV = 21; +export const CURRENT_RULES_REV = 22; /** 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 @@ -273,6 +273,7 @@ export const RULES_REVISIONS: { rev: number; note: string }[] = [ { rev: 19, note: "DUST CLOUD blinds whoever stands in it: no LOS spell may be cast from inside a cloud, nor at anyone standing in one, and VISIONSTONE does not see through it. Spells cast on oneself still work. Before, the cloud blocked only sight lines passing through it." }, { rev: 20, note: "A waterwall's wave names its victims before it pushes any of them. Before, a wave walking the way it pushed could catch a wizard it had just shoved and shove them again with the force left — one square into a wall cost two points instead of one." }, { rev: 21, note: "Two cards keep their whole promise. LIFESAVER's holder is not eliminated for losing both treasures. FORCE FIELD, after stopping the spell, stands until the end of the opponent's turn: they may not enter its caster's square, nor cast on or past them — on every side, where the card says one." }, + { rev: 22, note: "TELEPORT ignores the maze's outer edge as it ignores any wall: a teleporter leaving the maze at any square's edge re-enters at the opposite edge on the same line, one space on. Older games crossed the edge only at the lettered openings." }, ]; export interface GameConfig { @@ -1460,7 +1461,7 @@ const CARD_EFFECTS: Record const view = boardView(state); if (!view.cells[cellKey(to)]) return "destination is off the board"; if (state.squareContents[cellKey(to)]?.kind === "stone") return "that square is solid stone"; - if (wallIgnoringDistance(view, caster.position, to) > 4) { + if (wallIgnoringDistance(view, caster.position, to, (state.config.deckRev ?? 1) >= 22) > 4) { return "teleport reaches at most four spaces"; } // A teleport is a willing move: FEAR's bubble refuses it. @@ -3770,8 +3771,25 @@ export function walkingDistance(state: GameState, from: Cell, to: Cell, limit = return seen.get(cellKey(to)) ?? Infinity; } -/** BFS steps between cells ignoring walls (teleport distance). */ -export function wallIgnoringDistance(board: AssembledBoard, from: Cell, to: Cell): number { +/** Where a line leaving the maze at `cur` through `side` comes back in: + * the first square of the maze scanning from the opposite edge along the + * same row or column — the maze's edges as one continuous field. */ +export function edgeReentry(board: AssembledBoard, cur: Cell, side: Side): Cell | null { + const cells = Object.keys(board.cells).map((k) => k.split(",").map(Number) as [number, number]); + const line = side === "N" || side === "S" + ? cells.filter(([x]) => x === cur.x).map(([, y]) => y) + : cells.filter(([, y]) => y === cur.y).map(([x]) => x); + if (line.length === 0) return null; + const far = side === "N" || side === "W" ? Math.max(...line) : Math.min(...line); + const cell = side === "N" || side === "S" ? { x: cur.x, y: far } : { x: far, y: cur.y }; + return cellKey(cell) === cellKey(cur) ? null : cell; +} + +/** BFS steps between cells ignoring walls (teleport distance). With + * `wrapEdges` (rev 22) the outer edge is no more to a teleporter than any + * wall: a line leaving the maze re-enters at the opposite edge, one space + * on. Without it, only the lettered openings carry a teleporter across. */ +export function wallIgnoringDistance(board: AssembledBoard, from: Cell, to: Cell, wrapEdges = false): number { if (cellKey(from) === cellKey(to)) return 0; const seen = new Map([[cellKey(from), 0]]); const queue: Cell[] = [from]; @@ -3787,8 +3805,12 @@ export function wallIgnoringDistance(board: AssembledBoard, from: Cell, to: Cell const w = board.warps.find( (w) => cellKey(w.from.cell) === cellKey(cur) && w.from.side === side, ); - if (!w) continue; - n = w.to.cell; + if (w) n = w.to.cell; + else if (wrapEdges) { + const back = edgeReentry(board, cur, side); + if (!back) continue; + n = back; + } else continue; } if (seen.has(cellKey(n))) continue; seen.set(cellKey(n), d + 1); @@ -6036,7 +6058,7 @@ function doCounteract( const view = boardView(state); if (!view.cells[cellKey(to)]) return err("destination is off the board"); if (state.squareContents[cellKey(to)]?.kind === "stone") return err("that square is solid stone"); - if (wallIgnoringDistance(view, player.position, to) > 4) { + if (wallIgnoringDistance(view, player.position, to, (state.config.deckRev ?? 1) >= 22) > 4) { return err("teleport reaches at most four spaces"); } takeFromHand(player, instanceId); diff --git a/packages/engine/src/view.ts b/packages/engine/src/view.ts index 91b4c14..367c34f 100644 --- a/packages/engine/src/view.ts +++ b/packages/engine/src/view.ts @@ -6,6 +6,7 @@ import { neighbor, SIDES, edgeKey, sightBetween, bentSightThroughGap, traceSight import { cardDef, type CardInstance } from "./cards"; import { boardView, + edgeReentry, LOS_BLOCKING_CONTENT, type AmbushState, type CastStack, @@ -530,8 +531,13 @@ export function eligibleCellsFor(view: GameView, cardId: string, bentCorner = fa let n = neighbor(cur, side); if (!view.board.cells[key(n.x, n.y)]) { const w = view.board.warps.find((w) => w.from.cell.x === cur.x && w.from.cell.y === cur.y && w.from.side === side); - if (!w) continue; - n = w.to.cell; + if (w) n = w.to.cell; + else if (view.deckRev >= 22) { + // Rev 22: the outer edge is no more than a wall to a teleporter. + const back = edgeReentry(view.board, cur, side); + if (!back) continue; + n = back; + } else continue; } const nk = key(n.x, n.y); if (dist.has(nk)) continue; diff --git a/packages/engine/test/expansion-terrain.test.ts b/packages/engine/test/expansion-terrain.test.ts index 033947a..84c1b54 100644 --- a/packages/engine/test/expansion-terrain.test.ts +++ b/packages/engine/test/expansion-terrain.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { applyCommand, activePlayer, boardView, createGame, gameLos, type GameState } from "../src/game"; +import { applyCommand, activePlayer, boardView, createGame, gameLos, wallIgnoringDistance, type GameState } from "../src/game"; import { cellKey, edgeKey, neighbor, opposite, SIDES, stepTarget, type Cell, type Side } from "../src/board"; import type { CardInstance } from "../src/cards"; import { eligibleCellsFor, sightedCellsFor, viewFor } from "../src/view"; @@ -786,3 +786,38 @@ describe("teleport's reach wraps through the board's openings", () => { expect(r.ok).toBe(true); }); }); + +describe("teleport across the maze's outer edge (rev 22)", () => { + function atTheTopEdge(deckRev?: number) { + const config = { playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] as ("basic" | "expansion1")[], ...(deckRev ? { deckRev } : {}) }; + const state = toRound2(createGame(config).state); + const me = activePlayer(state); + const view = boardView(state); + // A square on the top row whose north side is plain edge, not a lettered mouth. + const top = Object.keys(view.cells).map((k) => k.split(",").map(Number) as [number, number]) + .filter(([x, y]) => y === 0 && !view.warps.some((w) => w.from.cell.x === x && w.from.cell.y === y && w.from.side === "N")) + .find(([x]) => !state.squareContents[`${x},0`])!; + me.position = { x: top[0], y: 0 }; + const column = Object.keys(view.cells).map((k) => k.split(",").map(Number) as [number, number]).filter(([x]) => x === top[0]).map(([, y]) => y); + const bottom = { x: top[0], y: Math.max(...column) }; + giveCard(state, me.id, "teleport"); + return { state, me, bottom }; + } + + it("re-enters at the bottom of the same column, one space on", () => { + const { state, me, bottom } = atTheTopEdge(); + expect(wallIgnoringDistance(boardView(state), me.position, bottom, true)).toBe(1); + expect(eligibleCellsFor(viewFor(state, me.id), "teleport")!.has(cellKey(bottom))).toBe(true); + const r = applyCommand(state, me.id, { type: "cast", instanceId: "teleport#T", target: { kind: "cell", cell: bottom } }); + expect(r.ok).toBe(true); + if (r.ok) expect(r.state.players.find((p) => p.id === me.id)!.position).toEqual(bottom); + }); + + it("older games cross only at the lettered openings", () => { + const { state, me, bottom } = atTheTopEdge(21); + expect(wallIgnoringDistance(boardView(state), me.position, bottom)).toBeGreaterThan(4); + expect(eligibleCellsFor(viewFor(state, me.id), "teleport")!.has(cellKey(bottom))).toBe(false); + const r = applyCommand(state, me.id, { type: "cast", instanceId: "teleport#T", target: { kind: "cell", cell: bottom } }); + expect(r.ok).toBe(false); + }); +}); diff --git a/packages/web/src/reference.ts b/packages/web/src/reference.ts index 33f03f5..06158e8 100644 --- a/packages/web/src/reference.ts +++ b/packages/web/src/reference.ts @@ -255,6 +255,10 @@ export const HOUSE_RULINGS: HouseRuling[] = [ id: "mad-dash", title: "Mad Dash", cards: ["mad-dash", "power-run"], body: ["MAD DASH doubles \"NUMBER cards and other add-ons\" too. A number riding the cast fuels it, and numbers or POWER RUN points played under the dash are doubled as well."], }, + { + id: "teleport", title: "Teleporting across the maze's edge", cards: ["teleport"], + body: ["TELEPORT ignores the maze's outer edge as it ignores any wall. A teleporter leaving the maze at any square's edge re-enters at the opposite edge on the same line, one space on — the lettered openings are not needed. Four spaces straight up from two squares below the top edge lands two squares up from the bottom."], + }, { id: "pits", title: "Crossing a pit", cards: ["create-pit"], body: [