From 803c51415e2cb08a556d5f78113559a2dbd32293 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Wed, 16 Sep 2026 23:56:13 -0400 Subject: [PATCH] Rev 22: TELEPORT crosses the maze's outer edge anywhere A teleporter ignores walls, and the outer edge is no more than a wall to it: a line leaving the maze at any square's edge re-enters at the opposite edge on the same row or column, one space on. The lettered openings connect as they always did. Older games crossed the edge only at the openings, and replay so; U3U2, dealt at rev 21 with no teleport yet cast, was re-stamped to 22 at its table's request. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG --- packages/engine/src/game.ts | 36 ++++++++++++++---- packages/engine/src/view.ts | 10 ++++- .../engine/test/expansion-terrain.test.ts | 37 ++++++++++++++++++- packages/web/src/reference.ts | 4 ++ 4 files changed, 77 insertions(+), 10 deletions(-) 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: [