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:
Eric Wagoner
2026-08-15 20:29:09 -04:00
co-authored by Claude Fable 5
parent 4ab44cc535
commit 1924114b6d
7 changed files with 844 additions and 21 deletions
+442 -17
View File
@@ -17,6 +17,7 @@ import {
type EdgeState, type EdgeState,
type Side, type Side,
SIDES, SIDES,
assembleBoard,
cellKey, cellKey,
edgeKey, edgeKey,
hasLineOfSight, hasLineOfSight,
@@ -123,6 +124,7 @@ export interface CastParams {
cell?: Cell; cell?: Cell;
cardId?: string; cardId?: string;
points?: number; points?: number;
clockwise?: boolean;
} }
export interface GameConfig { export interface GameConfig {
@@ -149,6 +151,8 @@ export interface GameState {
groundObjects: Record<string, CardInstance[]>; groundObjects: Record<string, CardInstance[]>;
/** The last spell card each player used (for REUSE SPELL). */ /** The last spell card each player used (for REUSE SPELL). */
lastSpellUsed: Record<PlayerId, string>; 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[]; players: PlayerState[];
treasures: TreasureState[]; treasures: TreasureState[];
sustained: SustainedEffect[]; sustained: SustainedEffect[];
@@ -180,22 +184,105 @@ export function gameLos(state: GameState, from: Cell, to: Cell): boolean {
return hasLineOfSight(boardView(state), from, to, blockers); 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 * 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 { function casterLos(
if (gameLos(state, from, to)) return true; state: GameState,
if (!displays(caster, "visionstone")) return false; caster: PlayerState,
// Try ignoring each single blocking edge in turn. from: Cell,
const view = boardView(state); to: Cell,
events: GameEvent[] = [],
): boolean {
const board = perceivedBoard(state, events, caster.id, { from, to });
const blockers: Record<string, true> = {}; const blockers: Record<string, true> = {};
for (const key of Object.keys(state.squareContents)) blockers[key] = true; for (const key of Object.keys(state.squareContents)) blockers[key] = true;
for (const key of Object.keys(view.edges)) { if (hasLineOfSight(board, from, to, blockers)) return true;
if ((view.edges[key] ?? "open") === "open") continue; if (!displays(caster, "visionstone")) return false;
const edges = { ...view.edges }; for (const key of Object.keys(board.edges)) {
if ((board.edges[key] ?? "open") === "open") continue;
const edges = { ...board.edges };
delete edges[key]; 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; return false;
} }
@@ -271,6 +358,14 @@ export type GameEvent =
| { type: "objectPickedUp"; player: PlayerId; card: CardInstance; at: Cell } | { type: "objectPickedUp"; player: PlayerId; card: CardInstance; at: Cell }
| { type: "objectDragged"; caster: PlayerId; what: string; from: Cell; to: Cell } | { type: "objectDragged"; caster: PlayerId; what: string; from: Cell; to: Cell }
| { type: "spellReused"; player: PlayerId; card: CardInstance } | { 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: "wallDestroyed"; caster: PlayerId; edge: { cell: Cell; side: Side }; wasDoor: boolean }
| { type: "doorUnlocked"; player: PlayerId; edge: { cell: Cell; side: Side }; withCardId: string } | { type: "doorUnlocked"; player: PlayerId; edge: { cell: Cell; side: Side }; withCardId: string }
| { type: "doorsRelocked"; count: number } | { type: "doorsRelocked"; count: number }
@@ -323,6 +418,8 @@ export type Command =
addInstanceId?: string; addInstanceId?: string;
/** EXTEND card attached (doubles duration). */ /** EXTEND card attached (doubles duration). */
extendInstanceId?: string; extendInstanceId?: string;
/** AROUND THE CORNER card attached (bends this cast's line of sight). */
aroundCornerInstanceId?: string;
target?: CastTarget; target?: CastTarget;
params?: CastParams; params?: CastParams;
} }
@@ -961,6 +1058,12 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
const view = boardView(state); const view = boardView(state);
if (cmd.target?.kind === "edge") { if (cmd.target?.kind === "edge") {
const key = edgeKey(cmd.target.cell, cmd.target.side); 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 (!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"; if (!losToEdge(view, caster.position, cmd.target.cell, cmd.target.side)) return "no line of sight";
const was = view.edges[key]; 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); 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": { "reuse-spell": {
kind: "neutral", kind: "neutral",
// "You may retrieve any spell you use immediately after you use it (but // "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. */ /** DRAG a player straight toward the caster, stopping at walls. */
function dragToward(state: GameState, target: PlayerState, dest: Cell): void { function dragToward(state: GameState, target: PlayerState, dest: Cell): void {
const view = boardView(state); const view = boardView(state);
@@ -1334,6 +1655,7 @@ export function createGame(config: GameConfig): { state: GameState; events: Game
squareContents: {}, squareContents: {},
groundObjects: {}, groundObjects: {},
lastSpellUsed: {}, lastSpellUsed: {},
illusionWalls: {},
players, players,
treasures, treasures,
sustained: [], sustained: [],
@@ -1443,9 +1765,32 @@ function doMove(prev: GameState, direction: Side): CommandResult {
const state = clone(prev); const state = clone(prev);
const p = activePlayer(state); 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 view = boardView(state);
const target = stepTarget(view, p.position, direction); const target = stepTarget(view, p.position, direction);
const events: GameEvent[] = [];
const misted = isMisted(state, p.id); const misted = isMisted(state, p.id);
const from = p.position; const from = p.position;
@@ -1455,7 +1800,14 @@ function doMove(prev: GameState, direction: Side): CommandResult {
const key = edgeKey(p.position, direction); const key = edgeKey(p.position, direction);
const edge = view.edges[key] ?? "open"; const edge = view.edges[key] ?? "open";
const dest = neighbor(p.position, direction); 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))) { if (edge === "door" && (state.doorStates[key] === "removed" || state.openDoorEdges.includes(key))) {
p.position = dest; p.position = dest;
via = "step"; via = "step";
@@ -1473,6 +1825,11 @@ function doMove(prev: GameState, direction: Side): CommandResult {
p.passWallCharges--; p.passWallCharges--;
p.position = dest; p.position = dest;
via = "passWall"; 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 { } else {
return err(`blocked by ${target.by}`); return err(`blocked by ${target.by}`);
} }
@@ -1613,6 +1970,7 @@ interface CastConsumables {
amplifies: CardInstance[]; amplifies: CardInstance[];
add: CardInstance | null; add: CardInstance | null;
extend: CardInstance | null; extend: CardInstance | null;
aroundCorner: CardInstance | null;
magnitude: Magnitude; magnitude: Magnitude;
} }
@@ -1661,6 +2019,13 @@ function gatherModifiers(
extend = c; 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." // POWERSTONE: "Add 1 to any NUMBER card played."
const stoneBonus = displays(caster, "powerstone") ? numbers.length : 0; const stoneBonus = displays(caster, "powerstone") ? numbers.length : 0;
const sum = numbers.length > 0 const sum = numbers.length > 0
@@ -1673,6 +2038,7 @@ function gatherModifiers(
amplifies, amplifies,
add, add,
extend, extend,
aroundCorner,
magnitude: { magnitude: {
numberValue: sum, numberValue: sum,
power: (sum ?? 1) * amp, power: (sum ?? 1) * amp,
@@ -1694,7 +2060,7 @@ function consumeCast(
takeFromHand(caster, card.instanceId); takeFromHand(caster, card.instanceId);
state.discard.push(card); 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; if (!c) continue;
takeFromHand(caster, c.instanceId); takeFromHand(caster, c.instanceId);
state.discard.push(c); state.discard.push(c);
@@ -1745,8 +2111,12 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
} }
const statusBlock = attackBlockedByStatus(state, caster, target); const statusBlock = attackBlockedByStatus(state, caster, target);
if (statusBlock) return err(statusBlock); if (statusBlock) return err(statusBlock);
if (effect.requiresLos && !casterLos(state, caster, caster.position, target.position)) { const preEvents: GameEvent[] = [];
return err("no line of sight to the target"); 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. // Attacking someone breaks any BUDDY pact you swore to them.
state.sustained = state.sustained.filter( 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); consumeCast(state, caster, inHand, mods, effect.keepInHand ?? false);
state.turn.attackUsed = true; state.turn.attackUsed = true;
state.stack = { state.stack = {
@@ -1780,7 +2203,9 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
waitingOn: target.id, waitingOn: target.id,
}; };
state.lastSpellUsed[caster.id] = inHand.cardId; 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", type: "spellCast",
caster: caster.id, caster: caster.id,
card: inHand, card: inHand,
@@ -1790,7 +2215,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
from: caster.position, from: caster.position,
target: target.id, target: target.id,
targetCell: target.position, targetCell: target.position,
}]; });
if (effect.keepInHand) { if (effect.keepInHand) {
events.push({ type: "cardDisplayed", player: caster.id, card: inHand }); events.push({ type: "cardDisplayed", player: caster.id, card: inHand });
} }
+18 -1
View File
@@ -50,17 +50,33 @@ export interface GameView {
groundObjects: Record<string, CardInstance[]>; groundObjects: Record<string, CardInstance[]>;
doorStates: Record<string, "jammed" | "removed">; doorStates: Record<string, "jammed" | "removed">;
openDoorEdges: string[]; 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 { export function viewFor(state: GameState, playerId: PlayerId): GameView {
const you = state.players.find((p) => p.id === playerId); 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 { return {
you: playerId, you: playerId,
phase: state.phase, phase: state.phase,
winner: state.winner, winner: state.winner,
turn: state.turn, turn: state.turn,
activePlayerId: state.players[state.turn.activeIndex]!.id, activePlayerId: state.players[state.turn.activeIndex]!.id,
board: boardView(state), board,
players: state.players.map((p) => ({ players: state.players.map((p) => ({
id: p.id, id: p.id,
position: p.position, position: p.position,
@@ -86,5 +102,6 @@ export function viewFor(state: GameState, playerId: PlayerId): GameView {
), ),
doorStates: { ...state.doorStates }, doorStates: { ...state.doorStates },
openDoorEdges: [...state.openDoorEdges], openDoorEdges: [...state.openDoorEdges],
knownIllusionEdges,
}; };
} }
+1 -1
View File
@@ -292,7 +292,7 @@ describe("stack discipline", () => {
it("unimplemented cards refuse to cast with a clear error", () => { it("unimplemented cards refuse to cast with a clear error", () => {
let { state } = newGame(); let { state } = newGame();
const caster = activePlayer(state); const caster = activePlayer(state);
const card = giveCard(state, caster.id, "ugly"); const card = giveCard(state, caster.id, "chaos"); // expansion1, unimplemented
const result = applyCommand(state, caster.id, { type: "cast", instanceId: card.instanceId }); const result = applyCommand(state, caster.id, { type: "cast", instanceId: card.instanceId });
expect(result.ok).toBe(false); expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toMatch(/not implemented/); if (!result.ok) expect(result.error).toMatch(/not implemented/);
+295
View File
@@ -0,0 +1,295 @@
import { describe, expect, it } from "vitest";
import {
applyCommand,
activePlayer,
createGame,
boardView,
gameLos,
sustainedOn,
viewFor,
type Command,
type GameState,
type PlayerId,
} from "../src";
import { cellKey, edgeKey, neighbor, SIDES, stepTarget, type Cell, type Side } from "../src/board";
import type { CardInstance } from "../src/cards";
function newGame(seed = 42) {
return createGame({ playerIds: ["alice", "bob"], seed, sets: ["basic"] });
}
function must(state: GameState, player: PlayerId, command: Command): GameState {
const result = applyCommand(state, player, command);
if (!result.ok) throw new Error(`command failed: ${result.error}`);
return result.state;
}
function giveCard(state: GameState, playerId: PlayerId, cardId: string, tag = "T", slot = 0): CardInstance {
const p = state.players.find((p) => p.id === playerId)!;
const instance = { instanceId: `${cardId}#${tag}`, cardId };
p.hand[slot] = instance;
return instance;
}
function toRound2(state: GameState): GameState {
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
return state;
}
function emptyNeighborCell(state: GameState, of: Cell): { cell: Cell; side: Side } {
const view = boardView(state);
for (const side of SIDES) {
const t = stepTarget(view, of, side);
if (t.kind !== "step") continue;
const key = cellKey(t.to);
if (view.homes.some((h) => cellKey(h) === key)) continue;
if (state.treasures.some((tr) => tr.position && cellKey(tr.position) === key)) continue;
if (state.players.some((p) => cellKey(p.position) === key)) continue;
return { cell: t.to, side };
}
throw new Error("no empty neighbor");
}
describe("around the corner", () => {
it("bends line of sight past a wall that blocks a straight cast", () => {
let { state } = newGame();
state = toRound2(state);
const attacker = activePlayer(state);
const defender = state.players.find((p) => p.id !== attacker.id)!;
// Find a hidden cell (no direct LOS) that IS reachable with one bend.
const view = boardView(state);
let hidden: Cell | null = null;
outer: for (const key of Object.keys(view.cells)) {
const [x, y] = key.split(",").map(Number) as [number, number];
const cell = { x, y };
if (gameLos(state, attacker.position, cell)) continue;
for (const midKey of Object.keys(view.cells)) {
const [mx, my] = midKey.split(",").map(Number) as [number, number];
const mid = { x: mx, y: my };
if (gameLos(state, attacker.position, mid) && gameLos(state, mid, cell)) {
hidden = cell;
break outer;
}
}
}
expect(hidden).not.toBeNull();
defender.position = hidden!;
const fb = giveCard(state, attacker.id, "fireball", "F", 0);
giveCard(state, attacker.id, "around-the-corner", "ATC", 1);
// Straight cast: refused.
const straight = applyCommand(state, attacker.id, {
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender.id },
});
expect(straight.ok).toBe(false);
// Bent cast: lands.
state = must(state, attacker.id, {
type: "cast", instanceId: fb.instanceId, aroundCornerInstanceId: "around-the-corner#ATC",
target: { kind: "player", playerId: defender.id },
});
state = must(state, defender.id, { type: "pass" });
expect(state.players.find((p) => p.id === defender.id)!.life).toBe(10);
});
});
describe("blind", () => {
it("blinded movement lurches in rolled directions and bumps cost movement", () => {
let { state } = newGame(7);
state = toRound2(state);
const attacker = activePlayer(state);
const defender = state.players.find((p) => p.id !== attacker.id)!;
defender.position = { ...attacker.position };
const bl = giveCard(state, attacker.id, "blind");
giveCard(state, attacker.id, "number-3", "N", 1);
state = must(state, attacker.id, {
type: "cast", instanceId: bl.instanceId, numberInstanceIds: ["number-3#N"],
target: { kind: "player", playerId: defender.id },
});
state = must(state, defender.id, { type: "pass" });
expect(sustainedOn(state, defender.id, "blind").length).toBe(1);
state = must(state, attacker.id, { type: "endTurn", draw: 0 });
// Every move consumes exactly one movement point whether it lands or bumps.
const before = state.turn.movementUsed;
state = must(state, defender.id, { type: "move", direction: "N" });
expect(state.turn.movementUsed).toBe(before + 1);
});
it("blinded casts fly in a rolled direction and can miss entirely", () => {
// Across seeds: a blinded caster aiming at a real target sometimes hits
// (roll matches), usually misses (attack dissipates, card still spent).
let hits = 0, misses = 0;
for (let seed = 1; seed <= 10; seed++) {
let { state } = newGame(seed);
state = toRound2(state);
const attacker = activePlayer(state);
const defender = state.players.find((p) => p.id !== attacker.id)!;
// Stand the defender one cell east-ish with LOS.
const spot = emptyNeighborCell(state, attacker.position);
defender.position = spot.cell;
state.sustained.push({
id: "fx-test", cardId: "blind", casterId: defender.id,
targetId: attacker.id, remainingTurns: 3, data: {},
});
const fb = giveCard(state, attacker.id, "fireball", "F", 0);
const result = applyCommand(state, attacker.id, {
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender.id },
});
expect(result.ok).toBe(true);
if (!result.ok) continue;
const dLife = result.state.players.find((p) => p.id === defender.id)!;
if (result.state.stack) {
hits++; // the roll matched: attack proceeds normally
} else {
misses++;
expect(result.state.turn.attackUsed).toBe(true); // card spent anyway
}
void dLife;
}
expect(hits + misses).toBe(10);
expect(misses).toBeGreaterThan(0);
});
});
describe("ugly", () => {
it("drives every visible opponent out of line of sight", () => {
let { state } = newGame();
const caster = activePlayer(state);
const opp = state.players.find((p) => p.id !== caster.id)!;
opp.position = { ...caster.position }; // same square: definitely in LOS
const ug = giveCard(state, caster.id, "ugly");
state = must(state, caster.id, { type: "cast", instanceId: ug.instanceId });
const after = state.players.find((p) => p.id === opp.id)!;
expect(gameLos(state, state.players.find((p) => p.id === caster.id)!.position, after.position)).toBe(false);
});
});
describe("illusion wall", () => {
function setupIllusion(seed = 42) {
let { state } = newGame(seed);
const caster = activePlayer(state);
const spot = emptyNeighborCell(state, caster.position);
const key = edgeKey(caster.position, spot.side);
const iw = giveCard(state, caster.id, "illusion-wall");
state = must(state, caster.id, {
type: "cast", instanceId: iw.instanceId,
target: { kind: "edge", cell: caster.position, side: spot.side },
});
return { state, caster: caster.id, key, side: spot.side, cell: spot.cell };
}
it("the caster walks through their own illusion; others see a wall", () => {
const { state, caster, key, side } = setupIllusion();
// Caster's view knows it's fake; the opponent's view shows a wall.
const casterView = viewFor(state, caster);
expect(casterView.knownIllusionEdges).toContain(key);
const other = state.players.find((p) => p.id !== caster)!.id;
const otherView = viewFor(state, other);
expect(otherView.board.edges[key]).toBe("wall");
// And the caster can move through it freely.
const after = applyCommand(state, caster, { type: "move", direction: side });
expect(after.ok).toBe(true);
});
it("opponents test the illusion when they bump it — some see through, some believe", () => {
let believed = 0, sawThrough = 0;
for (let seed = 1; seed <= 12; seed++) {
const { state, caster, side } = setupIllusion(seed);
const other = state.players.find((p) => p.id !== caster)!;
other.position = { ...state.players.find((p) => p.id === caster)!.position };
let s = must(state, caster, { type: "endTurn", draw: 0 });
const result = applyCommand(s, other.id, { type: "move", direction: side });
if (result.ok) sawThrough++;
else believed++;
}
expect(believed + sawThrough).toBe(12);
expect(believed).toBeGreaterThan(0);
expect(sawThrough).toBeGreaterThan(0);
});
it("dispel creation removes an illusion wall", () => {
let { state, caster, key, side } = setupIllusion();
const me = state.players.find((p) => p.id === caster)!;
const dc = giveCard(state, caster, "dispel-creation", "DC", 1);
state = must(state, caster, {
type: "cast", instanceId: dc.instanceId,
target: { kind: "edge", cell: me.position, side },
});
expect(state.illusionWalls[key]).toBeUndefined();
});
});
describe("sector manipulation", () => {
it("rotate sector turns walls and pieces together; the home stays centered", () => {
let { state } = newGame();
const me = activePlayer(state);
const idx = state.board.placements.findIndex(
(p) => me.position.x >= p.origin.x && me.position.x < p.origin.x + 5 &&
me.position.y >= p.origin.y && me.position.y < p.origin.y + 5,
);
const wallsBefore = Object.values(boardView(state).edges).filter((e) => e === "wall").length;
const homeBefore = { ...me.home };
const treasuresBefore = state.treasures.filter((t) => t.owner === me.id).map((t) => ({ ...t.position! }));
const rs = giveCard(state, me.id, "rotate-sector");
state = must(state, me.id, {
type: "cast", instanceId: rs.instanceId,
target: { kind: "cell", cell: me.position }, params: { clockwise: true },
});
const after = state.players.find((p) => p.id === me.id)!;
// Home star is the exact center: rotation cannot move it.
expect(cellKey(after.home)).toBe(cellKey(homeBefore));
// The wizard stood on the home (center) at setup? They may have been
// anywhere; either way they remain inside the same sector.
const p = state.board.placements[idx]!;
expect(after.position.x).toBeGreaterThanOrEqual(p.origin.x);
expect(after.position.x).toBeLessThan(p.origin.x + 5);
// Wall count is invariant under rotation.
const wallsAfter = Object.values(boardView(state).edges).filter((e) => e === "wall").length;
expect(wallsAfter).toBe(wallsBefore);
// Treasures rotated with the sector (diagonal flips to the other diagonal
// or stays, but they remain on the board inside the sector).
for (const t of state.treasures.filter((t) => t.owner === after.id)) {
expect(state.board.cells[cellKey(t.position!)]).toBe(true);
}
void treasuresBefore;
});
it("relocate sector slides everything and keeps adjacency", () => {
let { state } = newGame(); // 2 players: 5x10 column, sectors at y=0 and y=5
const me = activePlayer(state);
const idx = state.board.placements.findIndex(
(p) => me.position.y >= p.origin.y && me.position.y < p.origin.y + 5,
);
const myOrigin = state.board.placements[idx]!.origin;
const otherIdx = idx === 0 ? 1 : 0;
const otherOrigin = state.board.placements[otherIdx]!.origin;
// Move my sector to the EAST side of the other sector (still adjacent).
const dest = { x: otherOrigin.x + 5, y: otherOrigin.y };
const posBefore = { ...me.position };
const rel = giveCard(state, me.id, "relocate-sector");
state = must(state, me.id, {
type: "cast", instanceId: rel.instanceId,
target: { kind: "cell", cell: dest }, params: { cell: me.position },
});
const after = state.players.find((p) => p.id === me.id)!;
const dx = dest.x - myOrigin.x, dy = dest.y - myOrigin.y;
expect(after.position).toEqual({ x: posBefore.x + dx, y: posBefore.y + dy });
expect(cellKey(after.home)).toBe(cellKey({ x: after.home.x, y: after.home.y }));
expect(state.board.placements[idx]!.origin).toEqual(dest);
// The map reassembled: every treasure/wizard cell exists on the new board.
for (const t of state.treasures) {
if (t.position) expect(state.board.cells[cellKey(t.position)]).toBe(true);
}
// An island move is refused.
const rel2 = giveCard(state, me.id, "relocate-sector", "R2");
const refused = applyCommand(state, me.id, {
type: "cast", instanceId: rel2.instanceId,
target: { kind: "cell", cell: { x: 40, y: 40 } }, params: { cell: after.position },
});
expect(refused.ok).toBe(false);
});
});
+63 -2
View File
@@ -22,6 +22,12 @@
let nameInput = $state(""); let nameInput = $state("");
/** power-run points. */ /** power-run points. */
let runPoints = $state(1); let runPoints = $state(1);
/** Modifier cards attached to the pending cast. */
let attachedMods = $state<CardInstance[]>([]);
/** relocate-sector: the sector picked up, awaiting its destination. */
let pendingSectorFrom = $state<{ x: number; y: number } | null>(null);
/** rotate-sector direction. */
let rotateCW = $state(true);
/** Cards marked for discard. */ /** Cards marked for discard. */
let discardSelection = $state<Set<string>>(new Set()); let discardSelection = $state<Set<string>>(new Set());
@@ -37,9 +43,11 @@
]); ]);
const CELL_CARDS = new Set([ const CELL_CARDS = new Set([
"teleport", "fill-square-with-stone", "thornbush", "dispel-creation", "drag", "teleport", "fill-square-with-stone", "thornbush", "dispel-creation", "drag",
"rotate-sector", "relocate-sector",
]); ]);
const MODIFIER_CARDS = new Set(["amplify", "add", "extend", "around-the-corner"]);
const SELF_CARDS = new Set([ const SELF_CARDS = new Set([
"speed", "invisible", "shrink", "mist-body", "pass-through-wall", "reuse-spell", "invisible", "shrink", "mist-body",
]); ]);
const NAMED_CARDS = new Set(["card-erasure", "drop-object"]); const NAMED_CARDS = new Set(["card-erasure", "drop-object"]);
const edgeSelectMode = $derived(selectedCard != null && EDGE_CARDS.has(selectedCard.cardId)); const edgeSelectMode = $derived(selectedCard != null && EDGE_CARDS.has(selectedCard.cardId));
@@ -51,11 +59,23 @@
function clearSelection() { function clearSelection() {
selectedCard = null; selectedCard = null;
attachedNumber = null; attachedNumber = null;
attachedMods = [];
wbDamage = 0; wbDamage = 0;
pendingCellFor = null; pendingCellFor = null;
pendingSectorFrom = null;
nameInput = ""; nameInput = "";
} }
/** Fill the modifier fields of a cast command from the attachments. */
function applyMods(cmd: Parameters<typeof net.command>[0] & { type: "cast" }) {
for (const m of attachedMods) {
if (m.cardId === "amplify") (cmd.amplifyInstanceIds ??= []).push(m.instanceId);
else if (m.cardId === "add") cmd.addInstanceId = m.instanceId;
else if (m.cardId === "extend") cmd.extendInstanceId = m.instanceId;
else if (m.cardId === "around-the-corner") cmd.aroundCornerInstanceId = m.instanceId;
}
}
/** Map a typed card name to its id (case-insensitive). */ /** Map a typed card name to its id (case-insensitive). */
function nameToCardId(name: string): string | null { function nameToCardId(name: string): string | null {
const wanted = name.trim().toLowerCase(); const wanted = name.trim().toLowerCase();
@@ -85,6 +105,12 @@
wbDamage = numberTotal; wbDamage = numberTotal;
return; return;
} }
if (selectedCard && MODIFIER_CARDS.has(card.cardId) && !MODIFIER_CARDS.has(selectedCard.cardId)) {
attachedMods = attachedMods.some((m) => m.instanceId === card.instanceId)
? attachedMods.filter((m) => m.instanceId !== card.instanceId)
: [...attachedMods, card];
return;
}
if (selectedCard?.instanceId === card.instanceId) { if (selectedCard?.instanceId === card.instanceId) {
clearSelection(); clearSelection();
return; return;
@@ -100,7 +126,7 @@
// Instant untargeted spells (and stone displays) cast immediately; // Instant untargeted spells (and stone displays) cast immediately;
// duration self-spells wait so a number card can be attached. // duration self-spells wait so a number card can be attached.
const INSTANT = new Set([ const INSTANT = new Set([
"speed", "pass-through-wall", "reuse-spell", "speed", "pass-through-wall", "reuse-spell", "ugly",
"bloodstone", "brainstone", "powerstone", "shadowstone", "bloodstone", "brainstone", "powerstone", "shadowstone",
"shieldstone", "soulstone", "speedstone", "visionstone", "shieldstone", "soulstone", "speedstone", "visionstone",
]); ]);
@@ -136,6 +162,26 @@
clearSelection(); clearSelection();
return; return;
} }
if (selectedCard?.cardId === "relocate-sector") {
if (!pendingSectorFrom) {
pendingSectorFrom = cell; // first click: the sector to move
return;
}
net.command({
type: "cast", instanceId: selectedCard.instanceId,
target: { kind: "cell", cell }, params: { cell: pendingSectorFrom },
});
clearSelection();
return;
}
if (selectedCard?.cardId === "rotate-sector") {
net.command({
type: "cast", instanceId: selectedCard.instanceId,
target: { kind: "cell", cell }, params: { clockwise: rotateCW },
});
clearSelection();
return;
}
if (selectedCard && CELL_CARDS.has(selectedCard.cardId)) { if (selectedCard && CELL_CARDS.has(selectedCard.cardId)) {
net.command({ net.command({
type: "cast", instanceId: selectedCard.instanceId, type: "cast", instanceId: selectedCard.instanceId,
@@ -197,6 +243,7 @@
target: { kind: "player", playerId }, target: { kind: "player", playerId },
}; };
if (attachedNumber) cmd.numberInstanceIds = [attachedNumber.instanceId]; if (attachedNumber) cmd.numberInstanceIds = [attachedNumber.instanceId];
applyMods(cmd);
if (selectedCard.cardId === "waterbolt") { if (selectedCard.cardId === "waterbolt") {
cmd.params = { damage: wbDamage, knockback: numberTotal - wbDamage }; cmd.params = { damage: wbDamage, knockback: numberTotal - wbDamage };
} }
@@ -338,6 +385,20 @@
<label>card name <input bind:value={nameInput} placeholder="e.g. Fireball or treasure" /></label> <label>card name <input bind:value={nameInput} placeholder="e.g. Fireball or treasure" /></label>
— then click the target wizard — then click the target wizard
{/if} {/if}
{#if attachedMods.length > 0}
[+ {attachedMods.map((m) => cardDef(m.cardId).name).join(", ")}]
{/if}
{#if selectedCard?.cardId === "rotate-sector"}
<label><input type="checkbox" bind:checked={rotateCW} /> clockwise</label>
— click a square in the sector to rotate
{/if}
{#if selectedCard?.cardId === "relocate-sector"}
{#if pendingSectorFrom}
— now click the destination area
{:else}
— click a square in the sector to move
{/if}
{/if}
{#if selectedCard?.cardId === "power-run"} {#if selectedCard?.cardId === "power-run"}
<label>life to trade <input type="number" min="1" max="10" bind:value={runPoints} /></label> <label>life to trade <input type="number" min="1" max="10" bind:value={runPoints} /></label>
<button onclick={castPowerRun}>Run!</button> <button onclick={castPowerRun}>Run!</button>
+13
View File
@@ -144,6 +144,18 @@
{/if} {/if}
{/each} {/each}
<!-- illusions YOU know are fake: ghostly dashed lines -->
{#each view.knownIllusionEdges as key (key)}
{@const kind = key.split(":")[0]}
{@const ix = Number(key.split(":")[1]?.split(",")[0])}
{@const iy = Number(key.split(":")[1]?.split(",")[1])}
{#if kind === "V"}
<line x1={(ix + 1) * CELL} y1={iy * CELL} x2={(ix + 1) * CELL} y2={(iy + 1) * CELL} class="illusion" />
{:else}
<line x1={ix * CELL} y1={(iy + 1) * CELL} x2={(ix + 1) * CELL} y2={(iy + 1) * CELL} class="illusion" />
{/if}
{/each}
<!-- warp openings --> <!-- warp openings -->
{#each view.board.warps as w, i (i)} {#each view.board.warps as w, i (i)}
<text <text
@@ -208,6 +220,7 @@
.stone { fill: #6a6458; stroke: #3a362e; stroke-width: 2; } .stone { fill: #6a6458; stroke: #3a362e; stroke-width: 2; }
.bush { fill: #2e7d32; stroke: #1b4d1e; stroke-width: 2; } .bush { fill: #2e7d32; stroke: #1b4d1e; stroke-width: 2; }
.ground-object { fill: #a6812e; stroke: #4a3a10; stroke-width: 1; } .ground-object { fill: #a6812e; stroke: #4a3a10; stroke-width: 1; }
.illusion { stroke: #7a6f9a; stroke-width: 4; stroke-dasharray: 6 5; opacity: 0.7; }
.home { font-size: 26px; text-anchor: middle; dominant-baseline: middle; opacity: 0.85; } .home { font-size: 26px; text-anchor: middle; dominant-baseline: middle; opacity: 0.85; }
.treasure { stroke: #111; stroke-width: 1.2; } .treasure { stroke: #111; stroke-width: 1.2; }
.warp { font-size: 13px; text-anchor: middle; fill: #6a5f4b; font-weight: bold; } .warp { font-size: 13px; text-anchor: middle; fill: #6a5f4b; font-weight: bold; }
+12
View File
@@ -56,6 +56,18 @@ function humanize(e: GameEvent): string | null {
case "lockRemoved": return `${e.player} removes a door's lock for good.`; case "lockRemoved": return `${e.player} removes a door's lock for good.`;
case "cardDisplayed": return `${e.player} displays ${cardDef(e.card.cardId).name}.`; case "cardDisplayed": return `${e.player} displays ${cardDef(e.card.cardId).name}.`;
case "lifeTraded": return `${e.player} burns ${e.points} life for speed!`; case "lifeTraded": return `${e.player} burns ${e.points} life for speed!`;
case "castAroundCorner": return `The spell bends around the corner!`;
case "moveBumped": return `${e.player} blunders into a wall!`;
case "attackMisdirected": return e.newTarget
? `${e.attacker}'s blind attack veers off — and hits ${e.newTarget}!`
: `${e.attacker}'s blind attack flies off into the darkness.`;
case "retreatedInHorror": return `${e.player} flees the hideous sight!`;
case "illusionWallCreated": return `A wall appears... or does it?`;
case "illusionTested": return e.result === "seesThrough"
? `${e.player} sees right through the illusion!`
: `${e.player} is convinced the wall is real.`;
case "sectorRotated": return `The maze GRINDS — a sector rotates ${e.clockwise ? "clockwise" : "counterclockwise"}!`;
case "sectorRelocated": return `The maze SHUDDERS — an entire sector slides away!`;
case "trapRedrawnDuringDeal": return null; case "trapRedrawnDuringDeal": return null;
case "died": return `${e.player} is dead${e.killedBy ? ` — killed by ${e.killedBy}` : ""}.`; case "died": return `${e.player} is dead${e.killedBy ? ` — killed by ${e.killedBy}` : ""}.`;
case "handTaken": return `${e.to} takes ${e.count} cards from ${e.from}'s body.`; case "handTaken": return `${e.to} takes ${e.count} cards from ${e.from}'s body.`;