Expansion wave 3: twelve terrain cards
KILLER OOZE (1 damage on entry, slip on 1-2: drop treasure, 2 more, flat on your face until you roll to stand), CREATE PIT (jumps roll a D4 — clear it on 2-4, fall in on a 1 and climb out on later rolls), ROSEBUSH (3-point passage, blocks sight), DUST CLOUD (a permanent zone of blindness: direction rolls inside, no LOS through, defeats Visionstone), FILL SQUARE WITH SLIME (entering ends your actions, blocks sight), HANDFUL OF TACKS (adjacent-only scatter, 3 points to cross), CREATE DOOR (a new locked door in any wall or corridor), BOOBYTRAP (four face-down tokens, one secretly real — 4 points under anyone but the caster; the caster's view alone marks the true token), GLUE (objects pinned for twice the number card), SAFE (items locked away from everyone but the creator), TRADER (swap two floor items in sight; glue and safes hold fast), and STONE TO WATER (walls melt into range-2 waves, stone blocks into range-4 bursts). LOS blocking is now per-terrain-kind. The board renders all of it, boobytrap placement is a four-click ritual, and Trader is two. 108 tests passing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
28949bdb7d
commit
3a35922a40
+410
-19
@@ -66,6 +66,10 @@ export interface PlayerState {
|
|||||||
extraTurns: number;
|
extraTurns: number;
|
||||||
/** PASS THROUGH WALL charges (each lets one step through a wall). */
|
/** PASS THROUGH WALL charges (each lets one step through a wall). */
|
||||||
passWallCharges: number;
|
passWallCharges: number;
|
||||||
|
/** KILLER OOZE: slipped and fell; must roll to stand. */
|
||||||
|
fallenInOoze: boolean;
|
||||||
|
/** CREATE PIT: down in a pit; must roll to climb out. */
|
||||||
|
inPit: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A duration spell in play. Expires at the START of the caster's turns. */
|
/** A duration spell in play. Expires at the START of the caster's turns. */
|
||||||
@@ -103,14 +107,20 @@ export interface CreatureState {
|
|||||||
scorchedThisTurn: PlayerId[];
|
scorchedThisTurn: PlayerId[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Something occupying a whole square (FILL SQUARE WITH STONE, THORNBUSH). */
|
/** Something occupying a whole square (stone, bushes, ooze, pits, ...). */
|
||||||
export interface SquareContent {
|
export interface SquareContent {
|
||||||
kind: "stone" | "thornbush";
|
kind: "stone" | "thornbush" | "rosebush" | "ooze" | "dust" | "slime" | "tacks" | "pit" | "safe";
|
||||||
/** Damage taken so far; thornbushes die at 5. Stone is indestructible. */
|
/** Damage taken so far (thornbush dies at 5, rosebush at 5, ooze at 5 fire). */
|
||||||
damage: number;
|
damage: number;
|
||||||
createdBy: PlayerId;
|
createdBy: PlayerId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Which square contents block line of sight. */
|
||||||
|
const LOS_BLOCKING_CONTENT: Record<SquareContent["kind"], boolean> = {
|
||||||
|
stone: true, thornbush: true, rosebush: true, dust: true, slime: true,
|
||||||
|
ooze: false, tacks: false, pit: false, safe: false,
|
||||||
|
};
|
||||||
|
|
||||||
export interface TurnState {
|
export interface TurnState {
|
||||||
round: number;
|
round: number;
|
||||||
firstIndex: number;
|
firstIndex: number;
|
||||||
@@ -148,6 +158,8 @@ export interface CastParams {
|
|||||||
damage?: number;
|
damage?: number;
|
||||||
knockback?: number;
|
knockback?: number;
|
||||||
cell?: Cell;
|
cell?: Cell;
|
||||||
|
/** BOOBYTRAP: the four token cells; the FIRST is the real trap. */
|
||||||
|
cells?: Cell[];
|
||||||
cardId?: string;
|
cardId?: string;
|
||||||
points?: number;
|
points?: number;
|
||||||
clockwise?: boolean;
|
clockwise?: boolean;
|
||||||
@@ -185,6 +197,12 @@ export interface GameState {
|
|||||||
wandCharges: Record<string, number>;
|
wandCharges: Record<string, number>;
|
||||||
/** WARP WAND: walls opened for this turn only, with their prior state. */
|
/** WARP WAND: walls opened for this turn only, with their prior state. */
|
||||||
tempWarpEdges: { key: string; prior: EdgeState | null }[];
|
tempWarpEdges: { key: string; prior: EdgeState | null }[];
|
||||||
|
/** BOOBYTRAP: four face-down tokens, one real (its cell key is secret). */
|
||||||
|
boobytraps: { casterId: PlayerId; cells: Cell[]; realKey: string }[];
|
||||||
|
/** GLUE: object cells that cannot be picked up from, by cell key. */
|
||||||
|
gluedCells: Record<string, true>;
|
||||||
|
/** SAFE cells unlocked until end of turn (lock cards / the creator). */
|
||||||
|
openSafes: string[];
|
||||||
players: PlayerState[];
|
players: PlayerState[];
|
||||||
treasures: TreasureState[];
|
treasures: TreasureState[];
|
||||||
sustained: SustainedEffect[];
|
sustained: SustainedEffect[];
|
||||||
@@ -212,7 +230,9 @@ export function sustainedOn(state: GameState, playerId: PlayerId, cardId?: strin
|
|||||||
/** LOS including square-filling blockers (stone, thornbushes). */
|
/** LOS including square-filling blockers (stone, thornbushes). */
|
||||||
export function gameLos(state: GameState, from: Cell, to: Cell): boolean {
|
export function gameLos(state: GameState, from: Cell, to: Cell): boolean {
|
||||||
const blockers: Record<string, true> = {};
|
const blockers: Record<string, true> = {};
|
||||||
for (const key of Object.keys(state.squareContents)) blockers[key] = true;
|
for (const [key, content] of Object.entries(state.squareContents)) {
|
||||||
|
if (LOS_BLOCKING_CONTENT[content.kind]) blockers[key] = true;
|
||||||
|
}
|
||||||
return hasLineOfSight(boardView(state), from, to, blockers);
|
return hasLineOfSight(boardView(state), from, to, blockers);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,7 +313,9 @@ function casterLos(
|
|||||||
): boolean {
|
): boolean {
|
||||||
const board = perceivedBoard(state, events, caster.id, { from, to });
|
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, content] of Object.entries(state.squareContents)) {
|
||||||
|
if (LOS_BLOCKING_CONTENT[content.kind]) blockers[key] = true;
|
||||||
|
}
|
||||||
if (hasLineOfSight(board, from, to, blockers)) return true;
|
if (hasLineOfSight(board, from, to, blockers)) return true;
|
||||||
if (!displays(caster, "visionstone")) return false;
|
if (!displays(caster, "visionstone")) return false;
|
||||||
for (const key of Object.keys(board.edges)) {
|
for (const key of Object.keys(board.edges)) {
|
||||||
@@ -331,6 +353,12 @@ function isLockedInPlace(state: GameState, playerId: PlayerId): boolean {
|
|||||||
return sustainedOn(state, playerId, "lock-in-place").length > 0;
|
return sustainedOn(state, playerId, "lock-in-place").length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** BLIND spell, or standing inside a DUST CLOUD. */
|
||||||
|
function isBlinded(state: GameState, p: PlayerState): boolean {
|
||||||
|
if (sustainedOn(state, p.id, "blind").length > 0) return true;
|
||||||
|
return state.squareContents[cellKey(p.position)]?.kind === "dust";
|
||||||
|
}
|
||||||
|
|
||||||
/** Is a stone (or other displayable) face-up in front of this player? */
|
/** Is a stone (or other displayable) face-up in front of this player? */
|
||||||
export function displays(p: PlayerState, cardId: string): boolean {
|
export function displays(p: PlayerState, cardId: string): boolean {
|
||||||
return p.hand.some((c) => c.cardId === cardId && p.displayed.includes(c.instanceId));
|
return p.hand.some((c) => c.cardId === cardId && p.displayed.includes(c.instanceId));
|
||||||
@@ -382,7 +410,7 @@ export type GameEvent =
|
|||||||
| { type: "firewallBurned"; player: PlayerId }
|
| { type: "firewallBurned"; player: PlayerId }
|
||||||
| { type: "waterwallCrashes"; caster: PlayerId; edge: { cell: Cell; side: Side } }
|
| { type: "waterwallCrashes"; caster: PlayerId; edge: { cell: Cell; side: Side } }
|
||||||
| { type: "washedBack"; player: PlayerId; from: Cell; to: Cell; blockedSpaces: number }
|
| { type: "washedBack"; player: PlayerId; from: Cell; to: Cell; blockedSpaces: number }
|
||||||
| { type: "squareFilled"; caster: PlayerId; cell: Cell; kind: "stone" | "thornbush" }
|
| { type: "squareFilled"; caster: PlayerId; cell: Cell; kind: SquareContent["kind"] }
|
||||||
| { type: "creationDispelled"; caster: PlayerId; what: string }
|
| { type: "creationDispelled"; caster: PlayerId; what: string }
|
||||||
| { type: "enteredThornbush"; player: PlayerId; at: Cell }
|
| { type: "enteredThornbush"; player: PlayerId; at: Cell }
|
||||||
| { type: "objectThrown"; attacker: PlayerId; cardId: string; landedAt: Cell }
|
| { type: "objectThrown"; attacker: PlayerId; cardId: string; landedAt: Cell }
|
||||||
@@ -416,6 +444,21 @@ export type GameEvent =
|
|||||||
| { type: "shoved"; player: PlayerId; from: Cell; to: Cell; by: PlayerId }
|
| { type: "shoved"; player: PlayerId; from: Cell; to: Cell; by: PlayerId }
|
||||||
| { type: "webbed"; player: PlayerId }
|
| { type: "webbed"; player: PlayerId }
|
||||||
| { type: "cardRetrieved"; player: PlayerId; cardId: string }
|
| { type: "cardRetrieved"; player: PlayerId; cardId: string }
|
||||||
|
| { type: "slippedInOoze"; player: PlayerId; at: Cell }
|
||||||
|
| { type: "struggledInOoze"; player: PlayerId; stood: boolean }
|
||||||
|
| { type: "steppedOnTacks"; player: PlayerId; at: Cell }
|
||||||
|
| { type: "jumpedPit"; player: PlayerId; from: Cell; to: Cell }
|
||||||
|
| { type: "fellInPit"; player: PlayerId; at: Cell }
|
||||||
|
| { type: "climbedFromPit"; player: PlayerId; success: boolean }
|
||||||
|
| { type: "stuckInSlime"; player: PlayerId; at: Cell }
|
||||||
|
| { type: "boobytrapPlaced"; caster: PlayerId; cells: Cell[] }
|
||||||
|
| { type: "boobytrapSprung"; player: PlayerId; at: Cell }
|
||||||
|
| { type: "boobytrapPlacedPrivate"; visibleTo: PlayerId; realCell: Cell }
|
||||||
|
| { type: "objectsGlued"; caster: PlayerId; at: Cell; turns: number }
|
||||||
|
| { type: "safeCreated"; caster: PlayerId; at: Cell }
|
||||||
|
| { type: "safeOpened"; player: PlayerId; at: Cell }
|
||||||
|
| { type: "itemsTraded"; caster: PlayerId; a: Cell; b: Cell }
|
||||||
|
| { type: "stoneTurnedToWater"; caster: PlayerId; at: Cell | null }
|
||||||
| { 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 }
|
||||||
@@ -1438,6 +1481,171 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
|||||||
return "that card is not in the discard pile";
|
return "that card is not in the discard pile";
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// --- Expansion #1: terrain -----------------------------------------------
|
||||||
|
"killer-ooze": terrainEffect("ooze"),
|
||||||
|
rosebush: terrainEffect("rosebush"),
|
||||||
|
"dust-cloud": terrainEffect("dust"),
|
||||||
|
"fill-square-with-slime": terrainEffect("slime"),
|
||||||
|
"create-pit": terrainEffect("pit"),
|
||||||
|
"handful-of-tacks": {
|
||||||
|
kind: "neutral",
|
||||||
|
// NEUTRAL / ADJACENT: scattered at your feet, not thrown across the maze.
|
||||||
|
resolve: (state, events, caster, cmd) => {
|
||||||
|
if (!cmd.target || cmd.target.kind !== "cell") return "target a square";
|
||||||
|
const cell = cmd.target.cell;
|
||||||
|
if (Math.abs(cell.x - caster.position.x) + Math.abs(cell.y - caster.position.y) > 1) {
|
||||||
|
return "you must be adjacent to scatter tacks";
|
||||||
|
}
|
||||||
|
const problem = emptySquareTarget(state, cmd, caster);
|
||||||
|
if (typeof problem === "string") return problem;
|
||||||
|
state.squareContents[cellKey(cell)] = { kind: "tacks", damage: 0, createdBy: caster.id };
|
||||||
|
events.push({ type: "squareFilled", caster: caster.id, cell, kind: "tacks" });
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"create-door": {
|
||||||
|
kind: "neutral",
|
||||||
|
resolve: (state, events, caster, cmd) => {
|
||||||
|
if (!cmd.target || cmd.target.kind !== "edge") return "create-door targets a wall";
|
||||||
|
const { cell, side } = cmd.target;
|
||||||
|
const key = edgeKey(cell, side);
|
||||||
|
const view = boardView(state);
|
||||||
|
if ((view.edges[key] ?? "open") === "open") {
|
||||||
|
if (!view.cells[cellKey(cell)] || !view.cells[cellKey(neighbor(cell, side))]) {
|
||||||
|
return "the door must stand between two spaces";
|
||||||
|
}
|
||||||
|
} else if (view.edges[key] !== "wall") {
|
||||||
|
return "a door goes into a stone wall or an open corridor";
|
||||||
|
}
|
||||||
|
if (!losToEdge(view, caster.position, cell, side)) return "no line of sight";
|
||||||
|
state.edgeOverrides[key] = "door";
|
||||||
|
state.createdEdges[key] = true;
|
||||||
|
events.push({ type: "wallCreated", caster: caster.id, edge: { cell, side } });
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
boobytrap: {
|
||||||
|
kind: "neutral",
|
||||||
|
// Four face-down tokens; only the caster knows which is real.
|
||||||
|
resolve: (state, events, caster, cmd) => {
|
||||||
|
const cells = cmd.params?.cells;
|
||||||
|
if (!cells || cells.length !== 4) return "place four tokens (the first is the real trap)";
|
||||||
|
const view = boardView(state);
|
||||||
|
for (const c of cells) {
|
||||||
|
if (!view.cells[cellKey(c)]) return "a token is off the board";
|
||||||
|
if (state.squareContents[cellKey(c)]?.kind === "stone") return "a token is inside solid stone";
|
||||||
|
}
|
||||||
|
const uniq = new Set(cells.map(cellKey));
|
||||||
|
if (uniq.size !== 4) return "the four tokens go on four different squares";
|
||||||
|
state.boobytraps.push({ casterId: caster.id, cells: [...cells], realKey: cellKey(cells[0]!) });
|
||||||
|
events.push({ type: "boobytrapPlaced", caster: caster.id, cells: [...cells] });
|
||||||
|
events.push({ type: "boobytrapPlacedPrivate", visibleTo: caster.id, realCell: cells[0]! });
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
glue: {
|
||||||
|
kind: "neutral",
|
||||||
|
resolve: (state, events, caster, cmd, magnitude) => {
|
||||||
|
if (!cmd.target || cmd.target.kind !== "cell") return "glue targets an object's square";
|
||||||
|
const key = cellKey(cmd.target.cell);
|
||||||
|
const hasObject =
|
||||||
|
(state.groundObjects[key] ?? []).length > 0 ||
|
||||||
|
state.treasures.some((t) => t.position && cellKey(t.position) === key);
|
||||||
|
if (!hasObject) return "there is nothing there to glue down";
|
||||||
|
if (!gameLos(state, caster.position, cmd.target.cell)) return "no line of sight";
|
||||||
|
state.gluedCells[key] = true;
|
||||||
|
// "a duration equal to twice the NUMBER card played"
|
||||||
|
const turns = magnitude.duration * 2;
|
||||||
|
const fx: SustainedEffect = {
|
||||||
|
id: `fx-${state.nextEffectId++}`,
|
||||||
|
cardId: "glue", casterId: caster.id, targetId: caster.id,
|
||||||
|
remainingTurns: Math.max(1, turns), data: {}, edge: key,
|
||||||
|
};
|
||||||
|
state.sustained.push(fx);
|
||||||
|
events.push({ type: "objectsGlued", caster: caster.id, at: cmd.target.cell, turns: fx.remainingTurns });
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
safe: {
|
||||||
|
kind: "neutral",
|
||||||
|
resolve: (state, events, caster, cmd) => {
|
||||||
|
if (!cmd.target || cmd.target.kind !== "cell") return "safe targets a treasure or item";
|
||||||
|
const key = cellKey(cmd.target.cell);
|
||||||
|
if (state.squareContents[key]) return "that square is occupied";
|
||||||
|
const hasObject =
|
||||||
|
(state.groundObjects[key] ?? []).length > 0 ||
|
||||||
|
state.treasures.some((t) => t.position && cellKey(t.position) === key);
|
||||||
|
if (!hasObject) return "there is nothing there to lock up";
|
||||||
|
if (!gameLos(state, caster.position, cmd.target.cell)) return "no line of sight";
|
||||||
|
state.squareContents[key] = { kind: "safe", damage: 0, createdBy: caster.id };
|
||||||
|
events.push({ type: "safeCreated", caster: caster.id, at: cmd.target.cell });
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
trader: {
|
||||||
|
kind: "neutral",
|
||||||
|
// Swap two floor items, both in LOS. Does not overcome GLUE.
|
||||||
|
resolve: (state, events, caster, cmd) => {
|
||||||
|
const a = cmd.params?.cell;
|
||||||
|
const bT = cmd.target;
|
||||||
|
if (!a || !bT || bT.kind !== "cell") return "pick the two item squares to swap";
|
||||||
|
const b = bT.cell;
|
||||||
|
const ka = cellKey(a), kb = cellKey(b);
|
||||||
|
if (ka === kb) return "pick two different squares";
|
||||||
|
if (state.gluedCells[ka] || state.gluedCells[kb]) return "glue holds it fast";
|
||||||
|
if (state.squareContents[ka]?.kind === "safe" || state.squareContents[kb]?.kind === "safe") return "it is locked in a safe";
|
||||||
|
if (!gameLos(state, caster.position, a) || !gameLos(state, caster.position, b)) return "no line of sight";
|
||||||
|
const itemsA = state.groundObjects[ka] ?? [];
|
||||||
|
const itemsB = state.groundObjects[kb] ?? [];
|
||||||
|
const treasureA = state.treasures.find((t) => t.position && cellKey(t.position) === ka);
|
||||||
|
const treasureB = state.treasures.find((t) => t.position && cellKey(t.position) === kb);
|
||||||
|
if (itemsA.length + (treasureA ? 1 : 0) === 0 || itemsB.length + (treasureB ? 1 : 0) === 0) {
|
||||||
|
return "both squares must hold an item";
|
||||||
|
}
|
||||||
|
if (itemsA.length > 0 || itemsB.length > 0) {
|
||||||
|
if (itemsA.length > 0) state.groundObjects[kb] = [...itemsB.filter(() => false), ...itemsA];
|
||||||
|
else delete state.groundObjects[kb];
|
||||||
|
if (itemsB.length > 0) state.groundObjects[ka] = [...itemsB];
|
||||||
|
else delete state.groundObjects[ka];
|
||||||
|
}
|
||||||
|
if (treasureA) treasureA.position = { ...b };
|
||||||
|
if (treasureB) treasureB.position = { ...a };
|
||||||
|
events.push({ type: "itemsTraded", caster: caster.id, a, b });
|
||||||
|
checkVictory(state, events);
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"stone-to-water": {
|
||||||
|
kind: "neutral",
|
||||||
|
resolve: (state, events, caster, cmd) => {
|
||||||
|
const view = boardView(state);
|
||||||
|
if (cmd.target?.kind === "edge") {
|
||||||
|
const { cell, side } = cmd.target;
|
||||||
|
const key = edgeKey(cell, side);
|
||||||
|
if (view.edges[key] !== "wall") return "that is not a stone wall";
|
||||||
|
if (!losToEdge(view, caster.position, cell, side)) return "no line of sight";
|
||||||
|
state.edgeOverrides[key] = "open";
|
||||||
|
delete state.createdEdges[key];
|
||||||
|
events.push({ type: "stoneTurnedToWater", caster: caster.id, at: null });
|
||||||
|
// "Wall turns into a WATERWALL with a range and damage of 2."
|
||||||
|
waveFromEdge(state, events, cell, side, 2);
|
||||||
|
checkVictory(state, events);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (cmd.target?.kind === "cell") {
|
||||||
|
const key = cellKey(cmd.target.cell);
|
||||||
|
if (state.squareContents[key]?.kind !== "stone") return "that is not a solid stone block";
|
||||||
|
if (!gameLos(state, caster.position, cmd.target.cell)) return "no line of sight";
|
||||||
|
delete state.squareContents[key];
|
||||||
|
events.push({ type: "stoneTurnedToWater", caster: caster.id, at: cmd.target.cell });
|
||||||
|
// "Solid stone block turns into a WATERWALL with a range and damage of 4."
|
||||||
|
waveFromCell(state, events, cmd.target.cell, 4);
|
||||||
|
checkVictory(state, events);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return "target a stone wall or a solid stone block";
|
||||||
|
},
|
||||||
|
},
|
||||||
"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
|
||||||
@@ -1461,6 +1669,61 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Simple square-filling terrain creations (ooze, rosebush, dust, slime, pit). */
|
||||||
|
function terrainEffect(kind: SquareContent["kind"]): NeutralEffect {
|
||||||
|
return {
|
||||||
|
kind: "neutral",
|
||||||
|
resolve: (state, events, caster, cmd) => {
|
||||||
|
const problem = emptySquareTarget(state, cmd, caster);
|
||||||
|
if (typeof problem === "string") return problem;
|
||||||
|
state.squareContents[cellKey(problem)] = { kind, damage: 0, createdBy: caster.id };
|
||||||
|
events.push({ type: "squareFilled", caster: caster.id, cell: problem, kind });
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A collapsing waterwall wave from an edge: wash players back `range`. */
|
||||||
|
function waveFromEdge(state: GameState, events: GameEvent[], cell: Cell, side: Side, range: number): void {
|
||||||
|
const away = (s2: Side): Side => (s2 === "N" ? "S" : s2 === "S" ? "N" : s2 === "E" ? "W" : "E");
|
||||||
|
const pushes: { start: Cell; dir: Side }[] = [
|
||||||
|
{ start: cell, dir: away(side) },
|
||||||
|
{ start: neighbor(cell, side), dir: side },
|
||||||
|
];
|
||||||
|
for (const { start, dir } of pushes) {
|
||||||
|
let probe = start;
|
||||||
|
for (let dist = 0; dist < range; dist++) {
|
||||||
|
for (const p of state.players) {
|
||||||
|
if (p.alive && cellKey(p.position) === cellKey(probe)) washBackN(state, events, p, dir, range);
|
||||||
|
}
|
||||||
|
for (const c of [...state.creatures]) {
|
||||||
|
if (c.kind === "fire-imp" && cellKey(c.position) === cellKey(probe)) {
|
||||||
|
destroyCreature(state, events, c, "rushing water");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
probe = neighbor(probe, dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A wave bursting outward from a cell in all four directions. */
|
||||||
|
function waveFromCell(state: GameState, events: GameEvent[], center: Cell, range: number): void {
|
||||||
|
for (const dir of SIDES) {
|
||||||
|
let probe = center;
|
||||||
|
for (let dist = 0; dist < range; dist++) {
|
||||||
|
probe = neighbor(probe, dir);
|
||||||
|
for (const p of state.players) {
|
||||||
|
if (p.alive && cellKey(p.position) === cellKey(probe)) washBackN(state, events, p, dir, range);
|
||||||
|
}
|
||||||
|
for (const c of [...state.creatures]) {
|
||||||
|
if (c.kind === "fire-imp" && cellKey(c.position) === cellKey(probe)) {
|
||||||
|
destroyCreature(state, events, c, "rushing water");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** A monster summon: ATTACK-typed, uses your attack, appears in your LOS. */
|
/** A monster summon: ATTACK-typed, uses your attack, appears in your LOS. */
|
||||||
function summonEffect(kind: CreatureState["kind"]): NeutralEffect {
|
function summonEffect(kind: CreatureState["kind"]): NeutralEffect {
|
||||||
return {
|
return {
|
||||||
@@ -1531,26 +1794,30 @@ function emptySquareTarget(
|
|||||||
return cell;
|
return cell;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** WATERWALL: push a player 2 spaces along dir; 1 damage per blocked space. */
|
/** WATERWALL family: push a player `range` spaces; 1 damage per blocked space. */
|
||||||
function washBack(state: GameState, events: GameEvent[], p: PlayerState, dir: Side): void {
|
function washBackN(state: GameState, events: GameEvent[], p: PlayerState, dir: Side, range: number): void {
|
||||||
if (isLockedInPlace(state, p.id)) return;
|
if (isLockedInPlace(state, p.id)) return;
|
||||||
const view = boardView(state);
|
const view = boardView(state);
|
||||||
const from = p.position;
|
const from = p.position;
|
||||||
let moved = 0;
|
let moved = 0;
|
||||||
for (let i = 0; i < 2; i++) {
|
for (let i = 0; i < range; i++) {
|
||||||
const step = stepTarget(view, p.position, dir);
|
const step = stepTarget(view, p.position, dir);
|
||||||
if (step.kind === "blocked") break;
|
if (step.kind === "blocked") break;
|
||||||
if (state.squareContents[cellKey(step.to)]?.kind === "stone") break;
|
if (state.squareContents[cellKey(step.to)]?.kind === "stone") break;
|
||||||
p.position = step.to;
|
p.position = step.to;
|
||||||
moved++;
|
moved++;
|
||||||
}
|
}
|
||||||
const blockedSpaces = 2 - moved;
|
const blockedSpaces = range - moved;
|
||||||
events.push({ type: "washedBack", player: p.id, from, to: p.position, blockedSpaces });
|
events.push({ type: "washedBack", player: p.id, from, to: p.position, blockedSpaces });
|
||||||
if (blockedSpaces > 0) {
|
if (blockedSpaces > 0) {
|
||||||
applyDamage(state, events, p, blockedSpaces, "waterwall crush", null, "physical");
|
applyDamage(state, events, p, blockedSpaces, "waterwall crush", null, "physical");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function washBack(state: GameState, events: GameEvent[], p: PlayerState, dir: Side): void {
|
||||||
|
washBackN(state, events, p, dir, 2);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Creatures
|
// Creatures
|
||||||
|
|
||||||
@@ -2033,6 +2300,8 @@ export function createGame(config: GameConfig): { state: GameState; events: Game
|
|||||||
lostTurns: 0,
|
lostTurns: 0,
|
||||||
extraTurns: 0,
|
extraTurns: 0,
|
||||||
passWallCharges: 0,
|
passWallCharges: 0,
|
||||||
|
fallenInOoze: false,
|
||||||
|
inPit: false,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const treasures: TreasureState[] = players.flatMap((p, i) =>
|
const treasures: TreasureState[] = players.flatMap((p, i) =>
|
||||||
@@ -2095,6 +2364,9 @@ export function createGame(config: GameConfig): { state: GameState; events: Game
|
|||||||
nextCreatureId: 1,
|
nextCreatureId: 1,
|
||||||
wandCharges: {},
|
wandCharges: {},
|
||||||
tempWarpEdges: [],
|
tempWarpEdges: [],
|
||||||
|
boobytraps: [],
|
||||||
|
gluedCells: {},
|
||||||
|
openSafes: [],
|
||||||
players,
|
players,
|
||||||
treasures,
|
treasures,
|
||||||
sustained: [],
|
sustained: [],
|
||||||
@@ -2210,9 +2482,9 @@ function doMove(prev: GameState, direction: Side): CommandResult {
|
|||||||
const p = activePlayer(state);
|
const p = activePlayer(state);
|
||||||
const events: GameEvent[] = [];
|
const events: GameEvent[] = [];
|
||||||
|
|
||||||
// BLIND: "must roll direction on D4 if attempting to move ... bumping into
|
// BLIND (or a DUST CLOUD): "must roll direction on D4 if attempting to
|
||||||
// a wall counts as one space of movement. Reroll for each movement point."
|
// move ... bumping into a wall counts as one space of movement."
|
||||||
if (sustainedOn(state, p.id, "blind").length > 0) {
|
if (isBlinded(state, p)) {
|
||||||
const [roll, rngNext] = rollDie(state.rng);
|
const [roll, rngNext] = rollDie(state.rng);
|
||||||
state.rng = rngNext;
|
state.rng = rngNext;
|
||||||
direction = SIDES[roll - 1]!;
|
direction = SIDES[roll - 1]!;
|
||||||
@@ -2223,7 +2495,7 @@ function doMove(prev: GameState, direction: Side): CommandResult {
|
|||||||
const key = edgeKey(p.position, direction);
|
const key = edgeKey(p.position, direction);
|
||||||
if (state.illusionWalls[key] &&
|
if (state.illusionWalls[key] &&
|
||||||
illusionBelief(state, events, p.id, key) === "believes") {
|
illusionBelief(state, events, p.id, key) === "believes") {
|
||||||
if (sustainedOn(state, p.id, "blind").length > 0) {
|
if (isBlinded(state, p)) {
|
||||||
state.turn.movementUsed++;
|
state.turn.movementUsed++;
|
||||||
events.push({ type: "moveBumped", player: p.id, direction });
|
events.push({ type: "moveBumped", player: p.id, direction });
|
||||||
return { ok: true, state, events };
|
return { ok: true, state, events };
|
||||||
@@ -2244,7 +2516,7 @@ function doMove(prev: GameState, direction: Side): CommandResult {
|
|||||||
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)]) {
|
if (!view.cells[cellKey(dest)]) {
|
||||||
if (sustainedOn(state, p.id, "blind").length > 0) {
|
if (isBlinded(state, p)) {
|
||||||
state.turn.movementUsed++;
|
state.turn.movementUsed++;
|
||||||
events.push({ type: "moveBumped", player: p.id, direction });
|
events.push({ type: "moveBumped", player: p.id, direction });
|
||||||
return { ok: true, state, events };
|
return { ok: true, state, events };
|
||||||
@@ -2269,7 +2541,7 @@ 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) {
|
} else if (isBlinded(state, p)) {
|
||||||
// Blind bump: the wasted lurch costs a movement point.
|
// Blind bump: the wasted lurch costs a movement point.
|
||||||
state.turn.movementUsed++;
|
state.turn.movementUsed++;
|
||||||
events.push({ type: "moveBumped", player: p.id, direction });
|
events.push({ type: "moveBumped", player: p.id, direction });
|
||||||
@@ -2283,9 +2555,43 @@ function doMove(prev: GameState, direction: Side): CommandResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Square contents at the destination.
|
// Square contents at the destination.
|
||||||
const content = state.squareContents[cellKey(p.position)];
|
let content = state.squareContents[cellKey(p.position)];
|
||||||
if (content?.kind === "stone") return err("that square is solid stone");
|
if (content?.kind === "stone") return err("that square is solid stone");
|
||||||
|
|
||||||
|
// CREATE PIT: stepping onto a pit is a jump attempt — roll D4; on a 1 you
|
||||||
|
// fall in (2 damage, movement over); otherwise you sail across to the far
|
||||||
|
// side (if there is open floor there).
|
||||||
|
if (content?.kind === "pit" && !misted && !p.inPit) {
|
||||||
|
const [roll, rngNext] = rollDie(state.rng);
|
||||||
|
state.rng = rngNext;
|
||||||
|
if (roll === 1) {
|
||||||
|
p.inPit = true;
|
||||||
|
events.push({ type: "fellInPit", player: p.id, at: p.position });
|
||||||
|
applyDamage(state, events, p, 2, "pit fall", null, "physical");
|
||||||
|
state.turn.movementUsed = state.turn.movementAllowance;
|
||||||
|
checkVictory(state, events);
|
||||||
|
events.unshift({ type: "moved", player: p.id, from, to: p.position, direction, via });
|
||||||
|
return { ok: true, state, events };
|
||||||
|
}
|
||||||
|
const beyond = neighbor(p.position, direction);
|
||||||
|
const beyondOk =
|
||||||
|
view.cells[cellKey(beyond)] &&
|
||||||
|
(view.edges[edgeKey(p.position, direction)] ?? "open") === "open" &&
|
||||||
|
state.squareContents[cellKey(beyond)]?.kind !== "stone";
|
||||||
|
if (beyondOk) {
|
||||||
|
const pitCell = p.position;
|
||||||
|
p.position = beyond;
|
||||||
|
events.push({ type: "jumpedPit", player: p.id, from: pitCell, to: beyond });
|
||||||
|
content = state.squareContents[cellKey(p.position)];
|
||||||
|
} else {
|
||||||
|
// Nowhere to land: teeter back where you started.
|
||||||
|
p.position = from;
|
||||||
|
state.turn.movementUsed++;
|
||||||
|
events.push({ type: "moveBumped", player: p.id, direction });
|
||||||
|
return { ok: true, state, events };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
state.turn.movementUsed++;
|
state.turn.movementUsed++;
|
||||||
events.push({ type: "moved", player: p.id, from, to: p.position, direction, via });
|
events.push({ type: "moved", player: p.id, from, to: p.position, direction, via });
|
||||||
|
|
||||||
@@ -2308,6 +2614,55 @@ function doMove(prev: GameState, direction: Side): CommandResult {
|
|||||||
state.turn.actionsEnded = true;
|
state.turn.actionsEnded = true;
|
||||||
checkVictory(state, events);
|
checkVictory(state, events);
|
||||||
}
|
}
|
||||||
|
// ROSEBUSH: 3 points passing through (no turn loss).
|
||||||
|
if (content?.kind === "rosebush" && p.alive && !misted) {
|
||||||
|
applyDamage(state, events, p, 3, "rosebush thorns", null, "physical");
|
||||||
|
checkVictory(state, events);
|
||||||
|
}
|
||||||
|
// HANDFUL OF TACKS: 3 points crossing them.
|
||||||
|
if (content?.kind === "tacks" && p.alive && !misted) {
|
||||||
|
events.push({ type: "steppedOnTacks", player: p.id, at: p.position });
|
||||||
|
applyDamage(state, events, p, 3, "tacks", null, "physical");
|
||||||
|
checkVictory(state, events);
|
||||||
|
}
|
||||||
|
// KILLER OOZE: 1 point on entry; roll 1-2 to slip — drop treasure, 2 more
|
||||||
|
// points, and movement is over.
|
||||||
|
if (content?.kind === "ooze" && p.alive && !misted) {
|
||||||
|
applyDamage(state, events, p, 1, "acidic ooze", null, "physical");
|
||||||
|
if (p.alive) {
|
||||||
|
const [roll, rngNext] = rollDie(state.rng);
|
||||||
|
state.rng = rngNext;
|
||||||
|
if (roll <= 2) {
|
||||||
|
p.fallenInOoze = true;
|
||||||
|
events.push({ type: "slippedInOoze", player: p.id, at: p.position });
|
||||||
|
if (p.carriedTreasureId) {
|
||||||
|
const t = state.treasures.find((t) => t.id === p.carriedTreasureId)!;
|
||||||
|
t.carriedBy = null;
|
||||||
|
t.position = p.position;
|
||||||
|
p.carriedTreasureId = null;
|
||||||
|
events.push({ type: "treasureDropped", player: p.id, treasureId: t.id, at: p.position, onHomeOf: homeOwnerAt(state, p.position) });
|
||||||
|
}
|
||||||
|
applyDamage(state, events, p, 2, "ooze fall", null, "physical");
|
||||||
|
state.turn.movementUsed = state.turn.movementAllowance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
checkVictory(state, events);
|
||||||
|
}
|
||||||
|
// FILL SQUARE WITH SLIME: entering ends your turn's actions.
|
||||||
|
if (content?.kind === "slime" && p.alive && !misted) {
|
||||||
|
events.push({ type: "stuckInSlime", player: p.id, at: p.position });
|
||||||
|
state.turn.actionsEnded = true;
|
||||||
|
}
|
||||||
|
// BOOBYTRAP: the real token detonates under anyone but its caster.
|
||||||
|
for (const trap of [...state.boobytraps]) {
|
||||||
|
if (trap.casterId === p.id) continue;
|
||||||
|
if (cellKey(p.position) === trap.realKey) {
|
||||||
|
state.boobytraps = state.boobytraps.filter((t) => t !== trap);
|
||||||
|
events.push({ type: "boobytrapSprung", player: p.id, at: p.position });
|
||||||
|
applyDamage(state, events, p, 4, "boobytrap", null, "physical");
|
||||||
|
checkVictory(state, events);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return { ok: true, state, events };
|
return { ok: true, state, events };
|
||||||
}
|
}
|
||||||
@@ -2402,7 +2757,7 @@ function doPunch(prev: GameState, targetId: PlayerId): CommandResult {
|
|||||||
|
|
||||||
// BLIND: "...engage in combat..." — a blinded brawler flails on a die
|
// BLIND: "...engage in combat..." — a blinded brawler flails on a die
|
||||||
// roll; the swing connects only on a 1 (same convention as INVISIBLE).
|
// roll; the swing connects only on a 1 (same convention as INVISIBLE).
|
||||||
if (sustainedOn(state, attacker.id, "blind").length > 0) {
|
if (isBlinded(state, attacker)) {
|
||||||
const [roll, rngNext] = rollDie(state.rng);
|
const [roll, rngNext] = rollDie(state.rng);
|
||||||
state.rng = rngNext;
|
state.rng = rngNext;
|
||||||
if (roll !== 1) {
|
if (roll !== 1) {
|
||||||
@@ -2702,7 +3057,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
|||||||
// go intended distance" — if the die disagrees with the true direction,
|
// go intended distance" — if the die disagrees with the true direction,
|
||||||
// the spell hits whoever lies that way, or dissipates.
|
// the spell hits whoever lies that way, or dissipates.
|
||||||
let actualTarget = target;
|
let actualTarget = target;
|
||||||
if (sustainedOn(state, caster.id, "blind").length > 0 &&
|
if (isBlinded(state, caster) &&
|
||||||
cellKey(target.position) !== cellKey(caster.position)) {
|
cellKey(target.position) !== cellKey(caster.position)) {
|
||||||
const dx = target.position.x - caster.position.x;
|
const dx = target.position.x - caster.position.x;
|
||||||
const dy = target.position.y - caster.position.y;
|
const dy = target.position.y - caster.position.y;
|
||||||
@@ -3186,6 +3541,12 @@ function doPickUpTreasure(prev: GameState): CommandResult {
|
|||||||
const state = clone(prev);
|
const state = clone(prev);
|
||||||
const p = activePlayer(state);
|
const p = activePlayer(state);
|
||||||
if (p.carriedTreasureId) return err("you can only carry one treasure at a time");
|
if (p.carriedTreasureId) return err("you can only carry one treasure at a time");
|
||||||
|
const here = cellKey(p.position);
|
||||||
|
if (state.gluedCells[here]) return err("it is glued fast to the floor");
|
||||||
|
const safe = state.squareContents[here]?.kind === "safe";
|
||||||
|
if (safe && state.squareContents[here]!.createdBy !== p.id && !state.openSafes.includes(here)) {
|
||||||
|
return err("it is locked inside a safe");
|
||||||
|
}
|
||||||
const t = state.treasures.find(
|
const t = state.treasures.find(
|
||||||
(t) => t.position && cellKey(t.position) === cellKey(p.position) && !t.carriedBy,
|
(t) => t.position && cellKey(t.position) === cellKey(p.position) && !t.carriedBy,
|
||||||
);
|
);
|
||||||
@@ -3209,6 +3570,11 @@ function doPickUpObject(prev: GameState, instanceId: string): CommandResult {
|
|||||||
const state = clone(prev);
|
const state = clone(prev);
|
||||||
const p = activePlayer(state);
|
const p = activePlayer(state);
|
||||||
const key = cellKey(p.position);
|
const key = cellKey(p.position);
|
||||||
|
if (state.gluedCells[key]) return err("it is glued fast to the floor");
|
||||||
|
if (state.squareContents[key]?.kind === "safe" &&
|
||||||
|
state.squareContents[key]!.createdBy !== p.id && !state.openSafes.includes(key)) {
|
||||||
|
return err("it is locked inside a safe");
|
||||||
|
}
|
||||||
const here = state.groundObjects[key] ?? [];
|
const here = state.groundObjects[key] ?? [];
|
||||||
const idx = here.findIndex((c) => c.instanceId === instanceId);
|
const idx = here.findIndex((c) => c.instanceId === instanceId);
|
||||||
if (idx === -1) return err("that object is not here");
|
if (idx === -1) return err("that object is not here");
|
||||||
@@ -3386,6 +3752,10 @@ function beginTurnFor(state: GameState, events: GameEvent[], index: number): voi
|
|||||||
delete state.createdEdges[s.edge];
|
delete state.createdEdges[s.edge];
|
||||||
events.push({ type: "firewallExpired", edge: s.edge });
|
events.push({ type: "firewallExpired", edge: s.edge });
|
||||||
}
|
}
|
||||||
|
// GLUE dries out: the cell key rides in the same field.
|
||||||
|
if (s.cardId === "glue" && s.edge) {
|
||||||
|
delete state.gluedCells[s.edge];
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3393,6 +3763,25 @@ function beginTurnFor(state: GameState, events: GameEvent[], index: number): voi
|
|||||||
}
|
}
|
||||||
state.sustained = surviving;
|
state.sustained = surviving;
|
||||||
|
|
||||||
|
// KILLER OOZE / PIT: struggle rolls before you may move this turn.
|
||||||
|
let struggleImmobilized = false;
|
||||||
|
if (player.fallenInOoze) {
|
||||||
|
const [roll, rngNext] = rollDie(state.rng);
|
||||||
|
state.rng = rngNext;
|
||||||
|
const stood = roll <= 2;
|
||||||
|
events.push({ type: "struggledInOoze", player: player.id, stood });
|
||||||
|
if (stood) player.fallenInOoze = false;
|
||||||
|
else struggleImmobilized = true;
|
||||||
|
}
|
||||||
|
if (player.inPit) {
|
||||||
|
const [roll, rngNext] = rollDie(state.rng);
|
||||||
|
state.rng = rngNext;
|
||||||
|
const out = roll <= 2;
|
||||||
|
events.push({ type: "climbedFromPit", player: player.id, success: out });
|
||||||
|
if (out) player.inPit = false;
|
||||||
|
else struggleImmobilized = true;
|
||||||
|
}
|
||||||
|
|
||||||
// Movement allowance: SLOW forces 1 (and bars speed enhancements), SHRINK
|
// Movement allowance: SLOW forces 1 (and bars speed enhancements), SHRINK
|
||||||
// forces 2; SPEEDSTONE adds 1 otherwise.
|
// forces 2; SPEEDSTONE adds 1 otherwise.
|
||||||
let allowance = BASE_MOVEMENT;
|
let allowance = BASE_MOVEMENT;
|
||||||
@@ -3403,6 +3792,7 @@ function beginTurnFor(state: GameState, events: GameEvent[], index: number): voi
|
|||||||
allowance = Math.max(0, allowance - 3 * sustainedOn(state, player.id, "sticky-web").length);
|
allowance = Math.max(0, allowance - 3 * sustainedOn(state, player.id, "sticky-web").length);
|
||||||
const slows = sustainedOn(state, player.id, "slow");
|
const slows = sustainedOn(state, player.id, "slow");
|
||||||
if (slows.length > 0) allowance = 1;
|
if (slows.length > 0) allowance = 1;
|
||||||
|
if (struggleImmobilized) allowance = 0;
|
||||||
|
|
||||||
// SLOW: "his attacks [reduce] to every other turn, starting on his next
|
// SLOW: "his attacks [reduce] to every other turn, starting on his next
|
||||||
// turn" — forbidden on the 1st, 3rd, ... slowed turns.
|
// turn" — forbidden on the 1st, 3rd, ... slowed turns.
|
||||||
@@ -3480,6 +3870,7 @@ function doEndTurn(prev: GameState, draw: number): CommandResult {
|
|||||||
events.push({ type: "doorsRelocked", count: state.openDoorEdges.length });
|
events.push({ type: "doorsRelocked", count: state.openDoorEdges.length });
|
||||||
state.openDoorEdges = [];
|
state.openDoorEdges = [];
|
||||||
}
|
}
|
||||||
|
state.openSafes = [];
|
||||||
|
|
||||||
events.push({ type: "turnEnded", player: p.id });
|
events.push({ type: "turnEnded", player: p.id });
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ export interface GameView {
|
|||||||
creatures: CreatureState[];
|
creatures: CreatureState[];
|
||||||
/** Charges left on displayed wands (public), by card instance id. */
|
/** Charges left on displayed wands (public), by card instance id. */
|
||||||
wandCharges: Record<string, number>;
|
wandCharges: Record<string, number>;
|
||||||
|
/** Boobytrap tokens: everyone sees the four; only the caster sees which is real. */
|
||||||
|
boobytraps: { casterId: PlayerId; cells: { x: number; y: number }[]; realCell: { x: number; y: number } | null }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function viewFor(state: GameState, playerId: PlayerId): GameView {
|
export function viewFor(state: GameState, playerId: PlayerId): GameView {
|
||||||
@@ -109,5 +111,13 @@ export function viewFor(state: GameState, playerId: PlayerId): GameView {
|
|||||||
knownIllusionEdges,
|
knownIllusionEdges,
|
||||||
creatures: state.creatures.map((c) => ({ ...c, scorchedThisTurn: [...c.scorchedThisTurn] })),
|
creatures: state.creatures.map((c) => ({ ...c, scorchedThisTurn: [...c.scorchedThisTurn] })),
|
||||||
wandCharges: { ...state.wandCharges },
|
wandCharges: { ...state.wandCharges },
|
||||||
|
boobytraps: state.boobytraps.map((t) => {
|
||||||
|
const [rx, ry] = t.realKey.split(",").map(Number) as [number, number];
|
||||||
|
return {
|
||||||
|
casterId: t.casterId,
|
||||||
|
cells: t.cells.map((c) => ({ ...c })),
|
||||||
|
realCell: t.casterId === playerId ? { x: rx, y: ry } : null,
|
||||||
|
};
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
applyCommand,
|
||||||
|
activePlayer,
|
||||||
|
createGame,
|
||||||
|
boardView,
|
||||||
|
gameLos,
|
||||||
|
type Command,
|
||||||
|
type GameState,
|
||||||
|
type PlayerId,
|
||||||
|
} from "../src/game";
|
||||||
|
import { cellKey, 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", "expansion1"] });
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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("expansion terrain", () => {
|
||||||
|
it("killer ooze burns on entry and can drop you on your face", () => {
|
||||||
|
// Across seeds we should see both slips and clean crossings.
|
||||||
|
let slips = 0, crossings = 0;
|
||||||
|
for (let seed = 1; seed <= 10; seed++) {
|
||||||
|
let { state } = newGame(seed);
|
||||||
|
const me = activePlayer(state);
|
||||||
|
const spot = emptyNeighborCell(state, me.position);
|
||||||
|
const oz = giveCard(state, me.id, "killer-ooze");
|
||||||
|
state = must(state, me.id, {
|
||||||
|
type: "cast", instanceId: oz.instanceId, target: { kind: "cell", cell: spot.cell },
|
||||||
|
});
|
||||||
|
state = must(state, me.id, { type: "move", direction: spot.side });
|
||||||
|
const p = state.players.find((p) => p.id === me.id)!;
|
||||||
|
if (p.fallenInOoze) { slips++; expect(p.life).toBe(12); } // 1 + 2
|
||||||
|
else { crossings++; expect(p.life).toBe(14); }
|
||||||
|
}
|
||||||
|
expect(slips + crossings).toBe(10);
|
||||||
|
expect(slips).toBeGreaterThan(0);
|
||||||
|
expect(crossings).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a pit is jumped on 2-4 and fallen into on a 1", () => {
|
||||||
|
let falls = 0, jumps = 0, teeters = 0;
|
||||||
|
for (let seed = 1; seed <= 12; seed++) {
|
||||||
|
let { state } = newGame(seed);
|
||||||
|
const me = activePlayer(state);
|
||||||
|
const spot = emptyNeighborCell(state, me.position);
|
||||||
|
const pit = giveCard(state, me.id, "create-pit");
|
||||||
|
state = must(state, me.id, {
|
||||||
|
type: "cast", instanceId: pit.instanceId, target: { kind: "cell", cell: spot.cell },
|
||||||
|
});
|
||||||
|
state = must(state, me.id, { type: "move", direction: spot.side });
|
||||||
|
const p = state.players.find((p) => p.id === me.id)!;
|
||||||
|
if (p.inPit) { falls++; expect(p.life).toBe(13); }
|
||||||
|
else if (cellKey(p.position) === cellKey(me.position)) teeters++;
|
||||||
|
else jumps++;
|
||||||
|
}
|
||||||
|
expect(falls + jumps + teeters).toBe(12);
|
||||||
|
expect(falls).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dust cloud blinds anyone standing inside it", () => {
|
||||||
|
let { state } = newGame();
|
||||||
|
const me = activePlayer(state);
|
||||||
|
const spot = emptyNeighborCell(state, me.position);
|
||||||
|
const dc = giveCard(state, me.id, "dust-cloud");
|
||||||
|
state = must(state, me.id, {
|
||||||
|
type: "cast", instanceId: dc.instanceId, target: { kind: "cell", cell: spot.cell },
|
||||||
|
});
|
||||||
|
// LOS through the cloud is blocked.
|
||||||
|
const view = boardView(state);
|
||||||
|
const beyond = { x: spot.cell.x + (spot.cell.x - me.position.x), y: spot.cell.y + (spot.cell.y - me.position.y) };
|
||||||
|
if (view.cells[cellKey(beyond)]) {
|
||||||
|
expect(gameLos(state, activePlayer(state).position, beyond)).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("glue pins a treasure to the floor until it wears off", () => {
|
||||||
|
let { state } = newGame();
|
||||||
|
const me = activePlayer(state);
|
||||||
|
const treasure = state.treasures.find((t) => t.position && t.owner !== me.id)!;
|
||||||
|
me.position = { ...treasure.position! };
|
||||||
|
const gl = giveCard(state, me.id, "glue");
|
||||||
|
giveCard(state, me.id, "number-2", "N", 1);
|
||||||
|
state = must(state, me.id, {
|
||||||
|
type: "cast", instanceId: gl.instanceId, numberInstanceIds: ["number-2#N"],
|
||||||
|
target: { kind: "cell", cell: treasure.position! },
|
||||||
|
});
|
||||||
|
expect(applyCommand(state, me.id, { type: "pickUpTreasure" }).ok).toBe(false);
|
||||||
|
// 2 x 2 = 4 of the caster's turns later, the glue dries out.
|
||||||
|
for (let i = 0; i < 8; i++) {
|
||||||
|
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
|
||||||
|
}
|
||||||
|
state.players.find((p) => p.id === me.id)!.position = { ...treasure.position! };
|
||||||
|
expect(applyCommand(state, me.id, { type: "pickUpTreasure" }).ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a safe locks a treasure away from everyone but its creator", () => {
|
||||||
|
let { state } = newGame();
|
||||||
|
const me = activePlayer(state);
|
||||||
|
const enemy = state.players.find((p) => p.id !== me.id)!;
|
||||||
|
const treasure = state.treasures.find((t) => t.position && t.owner === enemy.id)!;
|
||||||
|
me.position = { ...treasure.position! };
|
||||||
|
const sf = giveCard(state, me.id, "safe");
|
||||||
|
state = must(state, me.id, {
|
||||||
|
type: "cast", instanceId: sf.instanceId, target: { kind: "cell", cell: treasure.position! },
|
||||||
|
});
|
||||||
|
// The enemy cannot take it...
|
||||||
|
state = must(state, me.id, { type: "endTurn", draw: 0 });
|
||||||
|
const e = state.players.find((p) => p.id === enemy.id)!;
|
||||||
|
e.position = { ...treasure.position! };
|
||||||
|
expect(applyCommand(state, enemy.id, { type: "pickUpTreasure" }).ok).toBe(false);
|
||||||
|
// ...but the creator knows the combination.
|
||||||
|
state = must(state, enemy.id, { type: "endTurn", draw: 0 });
|
||||||
|
state.players.find((p) => p.id === me.id)!.position = { ...treasure.position! };
|
||||||
|
expect(applyCommand(state, me.id, { type: "pickUpTreasure" }).ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("boobytrap detonates only under its real token, never under the caster", () => {
|
||||||
|
let { state } = newGame();
|
||||||
|
const me = activePlayer(state);
|
||||||
|
const enemy = state.players.find((p) => p.id !== me.id)!;
|
||||||
|
// Four distinct empty-ish cells: use home-adjacent floor cells of the board.
|
||||||
|
const view = boardView(state);
|
||||||
|
const open: Cell[] = [];
|
||||||
|
for (const key of Object.keys(view.cells)) {
|
||||||
|
const [x, y] = key.split(",").map(Number) as [number, number];
|
||||||
|
const c = { x, y };
|
||||||
|
if (state.squareContents[key]) continue;
|
||||||
|
open.push(c);
|
||||||
|
if (open.length === 4) break;
|
||||||
|
}
|
||||||
|
const bt = giveCard(state, me.id, "boobytrap");
|
||||||
|
state = must(state, me.id, {
|
||||||
|
type: "cast", instanceId: bt.instanceId, params: { cells: open },
|
||||||
|
});
|
||||||
|
// Caster strolls across the real token unharmed.
|
||||||
|
const caster = state.players.find((p) => p.id === me.id)!;
|
||||||
|
caster.position = { ...open[0]! };
|
||||||
|
// (position set directly — trap only triggers on a move; simulate enemy)
|
||||||
|
state = must(state, me.id, { type: "endTurn", draw: 0 });
|
||||||
|
const e = state.players.find((p) => p.id === enemy.id)!;
|
||||||
|
// Stand the enemy adjacent to the real token and step onto it.
|
||||||
|
for (const side of SIDES) {
|
||||||
|
const from = { x: open[0]!.x + (side === "E" ? -1 : side === "W" ? 1 : 0),
|
||||||
|
y: open[0]!.y + (side === "S" ? -1 : side === "N" ? 1 : 0) };
|
||||||
|
if (!view.cells[cellKey(from)]) continue;
|
||||||
|
const t = stepTarget(view, from, side);
|
||||||
|
if (t.kind === "step" && cellKey(t.to) === cellKey(open[0]!)) {
|
||||||
|
e.position = from;
|
||||||
|
const result = applyCommand(state, enemy.id, { type: "move", direction: side });
|
||||||
|
if (result.ok) {
|
||||||
|
state = result.state;
|
||||||
|
const hurt = state.players.find((p) => p.id === enemy.id)!;
|
||||||
|
expect(hurt.life).toBeLessThanOrEqual(11);
|
||||||
|
expect(state.boobytraps.length).toBe(0);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stone to water melts a stone block into a crashing wave", () => {
|
||||||
|
let { state } = newGame();
|
||||||
|
const me = activePlayer(state);
|
||||||
|
const spot = emptyNeighborCell(state, me.position);
|
||||||
|
const fs = giveCard(state, me.id, "fill-square-with-stone");
|
||||||
|
state = must(state, me.id, {
|
||||||
|
type: "cast", instanceId: fs.instanceId, target: { kind: "cell", cell: spot.cell },
|
||||||
|
});
|
||||||
|
const stw = giveCard(state, me.id, "stone-to-water", "SW", 1);
|
||||||
|
state = must(state, me.id, {
|
||||||
|
type: "cast", instanceId: stw.instanceId, target: { kind: "cell", cell: spot.cell },
|
||||||
|
});
|
||||||
|
expect(state.squareContents[cellKey(spot.cell)]).toBeUndefined();
|
||||||
|
// The caster stood beside the block: the wave washed them somewhere (or
|
||||||
|
// crushed them for blocked spaces) — either way life or position changed
|
||||||
|
// is acceptable; assert no crash and the block is gone.
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -29,6 +29,10 @@
|
|||||||
let attachedMods = $state<CardInstance[]>([]);
|
let attachedMods = $state<CardInstance[]>([]);
|
||||||
/** relocate-sector: the sector picked up, awaiting its destination. */
|
/** relocate-sector: the sector picked up, awaiting its destination. */
|
||||||
let pendingSectorFrom = $state<{ x: number; y: number } | null>(null);
|
let pendingSectorFrom = $state<{ x: number; y: number } | null>(null);
|
||||||
|
/** boobytrap: cells picked so far (first is the real one). */
|
||||||
|
let trapCells = $state<{ x: number; y: number }[]>([]);
|
||||||
|
/** trader: first item square picked. */
|
||||||
|
let tradeFrom = $state<{ x: number; y: number } | null>(null);
|
||||||
/** rotate-sector direction. */
|
/** rotate-sector direction. */
|
||||||
let rotateCW = $state(true);
|
let rotateCW = $state(true);
|
||||||
/** Cards marked for discard. */
|
/** Cards marked for discard. */
|
||||||
@@ -43,12 +47,14 @@
|
|||||||
const EDGE_CARDS = new Set([
|
const EDGE_CARDS = new Set([
|
||||||
"create-wall", "destroy-wall", "wall-of-fire", "waterwall",
|
"create-wall", "destroy-wall", "wall-of-fire", "waterwall",
|
||||||
"pick-lock", "jam-lock", "remove-lock", "master-key", "dispel-creation",
|
"pick-lock", "jam-lock", "remove-lock", "master-key", "dispel-creation",
|
||||||
"warp-wand",
|
"warp-wand", "create-door", "stone-to-water",
|
||||||
]);
|
]);
|
||||||
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",
|
"rotate-sector", "relocate-sector",
|
||||||
"troll", "skeleton", "wraith", "fire-imp", "democratic-monster", "shadow",
|
"troll", "skeleton", "wraith", "fire-imp", "democratic-monster", "shadow",
|
||||||
|
"killer-ooze", "rosebush", "dust-cloud", "fill-square-with-slime", "create-pit",
|
||||||
|
"handful-of-tacks", "glue", "safe", "trader", "stone-to-water", "boobytrap",
|
||||||
]);
|
]);
|
||||||
const CREATURE_TARGET_CARDS = new Set(["mega-monster"]);
|
const CREATURE_TARGET_CARDS = new Set(["mega-monster"]);
|
||||||
const MODIFIER_CARDS = new Set(["amplify", "add", "extend", "around-the-corner"]);
|
const MODIFIER_CARDS = new Set(["amplify", "add", "extend", "around-the-corner"]);
|
||||||
@@ -63,6 +69,8 @@
|
|||||||
const numberTotal = $derived(attachedNumber ? cardDef(attachedNumber.cardId).value! : 1);
|
const numberTotal = $derived(attachedNumber ? cardDef(attachedNumber.cardId).value! : 1);
|
||||||
|
|
||||||
function clearSelection() {
|
function clearSelection() {
|
||||||
|
trapCells = [];
|
||||||
|
tradeFrom = null;
|
||||||
selectedCreature = null;
|
selectedCreature = null;
|
||||||
selectedCard = null;
|
selectedCard = null;
|
||||||
attachedNumber = null;
|
attachedNumber = null;
|
||||||
@@ -181,6 +189,23 @@
|
|||||||
clearSelection();
|
clearSelection();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (selectedCard?.cardId === "boobytrap") {
|
||||||
|
trapCells = [...trapCells, cell];
|
||||||
|
if (trapCells.length === 4) {
|
||||||
|
net.command({ type: "cast", instanceId: selectedCard.instanceId, params: { cells: trapCells } });
|
||||||
|
clearSelection();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (selectedCard?.cardId === "trader") {
|
||||||
|
if (!tradeFrom) { tradeFrom = cell; return; }
|
||||||
|
net.command({
|
||||||
|
type: "cast", instanceId: selectedCard.instanceId,
|
||||||
|
target: { kind: "cell", cell }, params: { cell: tradeFrom },
|
||||||
|
});
|
||||||
|
clearSelection();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (selectedCard?.cardId === "rotate-sector") {
|
if (selectedCard?.cardId === "rotate-sector") {
|
||||||
net.command({
|
net.command({
|
||||||
type: "cast", instanceId: selectedCard.instanceId,
|
type: "cast", instanceId: selectedCard.instanceId,
|
||||||
@@ -457,6 +482,12 @@
|
|||||||
<label><input type="checkbox" bind:checked={rotateCW} /> clockwise</label>
|
<label><input type="checkbox" bind:checked={rotateCW} /> clockwise</label>
|
||||||
— click a square in the sector to rotate
|
— click a square in the sector to rotate
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if selectedCard?.cardId === "boobytrap"}
|
||||||
|
— place 4 tokens ({trapCells.length}/4; the FIRST is the real trap)
|
||||||
|
{/if}
|
||||||
|
{#if selectedCard?.cardId === "trader"}
|
||||||
|
{tradeFrom ? "— now the second item square" : "— click the first item square"}
|
||||||
|
{/if}
|
||||||
{#if selectedCard?.cardId === "relocate-sector"}
|
{#if selectedCard?.cardId === "relocate-sector"}
|
||||||
{#if pendingSectorFrom}
|
{#if pendingSectorFrom}
|
||||||
— now click the destination area
|
— now click the destination area
|
||||||
|
|||||||
@@ -111,11 +111,31 @@
|
|||||||
{@const sy = Number(key.split(",")[1])}
|
{@const sy = Number(key.split(",")[1])}
|
||||||
{#if content.kind === "stone"}
|
{#if content.kind === "stone"}
|
||||||
<rect x={sx * CELL + 2} y={sy * CELL + 2} width={CELL - 4} height={CELL - 4} class="stone" rx="4" />
|
<rect x={sx * CELL + 2} y={sy * CELL + 2} width={CELL - 4} height={CELL - 4} class="stone" rx="4" />
|
||||||
{:else}
|
{:else if content.kind === "thornbush" || content.kind === "rosebush"}
|
||||||
<circle cx={sx * CELL + CELL / 2} cy={sy * CELL + CELL / 2} r={CELL * 0.36} class="bush" />
|
<circle cx={sx * CELL + CELL / 2} cy={sy * CELL + CELL / 2} r={CELL * 0.36}
|
||||||
|
class={content.kind === "rosebush" ? "rose" : "bush"} />
|
||||||
|
{:else if content.kind === "ooze" || content.kind === "slime"}
|
||||||
|
<rect x={sx * CELL + 5} y={sy * CELL + 5} width={CELL - 10} height={CELL - 10} rx="10"
|
||||||
|
class={content.kind === "ooze" ? "ooze" : "slime"} />
|
||||||
|
{:else if content.kind === "dust"}
|
||||||
|
<circle cx={sx * CELL + CELL / 2} cy={sy * CELL + CELL / 2} r={CELL * 0.4} class="dust" />
|
||||||
|
{:else if content.kind === "pit"}
|
||||||
|
<rect x={sx * CELL + 7} y={sy * CELL + 7} width={CELL - 14} height={CELL - 14} class="pit" />
|
||||||
|
{:else if content.kind === "tacks"}
|
||||||
|
<text x={sx * CELL + CELL / 2} y={sy * CELL + CELL / 2 + 4} class="tacks">✻✻</text>
|
||||||
|
{:else if content.kind === "safe"}
|
||||||
|
<rect x={sx * CELL + 10} y={sy * CELL + 12} width={CELL - 20} height={CELL - 22} rx="3" class="safe" />
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
|
<!-- boobytrap tokens: face-down for everyone (the caster knows the real one) -->
|
||||||
|
{#each view.boobytraps as trap, ti (ti)}
|
||||||
|
{#each trap.cells as tc, i (i)}
|
||||||
|
<circle cx={tc.x * CELL + CELL * 0.26} cy={tc.y * CELL + CELL * 0.26} r="6"
|
||||||
|
class="trap-token" class:trap-real={trap.realCell != null && tc.x === trap.realCell.x && tc.y === trap.realCell.y} />
|
||||||
|
{/each}
|
||||||
|
{/each}
|
||||||
|
|
||||||
<!-- ground objects -->
|
<!-- ground objects -->
|
||||||
{#each Object.entries(view.groundObjects) as [key, objects] (key)}
|
{#each Object.entries(view.groundObjects) as [key, objects] (key)}
|
||||||
{@const gx = Number(key.split(",")[0])}
|
{@const gx = Number(key.split(",")[0])}
|
||||||
@@ -245,6 +265,15 @@
|
|||||||
.firewall { fill: #e0442a; }
|
.firewall { fill: #e0442a; }
|
||||||
.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; }
|
||||||
|
.rose { fill: #2e7d32; stroke: #b0245a; stroke-width: 3; }
|
||||||
|
.ooze { fill: #58a12b; opacity: 0.85; }
|
||||||
|
.slime { fill: #8ec52e; opacity: 0.85; }
|
||||||
|
.dust { fill: #9b9184; opacity: 0.75; }
|
||||||
|
.pit { fill: #171512; }
|
||||||
|
.tacks { font-size: 15px; text-anchor: middle; fill: #444; }
|
||||||
|
.safe { fill: #7d8894; stroke: #2f3844; stroke-width: 2; }
|
||||||
|
.trap-token { fill: #513c22; stroke: #201709; stroke-width: 1.5; }
|
||||||
|
.trap-real { stroke: #d3352b; stroke-width: 2.5; }
|
||||||
.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; }
|
.illusion { stroke: #7a6f9a; stroke-width: 4; stroke-dasharray: 6 5; opacity: 0.7; }
|
||||||
.creature { cursor: pointer; }
|
.creature { cursor: pointer; }
|
||||||
|
|||||||
Reference in New Issue
Block a user