From 73150a89b5c123eb679e7caa6f7f4b3177282c56 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Sun, 16 Aug 2026 20:07:01 -0400 Subject: [PATCH] Rev 11: the maze has no wall at zero, and moving sectors carry everything MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/engine/src/game.ts | 41 ++++++- packages/engine/src/view.ts | 3 + .../test/sight-illusions-sectors.test.ts | 104 +++++++++++++++++- packages/server/src/rooms.ts | 2 +- packages/web/src/App.svelte | 3 +- packages/web/src/Board.svelte | 12 +- packages/web/src/local.svelte.ts | 2 +- 7 files changed, 157 insertions(+), 10 deletions(-) diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index 79f5909..0f689e8 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -2806,6 +2806,24 @@ function remapState( for (const t of state.treasures) { 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. */ @@ -2839,7 +2857,11 @@ function relocateSector(state: GameState, index: number, dest: Cell): string | n const placements = state.board.placements; const current = placements[index]!.origin; 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++) { if (i === index) continue; 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 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. - state.board = assembleBoard(newPlacements); + state.board = assembleBoard(shifted); 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; } diff --git a/packages/engine/src/view.ts b/packages/engine/src/view.ts index fc9c648..eb86eb6 100644 --- a/packages/engine/src/view.ts +++ b/packages/engine/src/view.ts @@ -40,6 +40,8 @@ export interface GameView { winReason: "treasures" | "lastStanding" | null; turn: TurnState; activePlayerId: PlayerId; + /** The game's rules revision — the client mirrors rev-gated legality. */ + deckRev: number; /** Board with dynamic wall changes already merged in. */ board: AssembledBoard; players: PlayerPublicView[]; @@ -120,6 +122,7 @@ export function viewFor(state: GameState, playerId: PlayerId): GameView { revealedHands: state.phase === "finished" ? Object.fromEntries(state.players.map((p) => [p.id, p.alive ? [...p.hand] : [...(p.finalHand ?? p.hand)]])) : null, + deckRev: state.config.deckRev ?? 1, treasures: state.treasures.map((t) => ({ ...t })), deckCount: state.deck.length, discardCount: state.discard.length, diff --git a/packages/engine/test/sight-illusions-sectors.test.ts b/packages/engine/test/sight-illusions-sectors.test.ts index bcbae70..f6396fe 100644 --- a/packages/engine/test/sight-illusions-sectors.test.ts +++ b/packages/engine/test/sight-illusions-sectors.test.ts @@ -1,5 +1,5 @@ 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 type { CardInstance } from "../src/cards"; 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); }); }); + +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)); + }); +}); diff --git a/packages/server/src/rooms.ts b/packages/server/src/rooms.ts index bc18d86..fd2b7d8 100644 --- a/packages/server/src/rooms.ts +++ b/packages/server/src/rooms.ts @@ -56,7 +56,7 @@ export interface Room { const rooms = new Map(); /** 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"; diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index b7c250b..a4462f4 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -151,7 +151,8 @@ const k = `${c.x},${c.y}`; if (seen.has(k)) continue; 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; 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); diff --git a/packages/web/src/Board.svelte b/packages/web/src/Board.svelte index 9db4357..e5f3a4a 100644 --- a/packages/web/src/Board.svelte +++ b/packages/web/src/Board.svelte @@ -45,12 +45,16 @@ } = $props(); 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( - 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( - 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 a max-height flex column. Doubled so CSS max-* caps still govern. -->