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