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:
co-authored by
Claude Fable 5
parent
a17b5238f0
commit
8d308bc606
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user