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:
co-authored by
Claude Fable 5
parent
53c84ec6cb
commit
73150a89b5
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -56,7 +56,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 = 10;
|
||||
const RULES_REV = 11;
|
||||
|
||||
const ROOM_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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. -->
|
||||
<svg
|
||||
viewBox={`-8 -8 ${boundsW} ${boundsH}`}
|
||||
viewBox={`${minBx} ${minBy} ${boundsW} ${boundsH}`}
|
||||
width={boundsW * 2}
|
||||
height={boundsH * 2}
|
||||
class="board"
|
||||
|
||||
@@ -132,7 +132,7 @@ class LocalGame {
|
||||
seed,
|
||||
sets: expansion ? ["basic", "expansion1"] : ["basic"],
|
||||
...(colors ? { colors } : {}),
|
||||
deckRev: 10,
|
||||
deckRev: 11,
|
||||
};
|
||||
const { state, events } = createGame(config);
|
||||
this.config = config;
|
||||
|
||||
Reference in New Issue
Block a user