Final wave: complete the 6th edition basic set (69/69 cards)
The hard six: AROUND THE CORNER attaches to any LOS attack and bends the sight line through one intermediate cell. BLIND victims lurch in die-rolled directions (bumping a wall costs the movement point, per the card) and their attacks fly wherever the die says — hitting whoever stands that way, or dissipating. UGLY drives every opponent in sight fleeing along shortest paths (breadth-first, die-broken ties) until they cannot see the caster. ILLUSION WALL is per-player reality: each opponent rolls 50/50 the first time it matters and the wall is real for believers forever — believers' views render it as a wall, the caster and those who saw through it get a ghostly dashed line, and Dispel Creation banishes it. ROTATE SECTOR turns a sector 90 degrees with every wall, door, wizard, treasure, object, firewall and alteration turning in place (the home star, being the exact center, never moves); RELOCATE SECTOR slides a sector anywhere that keeps all sectors adjacent, reassembling the map and recomputing wraparounds. Client: modifier attachment (Amplify/Add/Extend/Around The Corner), two-stage relocate, rotation direction toggle, dashed known-illusions. Every card in the 6th edition basic deck is now implemented. 81 tests passing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
4ab44cc535
commit
1924114b6d
+442
-17
@@ -17,6 +17,7 @@ import {
|
||||
type EdgeState,
|
||||
type Side,
|
||||
SIDES,
|
||||
assembleBoard,
|
||||
cellKey,
|
||||
edgeKey,
|
||||
hasLineOfSight,
|
||||
@@ -123,6 +124,7 @@ export interface CastParams {
|
||||
cell?: Cell;
|
||||
cardId?: string;
|
||||
points?: number;
|
||||
clockwise?: boolean;
|
||||
}
|
||||
|
||||
export interface GameConfig {
|
||||
@@ -149,6 +151,8 @@ export interface GameState {
|
||||
groundObjects: Record<string, CardInstance[]>;
|
||||
/** The last spell card each player used (for REUSE SPELL). */
|
||||
lastSpellUsed: Record<PlayerId, string>;
|
||||
/** ILLUSION WALLs by edge key: real only for those who believe. */
|
||||
illusionWalls: Record<string, { createdBy: PlayerId; belief: Record<PlayerId, "believes" | "seesThrough"> }>;
|
||||
players: PlayerState[];
|
||||
treasures: TreasureState[];
|
||||
sustained: SustainedEffect[];
|
||||
@@ -180,22 +184,105 @@ export function gameLos(state: GameState, from: Cell, to: Cell): boolean {
|
||||
return hasLineOfSight(boardView(state), from, to, blockers);
|
||||
}
|
||||
|
||||
/** Parse an edge key back into its north/west cell and side. */
|
||||
function parseEdgeKey(key: string): { cell: Cell; side: Side } {
|
||||
const [kind, coords] = key.split(":") as [string, string];
|
||||
const [x, y] = coords.split(",").map(Number) as [number, number];
|
||||
return { cell: { x, y }, side: kind === "V" ? "E" : "S" };
|
||||
}
|
||||
|
||||
/**
|
||||
* What does this player believe about an illusion wall? Rolls the 50% chance
|
||||
* lazily the first time it matters ("when they gain L.O.S. to it").
|
||||
*/
|
||||
function illusionBelief(
|
||||
state: GameState,
|
||||
events: GameEvent[],
|
||||
playerId: PlayerId,
|
||||
key: string,
|
||||
): "believes" | "seesThrough" {
|
||||
const wall = state.illusionWalls[key]!;
|
||||
if (wall.createdBy === playerId) return "seesThrough";
|
||||
const known = wall.belief[playerId];
|
||||
if (known) return known;
|
||||
const [roll, rngNext] = rollDie(state.rng);
|
||||
state.rng = rngNext;
|
||||
const result = roll <= 2 ? "seesThrough" : "believes";
|
||||
wall.belief[playerId] = result;
|
||||
events.push({ type: "illusionTested", player: playerId, edge: key, result });
|
||||
return result;
|
||||
}
|
||||
|
||||
/** The board as one player perceives it: believed illusions become walls. */
|
||||
function perceivedBoard(
|
||||
state: GameState,
|
||||
events: GameEvent[],
|
||||
viewerId: PlayerId,
|
||||
sightLine?: { from: Cell; to: Cell },
|
||||
): AssembledBoard {
|
||||
const view = boardView(state);
|
||||
const keys = Object.keys(state.illusionWalls);
|
||||
if (keys.length === 0) return view;
|
||||
const edges = { ...view.edges };
|
||||
for (const key of keys) {
|
||||
const known = state.illusionWalls[key]!.belief[viewerId];
|
||||
const isCreator = state.illusionWalls[key]!.createdBy === viewerId;
|
||||
if (isCreator || known === "seesThrough") continue;
|
||||
if (known === "believes") {
|
||||
edges[key] = "wall";
|
||||
continue;
|
||||
}
|
||||
// Untested: only roll if this sight line would actually cross it.
|
||||
if (sightLine) {
|
||||
const { cell, side } = parseEdgeKey(key);
|
||||
const test = { ...view, edges: { [key]: "wall" as const } };
|
||||
void cell; void side;
|
||||
const crossesIt = !hasLineOfSight(test, sightLine.from, sightLine.to);
|
||||
if (crossesIt) {
|
||||
if (illusionBelief(state, events, viewerId, key) === "believes") edges[key] = "wall";
|
||||
}
|
||||
} else {
|
||||
edges[key] = "wall"; // no sight context: treat as real until tested
|
||||
}
|
||||
}
|
||||
return { ...view, edges };
|
||||
}
|
||||
|
||||
/**
|
||||
* LOS for a caster: VISIONSTONE lets its holder see through exactly one
|
||||
* wall or door (of any type), at their option.
|
||||
* wall or door (of any type); believed ILLUSION WALLs block them.
|
||||
*/
|
||||
function casterLos(state: GameState, caster: PlayerState, from: Cell, to: Cell): boolean {
|
||||
if (gameLos(state, from, to)) return true;
|
||||
if (!displays(caster, "visionstone")) return false;
|
||||
// Try ignoring each single blocking edge in turn.
|
||||
const view = boardView(state);
|
||||
function casterLos(
|
||||
state: GameState,
|
||||
caster: PlayerState,
|
||||
from: Cell,
|
||||
to: Cell,
|
||||
events: GameEvent[] = [],
|
||||
): boolean {
|
||||
const board = perceivedBoard(state, events, caster.id, { from, to });
|
||||
const blockers: Record<string, true> = {};
|
||||
for (const key of Object.keys(state.squareContents)) blockers[key] = true;
|
||||
for (const key of Object.keys(view.edges)) {
|
||||
if ((view.edges[key] ?? "open") === "open") continue;
|
||||
const edges = { ...view.edges };
|
||||
if (hasLineOfSight(board, from, to, blockers)) return true;
|
||||
if (!displays(caster, "visionstone")) return false;
|
||||
for (const key of Object.keys(board.edges)) {
|
||||
if ((board.edges[key] ?? "open") === "open") continue;
|
||||
const edges = { ...board.edges };
|
||||
delete edges[key];
|
||||
if (hasLineOfSight({ ...view, edges }, from, to, blockers)) return true;
|
||||
if (hasLineOfSight({ ...board, edges }, from, to, blockers)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Bent LOS for AROUND THE CORNER: caster sees a middle cell, which sees the target. */
|
||||
function bentLos(state: GameState, caster: PlayerState, from: Cell, to: Cell, events: GameEvent[]): boolean {
|
||||
if (casterLos(state, caster, from, to, events)) return true;
|
||||
const view = boardView(state);
|
||||
for (const key of Object.keys(view.cells)) {
|
||||
const [mx, my] = key.split(",").map(Number) as [number, number];
|
||||
const mid = { x: mx, y: my };
|
||||
if (casterLos(state, caster, from, mid, events) && casterLos(state, caster, mid, to, events)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -271,6 +358,14 @@ export type GameEvent =
|
||||
| { type: "objectPickedUp"; player: PlayerId; card: CardInstance; at: Cell }
|
||||
| { type: "objectDragged"; caster: PlayerId; what: string; from: Cell; to: Cell }
|
||||
| { type: "spellReused"; player: PlayerId; card: CardInstance }
|
||||
| { type: "castAroundCorner"; caster: PlayerId }
|
||||
| { type: "moveBumped"; player: PlayerId; direction: Side }
|
||||
| { type: "attackMisdirected"; attacker: PlayerId; intended: PlayerId; rolledDirection: Side; newTarget: PlayerId | null }
|
||||
| { type: "retreatedInHorror"; player: PlayerId; from: Cell; to: Cell }
|
||||
| { type: "illusionWallCreated"; caster: PlayerId; edge: { cell: Cell; side: Side } }
|
||||
| { type: "illusionTested"; player: PlayerId; edge: string; result: "believes" | "seesThrough" }
|
||||
| { type: "sectorRotated"; caster: PlayerId; sectorIndex: number; clockwise: boolean }
|
||||
| { type: "sectorRelocated"; caster: PlayerId; sectorIndex: number; from: Cell; to: Cell }
|
||||
| { type: "wallDestroyed"; caster: PlayerId; edge: { cell: Cell; side: Side }; wasDoor: boolean }
|
||||
| { type: "doorUnlocked"; player: PlayerId; edge: { cell: Cell; side: Side }; withCardId: string }
|
||||
| { type: "doorsRelocked"; count: number }
|
||||
@@ -323,6 +418,8 @@ export type Command =
|
||||
addInstanceId?: string;
|
||||
/** EXTEND card attached (doubles duration). */
|
||||
extendInstanceId?: string;
|
||||
/** AROUND THE CORNER card attached (bends this cast's line of sight). */
|
||||
aroundCornerInstanceId?: string;
|
||||
target?: CastTarget;
|
||||
params?: CastParams;
|
||||
}
|
||||
@@ -961,6 +1058,12 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
const view = boardView(state);
|
||||
if (cmd.target?.kind === "edge") {
|
||||
const key = edgeKey(cmd.target.cell, cmd.target.side);
|
||||
if (state.illusionWalls[key]) {
|
||||
if (!losToEdge(boardView(state), caster.position, cmd.target.cell, cmd.target.side)) return "no line of sight";
|
||||
delete state.illusionWalls[key];
|
||||
events.push({ type: "creationDispelled", caster: caster.id, what: "illusion wall" });
|
||||
return null;
|
||||
}
|
||||
if (!state.createdEdges[key]) return "that is not a created thing";
|
||||
if (!losToEdge(view, caster.position, cmd.target.cell, cmd.target.side)) return "no line of sight";
|
||||
const was = view.edges[key];
|
||||
@@ -1054,6 +1157,80 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
attachSustained(ctx.state, ctx.events, "slow-death", ctx.attacker.id, ctx.defender.id, 1_000_000_000);
|
||||
},
|
||||
},
|
||||
blind: { kind: "attack", requiresLos: true, baseDamage: () => 0, sustains: true },
|
||||
"around-the-corner": {
|
||||
kind: "neutral",
|
||||
// Never cast alone — attached to an attack via aroundCornerInstanceId.
|
||||
resolve: () => "attach Around The Corner to an attack instead of casting it alone",
|
||||
},
|
||||
ugly: {
|
||||
kind: "neutral",
|
||||
// "All opponents in L.O.S. retreat as far away as necessary to avoid
|
||||
// L.O.S., along the shortest path available."
|
||||
resolve: (state, events, caster) => {
|
||||
for (const opp of state.players) {
|
||||
if (!opp.alive || opp.id === caster.id) continue;
|
||||
if (!gameLos(state, caster.position, opp.position)) continue;
|
||||
if (isLockedInPlace(state, opp.id) || sustainedOn(state, opp.id, "medusa").length > 0) continue;
|
||||
retreatFromSight(state, events, opp, caster.position);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
"illusion-wall": {
|
||||
kind: "neutral",
|
||||
resolve: (state, events, caster, cmd) => {
|
||||
if (!cmd.target || cmd.target.kind !== "edge") return "illusion wall targets a wall edge";
|
||||
const { cell, side } = cmd.target;
|
||||
const view = boardView(state);
|
||||
if (!view.cells[cellKey(cell)] || !view.cells[cellKey(neighbor(cell, side))]) {
|
||||
return "the illusion must span two spaces on the board";
|
||||
}
|
||||
const key = edgeKey(cell, side);
|
||||
if ((view.edges[key] ?? "open") !== "open") return "there is already something in that wall line";
|
||||
if (state.illusionWalls[key]) return "an illusion already shimmers there";
|
||||
if (!losToEdge(view, caster.position, cell, side)) return "no line of sight";
|
||||
state.illusionWalls[key] = { createdBy: caster.id, belief: {} };
|
||||
events.push({ type: "illusionWallCreated", caster: caster.id, edge: { cell, side } });
|
||||
return null;
|
||||
},
|
||||
},
|
||||
"rotate-sector": {
|
||||
kind: "neutral",
|
||||
// "Allows you to rotate any one sector 90 degrees."
|
||||
resolve: (state, events, caster, cmd) => {
|
||||
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);
|
||||
events.push({ type: "sectorRotated", caster: caster.id, sectorIndex: idx, clockwise: cmd.params?.clockwise ?? true });
|
||||
return null;
|
||||
},
|
||||
},
|
||||
"relocate-sector": {
|
||||
kind: "neutral",
|
||||
// "Relocate (but not rotate) any one sector to any other area, so long as
|
||||
// all sectors are still adjacent to at least one other."
|
||||
resolve: (state, events, caster, cmd) => {
|
||||
if (!cmd.target || cmd.target.kind !== "cell") return "click the destination area";
|
||||
const fromCell = cmd.params?.cell;
|
||||
if (!fromCell) return "pick the sector to move first";
|
||||
const idx = sectorIndexAt(state.board, fromCell);
|
||||
if (idx === -1) return "that is not on a sector";
|
||||
const dest: Cell = {
|
||||
x: Math.floor(cmd.target.cell.x / 5) * 5,
|
||||
y: Math.floor(cmd.target.cell.y / 5) * 5,
|
||||
};
|
||||
const fromOrigin = { ...state.board.placements[idx]!.origin };
|
||||
const problem = relocateSector(state, idx, dest);
|
||||
if (problem) return problem;
|
||||
events.push({
|
||||
type: "sectorRelocated", caster: caster.id, sectorIndex: idx,
|
||||
from: fromOrigin, to: dest,
|
||||
});
|
||||
return null;
|
||||
},
|
||||
},
|
||||
"reuse-spell": {
|
||||
kind: "neutral",
|
||||
// "You may retrieve any spell you use immediately after you use it (but
|
||||
@@ -1142,6 +1319,150 @@ function washBack(state: GameState, events: GameEvent[], p: PlayerState, dir: Si
|
||||
}
|
||||
}
|
||||
|
||||
/** UGLY: breadth-first flee to the nearest cell out of the horror's sight. */
|
||||
function retreatFromSight(state: GameState, events: GameEvent[], p: PlayerState, horror: Cell): void {
|
||||
const view = boardView(state);
|
||||
const start = p.position;
|
||||
const seen = new Set<string>([cellKey(start)]);
|
||||
let frontier: Cell[] = [start];
|
||||
const safeAt = (c: Cell) => !gameLos(state, horror, c);
|
||||
for (let depth = 0; depth < 60 && frontier.length > 0; depth++) {
|
||||
const safe = frontier.filter(safeAt);
|
||||
if (safe.length > 0) {
|
||||
// Multiple equally short refuges: the die decides.
|
||||
let choice = safe[0]!;
|
||||
if (safe.length > 1) {
|
||||
const [i, rngNext] = nextInt(state.rng, safe.length);
|
||||
state.rng = rngNext;
|
||||
choice = safe[i]!;
|
||||
}
|
||||
const from = p.position;
|
||||
p.position = choice;
|
||||
events.push({ type: "retreatedInHorror", player: p.id, from, to: choice });
|
||||
return;
|
||||
}
|
||||
const next: Cell[] = [];
|
||||
for (const c of frontier) {
|
||||
for (const side of SIDES) {
|
||||
const step = stepTarget(view, c, side);
|
||||
if (step.kind === "blocked") continue;
|
||||
if (state.squareContents[cellKey(step.to)]?.kind === "stone") continue;
|
||||
if (seen.has(cellKey(step.to))) continue;
|
||||
seen.add(cellKey(step.to));
|
||||
next.push(step.to);
|
||||
}
|
||||
}
|
||||
frontier = next;
|
||||
}
|
||||
// Nowhere to hide: they cower where they stand.
|
||||
}
|
||||
|
||||
/** Which placement contains this cell? */
|
||||
function sectorIndexAt(board: AssembledBoard, cell: Cell): number {
|
||||
return board.placements.findIndex(
|
||||
(p) => cell.x >= p.origin.x && cell.x < p.origin.x + 5 && cell.y >= p.origin.y && cell.y < p.origin.y + 5,
|
||||
);
|
||||
}
|
||||
|
||||
/** Remap every piece of coordinate-keyed state through cell/side transforms. */
|
||||
function remapState(
|
||||
state: GameState,
|
||||
inSector: (c: Cell) => boolean,
|
||||
mapCell: (c: Cell) => Cell,
|
||||
mapSide: (s: Side) => Side,
|
||||
): 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 remapRecord = <T,>(rec: Record<string, T>, mapKey: (k: string) => string): Record<string, T> =>
|
||||
Object.fromEntries(Object.entries(rec).map(([k, v]) => [mapKey(k), v]));
|
||||
const mapCellKey = (key: string): string => {
|
||||
const [x, y] = key.split(",").map(Number) as [number, number];
|
||||
const c = { x, y };
|
||||
return inSector(c) ? cellKey(mapCell(c)) : key;
|
||||
};
|
||||
|
||||
state.edgeOverrides = remapRecord(state.edgeOverrides, mapEdgeKey);
|
||||
state.createdEdges = remapRecord(state.createdEdges, mapEdgeKey);
|
||||
state.doorStates = remapRecord(state.doorStates, mapEdgeKey);
|
||||
state.illusionWalls = remapRecord(state.illusionWalls, mapEdgeKey);
|
||||
state.openDoorEdges = state.openDoorEdges.map(mapEdgeKey);
|
||||
state.squareContents = remapRecord(state.squareContents, mapCellKey);
|
||||
state.groundObjects = remapRecord(state.groundObjects, mapCellKey);
|
||||
for (const fx of state.sustained) {
|
||||
if (fx.edge) fx.edge = mapEdgeKey(fx.edge);
|
||||
}
|
||||
for (const p of state.players) {
|
||||
if (inSector(p.position)) p.position = mapCell(p.position);
|
||||
if (inSector(p.home)) p.home = mapCell(p.home);
|
||||
}
|
||||
for (const t of state.treasures) {
|
||||
if (t.position && inSector(t.position)) t.position = mapCell(t.position);
|
||||
}
|
||||
}
|
||||
|
||||
/** ROTATE SECTOR: 90 degrees, pieces and alterations turning with it. */
|
||||
function rotateSector(state: GameState, index: number, clockwise: boolean): 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;
|
||||
const mapCell = (c: Cell): Cell => {
|
||||
const lx = c.x - ox, ly = c.y - oy;
|
||||
return clockwise
|
||||
? { x: ox + (4 - ly), y: oy + lx }
|
||||
: { x: ox + ly, y: oy + (4 - lx) };
|
||||
};
|
||||
const order: Side[] = ["N", "E", "S", "W"];
|
||||
const mapSide = (s: Side): Side => order[(order.indexOf(s) + (clockwise ? 1 : 3)) % 4]!;
|
||||
|
||||
const newPlacements = state.board.placements.map((p, i) =>
|
||||
i === index
|
||||
? { ...p, rotation: (((p.rotation + (clockwise ? 90 : 270)) % 360) as 0 | 90 | 180 | 270) }
|
||||
: p,
|
||||
);
|
||||
const oldWarps = state.board.warps;
|
||||
const rebuilt = assembleBoard(newPlacements);
|
||||
rebuilt.warps = oldWarps; // openings are centered: rotation never moves them
|
||||
state.board = rebuilt;
|
||||
remapState(state, inSector, mapCell, mapSide);
|
||||
}
|
||||
|
||||
/** RELOCATE SECTOR: slide a sector to a new area; wraparounds recompute. */
|
||||
function relocateSector(state: GameState, index: number, dest: Cell): 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";
|
||||
if (dest.x < 0 || dest.y < 0) return "the sector cannot go there";
|
||||
for (let i = 0; i < placements.length; i++) {
|
||||
if (i === index) continue;
|
||||
const o = placements[i]!.origin;
|
||||
if (o.x === dest.x && o.y === dest.y) return "another sector is there";
|
||||
}
|
||||
// "All sectors still adjacent to at least one other."
|
||||
const origins = placements.map((p, i) => (i === index ? dest : p.origin));
|
||||
const adjacent = (a: Cell, b: Cell) =>
|
||||
(Math.abs(a.x - b.x) === 5 && a.y === b.y) || (Math.abs(a.y - b.y) === 5 && a.x === b.x);
|
||||
for (let i = 0; i < origins.length; i++) {
|
||||
if (!origins.some((o, j) => j !== i && adjacent(origins[i]!, o))) {
|
||||
return "every sector must stay adjacent to at least one other";
|
||||
}
|
||||
}
|
||||
|
||||
const { x: ox, y: oy } = current;
|
||||
const inSector = (c: Cell) => c.x >= ox && c.x < ox + 5 && c.y >= oy && c.y < oy + 5;
|
||||
const dx = dest.x - ox, dy = dest.y - oy;
|
||||
const mapCell = (c: Cell): Cell => ({ x: c.x + dx, y: c.y + dy });
|
||||
|
||||
const newPlacements = placements.map((p, i) => (i === index ? { ...p, origin: dest } : p));
|
||||
// "Only opposite board edges connect" after a relocation: default pairings.
|
||||
state.board = assembleBoard(newPlacements);
|
||||
remapState(state, inSector, mapCell, (s) => s);
|
||||
return null;
|
||||
}
|
||||
|
||||
/** DRAG a player straight toward the caster, stopping at walls. */
|
||||
function dragToward(state: GameState, target: PlayerState, dest: Cell): void {
|
||||
const view = boardView(state);
|
||||
@@ -1334,6 +1655,7 @@ export function createGame(config: GameConfig): { state: GameState; events: Game
|
||||
squareContents: {},
|
||||
groundObjects: {},
|
||||
lastSpellUsed: {},
|
||||
illusionWalls: {},
|
||||
players,
|
||||
treasures,
|
||||
sustained: [],
|
||||
@@ -1443,9 +1765,32 @@ function doMove(prev: GameState, direction: Side): CommandResult {
|
||||
|
||||
const state = clone(prev);
|
||||
const p = activePlayer(state);
|
||||
const events: GameEvent[] = [];
|
||||
|
||||
// BLIND: "must roll direction on D4 if attempting to move ... bumping into
|
||||
// a wall counts as one space of movement. Reroll for each movement point."
|
||||
if (sustainedOn(state, p.id, "blind").length > 0) {
|
||||
const [roll, rngNext] = rollDie(state.rng);
|
||||
state.rng = rngNext;
|
||||
direction = SIDES[roll - 1]!;
|
||||
}
|
||||
|
||||
// A believed (or untested, when bumped) ILLUSION WALL blocks the believer.
|
||||
{
|
||||
const key = edgeKey(p.position, direction);
|
||||
if (state.illusionWalls[key] &&
|
||||
illusionBelief(state, events, p.id, key) === "believes") {
|
||||
if (sustainedOn(state, p.id, "blind").length > 0) {
|
||||
state.turn.movementUsed++;
|
||||
events.push({ type: "moveBumped", player: p.id, direction });
|
||||
return { ok: true, state, events };
|
||||
}
|
||||
return err("blocked by wall");
|
||||
}
|
||||
}
|
||||
|
||||
const view = boardView(state);
|
||||
const target = stepTarget(view, p.position, direction);
|
||||
const events: GameEvent[] = [];
|
||||
const misted = isMisted(state, p.id);
|
||||
|
||||
const from = p.position;
|
||||
@@ -1455,7 +1800,14 @@ function doMove(prev: GameState, direction: Side): CommandResult {
|
||||
const key = edgeKey(p.position, direction);
|
||||
const edge = view.edges[key] ?? "open";
|
||||
const dest = neighbor(p.position, direction);
|
||||
if (!view.cells[cellKey(dest)]) return err("blocked");
|
||||
if (!view.cells[cellKey(dest)]) {
|
||||
if (sustainedOn(state, p.id, "blind").length > 0) {
|
||||
state.turn.movementUsed++;
|
||||
events.push({ type: "moveBumped", player: p.id, direction });
|
||||
return { ok: true, state, events };
|
||||
}
|
||||
return err("blocked");
|
||||
}
|
||||
if (edge === "door" && (state.doorStates[key] === "removed" || state.openDoorEdges.includes(key))) {
|
||||
p.position = dest;
|
||||
via = "step";
|
||||
@@ -1473,6 +1825,11 @@ function doMove(prev: GameState, direction: Side): CommandResult {
|
||||
p.passWallCharges--;
|
||||
p.position = dest;
|
||||
via = "passWall";
|
||||
} else if (sustainedOn(state, p.id, "blind").length > 0) {
|
||||
// Blind bump: the wasted lurch costs a movement point.
|
||||
state.turn.movementUsed++;
|
||||
events.push({ type: "moveBumped", player: p.id, direction });
|
||||
return { ok: true, state, events };
|
||||
} else {
|
||||
return err(`blocked by ${target.by}`);
|
||||
}
|
||||
@@ -1613,6 +1970,7 @@ interface CastConsumables {
|
||||
amplifies: CardInstance[];
|
||||
add: CardInstance | null;
|
||||
extend: CardInstance | null;
|
||||
aroundCorner: CardInstance | null;
|
||||
magnitude: Magnitude;
|
||||
}
|
||||
|
||||
@@ -1661,6 +2019,13 @@ function gatherModifiers(
|
||||
extend = c;
|
||||
}
|
||||
|
||||
let aroundCorner: CardInstance | null = null;
|
||||
if (cmd.aroundCornerInstanceId) {
|
||||
const c = find(cmd.aroundCornerInstanceId);
|
||||
if (!c || c.cardId !== "around-the-corner") return "AROUND THE CORNER card not in hand";
|
||||
aroundCorner = c;
|
||||
}
|
||||
|
||||
// POWERSTONE: "Add 1 to any NUMBER card played."
|
||||
const stoneBonus = displays(caster, "powerstone") ? numbers.length : 0;
|
||||
const sum = numbers.length > 0
|
||||
@@ -1673,6 +2038,7 @@ function gatherModifiers(
|
||||
amplifies,
|
||||
add,
|
||||
extend,
|
||||
aroundCorner,
|
||||
magnitude: {
|
||||
numberValue: sum,
|
||||
power: (sum ?? 1) * amp,
|
||||
@@ -1694,7 +2060,7 @@ function consumeCast(
|
||||
takeFromHand(caster, card.instanceId);
|
||||
state.discard.push(card);
|
||||
}
|
||||
for (const c of [...mods.numbers, ...mods.amplifies, mods.add, mods.extend]) {
|
||||
for (const c of [...mods.numbers, ...mods.amplifies, mods.add, mods.extend, mods.aroundCorner]) {
|
||||
if (!c) continue;
|
||||
takeFromHand(caster, c.instanceId);
|
||||
state.discard.push(c);
|
||||
@@ -1745,8 +2111,12 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
||||
}
|
||||
const statusBlock = attackBlockedByStatus(state, caster, target);
|
||||
if (statusBlock) return err(statusBlock);
|
||||
if (effect.requiresLos && !casterLos(state, caster, caster.position, target.position)) {
|
||||
return err("no line of sight to the target");
|
||||
const preEvents: GameEvent[] = [];
|
||||
if (effect.requiresLos) {
|
||||
const sighted = mods.aroundCorner
|
||||
? bentLos(state, caster, caster.position, target.position, preEvents)
|
||||
: casterLos(state, caster, caster.position, target.position, preEvents);
|
||||
if (!sighted) return err("no line of sight to the target");
|
||||
}
|
||||
// Attacking someone breaks any BUDDY pact you swore to them.
|
||||
state.sustained = state.sustained.filter(
|
||||
@@ -1765,6 +2135,59 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
||||
}
|
||||
}
|
||||
|
||||
// BLIND: casts at others fly in a rolled direction. "Misdirected spells
|
||||
// go intended distance" — if the die disagrees with the true direction,
|
||||
// the spell hits whoever lies that way, or dissipates.
|
||||
let actualTarget = target;
|
||||
if (sustainedOn(state, caster.id, "blind").length > 0 &&
|
||||
cellKey(target.position) !== cellKey(caster.position)) {
|
||||
const dx = target.position.x - caster.position.x;
|
||||
const dy = target.position.y - caster.position.y;
|
||||
const intended: Side =
|
||||
Math.abs(dx) >= Math.abs(dy) && dx !== 0 ? (dx > 0 ? "E" : "W") : dy > 0 ? "S" : "N";
|
||||
const [roll, rngNext] = rollDie(state.rng);
|
||||
state.rng = rngNext;
|
||||
const rolled = SIDES[roll - 1]!;
|
||||
if (rolled !== intended) {
|
||||
const along = state.players.find((p) => {
|
||||
if (!p.alive || p.id === caster.id) return false;
|
||||
const px = p.position.x - caster.position.x;
|
||||
const py = p.position.y - caster.position.y;
|
||||
const dirOf: Side | null =
|
||||
Math.abs(px) >= Math.abs(py) && px !== 0 ? (px > 0 ? "E" : "W") : py !== 0 ? (py > 0 ? "S" : "N") : null;
|
||||
return dirOf === rolled && casterLos(state, caster, caster.position, p.position);
|
||||
});
|
||||
consumeCast(state, caster, inHand, mods, effect.keepInHand ?? false);
|
||||
state.turn.attackUsed = true;
|
||||
state.lastSpellUsed[caster.id] = inHand.cardId;
|
||||
const missEvents: GameEvent[] = [...preEvents, {
|
||||
type: "attackMisdirected", attacker: caster.id, intended: target.id,
|
||||
rolledDirection: rolled, newTarget: along?.id ?? null,
|
||||
}];
|
||||
if (!along) return { ok: true, state, events: missEvents }; // dissipates
|
||||
actualTarget = along;
|
||||
state.stack = {
|
||||
attackerId: caster.id,
|
||||
defenderId: along.id,
|
||||
attackCard: inHand,
|
||||
numberValue: mods.magnitude.numberValue,
|
||||
amplifyFactor: 2 ** mods.amplifies.length,
|
||||
extendFactor: mods.extend ? 2 : 1,
|
||||
params: cmd.params ?? null,
|
||||
kind: effect.physical ? "physical" : "spell",
|
||||
counters: [],
|
||||
waitingOn: along.id,
|
||||
};
|
||||
missEvents.push({
|
||||
type: "spellCast", caster: caster.id, card: inHand, cardId: inHand.cardId,
|
||||
numberCards: mods.numbers, numberValue: mods.magnitude.numberValue,
|
||||
from: caster.position, target: along.id, targetCell: along.position,
|
||||
});
|
||||
return { ok: true, state, events: missEvents };
|
||||
}
|
||||
}
|
||||
void actualTarget;
|
||||
|
||||
consumeCast(state, caster, inHand, mods, effect.keepInHand ?? false);
|
||||
state.turn.attackUsed = true;
|
||||
state.stack = {
|
||||
@@ -1780,7 +2203,9 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
||||
waitingOn: target.id,
|
||||
};
|
||||
state.lastSpellUsed[caster.id] = inHand.cardId;
|
||||
const events: GameEvent[] = [{
|
||||
const events: GameEvent[] = [...preEvents];
|
||||
if (mods.aroundCorner) events.push({ type: "castAroundCorner", caster: caster.id });
|
||||
events.push({
|
||||
type: "spellCast",
|
||||
caster: caster.id,
|
||||
card: inHand,
|
||||
@@ -1790,7 +2215,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
||||
from: caster.position,
|
||||
target: target.id,
|
||||
targetCell: target.position,
|
||||
}];
|
||||
});
|
||||
if (effect.keepInHand) {
|
||||
events.push({ type: "cardDisplayed", player: caster.id, card: inHand });
|
||||
}
|
||||
|
||||
@@ -50,17 +50,33 @@ export interface GameView {
|
||||
groundObjects: Record<string, CardInstance[]>;
|
||||
doorStates: Record<string, "jammed" | "removed">;
|
||||
openDoorEdges: string[];
|
||||
/** Illusion edges YOU know are fake (creator or saw through); others see walls. */
|
||||
knownIllusionEdges: string[];
|
||||
}
|
||||
|
||||
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.
|
||||
const base = boardView(state);
|
||||
const knownIllusionEdges: string[] = [];
|
||||
let edges = base.edges;
|
||||
for (const [key, wall] of Object.entries(state.illusionWalls)) {
|
||||
const knows = wall.createdBy === playerId || wall.belief[playerId] === "seesThrough";
|
||||
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,
|
||||
turn: state.turn,
|
||||
activePlayerId: state.players[state.turn.activeIndex]!.id,
|
||||
board: boardView(state),
|
||||
board,
|
||||
players: state.players.map((p) => ({
|
||||
id: p.id,
|
||||
position: p.position,
|
||||
@@ -86,5 +102,6 @@ export function viewFor(state: GameState, playerId: PlayerId): GameView {
|
||||
),
|
||||
doorStates: { ...state.doorStates },
|
||||
openDoorEdges: [...state.openDoorEdges],
|
||||
knownIllusionEdges,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user