Rev 20: the junction wall rolls for its allegiance

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 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-17 17:34:33 -04:00
co-authored by Claude Fable 5
parent a17b5238f0
commit 8d308bc606
4 changed files with 79 additions and 10 deletions
+49 -8
View File
@@ -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<string> {
const travel = new Set<string>();
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<string>([
...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<string, AttackEffect | NeutralEffect | CounterEffect>
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<string, AttackEffect | NeutralEffect | CounterEffect>
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<string>,
): 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 = <T,>(rec: Record<string, T>, mapKey: (k: string) => string): Record<string, T> =>
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<string>): 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>): 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);
}
@@ -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);
});
});
+1 -1
View File
@@ -54,7 +54,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 = 19;
const RULES_REV = 20;
const ROOM_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
+1 -1
View File
@@ -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) {