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) {
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user