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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -292,7 +292,7 @@ describe("stack discipline", () => {
|
||||
it("unimplemented cards refuse to cast with a clear error", () => {
|
||||
let { state } = newGame();
|
||||
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 });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error).toMatch(/not implemented/);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,12 @@
|
||||
let nameInput = $state("");
|
||||
/** power-run points. */
|
||||
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. */
|
||||
let discardSelection = $state<Set<string>>(new Set());
|
||||
|
||||
@@ -37,9 +43,11 @@
|
||||
]);
|
||||
const CELL_CARDS = new Set([
|
||||
"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([
|
||||
"speed", "invisible", "shrink", "mist-body", "pass-through-wall", "reuse-spell",
|
||||
"invisible", "shrink", "mist-body",
|
||||
]);
|
||||
const NAMED_CARDS = new Set(["card-erasure", "drop-object"]);
|
||||
const edgeSelectMode = $derived(selectedCard != null && EDGE_CARDS.has(selectedCard.cardId));
|
||||
@@ -51,11 +59,23 @@
|
||||
function clearSelection() {
|
||||
selectedCard = null;
|
||||
attachedNumber = null;
|
||||
attachedMods = [];
|
||||
wbDamage = 0;
|
||||
pendingCellFor = null;
|
||||
pendingSectorFrom = null;
|
||||
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). */
|
||||
function nameToCardId(name: string): string | null {
|
||||
const wanted = name.trim().toLowerCase();
|
||||
@@ -85,6 +105,12 @@
|
||||
wbDamage = numberTotal;
|
||||
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) {
|
||||
clearSelection();
|
||||
return;
|
||||
@@ -100,7 +126,7 @@
|
||||
// Instant untargeted spells (and stone displays) cast immediately;
|
||||
// duration self-spells wait so a number card can be attached.
|
||||
const INSTANT = new Set([
|
||||
"speed", "pass-through-wall", "reuse-spell",
|
||||
"speed", "pass-through-wall", "reuse-spell", "ugly",
|
||||
"bloodstone", "brainstone", "powerstone", "shadowstone",
|
||||
"shieldstone", "soulstone", "speedstone", "visionstone",
|
||||
]);
|
||||
@@ -136,6 +162,26 @@
|
||||
clearSelection();
|
||||
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)) {
|
||||
net.command({
|
||||
type: "cast", instanceId: selectedCard.instanceId,
|
||||
@@ -197,6 +243,7 @@
|
||||
target: { kind: "player", playerId },
|
||||
};
|
||||
if (attachedNumber) cmd.numberInstanceIds = [attachedNumber.instanceId];
|
||||
applyMods(cmd);
|
||||
if (selectedCard.cardId === "waterbolt") {
|
||||
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>
|
||||
— then click the target wizard
|
||||
{/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"}
|
||||
<label>life to trade <input type="number" min="1" max="10" bind:value={runPoints} /></label>
|
||||
<button onclick={castPowerRun}>Run!</button>
|
||||
|
||||
@@ -144,6 +144,18 @@
|
||||
{/if}
|
||||
{/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 -->
|
||||
{#each view.board.warps as w, i (i)}
|
||||
<text
|
||||
@@ -208,6 +220,7 @@
|
||||
.stone { fill: #6a6458; stroke: #3a362e; stroke-width: 2; }
|
||||
.bush { fill: #2e7d32; stroke: #1b4d1e; stroke-width: 2; }
|
||||
.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; }
|
||||
.treasure { stroke: #111; stroke-width: 1.2; }
|
||||
.warp { font-size: 13px; text-anchor: middle; fill: #6a5f4b; font-weight: bold; }
|
||||
|
||||
@@ -56,6 +56,18 @@ function humanize(e: GameEvent): string | null {
|
||||
case "lockRemoved": return `${e.player} removes a door's lock for good.`;
|
||||
case "cardDisplayed": return `${e.player} displays ${cardDef(e.card.cardId).name}.`;
|
||||
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 "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.`;
|
||||
|
||||
Reference in New Issue
Block a user