Rev 7: sight passes only through the aisle warps
The cards settle what the morning's feature got wrong. DIMENSIONAL WARP: "There is no L.O.S. through the warp." The AUTO WARP: "for all purposes, treat the connected board edges as though they are adjacent" — an explicit grant, for edges that physically meet at a corner. The lettered wraparounds get no grant at all: their rule is about walking. So the pattern is that warps carry movement, not sight, unless the text says otherwise — and only the aisle corners say otherwise. Warps now carry an aisle flag (the 3-player AUTO WARP and the 5-player corner arcs); hasWarpLineOfSight honors only those, with the adjacency geometry — diagonals through the corner mouth — unchanged from this morning where it rightfully applies. Games begun before rev 7 keep the wider sight their logged commands were validated against, via a legacy flag boardView stamps from the game's rules revision. Tests pin all three states: lettered mouths see nothing, aisle mouths see the corner fan, legacy games see everything they used to. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
8af8962330
commit
493f3bc567
@@ -27,6 +27,13 @@ export interface Warp {
|
||||
from: { cell: Cell; side: Side };
|
||||
/** ...you arrive at this cell (entering through `side`). */
|
||||
to: { cell: Cell; side: Side };
|
||||
/**
|
||||
* An aisle (AUTO) warp joins two edges that physically meet at a corner:
|
||||
* "for all purposes, treat the connected board edges as though they are
|
||||
* adjacent" — including line of sight. The lettered wraparounds carry
|
||||
* movement only; no rule grants sight across the map.
|
||||
*/
|
||||
aisle?: boolean;
|
||||
}
|
||||
|
||||
export interface AssembledBoard {
|
||||
@@ -38,6 +45,8 @@ export interface AssembledBoard {
|
||||
/** Cells that are part of the map (all cells inside placed sectors). */
|
||||
cells: Record<string, true>;
|
||||
warps: Warp[];
|
||||
/** Pre-rev-7 games saw through every warp; their replays keep that sight. */
|
||||
sightThroughAllWarps?: boolean;
|
||||
/** Per player-sector info, in placement order. */
|
||||
homes: Cell[];
|
||||
treasureSpaces: Cell[][];
|
||||
@@ -119,7 +128,7 @@ export interface AssemblyOptions {
|
||||
* openings pair straight across the map — correct for the 4p square; the
|
||||
* standard 2p column crosses its side openings and must pass pairs.
|
||||
*/
|
||||
warpPairs?: [OpeningRef, OpeningRef][];
|
||||
warpPairs?: ([OpeningRef, OpeningRef] | [OpeningRef, OpeningRef, "aisle"])[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -204,11 +213,12 @@ export function assembleBoard(
|
||||
};
|
||||
|
||||
if (options.warpPairs) {
|
||||
for (const [a, b] of options.warpPairs) {
|
||||
for (const [a, b, kind] of options.warpPairs) {
|
||||
const ca = openingCell(a.sector, a.side);
|
||||
const cb = openingCell(b.sector, b.side);
|
||||
warps.push({ from: { cell: ca, side: a.side }, to: { cell: cb, side: b.side } });
|
||||
warps.push({ from: { cell: cb, side: b.side }, to: { cell: ca, side: a.side } });
|
||||
const aisle = kind === "aisle" ? { aisle: true as const } : {};
|
||||
warps.push({ from: { cell: ca, side: a.side }, to: { cell: cb, side: b.side }, ...aisle });
|
||||
warps.push({ from: { cell: cb, side: b.side }, to: { cell: ca, side: a.side }, ...aisle });
|
||||
}
|
||||
} else {
|
||||
for (let i = 0; i < placements.length; i++) {
|
||||
@@ -363,6 +373,7 @@ export function hasWarpLineOfSight(
|
||||
};
|
||||
const EPS = 1e-9;
|
||||
for (const w of board.warps) {
|
||||
if (!w.aisle && !board.sightThroughAllWarps) continue;
|
||||
const mouthA = w.from.cell;
|
||||
const sideA = w.from.side;
|
||||
const mouthB = w.to.cell;
|
||||
|
||||
@@ -218,6 +218,8 @@ export interface GameConfig {
|
||||
* BIG MAN pushes occupants ahead, steps over floor hazards for 2 points,
|
||||
* and bars monsters from his square. Rev 6: ANTI-ANTI cannot pin a
|
||||
* teleport escape ("does not work against escape" — the card face).
|
||||
* Rev 7: sight passes only through aisle-warp corners, not the lettered
|
||||
* wraparounds ("no rule grants it; DIMENSIONAL WARP explicitly denies it").
|
||||
*/
|
||||
deckRev?: number;
|
||||
}
|
||||
@@ -290,8 +292,17 @@ export interface GameState {
|
||||
|
||||
/** The board with dynamic wall changes applied — use for movement and LOS. */
|
||||
export function boardView(state: GameState): AssembledBoard {
|
||||
if (Object.keys(state.edgeOverrides).length === 0) return state.board;
|
||||
return { ...state.board, edges: { ...state.board.edges, ...state.edgeOverrides } };
|
||||
// Rev 7 restricts warp sight to the aisle corners; earlier games keep the
|
||||
// wider sight their commands were validated against.
|
||||
const legacySight = (state.config.deckRev ?? 1) < 7;
|
||||
if (Object.keys(state.edgeOverrides).length === 0) {
|
||||
return legacySight ? { ...state.board, sightThroughAllWarps: true } : state.board;
|
||||
}
|
||||
return {
|
||||
...state.board,
|
||||
edges: { ...state.board.edges, ...state.edgeOverrides },
|
||||
...(legacySight ? { sightThroughAllWarps: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function sustainedOn(state: GameState, playerId: PlayerId, cardId?: string): SustainedEffect[] {
|
||||
|
||||
@@ -68,7 +68,7 @@ export function setupBoard(playerCount: number, rng: RngState): SetupResult {
|
||||
];
|
||||
const board = assembleBoard(placements, {
|
||||
warpPairs: [
|
||||
[{ sector: 0, side: "W" }, { sector: 1, side: "N" }], // AUTO WARP corner
|
||||
[{ sector: 0, side: "W" }, { sector: 1, side: "N" }, "aisle"], // AUTO WARP corner
|
||||
[{ sector: 0, side: "N" }, { sector: 2, side: "S" }],
|
||||
[{ sector: 1, side: "W" }, { sector: 2, side: "E" }],
|
||||
[{ sector: 0, side: "E" }, { sector: 1, side: "S" }],
|
||||
@@ -106,10 +106,10 @@ export function setupBoard(playerCount: number, rng: RngState): SetupResult {
|
||||
warpPairs: [
|
||||
[{ sector: 0, side: "N" }, { sector: 4, side: "S" }], // tip to tip
|
||||
[{ sector: 1, side: "W" }, { sector: 3, side: "E" }],
|
||||
[{ sector: 0, side: "W" }, { sector: 1, side: "N" }], // aisle-warp corners
|
||||
[{ sector: 0, side: "E" }, { sector: 3, side: "N" }],
|
||||
[{ sector: 4, side: "W" }, { sector: 1, side: "S" }],
|
||||
[{ sector: 4, side: "E" }, { sector: 3, side: "S" }],
|
||||
[{ sector: 0, side: "W" }, { sector: 1, side: "N" }, "aisle"], // aisle-warp corners
|
||||
[{ sector: 0, side: "E" }, { sector: 3, side: "N" }, "aisle"],
|
||||
[{ sector: 4, side: "W" }, { sector: 1, side: "S" }, "aisle"],
|
||||
[{ sector: 4, side: "E" }, { sector: 3, side: "S" }, "aisle"],
|
||||
],
|
||||
});
|
||||
return { board, rng: rngN };
|
||||
|
||||
@@ -138,39 +138,26 @@ describe("player counts 2-6", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("line of sight through wraparound openings", () => {
|
||||
describe("sight and the wraparound openings", () => {
|
||||
const twoSector: SectorPlacement[] = [
|
||||
{ boardId: "board-a", origin: { x: 0, y: 0 }, rotation: 0 },
|
||||
{ boardId: "board-b", origin: { x: 0, y: 5 }, rotation: 0 },
|
||||
];
|
||||
|
||||
it("sees straight down the corridor, out one mouth and in the other", () => {
|
||||
it("lettered wraparounds carry movement only — no sight across the map", () => {
|
||||
const board = assembleBoard(twoSector);
|
||||
// (2,0) N wraps to (2,9): the mouths see each other through the join.
|
||||
expect(hasLineOfSight(board, { x: 2, y: 0 }, { x: 2, y: 9 })).toBe(false);
|
||||
// (2,0) N wraps to (2,9) for walking, but no rule grants sight there,
|
||||
// and DIMENSIONAL WARP's own face denies it for warps generally.
|
||||
expect(sightBetween(board, { x: 2, y: 0 }, { x: 2, y: 9 })).toBe(false);
|
||||
expect(sightBetween(board, { x: 2, y: 9 }, { x: 2, y: 0 })).toBe(false);
|
||||
});
|
||||
|
||||
it("pre-rev-7 games keep the wider sight their commands were validated under", () => {
|
||||
const board = { ...assembleBoard(twoSector), sightThroughAllWarps: true };
|
||||
expect(sightBetween(board, { x: 2, y: 0 }, { x: 2, y: 9 })).toBe(true);
|
||||
expect(sightBetween(board, { x: 2, y: 9 }, { x: 2, y: 0 })).toBe(true);
|
||||
});
|
||||
|
||||
it("demands a straight corridor: off-axis viewers see nothing", () => {
|
||||
const board = assembleBoard(twoSector);
|
||||
expect(sightBetween(board, { x: 1, y: 0 }, { x: 2, y: 9 })).toBe(false);
|
||||
expect(sightBetween(board, { x: 3, y: 1 }, { x: 2, y: 9 })).toBe(false);
|
||||
});
|
||||
|
||||
it("walls inside either corridor still block the warp sight", () => {
|
||||
const board = assembleBoard(twoSector);
|
||||
// Layout A: the wall between (2,0) and (2,1)'s row (home column sight is
|
||||
// cut above row 2 — pinned earlier) blocks a deeper viewer's warp sight.
|
||||
const deepViewer = { x: 2, y: 2 };
|
||||
expect(hasLineOfSight(board, deepViewer, { x: 2, y: 0 })).toBe(false);
|
||||
expect(sightBetween(board, deepViewer, { x: 2, y: 9 })).toBe(false);
|
||||
});
|
||||
|
||||
it("a filled mouth chokes the tunnel", () => {
|
||||
const board = assembleBoard(twoSector);
|
||||
const stone = { "2,9": true as const };
|
||||
expect(sightBetween(board, { x: 2, y: 0 }, { x: 2, y: 8 }, stone)).toBe(false);
|
||||
// Even then: walls inside the corridor still block, and a filled mouth chokes.
|
||||
expect(sightBetween(board, { x: 2, y: 2 }, { x: 2, y: 9 })).toBe(false);
|
||||
expect(sightBetween(board, { x: 2, y: 0 }, { x: 2, y: 8 }, { "2,9": true })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -237,29 +224,34 @@ describe("setup diagram pairings (rulebook Set-Up Diagram)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("sight through warps treats the boards as adjacent", () => {
|
||||
const twoSector: SectorPlacement[] = [
|
||||
{ boardId: "board-a", origin: { x: 0, y: 0 }, rotation: 0 },
|
||||
{ boardId: "board-b", origin: { x: 0, y: 5 }, rotation: 0 },
|
||||
];
|
||||
|
||||
it("diagonal lines pass through the one-cell mouth like any doorway", () => {
|
||||
const board = assembleBoard(twoSector);
|
||||
// From SOME warp mouth, at least one square off the straight corridor
|
||||
// axis must be visible through the opening — the adjacency promise.
|
||||
let offAxis = 0;
|
||||
for (const w of board.warps) {
|
||||
const mouth = w.from.cell;
|
||||
const axisVertical = w.from.side === "N" || w.from.side === "S";
|
||||
for (const key of Object.keys(board.cells)) {
|
||||
const [x, y] = key.split(",").map(Number) as [number, number];
|
||||
const onAxis = axisVertical ? x === mouth.x : y === mouth.y;
|
||||
if (onAxis) continue;
|
||||
if (!hasLineOfSight(board, mouth, { x, y }) && sightBetween(board, mouth, { x, y })) {
|
||||
offAxis++;
|
||||
}
|
||||
describe("the aisle warp treats its corner as adjacent — sight included", () => {
|
||||
it("sight passes through the AUTO WARP, diagonals and all", () => {
|
||||
const { board } = setupBoardForPins(3, createRng(7));
|
||||
const aisle = board.warps.find((w) => w.aisle)!;
|
||||
expect(aisle).toBeDefined();
|
||||
// From the aisle mouth, squares beyond the corner are visible — including
|
||||
// at least one OFF the straight axis (the adjacency promise).
|
||||
const mouth = aisle.from.cell;
|
||||
const axisVertical = aisle.from.side === "N" || aisle.from.side === "S";
|
||||
let seen = 0, offAxis = 0;
|
||||
for (const key of Object.keys(board.cells)) {
|
||||
const [x, y] = key.split(",").map(Number) as [number, number];
|
||||
if (hasLineOfSight(board, mouth, { x, y })) continue;
|
||||
if (sightBetween(board, mouth, { x, y })) {
|
||||
seen++;
|
||||
if (axisVertical ? x !== mouth.x : y !== mouth.y) offAxis++;
|
||||
}
|
||||
}
|
||||
expect(seen).toBeGreaterThan(0);
|
||||
expect(offAxis).toBeGreaterThan(0);
|
||||
// A lettered (non-aisle) mouth on the same board sees nothing extra.
|
||||
const lettered = board.warps.find((w) => !w.aisle)!;
|
||||
const lm = lettered.from.cell;
|
||||
let extra = 0;
|
||||
for (const key of Object.keys(board.cells)) {
|
||||
const [x, y] = key.split(",").map(Number) as [number, number];
|
||||
if (!hasLineOfSight(board, lm, { x, y }) && sightBetween(board, lm, { x, y })) extra++;
|
||||
}
|
||||
expect(extra).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -46,7 +46,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 = 6;
|
||||
const RULES_REV = 7;
|
||||
|
||||
const ROOM_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||
|
||||
|
||||
@@ -101,7 +101,7 @@ class LocalGame {
|
||||
seed,
|
||||
sets: expansion ? ["basic", "expansion1"] : ["basic"],
|
||||
...(colors ? { colors } : {}),
|
||||
deckRev: 6,
|
||||
deckRev: 7,
|
||||
};
|
||||
const { state, events } = createGame(config);
|
||||
this.config = config;
|
||||
|
||||
Reference in New Issue
Block a user