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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
This commit is contained in:
Eric Wagoner
2026-09-16 23:56:13 -04:00
co-authored by Claude Fable 5.1
parent 7a6aa3d2a1
commit 803c51415e
4 changed files with 77 additions and 10 deletions
+29 -7
View File
@@ -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<string, AttackEffect | NeutralEffect | CounterEffect>
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<string, number>([[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);
+8 -2
View File
@@ -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;
+36 -1
View File
@@ -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);
});
});