Rev 11: the maze has no wall at zero, and moving sectors carry everything

Relocate Sector could never land a sector west or north of the origin
— placements the physical game allows by simply sliding the whole map
across the table. The coordinate grid still cannot go negative, but
now it does not need to: a sector may land at negative coordinates
and the whole maze renormalizes, zero-anchoring on both axes (which
also pulls the maze snug when the origin corner is vacated, instead
of leaving a blank band). The client offers the new landings as ghost
slots on every side, verified slot-for-slot against engine truth.

The move also closes a real gap: remapState never carried creatures,
boobytrap tokens, glue, open safes, or dimensional warp tokens when a
sector relocated or rotated — they were left hovering at their old
coordinates. Sectors now carry everything standing on them. Both
changes ride rules rev 11; older ledgers replay their flaws intact,
as replay determinism demands.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-16 20:07:01 -04:00
co-authored by Claude Fable 5
parent 53c84ec6cb
commit 73150a89b5
7 changed files with 157 additions and 10 deletions
+39 -2
View File
@@ -2806,6 +2806,24 @@ function remapState(
for (const t of state.treasures) { for (const t of state.treasures) {
if (t.position && inSector(t.position)) t.position = mapCell(t.position); if (t.position && inSector(t.position)) t.position = mapCell(t.position);
} }
// A moving sector carries everything standing on it. Earlier revisions left
// creatures, traps, glue, safes, and warp tokens at their old coordinates;
// their stored games must replay with that flaw intact (rules rev 11).
if ((state.config.deckRev ?? 1) >= 11) {
for (const c of state.creatures) {
if (inSector(c.position)) c.position = mapCell(c.position);
}
for (const t of state.boobytraps) {
t.cells = t.cells.map((c) => (inSector(c) ? mapCell(c) : c));
t.realKey = mapCellKey(t.realKey);
}
state.gluedCells = remapRecord(state.gluedCells, mapCellKey);
state.openSafes = state.openSafes.map(mapCellKey);
for (const w of state.dimWarps) {
if (inSector(w.a)) w.a = mapCell(w.a);
if (inSector(w.b)) w.b = mapCell(w.b);
}
}
} }
/** ROTATE SECTOR: 90 degrees, pieces and alterations turning with it. */ /** ROTATE SECTOR: 90 degrees, pieces and alterations turning with it. */
@@ -2839,7 +2857,11 @@ function relocateSector(state: GameState, index: number, dest: Cell): string | n
const placements = state.board.placements; const placements = state.board.placements;
const current = placements[index]!.origin; const current = placements[index]!.origin;
if (dest.x === current.x && dest.y === current.y) return "the sector is already there"; if (dest.x === current.x && dest.y === current.y) return "the sector is already there";
if (dest.x < 0 || dest.y < 0) return "the sector cannot go there"; // The grid has no wall at zero: a sector may land beyond the old origin and
// the whole maze renormalizes back into positive coordinates (rules rev 11).
if ((state.config.deckRev ?? 1) < 11 && (dest.x < 0 || dest.y < 0)) {
return "the sector cannot go there";
}
for (let i = 0; i < placements.length; i++) { for (let i = 0; i < placements.length; i++) {
if (i === index) continue; if (i === index) continue;
const o = placements[i]!.origin; const o = placements[i]!.origin;
@@ -2861,9 +2883,24 @@ function relocateSector(state: GameState, index: number, dest: Cell): string | n
const mapCell = (c: Cell): Cell => ({ x: c.x + dx, y: c.y + dy }); const mapCell = (c: Cell): Cell => ({ x: c.x + dx, y: c.y + dy });
const newPlacements = placements.map((p, i) => (i === index ? { ...p, origin: dest } : p)); const newPlacements = placements.map((p, i) => (i === index ? { ...p, origin: dest } : p));
// Zero-anchor the maze after the move: negative landings shift back into
// positive coordinates, and vacating the origin corner pulls the maze snug
// instead of leaving a blank band (rules rev 11; older games never shift).
const shift = (state.config.deckRev ?? 1) >= 11
? {
x: -Math.min(...newPlacements.map((p) => p.origin.x)),
y: -Math.min(...newPlacements.map((p) => p.origin.y)),
}
: { x: 0, y: 0 };
const shifted = shift.x || shift.y
? newPlacements.map((p) => ({ ...p, origin: { x: p.origin.x + shift.x, y: p.origin.y + shift.y } }))
: newPlacements;
// "Only opposite board edges connect" after a relocation: default pairings. // "Only opposite board edges connect" after a relocation: default pairings.
state.board = assembleBoard(newPlacements); state.board = assembleBoard(shifted);
remapState(state, inSector, mapCell, (s) => s); remapState(state, inSector, mapCell, (s) => s);
if (shift.x || shift.y) {
remapState(state, () => true, (c) => ({ x: c.x + shift.x, y: c.y + shift.y }), (s) => s);
}
return null; return null;
} }
+3
View File
@@ -40,6 +40,8 @@ export interface GameView {
winReason: "treasures" | "lastStanding" | null; winReason: "treasures" | "lastStanding" | null;
turn: TurnState; turn: TurnState;
activePlayerId: PlayerId; activePlayerId: PlayerId;
/** The game's rules revision — the client mirrors rev-gated legality. */
deckRev: number;
/** Board with dynamic wall changes already merged in. */ /** Board with dynamic wall changes already merged in. */
board: AssembledBoard; board: AssembledBoard;
players: PlayerPublicView[]; players: PlayerPublicView[];
@@ -120,6 +122,7 @@ export function viewFor(state: GameState, playerId: PlayerId): GameView {
revealedHands: state.phase === "finished" revealedHands: state.phase === "finished"
? Object.fromEntries(state.players.map((p) => [p.id, p.alive ? [...p.hand] : [...(p.finalHand ?? p.hand)]])) ? Object.fromEntries(state.players.map((p) => [p.id, p.alive ? [...p.hand] : [...(p.finalHand ?? p.hand)]]))
: null, : null,
deckRev: state.config.deckRev ?? 1,
treasures: state.treasures.map((t) => ({ ...t })), treasures: state.treasures.map((t) => ({ ...t })),
deckCount: state.deck.length, deckCount: state.deck.length,
discardCount: state.discard.length, discardCount: state.discard.length,
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { applyCommand, activePlayer, boardView, gameLos, sustainedOn, viewFor } from "../src"; import { applyCommand, activePlayer, boardView, createGame, gameLos, sustainedOn, viewFor } from "../src";
import { cellKey, edgeKey, type Cell } from "../src/board"; import { cellKey, edgeKey, type Cell } from "../src/board";
import type { CardInstance } from "../src/cards"; import type { CardInstance } from "../src/cards";
import { newGame, must, giveCard, toRound2, emptyNeighborCell } from "./helpers"; import { newGame, must, giveCard, toRound2, emptyNeighborCell } from "./helpers";
@@ -334,3 +334,105 @@ describe("6e card-face corrections", () => {
expect(applyCommand(state, defender.id, { type: "counteract", instanceId: "wall-of-fire#WOF2" }).ok).toBe(false); expect(applyCommand(state, defender.id, { type: "counteract", instanceId: "wall-of-fire#WOF2" }).ok).toBe(false);
}); });
}); });
describe("relocation past the origin (rules rev 11)", () => {
function rev11Game() {
const { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"], deckRev: 11 });
return state;
}
it("a sector may land at negative coordinates — the maze renormalizes", () => {
let state = rev11Game();
const me = activePlayer(state);
const other = state.players.find((p) => p.id !== me.id)!;
const idx = state.board.placements.findIndex(
(p) => me.position.y >= p.origin.y && me.position.y < p.origin.y + 5,
);
const otherIdx = idx === 0 ? 1 : 0;
const otherOrigin = state.board.placements[otherIdx]!.origin;
// Land WEST of the other sector: x = otherOrigin.x - 5, likely negative.
const dest = { x: otherOrigin.x - 5, y: otherOrigin.y };
const otherPosBefore = { ...other.position };
const rel = giveCard(state, me.id, "relocate-sector");
state = must(state, me.id, {
type: "cast", instanceId: rel.instanceId,
target: { kind: "cell", cell: dest }, params: { cell: me.position },
});
// The maze zero-anchors: origins snug against both axes.
const origins = state.board.placements.map((p: { origin: Cell }) => p.origin);
expect(Math.min(...origins.map((o: Cell) => o.x))).toBe(0);
expect(Math.min(...origins.map((o: Cell) => o.y))).toBe(0);
// Everyone stands on a cell the reassembled board knows.
for (const p of state.players) {
expect(state.board.cells[cellKey(p.position)]).toBe(true);
expect(state.board.cells[cellKey(p.home)]).toBe(true);
}
// The static sector's tenant rode the renormalization shift exactly.
const finalOther = state.board.placements[otherIdx]!.origin;
const shifted = state.players.find((p: { id: string }) => p.id === other.id)!;
expect(shifted.position).toEqual({
x: otherPosBefore.x + (finalOther.x - otherOrigin.x),
y: otherPosBefore.y + (finalOther.y - otherOrigin.y),
});
});
it("older revisions still refuse the negative landing", () => {
let { state } = newGame(); // helper default: rev-ungated (1)
const me = activePlayer(state);
const idx = state.board.placements.findIndex(
(p) => me.position.y >= p.origin.y && me.position.y < p.origin.y + 5,
);
const otherOrigin = state.board.placements[idx === 0 ? 1 : 0]!.origin;
const rel = giveCard(state, me.id, "relocate-sector");
const refused = applyCommand(state, me.id, {
type: "cast", instanceId: rel.instanceId,
target: { kind: "cell", cell: { x: otherOrigin.x - 5, y: otherOrigin.y - 5 } },
params: { cell: me.position },
});
expect(refused.ok).toBe(false);
});
it("a moving sector carries its creature, glue, warp tokens, and traps", () => {
let state = rev11Game();
const me = activePlayer(state);
const idx = state.board.placements.findIndex(
(p) => me.position.y >= p.origin.y && me.position.y < p.origin.y + 5,
);
const myOrigin = state.board.placements[idx]!.origin;
const otherOrigin = state.board.placements[idx === 0 ? 1 : 0]!.origin;
const inMine = (c: Cell) =>
c.x >= myOrigin.x && c.x < myOrigin.x + 5 && c.y >= myOrigin.y && c.y < myOrigin.y + 5;
// Seed passengers on the moving sector.
const spot = { x: myOrigin.x + 2, y: myOrigin.y + 2 };
expect(inMine(spot)).toBe(true);
state.creatures.push({
id: "c1", kind: "troll", controllerId: me.id, position: { ...spot },
damage: 0, maxDamage: 5, movesPerTurn: 3, movementUsed: 0,
attackUsed: false, justCreated: false,
} as (typeof state.creatures)[number]);
state.gluedCells[cellKey(spot)] = true;
state.dimWarps.push({ a: { ...spot }, b: { x: otherOrigin.x + 1, y: otherOrigin.y + 1 } });
state.boobytraps.push({ casterId: me.id, cells: [{ ...spot }], realKey: cellKey(spot) });
const dest = { x: otherOrigin.x + 5, y: otherOrigin.y };
const rel = giveCard(state, me.id, "relocate-sector");
state = must(state, me.id, {
type: "cast", instanceId: rel.instanceId,
target: { kind: "cell", cell: dest }, params: { cell: me.position },
});
// Expected coordinates follow the final placements (move + zero-anchor).
const finalMine = state.board.placements[idx]!.origin;
const finalOther = state.board.placements[idx === 0 ? 1 : 0]!.origin;
const moved = { x: spot.x + finalMine.x - myOrigin.x, y: spot.y + finalMine.y - myOrigin.y };
const staticShifted = {
x: otherOrigin.x + 1 + finalOther.x - otherOrigin.x,
y: otherOrigin.y + 1 + finalOther.y - otherOrigin.y,
};
expect(state.creatures[0]!.position).toEqual(moved);
expect(state.gluedCells[cellKey(moved)]).toBe(true);
expect(state.dimWarps[0]!.a).toEqual(moved);
expect(state.dimWarps[0]!.b).toEqual(staticShifted);
expect(state.boobytraps[0]!.cells[0]).toEqual(moved);
expect(state.boobytraps[0]!.realKey).toBe(cellKey(moved));
});
});
+1 -1
View File
@@ -56,7 +56,7 @@ export interface Room {
const rooms = new Map<string, Room>(); const rooms = new Map<string, Room>();
/** Rules revision new games are dealt under (stored games keep their own). */ /** Rules revision new games are dealt under (stored games keep their own). */
const RULES_REV = 10; const RULES_REV = 11;
const ROOM_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; const ROOM_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
+2 -1
View File
@@ -151,7 +151,8 @@
const k = `${c.x},${c.y}`; const k = `${c.x},${c.y}`;
if (seen.has(k)) continue; if (seen.has(k)) continue;
seen.add(k); seen.add(k);
if (c.x < 0 || c.y < 0) continue; // Before rev 11 the grid stopped at zero; newer games renormalize.
if (view!.deckRev < 11 && (c.x < 0 || c.y < 0)) continue;
if (origins.some((o) => o.x === c.x && o.y === c.y)) continue; if (origins.some((o) => o.x === c.x && o.y === c.y)) continue;
const next = origins.map((o, j) => (j === idx ? c : o)); const next = origins.map((o, j) => (j === idx ? c : o));
if (next.every((o, j) => next.some((p, m) => m !== j && adjacent(o, p)))) out.push(c); if (next.every((o, j) => next.some((p, m) => m !== j && adjacent(o, p)))) out.push(c);
+8 -4
View File
@@ -45,12 +45,16 @@
} = $props(); } = $props();
const SECTOR = 5; const SECTOR = 5;
/** Ghost slots can lie beyond the assembled maze; the canvas grows to hold them. */ /** Ghost slots can lie beyond the assembled maze — on any side, including
* negative coordinates (the maze renormalizes after the landing). The
* canvas grows in whichever direction holds them. */
const minBx = $derived(Math.min(0, ...(ghostSlots ?? []).map((g) => g.x)) * CELL - 8);
const minBy = $derived(Math.min(0, ...(ghostSlots ?? []).map((g) => g.y)) * CELL - 8);
const boundsW = $derived( const boundsW = $derived(
Math.max(view.board.width, ...(ghostSlots ?? []).map((g) => g.x + SECTOR)) * CELL + 16, Math.max(view.board.width, ...(ghostSlots ?? []).map((g) => g.x + SECTOR)) * CELL + 8 - minBx,
); );
const boundsH = $derived( const boundsH = $derived(
Math.max(view.board.height, ...(ghostSlots ?? []).map((g) => g.y + SECTOR)) * CELL + 16, Math.max(view.board.height, ...(ghostSlots ?? []).map((g) => g.y + SECTOR)) * CELL + 8 - minBy,
); );
@@ -171,7 +175,7 @@
derive one from the viewBox alone and collapses the board to 0x0 inside derive one from the viewBox alone and collapses the board to 0x0 inside
a max-height flex column. Doubled so CSS max-* caps still govern. --> a max-height flex column. Doubled so CSS max-* caps still govern. -->
<svg <svg
viewBox={`-8 -8 ${boundsW} ${boundsH}`} viewBox={`${minBx} ${minBy} ${boundsW} ${boundsH}`}
width={boundsW * 2} width={boundsW * 2}
height={boundsH * 2} height={boundsH * 2}
class="board" class="board"
+1 -1
View File
@@ -132,7 +132,7 @@ class LocalGame {
seed, seed,
sets: expansion ? ["basic", "expansion1"] : ["basic"], sets: expansion ? ["basic", "expansion1"] : ["basic"],
...(colors ? { colors } : {}), ...(colors ? { colors } : {}),
deckRev: 10, deckRev: 11,
}; };
const { state, events } = createGame(config); const { state, events } = createGame(config);
this.config = config; this.config = config;