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) {
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;
}