Rev 15: the held doorway carries sight — the whole point of holding it
The table holds doors open to cast back through them at pursuers; our held door opened for feet but not for eyes. From rev 15 a held door is an open doorway to sight — casting at players, creatures, and squares sees through it, the client's dimming aid and the automatons alike (they share sightedCellsFor). REMOVE LOCK's "still considered to block L.O.S." stands: that speaks of a CLOSED door, and an unheld unlocked door still blocks. Rev-gated because rev-14 ledgers may already contain holds and must replay blind. Pinned three ways: the holder blasts a pursuer through the held doorway; an unheld unlocked door still blocks; rev 14 stays blind. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
a0c65413ef
commit
adf463effa
@@ -326,9 +326,19 @@ export function losBlockers(state: GameState): Record<string, true> {
|
|||||||
return blockers;
|
return blockers;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A held-open door is an open doorway to the eye (rules rev 15): REMOVE
|
||||||
|
* LOCK's "still considered to block L.O.S." speaks of a CLOSED door, and
|
||||||
|
* the table holds doors open precisely to cast back through them. */
|
||||||
|
function openHeldDoors(state: GameState, board: AssembledBoard): AssembledBoard {
|
||||||
|
if ((state.config.deckRev ?? 1) < 15 || state.heldDoors.length === 0) return board;
|
||||||
|
const edges = { ...board.edges };
|
||||||
|
for (const h of state.heldDoors) delete edges[h.key];
|
||||||
|
return { ...board, edges };
|
||||||
|
}
|
||||||
|
|
||||||
/** LOS including square-filling blockers. */
|
/** LOS including square-filling blockers. */
|
||||||
export function gameLos(state: GameState, from: Cell, to: Cell): boolean {
|
export function gameLos(state: GameState, from: Cell, to: Cell): boolean {
|
||||||
return sightBetween(boardView(state), from, to, losBlockers(state));
|
return sightBetween(openHeldDoors(state, boardView(state)), from, to, losBlockers(state));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Parse an edge key back into its north/west cell and side. */
|
/** Parse an edge key back into its north/west cell and side. */
|
||||||
@@ -404,7 +414,7 @@ function casterLos(
|
|||||||
to: Cell,
|
to: Cell,
|
||||||
events: GameEvent[] = [],
|
events: GameEvent[] = [],
|
||||||
): boolean {
|
): boolean {
|
||||||
const board = perceivedBoard(state, events, caster.id, { from, to });
|
const board = openHeldDoors(state, perceivedBoard(state, events, caster.id, { from, to }));
|
||||||
const blockers = losBlockers(state);
|
const blockers = losBlockers(state);
|
||||||
if (sightBetween(board, from, to, blockers)) return true;
|
if (sightBetween(board, from, to, blockers)) return true;
|
||||||
if (!displays(caster, "visionstone")) return false;
|
if (!displays(caster, "visionstone")) return false;
|
||||||
|
|||||||
@@ -173,6 +173,13 @@ export function sightedCellsFor(view: GameView): Set<string> {
|
|||||||
const out = new Set<string>();
|
const out = new Set<string>();
|
||||||
const me = view.players.find((p) => p.id === view.you);
|
const me = view.players.find((p) => p.id === view.you);
|
||||||
if (!me) return out;
|
if (!me) return out;
|
||||||
|
// Held-open doors are open doorways to the eye (rules rev 15).
|
||||||
|
let board = view.board;
|
||||||
|
if (view.deckRev >= 15 && view.heldDoorEdges.length > 0) {
|
||||||
|
const edges = { ...board.edges };
|
||||||
|
for (const k of view.heldDoorEdges) delete edges[k];
|
||||||
|
board = { ...board, edges };
|
||||||
|
}
|
||||||
const blockers: Record<string, true> = {};
|
const blockers: Record<string, true> = {};
|
||||||
for (const [key, content] of Object.entries(view.squareContents)) {
|
for (const [key, content] of Object.entries(view.squareContents)) {
|
||||||
if (LOS_BLOCKING_CONTENT[content.kind]) blockers[key] = true;
|
if (LOS_BLOCKING_CONTENT[content.kind]) blockers[key] = true;
|
||||||
@@ -182,9 +189,9 @@ export function sightedCellsFor(view: GameView): Set<string> {
|
|||||||
blockers[`${p.position.x},${p.position.y}`] = true;
|
blockers[`${p.position.x},${p.position.y}`] = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const key of Object.keys(view.board.cells)) {
|
for (const key of Object.keys(board.cells)) {
|
||||||
const [x, y] = key.split(",").map(Number) as [number, number];
|
const [x, y] = key.split(",").map(Number) as [number, number];
|
||||||
if (sightBetween(view.board, me.position, { x, y }, blockers)) out.add(key);
|
if (sightBetween(board, me.position, { x, y }, blockers)) out.add(key);
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -409,3 +409,68 @@ describe("holding the door open (Pick Lock / Master Key)", () => {
|
|||||||
expect(released).toBe(true);
|
expect(released).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("a held door is an open doorway to the eye (rules rev 15)", () => {
|
||||||
|
function sightRig(deckRev: number) {
|
||||||
|
let { state } = createGame({ playerIds: ["holder", "pursuer"], seed: 42, sets: ["basic"], deckRev });
|
||||||
|
state = toRound2(state);
|
||||||
|
const view = boardView(state);
|
||||||
|
for (const [key, edge] of Object.entries(view.edges)) {
|
||||||
|
if (edge !== "door") continue;
|
||||||
|
const [kind, coords] = key.split(":") as [string, string];
|
||||||
|
const [x, y] = coords.split(",").map(Number) as [number, number];
|
||||||
|
const cell = { x, y };
|
||||||
|
const side = kind === "V" ? ("E" as Side) : ("S" as Side);
|
||||||
|
const holder = activePlayer(state);
|
||||||
|
const pursuer = state.players.find((p) => p.id !== holder.id)!;
|
||||||
|
holder.position = { ...cell };
|
||||||
|
pursuer.position = neighbor(cell, side);
|
||||||
|
return { state, cell, side, holder: holder.id, pursuer: pursuer.id };
|
||||||
|
}
|
||||||
|
throw new Error("setup: seed 42 grew a maze with no doors");
|
||||||
|
}
|
||||||
|
|
||||||
|
it("rev 15: the holder blasts the pursuer through the doorway", () => {
|
||||||
|
let { state, cell, side, holder, pursuer } = sightRig(15);
|
||||||
|
const pick = giveCard(state, holder, "pick-lock");
|
||||||
|
state = must(state, holder, {
|
||||||
|
type: "cast", instanceId: pick.instanceId,
|
||||||
|
target: { kind: "edge", cell, side }, params: { hold: true },
|
||||||
|
});
|
||||||
|
const fb = giveCard(state, holder, "fireball", "FB", 1);
|
||||||
|
const lifeBefore = state.players.find((p) => p.id === pursuer)!.life;
|
||||||
|
state = must(state, holder, {
|
||||||
|
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: pursuer },
|
||||||
|
});
|
||||||
|
state = must(state, pursuer, { type: "pass" });
|
||||||
|
expect(state.players.find((p) => p.id === pursuer)!.life).toBeLessThan(lifeBefore);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an unheld unlocked door still blocks sight", () => {
|
||||||
|
let { state, cell, side, holder, pursuer } = sightRig(15);
|
||||||
|
const pick = giveCard(state, holder, "pick-lock");
|
||||||
|
state = must(state, holder, {
|
||||||
|
type: "cast", instanceId: pick.instanceId,
|
||||||
|
target: { kind: "edge", cell, side },
|
||||||
|
});
|
||||||
|
const fb = giveCard(state, holder, "fireball", "FB", 1);
|
||||||
|
const refused = applyCommand(state, holder, {
|
||||||
|
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: pursuer },
|
||||||
|
});
|
||||||
|
expect(refused.ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("earlier revisions keep the blocked sightline, hold or no", () => {
|
||||||
|
let { state, cell, side, holder, pursuer } = sightRig(14);
|
||||||
|
const pick = giveCard(state, holder, "pick-lock");
|
||||||
|
state = must(state, holder, {
|
||||||
|
type: "cast", instanceId: pick.instanceId,
|
||||||
|
target: { kind: "edge", cell, side }, params: { hold: true },
|
||||||
|
});
|
||||||
|
const fb = giveCard(state, holder, "fireball", "FB", 1);
|
||||||
|
const refused = applyCommand(state, holder, {
|
||||||
|
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: pursuer },
|
||||||
|
});
|
||||||
|
expect(refused.ok).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -54,7 +54,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 = 14;
|
const RULES_REV = 15;
|
||||||
|
|
||||||
const ROOM_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
const ROOM_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||||
|
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ class LocalGame {
|
|||||||
seed,
|
seed,
|
||||||
sets: expansion ? ["basic", "expansion1"] : ["basic"],
|
sets: expansion ? ["basic", "expansion1"] : ["basic"],
|
||||||
...(colors ? { colors } : {}),
|
...(colors ? { colors } : {}),
|
||||||
deckRev: 14,
|
deckRev: 15,
|
||||||
};
|
};
|
||||||
const { state, events } = createGame(config);
|
const { state, events } = createGame(config);
|
||||||
for (const e of events) {
|
for (const e of events) {
|
||||||
|
|||||||
Reference in New Issue
Block a user