A stone-bearing automaton fired Blind through a warp mouth and one wall, and the table saw nothing but the attack modal — the sight trace only knew plain and bent-corner lines, so a legal shot read as a cheat. When the attacker displays VISIONSTONE and no plain line exists, stackSightTrace now finds the single edge whose removal opens the line (warp legs included), draws the ray through it, and the keymap pulses the dissolved wall so the answer to "how can he even see me?" is on the board. Verified against the live LZ5R blind moment: warp mouths at (0,2)W and (4,7)E, pierced wall V:3,8. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
493 lines
20 KiB
TypeScript
493 lines
20 KiB
TypeScript
// Per-player projection of GameState: everything public, plus YOUR hand.
|
|
// The server sends this after every state change; clients never see the
|
|
// deck order or other players' hands.
|
|
|
|
import { SIDES, edgeKey, sightBetween, bentSightThroughGap, traceSight, type AssembledBoard, type Cell, type SightTrace } from "./board";
|
|
import { cardDef, type CardInstance } from "./cards";
|
|
import {
|
|
boardView,
|
|
LOS_BLOCKING_CONTENT,
|
|
type AmbushState,
|
|
type CastStack,
|
|
type CreatureState,
|
|
type GameState,
|
|
type PlayerId,
|
|
type SquareContent,
|
|
type SustainedEffect,
|
|
type TreasureState,
|
|
type TurnState,
|
|
} from "./game";
|
|
|
|
export interface PlayerPublicView {
|
|
id: PlayerId;
|
|
position: { x: number; y: number };
|
|
home: { x: number; y: number };
|
|
life: number;
|
|
alive: boolean;
|
|
handCount: number;
|
|
carriedTreasureId: string | null;
|
|
lostTurns: number;
|
|
extraTurns: number;
|
|
/** Banked PASS THROUGH WALL crossings — cast openly, so public knowledge. */
|
|
passWallCharges: number;
|
|
displayed: CardInstance[];
|
|
/** Which of the six physical wizard colors this player plays. */
|
|
colorIndex: number;
|
|
}
|
|
|
|
export interface GameView {
|
|
you: PlayerId;
|
|
phase: GameState["phase"];
|
|
winner: PlayerId | null;
|
|
winReason: "treasures" | "lastStanding" | null;
|
|
turn: TurnState;
|
|
activePlayerId: PlayerId;
|
|
/** Board with dynamic wall changes already merged in. */
|
|
board: AssembledBoard;
|
|
players: PlayerPublicView[];
|
|
yourHand: CardInstance[];
|
|
treasures: TreasureState[];
|
|
deckCount: number;
|
|
discardCount: number;
|
|
/** The discard pile, face-up as at the table (last = most recent). */
|
|
discardPile: CardInstance[];
|
|
/** Cards on the stack are face-up: the whole exchange is public. */
|
|
stack: CastStack | null;
|
|
pendingDiscard: PlayerId | null;
|
|
/** Duration spells in play (public knowledge). */
|
|
sustained: SustainedEffect[];
|
|
squareContents: Record<string, SquareContent>;
|
|
/** Cells whose contents are glued down (public: the cast was seen). */
|
|
gluedCells: Record<string, true>;
|
|
/** Safes standing open (their combination entered this turn). */
|
|
openSafes: string[];
|
|
groundObjects: Record<string, CardInstance[]>;
|
|
/** The rules revision this game was dealt under. */
|
|
deckRev: number;
|
|
doorStates: Record<string, "jammed" | "removed">;
|
|
/** Accumulated attack damage per edge (public — cracks show). */
|
|
wallDamage: Record<string, number>;
|
|
openDoorEdges: string[];
|
|
/** Door edges held open by a standing wizard. */
|
|
heldDoorEdges: string[];
|
|
/** Edges conjured into being (walls, doors, firewalls) - dispellable. */
|
|
createdEdges: string[];
|
|
/** Illusion edges YOU know are fake (creator or saw through); others see walls. */
|
|
knownIllusionEdges: string[];
|
|
/** Every illusion edge and YOUR verdict on it: untested
|
|
* shimmers, believes renders solid, mine/seesThrough are ghosts. */
|
|
illusionEdges: Record<string, "untested" | "believes" | "seesThrough" | "mine">;
|
|
creatures: CreatureState[];
|
|
/** Charges left on displayed wands (public), by card instance id. */
|
|
wandCharges: Record<string, number>;
|
|
/** Boobytrap tokens: everyone sees the four; only the caster sees which is real. */
|
|
boobytraps: { casterId: PlayerId; cells: { x: number; y: number }[]; realCell: { x: number; y: number } | null }[];
|
|
dimWarps: { a: { x: number; y: number }; b: { x: number; y: number } }[];
|
|
outOfTurnWindow: { playerId: PlayerId; kind: "interrupt" | "opportunity-fire" } | null;
|
|
/** CHAOS shield windows in progress (public: everyone sees it coming). */
|
|
chaosPending: { casterId: PlayerId; queue: PlayerId[] } | null;
|
|
/** A grab hangs while the treasure's owner decides their Ward. */
|
|
wardPending: { ownerId: PlayerId; takerId: PlayerId } | null;
|
|
/** YOUR armed ambushes. Other players' ambushes are invisible. */
|
|
yourAmbushes: AmbushState[];
|
|
/** Once the game is finished, every hand goes face-up on the table. */
|
|
revealedHands: Record<PlayerId, CardInstance[]> | null;
|
|
}
|
|
|
|
export function viewFor(state: GameState, playerId: PlayerId): GameView {
|
|
const you = state.players.find((p) => p.id === playerId);
|
|
// Illusion walls render as real walls unless this viewer knows better.
|
|
// Their locations are open knowledge (the cast is public at a table) —
|
|
// what stays personal is each player's verdict.
|
|
const base = boardView(state);
|
|
const knownIllusionEdges: string[] = [];
|
|
const illusionEdges: Record<string, "untested" | "believes" | "seesThrough" | "mine"> = {};
|
|
let edges = base.edges;
|
|
for (const [key, wall] of Object.entries(state.illusionWalls)) {
|
|
const knows = wall.createdBy === playerId || wall.belief[playerId] === "seesThrough";
|
|
illusionEdges[key] =
|
|
wall.createdBy === playerId ? "mine"
|
|
: wall.belief[playerId] ?? "untested";
|
|
if (knows) {
|
|
knownIllusionEdges.push(key);
|
|
} else {
|
|
if (edges === base.edges) edges = { ...base.edges };
|
|
edges[key] = "wall";
|
|
}
|
|
}
|
|
const board = edges === base.edges ? base : { ...base, edges };
|
|
return {
|
|
you: playerId,
|
|
phase: state.phase,
|
|
winner: state.winner,
|
|
winReason: state.winReason,
|
|
turn: state.turn,
|
|
activePlayerId: state.players[state.turn.activeIndex]!.id,
|
|
board,
|
|
players: state.players.map((p, i) => ({
|
|
id: p.id,
|
|
colorIndex: state.config.colors?.[i] ?? i,
|
|
position: p.position,
|
|
home: p.home,
|
|
life: p.life,
|
|
alive: p.alive,
|
|
handCount: p.hand.length,
|
|
carriedTreasureId: p.carriedTreasureId,
|
|
lostTurns: p.lostTurns,
|
|
extraTurns: p.extraTurns,
|
|
passWallCharges: p.passWallCharges,
|
|
displayed: p.hand.filter((c) => p.displayed.includes(c.instanceId)),
|
|
})),
|
|
yourHand: you ? [...you.hand] : [],
|
|
revealedHands: state.phase === "finished"
|
|
? Object.fromEntries(state.players.map((p) => [p.id, p.alive ? [...p.hand] : [...(p.finalHand ?? p.hand)]]))
|
|
: null,
|
|
treasures: state.treasures.map((t) => ({ ...t })),
|
|
deckCount: state.deck.length,
|
|
discardCount: state.discard.length,
|
|
discardPile: [...state.discard],
|
|
stack: state.stack,
|
|
pendingDiscard: state.pendingDiscard,
|
|
sustained: state.sustained.map((s) => ({ ...s })),
|
|
squareContents: { ...state.squareContents },
|
|
gluedCells: { ...state.gluedCells },
|
|
openSafes: [...state.openSafes],
|
|
groundObjects: Object.fromEntries(
|
|
Object.entries(state.groundObjects).map(([k, v]) => [k, [...v]]),
|
|
),
|
|
deckRev: state.config.deckRev ?? 1,
|
|
doorStates: { ...state.doorStates },
|
|
wallDamage: { ...state.wallDamage },
|
|
openDoorEdges: [...state.openDoorEdges],
|
|
heldDoorEdges: state.heldDoors.map((h) => h.key),
|
|
createdEdges: Object.keys(state.createdEdges),
|
|
knownIllusionEdges,
|
|
illusionEdges,
|
|
creatures: state.creatures.map((c) => ({ ...c, scorchedThisTurn: [...c.scorchedThisTurn] })),
|
|
wandCharges: { ...state.wandCharges },
|
|
dimWarps: state.dimWarps.map((w) => ({ a: { ...w.a }, b: { ...w.b } })),
|
|
outOfTurnWindow: state.outOfTurnWindow ? { ...state.outOfTurnWindow } : null,
|
|
chaosPending: state.chaosPending
|
|
? { casterId: state.chaosPending.casterId, queue: [...state.chaosPending.queue] }
|
|
: null,
|
|
wardPending: state.wardPending ? { ...state.wardPending } : null,
|
|
yourAmbushes: state.ambushes
|
|
.filter((a) => a.ownerId === playerId)
|
|
.map((a) => ({ ...a, numbers: [...a.numbers] })),
|
|
boobytraps: state.boobytraps.map((t) => {
|
|
const [rx, ry] = t.realKey.split(",").map(Number) as [number, number];
|
|
return {
|
|
casterId: t.casterId,
|
|
cells: t.cells.map((c) => ({ ...c })),
|
|
realCell: t.casterId === playerId ? { x: rx, y: ry } : null,
|
|
};
|
|
}),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Every cell the viewing player can see from where they stand, by the same
|
|
* line-of-sight rules the engine enforces — computed from the VIEW, so it
|
|
* reflects what this player knows (illusion walls they believe in block it).
|
|
* The basis for the client's "dim the ineligible squares" targeting aid.
|
|
*/
|
|
export function sightedCellsFor(view: GameView): Set<string> {
|
|
const out = new Set<string>();
|
|
const me = view.players.find((p) => p.id === view.you);
|
|
if (!me) return out;
|
|
const { board, blockers } = sightBasis(view);
|
|
for (const key of Object.keys(board.cells)) {
|
|
const [x, y] = key.split(",").map(Number) as [number, number];
|
|
if (sightBetween(board, me.position, { x, y }, blockers)) out.add(key);
|
|
}
|
|
// VISIONSTONE lets its bearer see through exactly one wall or door —
|
|
// any one — so a square is also sighted if removing a single edge
|
|
// reveals it, mirroring the engine's casterLos.
|
|
if (me.displayed.some((c) => c.cardId === "visionstone")) {
|
|
const unseen = Object.keys(board.cells).filter((k) => !out.has(k));
|
|
for (const edgeK of Object.keys(board.edges)) {
|
|
if (unseen.length === 0) break;
|
|
if ((board.edges[edgeK] ?? "open") === "open") continue;
|
|
const edges = { ...board.edges };
|
|
delete edges[edgeK];
|
|
const opened = { ...board, edges };
|
|
for (let i = unseen.length - 1; i >= 0; i--) {
|
|
const [x, y] = unseen[i]!.split(",").map(Number) as [number, number];
|
|
if (sightBetween(opened, me.position, { x, y }, blockers)) {
|
|
out.add(unseen[i]!);
|
|
unseen.splice(i, 1);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Sight with an AROUND THE CORNER attached: every square straightly sighted,
|
|
* plus every square visible from one of those — the caster looks to a middle
|
|
* cell and the spell turns there, mirroring the engine's bentLos.
|
|
*/
|
|
export function bentSightedCellsFor(view: GameView): Set<string> {
|
|
const out = sightedCellsFor(view);
|
|
const me = view.players.find((p) => p.id === view.you);
|
|
if (!me) return out;
|
|
const mids = [...out].map((k) => {
|
|
const [x, y] = k.split(",").map(Number) as [number, number];
|
|
return { x, y };
|
|
});
|
|
const { board, blockers } = sightBasis(view);
|
|
for (const key of Object.keys(board.cells)) {
|
|
if (out.has(key)) continue;
|
|
const [x, y] = key.split(",").map(Number) as [number, number];
|
|
const cell = { x, y };
|
|
if (mids.some((mid) => sightBetween(board, mid, cell, blockers)) ||
|
|
bentSightThroughGap(board, me.position, cell, blockers)) {
|
|
out.add(key);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** The board-as-seen and sight blockers this view's sight rules run against. */
|
|
function sightBasis(view: GameView): { board: GameView["board"]; blockers: Record<string, true> } {
|
|
// Held-open doors are open doorways to every eye. Beyond them, the
|
|
// viewer at a door's threshold may pull it open and peek (rules rev 2):
|
|
// lock removed, door unlocked this turn, or PICK LOCK / MASTER KEY in
|
|
// hand — mirroring the engine's doorsAjar.
|
|
let board = view.board;
|
|
const openKeys = new Set(view.heldDoorEdges);
|
|
const me = view.players.find((p) => p.id === view.you);
|
|
if (view.deckRev >= 2 && me?.alive) {
|
|
const carriesKey = view.yourHand.some((c) => c.cardId === "pick-lock" || c.cardId === "master-key");
|
|
for (const side of SIDES) {
|
|
const key = edgeKey(me.position, side);
|
|
if (board.edges[key] !== "door") continue;
|
|
const workable = carriesKey && view.doorStates[key] !== "jammed";
|
|
if (workable || view.doorStates[key] === "removed" || view.openDoorEdges.includes(key)) openKeys.add(key);
|
|
}
|
|
}
|
|
if (openKeys.size > 0) {
|
|
const edges = { ...board.edges };
|
|
for (const k of openKeys) delete edges[k];
|
|
board = { ...board, edges };
|
|
}
|
|
const blockers: Record<string, true> = {};
|
|
for (const [key, content] of Object.entries(view.squareContents)) {
|
|
if (LOS_BLOCKING_CONTENT[content.kind]) blockers[key] = true;
|
|
}
|
|
for (const p of view.players) {
|
|
if (p.alive && view.sustained.some((s) => s.cardId === "big-man" && s.targetId === p.id)) {
|
|
blockers[`${p.position.x},${p.position.y}`] = true;
|
|
}
|
|
}
|
|
return { board, blockers };
|
|
}
|
|
|
|
/**
|
|
* How one square sees another under this viewer's knowledge of the board
|
|
* (believed illusion walls block; held doors admit). Null when no sight
|
|
* exists — the renderer's material for drawing the line an attack traveled.
|
|
*/
|
|
export function traceSightFor(view: GameView, from: Cell, to: Cell): SightTrace | null {
|
|
const { board, blockers } = sightBasis(view);
|
|
return traceSight(board, from, to, blockers);
|
|
}
|
|
|
|
/**
|
|
* AROUND THE CORNER's bent sight, from this viewer's knowledge: the caster
|
|
* sees a middle square which sees the target — mirroring the engine's
|
|
* bentLos so a client can predict whether the modifier will land.
|
|
*/
|
|
export function bentSightFor(view: GameView, from: Cell, to: Cell): boolean {
|
|
const { board, blockers } = sightBasis(view);
|
|
if (sightBetween(board, from, to, blockers)) return true;
|
|
for (const key of Object.keys(board.cells)) {
|
|
if (blockers[key]) continue;
|
|
const [mx, my] = key.split(",").map(Number) as [number, number];
|
|
const mid = { x: mx, y: my };
|
|
if (sightBetween(board, from, mid, blockers) && sightBetween(board, mid, to, blockers)) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* The sight line behind the attack currently on the stack — the board's
|
|
* answer to "how can he even see me?". Null when nothing should draw:
|
|
* no stack, a creature's or physical attack, a non-LOS card, attacker and
|
|
* defender sharing a square, or no sight under this viewer's knowledge
|
|
* (a believed illusion wall can honestly hide the line).
|
|
*/
|
|
export function stackSightTrace(
|
|
view: GameView,
|
|
): {
|
|
from: Cell;
|
|
to: Cell;
|
|
trace: SightTrace;
|
|
bend?: { mid: Cell; trace: SightTrace };
|
|
/** VISIONSTONE: the one wall or door the bearer's sight dissolved (edge key). */
|
|
pierced?: string;
|
|
} | null {
|
|
const stack = view.stack;
|
|
if (!stack || stack.creatureId) return null;
|
|
if (!stack.attackCard || cardDef(stack.attackCard.cardId).los !== true) return null;
|
|
const a = view.players.find((p) => p.id === stack.attackerId);
|
|
const d = view.players.find((p) => p.id === stack.defenderId);
|
|
if (!a || !d) return null;
|
|
if (a.position.x === d.position.x && a.position.y === d.position.y) return null;
|
|
const trace = traceSightFor(view, a.position, d.position);
|
|
if (trace) return { from: a.position, to: d.position, trace };
|
|
// VISIONSTONE: the bearer sees through exactly one wall or door. When no
|
|
// plain line exists, find the single edge whose removal opens one and draw
|
|
// the ray straight through it, marking the pierced wall for the table.
|
|
if (a.displayed.some((c) => c.cardId === "visionstone")) {
|
|
const { board, blockers } = sightBasis(view);
|
|
for (const [key, edge] of Object.entries(board.edges)) {
|
|
if (edge === "open") continue;
|
|
const edges = { ...board.edges };
|
|
delete edges[key];
|
|
const t = traceSight({ ...board, edges }, a.position, d.position, blockers);
|
|
if (t) return { from: a.position, to: d.position, trace: t, pierced: key };
|
|
}
|
|
}
|
|
// AROUND THE CORNER: no straight line exists — find a middle square both
|
|
// ends can see and draw the sight leg by leg, so the table can audit the
|
|
// needle instead of doubting it.
|
|
if (stack.bentCorner) {
|
|
for (const key of Object.keys(view.board.cells)) {
|
|
const [mx, my] = key.split(",").map(Number) as [number, number];
|
|
const mid = { x: mx, y: my };
|
|
if ((mid.x === a.position.x && mid.y === a.position.y) ||
|
|
(mid.x === d.position.x && mid.y === d.position.y)) continue;
|
|
const leg1 = traceSightFor(view, a.position, mid);
|
|
if (!leg1) continue;
|
|
const leg2 = traceSightFor(view, mid, d.position);
|
|
if (leg2) return { from: a.position, to: d.position, trace: leg1, bend: { mid, trace: leg2 } };
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
const CREATION_CARD_IDS = new Set([
|
|
"fill-square-with-stone", "thornbush", "killer-ooze", "rosebush", "dust-cloud",
|
|
"fill-square-with-slime", "create-pit", "handful-of-tacks",
|
|
]);
|
|
const SUMMON_CARD_IDS = new Set([
|
|
"troll", "skeleton", "wraith", "fire-imp", "democratic-monster", "shadow",
|
|
]);
|
|
|
|
/**
|
|
* Squares a cell-target card can legally aim at, for the client's dimming
|
|
* aid — mirroring the engine's own validation from the viewer's knowledge.
|
|
* Null = this card's eligibility is not modeled; light everything.
|
|
*/
|
|
export function eligibleCellsFor(view: GameView, cardId: string, bentCorner = false): Set<string> | null {
|
|
const me = view.players.find((p) => p.id === view.you);
|
|
if (!me) return null;
|
|
const cells = Object.keys(view.board.cells);
|
|
const key = (x: number, y: number) => `${x},${y}`;
|
|
const sighted = bentCorner ? bentSightedCellsFor(view) : sightedCellsFor(view);
|
|
|
|
if (CREATION_CARD_IDS.has(cardId)) {
|
|
// emptySquareTarget: on the board, unoccupied by content, home, wizard,
|
|
// treasure, object, or a warp token — and in the caster's sight.
|
|
const out = new Set<string>();
|
|
for (const k of cells) {
|
|
if (view.squareContents[k]) continue;
|
|
if (view.board.homes.some((h) => key(h.x, h.y) === k)) continue;
|
|
if (view.players.some((p) => p.alive && key(p.position.x, p.position.y) === k)) continue;
|
|
if (view.treasures.some((t) => t.position && key(t.position.x, t.position.y) === k)) continue;
|
|
if ((view.groundObjects[k] ?? []).length > 0) continue;
|
|
if (view.dimWarps.some((w) => key(w.a.x, w.a.y) === k || key(w.b.x, w.b.y) === k)) continue;
|
|
if (!sighted.has(k)) continue;
|
|
out.add(k);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
if (SUMMON_CARD_IDS.has(cardId)) {
|
|
// Summon: any sighted square free of content and creatures.
|
|
const out = new Set<string>();
|
|
for (const k of cells) {
|
|
if (view.squareContents[k]) continue;
|
|
if (view.creatures.some((c) => key(c.position.x, c.position.y) === k)) continue;
|
|
if (!sighted.has(k)) continue;
|
|
out.add(k);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
if (cardId === "teleport") {
|
|
// Up to four spaces, walls and objects ignored; not into solid stone.
|
|
// BFS over existing cells, matching the engine's wallIgnoringDistance.
|
|
const out = new Set<string>();
|
|
const dist = new Map<string, number>([[key(me.position.x, me.position.y), 0]]);
|
|
const queue = [me.position];
|
|
while (queue.length > 0) {
|
|
const cur = queue.shift()!;
|
|
const d = dist.get(key(cur.x, cur.y))!;
|
|
if (d >= 4) continue;
|
|
for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]] as const) {
|
|
const n = { x: cur.x + dx, y: cur.y + dy };
|
|
const nk = key(n.x, n.y);
|
|
if (!view.board.cells[nk] || dist.has(nk)) continue;
|
|
dist.set(nk, d + 1);
|
|
queue.push(n);
|
|
if (view.squareContents[nk]?.kind !== "stone") out.add(nk);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
if (cardId === "boobytrap") {
|
|
// Tokens go anywhere on the board except solid stone — no sight needed;
|
|
// a trap you can see coming is a poor trap.
|
|
return new Set(cells.filter((k) => view.squareContents[k]?.kind !== "stone"));
|
|
}
|
|
|
|
if (cardId === "glue" || cardId === "safe") {
|
|
// Both seize a square that HOLDS something — a loose object or an
|
|
// unclaimed treasure — within the caster's sight. The safe additionally
|
|
// needs the square clear of terrain to build around its prize.
|
|
const out = new Set<string>();
|
|
for (const k of cells) {
|
|
if (cardId === "safe" && view.squareContents[k]) continue;
|
|
const hasObject =
|
|
(view.groundObjects[k] ?? []).length > 0 ||
|
|
view.treasures.some((t) => t.position && !t.carriedBy && key(t.position.x, t.position.y) === k);
|
|
if (!hasObject) continue;
|
|
if (!sighted.has(k)) continue;
|
|
out.add(k);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
if (cardId === "thumb-of-god") {
|
|
// The die is aimed by sight; where it lands after drifting is fate's.
|
|
return sighted;
|
|
}
|
|
if (cardId === "stone-to-water") {
|
|
// The cast demands sight of the stone block, so only sighted ones light.
|
|
// The card equally targets stone WALLS — edges the cell-shadow cannot
|
|
// express — so with no stone square in view, dimming would shroud the
|
|
// true targets: light everything instead.
|
|
const out = new Set(
|
|
cells.filter((k) => view.squareContents[k]?.kind === "stone" && sighted.has(k)),
|
|
);
|
|
return out.size > 0 ? out : null;
|
|
}
|
|
if (cardId === "dispel-creation") {
|
|
// Anything created — terrain or creature, whoever made it — in sight.
|
|
const out = new Set<string>();
|
|
for (const k of cells) {
|
|
const created = view.squareContents[k] != null ||
|
|
view.creatures.some((c) => key(c.position.x, c.position.y) === k);
|
|
if (created && sighted.has(k)) out.add(k);
|
|
}
|
|
return out;
|
|
}
|
|
return null;
|
|
}
|