From 8d308bc6064dcfe0c552efb6a6e5a4203bce1925 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Mon, 17 Aug 2026 17:34:33 -0400 Subject: [PATCH] Rev 20: the junction wall rolls for its allegiance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rulebook (Cards And Actions That Change The Map): a CREATE WALL or DESTROY WALL alteration on a junction between sectors rolls a 50-50 when "some other moron" rotates one of them — 1-2 stays with the standing sector, 3-4 travels with the moving one, per the book's own low/high convention. Both rotation and relocation roll (the same seam tears either way), demolished holes roll alongside conjured walls, the pips land loud in the chronicle, and a traveling edge rides anchored to its in-sector cell. Rev-gated: the roll consumes dice that stored games never spent. Co-Authored-By: Claude Fable 5 --- packages/engine/src/game.ts | 57 ++++++++++++++++--- .../test/sight-illusions-sectors.test.ts | 28 +++++++++ packages/server/src/rooms.ts | 2 +- packages/web/src/local.svelte.ts | 2 +- 4 files changed, 79 insertions(+), 10 deletions(-) diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index 557b5a1..27c8498 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -746,6 +746,35 @@ interface DamagePipeline { kind: "spell" | "physical"; } +/** Conjured or demolished edges straddling a sector boundary. "If someone + * has cast CREATE WALL or DESTROY WALL on a junction between sectors ... + * roll the die for a 50-50% chance to see which one of the two sectors the + * 'alteration' stays with" (rulebook, Cards And Actions That Change The + * Map). 1-2 stays behind; 3-4 travels (the book's own low/high call). */ +function rollJunctionAlterations( + state: GameState, events: GameEvent[], casterId: PlayerId, + origin: Cell, +): Set { + const travel = new Set(); + if ((state.config.deckRev ?? 1) < 20) return travel; + const inSector = (c: Cell) => + c.x >= origin.x && c.x < origin.x + 5 && c.y >= origin.y && c.y < origin.y + 5; + const altered = new Set([ + ...Object.keys(state.createdEdges), + ...Object.keys(state.edgeOverrides).filter( + (k) => state.edgeOverrides[k] === "open" && (state.board.edges[k] ?? "open") !== "open", + ), + ]); + for (const key of altered) { + const { cell, side } = parseEdgeKey(key); + if (inSector(cell) === inSector(neighbor(cell, side))) continue; + const roll = rollD4(state, events, casterId, + "the junction alteration picks a side — 1-2 stays, 3-4 travels"); + if (roll >= 3) travel.add(key); + } + return travel; +} + /** The opposite perimeter edge in the same row or column — where the * maze's default wraparound would connect ("only opposite board edges * connect"). Null when the board's shape offers no counterpart. */ @@ -1522,7 +1551,9 @@ const CARD_EFFECTS: Record if (!cmd.target || cmd.target.kind !== "cell") return "click a square in the sector to rotate"; const idx = sectorIndexAt(state.board, cmd.target.cell); if (idx === -1) return "that is not on a sector"; - rotateSector(state, idx, cmd.params?.clockwise ?? true); + const travel = rollJunctionAlterations( + state, events, caster.id, state.board.placements[idx]!.origin); + rotateSector(state, idx, cmd.params?.clockwise ?? true, travel); events.push({ type: "sectorRotated", caster: caster.id, sectorIndex: idx, clockwise: cmd.params?.clockwise ?? true }); return null; }, @@ -1542,7 +1573,8 @@ const CARD_EFFECTS: Record y: Math.floor(cmd.target.cell.y / 5) * 5, }; const fromOrigin = { ...state.board.placements[idx]!.origin }; - const problem = relocateSector(state, idx, dest); + const travel = rollJunctionAlterations(state, events, caster.id, fromOrigin); + const problem = relocateSector(state, idx, dest, travel); if (problem) return problem; const finalTo = { ...state.board.placements[idx]!.origin }; const shift = { x: finalTo.x - dest.x, y: finalTo.y - dest.y }; @@ -2920,12 +2952,21 @@ function remapState( inSector: (c: Cell) => boolean, mapCell: (c: Cell) => Cell, mapSide: (s: Side) => Side, + travelKeys?: Set, ): void { const mapEdgeKey = (key: string): string => { const { cell, side } = parseEdgeKey(key); const other = neighbor(cell, side); - if (!inSector(cell) || !inSector(other)) return key; // boundary: leave - return edgeKey(mapCell(cell), mapSide(side)); + const inA = inSector(cell), inB = inSector(other); + if (inA && inB) return edgeKey(mapCell(cell), mapSide(side)); + // A boundary edge stays put — unless the die said it travels: then it + // rides anchored to its in-sector cell. + if (travelKeys?.has(key) && (inA || inB)) { + const anchor = inA ? cell : other; + const anchorSide = inA ? side : opposite(side); + return edgeKey(mapCell(anchor), mapSide(anchorSide)); + } + return key; }; const remapRecord = (rec: Record, mapKey: (k: string) => string): Record => Object.fromEntries(Object.entries(rec).map(([k, v]) => [mapKey(k), v])); @@ -2979,7 +3020,7 @@ function remapState( } /** ROTATE SECTOR: 90 degrees, pieces and alterations turning with it. */ -function rotateSector(state: GameState, index: number, clockwise: boolean): void { +function rotateSector(state: GameState, index: number, clockwise: boolean, travelKeys?: Set): void { const placement = state.board.placements[index]!; const { x: ox, y: oy } = placement.origin; const inSector = (c: Cell) => c.x >= ox && c.x < ox + 5 && c.y >= oy && c.y < oy + 5; @@ -3001,11 +3042,11 @@ function rotateSector(state: GameState, index: number, clockwise: boolean): void const rebuilt = assembleBoard(newPlacements); rebuilt.warps = oldWarps; // openings are centered: rotation never moves them state.board = rebuilt; - remapState(state, inSector, mapCell, mapSide); + remapState(state, inSector, mapCell, mapSide, travelKeys); } /** RELOCATE SECTOR: slide a sector to a new area; wraparounds recompute. */ -function relocateSector(state: GameState, index: number, dest: Cell): string | null { +function relocateSector(state: GameState, index: number, dest: Cell, travelKeys?: Set): string | null { 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"; @@ -3049,7 +3090,7 @@ function relocateSector(state: GameState, index: number, dest: Cell): string | n : newPlacements; // "Only opposite board edges connect" after a relocation: default pairings. state.board = assembleBoard(shifted); - remapState(state, inSector, mapCell, (s) => s); + remapState(state, inSector, mapCell, (s) => s, travelKeys); if (shift.x || shift.y) { remapState(state, () => true, (c) => ({ x: c.x + shift.x, y: c.y + shift.y }), (s) => s); } diff --git a/packages/engine/test/sight-illusions-sectors.test.ts b/packages/engine/test/sight-illusions-sectors.test.ts index 880b8bd..9c7fc51 100644 --- a/packages/engine/test/sight-illusions-sectors.test.ts +++ b/packages/engine/test/sight-illusions-sectors.test.ts @@ -435,3 +435,31 @@ describe("relocation past the origin (rules rev 11)", () => { expect(state.boobytraps[0]!.realKey).toBe(cellKey(moved)); }); }); + +describe("junction alterations roll for their sector (rules rev 20)", () => { + it("a conjured wall on the seam obeys the die: 1-2 stays, 3-4 travels", () => { + const { state: fresh } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"], deckRev: 20 }); + let state = toRound2(fresh); + // The 2p board is two sectors stacked: the seam runs between them. + const [p0, p1] = state.board.placements; + const seamY = Math.max(p0!.origin.y, p1!.origin.y) - 1; + const seamCell = { x: p0!.origin.x + 2, y: seamY }; + const key = edgeKey(seamCell, "S"); + // Conjure a wall on the seam (state surgery: the cast needs LOS we may lack). + state.edgeOverrides[key] = "wall"; + state.createdEdges[key] = true; + const caster = activePlayer(state); + const rot = giveCard(state, caster.id, "rotate-sector"); + const rngBefore = JSON.stringify(state.rng); + state = must(state, caster.id, { + type: "cast", instanceId: rot.instanceId, + target: { kind: "cell", cell: { x: p1!.origin.x + 2, y: p1!.origin.y + 2 } }, + params: { clockwise: true }, + }); + // A die was consumed for the seam wall. + expect(JSON.stringify(state.rng)).not.toBe(rngBefore); + // The wall is somewhere: either the old seam key or a remapped edge. + const createdKeys = Object.keys(state.createdEdges); + expect(createdKeys.length).toBe(1); + }); +}); diff --git a/packages/server/src/rooms.ts b/packages/server/src/rooms.ts index d8d04c8..978e644 100644 --- a/packages/server/src/rooms.ts +++ b/packages/server/src/rooms.ts @@ -54,7 +54,7 @@ export interface Room { const rooms = new Map(); /** Rules revision new games are dealt under (stored games keep their own). */ -const RULES_REV = 19; +const RULES_REV = 20; const ROOM_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; diff --git a/packages/web/src/local.svelte.ts b/packages/web/src/local.svelte.ts index f54771c..a67bae4 100644 --- a/packages/web/src/local.svelte.ts +++ b/packages/web/src/local.svelte.ts @@ -136,7 +136,7 @@ class LocalGame { seed, sets: expansion ? ["basic", "expansion1"] : ["basic"], ...(colors ? { colors } : {}), - deckRev: 19, + deckRev: 20, }; const { state, events } = createGame(config); for (const e of events) {