Compare commits
@@ -0,0 +1,59 @@
|
||||
---
|
||||
name: wizwar-audits
|
||||
description: Run the Wiz-War health audits — UI coverage of engine events/cards (the "telepath audit"), live-game ledger inspection by room code, and the pre-deploy determinism gate. Use when asked to audit, when adding cards or events, or when a player reports "the engine did X but I never saw it".
|
||||
---
|
||||
|
||||
# Wiz-War audits
|
||||
|
||||
Three recurring audits keep the maze honest. The first is self-enforcing;
|
||||
the other two are on-demand.
|
||||
|
||||
## 1. UI coverage — "the telepath audit" (automated)
|
||||
|
||||
`packages/engine/test/ui-coverage.test.ts` runs with every `npm test` and
|
||||
fails when:
|
||||
|
||||
- a GameEvent type has no client handling (no `humanize` case in
|
||||
`net.svelte.ts`, no fx in `fx.ts`, no modal check in `App.svelte`) and is
|
||||
not in the test's `SILENT_BY_DESIGN` allowlist — each allowlist entry
|
||||
needs a reason for why the player sees the information another way;
|
||||
- a card resolver demanding a cell/edge target is missing from App's
|
||||
`CELL_CARDS` / `EDGE_CARDS` click-targeting sets (an unaimable cast).
|
||||
|
||||
When adding an **event**: give it a `humanize` line at minimum. Private
|
||||
info (`visibleTo` events carrying cards) deserves the `cardReveal` modal
|
||||
in App.svelte — see `handRevealedPrivate` / `cardsStolenPrivate` /
|
||||
`handTakenPrivate` for the pattern. When adding a **card** with a cell or
|
||||
edge target, add it to the App targeting set; with `params`, wire the
|
||||
named-card picker (`NAMED_CARDS` + suggestions) or a bespoke control.
|
||||
|
||||
The one axis the test cannot judge: whether a `params`-taking card's
|
||||
input UI actually offers sensible choices. Check that by hand when adding
|
||||
one (thief → target's steallables, deja-vu → discard contents, etc.).
|
||||
|
||||
## 2. Live-game audit by room code
|
||||
|
||||
Fetch and replay production ledgers (details in auto-memory
|
||||
`wizwar-fetch-game-files`):
|
||||
|
||||
scp root@104.236.96.198:/var/lib/wizwar/rooms/<CODE>.jsonl <scratchpad>/
|
||||
|
||||
Replay with `createGame({playerIds, seed, sets, colors, deckRev})` +
|
||||
`applyCommand` per command line (see `deploy/replay-verify.mjs`). Stop at
|
||||
any seq to inspect full state. To ask why a bot did something, rebuild
|
||||
the state at its turn and call `automatonCommand(viewFor(state, id),
|
||||
style, tier)` — and if its choice differs from the ledger, the engine
|
||||
refused it and the fallback burned the turn (the X2XN pattern).
|
||||
|
||||
For "which games are open/stalled" sweeps: fetch all `*.jsonl`, replay
|
||||
each, and report phase / round / humans vs bots / last command's `at`.
|
||||
|
||||
## 3. Determinism gate (before deploying engine changes)
|
||||
|
||||
deploy/verify-ledgers.sh 104.236.96.198
|
||||
|
||||
Strict-replays every production ledger against the local engine; one
|
||||
refused command fails. A room whose ledger no longer replays becomes
|
||||
unreachable after restart. Rules changes while games are live need a
|
||||
`deckRev` bump plus an engine gate (the convention survives the 2026-08
|
||||
reset to rev 1).
|
||||
@@ -4,5 +4,6 @@ dist/
|
||||
.env
|
||||
.DS_Store
|
||||
data/
|
||||
.claude/
|
||||
.claude/*
|
||||
!.claude/skills/
|
||||
.playwright-mcp/
|
||||
|
||||
@@ -23,6 +23,14 @@ research), installs dependencies on the droplet, and restarts the service.
|
||||
Games in progress survive: state lives in room files, and clients reconnect
|
||||
automatically.
|
||||
|
||||
Before any deploy that touches the engine, run the determinism gate:
|
||||
|
||||
deploy/verify-ledgers.sh 104.236.96.198
|
||||
|
||||
It fetches every production ledger and strictly replays it against the local
|
||||
engine; a single refused command fails the check. A room whose ledger no
|
||||
longer replays becomes unreachable after restart, so this is not optional.
|
||||
|
||||
## New droplet from scratch
|
||||
|
||||
1. `doctl compute droplet create wizwar --region nyc3 --size s-1vcpu-1gb \
|
||||
@@ -35,4 +43,10 @@ automatically.
|
||||
|
||||
- Logs: `ssh root@<ip> journalctl -u wizwar -f`
|
||||
- Restart: `ssh root@<ip> systemctl restart wizwar`
|
||||
- Backup games: `scp -r root@<ip>:/var/lib/wizwar/rooms ./rooms-backup`
|
||||
- Nightly backups: `wizwar-backup.sh` runs from root's crontab at 07:17 UTC,
|
||||
pushing /var/lib/wizwar to the `kestrel-wizwar-backups` Space (nyc3) via
|
||||
rclone. It is installed at /usr/local/bin/wizwar-backup.sh on the droplet;
|
||||
`setup-droplet.sh` does NOT install it — on a fresh droplet, copy the
|
||||
script, configure rclone (the script refuses to run while the config still
|
||||
holds its CHANGE_ME placeholder), and add the cron entry by hand.
|
||||
- One-off backup: `scp -r root@<ip>:/var/lib/wizwar/rooms ./rooms-backup`
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
// Strict full-ledger replay: ANY refused command is a hard failure.
|
||||
import { readFileSync } from "fs";
|
||||
import { createGame, applyCommand } from "../packages/engine/src/game.ts";
|
||||
const lines = readFileSync(process.argv[2], "utf8").trim().split("\n").map((l) => JSON.parse(l));
|
||||
const meta = lines.find((l) => l.kind === "meta");
|
||||
const start = lines.find((l) => l.kind === "start");
|
||||
if (!start) {
|
||||
console.log(`OK ${process.argv[2].split("/").pop()}: lobby only, nothing to replay`);
|
||||
process.exit(0);
|
||||
}
|
||||
const joins = lines.filter((l) => l.kind === "join");
|
||||
const playerIds = [...new Set([meta.hostId, ...joins.map((j) => j.name)])];
|
||||
let { state } = createGame({ playerIds, seed: meta.seed, sets: start.expansion ? ["basic", "expansion1"] : ["basic"], colors: start.colors, deckRev: start.deckRev });
|
||||
let n = 0;
|
||||
for (const l of lines) {
|
||||
if (l.kind !== "command") continue;
|
||||
const r = applyCommand(state, l.playerId, l.command);
|
||||
if (!r.ok) { console.log(`FAIL ${process.argv[2].split("/").pop()} seq ${l.seq}: ${r.error}`); process.exit(1); }
|
||||
state = r.state; n++;
|
||||
}
|
||||
console.log(`OK ${process.argv[2].split("/").pop()}: ${n} commands replay clean`);
|
||||
@@ -0,0 +1,21 @@
|
||||
#!/bin/bash
|
||||
# Pre-deploy determinism check: fetch every production ledger and strictly
|
||||
# replay it against the LOCAL engine. Any refused command is a hard failure.
|
||||
# Run from the repo root before deploying any engine change.
|
||||
set -euo pipefail
|
||||
HOST="${1:-104.236.96.198}"
|
||||
TMP=$(mktemp -d)
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
scp -q "root@$HOST:/var/lib/wizwar/rooms/*.jsonl" "$TMP/"
|
||||
fails=0
|
||||
for f in "$TMP"/*.jsonl; do
|
||||
# `|| true`: a failing replay must feed the tally, not abort the loop.
|
||||
out=$(npx tsx deploy/replay-verify.mjs "$f" 2>&1 | tail -1) || true
|
||||
case "$out" in
|
||||
OK*) ;;
|
||||
*) echo "${out:-$(basename "$f"): replay crashed with no output}"; fails=$((fails+1));;
|
||||
esac
|
||||
done
|
||||
count=$(ls "$TMP" | wc -l | tr -d ' ')
|
||||
echo "--- $count ledgers checked, $fails failures"
|
||||
[ "$fails" -eq 0 ]
|
||||
@@ -0,0 +1,32 @@
|
||||
#!/bin/bash
|
||||
# Nightly wiz-war ledger backup to DigitalOcean Spaces.
|
||||
# current/ - exact mirror of /var/lib/wizwar
|
||||
# snapshots/ - one dated copy per day, pruned after 90 days
|
||||
# Ledgers are append-only JSONL; the game server never needs stopping.
|
||||
set -u
|
||||
BUCKET="kestrel-wizwar-backups"
|
||||
SRC="/var/lib/wizwar"
|
||||
LOG="/var/log/wizwar/backup.log"
|
||||
STAMP=$(date +%Y-%m-%d)
|
||||
|
||||
if grep -q CHANGE_ME /root/.config/rclone/rclone.conf; then
|
||||
echo "$(date -Is) skipped: Spaces credentials not configured yet" >> "$LOG"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
{
|
||||
echo "=== $(date -Is) backup start"
|
||||
rclone sync "$SRC" "spaces:$BUCKET/wizwar/current" 2>&1
|
||||
rclone copy "$SRC" "spaces:$BUCKET/wizwar/snapshots/$STAMP" 2>&1
|
||||
# Prune dated snapshots older than 90 days (by directory name).
|
||||
CUTOFF=$(date -d "90 days ago" +%Y-%m-%d)
|
||||
rclone lsf "spaces:$BUCKET/wizwar/snapshots/" --dirs-only 2>/dev/null | \
|
||||
while read -r d; do
|
||||
day="${d%/}"
|
||||
if [[ "$day" < "$CUTOFF" ]]; then
|
||||
echo "pruning snapshot $day"
|
||||
rclone purge "spaces:$BUCKET/wizwar/snapshots/$day" 2>&1
|
||||
fi
|
||||
done
|
||||
echo "=== $(date -Is) backup done"
|
||||
} >> "$LOG"
|
||||
@@ -360,6 +360,41 @@ export function hasWarpLineOfSight(
|
||||
to: Cell,
|
||||
blockedCells?: Record<string, true>,
|
||||
): boolean {
|
||||
return warpSightTrace(board, from, to, blockedCells) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* How a sight line reached its target — the material for drawing it. A warp
|
||||
* trace carries the two mouths and the exact rim-crossing points (in board
|
||||
* coordinates, where cell (x,y) spans [x,x+1]) so a renderer can draw the
|
||||
* near leg to `entry` and the far leg from `exit`.
|
||||
*/
|
||||
export type SightTrace =
|
||||
| { kind: "direct" }
|
||||
| {
|
||||
kind: "warp";
|
||||
mouthA: { cell: Cell; side: Side };
|
||||
mouthB: { cell: Cell; side: Side };
|
||||
entry: { x: number; y: number };
|
||||
exit: { x: number; y: number };
|
||||
};
|
||||
|
||||
export function traceSight(
|
||||
board: AssembledBoard,
|
||||
from: Cell,
|
||||
to: Cell,
|
||||
blockedCells?: Record<string, true>,
|
||||
): SightTrace | null {
|
||||
if (hasLineOfSight(board, from, to, blockedCells)) return { kind: "direct" };
|
||||
return warpSightTrace(board, from, to, blockedCells);
|
||||
}
|
||||
|
||||
function warpSightTrace(
|
||||
board: AssembledBoard,
|
||||
from: Cell,
|
||||
to: Cell,
|
||||
blockedCells?: Record<string, true>,
|
||||
): Extract<SightTrace, { kind: "warp" }> | null {
|
||||
const DIR: Record<Side, { x: number; y: number }> = {
|
||||
N: { x: 0, y: -1 }, S: { x: 0, y: 1 }, E: { x: 1, y: 0 }, W: { x: -1, y: 0 },
|
||||
};
|
||||
@@ -372,6 +407,9 @@ export function hasWarpLineOfSight(
|
||||
// A filled mouth chokes the tunnel unless the viewer/target IS the mouth.
|
||||
if (blockedCells?.[cellKey(mouthA)] && cellKey(from) !== cellKey(mouthA)) continue;
|
||||
if (blockedCells?.[cellKey(mouthB)] && cellKey(to) !== cellKey(mouthB)) continue;
|
||||
// A wall built over either mouth seals the tunnel to sight entirely.
|
||||
if (edgeState(board, mouthA, sideA) !== "open") continue;
|
||||
if (edgeState(board, mouthB, w.to.side) !== "open") continue;
|
||||
|
||||
// Rotation taking the far board's inward direction onto sideA.
|
||||
const u = DIR[inward], v = DIR[sideA];
|
||||
@@ -420,10 +458,16 @@ export function hasWarpLineOfSight(
|
||||
segmentClear(board, Pfar.x, Pfar.y, to.x + 0.5, to.y + 0.5, blockedCells,
|
||||
[cellKey(to), cellKey(mouthB)])
|
||||
) {
|
||||
return true;
|
||||
return {
|
||||
kind: "warp",
|
||||
mouthA: { cell: mouthA, side: sideA },
|
||||
mouthB: { cell: mouthB, side: w.to.side },
|
||||
entry: P,
|
||||
exit: Pfar,
|
||||
};
|
||||
}
|
||||
}
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The game's full line-of-sight check: direct, or through a wraparound opening. */
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import cardsData from "../data/cards.json";
|
||||
|
||||
export type CardSet = "basic" | "expansion1" | "expansion2";
|
||||
export type CardSet = "basic" | "expansion1";
|
||||
export type CardType =
|
||||
| "attack"
|
||||
| "neutral"
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
// The server sends this after every state change; clients never see the
|
||||
// deck order or other players' hands.
|
||||
|
||||
import { sightBetween, type AssembledBoard } from "./board";
|
||||
import { type CardInstance } from "./cards";
|
||||
import { sightBetween, traceSight, type AssembledBoard, type Cell, type SightTrace } from "./board";
|
||||
import { cardDef, type CardInstance } from "./cards";
|
||||
import {
|
||||
boardView,
|
||||
LOS_BLOCKING_CONTENT,
|
||||
@@ -28,6 +28,8 @@ export interface PlayerPublicView {
|
||||
carriedTreasureId: string | null;
|
||||
lostTurns: number;
|
||||
extraTurns: number;
|
||||
/** Banked PASS THROUGH WALL crossings — cast openly, so public knowledge. */
|
||||
passWallCharges: number;
|
||||
displayed: CardInstance[];
|
||||
/** Which of the six physical wizard colors this player plays. */
|
||||
colorIndex: number;
|
||||
@@ -40,8 +42,6 @@ export interface GameView {
|
||||
winReason: "treasures" | "lastStanding" | null;
|
||||
turn: TurnState;
|
||||
activePlayerId: PlayerId;
|
||||
/** The game's rules revision — the client mirrors rev-gated legality. */
|
||||
deckRev: number;
|
||||
/** Board with dynamic wall changes already merged in. */
|
||||
board: AssembledBoard;
|
||||
players: PlayerPublicView[];
|
||||
@@ -57,13 +57,24 @@ export interface GameView {
|
||||
/** Duration spells in play (public knowledge). */
|
||||
sustained: SustainedEffect[];
|
||||
squareContents: Record<string, SquareContent>;
|
||||
/** Cells whose contents are glued down (public: the cast was seen). */
|
||||
gluedCells: Record<string, true>;
|
||||
/** Safes standing open (their combination entered this turn). */
|
||||
openSafes: string[];
|
||||
groundObjects: Record<string, CardInstance[]>;
|
||||
doorStates: Record<string, "jammed" | "removed">;
|
||||
/** Accumulated attack damage per edge (public — cracks show). */
|
||||
wallDamage: Record<string, number>;
|
||||
openDoorEdges: string[];
|
||||
/** Door edges held open by a standing wizard. */
|
||||
heldDoorEdges: string[];
|
||||
/** Edges conjured into being (walls, doors, firewalls) - dispellable. */
|
||||
createdEdges: string[];
|
||||
/** Illusion edges YOU know are fake (creator or saw through); others see walls. */
|
||||
knownIllusionEdges: string[];
|
||||
/** Every illusion edge and YOUR verdict on it: untested
|
||||
* shimmers, believes renders solid, mine/seesThrough are ghosts. */
|
||||
illusionEdges: Record<string, "untested" | "believes" | "seesThrough" | "mine">;
|
||||
creatures: CreatureState[];
|
||||
/** Charges left on displayed wands (public), by card instance id. */
|
||||
wandCharges: Record<string, number>;
|
||||
@@ -73,8 +84,8 @@ export interface GameView {
|
||||
outOfTurnWindow: { playerId: PlayerId; kind: "interrupt" | "opportunity-fire" } | null;
|
||||
/** CHAOS shield windows in progress (public: everyone sees it coming). */
|
||||
chaosPending: { casterId: PlayerId; queue: PlayerId[] } | null;
|
||||
/** Whether YOUR ward is set to spring. */
|
||||
yourWardArmed: boolean;
|
||||
/** A grab hangs while the treasure's owner decides their Ward. */
|
||||
wardPending: { ownerId: PlayerId; takerId: PlayerId } | null;
|
||||
/** YOUR armed ambushes. Other players' ambushes are invisible. */
|
||||
yourAmbushes: AmbushState[];
|
||||
/** Once the game is finished, every hand goes face-up on the table. */
|
||||
@@ -84,11 +95,17 @@ export interface GameView {
|
||||
export function viewFor(state: GameState, playerId: PlayerId): GameView {
|
||||
const you = state.players.find((p) => p.id === playerId);
|
||||
// Illusion walls render as real walls unless this viewer knows better.
|
||||
// Their locations are open knowledge (the cast is public at a table) —
|
||||
// what stays personal is each player's verdict.
|
||||
const base = boardView(state);
|
||||
const knownIllusionEdges: string[] = [];
|
||||
const illusionEdges: Record<string, "untested" | "believes" | "seesThrough" | "mine"> = {};
|
||||
let edges = base.edges;
|
||||
for (const [key, wall] of Object.entries(state.illusionWalls)) {
|
||||
const knows = wall.createdBy === playerId || wall.belief[playerId] === "seesThrough";
|
||||
illusionEdges[key] =
|
||||
wall.createdBy === playerId ? "mine"
|
||||
: wall.belief[playerId] ?? "untested";
|
||||
if (knows) {
|
||||
knownIllusionEdges.push(key);
|
||||
} else {
|
||||
@@ -116,13 +133,13 @@ export function viewFor(state: GameState, playerId: PlayerId): GameView {
|
||||
carriedTreasureId: p.carriedTreasureId,
|
||||
lostTurns: p.lostTurns,
|
||||
extraTurns: p.extraTurns,
|
||||
passWallCharges: p.passWallCharges,
|
||||
displayed: p.hand.filter((c) => p.displayed.includes(c.instanceId)),
|
||||
})),
|
||||
yourHand: you ? [...you.hand] : [],
|
||||
revealedHands: state.phase === "finished"
|
||||
? Object.fromEntries(state.players.map((p) => [p.id, p.alive ? [...p.hand] : [...(p.finalHand ?? p.hand)]]))
|
||||
: null,
|
||||
deckRev: state.config.deckRev ?? 1,
|
||||
treasures: state.treasures.map((t) => ({ ...t })),
|
||||
deckCount: state.deck.length,
|
||||
discardCount: state.discard.length,
|
||||
@@ -131,13 +148,18 @@ export function viewFor(state: GameState, playerId: PlayerId): GameView {
|
||||
pendingDiscard: state.pendingDiscard,
|
||||
sustained: state.sustained.map((s) => ({ ...s })),
|
||||
squareContents: { ...state.squareContents },
|
||||
gluedCells: { ...state.gluedCells },
|
||||
openSafes: [...state.openSafes],
|
||||
groundObjects: Object.fromEntries(
|
||||
Object.entries(state.groundObjects).map(([k, v]) => [k, [...v]]),
|
||||
),
|
||||
doorStates: { ...state.doorStates },
|
||||
wallDamage: { ...state.wallDamage },
|
||||
openDoorEdges: [...state.openDoorEdges],
|
||||
heldDoorEdges: state.heldDoors.map((h) => h.key),
|
||||
createdEdges: Object.keys(state.createdEdges),
|
||||
knownIllusionEdges,
|
||||
illusionEdges,
|
||||
creatures: state.creatures.map((c) => ({ ...c, scorchedThisTurn: [...c.scorchedThisTurn] })),
|
||||
wandCharges: { ...state.wandCharges },
|
||||
dimWarps: state.dimWarps.map((w) => ({ a: { ...w.a }, b: { ...w.b } })),
|
||||
@@ -145,7 +167,7 @@ export function viewFor(state: GameState, playerId: PlayerId): GameView {
|
||||
chaosPending: state.chaosPending
|
||||
? { casterId: state.chaosPending.casterId, queue: [...state.chaosPending.queue] }
|
||||
: null,
|
||||
yourWardArmed: state.wardArmed.includes(playerId),
|
||||
wardPending: state.wardPending ? { ...state.wardPending } : null,
|
||||
yourAmbushes: state.ambushes
|
||||
.filter((a) => a.ownerId === playerId)
|
||||
.map((a) => ({ ...a, numbers: [...a.numbers] })),
|
||||
@@ -170,6 +192,23 @@ export function sightedCellsFor(view: GameView): Set<string> {
|
||||
const out = new Set<string>();
|
||||
const me = view.players.find((p) => p.id === view.you);
|
||||
if (!me) return out;
|
||||
const { board, blockers } = sightBasis(view);
|
||||
for (const key of Object.keys(board.cells)) {
|
||||
const [x, y] = key.split(",").map(Number) as [number, number];
|
||||
if (sightBetween(board, me.position, { x, y }, blockers)) out.add(key);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The board-as-seen and sight blockers this view's sight rules run against. */
|
||||
function sightBasis(view: GameView): { board: GameView["board"]; blockers: Record<string, true> } {
|
||||
// Held-open doors are open doorways to the eye.
|
||||
let board = view.board;
|
||||
if (view.heldDoorEdges.length > 0) {
|
||||
const edges = { ...board.edges };
|
||||
for (const k of view.heldDoorEdges) delete edges[k];
|
||||
board = { ...board, edges };
|
||||
}
|
||||
const blockers: Record<string, true> = {};
|
||||
for (const [key, content] of Object.entries(view.squareContents)) {
|
||||
if (LOS_BLOCKING_CONTENT[content.kind]) blockers[key] = true;
|
||||
@@ -179,11 +218,57 @@ export function sightedCellsFor(view: GameView): Set<string> {
|
||||
blockers[`${p.position.x},${p.position.y}`] = true;
|
||||
}
|
||||
}
|
||||
for (const key of Object.keys(view.board.cells)) {
|
||||
const [x, y] = key.split(",").map(Number) as [number, number];
|
||||
if (sightBetween(view.board, me.position, { x, y }, blockers)) out.add(key);
|
||||
return { board, blockers };
|
||||
}
|
||||
|
||||
/**
|
||||
* How one square sees another under this viewer's knowledge of the board
|
||||
* (believed illusion walls block; held doors admit). Null when no sight
|
||||
* exists — the renderer's material for drawing the line an attack traveled.
|
||||
*/
|
||||
export function traceSightFor(view: GameView, from: Cell, to: Cell): SightTrace | null {
|
||||
const { board, blockers } = sightBasis(view);
|
||||
return traceSight(board, from, to, blockers);
|
||||
}
|
||||
|
||||
/**
|
||||
* AROUND THE CORNER's bent sight, from this viewer's knowledge: the caster
|
||||
* sees a middle square which sees the target — mirroring the engine's
|
||||
* bentLos so a client can predict whether the modifier will land.
|
||||
*/
|
||||
export function bentSightFor(view: GameView, from: Cell, to: Cell): boolean {
|
||||
const { board, blockers } = sightBasis(view);
|
||||
if (sightBetween(board, from, to, blockers)) return true;
|
||||
for (const key of Object.keys(board.cells)) {
|
||||
if (blockers[key]) continue;
|
||||
const [mx, my] = key.split(",").map(Number) as [number, number];
|
||||
const mid = { x: mx, y: my };
|
||||
if (sightBetween(board, from, mid, blockers) && sightBetween(board, mid, to, blockers)) {
|
||||
return true;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* The sight line behind the attack currently on the stack — the board's
|
||||
* answer to "how can he even see me?". Null when nothing should draw:
|
||||
* no stack, a creature's or physical attack, a non-LOS card, attacker and
|
||||
* defender sharing a square, or no sight under this viewer's knowledge
|
||||
* (a believed illusion wall can honestly hide the line).
|
||||
*/
|
||||
export function stackSightTrace(
|
||||
view: GameView,
|
||||
): { from: Cell; to: Cell; trace: SightTrace } | null {
|
||||
const stack = view.stack;
|
||||
if (!stack || stack.creatureId) return null;
|
||||
if (!stack.attackCard || cardDef(stack.attackCard.cardId).los !== true) return null;
|
||||
const a = view.players.find((p) => p.id === stack.attackerId);
|
||||
const d = view.players.find((p) => p.id === stack.defenderId);
|
||||
if (!a || !d) return null;
|
||||
if (a.position.x === d.position.x && a.position.y === d.position.y) return null;
|
||||
const trace = traceSightFor(view, a.position, d.position);
|
||||
return trace ? { from: a.position, to: d.position, trace } : null;
|
||||
}
|
||||
|
||||
const CREATION_CARD_IDS = new Set([
|
||||
@@ -280,8 +365,19 @@ export function eligibleCellsFor(view: GameView, cardId: string): Set<string> |
|
||||
return out;
|
||||
}
|
||||
|
||||
if (cardId === "thumb-of-god") {
|
||||
// The die is aimed by sight; where it lands after drifting is fate's.
|
||||
return sighted;
|
||||
}
|
||||
if (cardId === "stone-to-water") {
|
||||
return new Set(cells.filter((k) => view.squareContents[k]?.kind === "stone"));
|
||||
// The cast demands sight of the stone block, so only sighted ones light.
|
||||
// The card equally targets stone WALLS — edges the cell-shadow cannot
|
||||
// express — so with no stone square in view, dimming would shroud the
|
||||
// true targets: light everything instead.
|
||||
const out = new Set(
|
||||
cells.filter((k) => view.squareContents[k]?.kind === "stone" && sighted.has(k)),
|
||||
);
|
||||
return out.size > 0 ? out : null;
|
||||
}
|
||||
if (cardId === "dispel-creation") {
|
||||
// Anything created — terrain or creature, whoever made it — in sight.
|
||||
|
||||
@@ -2,11 +2,14 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
applyCommand,
|
||||
createGame,
|
||||
sustainedOn,
|
||||
type GameState,
|
||||
type PlayerId,
|
||||
} from "../src/game";
|
||||
import { viewFor } from "../src/view";
|
||||
import { cellKey, edgeKey } from "../src/board";
|
||||
import { sightedCellsFor, viewFor } from "../src/view";
|
||||
import { automatonCommand, automatonFallback, type AutomatonStyle, type AutomatonTier } from "../src/automaton";
|
||||
import { pushSustained } from "./helpers";
|
||||
|
||||
/** Whose input does the maze want right now? */
|
||||
function actingSeat(state: GameState): PlayerId {
|
||||
@@ -31,7 +34,6 @@ function playOut(
|
||||
playerIds: ids,
|
||||
seed,
|
||||
sets: expansion ? ["basic", "expansion1"] : ["basic"],
|
||||
deckRev: 8,
|
||||
});
|
||||
let commands = 0;
|
||||
let stuck = 0;
|
||||
@@ -117,9 +119,8 @@ describe("automaton vs automaton", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("the clockwork does not waste counters on pointless targets", () => {
|
||||
function underAttack(attackId: string, defenderHand: { instanceId: string; cardId: string }[], rig?: (s: any, defender: string) => void) {
|
||||
let { state } = createGame({ playerIds: ["human", "bot"], seed: 42, sets: ["basic", "expansion1"], deckRev: 13 });
|
||||
function underAttack(attackId: string, defenderHand: { instanceId: string; cardId: string }[], rig?: (s: GameState, defender: string) => void) {
|
||||
let { state } = createGame({ playerIds: ["human", "bot"], seed: 42, sets: ["basic", "expansion1"] });
|
||||
// burn round 1
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
||||
@@ -131,8 +132,8 @@ describe("the clockwork does not waste counters on pointless targets", () => {
|
||||
if (!r.ok) throw new Error(`setup: ${r.error}`);
|
||||
state = r.state;
|
||||
}
|
||||
const human = state.players.find((p: { id: string }) => p.id === "human")!;
|
||||
const bot = state.players.find((p: { id: string }) => p.id === "bot")!;
|
||||
const human = state.players.find((p) => p.id === "human")!;
|
||||
const bot = state.players.find((p) => p.id === "bot")!;
|
||||
bot.position = { ...human.position };
|
||||
defenderHand.forEach((c, i) => { bot.hand[i] = c; });
|
||||
human.hand[0] = { instanceId: `${attackId}#A`, cardId: attackId };
|
||||
@@ -143,8 +144,9 @@ describe("the clockwork does not waste counters on pointless targets", () => {
|
||||
});
|
||||
if (!r.ok) throw new Error(`setup: ${r.error}`);
|
||||
return r.state;
|
||||
}
|
||||
}
|
||||
|
||||
describe("the clockwork does not waste counters on pointless targets", () => {
|
||||
it("passes on drop-object rather than absorbing nothing", () => {
|
||||
const state = underAttack("drop-object", [
|
||||
{ instanceId: "absorb#T", cardId: "absorb" },
|
||||
@@ -160,8 +162,8 @@ describe("the clockwork does not waste counters on pointless targets", () => {
|
||||
{ instanceId: "full-shield#T", cardId: "full-shield" },
|
||||
{ instanceId: "dagger#T", cardId: "dagger" },
|
||||
], (s, defender) => {
|
||||
const d = s.players.find((p: { id: string }) => p.id === defender)!;
|
||||
const t = s.treasures.find((t: { owner: string }) => t.owner !== defender)!;
|
||||
const d = s.players.find((p) => p.id === defender)!;
|
||||
const t = s.treasures.find((t) => t.owner !== defender)!;
|
||||
t.position = null; t.carriedBy = defender; d.carriedTreasureId = t.id;
|
||||
});
|
||||
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
|
||||
@@ -169,9 +171,196 @@ describe("the clockwork does not waste counters on pointless targets", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("the clockwork guards gold only within sight", () => {
|
||||
// A refused cast forfeits the bot's whole turn (the fallback is endTurn),
|
||||
// so SAFE must only ever be offered where the engine's sight rule allows it.
|
||||
function goldOnTheFloor() {
|
||||
let { state } = createGame({ playerIds: ["bot", "foe"], seed: 7, sets: ["basic", "expansion1"] });
|
||||
while (actingSeat(state) !== "bot") {
|
||||
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
state = r.state;
|
||||
}
|
||||
const bot = state.players.find((p) => p.id === "bot")!;
|
||||
const foe = state.players.find((p) => p.id === "foe")!;
|
||||
foe.position = { ...bot.position };
|
||||
bot.hand = [{ instanceId: "safe#T", cardId: "safe" }];
|
||||
const gold = state.treasures.find((t) => t.owner === "bot")!;
|
||||
gold.carriedBy = null;
|
||||
return { state, bot, gold };
|
||||
}
|
||||
|
||||
it("never blind-casts SAFE at a treasure out of sight", () => {
|
||||
const { state, bot, gold } = goldOnTheFloor();
|
||||
const sighted = sightedCellsFor(viewFor(state, "bot"));
|
||||
const hidden = Object.keys(state.board.cells).find(
|
||||
(k) => !sighted.has(k) && !state.squareContents[k],
|
||||
)!;
|
||||
const [x, y] = hidden.split(",").map(Number);
|
||||
gold.position = { x: x!, y: y! };
|
||||
|
||||
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
|
||||
// Whatever the brain picks, the engine must accept it — a refusal
|
||||
// forfeits the bot's turn.
|
||||
const chosen = cmd ?? automatonFallback(viewFor(state, "bot"), "archmage");
|
||||
expect(applyCommand(state, "bot", chosen).ok).toBe(true);
|
||||
});
|
||||
|
||||
it("still locks up gold it can see", () => {
|
||||
const { state, bot, gold } = goldOnTheFloor();
|
||||
const sighted = sightedCellsFor(viewFor(state, "bot"));
|
||||
const seen = [...sighted].find(
|
||||
(k) => k !== cellKey(bot.position) && !state.squareContents[k],
|
||||
)!;
|
||||
const [x, y] = seen.split(",").map(Number);
|
||||
gold.position = { x: x!, y: y! };
|
||||
|
||||
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
|
||||
expect(cmd).toEqual({
|
||||
type: "cast", instanceId: "safe#T",
|
||||
target: { kind: "cell", cell: gold.position },
|
||||
});
|
||||
expect(applyCommand(state, "bot", cmd!).ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the clockwork honors IDIOT's march", () => {
|
||||
// The engine never steers a cursed wizard's feet; the duty is the brain's.
|
||||
function cursedBot() {
|
||||
let { state } = createGame({ playerIds: ["bot", "foe"], seed: 7, sets: ["basic", "expansion1"] });
|
||||
// Past round 1 (no combat) and around to the bot's turn.
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
state = r.state;
|
||||
}
|
||||
while (actingSeat(state) !== "bot") {
|
||||
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
state = r.state;
|
||||
}
|
||||
pushSustained(state, {
|
||||
id: "fx-idiot", cardId: "idiot", casterId: "foe", targetId: "bot",
|
||||
remainingTurns: 9999, data: {},
|
||||
});
|
||||
return state;
|
||||
}
|
||||
|
||||
it("marches to its own gold until the curse lifts", () => {
|
||||
let state = cursedBot();
|
||||
state.players.find((p) => p.id === "bot")!.hand = [];
|
||||
// A walled route can cost more steps than one turn's allowance; the
|
||||
// march may span turns (the foe just passes).
|
||||
for (let i = 0; i < 30 && sustainedOn(state, "bot", "idiot").length > 0; i++) {
|
||||
const seat = actingSeat(state);
|
||||
const cmd = seat === "bot"
|
||||
? automatonCommand(viewFor(state, "bot"), "hunter", "archmage")
|
||||
?? automatonFallback(viewFor(state, "bot"), "archmage")
|
||||
: { type: "endTurn", draw: 0 } as const;
|
||||
const r = applyCommand(state, seat, cmd);
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
state = r.state;
|
||||
}
|
||||
expect(sustainedOn(state, "bot", "idiot").length).toBe(0);
|
||||
});
|
||||
|
||||
it("shakes its gold from a thief's arms with DROP OBJECT", () => {
|
||||
const state = cursedBot();
|
||||
const bot = state.players.find((p) => p.id === "bot")!;
|
||||
const thief = state.players.find((p) => p.id === "foe")!;
|
||||
thief.position = { ...bot.position };
|
||||
const own = state.treasures.find((t) => t.owner === "bot")!;
|
||||
own.carriedBy = "foe";
|
||||
own.position = null;
|
||||
thief.carriedTreasureId = own.id;
|
||||
bot.hand = [{ instanceId: "drop-object#T", cardId: "drop-object" }];
|
||||
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
|
||||
expect(cmd).toEqual({
|
||||
type: "cast", instanceId: "drop-object#T",
|
||||
target: { kind: "player", playerId: "foe" }, params: { cardId: "treasure" },
|
||||
});
|
||||
expect(applyCommand(state, "bot", cmd!).ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the clockwork wades hazards rather than surrender", () => {
|
||||
it("takes the slime road when no clean path to anything exists", () => {
|
||||
let { state } = createGame({ playerIds: ["bot", "foe"], seed: 7, sets: ["basic", "expansion1"] });
|
||||
while (actingSeat(state) !== "bot") {
|
||||
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
state = r.state;
|
||||
}
|
||||
const bot = state.players.find((p) => p.id === "bot")!;
|
||||
bot.hand = [];
|
||||
// Wall the bot into its square except one side; slime that one exit.
|
||||
const sides = ["N", "S", "E", "W"] as const;
|
||||
const board = state.board;
|
||||
const open = sides.filter((s) => {
|
||||
const n = { x: bot.position.x + (s === "E" ? 1 : s === "W" ? -1 : 0),
|
||||
y: bot.position.y + (s === "S" ? 1 : s === "N" ? -1 : 0) };
|
||||
return board.cells[cellKey(n)] !== undefined;
|
||||
});
|
||||
const exit = open[0]!;
|
||||
for (const s2 of sides) {
|
||||
if (s2 !== exit) state.edgeOverrides[edgeKey(bot.position, s2)] = "wall";
|
||||
else state.edgeOverrides[edgeKey(bot.position, s2)] = "open";
|
||||
}
|
||||
const beyond = { x: bot.position.x + (exit === "E" ? 1 : exit === "W" ? -1 : 0),
|
||||
y: bot.position.y + (exit === "S" ? 1 : exit === "N" ? -1 : 0) };
|
||||
state.squareContents[cellKey(beyond)] = { kind: "slime", damage: 0, createdBy: "foe" };
|
||||
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
|
||||
expect(cmd).toEqual({ type: "move", direction: exit });
|
||||
expect(applyCommand(state, "bot", cmd!).ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the clockwork respects the bush's shelter", () => {
|
||||
function faceOffWithFireball() {
|
||||
let { state } = createGame({ playerIds: ["bot", "foe"], seed: 7, sets: ["basic", "expansion1"] });
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
state = r.state;
|
||||
}
|
||||
while (actingSeat(state) !== "bot") {
|
||||
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
state = r.state;
|
||||
}
|
||||
const bot = state.players.find((p) => p.id === "bot")!;
|
||||
const foe = state.players.find((p) => p.id === "foe")!;
|
||||
foe.position = { ...bot.position };
|
||||
bot.hand = [{ instanceId: "fireball#T", cardId: "fireball" }];
|
||||
return { state, bot, foe };
|
||||
}
|
||||
|
||||
it("never aims at a wizard sheltered in a thornbush", () => {
|
||||
const { state, foe } = faceOffWithFireball();
|
||||
state.squareContents[cellKey(foe.position)] = { kind: "thornbush", damage: 0, createdBy: "foe" };
|
||||
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage")
|
||||
?? automatonFallback(viewFor(state, "bot"), "archmage");
|
||||
// Whatever it picks, the engine must accept it — and it must not be
|
||||
// the refused fireball that would burn the whole turn.
|
||||
expect(cmd).not.toMatchObject({ type: "cast", instanceId: "fireball#T" });
|
||||
expect(applyCommand(state, "bot", cmd).ok).toBe(true);
|
||||
});
|
||||
|
||||
it("never attacks out of its own bush", () => {
|
||||
const { state, bot, foe } = faceOffWithFireball();
|
||||
foe.position = { x: bot.position.x, y: bot.position.y };
|
||||
state.squareContents[cellKey(bot.position)] = { kind: "thornbush", damage: 0, createdBy: "foe" };
|
||||
foe.position = { ...bot.position };
|
||||
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage")
|
||||
?? automatonFallback(viewFor(state, "bot"), "archmage");
|
||||
expect(cmd).not.toMatchObject({ type: "cast", instanceId: "fireball#T" });
|
||||
expect(applyCommand(state, "bot", cmd).ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a clogged hand gets shed, not hoarded", () => {
|
||||
it("the bot discards dead weight so the end-of-turn draw has room", () => {
|
||||
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic"], deckRev: 13 });
|
||||
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic"] });
|
||||
while (actingSeat(state) !== "bot") {
|
||||
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
@@ -180,7 +369,7 @@ describe("a clogged hand gets shed, not hoarded", () => {
|
||||
const bot = state.players.find((p) => p.id === "bot")!;
|
||||
// Seven situational neutrals the brain has no play for: a dead hand.
|
||||
bot.hand = Array.from({ length: 7 }, (_, i) => (
|
||||
{ instanceId: `illusion-wall#${i}`, cardId: "illusion-wall" }
|
||||
{ instanceId: `rotate-sector#${i}`, cardId: "rotate-sector" }
|
||||
));
|
||||
// Walk the bot's turn until it wants to end: it must shed before drawing.
|
||||
for (let guard = 0; guard < 30; guard++) {
|
||||
@@ -203,3 +392,347 @@ describe("a clogged hand gets shed, not hoarded", () => {
|
||||
throw new Error("never reached the end of the turn");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the clockwork honors absorb's fine print", () => {
|
||||
it("answers NO SPELL with blunt, never absorb — durations soak no points", () => {
|
||||
const state = underAttack("no-spell", [
|
||||
{ instanceId: "absorb#T", cardId: "absorb" },
|
||||
{ instanceId: "blunt#T", cardId: "blunt" },
|
||||
]);
|
||||
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
|
||||
expect(cmd).toEqual({ type: "counteract", instanceId: "blunt#T" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("the clockwork wields destroy wall", () => {
|
||||
it("blasts its way out when no road leads to the gold", () => {
|
||||
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic"] });
|
||||
while (actingSeat(state) !== "bot") {
|
||||
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
state = r.state;
|
||||
}
|
||||
const bot = state.players.find((p) => p.id === "bot")!;
|
||||
// Brick the bot into a one-square cell far from everything.
|
||||
bot.position = { x: 4, y: 4 };
|
||||
for (const side of ["N", "S", "E", "W"] as const) {
|
||||
state.edgeOverrides[edgeKey(bot.position, side)] = "wall";
|
||||
}
|
||||
state.players.find((p) => p.id === "other")!.position = { x: 0, y: 0 };
|
||||
bot.hand[0] = { instanceId: "destroy-wall#T", cardId: "destroy-wall" };
|
||||
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
|
||||
expect(cmd).toMatchObject({ type: "cast", instanceId: "destroy-wall#T" });
|
||||
// And the engine accepts the blast it chose.
|
||||
const r = applyCommand(state, "bot", cmd!);
|
||||
expect(r.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("values destroy wall above the chaff when forced to discard", () => {
|
||||
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic"] });
|
||||
const bot = state.players.find((p) => p.id === "bot")!;
|
||||
bot.hand = [
|
||||
{ instanceId: "destroy-wall#T", cardId: "destroy-wall" },
|
||||
{ instanceId: "trader#T", cardId: "trader" },
|
||||
{ instanceId: "strength#T", cardId: "strength" },
|
||||
{ instanceId: "adrenaline#T", cardId: "adrenaline" },
|
||||
{ instanceId: "full-shield#T", cardId: "full-shield" },
|
||||
{ instanceId: "fireball#T", cardId: "fireball" },
|
||||
{ instanceId: "number-3#T", cardId: "number-3" },
|
||||
{ instanceId: "troll#T", cardId: "troll" },
|
||||
];
|
||||
state.pendingDiscard = "bot";
|
||||
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
|
||||
expect(cmd?.type).toBe("discard");
|
||||
if (cmd?.type === "discard") {
|
||||
expect(cmd.instanceIds).not.toContain("destroy-wall#T");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("the clockwork wields pass through wall", () => {
|
||||
it("banks a crossing when bricked in, then steps through the wall", () => {
|
||||
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic"] });
|
||||
while (actingSeat(state) !== "bot") {
|
||||
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
state = r.state;
|
||||
}
|
||||
const bot = state.players.find((p) => p.id === "bot")!;
|
||||
bot.position = { x: 4, y: 4 };
|
||||
for (const side of ["N", "S", "E", "W"] as const) {
|
||||
state.edgeOverrides[edgeKey(bot.position, side)] = "wall";
|
||||
}
|
||||
state.players.find((p) => p.id === "other")!.position = { x: 0, y: 0 };
|
||||
bot.hand[0] = { instanceId: "pass-through-wall#T", cardId: "pass-through-wall" };
|
||||
const cast = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
|
||||
expect(cast).toMatchObject({ type: "cast", instanceId: "pass-through-wall#T" });
|
||||
let r = applyCommand(state, "bot", cast!);
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
state = r.state;
|
||||
expect(state.players.find((p) => p.id === "bot")!.passWallCharges).toBe(1);
|
||||
// The charge is spent on a step through the bricks.
|
||||
const step = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
|
||||
expect(step?.type).toBe("move");
|
||||
r = applyCommand(state, "bot", step!);
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
const after = r.state.players.find((p) => p.id === "bot")!;
|
||||
expect(cellKey(after.position)).not.toBe(cellKey({ x: 4, y: 4 }));
|
||||
expect(after.passWallCharges).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the clockwork denies the road", () => {
|
||||
it("walls a thief's corridor when its treasure is being carried home", () => {
|
||||
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic"] });
|
||||
while (actingSeat(state) !== "bot") {
|
||||
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
state = r.state;
|
||||
}
|
||||
const bot = state.players.find((p) => p.id === "bot")!;
|
||||
const thief = state.players.find((p) => p.id === "other")!;
|
||||
// The thief carries the bot's treasure down a one-lane tube to its home.
|
||||
const t = state.treasures.find((t) => t.owner === "bot")!;
|
||||
t.position = null;
|
||||
t.carriedBy = "other";
|
||||
thief.carriedTreasureId = t.id;
|
||||
thief.position = { x: 4, y: 2 };
|
||||
thief.home = { x: 4, y: 6 };
|
||||
for (let y = 2; y <= 6; y++) {
|
||||
state.edgeOverrides[edgeKey({ x: 4, y }, "E")] = "wall";
|
||||
state.edgeOverrides[edgeKey({ x: 4, y }, "W")] = "wall";
|
||||
}
|
||||
state.edgeOverrides[edgeKey({ x: 4, y: 6 }, "S")] = "wall";
|
||||
for (let y = 2; y <= 5; y++) {
|
||||
state.edgeOverrides[edgeKey({ x: 4, y }, "S")] = "open";
|
||||
}
|
||||
bot.position = { x: 4, y: 4 };
|
||||
bot.hand = [{ instanceId: "create-wall#T", cardId: "create-wall" }];
|
||||
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
|
||||
expect(cmd).toMatchObject({ type: "cast", instanceId: "create-wall#T" });
|
||||
const r = applyCommand(state, "bot", cmd!);
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
// The blockade must actually sever the thief's road home.
|
||||
const target = (cmd as { target: { cell: { x: number; y: number }; side: string } }).target;
|
||||
expect(["N", "S"]).toContain(target.side);
|
||||
expect(target.cell.x).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the widened spellbook", () => {
|
||||
it("dispels a conjured wall standing between it and the only road", () => {
|
||||
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic", "expansion1"] });
|
||||
while (actingSeat(state) !== "bot") {
|
||||
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
state = r.state;
|
||||
}
|
||||
const bot = state.players.find((p) => p.id === "bot")!;
|
||||
bot.position = { x: 4, y: 4 };
|
||||
for (const side of ["N", "S", "E", "W"] as const) {
|
||||
const k = edgeKey(bot.position, side);
|
||||
state.edgeOverrides[k] = "wall";
|
||||
state.createdEdges[k] = true;
|
||||
}
|
||||
state.players.find((p) => p.id === "other")!.position = { x: 0, y: 0 };
|
||||
bot.hand = [{ instanceId: "dispel-creation#T", cardId: "dispel-creation" }];
|
||||
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
|
||||
expect(cmd).toMatchObject({ type: "cast", instanceId: "dispel-creation#T" });
|
||||
const r = applyCommand(state, "bot", cmd!);
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
});
|
||||
|
||||
it("offers a buddy pact to the hound at its heels", () => {
|
||||
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic", "expansion1"] });
|
||||
while (actingSeat(state) !== "bot") {
|
||||
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
state = r.state;
|
||||
}
|
||||
const bot = state.players.find((p) => p.id === "bot")!;
|
||||
const hound = state.players.find((p) => p.id === "other")!;
|
||||
hound.position = { ...bot.position };
|
||||
bot.hand = [{ instanceId: "buddy#T", cardId: "buddy" }];
|
||||
const cmd = automatonCommand(viewFor(state, "bot"), "worrier", "archmage");
|
||||
expect(cmd).toEqual({
|
||||
type: "cast", instanceId: "buddy#T",
|
||||
target: { kind: "player", playerId: "other" },
|
||||
});
|
||||
const r = applyCommand(state, "bot", cmd!);
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
});
|
||||
});
|
||||
|
||||
describe("finishing tactics: exile and adrenaline", () => {
|
||||
it("exiles a thief carrying its gold to the far end of nowhere", () => {
|
||||
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic", "expansion1"] });
|
||||
// burn round 1 and reach the bot's turn
|
||||
for (let guard = 0; guard < 10 && !(actingSeat(state) === "bot" && state.turn.round > 1); guard++) {
|
||||
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
state = r.state;
|
||||
}
|
||||
const bot = state.players.find((p) => p.id === "bot")!;
|
||||
const thief = state.players.find((p) => p.id === "other")!;
|
||||
const t = state.treasures.find((t) => t.owner === "bot")!;
|
||||
t.position = null;
|
||||
t.carriedBy = "other";
|
||||
thief.carriedTreasureId = t.id;
|
||||
// The thief is a step from delivering; the bot watches from beside them.
|
||||
thief.position = { x: thief.home.x, y: thief.home.y === 0 ? 1 : thief.home.y - 1 };
|
||||
bot.position = { ...thief.position };
|
||||
bot.hand = [{ instanceId: "teleport-opponent#T", cardId: "teleport-opponent" }];
|
||||
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
|
||||
expect(cmd).toMatchObject({ type: "cast", instanceId: "teleport-opponent#T" });
|
||||
const dest = (cmd as { params: { cell: { x: number; y: number } } }).params.cell;
|
||||
// The chosen square is a long march from the thief's own home.
|
||||
const d = Math.abs(dest.x - thief.home.x) + Math.abs(dest.y - thief.home.y);
|
||||
expect(d).toBeGreaterThanOrEqual(5);
|
||||
const r = applyCommand(state, "bot", cmd!);
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
});
|
||||
|
||||
it("casts adrenaline when two blows finish what one cannot", () => {
|
||||
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic", "expansion1"] });
|
||||
for (let guard = 0; guard < 10 && !(actingSeat(state) === "bot" && state.turn.round > 1); guard++) {
|
||||
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
state = r.state;
|
||||
}
|
||||
const bot = state.players.find((p) => p.id === "bot")!;
|
||||
const prey = state.players.find((p) => p.id === "other")!;
|
||||
prey.position = { ...bot.position };
|
||||
prey.life = 7; // fireball (5) alone cannot; fireball + dagger (3) can
|
||||
bot.hand = [
|
||||
{ instanceId: "adrenaline#T", cardId: "adrenaline" },
|
||||
{ instanceId: "fireball#T", cardId: "fireball" },
|
||||
{ instanceId: "dagger#T", cardId: "dagger" },
|
||||
{ instanceId: "number-2#T", cardId: "number-2" },
|
||||
];
|
||||
const cmd = automatonCommand(viewFor(state, "bot"), "berserker", "archmage");
|
||||
expect(cmd).toMatchObject({ type: "cast", instanceId: "adrenaline#T" });
|
||||
const r = applyCommand(state, "bot", cmd!);
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the fireball-then-buddy lockout", () => {
|
||||
it("burns the target, then signs the pact so they cannot hit back", () => {
|
||||
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic", "expansion1"] });
|
||||
for (let guard = 0; guard < 10 && !(actingSeat(state) === "bot" && state.turn.round > 1); guard++) {
|
||||
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
state = r.state;
|
||||
}
|
||||
const bot = state.players.find((p) => p.id === "bot")!;
|
||||
const prey = state.players.find((p) => p.id === "other")!;
|
||||
prey.position = { ...bot.position };
|
||||
bot.life = 8; // bleeding: the clockwork wants out of this fight
|
||||
bot.hand = [
|
||||
{ instanceId: "fireball#T", cardId: "fireball" },
|
||||
{ instanceId: "buddy#T", cardId: "buddy" },
|
||||
];
|
||||
// First: the blow.
|
||||
let cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
|
||||
expect(cmd).toMatchObject({ type: "cast", instanceId: "fireball#T" });
|
||||
let r = applyCommand(state, "bot", cmd!);
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
state = r.state;
|
||||
r = applyCommand(state, "other", { type: "pass" });
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
state = r.state;
|
||||
// Then: the pact.
|
||||
cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
|
||||
expect(cmd).toEqual({
|
||||
type: "cast", instanceId: "buddy#T",
|
||||
target: { kind: "player", playerId: "other" },
|
||||
});
|
||||
r = applyCommand(state, "bot", cmd!);
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
state = r.state;
|
||||
// The pact holds: the victim cannot strike their tormentor.
|
||||
expect(state.sustained.some(
|
||||
(s) => s.cardId === "buddy" && s.casterId === "bot" && s.targetId === "other",
|
||||
)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a pact once signed is honored", () => {
|
||||
it("will not attack the wizard it just buddied", () => {
|
||||
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic", "expansion1"] });
|
||||
for (let guard = 0; guard < 10 && !(actingSeat(state) === "bot" && state.turn.round > 1); guard++) {
|
||||
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
state = r.state;
|
||||
}
|
||||
const bot = state.players.find((p) => p.id === "bot")!;
|
||||
const prey = state.players.find((p) => p.id === "other")!;
|
||||
prey.position = { ...bot.position };
|
||||
// The pact already stands; a fireball waits in hand as temptation.
|
||||
pushSustained(state, {
|
||||
id: "fx-test", cardId: "buddy", casterId: "bot", targetId: "other",
|
||||
remainingTurns: 1000, data: {},
|
||||
});
|
||||
bot.hand = [{ instanceId: "fireball#T", cardId: "fireball" }];
|
||||
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
|
||||
// Anything but an attack on the pacted wizard: no cast at them, no punch.
|
||||
expect(cmd?.type === "punch").toBe(false);
|
||||
if (cmd?.type === "cast") {
|
||||
expect((cmd as { target?: { playerId?: string } }).target?.playerId).not.toBe("other");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("no number is wasted on a turn that ends at a grab", () => {
|
||||
it("gold two free steps away: the berserker charges without spending its 5", () => {
|
||||
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic", "expansion1"] });
|
||||
for (let guard = 0; guard < 10 && !(actingSeat(state) === "bot" && state.turn.round > 1); guard++) {
|
||||
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
state = r.state;
|
||||
}
|
||||
const bot = state.players.find((p) => p.id === "bot")!;
|
||||
const prey = state.players.find((p) => p.id === "other")!;
|
||||
// Enemy far across the maze; their treasure lies one step from the bot.
|
||||
prey.position = { x: 0, y: 0 };
|
||||
bot.position = { x: 5, y: 5 };
|
||||
const t = state.treasures.find((t) => t.owner === "other")!;
|
||||
t.position = { x: 5, y: 6 };
|
||||
t.carriedBy = null;
|
||||
state.edgeOverrides[edgeKey({ x: 5, y: 5 }, "S")] = "open";
|
||||
bot.hand = [
|
||||
{ instanceId: "number-5#T", cardId: "number-5" },
|
||||
{ instanceId: "number-2#T", cardId: "number-2" },
|
||||
];
|
||||
const cmd = automatonCommand(viewFor(state, "bot"), "berserker", "archmage");
|
||||
expect(cmd?.type).not.toBe("playNumberForMovement");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the clockwork flees the dread", () => {
|
||||
it("caught in FEAR's bubble, it spends its legs moving away", () => {
|
||||
let { state } = createGame({ playerIds: ["bot", "grim"], seed: 42, sets: ["basic"] });
|
||||
for (let guard = 0; guard < 10 && !(actingSeat(state) === "bot" && state.turn.round > 1); guard++) {
|
||||
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
state = r.state;
|
||||
}
|
||||
const bot = state.players.find((p) => p.id === "bot")!;
|
||||
const grim = state.players.find((p) => p.id === "grim")!;
|
||||
grim.position = { x: 2, y: 5 };
|
||||
bot.position = { x: 2, y: 7 }; // two spaces inside the dread
|
||||
pushSustained(state, {
|
||||
id: "fx-fear", cardId: "fear", casterId: "grim", targetId: "grim",
|
||||
remainingTurns: 5, data: {},
|
||||
});
|
||||
const cmd = automatonCommand(viewFor(state, "bot"), "berserker", "archmage");
|
||||
expect(cmd?.type).toBe("move");
|
||||
if (cmd?.type === "move") {
|
||||
const r = applyCommand(state, "bot", cmd);
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
const after = r.state.players.find((p) => p.id === "bot")!;
|
||||
const d = Math.abs(after.position.x - grim.position.x) + Math.abs(after.position.y - grim.position.y);
|
||||
expect(d).toBeGreaterThan(2);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,7 +9,8 @@ import {
|
||||
type SectorPlacement,
|
||||
} from "../src/board";
|
||||
import { buildDeck } from "../src/cards";
|
||||
import { activePlayer, applyCommand, createGame } from "../src/game";
|
||||
import { edgeKey, type Side } from "../src/board";
|
||||
import { activePlayer, applyCommand, boardView, createGame, gameLos } from "../src/game";
|
||||
import { createRng } from "../src/rng";
|
||||
import { setupBoard } from "../src/setups";
|
||||
import { giveCard } from "./helpers";
|
||||
@@ -200,7 +201,7 @@ describe("setup diagram pairings (rulebook Set-Up Diagram)", () => {
|
||||
});
|
||||
|
||||
it("relocation discards the aisle warp: only opposite edges connect after", () => {
|
||||
let { state } = createGame({ playerIds: ["a", "b", "c"], seed: 7, sets: ["basic"], deckRev: 5 });
|
||||
let { state } = createGame({ playerIds: ["a", "b", "c"], seed: 7, sets: ["basic"] });
|
||||
const bent = (warps: typeof state.board.warps) =>
|
||||
warps.filter((w) => w.from.cell.x !== w.to.cell.x && w.from.cell.y !== w.to.cell.y);
|
||||
expect(bent(state.board.warps).length).toBeGreaterThan(0); // the arc exists
|
||||
@@ -240,3 +241,77 @@ describe("the aisle warp treats its corner as adjacent — sight included", () =
|
||||
expect(offAxis).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bricking over a warp mouth", () => {
|
||||
it("create wall seals a lettered opening: no passage, no sight", () => {
|
||||
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"] });
|
||||
// round 1 passes
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const r = applyCommand(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
state = r.state;
|
||||
}
|
||||
const w = state.board.warps[0]!;
|
||||
const caster = activePlayer(state);
|
||||
caster.position = { ...w.from.cell };
|
||||
caster.hand[0] = { instanceId: "cw#1", cardId: "create-wall" };
|
||||
const r = applyCommand(state, caster.id, {
|
||||
type: "cast", instanceId: "cw#1",
|
||||
target: { kind: "edge", cell: w.from.cell, side: w.from.side },
|
||||
});
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
state = r.state;
|
||||
// Passage: stepping into the warp is now a wall.
|
||||
const step = stepTarget(boardView(state), w.from.cell, w.from.side);
|
||||
expect(step.kind).toBe("blocked");
|
||||
// Sight: the far mouth no longer carries warp sight.
|
||||
expect(gameLos(state, w.from.cell, w.to.cell)).toBe(false);
|
||||
// One tunnel: the FAR mouth is walled too, and unwalling it reopens both.
|
||||
const farStep = stepTarget(boardView(state), w.to.cell, w.to.side);
|
||||
expect(farStep.kind).toBe("blocked");
|
||||
const caster2 = activePlayer(state);
|
||||
caster2.position = { ...w.to.cell };
|
||||
caster2.hand[0] = { instanceId: "dw#1", cardId: "destroy-wall" };
|
||||
const r2 = applyCommand(state, caster2.id, {
|
||||
type: "cast", instanceId: "dw#1",
|
||||
target: { kind: "edge", cell: w.to.cell, side: w.to.side },
|
||||
});
|
||||
if (!r2.ok) throw new Error(r2.error);
|
||||
expect(stepTarget(boardView(r2.state), w.from.cell, w.from.side).kind).toBe("warp");
|
||||
});
|
||||
});
|
||||
|
||||
describe("breaching the rim", () => {
|
||||
it("destroying a perimeter wall opens both sides as a new warp", () => {
|
||||
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"] });
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const r = applyCommand(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
state = r.state;
|
||||
}
|
||||
// Find a rim wall with a wrap counterpart that is NOT already an opening.
|
||||
const view = boardView(state);
|
||||
for (const key of Object.keys(view.cells)) {
|
||||
const [x, y] = key.split(",").map(Number) as [number, number];
|
||||
for (const side of ["N", "E", "S", "W"] as Side[]) {
|
||||
const n = { x: x + (side === "E" ? 1 : side === "W" ? -1 : 0), y: y + (side === "S" ? 1 : side === "N" ? -1 : 0) };
|
||||
if (view.cells[`${n.x},${n.y}`]) continue;
|
||||
if ((view.edges[edgeKey({ x, y }, side)] ?? "open") !== "wall") continue;
|
||||
const caster = activePlayer(state);
|
||||
caster.position = { x, y };
|
||||
caster.hand[0] = { instanceId: "dw#1", cardId: "destroy-wall" };
|
||||
const warpsBefore = state.board.warps.length;
|
||||
const r = applyCommand(state, caster.id, {
|
||||
type: "cast", instanceId: "dw#1", target: { kind: "edge", cell: { x, y }, side },
|
||||
});
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
if (r.state.board.warps.length === warpsBefore) continue; // no counterpart in this row
|
||||
expect(r.state.board.warps.length).toBe(warpsBefore + 2);
|
||||
const w = r.state.board.warps[warpsBefore]!;
|
||||
expect(stepTarget(boardView(r.state), w.from.cell, w.from.side).kind).toBe("warp");
|
||||
return;
|
||||
}
|
||||
}
|
||||
throw new Error("setup: no breachable rim wall found");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,10 +31,10 @@ describe("attack spells", () => {
|
||||
d.hand[1] = { instanceId: "full-shield#T", cardId: "full-shield" };
|
||||
|
||||
state = must(state, attacker, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender } });
|
||||
// Full shield stops everything -> stones survive.
|
||||
// Full shield stops everything -> stones survive. (A total stop
|
||||
// resolves on the attacker's pass; no bounce back.)
|
||||
state = must(state, defender, { type: "counteract", instanceId: "full-shield#T" });
|
||||
state = must(state, attacker, { type: "pass" });
|
||||
state = must(state, defender, { type: "pass" });
|
||||
const after = state.players.find((p) => p.id === defender)!;
|
||||
expect(after.life).toBe(15);
|
||||
expect(after.hand.some((c) => c.cardId === "powerstone")).toBe(true);
|
||||
@@ -111,7 +111,8 @@ describe("attack spells", () => {
|
||||
state = must(state, attacker, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender } });
|
||||
state = must(state, defender, { type: "counteract", instanceId: "full-reflection#T" });
|
||||
state = must(state, attacker, { type: "pass" });
|
||||
state = must(state, defender, { type: "pass" });
|
||||
// The returned spell is a fresh attack on its caster: their window.
|
||||
state = must(state, attacker, { type: "pass" });
|
||||
expect(state.players.find((p) => p.id === defender)!.life).toBe(15);
|
||||
expect(state.players.find((p) => p.id === attacker)!.life).toBe(10);
|
||||
});
|
||||
@@ -250,14 +251,6 @@ describe("stack discipline", () => {
|
||||
expect(applyCommand(state, defender, { type: "move", direction: "N" }).ok).toBe(false);
|
||||
});
|
||||
|
||||
it("unimplemented cards refuse to cast with a clear error", () => {
|
||||
let { state } = newGame();
|
||||
const caster = activePlayer(state);
|
||||
const card = giveCard(state, caster.id, "bomb"); // expansion2: historical, never implemented
|
||||
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/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("speedstone", () => {
|
||||
@@ -315,17 +308,15 @@ describe("stored-log compatibility", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("deck revisions", () => {
|
||||
it("revision 2 removes LIFESAVER at two players, and only there", () => {
|
||||
const two = createGame({ playerIds: ["a", "b"], seed: 9, sets: ["basic", "expansion1"], deckRev: 2 });
|
||||
describe("the two-player deck", () => {
|
||||
it("two-player games shed LIFESAVER; bigger tables keep it", () => {
|
||||
const two = createGame({ playerIds: ["a", "b"], seed: 9, sets: ["basic", "expansion1"] });
|
||||
const inGame = (s: typeof two.state) =>
|
||||
[...s.deck, ...s.discard, ...s.players.flatMap((p) => p.hand)].some((c) => c.cardId === "lifesaver");
|
||||
expect(inGame(two.state)).toBe(false);
|
||||
// Three players keep it; old two-player games (no deckRev) keep it too.
|
||||
const three = createGame({ playerIds: ["a", "b", "c"], seed: 9, sets: ["basic", "expansion1"], deckRev: 2 });
|
||||
// Three players keep it.
|
||||
const three = createGame({ playerIds: ["a", "b", "c"], seed: 9, sets: ["basic", "expansion1"] });
|
||||
expect(inGame(three.state)).toBe(true);
|
||||
const legacy = createGame({ playerIds: ["a", "b"], seed: 9, sets: ["basic", "expansion1"] });
|
||||
expect(inGame(legacy.state)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -388,42 +379,31 @@ describe("attacking walls and doors", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("ward arming and chaos shields (rules rev 3)", () => {
|
||||
function rev3Game(seed = 42) {
|
||||
return createGame({ playerIds: ["alice", "bob", "cara"], seed, sets: ["basic", "expansion1"], deckRev: 3 });
|
||||
describe("the ward window and chaos shields", () => {
|
||||
function threeGame(seed = 42) {
|
||||
return createGame({ playerIds: ["alice", "bob", "cara"], seed, sets: ["basic", "expansion1"] });
|
||||
}
|
||||
|
||||
it("ward springs only when its owner armed it", () => {
|
||||
let { state } = rev3Game();
|
||||
it("the grab hangs while the ward's owner decides — and arming ahead is refused", () => {
|
||||
let { state } = threeGame();
|
||||
state = toRound2(state);
|
||||
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
|
||||
const thief = activePlayer(state);
|
||||
const owner = state.players.find((p) => p.id !== thief.id)!;
|
||||
giveCard(state, owner.id, "ward", "W", 0);
|
||||
expect(applyCommand(state, owner.id, { type: "armWard" }).ok).toBe(false);
|
||||
const treasure = state.treasures.find((t) => t.owner === owner.id)!;
|
||||
|
||||
// Unarmed: the grab goes unpunished.
|
||||
thief.position = { ...treasure.position! };
|
||||
let s2 = must(state, thief.id, { type: "pickUpTreasure" });
|
||||
expect(s2.players.find((p) => p.id === thief.id)!.life).toBe(15);
|
||||
expect(s2.players.find((p) => p.id === owner.id)!.hand.some((c) => c.cardId === "ward")).toBe(true);
|
||||
|
||||
// Armed (on the owner's own turn): the trap bites for 3.
|
||||
const ownerTurnState = (() => {
|
||||
let s = state;
|
||||
while (activePlayer(s).id !== owner.id) s = must(s, activePlayer(s).id, { type: "endTurn", draw: 0 });
|
||||
return s;
|
||||
})();
|
||||
let s3 = must(ownerTurnState, owner.id, { type: "armWard", armed: true });
|
||||
while (activePlayer(s3).id !== thief.id) s3 = must(s3, activePlayer(s3).id, { type: "endTurn", draw: 0 });
|
||||
s3.players.find((p) => p.id === thief.id)!.position = { ...treasure.position! };
|
||||
s3 = must(s3, thief.id, { type: "pickUpTreasure" });
|
||||
expect(s3.players.find((p) => p.id === thief.id)!.life).toBe(12);
|
||||
expect(s3.wardArmed).not.toContain(owner.id);
|
||||
state = must(state, thief.id, { type: "pickUpTreasure" });
|
||||
expect(state.wardPending).toEqual({ ownerId: owner.id, takerId: thief.id });
|
||||
// Spring it: the thief bleeds 3 and the ward is spent.
|
||||
state = must(state, owner.id, { type: "wardChoice", play: true });
|
||||
expect(state.players.find((p) => p.id === thief.id)!.life).toBe(12);
|
||||
expect(state.players.find((p) => p.id === owner.id)!.hand.some((c) => c.cardId === "ward")).toBe(false);
|
||||
});
|
||||
|
||||
it("chaos: bystanders may shield out, reflections are refused", () => {
|
||||
let { state } = rev3Game();
|
||||
let { state } = threeGame();
|
||||
state = toRound2(state);
|
||||
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
|
||||
const caster = activePlayer(state);
|
||||
@@ -450,7 +430,7 @@ describe("ward arming and chaos shields (rules rev 3)", () => {
|
||||
});
|
||||
|
||||
it("chaos: the defender's full shield sits them out without stopping it", () => {
|
||||
let { state } = rev3Game();
|
||||
let { state } = threeGame();
|
||||
state = toRound2(state);
|
||||
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
|
||||
const caster = activePlayer(state);
|
||||
@@ -466,25 +446,12 @@ describe("ward arming and chaos shields (rules rev 3)", () => {
|
||||
});
|
||||
state = must(state, defender.id, { type: "counteract", instanceId: "full-shield#S" });
|
||||
state = must(state, caster.id, { type: "pass" }); // caster declines to anti-anti
|
||||
state = must(state, defender.id, { type: "pass" }); // defender rests on the shield
|
||||
// Bystander declines; the scramble happens without the defender.
|
||||
expect(state.chaosPending?.queue[0]).toBe(bystander.id);
|
||||
state = must(state, bystander.id, { type: "pass" });
|
||||
expect(state.chaosPending).toBeNull();
|
||||
expect(defenderHand()).toEqual(kept);
|
||||
});
|
||||
|
||||
it("legacy games (rev < 3) keep the automatic ward", () => {
|
||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"] });
|
||||
state = toRound2(state);
|
||||
const thief = activePlayer(state);
|
||||
const owner = state.players.find((p) => p.id !== thief.id)!;
|
||||
giveCard(state, owner.id, "ward", "W", 0);
|
||||
const treasure = state.treasures.find((t) => t.owner === owner.id)!;
|
||||
thief.position = { ...treasure.position! };
|
||||
state = must(state, thief.id, { type: "pickUpTreasure" });
|
||||
expect(state.players.find((p) => p.id === thief.id)!.life).toBe(12);
|
||||
});
|
||||
});
|
||||
|
||||
describe("zero-damage utility attacks", () => {
|
||||
@@ -525,8 +492,7 @@ describe("zero-damage utility attacks", () => {
|
||||
target: { kind: "player", playerId: defender },
|
||||
});
|
||||
state = must(state, defender, { type: "counteract", instanceId: "full-shield#FS" });
|
||||
state = must(state, attacker, { type: "pass" });
|
||||
const result = applyCommand(state, defender, { type: "pass" });
|
||||
const result = applyCommand(state, attacker, { type: "pass" });
|
||||
if (!result.ok) throw new Error(result.error);
|
||||
const resolved = result.events.find((e) => e.type === "attackResolved");
|
||||
expect(resolved && "fullyStopped" in resolved && resolved.fullyStopped).toBe(true);
|
||||
@@ -548,32 +514,11 @@ describe("teleport as a counteraction", () => {
|
||||
});
|
||||
state = must(state, defender, { type: "counteract", instanceId: tp.instanceId, params: { cell: escape } });
|
||||
state = must(state, attacker, { type: "pass" });
|
||||
state = must(state, defender, { type: "pass" });
|
||||
const after = state.players.find((p) => p.id === defender)!;
|
||||
expect(after.life).toBe(15);
|
||||
expect(cellKey(after.position)).toBe(cellKey(escape));
|
||||
});
|
||||
|
||||
it("ANTI-ANTI pins the boots in pre-rev-6 games (their stored chains replay)", () => {
|
||||
let { state } = newGame();
|
||||
state = toRound2(state);
|
||||
const { attacker, defender } = faceOff(state);
|
||||
const fb = giveCard(state, attacker, "fireball");
|
||||
const aa = giveCard(state, attacker, "anti-anti", "AA", 1);
|
||||
const tp = giveCard(state, defender, "teleport", "TP", 0);
|
||||
const d = state.players.find((p) => p.id === defender)!;
|
||||
const home = { ...d.position };
|
||||
state = must(state, attacker, {
|
||||
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender },
|
||||
});
|
||||
state = must(state, defender, { type: "counteract", instanceId: tp.instanceId, params: { cell: { x: home.x, y: home.y + 1 } } });
|
||||
state = must(state, attacker, { type: "counteract", instanceId: aa.instanceId });
|
||||
state = must(state, defender, { type: "pass" });
|
||||
const after = state.players.find((p) => p.id === defender)!;
|
||||
expect(cellKey(after.position)).toBe(cellKey(home));
|
||||
expect(after.life).toBe(10);
|
||||
});
|
||||
|
||||
it("reaches at most four spaces", () => {
|
||||
let { state } = newGame();
|
||||
state = toRound2(state);
|
||||
@@ -593,7 +538,7 @@ describe("teleport as a counteraction", () => {
|
||||
|
||||
describe("slime holds spells", () => {
|
||||
function slimeRig() {
|
||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 5 });
|
||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
|
||||
state = toRound2(state);
|
||||
const caster = activePlayer(state);
|
||||
const victim = state.players.find((p) => p.id !== caster.id)!;
|
||||
@@ -646,7 +591,6 @@ describe("slime holds spells", () => {
|
||||
state = must(state, rig.victim, { type: "move", direction: rig.slimeAt.side });
|
||||
state = must(state, rig.victim, { type: "counteract", instanceId: "full-reflection#FR" });
|
||||
state = must(state, rig.caster, { type: "pass" });
|
||||
state = must(state, rig.victim, { type: "pass" });
|
||||
expect(state.players.find((p) => p.id === rig.caster)!.life).toBe(15);
|
||||
expect(state.players.find((p) => p.id === rig.victim)!.life).toBe(15);
|
||||
});
|
||||
@@ -671,12 +615,12 @@ describe("slime holds spells", () => {
|
||||
});
|
||||
|
||||
describe("answering counteractions (FAQ rulings)", () => {
|
||||
function rev6() {
|
||||
return createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"], deckRev: 6 });
|
||||
function freshGame() {
|
||||
return createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"] });
|
||||
}
|
||||
|
||||
it("ABSORB SPELL steals a FULL REFLECTION out of the air", () => {
|
||||
let { state } = rev6();
|
||||
let { state } = freshGame();
|
||||
state = toRound2(state);
|
||||
const { attacker, defender } = faceOff(state);
|
||||
const fb = giveCard(state, attacker, "fireball");
|
||||
@@ -696,7 +640,7 @@ describe("answering counteractions (FAQ rulings)", () => {
|
||||
});
|
||||
|
||||
it("FULL SHIELD cannot be absorbed — it is not cast at you", () => {
|
||||
let { state } = rev6();
|
||||
let { state } = freshGame();
|
||||
state = toRound2(state);
|
||||
const { attacker, defender } = faceOff(state);
|
||||
const fb = giveCard(state, attacker, "fireball");
|
||||
@@ -711,8 +655,8 @@ describe("answering counteractions (FAQ rulings)", () => {
|
||||
if (!r.ok) expect(r.error).toMatch(/cannot be absorbed/);
|
||||
});
|
||||
|
||||
it("ANTI-ANTI does not work against a teleport escape (rev 6)", () => {
|
||||
let { state } = rev6();
|
||||
it("ANTI-ANTI does not work against a teleport escape", () => {
|
||||
let { state } = freshGame();
|
||||
state = toRound2(state);
|
||||
const { attacker, defender } = faceOff(state);
|
||||
const fb = giveCard(state, attacker, "fireball");
|
||||
@@ -727,16 +671,15 @@ describe("answering counteractions (FAQ rulings)", () => {
|
||||
const r = applyCommand(state, attacker, { type: "counteract", instanceId: aa.instanceId });
|
||||
expect(r.ok).toBe(false);
|
||||
state = must(state, attacker, { type: "pass" });
|
||||
state = must(state, defender, { type: "pass" });
|
||||
const after = state.players.find((p) => p.id === defender)!;
|
||||
expect(after.life).toBe(15);
|
||||
expect(cellKey(after.position)).toBe(cellKey(escape));
|
||||
});
|
||||
});
|
||||
|
||||
describe("speed and warp-token creation (rules rev 8)", () => {
|
||||
describe("speed and warp-token creation", () => {
|
||||
it("a SPEED bonus turn burns a turn of durations on the hastened wizard", () => {
|
||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"], deckRev: 8 });
|
||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"] });
|
||||
state = toRound2(state);
|
||||
const me = activePlayer(state);
|
||||
const other = state.players.find((p) => p.id !== me.id)!;
|
||||
@@ -755,7 +698,7 @@ describe("speed and warp-token creation (rules rev 8)", () => {
|
||||
});
|
||||
|
||||
it("nothing can be created on a dimensional warp token", () => {
|
||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 8 });
|
||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
|
||||
state = toRound2(state);
|
||||
const me = activePlayer(state);
|
||||
const spot = { x: me.position.x + 1, y: me.position.y };
|
||||
@@ -769,9 +712,9 @@ describe("speed and warp-token creation (rules rev 8)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("redirection (rules rev 9)", () => {
|
||||
describe("redirection", () => {
|
||||
it("the two chosen exits connect; their old partners pair with each other", () => {
|
||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"], deckRev: 9 });
|
||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"] });
|
||||
state = toRound2(state);
|
||||
const me = activePlayer(state);
|
||||
// Pick two exits that are NOT already partners.
|
||||
@@ -794,16 +737,16 @@ describe("redirection (rules rev 9)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("total stops end the exchange (rules rev 10)", () => {
|
||||
function rev10Game() {
|
||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"], deckRev: 10 });
|
||||
describe("total stops end the exchange", () => {
|
||||
function faceOffRig() {
|
||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"] });
|
||||
state = toRound2(state);
|
||||
const { attacker, defender } = faceOff(state);
|
||||
return { state, attacker, defender };
|
||||
}
|
||||
|
||||
it("a declined answer to Force Field resolves at once — no goading re-prompt", () => {
|
||||
let { state, attacker, defender } = rev10Game();
|
||||
let { state, attacker, defender } = faceOffRig();
|
||||
const fb = giveCard(state, attacker, "fireball");
|
||||
const d = state.players.find((p) => p.id === defender)!;
|
||||
const lifeBefore = d.life;
|
||||
@@ -817,7 +760,7 @@ describe("total stops end the exchange (rules rev 10)", () => {
|
||||
});
|
||||
|
||||
it("a partial counter still invites the defender to stack more", () => {
|
||||
let { state, attacker, defender } = rev10Game();
|
||||
let { state, attacker, defender } = faceOffRig();
|
||||
const fb = giveCard(state, attacker, "fireball");
|
||||
const d = state.players.find((p) => p.id === defender)!;
|
||||
d.hand[0] = { instanceId: "blunt#T", cardId: "blunt" };
|
||||
@@ -830,7 +773,7 @@ describe("total stops end the exchange (rules rev 10)", () => {
|
||||
});
|
||||
|
||||
it("a nullified shield is no stop — the exchange returns to the defender", () => {
|
||||
let { state, attacker, defender } = rev10Game();
|
||||
let { state, attacker, defender } = faceOffRig();
|
||||
const fb = giveCard(state, attacker, "fireball");
|
||||
const a = state.players.find((p) => p.id === attacker)!;
|
||||
const d = state.players.find((p) => p.id === defender)!;
|
||||
@@ -847,7 +790,7 @@ describe("total stops end the exchange (rules rev 10)", () => {
|
||||
|
||||
describe("escapes and elemental walls as counteractions", () => {
|
||||
function rigged(attackId: string, defenderCards: { instanceId: string; cardId: string }[]) {
|
||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"], deckRev: 12 });
|
||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"] });
|
||||
state = toRound2(state);
|
||||
const { attacker, defender } = faceOff(state);
|
||||
const atk = giveCard(state, attacker, attackId);
|
||||
@@ -893,7 +836,7 @@ describe("escapes and elemental walls as counteractions", () => {
|
||||
const lifeBefore = state.players.find((p) => p.id === defender)!.life;
|
||||
state = must(state, defender, { type: "counteract", instanceId: "waterwall#T" });
|
||||
state = must(state, attacker, { type: "pass" });
|
||||
// A total stop: the attacker's declined answer resolves at once (rev 10+).
|
||||
// A total stop: the attacker's declined answer resolves at once.
|
||||
expect(state.stack).toBeNull();
|
||||
expect(state.players.find((p) => p.id === defender)!.life).toBe(lifeBefore);
|
||||
});
|
||||
@@ -906,3 +849,188 @@ describe("escapes and elemental walls as counteractions", () => {
|
||||
expect(refused.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("empathy as a counteraction", () => {
|
||||
function empathyRig() {
|
||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
|
||||
state = toRound2(state);
|
||||
const { attacker, defender } = faceOff(state);
|
||||
const fb = giveCard(state, attacker, "fireball");
|
||||
const d = state.players.find((p) => p.id === defender)!;
|
||||
d.hand[0] = { instanceId: "empathy#T", cardId: "empathy" };
|
||||
d.hand[1] = { instanceId: "number-3#T", cardId: "number-3" };
|
||||
state = must(state, attacker, {
|
||||
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender },
|
||||
});
|
||||
return { state, attacker, defender };
|
||||
}
|
||||
|
||||
it("the blow lands on both — and the link lingers its number's turns", () => {
|
||||
let { state, attacker, defender } = empathyRig();
|
||||
state = must(state, defender, {
|
||||
type: "counteract", instanceId: "empathy#T", numberInstanceIds: ["number-3#T"],
|
||||
});
|
||||
state = must(state, attacker, { type: "pass" });
|
||||
state = must(state, defender, { type: "pass" });
|
||||
expect(state.players.find((p) => p.id === defender)!.life).toBe(10);
|
||||
expect(state.players.find((p) => p.id === attacker)!.life).toBe(10);
|
||||
const fx = state.sustained.find((f) => f.cardId === "empathy" && f.targetId === defender);
|
||||
expect(fx?.remainingTurns).toBe(3);
|
||||
});
|
||||
|
||||
it("anti-anti severs the link: no mirror, no lingering spell", () => {
|
||||
let { state, attacker, defender } = empathyRig();
|
||||
state = must(state, defender, { type: "counteract", instanceId: "empathy#T" });
|
||||
const a = state.players.find((p) => p.id === attacker)!;
|
||||
a.hand[1] = { instanceId: "anti-anti#T", cardId: "anti-anti" };
|
||||
state = must(state, attacker, { type: "counteract", instanceId: "anti-anti#T" });
|
||||
state = must(state, defender, { type: "pass" });
|
||||
expect(state.players.find((p) => p.id === defender)!.life).toBe(10);
|
||||
expect(state.players.find((p) => p.id === attacker)!.life).toBe(15);
|
||||
expect(state.sustained.some((f) => f.cardId === "empathy" && f.targetId === defender)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("empathy mirrors resolution-hook blows", () => {
|
||||
it("a believed illusion bites both wizards", () => {
|
||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
|
||||
state = toRound2(state);
|
||||
const { attacker, defender } = faceOff(state);
|
||||
const ill = giveCard(state, attacker, "illusionary-attack");
|
||||
const d = state.players.find((p) => p.id === defender)!;
|
||||
d.hand[0] = { instanceId: "empathy#T", cardId: "empathy" };
|
||||
state = must(state, attacker, {
|
||||
type: "cast", instanceId: ill.instanceId,
|
||||
target: { kind: "player", playerId: defender },
|
||||
params: { cardId: "fireball" },
|
||||
});
|
||||
state = must(state, defender, { type: "counteract", instanceId: "empathy#T" });
|
||||
state = must(state, attacker, { type: "pass" });
|
||||
state = must(state, defender, { type: "pass" });
|
||||
const dAfter = state.players.find((p) => p.id === defender)!;
|
||||
const aAfter = state.players.find((p) => p.id === attacker)!;
|
||||
// Whichever way belief rolled, the two lives moved in lockstep.
|
||||
expect(15 - aAfter.life).toBe(15 - dAfter.life);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the reflected-attack window", () => {
|
||||
function reflectRig() {
|
||||
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"] });
|
||||
state = toRound2(state);
|
||||
const { attacker, defender } = faceOff(state);
|
||||
const sd = giveCard(state, attacker, "sudden-death");
|
||||
giveCard(state, defender, "full-reflection", "FR", 0);
|
||||
state = must(state, attacker, {
|
||||
type: "cast", instanceId: sd.instanceId, target: { kind: "player", playerId: defender },
|
||||
});
|
||||
state = must(state, defender, { type: "counteract", instanceId: "full-reflection#FR" });
|
||||
// The attacker's pass meets the total stop and settles the reflection —
|
||||
// which opens the returned spell's window, waiting on the caster.
|
||||
state = must(state, attacker, { type: "pass" });
|
||||
return { state, attacker, defender };
|
||||
}
|
||||
|
||||
it("the returned spell opens a window: absorb soaks the reflected blast", () => {
|
||||
let { state, attacker, defender } = reflectRig();
|
||||
// The reflection settled into a NEW stack: the caster now defends.
|
||||
expect(state.stack).not.toBeNull();
|
||||
expect(state.stack!.defenderId).toBe(attacker);
|
||||
expect(state.stack!.reflectedBase).toEqual({ damage: 10, duration: 0 });
|
||||
giveCard(state, attacker, "absorb", "AB", 0);
|
||||
state = must(state, attacker, { type: "counteract", instanceId: "absorb#AB" });
|
||||
state = must(state, defender, { type: "pass" });
|
||||
state = must(state, attacker, { type: "pass" });
|
||||
// 10 reflected minus absorb's 3.
|
||||
expect(state.players.find((p) => p.id === attacker)!.life).toBe(8);
|
||||
expect(state.players.find((p) => p.id === defender)!.life).toBe(15);
|
||||
});
|
||||
|
||||
it("a second full reflection turns it around again — the ping-pong war", () => {
|
||||
let { state, attacker, defender } = reflectRig();
|
||||
giveCard(state, attacker, "full-reflection", "FR2", 0);
|
||||
state = must(state, attacker, { type: "counteract", instanceId: "full-reflection#FR2" });
|
||||
state = must(state, defender, { type: "pass" });
|
||||
// Turned around again: now the original defender must answer it.
|
||||
expect(state.stack).not.toBeNull();
|
||||
expect(state.stack!.defenderId).toBe(defender);
|
||||
state = must(state, defender, { type: "pass" });
|
||||
expect(state.players.find((p) => p.id === defender)!.life).toBe(5);
|
||||
expect(state.players.find((p) => p.id === attacker)!.life).toBe(15);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a reflected lightning blast ends the caster's turn", () => {
|
||||
function boltRig() {
|
||||
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"] });
|
||||
state = toRound2(state);
|
||||
const { attacker, defender } = faceOff(state);
|
||||
const lb = giveCard(state, attacker, "lightning-blast");
|
||||
giveCard(state, attacker, "number-3", "N", 1);
|
||||
giveCard(state, defender, "full-reflection", "FR", 0);
|
||||
state = must(state, attacker, {
|
||||
type: "cast", instanceId: lb.instanceId, numberInstanceIds: ["number-3#N"],
|
||||
target: { kind: "player", playerId: defender },
|
||||
});
|
||||
state = must(state, defender, { type: "counteract", instanceId: "full-reflection#FR" });
|
||||
state = must(state, attacker, { type: "pass" });
|
||||
// rev 25+: the returned bolt opens its window; the caster declines to answer.
|
||||
if (state.stack) state = must(state, attacker, { type: "pass" });
|
||||
return { state, attacker, defender };
|
||||
}
|
||||
|
||||
it("FAQ: 'you lose the rest of your turn and can't draw cards'", () => {
|
||||
let { state, attacker } = boltRig();
|
||||
expect(state.players.find((p) => p.id === attacker)!.life).toBe(12);
|
||||
expect(state.turn.actionsEnded).toBe(true);
|
||||
// No more marching, and the end-of-turn draw comes up empty.
|
||||
expect(applyCommand(state, attacker, { type: "move", direction: "N" }).ok).toBe(false);
|
||||
const before = state.players.find((p) => p.id === attacker)!.hand.length;
|
||||
state = must(state, attacker, { type: "endTurn", draw: 2 });
|
||||
expect(state.players.find((p) => p.id === attacker)!.hand.length).toBe(before);
|
||||
// And the stun still costs the next turn.
|
||||
expect(state.players.find((p) => p.id === attacker)!.lostTurns
|
||||
+ (activePlayer(state).id === attacker ? 1 : 0)).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fireball burns only the stones in play", () => {
|
||||
it("a displayed stone dies; a hidden one stays secret and safe", () => {
|
||||
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"] });
|
||||
state = toRound2(state);
|
||||
const { attacker, defender } = faceOff(state);
|
||||
const fb = giveCard(state, attacker, "fireball");
|
||||
const d = state.players.find((p) => p.id === defender)!;
|
||||
d.hand[0] = { instanceId: "powerstone#T", cardId: "powerstone" };
|
||||
d.hand[1] = { instanceId: "speedstone#T", cardId: "speedstone" };
|
||||
d.displayed = ["powerstone#T"]; // only the powerstone is on the table
|
||||
state = must(state, attacker, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender } });
|
||||
state = must(state, defender, { type: "pass" });
|
||||
const after = state.players.find((p) => p.id === defender)!;
|
||||
expect(after.hand.some((c) => c.instanceId === "powerstone#T")).toBe(false);
|
||||
expect(after.hand.some((c) => c.instanceId === "speedstone#T")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stone dead counts only the stones in play", () => {
|
||||
it("hidden stones neither add damage nor betray their count", () => {
|
||||
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"] });
|
||||
state = toRound2(state);
|
||||
const { attacker, defender } = faceOff(state);
|
||||
const sd = giveCard(state, attacker, "stone-dead");
|
||||
giveCard(state, attacker, "number-3", "N", 1);
|
||||
const d = state.players.find((p) => p.id === defender)!;
|
||||
d.hand[0] = { instanceId: "powerstone#T", cardId: "powerstone" };
|
||||
d.hand[1] = { instanceId: "speedstone#T", cardId: "speedstone" };
|
||||
d.hand[2] = { instanceId: "bloodstone#T", cardId: "bloodstone" };
|
||||
d.displayed = ["powerstone#T"]; // one on the table, two secret
|
||||
state = must(state, attacker, {
|
||||
type: "cast", instanceId: sd.instanceId, numberInstanceIds: ["number-3#N"],
|
||||
target: { kind: "player", playerId: defender },
|
||||
});
|
||||
state = must(state, defender, { type: "pass" });
|
||||
// 3 x 1 displayed stone = 3; the bloodstone is hidden, so it soaks
|
||||
// nothing: 15 - 3 = 12.
|
||||
expect(state.players.find((p) => p.id === defender)!.life).toBe(12);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { applyCommand, activePlayer, boardView, creatureAt, type GameState, type PlayerId, createGame } from "../src/game";
|
||||
import { cellKey, SIDES, stepTarget, type Cell, type Side } from "../src/board";
|
||||
import { cellKey, edgeKey, SIDES, stepTarget, type Cell, type Side } from "../src/board";
|
||||
import type { CardInstance } from "../src/cards";
|
||||
import { newExpansionGame as newGame, must, giveCard, toRound2, emptyNeighborCell } from "./helpers";
|
||||
import { newExpansionGame as newGame, must, giveCard, toRound2, emptyNeighborCell, pushSustained } from "./helpers";
|
||||
|
||||
/** Summon a creature next to its creator (round 2+, consumes the attack). */
|
||||
function summon(state: GameState, playerId: PlayerId, kind: string, tag = "S") {
|
||||
@@ -17,7 +17,8 @@ function summon(state: GameState, playerId: PlayerId, kind: string, tag = "S") {
|
||||
|
||||
describe("expansion deck", () => {
|
||||
it("basic + expansion1 builds the full 200-card game", () => {
|
||||
const { state } = newGame();
|
||||
// Three seats: two-player games shed LIFESAVER from the build.
|
||||
const { state } = newGame(42, ["a", "b", "c"]);
|
||||
const total = state.deck.length + state.discard.length +
|
||||
state.players.reduce((s, p) => s + p.hand.length, 0);
|
||||
expect(total).toBe(200);
|
||||
@@ -59,6 +60,8 @@ describe("monsters", () => {
|
||||
state = must(state, me, {
|
||||
type: "creatureAttack", creatureId: state.creatures[0]!.id, targetId: victim.id,
|
||||
});
|
||||
// The blow opens the victim's counteraction window; they take it raw.
|
||||
state = must(state, victim.id, { type: "pass" });
|
||||
expect(state.players.find((p) => p.id !== me)!.life).toBe(13);
|
||||
});
|
||||
|
||||
@@ -136,6 +139,7 @@ describe("monsters", () => {
|
||||
break;
|
||||
}
|
||||
}
|
||||
state = must(state, enemy2.id, { type: "pass" });
|
||||
const bitten = state.players.find((p) => p.id !== me)!;
|
||||
expect(bitten.life).toBe(13);
|
||||
expect(bitten.hand.length).toBe(6);
|
||||
@@ -293,9 +297,9 @@ describe("expansion support cards", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("counteracting a creature's blow (rules rev 4)", () => {
|
||||
function rev4() {
|
||||
return createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 4 });
|
||||
describe("counteracting a creature's blow", () => {
|
||||
function freshGame() {
|
||||
return createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
|
||||
}
|
||||
function wraithOnVictim(state: GameState) {
|
||||
state = toRound2(state);
|
||||
@@ -312,7 +316,7 @@ describe("counteracting a creature's blow (rules rev 4)", () => {
|
||||
}
|
||||
|
||||
it("BLUNT halves the wraith's touch; the card theft still lands", () => {
|
||||
let { state } = rev4();
|
||||
let { state } = freshGame();
|
||||
const rig = wraithOnVictim(state);
|
||||
state = rig.state;
|
||||
giveCard(state, rig.victim, "blunt", "B", 0);
|
||||
@@ -328,7 +332,7 @@ describe("counteracting a creature's blow (rules rev 4)", () => {
|
||||
});
|
||||
|
||||
it("FULL REFLECTION turns the touch back on the wraith, theft and all", () => {
|
||||
let { state } = rev4();
|
||||
let { state } = freshGame();
|
||||
const rig = wraithOnVictim(state);
|
||||
state = rig.state;
|
||||
giveCard(state, rig.victim, "full-reflection", "FR", 0);
|
||||
@@ -341,20 +345,11 @@ describe("counteracting a creature's blow (rules rev 4)", () => {
|
||||
const wraith = state.creatures.find((c) => c.id === "w1")!;
|
||||
expect(wraith.damage).toBe(2);
|
||||
});
|
||||
|
||||
it("legacy games (rev < 4) keep the instant touch", () => {
|
||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
|
||||
const rig = wraithOnVictim(state);
|
||||
state = rig.state;
|
||||
state = must(state, rig.controller, { type: "moveCreature", creatureId: "w1", direction: rig.side });
|
||||
expect(state.stack).toBeNull();
|
||||
expect(state.players.find((p) => p.id === rig.victim)!.life).toBe(13);
|
||||
});
|
||||
});
|
||||
|
||||
describe("big man (rules rev 5)", () => {
|
||||
describe("big man", () => {
|
||||
function bigRig() {
|
||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 5 });
|
||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
|
||||
state = toRound2(state);
|
||||
const giant = activePlayer(state);
|
||||
state.sustained.push({
|
||||
@@ -450,9 +445,9 @@ describe("big man (rules rev 5)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("monsters roll to hit the hidden (rules rev 13)", () => {
|
||||
function creatureVsInvisible(deckRev: number) {
|
||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev });
|
||||
describe("monsters roll to hit the hidden", () => {
|
||||
function creatureVsInvisible() {
|
||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
|
||||
state = toRound2(state);
|
||||
const me = activePlayer(state).id;
|
||||
const victim = state.players.find((p) => p.id !== me)!;
|
||||
@@ -466,38 +461,22 @@ describe("monsters roll to hit the hidden (rules rev 13)", () => {
|
||||
id: "fx1", cardId: "invisible", casterId: victim.id, targetId: victim.id,
|
||||
remainingTurns: 3, data: {},
|
||||
});
|
||||
return { state, me, victim, trollId: bones.id };
|
||||
return { state, me, victim, skeletonId: bones.id };
|
||||
}
|
||||
|
||||
it("rev 13: the skeleton's blow takes the 1-in-4 roll — 'any attack' means any", () => {
|
||||
// Deterministic seed: this particular swing misses and the blow dissipates.
|
||||
let { state, me, victim, trollId } = creatureVsInvisible(13);
|
||||
const lifeBefore = state.players.find((p) => p.id === victim.id)!.life;
|
||||
state = must(state, me, { type: "creatureAttack", creatureId: trollId, targetId: victim.id });
|
||||
state = must(state, victim.id, { type: "pass" });
|
||||
const rolled = state.rng;
|
||||
expect(rolled).toBeDefined();
|
||||
const after = state.players.find((p) => p.id === victim.id)!;
|
||||
const missed = after.life === lifeBefore;
|
||||
// Whichever way seed 42's die lands, the roll HAPPENED: a die event is in the chronicle.
|
||||
expect(missed || after.life < lifeBefore).toBe(true);
|
||||
});
|
||||
|
||||
it("earlier revisions keep the old certainty: no roll, the blow just lands", () => {
|
||||
let { state, me, victim, trollId } = creatureVsInvisible(12);
|
||||
const lifeBefore = state.players.find((p) => p.id === victim.id)!.life;
|
||||
it("the skeleton's blow takes the 1-in-4 roll — 'any attack' means any", () => {
|
||||
let { state, me, victim, skeletonId } = creatureVsInvisible();
|
||||
const rngBefore = JSON.stringify(state.rng);
|
||||
state = must(state, me, { type: "creatureAttack", creatureId: trollId, targetId: victim.id });
|
||||
state = must(state, me, { type: "creatureAttack", creatureId: skeletonId, targetId: victim.id });
|
||||
state = must(state, victim.id, { type: "pass" });
|
||||
expect(state.players.find((p) => p.id === victim.id)!.life).toBeLessThan(lifeBefore);
|
||||
// No die was consumed on the way.
|
||||
expect(JSON.stringify(state.rng)).toBe(rngBefore);
|
||||
// The die was consumed, whichever way it landed.
|
||||
expect(JSON.stringify(state.rng)).not.toBe(rngBefore);
|
||||
});
|
||||
});
|
||||
|
||||
describe("elimination sweeps the board either way (rules rev 14)", () => {
|
||||
describe("elimination sweeps the board either way", () => {
|
||||
it("a treasure-eliminated wizard's fire imp vanishes with them", () => {
|
||||
let { state } = createGame({ playerIds: ["a", "b", "c"], seed: 42, sets: ["basic", "expansion1"], deckRev: 14 });
|
||||
let { state } = createGame({ playerIds: ["a", "b", "c"], seed: 42, sets: ["basic", "expansion1"] });
|
||||
const victim = state.players.find((p) => p.id === "c")!;
|
||||
state.creatures.push({
|
||||
id: "imp1", kind: "fire-imp", controllerId: "c",
|
||||
@@ -526,3 +505,219 @@ describe("elimination sweeps the board either way (rules rev 14)", () => {
|
||||
expect(state.sustained.some((s) => s.targetId === "c")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("creatures walk open doorways", () => {
|
||||
it("a skeleton passes a door whose lock was removed", () => {
|
||||
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"] });
|
||||
state = toRound2(state);
|
||||
const view = boardView(state);
|
||||
for (const [key, edge] of Object.entries(view.edges)) {
|
||||
if (edge !== "door") continue;
|
||||
const [kind, coords] = key.split(":") as [string, string];
|
||||
const [x, y] = coords.split(",").map(Number) as [number, number];
|
||||
const side = kind === "V" ? ("E" as Side) : ("S" as Side);
|
||||
state.doorStates[key] = "removed";
|
||||
state.creatures.push({
|
||||
id: "sk1", kind: "skeleton", controllerId: activePlayer(state).id,
|
||||
position: { x, y }, damage: 0, maxDamage: 4, movesPerTurn: 3,
|
||||
movementUsed: 0, attackUsed: false, justCreated: false,
|
||||
wallPassesPerTurn: 0, wallPassUsed: 0, scorchedThisTurn: [],
|
||||
});
|
||||
const r = applyCommand(state, activePlayer(state).id, {
|
||||
type: "moveCreature", creatureId: "sk1", direction: side,
|
||||
});
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
const sk = r.state.creatures.find((c) => c.id === "sk1")!;
|
||||
expect(cellKey(sk.position)).toBe(cellKey({ x: x + (side === "E" ? 1 : 0), y: y + (side === "S" ? 1 : 0) }));
|
||||
return;
|
||||
}
|
||||
throw new Error("setup: seed 42 grew a maze with no doors");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the wave carries monsters", () => {
|
||||
it("a cornered skeleton takes the waterwall crush", () => {
|
||||
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"] });
|
||||
state = toRound2(state);
|
||||
const caster = activePlayer(state);
|
||||
// A skeleton pinned against solid stone, one square from the wave's edge.
|
||||
const view = boardView(state);
|
||||
for (const [key, edge] of Object.entries(view.edges)) {
|
||||
if (edge !== "wall") continue;
|
||||
const [kind, coords] = key.split(":") as [string, string];
|
||||
if (kind !== "H") continue;
|
||||
const [x, y] = coords.split(",").map(Number) as [number, number];
|
||||
// Wave from this wall southward washes the square below.
|
||||
const below = { x, y: y + 1 };
|
||||
if (!view.cells[cellKey(below)]) continue;
|
||||
state.creatures.push({
|
||||
id: "sk1", kind: "skeleton", controllerId: caster.id,
|
||||
position: { ...below }, damage: 0, maxDamage: 4, movesPerTurn: 3,
|
||||
movementUsed: 0, attackUsed: false, justCreated: false,
|
||||
wallPassesPerTurn: 0, wallPassUsed: 0, scorchedThisTurn: [],
|
||||
});
|
||||
// Pin it: stone directly south of it.
|
||||
state.squareContents[cellKey({ x, y: y + 2 })] = { kind: "stone", damage: 0, createdBy: caster.id };
|
||||
caster.position = { x, y };
|
||||
const stw = giveCard(state, caster.id, "stone-to-water");
|
||||
const r = applyCommand(state, caster.id, {
|
||||
type: "cast", instanceId: stw.instanceId,
|
||||
target: { kind: "edge", cell: { x, y }, side: "S" },
|
||||
});
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
const sk = r.state.creatures.find((c) => c.id === "sk1");
|
||||
// Washed against the stone: it took crush damage (or died of it).
|
||||
if (sk) expect(sk.damage).toBeGreaterThan(0);
|
||||
return;
|
||||
}
|
||||
throw new Error("setup: no horizontal wall with a floor below");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the democratic monster's claw survives the first wizard's death", () => {
|
||||
it("refreshes each round even with the roll-off winner dead", () => {
|
||||
let { state } = createGame({ playerIds: ["a", "b", "c"], seed: 42, sets: ["basic", "expansion1"] });
|
||||
state = toRound2(state);
|
||||
const firstId = state.players[state.turn.firstIndex]!.id;
|
||||
const dm = {
|
||||
id: "dm1", kind: "democratic-monster" as const, controllerId: state.players[0]!.id,
|
||||
position: { x: 0, y: 0 }, damage: 0, maxDamage: 5, movesPerTurn: 3,
|
||||
movementUsed: 0, attackUsed: true, justCreated: false,
|
||||
wallPassesPerTurn: 0, wallPassUsed: 0, scorchedThisTurn: [] as string[],
|
||||
};
|
||||
state.creatures.push(dm);
|
||||
// The roll-off winner falls.
|
||||
const first = state.players.find((p) => p.id === firstId)!;
|
||||
first.alive = false;
|
||||
first.finalHand = [...first.hand];
|
||||
// Walk a full round of the survivors: the claw must refresh.
|
||||
for (let i = 0; i < 4 && state.creatures[0]!.attackUsed; i++) {
|
||||
const active = activePlayer(state);
|
||||
const r = applyCommand(state, active.id, { type: "endTurn", draw: 0 });
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
state = r.state;
|
||||
}
|
||||
expect(state.creatures[0]!.attackUsed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a mid-round democratic monster claws on the next turn", () => {
|
||||
it("creation spends the turn, not the round", () => {
|
||||
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"] });
|
||||
state = toRound2(state);
|
||||
const creator = activePlayer(state);
|
||||
const r0 = summon(state, creator.id, "democratic-monster");
|
||||
state = r0.state;
|
||||
const dm = state.creatures.find((c) => c.kind === "democratic-monster")!;
|
||||
expect(dm.attackUsed).toBe(false);
|
||||
expect(dm.justCreated).toBe(true);
|
||||
// Next player's turn: justCreated clears; the claw is live.
|
||||
state = must(state, creator.id, { type: "endTurn", draw: 0 });
|
||||
const mover = activePlayer(state);
|
||||
const victim = state.players.find((p) => p.id !== mover.id)!;
|
||||
const fresh = state.creatures.find((c) => c.kind === "democratic-monster")!;
|
||||
expect(fresh.justCreated).toBe(false);
|
||||
// March it onto the victim: the touch must open.
|
||||
fresh.position = { x: victim.position.x - 1, y: victim.position.y };
|
||||
const r = applyCommand(state, mover.id, { type: "moveCreature", creatureId: fresh.id, direction: "E" });
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
expect(r.state.stack?.creatureId ?? null).toBe(fresh.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("collapsing walls crush monsters too", () => {
|
||||
function wallRig() {
|
||||
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"] });
|
||||
state = toRound2(state);
|
||||
const view = boardView(state);
|
||||
for (const [key, edge] of Object.entries(view.edges)) {
|
||||
if (edge !== "wall") continue;
|
||||
const [kind, coords] = key.split(":") as [string, string];
|
||||
const [x, y] = coords.split(",").map(Number) as [number, number];
|
||||
const side = kind === "V" ? ("E" as Side) : ("S" as Side);
|
||||
const other = { x: x + (side === "E" ? 1 : 0), y: y + (side === "S" ? 1 : 0) };
|
||||
if (!view.cells[cellKey(other)]) continue;
|
||||
const caster = activePlayer(state);
|
||||
caster.position = { x, y };
|
||||
state.creatures.push({
|
||||
id: "tr1", kind: "troll", controllerId: caster.id,
|
||||
position: { ...other }, damage: 0, maxDamage: 6, movesPerTurn: 3,
|
||||
movementUsed: 0, attackUsed: false, justCreated: false,
|
||||
wallPassesPerTurn: 0, wallPassUsed: 0, scorchedThisTurn: [],
|
||||
});
|
||||
const dw = giveCard(state, caster.id, "destroy-wall");
|
||||
const r = applyCommand(state, caster.id, {
|
||||
type: "cast", instanceId: dw.instanceId, target: { kind: "edge", cell: { x, y }, side },
|
||||
});
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
return r.state;
|
||||
}
|
||||
throw new Error("setup: no interior wall found");
|
||||
}
|
||||
|
||||
it("the adjacent troll takes the four points", () => {
|
||||
const state = wallRig();
|
||||
const troll = state.creatures.find((c) => c.id === "tr1");
|
||||
// Four points on a six-point troll: hurt but standing (or regenerating).
|
||||
expect(troll?.damage).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the self-stack resolves on a pass", () => {
|
||||
function selfTouch() {
|
||||
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"] });
|
||||
state = toRound2(state);
|
||||
const creator = activePlayer(state);
|
||||
state.creatures.push({
|
||||
id: "dm1", kind: "democratic-monster", controllerId: creator.id,
|
||||
position: { x: creator.position.x - 1, y: creator.position.y },
|
||||
damage: 0, maxDamage: 5, movesPerTurn: 3, movementUsed: 0,
|
||||
attackUsed: false, justCreated: false,
|
||||
wallPassesPerTurn: 0, wallPassUsed: 0, scorchedThisTurn: [],
|
||||
});
|
||||
const r = applyCommand(state, creator.id, { type: "moveCreature", creatureId: "dm1", direction: "E" });
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
return { state: r.state, creator: creator.id };
|
||||
}
|
||||
|
||||
it("passing your own monster's touch takes the claw and moves on", () => {
|
||||
const { state, creator } = selfTouch();
|
||||
if (!state.stack) return; // a wall between: the touch never opened
|
||||
expect(state.stack.attackerId).toBe(creator);
|
||||
expect(state.stack.defenderId).toBe(creator);
|
||||
const r = applyCommand(state, creator, { type: "pass" });
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
expect(r.state.stack).toBeNull();
|
||||
expect(r.state.players.find((p) => p.id === creator)!.life).toBe(13);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fear holds off monsters and unwilling feet alike", () => {
|
||||
it("a commanded monster cannot close within three of the fearsome", () => {
|
||||
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"] });
|
||||
const a = state.players.find((p) => p.id === "a")!;
|
||||
const b = state.players.find((p) => p.id === "b")!;
|
||||
// b radiates fear; a's troll stands exactly four away, aimed straight at b.
|
||||
pushSustained(state, {
|
||||
id: "fx-fear", cardId: "fear", casterId: "b", targetId: "b",
|
||||
remainingTurns: 5, data: {},
|
||||
});
|
||||
b.position = { x: 2, y: 5 };
|
||||
a.position = { x: 0, y: 0 };
|
||||
state.creatures.push({
|
||||
id: "troll-1", kind: "troll", controllerId: "a", position: { x: 2, y: 9 },
|
||||
damage: 0, maxDamage: 6, movesPerTurn: 3, movementUsed: 0,
|
||||
wallPassesPerTurn: 0, wallPassUsed: 0, attackUsed: false, justCreated: false,
|
||||
scorchedThisTurn: [],
|
||||
} as never);
|
||||
for (const y of [5, 6, 7, 8]) state.edgeOverrides[edgeKey({ x: 2, y }, "S")] = "open";
|
||||
while (state.players[state.turn.activeIndex]!.id !== "a") {
|
||||
const r = applyCommand(state, state.players[state.turn.activeIndex]!.id, { type: "endTurn", draw: 0 });
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
state = r.state;
|
||||
}
|
||||
const r = applyCommand(state, "a", { type: "moveCreature", creatureId: "troll-1", direction: "N" });
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) expect(r.error).toContain("dread");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { applyCommand, activePlayer, boardView, sustainedOn, type GameState } from "../src/game";
|
||||
import { applyCommand, activePlayer, boardView, createGame, sustainedOn, type GameState } from "../src/game";
|
||||
import { cellKey, edgeKey, neighbor, type Side } from "../src/board";
|
||||
import type { CardInstance } from "../src/cards";
|
||||
import { newGame, must, giveCard, toRound2, faceOff, castAt } from "./helpers";
|
||||
@@ -340,9 +340,122 @@ describe("cast modifiers", () => {
|
||||
});
|
||||
state = must(state, defender, { type: "counteract", instanceId: "reverse#R" });
|
||||
state = must(state, attacker, { type: "pass" });
|
||||
state = must(state, defender, { type: "pass" });
|
||||
const d = state.players.find((p) => p.id === defender)!;
|
||||
expect(d.life).toBe(19); // gained 4 instead of losing it
|
||||
expect(d.lostTurns).toBe(1); // the stun still applies
|
||||
});
|
||||
});
|
||||
|
||||
describe("holding the door open (Pick Lock / Master Key)", () => {
|
||||
function doorRig() {
|
||||
let { state } = createGame({ playerIds: ["holder", "guest"], seed: 42, sets: ["basic"] });
|
||||
state = toRound2(state);
|
||||
// Find a door edge; stand the acting player beside it.
|
||||
const view = boardView(state);
|
||||
for (const [key, edge] of Object.entries(view.edges)) {
|
||||
if (edge !== "door") continue;
|
||||
const [kind, coords] = key.split(":") as [string, string];
|
||||
const [x, y] = coords.split(",").map(Number) as [number, number];
|
||||
const cell = { x, y };
|
||||
const side = kind === "V" ? ("E" as Side) : ("S" as Side);
|
||||
const holder = activePlayer(state);
|
||||
holder.position = { ...cell };
|
||||
return { state, key, cell, side, holder: holder.id };
|
||||
}
|
||||
throw new Error("setup: seed 42 grew a maze with no doors");
|
||||
}
|
||||
|
||||
it("a held door outlives the turn and admits another wizard", () => {
|
||||
let { state, key, cell, side, holder } = doorRig();
|
||||
const pick = giveCard(state, holder, "pick-lock");
|
||||
state = must(state, holder, {
|
||||
type: "cast", instanceId: pick.instanceId,
|
||||
target: { kind: "edge", cell, side }, params: { hold: true },
|
||||
});
|
||||
state = must(state, holder, { type: "endTurn", draw: 0 });
|
||||
expect(state.heldDoors.some((h) => h.key === key)).toBe(true);
|
||||
const guest = state.players.find((p) => p.id !== holder)!;
|
||||
guest.position = { ...cell };
|
||||
const r = applyCommand(state, guest.id, { type: "move", direction: side });
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
const through = r.state.players.find((p) => p.id === guest.id)!;
|
||||
expect(cellKey(through.position)).toBe(cellKey(neighbor(cell, side)));
|
||||
});
|
||||
|
||||
it("the door swings shut the moment the holder steps away", () => {
|
||||
let { state, key, cell, side, holder } = doorRig();
|
||||
const pick = giveCard(state, holder, "pick-lock");
|
||||
state = must(state, holder, {
|
||||
type: "cast", instanceId: pick.instanceId,
|
||||
target: { kind: "edge", cell, side }, params: { hold: true },
|
||||
});
|
||||
expect(state.heldDoors.some((h) => h.key === key)).toBe(true);
|
||||
// March the holder until adjacency breaks; the sweep must release.
|
||||
let walked = state;
|
||||
let released = false;
|
||||
const dirs: Side[] = ["N", "E", "S", "W"];
|
||||
for (const d1 of dirs) {
|
||||
const r1 = applyCommand(walked, holder, { type: "move", direction: d1 });
|
||||
if (!r1.ok) continue;
|
||||
if (!r1.state.heldDoors.some((h) => h.key === key)) { released = true; break; }
|
||||
for (const d2 of dirs) {
|
||||
const r2 = applyCommand(r1.state, holder, { type: "move", direction: d2 });
|
||||
if (!r2.ok) continue;
|
||||
if (!r2.state.heldDoors.some((h) => h.key === key)) { released = true; break; }
|
||||
}
|
||||
if (released) break;
|
||||
}
|
||||
expect(released).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a held door is an open doorway to the eye", () => {
|
||||
function sightRig() {
|
||||
let { state } = createGame({ playerIds: ["holder", "pursuer"], seed: 42, sets: ["basic"] });
|
||||
state = toRound2(state);
|
||||
const view = boardView(state);
|
||||
for (const [key, edge] of Object.entries(view.edges)) {
|
||||
if (edge !== "door") continue;
|
||||
const [kind, coords] = key.split(":") as [string, string];
|
||||
const [x, y] = coords.split(",").map(Number) as [number, number];
|
||||
const cell = { x, y };
|
||||
const side = kind === "V" ? ("E" as Side) : ("S" as Side);
|
||||
const holder = activePlayer(state);
|
||||
const pursuer = state.players.find((p) => p.id !== holder.id)!;
|
||||
holder.position = { ...cell };
|
||||
pursuer.position = neighbor(cell, side);
|
||||
return { state, cell, side, holder: holder.id, pursuer: pursuer.id };
|
||||
}
|
||||
throw new Error("setup: seed 42 grew a maze with no doors");
|
||||
}
|
||||
|
||||
it("the holder blasts the pursuer through the held doorway", () => {
|
||||
let { state, cell, side, holder, pursuer } = sightRig();
|
||||
const pick = giveCard(state, holder, "pick-lock");
|
||||
state = must(state, holder, {
|
||||
type: "cast", instanceId: pick.instanceId,
|
||||
target: { kind: "edge", cell, side }, params: { hold: true },
|
||||
});
|
||||
const fb = giveCard(state, holder, "fireball", "FB", 1);
|
||||
const lifeBefore = state.players.find((p) => p.id === pursuer)!.life;
|
||||
state = must(state, holder, {
|
||||
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: pursuer },
|
||||
});
|
||||
state = must(state, pursuer, { type: "pass" });
|
||||
expect(state.players.find((p) => p.id === pursuer)!.life).toBeLessThan(lifeBefore);
|
||||
});
|
||||
|
||||
it("an unheld unlocked door still blocks sight", () => {
|
||||
let { state, cell, side, holder, pursuer } = sightRig();
|
||||
const pick = giveCard(state, holder, "pick-lock");
|
||||
state = must(state, holder, {
|
||||
type: "cast", instanceId: pick.instanceId,
|
||||
target: { kind: "edge", cell, side },
|
||||
});
|
||||
const fb = giveCard(state, holder, "fireball", "FB", 1);
|
||||
const refused = applyCommand(state, holder, {
|
||||
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: pursuer },
|
||||
});
|
||||
expect(refused.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { applyCommand, activePlayer, boardView, gameLos, sustainedOn } from "../src/game";
|
||||
import { cellKey, stepTarget } from "../src/board";
|
||||
import { applyCommand, activePlayer, boardView, createGame, gameLos, sustainedOn } from "../src/game";
|
||||
import { cellKey, edgeKey, stepTarget } from "../src/board";
|
||||
import type { CardInstance } from "../src/cards";
|
||||
import { newExpansionGame as newGame, must, giveCard, toRound2, faceOff, castAt } from "./helpers";
|
||||
|
||||
@@ -106,7 +106,7 @@ describe("expansion combat cards", () => {
|
||||
expect(state.players.find((p) => p.id === attacker)!.life).toBe(10);
|
||||
});
|
||||
|
||||
it("ward springs when a trapped treasure is grabbed", () => {
|
||||
it("ward springs in the moment: the grab hangs on the owner's choice", () => {
|
||||
let { state } = newGame();
|
||||
const me = activePlayer(state);
|
||||
const enemy = state.players.find((p) => p.id !== me.id)!;
|
||||
@@ -114,6 +114,8 @@ describe("expansion combat cards", () => {
|
||||
const treasure = state.treasures.find((t) => t.owner === enemy.id && t.position)!;
|
||||
me.position = { ...treasure.position! };
|
||||
state = must(state, me.id, { type: "pickUpTreasure" });
|
||||
expect(state.wardPending).toEqual({ ownerId: enemy.id, takerId: me.id });
|
||||
state = must(state, enemy.id, { type: "wardChoice", play: true });
|
||||
expect(state.players.find((p) => p.id === me.id)!.life).toBe(12);
|
||||
expect(state.players.find((p) => p.id === enemy.id)!.hand.some((c) => c.cardId === "ward")).toBe(false);
|
||||
});
|
||||
@@ -161,6 +163,114 @@ describe("expansion combat cards", () => {
|
||||
expect(r.error).toMatch(/blocked/);
|
||||
}
|
||||
});
|
||||
|
||||
it("idiot forbids item handling and punches but allows counteractions", () => {
|
||||
let { state } = createGame({
|
||||
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"],
|
||||
});
|
||||
state = toRound2(state);
|
||||
const { attacker, defender } = faceOff(state);
|
||||
// The victim carries an OPPONENT'S treasure into the spell.
|
||||
const d = state.players.find((p) => p.id === defender)!;
|
||||
const stolen = state.treasures.find((t) => t.owner === attacker)!;
|
||||
stolen.carriedBy = defender;
|
||||
stolen.position = null;
|
||||
d.carriedTreasureId = stolen.id;
|
||||
const id = giveCard(state, attacker, "idiot");
|
||||
state = castAt(state, attacker, defender, id);
|
||||
expect(sustainedOn(state, defender, "idiot").length).toBe(1);
|
||||
state = must(state, attacker, { type: "endTurn", draw: 0 });
|
||||
|
||||
// No dropping the carried treasure (no capturing it on your home, either).
|
||||
const drop = applyCommand(state, defender, { type: "dropTreasure" });
|
||||
expect(drop.ok).toBe(false);
|
||||
if (!drop.ok) expect(drop.error).toMatch(/treasure/);
|
||||
// No punching the tormentor.
|
||||
expect(applyCommand(state, defender, { type: "punch", targetId: attacker }).ok).toBe(false);
|
||||
// Goal-aiding spells stay castable (FAQ: "You could, however, destroy a wall").
|
||||
const sp = giveCard(state, defender, "speed", "SP", 0);
|
||||
expect(applyCommand(state, defender, { type: "cast", instanceId: sp.instanceId }).ok).toBe(true);
|
||||
// Counteractions are expressly allowed: absorb an incoming fireball.
|
||||
state = must(state, defender, { type: "endTurn", draw: 0 });
|
||||
const fb = giveCard(state, attacker, "fireball", "F", 0);
|
||||
state = must(state, attacker, {
|
||||
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender },
|
||||
});
|
||||
giveCard(state, defender, "absorb-spell", "AB", 1);
|
||||
expect(applyCommand(state, defender, { type: "counteract", instanceId: "absorb-spell#AB" }).ok).toBe(true);
|
||||
});
|
||||
|
||||
it("idiot never steers the march — the step goes where asked", () => {
|
||||
let { state } = createGame({
|
||||
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"],
|
||||
});
|
||||
state = toRound2(state);
|
||||
const { attacker, defender } = faceOff(state);
|
||||
const id = giveCard(state, attacker, "idiot");
|
||||
state = castAt(state, attacker, defender, id);
|
||||
expect(sustainedOn(state, defender, "idiot").length).toBe(1);
|
||||
state = must(state, attacker, { type: "endTurn", draw: 0 });
|
||||
const d = state.players.find((p) => p.id === defender)!;
|
||||
const board = boardView(state);
|
||||
const open = (["N", "S", "E", "W"] as const)
|
||||
.filter((s) => stepTarget(board, d.position, s).kind === "step");
|
||||
expect(open.length).toBeGreaterThanOrEqual(2);
|
||||
// Plant the victim's own gold one step out one way; walk the other way.
|
||||
|
||||
const toward = stepTarget(board, d.position, open[0]!);
|
||||
const away = stepTarget(board, d.position, open[1]!);
|
||||
if (toward.kind === "blocked" || away.kind === "blocked") throw new Error("unreachable");
|
||||
const own = state.treasures.find((t) => t.owner === defender)!;
|
||||
own.carriedBy = null;
|
||||
own.position = toward.to;
|
||||
state = must(state, defender, { type: "move", direction: open[1]! });
|
||||
expect(cellKey(state.players.find((p) => p.id === defender)!.position)).toBe(cellKey(away.to));
|
||||
});
|
||||
|
||||
it("idiot permits DROP OBJECT that frees the victim's own treasure", () => {
|
||||
let { state } = createGame({
|
||||
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"],
|
||||
});
|
||||
state = toRound2(state);
|
||||
const { attacker, defender } = faceOff(state);
|
||||
const thief = state.players.find((p) => p.id === attacker)!;
|
||||
const own = state.treasures.find((t) => t.owner === defender)!;
|
||||
own.carriedBy = attacker;
|
||||
own.position = null;
|
||||
thief.carriedTreasureId = own.id;
|
||||
const id = giveCard(state, attacker, "idiot");
|
||||
state = castAt(state, attacker, defender, id);
|
||||
state = must(state, attacker, { type: "endTurn", draw: 0 });
|
||||
// An attack for its own sake stays forbidden.
|
||||
const fb = giveCard(state, defender, "fireball", "F", 0);
|
||||
expect(applyCommand(state, defender, {
|
||||
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: attacker },
|
||||
}).ok).toBe(false);
|
||||
// Shaking YOUR OWN gold out of the thief's arms serves the march —
|
||||
// while carried it cannot be stood upon.
|
||||
const dr = giveCard(state, defender, "drop-object", "D", 1);
|
||||
const r = applyCommand(state, defender, {
|
||||
type: "cast", instanceId: dr.instanceId,
|
||||
target: { kind: "player", playerId: attacker }, params: { cardId: "treasure" },
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("idiot has no effect on a victim carrying their own treasure", () => {
|
||||
let { state } = createGame({
|
||||
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"],
|
||||
});
|
||||
state = toRound2(state);
|
||||
const { attacker, defender } = faceOff(state);
|
||||
const d = state.players.find((p) => p.id === defender)!;
|
||||
const own = state.treasures.find((t) => t.owner === defender)!;
|
||||
own.carriedBy = defender;
|
||||
own.position = null;
|
||||
d.carriedTreasureId = own.id;
|
||||
const id = giveCard(state, attacker, "idiot");
|
||||
state = castAt(state, attacker, defender, id);
|
||||
expect(sustainedOn(state, defender, "idiot").length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("swap home bases", () => {
|
||||
@@ -283,3 +393,285 @@ describe("ambushes (async interrupts)", () => {
|
||||
expect(state.ambushes.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("strength tears treasures from wizards' arms", () => {
|
||||
function grip(seed: number) {
|
||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed, sets: ["basic", "expansion1"] });
|
||||
state = toRound2(state);
|
||||
const { attacker, defender } = faceOff(state);
|
||||
const d = state.players.find((p) => p.id === defender)!;
|
||||
// The defender carries the ATTACKER's own treasure (no ward window).
|
||||
const stolen = state.treasures.find((t) => t.owner === attacker)!;
|
||||
stolen.carriedBy = defender;
|
||||
stolen.position = null;
|
||||
d.carriedTreasureId = stolen.id;
|
||||
const st = giveCard(state, attacker, "strength");
|
||||
giveCard(state, attacker, "number-2", "N", 1);
|
||||
state = must(state, attacker, {
|
||||
type: "cast", instanceId: st.instanceId, numberInstanceIds: ["number-2#N"],
|
||||
});
|
||||
return { state, attacker, defender, treasure: stolen.id };
|
||||
}
|
||||
|
||||
it("the grip is an attack: the victim keeps the treasure only on a 1", () => {
|
||||
let torn = 0, kept = 0;
|
||||
for (let seed = 1; seed <= 12; seed++) {
|
||||
const { state, attacker, defender, treasure } = grip(seed);
|
||||
const r = applyCommand(state, attacker, { type: "tearTreasure", targetId: defender });
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
expect(r.state.turn.attackUsed).toBe(true);
|
||||
// FAQ: tearing is not picking up — the turn's actions continue.
|
||||
expect(r.state.turn.actionsEnded).toBe(false);
|
||||
const a = r.state.players.find((p) => p.id === attacker)!;
|
||||
const d = r.state.players.find((p) => p.id === defender)!;
|
||||
if (a.carriedTreasureId === treasure) {
|
||||
torn++;
|
||||
expect(d.carriedTreasureId).toBeNull();
|
||||
} else {
|
||||
kept++;
|
||||
expect(d.carriedTreasureId).toBe(treasure);
|
||||
}
|
||||
// One attack per turn: a second wrench is refused.
|
||||
expect(applyCommand(r.state, attacker, { type: "tearTreasure", targetId: defender }).ok).toBe(false);
|
||||
}
|
||||
expect(torn + kept).toBe(12);
|
||||
expect(torn).toBeGreaterThan(0);
|
||||
expect(kept).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("without STRENGTH the grip is refused", () => {
|
||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
|
||||
state = toRound2(state);
|
||||
const { attacker, defender } = faceOff(state);
|
||||
const d = state.players.find((p) => p.id === defender)!;
|
||||
const t = state.treasures.find((t) => t.owner === attacker)!;
|
||||
t.carriedBy = defender;
|
||||
t.position = null;
|
||||
d.carriedTreasureId = t.id;
|
||||
const r = applyCommand(state, attacker, { type: "tearTreasure", targetId: defender });
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("laden arms tear it loose onto the floor — and the ward's owner gets their window", () => {
|
||||
for (let seed = 1; seed <= 12; seed++) {
|
||||
let { state } = createGame({ playerIds: ["alice", "bob", "cara"], seed, sets: ["basic", "expansion1"] });
|
||||
state = toRound2(state);
|
||||
while (activePlayer(state).id !== "alice") {
|
||||
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
|
||||
}
|
||||
const alice = state.players.find((p) => p.id === "alice")!;
|
||||
const bob = state.players.find((p) => p.id === "bob")!;
|
||||
bob.position = { ...alice.position };
|
||||
// Alice already hauls her own gold; Bob carries CARA's — and Cara
|
||||
// holds a WARD over it.
|
||||
const mine = state.treasures.find((t) => t.owner === "alice")!;
|
||||
mine.carriedBy = "alice";
|
||||
mine.position = null;
|
||||
alice.carriedTreasureId = mine.id;
|
||||
const caras = state.treasures.find((t) => t.owner === "cara")!;
|
||||
caras.carriedBy = "bob";
|
||||
caras.position = null;
|
||||
bob.carriedTreasureId = caras.id;
|
||||
giveCard(state, "cara", "ward", "W", 0);
|
||||
const st = giveCard(state, "alice", "strength");
|
||||
giveCard(state, "alice", "number-2", "N", 1);
|
||||
state = must(state, "alice", {
|
||||
type: "cast", instanceId: st.instanceId, numberInstanceIds: ["number-2#N"],
|
||||
});
|
||||
const r = applyCommand(state, "alice", { type: "tearTreasure", targetId: "bob" });
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
const after = r.state.treasures.find((t) => t.id === caras.id)!;
|
||||
if (after.carriedBy === "bob") continue; // Bob rolled his 1 — try again
|
||||
// Torn loose: Alice's arms are full, so it lands at her feet…
|
||||
expect(after.position).toEqual(alice.position);
|
||||
expect(r.state.players.find((p) => p.id === "alice")!.carriedTreasureId).toBe(mine.id);
|
||||
// …and the seizure hangs on Cara's ward.
|
||||
expect(r.state.wardPending).toEqual({ ownerId: "cara", takerId: "alice" });
|
||||
return;
|
||||
}
|
||||
throw new Error("every seed rolled a 1 — the rig is wrong");
|
||||
});
|
||||
});
|
||||
|
||||
describe("swap meet trades carried items", () => {
|
||||
function rig() {
|
||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
|
||||
state = toRound2(state);
|
||||
const { attacker, defender } = faceOff(state);
|
||||
const sm = giveCard(state, attacker, "swap-meet");
|
||||
giveCard(state, attacker, "dagger", "D", 1);
|
||||
giveCard(state, defender, "blaster-wand", "W", 0);
|
||||
state = must(state, attacker, {
|
||||
type: "cast", instanceId: sm.instanceId,
|
||||
target: { kind: "player", playerId: defender },
|
||||
params: { cardId: "dagger;blaster-wand" },
|
||||
});
|
||||
state = must(state, defender, { type: "pass" });
|
||||
return { state, attacker, defender };
|
||||
}
|
||||
|
||||
it("a dagger trades for a wand — both are carried items", () => {
|
||||
const { state, attacker, defender } = rig();
|
||||
expect(state.players.find((p) => p.id === attacker)!.hand.some((c) => c.cardId === "blaster-wand")).toBe(true);
|
||||
expect(state.players.find((p) => p.id === defender)!.hand.some((c) => c.cardId === "dagger")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("swap meet trades treasures too", () => {
|
||||
function treasureRig(theirsToken: string, mineToken: string) {
|
||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
|
||||
state = toRound2(state);
|
||||
const { attacker, defender } = faceOff(state);
|
||||
const a = state.players.find((p) => p.id === attacker)!;
|
||||
const d = state.players.find((p) => p.id === defender)!;
|
||||
// The defender hauls one of the attacker's own treasures.
|
||||
const stolen = state.treasures.find((t) => t.owner === attacker)!;
|
||||
stolen.position = null; stolen.carriedBy = defender; d.carriedTreasureId = stolen.id;
|
||||
const sm = giveCard(state, attacker, "swap-meet");
|
||||
giveCard(state, attacker, "dagger", "D", 1);
|
||||
state = must(state, attacker, {
|
||||
type: "cast", instanceId: sm.instanceId,
|
||||
target: { kind: "player", playerId: defender },
|
||||
params: { cardId: `${mineToken};${theirsToken}` },
|
||||
});
|
||||
state = must(state, defender, { type: "pass" });
|
||||
return { state, attacker, defender, a, d, stolen };
|
||||
}
|
||||
|
||||
it("a dagger buys back the treasure in the thief's arms", () => {
|
||||
const { state, attacker, defender } = treasureRig("treasure", "dagger");
|
||||
const a = state.players.find((p) => p.id === attacker)!;
|
||||
const d = state.players.find((p) => p.id === defender)!;
|
||||
const t = state.treasures.find((tr) => tr.owner === attacker && tr.carriedBy)!;
|
||||
expect(a.carriedTreasureId).toBe(t.id);
|
||||
expect(t.carriedBy).toBe(attacker);
|
||||
expect(d.carriedTreasureId).toBeNull();
|
||||
expect(d.hand.some((c) => c.cardId === "dagger")).toBe(true);
|
||||
});
|
||||
|
||||
it("the trade refuses to overload full arms", () => {
|
||||
// The attacker also carries a treasure: claiming theirs with a dagger
|
||||
// would mean two in hand — the swap quietly cannot happen.
|
||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
|
||||
state = toRound2(state);
|
||||
const { attacker, defender } = faceOff(state);
|
||||
const a = state.players.find((p) => p.id === attacker)!;
|
||||
const d = state.players.find((p) => p.id === defender)!;
|
||||
const t1 = state.treasures.find((t) => t.owner === attacker)!;
|
||||
t1.position = null; t1.carriedBy = attacker; a.carriedTreasureId = t1.id;
|
||||
const t2 = state.treasures.find((t) => t.owner === defender)!;
|
||||
t2.position = null; t2.carriedBy = defender; d.carriedTreasureId = t2.id;
|
||||
const sm = giveCard(state, attacker, "swap-meet");
|
||||
giveCard(state, attacker, "dagger", "D", 1);
|
||||
state = must(state, attacker, {
|
||||
type: "cast", instanceId: sm.instanceId,
|
||||
target: { kind: "player", playerId: defender },
|
||||
params: { cardId: "dagger;treasure" },
|
||||
});
|
||||
state = must(state, defender, { type: "pass" });
|
||||
expect(state.players.find((p) => p.id === attacker)!.carriedTreasureId).toBe(t1.id);
|
||||
expect(state.players.find((p) => p.id === defender)!.carriedTreasureId).toBe(t2.id);
|
||||
});
|
||||
|
||||
it("treasure for treasure trades both armfuls", () => {
|
||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
|
||||
state = toRound2(state);
|
||||
const { attacker, defender } = faceOff(state);
|
||||
const a = state.players.find((p) => p.id === attacker)!;
|
||||
const d = state.players.find((p) => p.id === defender)!;
|
||||
const t1 = state.treasures.find((t) => t.owner === attacker)!;
|
||||
t1.position = null; t1.carriedBy = attacker; a.carriedTreasureId = t1.id;
|
||||
const t2 = state.treasures.find((t) => t.owner === defender)!;
|
||||
t2.position = null; t2.carriedBy = defender; d.carriedTreasureId = t2.id;
|
||||
const sm = giveCard(state, attacker, "swap-meet");
|
||||
state = must(state, attacker, {
|
||||
type: "cast", instanceId: sm.instanceId,
|
||||
target: { kind: "player", playerId: defender },
|
||||
params: { cardId: "treasure;treasure" },
|
||||
});
|
||||
state = must(state, defender, { type: "pass" });
|
||||
expect(state.players.find((p) => p.id === attacker)!.carriedTreasureId).toBe(t2.id);
|
||||
expect(state.players.find((p) => p.id === defender)!.carriedTreasureId).toBe(t1.id);
|
||||
expect(state.treasures.find((t) => t.id === t1.id)!.carriedBy).toBe(defender);
|
||||
expect(state.treasures.find((t) => t.id === t2.id)!.carriedBy).toBe(attacker);
|
||||
});
|
||||
});
|
||||
|
||||
describe("full reflection hands the swap meet choice to the reflector", () => {
|
||||
function reflectedSwapRig(reflectorChoice?: string) {
|
||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
|
||||
state = toRound2(state);
|
||||
const { attacker, defender } = faceOff(state);
|
||||
const a = state.players.find((p) => p.id === attacker)!;
|
||||
// The caster carries a treasure and offers a dagger; the reflector may
|
||||
// instead demand the trade of their own choosing.
|
||||
const t = state.treasures.find((t) => t.owner === attacker)!;
|
||||
t.position = null; t.carriedBy = attacker; a.carriedTreasureId = t.id;
|
||||
const sm = giveCard(state, attacker, "swap-meet");
|
||||
giveCard(state, attacker, "dagger", "D", 1);
|
||||
giveCard(state, defender, "full-reflection", "FR", 0);
|
||||
giveCard(state, defender, "large-rock", "R", 1);
|
||||
state = must(state, attacker, {
|
||||
type: "cast", instanceId: sm.instanceId,
|
||||
target: { kind: "player", playerId: defender },
|
||||
params: { cardId: "dagger;large-rock" },
|
||||
});
|
||||
state = must(state, defender, {
|
||||
type: "counteract", instanceId: "full-reflection#FR",
|
||||
...(reflectorChoice ? { params: { cardId: reflectorChoice } } : {}),
|
||||
});
|
||||
state = must(state, attacker, { type: "pass" });
|
||||
return { state, attacker, defender, treasureId: t.id };
|
||||
}
|
||||
|
||||
it("the reflector rewrites the trade: their rock for the caster's treasure", () => {
|
||||
const { state, attacker, defender, treasureId } = reflectedSwapRig("large-rock;treasure");
|
||||
const d = state.players.find((p) => p.id === defender)!;
|
||||
expect(d.carriedTreasureId).toBe(treasureId);
|
||||
expect(state.players.find((p) => p.id === attacker)!.hand.some((c) => c.cardId === "large-rock")).toBe(true);
|
||||
expect(state.players.find((p) => p.id === attacker)!.carriedTreasureId).toBeNull();
|
||||
});
|
||||
|
||||
it("the reflector may decline: 'none' trades nothing", () => {
|
||||
const { state, attacker, defender, treasureId } = reflectedSwapRig("none");
|
||||
expect(state.players.find((p) => p.id === attacker)!.carriedTreasureId).toBe(treasureId);
|
||||
expect(state.players.find((p) => p.id === defender)!.hand.some((c) => c.cardId === "large-rock")).toBe(true);
|
||||
});
|
||||
|
||||
it("a bare full reflection with no choice trades nothing", () => {
|
||||
const { state, attacker } = reflectedSwapRig(undefined);
|
||||
expect(state.players.find((p) => p.id === attacker)!.hand.some((c) => c.cardId === "dagger")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("go away routs around walls", () => {
|
||||
it("a victim against a wall bends the line instead of standing still", () => {
|
||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
|
||||
state = toRound2(state);
|
||||
const { attacker, defender } = faceOff(state);
|
||||
const a = state.players.find((p) => p.id === attacker)!;
|
||||
const d = state.players.find((p) => p.id === defender)!;
|
||||
// Attacker west of the victim, a wall hard against the victim's east:
|
||||
// the straight line away is blocked from the first step.
|
||||
a.position = { x: 1, y: 5 };
|
||||
d.position = { x: 2, y: 5 };
|
||||
state.edgeOverrides[edgeKey({ x: 1, y: 5 }, "E")] = "open";
|
||||
state.edgeOverrides[edgeKey({ x: 2, y: 5 }, "E")] = "wall";
|
||||
state.edgeOverrides[edgeKey({ x: 2, y: 5 }, "N")] = "open";
|
||||
state.edgeOverrides[edgeKey({ x: 2, y: 5 }, "S")] = "open";
|
||||
state.edgeOverrides[edgeKey({ x: 2, y: 4 }, "N")] = "open";
|
||||
state.edgeOverrides[edgeKey({ x: 2, y: 6 }, "S")] = "open";
|
||||
const ga = giveCard(state, attacker, "go-away");
|
||||
giveCard(state, attacker, "number-3", "N3", 1);
|
||||
state = must(state, attacker, {
|
||||
type: "cast", instanceId: ga.instanceId, numberInstanceIds: ["number-3#N3"],
|
||||
target: { kind: "player", playerId: defender },
|
||||
});
|
||||
state = must(state, defender, { type: "pass" });
|
||||
const after = state.players.find((p) => p.id === defender)!;
|
||||
const dist = Math.abs(after.position.x - 1) + Math.abs(after.position.y - 5);
|
||||
// Three routed spaces: strictly farther than where they stood.
|
||||
expect(dist).toBeGreaterThanOrEqual(3);
|
||||
expect(after.lostTurns).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { applyCommand, activePlayer, boardView, gameLos } from "../src/game";
|
||||
import { cellKey, SIDES, stepTarget, type Cell } from "../src/board";
|
||||
import { applyCommand, activePlayer, boardView, createGame, gameLos } from "../src/game";
|
||||
import { cellKey, edgeKey, SIDES, stepTarget, type Cell, type Side } from "../src/board";
|
||||
import type { CardInstance } from "../src/cards";
|
||||
import { eligibleCellsFor, sightedCellsFor, viewFor } from "../src/view";
|
||||
import { newExpansionGame as newGame, must, giveCard, emptyNeighborCell } from "./helpers";
|
||||
import { newExpansionGame as newGame, must, giveCard, emptyNeighborCell, plainRimWall } from "./helpers";
|
||||
|
||||
describe("expansion terrain", () => {
|
||||
it("killer ooze burns on entry and can drop you on your face", () => {
|
||||
@@ -161,6 +161,136 @@ describe("expansion terrain", () => {
|
||||
expect(state.squareContents[cellKey(spot.cell)]).toBeUndefined();
|
||||
// Wave side effects vary by geometry; the melt itself is the pinned behavior.
|
||||
});
|
||||
|
||||
it("wall of fire takes a rim warp — both mouths burn the crossing", () => {
|
||||
let { state } = createGame({
|
||||
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"],
|
||||
});
|
||||
const me = activePlayer(state);
|
||||
const warp = state.board.warps[0]!;
|
||||
me.position = { ...warp.from.cell };
|
||||
const wof = giveCard(state, me.id, "wall-of-fire", "WF", 0);
|
||||
const r = applyCommand(state, me.id, {
|
||||
type: "cast", instanceId: wof.instanceId,
|
||||
target: { kind: "edge", cell: warp.from.cell, side: warp.from.side },
|
||||
});
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
state = r.state;
|
||||
const nearKey = edgeKey(warp.from.cell, warp.from.side);
|
||||
const farKey = edgeKey(warp.to.cell, warp.to.side);
|
||||
expect(boardView(state).edges[nearKey]).toBe("firewall");
|
||||
expect(boardView(state).edges[farKey]).toBe("firewall");
|
||||
// The corridor still runs — through flame: the crossing lands on the
|
||||
// far rim and burns for 4.
|
||||
state = must(state, me.id, { type: "move", direction: warp.from.side });
|
||||
const after = state.players.find((p) => p.id === me.id)!;
|
||||
expect(cellKey(after.position)).toBe(cellKey(warp.to.cell));
|
||||
expect(after.life).toBe(11);
|
||||
});
|
||||
|
||||
it("waterwall takes a rim warp — the collapse washes both rims", () => {
|
||||
let { state } = createGame({
|
||||
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"],
|
||||
});
|
||||
const me = activePlayer(state);
|
||||
const other = state.players.find((p) => p.id !== me.id)!;
|
||||
const warp = state.board.warps[0]!;
|
||||
me.position = { ...warp.from.cell };
|
||||
other.position = { ...warp.to.cell };
|
||||
const ww = giveCard(state, me.id, "waterwall", "WW", 0);
|
||||
const r = applyCommand(state, me.id, {
|
||||
type: "cast", instanceId: ww.instanceId,
|
||||
target: { kind: "edge", cell: warp.from.cell, side: warp.from.side },
|
||||
});
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
// Both wizards stood in the mouths; the collapse washed both inward.
|
||||
const meAfter = r.state.players.find((p) => p.id === me.id)!;
|
||||
const otherAfter = r.state.players.find((p) => p.id === other.id)!;
|
||||
expect(cellKey(meAfter.position)).not.toBe(cellKey(warp.from.cell));
|
||||
expect(cellKey(otherAfter.position)).not.toBe(cellKey(warp.to.cell));
|
||||
});
|
||||
|
||||
it("illusion wall hangs on a rim warp mouth", () => {
|
||||
const { state } = createGame({
|
||||
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"],
|
||||
});
|
||||
const me = activePlayer(state);
|
||||
const warp = state.board.warps[0]!;
|
||||
me.position = { ...warp.from.cell };
|
||||
const il = giveCard(state, me.id, "illusion-wall", "IL", 0);
|
||||
const r = applyCommand(state, me.id, {
|
||||
type: "cast", instanceId: il.instanceId,
|
||||
target: { kind: "edge", cell: warp.from.cell, side: warp.from.side },
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) {
|
||||
expect(r.state.illusionWalls[edgeKey(warp.from.cell, warp.from.side)]).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("stone to water breaches the outer rim, opening a warp", () => {
|
||||
let { state } = createGame({
|
||||
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"],
|
||||
});
|
||||
const me = activePlayer(state);
|
||||
const rim = plainRimWall(state);
|
||||
me.position = { ...rim.cell };
|
||||
const warpsBefore = state.board.warps.length;
|
||||
const stw = giveCard(state, me.id, "stone-to-water", "SW", 0);
|
||||
state = must(state, me.id, {
|
||||
type: "cast", instanceId: stw.instanceId,
|
||||
target: { kind: "edge", cell: rim.cell, side: rim.side },
|
||||
});
|
||||
// The far rim melted with it and the wraparound now runs.
|
||||
expect(state.board.warps.length).toBe(warpsBefore + 2);
|
||||
});
|
||||
|
||||
it("no doors through the rim", () => {
|
||||
const { state } = createGame({
|
||||
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"],
|
||||
});
|
||||
const me = activePlayer(state);
|
||||
const rim = plainRimWall(state);
|
||||
me.position = { ...rim.cell };
|
||||
const cd = giveCard(state, me.id, "create-door", "CD", 0);
|
||||
const r = applyCommand(state, me.id, {
|
||||
type: "cast", instanceId: cd.instanceId,
|
||||
target: { kind: "edge", cell: rim.cell, side: rim.side },
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("stone to water melts a door — a small entryway in a stone wall", () => {
|
||||
const { state } = createGame({
|
||||
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"],
|
||||
});
|
||||
const me = activePlayer(state);
|
||||
const board = boardView(state);
|
||||
// Stand the caster at some door and melt it point-blank.
|
||||
let doorAt: { cell: Cell; side: Side } | null = null;
|
||||
outer: for (const k of Object.keys(board.cells)) {
|
||||
const [x, y] = k.split(",").map(Number) as [number, number];
|
||||
for (const side of SIDES) {
|
||||
if (board.edges[edgeKey({ x, y }, side)] === "door") {
|
||||
doorAt = { cell: { x, y }, side };
|
||||
break outer;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!doorAt) throw new Error("setup: no door on this board");
|
||||
me.position = { ...doorAt.cell };
|
||||
const stw = giveCard(state, me.id, "stone-to-water", "SW", 0);
|
||||
const r = applyCommand(state, me.id, {
|
||||
type: "cast", instanceId: stw.instanceId,
|
||||
target: { kind: "edge", cell: doorAt.cell, side: doorAt.side },
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) {
|
||||
const key = edgeKey(doorAt.cell, doorAt.side);
|
||||
expect(boardView(r.state).edges[key] ?? "open").toBe("open");
|
||||
expect(r.state.doorStates[key]).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("eligibility dimming mirrors the engine", () => {
|
||||
@@ -212,3 +342,39 @@ describe("eligibility dimming mirrors the engine", () => {
|
||||
for (const k of lit) expect(seen.has(k)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a wave's force is spent as it travels", () => {
|
||||
/** Caster at B, one cell above A; the target wall is A's south edge, so
|
||||
* the range-2 wave covers A (dist 0) and B (dist 1). */
|
||||
function rig(behind: "open" | "wall") {
|
||||
const { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"] });
|
||||
const me = activePlayer(state);
|
||||
const A = { x: 4, y: 5 }, B = { x: 4, y: 4 }, Bn = { x: 4, y: 3 }, Bnn = { x: 4, y: 2 };
|
||||
for (const c of [A, B, Bn, Bnn]) expect(boardView(state).cells[cellKey(c)]).toBeTruthy();
|
||||
state.edgeOverrides[edgeKey(A, "S")] = "wall";
|
||||
state.edgeOverrides[edgeKey(A, "N")] = "open";
|
||||
state.edgeOverrides[edgeKey(B, "N")] = behind;
|
||||
state.edgeOverrides[edgeKey(Bn, "N")] = "open";
|
||||
me.position = { ...B };
|
||||
// The other wizard waits far outside the wave.
|
||||
state.players.find((p) => p.id !== me.id)!.position = { x: 0, y: 9 };
|
||||
const stw = giveCard(state, me.id, "stone-to-water", "SW", 0);
|
||||
const after = must(state, me.id, {
|
||||
type: "cast", instanceId: stw.instanceId, target: { kind: "edge", cell: A, side: "S" },
|
||||
});
|
||||
return { me: after.players.find((p) => p.id === me.id)!, B, Bn, Bnn };
|
||||
}
|
||||
|
||||
it("a victim at the wave's far edge is carried one space, unhurt", () => {
|
||||
const { me, Bn } = rig("open");
|
||||
expect(cellKey(me.position)).toBe(cellKey(Bn));
|
||||
expect(me.life).toBe(15);
|
||||
});
|
||||
|
||||
it("only unspent force crushes: one space of push blocked is one damage", () => {
|
||||
const { me, B } = rig("wall");
|
||||
expect(cellKey(me.position)).toBe(cellKey(B));
|
||||
expect(me.life).toBe(14);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { giveCard } from "./helpers";
|
||||
import { edgeKey } from "../src/board";
|
||||
import {
|
||||
applyCommand,
|
||||
activePlayer,
|
||||
@@ -224,3 +226,138 @@ describe("treasures and victory", () => {
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("elimination by lost treasures drops what the fallen carried", () => {
|
||||
it("the carried treasure lands where the wizard stood", () => {
|
||||
let { state } = createGame({ playerIds: ["a", "b", "c"], seed: 42, sets: ["basic"] });
|
||||
const A = state.players.find((p) => p.id === "a")!;
|
||||
const B = state.players.find((p) => p.id === "b")!;
|
||||
const C = state.players.find((p) => p.id === "c")!;
|
||||
// C's first treasure already sits captured on B's home...
|
||||
const c1 = state.treasures.filter((t) => t.owner === "c")[0]!;
|
||||
c1.position = { ...B.home };
|
||||
// ...C carries one of B's treasures...
|
||||
const bt = state.treasures.filter((t) => t.owner === "b")[0]!;
|
||||
bt.position = null;
|
||||
bt.carriedBy = "c";
|
||||
C.carriedTreasureId = bt.id;
|
||||
// ...and A, standing on their own home, drops C's second treasure there.
|
||||
const c2 = state.treasures.filter((t) => t.owner === "c")[1]!;
|
||||
c2.position = null;
|
||||
c2.carriedBy = "a";
|
||||
A.carriedTreasureId = c2.id;
|
||||
A.position = { ...A.home };
|
||||
while (state.players[state.turn.activeIndex]!.id !== "a") {
|
||||
const r = applyCommand(state, state.players[state.turn.activeIndex]!.id, { type: "endTurn", draw: 0 });
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
state = r.state;
|
||||
}
|
||||
const r = applyCommand(state, "a", { type: "dropTreasure" });
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
state = r.state;
|
||||
// C is eliminated by lost treasures — and B's treasure lies where C stood.
|
||||
const cAfter = state.players.find((p) => p.id === "c")!;
|
||||
expect(cAfter.alive).toBe(false);
|
||||
expect(cAfter.carriedTreasureId).toBeNull();
|
||||
const btAfter = state.treasures.find((t) => t.id === bt.id)!;
|
||||
expect(btAfter.carriedBy).toBeNull();
|
||||
expect(btAfter.position).toEqual(cAfter.position);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the Ward is played in the moment", () => {
|
||||
function grabRig() {
|
||||
let { state } = createGame({ playerIds: ["thief", "owner"], seed: 42, sets: ["basic"] });
|
||||
const thief = state.players.find((p) => p.id === "thief")!;
|
||||
const owner = state.players.find((p) => p.id === "owner")!;
|
||||
giveCard(state, "owner", "ward", "W", 0);
|
||||
const t = state.treasures.find((t) => t.owner === "owner" && t.position)!;
|
||||
thief.position = { ...t.position! };
|
||||
while (state.players[state.turn.activeIndex]!.id !== "thief") {
|
||||
const r = applyCommand(state, state.players[state.turn.activeIndex]!.id, { type: "endTurn", draw: 0 });
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
state = r.state;
|
||||
}
|
||||
const r = applyCommand(state, "thief", { type: "pickUpTreasure" });
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
return { state: r.state, owner, thief };
|
||||
}
|
||||
|
||||
it("the grab hangs on the owner; springing costs the thief 3 and the card", () => {
|
||||
let { state } = grabRig();
|
||||
expect(state.wardPending).toEqual({ ownerId: "owner", takerId: "thief" });
|
||||
// Nobody else may act while it hangs.
|
||||
expect(applyCommand(state, "thief", { type: "endTurn", draw: 0 }).ok).toBe(false);
|
||||
const r = applyCommand(state, "owner", { type: "wardChoice", play: true });
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
state = r.state;
|
||||
expect(state.wardPending).toBeNull();
|
||||
expect(state.players.find((p) => p.id === "thief")!.life).toBe(12);
|
||||
expect(state.players.find((p) => p.id === "owner")!.hand.some((c) => c.cardId === "ward")).toBe(false);
|
||||
});
|
||||
|
||||
it("declining lets the thief go, Ward still in hand", () => {
|
||||
let { state } = grabRig();
|
||||
const r = applyCommand(state, "owner", { type: "wardChoice", play: false });
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
state = r.state;
|
||||
expect(state.players.find((p) => p.id === "thief")!.life).toBe(15);
|
||||
expect(state.players.find((p) => p.id === "owner")!.hand.some((c) => c.cardId === "ward")).toBe(true);
|
||||
expect(applyCommand(state, "thief", { type: "endTurn", draw: 0 }).ok).toBe(true);
|
||||
});
|
||||
|
||||
it("arming ahead is refused — the ward waits for the grab", () => {
|
||||
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"] });
|
||||
giveCard(state, state.players[state.turn.activeIndex]!.id, "ward", "W", 0);
|
||||
const r = applyCommand(state, state.players[state.turn.activeIndex]!.id, { type: "armWard" });
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("an ambushed teleport carries its destination", () => {
|
||||
it("the trap springs and the victim lands where the trapper said", () => {
|
||||
let { state } = createGame({ playerIds: ["trapper", "prey"], seed: 42, sets: ["basic", "expansion1"] });
|
||||
for (let guard = 0; guard < 10 && !(state.players[state.turn.activeIndex]!.id === "trapper" && state.turn.round > 1); guard++) {
|
||||
const r = applyCommand(state, state.players[state.turn.activeIndex]!.id, { type: "endTurn", draw: 0 });
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
state = r.state;
|
||||
}
|
||||
const trapper = state.players.find((p) => p.id === "trapper")!;
|
||||
const prey = state.players.find((p) => p.id === "prey")!;
|
||||
giveCard(state, "trapper", "interrupt", "I", 0);
|
||||
giveCard(state, "trapper", "teleport-opponent", "TO", 1);
|
||||
// Arming without a destination is refused; with one it is stored.
|
||||
const bad = applyCommand(state, "trapper", {
|
||||
type: "setAmbush", instanceId: "interrupt#I", trigger: { kind: "near" },
|
||||
spellInstanceId: "teleport-opponent#TO",
|
||||
});
|
||||
expect(bad.ok).toBe(false);
|
||||
const dest = { x: trapper.home.x, y: trapper.home.y === 0 ? 1 : trapper.home.y - 1 };
|
||||
let r = applyCommand(state, "trapper", {
|
||||
type: "setAmbush", instanceId: "interrupt#I", trigger: { kind: "near" },
|
||||
spellInstanceId: "teleport-opponent#TO", cell: dest,
|
||||
});
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
state = r.state;
|
||||
r = applyCommand(state, "trapper", { type: "endTurn", draw: 0 });
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
state = r.state;
|
||||
// The prey walks adjacent; the trap springs; the prey passes; they land
|
||||
// at dest. (Re-find both: applyCommand clones made the handles stale.)
|
||||
const trapperNow = state.players.find((p) => p.id === "trapper")!;
|
||||
const preyNow = state.players.find((p) => p.id === "prey")!;
|
||||
const below = trapperNow.position.y >= 2;
|
||||
preyNow.position = { x: trapperNow.position.x, y: trapperNow.position.y + (below ? -2 : 2) };
|
||||
const dir = below ? "S" : "N";
|
||||
state.edgeOverrides[edgeKey(preyNow.position, dir)] = "open";
|
||||
r = applyCommand(state, "prey", { type: "move", direction: dir });
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
state = r.state;
|
||||
expect(state.stack?.attackCard?.cardId).toBe("teleport-opponent");
|
||||
expect(state.stack?.params?.cell).toEqual(dest);
|
||||
r = applyCommand(state, "prey", { type: "pass" });
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
state = r.state;
|
||||
expect(state.players.find((p) => p.id === "prey")!.position).toEqual(dest);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,8 +11,9 @@ import {
|
||||
type GameState,
|
||||
type PlayerId,
|
||||
} from "../src/game";
|
||||
import { cellKey, SIDES, stepTarget, type Cell, type Side } from "../src/board";
|
||||
import { cellKey, edgeKey, neighbor, SIDES, stepTarget, type Cell, type Side } from "../src/board";
|
||||
import type { CardInstance } from "../src/cards";
|
||||
import type { SustainedEffect } from "../src/game";
|
||||
|
||||
export function newGame(seed = 42, players: PlayerId[] = ["alice", "bob"]) {
|
||||
return createGame({ playerIds: players, seed, sets: ["basic"] });
|
||||
@@ -76,3 +77,26 @@ export function emptyNeighborCell(state: GameState, of: Cell): { cell: Cell; sid
|
||||
}
|
||||
throw new Error("no empty neighbor");
|
||||
}
|
||||
|
||||
/** Some off-board edge that is a bare stone wall — no warp behind it. */
|
||||
export function plainRimWall(state: GameState): { cell: Cell; side: Side } {
|
||||
const board = boardView(state);
|
||||
for (const k of Object.keys(board.cells)) {
|
||||
const [x, y] = k.split(",").map(Number) as [number, number];
|
||||
for (const side of SIDES) {
|
||||
if (board.cells[cellKey(neighbor({ x, y }, side))]) continue;
|
||||
if (board.edges[edgeKey({ x, y }, side)] !== "wall") continue;
|
||||
if (board.warps.some((w) => cellKey(w.from.cell) === k && w.from.side === side)) continue;
|
||||
return { cell: { x, y }, side };
|
||||
}
|
||||
}
|
||||
throw new Error("setup: no plain rim wall on this board");
|
||||
}
|
||||
|
||||
/** Rig a duration spell directly, sparing the cast ceremony. */
|
||||
export function pushSustained(
|
||||
state: GameState,
|
||||
fx: Omit<SustainedEffect, "data"> & { data?: SustainedEffect["data"] },
|
||||
): void {
|
||||
state.sustained.push({ data: {}, ...fx });
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { applyCommand, activePlayer, boardView, createGame, gameLos, sustainedOn, viewFor } from "../src";
|
||||
import { cellKey, edgeKey, type Cell } from "../src/board";
|
||||
import { cellKey, edgeKey, hasLineOfSight, sightBetween, traceSight, type Cell } from "../src/board";
|
||||
import type { CardInstance } from "../src/cards";
|
||||
import { newGame, must, giveCard, toRound2, emptyNeighborCell } from "./helpers";
|
||||
|
||||
@@ -143,13 +143,16 @@ describe("illusion wall", () => {
|
||||
expect(after.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("opponents test the illusion when they bump it — some see through, some believe", () => {
|
||||
it("an untested illusion blocks the bump; a tested eye rolls its verdict", () => {
|
||||
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 });
|
||||
// Bumping blind is refused: eyes must be tested first.
|
||||
expect(applyCommand(s, other.id, { type: "move", direction: side }).ok).toBe(false);
|
||||
s = must(s, other.id, { type: "testIllusion", cell: other.position, side });
|
||||
const result = applyCommand(s, other.id, { type: "move", direction: side });
|
||||
if (result.ok) sawThrough++;
|
||||
else believed++;
|
||||
@@ -223,11 +226,16 @@ describe("sector manipulation", () => {
|
||||
type: "cast", instanceId: rel.instanceId,
|
||||
target: { kind: "cell", cell: dest }, params: { cell: me.position },
|
||||
});
|
||||
// The maze zero-anchors after the move; measure against the sector's
|
||||
// origin as it settled, not the requested landing.
|
||||
const after = state.players.find((p) => p.id === me.id)!;
|
||||
const dx = dest.x - myOrigin.x, dy = dest.y - myOrigin.y;
|
||||
const newOrigin = state.board.placements[idx]!.origin;
|
||||
const dx = newOrigin.x - myOrigin.x, dy = newOrigin.y - myOrigin.y;
|
||||
expect(after.position).toEqual({ x: posBefore.x + dx, y: posBefore.y + dy });
|
||||
expect(after.home).toEqual({ x: homeBefore.x + dx, y: homeBefore.y + dy });
|
||||
expect(state.board.placements[idx]!.origin).toEqual(dest);
|
||||
// My sector sits east of the other, as asked.
|
||||
const otherAfter = state.board.placements[idx === 0 ? 1 : 0]!.origin;
|
||||
expect(newOrigin).toEqual({ x: otherAfter.x + 5, y: otherAfter.y });
|
||||
// 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);
|
||||
@@ -320,7 +328,6 @@ describe("6e card-face corrections", () => {
|
||||
});
|
||||
state = must(state, defender.id, { type: "counteract", instanceId: "wall-of-fire#WOF" });
|
||||
state = must(state, attacker.id, { type: "pass" });
|
||||
state = must(state, defender.id, { type: "pass" });
|
||||
expect(state.players.find((p) => p.id === defender.id)!.life).toBe(15);
|
||||
|
||||
// But it cannot counter a fireball.
|
||||
@@ -335,14 +342,14 @@ describe("6e card-face corrections", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("relocation past the origin (rules rev 11)", () => {
|
||||
function rev11Game() {
|
||||
const { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"], deckRev: 11 });
|
||||
describe("relocation past the origin", () => {
|
||||
function freshGame() {
|
||||
const { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"] });
|
||||
return state;
|
||||
}
|
||||
|
||||
it("a sector may land at negative coordinates — the maze renormalizes", () => {
|
||||
let state = rev11Game();
|
||||
let state = freshGame();
|
||||
const me = activePlayer(state);
|
||||
const other = state.players.find((p) => p.id !== me.id)!;
|
||||
const idx = state.board.placements.findIndex(
|
||||
@@ -376,8 +383,8 @@ describe("relocation past the origin (rules rev 11)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("older revisions still refuse the negative landing", () => {
|
||||
let { state } = newGame(); // helper default: rev-ungated (1)
|
||||
it("a diagonal landing breaks adjacency and is refused", () => {
|
||||
let { state } = newGame();
|
||||
const me = activePlayer(state);
|
||||
const idx = state.board.placements.findIndex(
|
||||
(p) => me.position.y >= p.origin.y && me.position.y < p.origin.y + 5,
|
||||
@@ -393,7 +400,7 @@ describe("relocation past the origin (rules rev 11)", () => {
|
||||
});
|
||||
|
||||
it("a moving sector carries its creature, glue, warp tokens, and traps", () => {
|
||||
let state = rev11Game();
|
||||
let state = freshGame();
|
||||
const me = activePlayer(state);
|
||||
const idx = state.board.placements.findIndex(
|
||||
(p) => me.position.y >= p.origin.y && me.position.y < p.origin.y + 5,
|
||||
@@ -435,3 +442,126 @@ describe("relocation past the origin (rules rev 11)", () => {
|
||||
expect(state.boobytraps[0]!.realKey).toBe(cellKey(moved));
|
||||
});
|
||||
});
|
||||
|
||||
describe("junction alterations roll for their sector", () => {
|
||||
it("a conjured wall on the seam obeys the die: 1-2 stays, 3-4 travels", () => {
|
||||
const { state: fresh } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"] });
|
||||
let state = toRound2(fresh);
|
||||
// The 2p board is two sectors stacked: the seam runs between them.
|
||||
const [p0, p1] = state.board.placements;
|
||||
const seamY = Math.max(p0!.origin.y, p1!.origin.y) - 1;
|
||||
const seamCell = { x: p0!.origin.x + 2, y: seamY };
|
||||
const key = edgeKey(seamCell, "S");
|
||||
// Conjure a wall on the seam (state surgery: the cast needs LOS we may lack).
|
||||
state.edgeOverrides[key] = "wall";
|
||||
state.createdEdges[key] = true;
|
||||
const caster = activePlayer(state);
|
||||
const rot = giveCard(state, caster.id, "rotate-sector");
|
||||
const rngBefore = JSON.stringify(state.rng);
|
||||
state = must(state, caster.id, {
|
||||
type: "cast", instanceId: rot.instanceId,
|
||||
target: { kind: "cell", cell: { x: p1!.origin.x + 2, y: p1!.origin.y + 2 } },
|
||||
params: { clockwise: true },
|
||||
});
|
||||
// A die was consumed for the seam wall.
|
||||
expect(JSON.stringify(state.rng)).not.toBe(rngBefore);
|
||||
// The wall is somewhere: either the old seam key or a remapped edge.
|
||||
const createdKeys = Object.keys(state.createdEdges);
|
||||
expect(createdKeys.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sight tracing", () => {
|
||||
it("agrees with sightBetween on every pair and pins entry/exit to the mouths", () => {
|
||||
const { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"] });
|
||||
const board = boardView(state);
|
||||
const cells = Object.keys(board.cells).map((k) => {
|
||||
const [x, y] = k.split(",").map(Number) as [number, number];
|
||||
return { x, y };
|
||||
});
|
||||
let warped = 0;
|
||||
for (const from of cells) {
|
||||
for (const to of cells) {
|
||||
const t = traceSight(board, from, to);
|
||||
expect(t !== null).toBe(sightBetween(board, from, to));
|
||||
if (!t) continue;
|
||||
if (hasLineOfSight(board, from, to)) {
|
||||
expect(t.kind).toBe("direct");
|
||||
} else {
|
||||
expect(t.kind).toBe("warp");
|
||||
if (t.kind === "warp") {
|
||||
warped++;
|
||||
// Each crossing point lies on its mouth's one-cell rim segment.
|
||||
const onRim = (p: { x: number; y: number }, m: { cell: Cell; side: string }) => {
|
||||
if (m.side === "N") return p.y === m.cell.y && p.x > m.cell.x && p.x < m.cell.x + 1;
|
||||
if (m.side === "S") return p.y === m.cell.y + 1 && p.x > m.cell.x && p.x < m.cell.x + 1;
|
||||
if (m.side === "W") return p.x === m.cell.x && p.y > m.cell.y && p.y < m.cell.y + 1;
|
||||
return p.x === m.cell.x + 1 && p.y > m.cell.y && p.y < m.cell.y + 1;
|
||||
};
|
||||
expect(onRim(t.entry, t.mouthA)).toBe(true);
|
||||
expect(onRim(t.exit, t.mouthB)).toBe(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(warped).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("illusions are tested by choice", () => {
|
||||
function shimmerRig() {
|
||||
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"] });
|
||||
state = toRound2(state);
|
||||
const caster = activePlayer(state);
|
||||
const mark = state.players.find((p) => p.id !== caster.id)!;
|
||||
// Conjure the illusion on an OPEN edge beside the victim (state surgery
|
||||
// for a deterministic spot; the cast itself is tested elsewhere).
|
||||
const spot = emptyNeighborCell(state, mark.position);
|
||||
const key = edgeKey(mark.position, spot.side);
|
||||
state.illusionWalls[key] = { createdBy: caster.id, belief: {} };
|
||||
state = must(state, caster.id, { type: "endTurn", draw: 0 });
|
||||
return {
|
||||
state, caster: caster.id, victim: mark.id, side: spot.side,
|
||||
cell: { ...state.players.find((p) => p.id === mark.id)!.position },
|
||||
};
|
||||
}
|
||||
|
||||
it("an untested shimmer blocks the walker with its own message, no die rolled", () => {
|
||||
const { state, victim, side } = shimmerRig();
|
||||
const rngBefore = JSON.stringify(state.rng);
|
||||
const r = applyCommand(state, victim, { type: "move", direction: side });
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) expect(r.error).toContain("shimmers");
|
||||
expect(JSON.stringify(state.rng)).toBe(rngBefore);
|
||||
});
|
||||
|
||||
it("the belief test is an explicit roll; the verdict then governs the wall", () => {
|
||||
let { state, victim, cell, side } = shimmerRig();
|
||||
state = must(state, victim, { type: "testIllusion", cell, side });
|
||||
const verdict = state.illusionWalls[edgeKey(cell, side)]!.belief[victim];
|
||||
expect(verdict === "believes" || verdict === "seesThrough").toBe(true);
|
||||
const r = applyCommand(state, victim, { type: "move", direction: side });
|
||||
if (verdict === "seesThrough") {
|
||||
expect(r.ok).toBe(true);
|
||||
} else {
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) expect(r.error).toBe("blocked by wall");
|
||||
}
|
||||
// A second test is refused: eyes rule once.
|
||||
expect(applyCommand(state, victim, { type: "testIllusion", cell, side }).ok).toBe(false);
|
||||
});
|
||||
|
||||
it("the creator strolls through their own fake without any test", () => {
|
||||
let { state, caster } = shimmerRig();
|
||||
// Skip to the caster's turn; the wall never blocks its maker.
|
||||
while (activePlayer(state).id !== caster) {
|
||||
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
|
||||
}
|
||||
const me = state.players.find((p) => p.id === caster)!;
|
||||
const other = state.players.find((p) => p.id !== caster)!;
|
||||
me.position = { ...other.position };
|
||||
const r = applyCommand(state, caster, { type: "move", direction: "E" });
|
||||
// Blocked only if some REAL edge stands there; the illusion itself never objects.
|
||||
if (!r.ok) expect(r.error).not.toContain("shimmers");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { applyCommand, activePlayer, boardView, gameLos, sustainedOn } from "../src/game";
|
||||
import { applyCommand, activePlayer, boardView, createGame, gameLos, sustainedOn } from "../src/game";
|
||||
import { cellKey, edgeKey, neighbor, SIDES } from "../src/board";
|
||||
import type { CardInstance } from "../src/cards";
|
||||
import { newGame, must, giveCard, toRound2, faceOff, castAt, emptyNeighborCell } from "./helpers";
|
||||
@@ -277,3 +277,17 @@ describe("control effects", () => {
|
||||
expect(state.players.find((p) => p.id === me.id)!.hand.some((c) => c.cardId === "create-wall")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("picking one of two treasures", () => {
|
||||
it("a named treasure is the one taken", () => {
|
||||
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"] });
|
||||
state = toRound2(state);
|
||||
const p = activePlayer(state);
|
||||
const [t1, t2] = state.treasures.filter((t) => t.owner !== p.id);
|
||||
t1!.position = { ...p.position }; t1!.carriedBy = null;
|
||||
t2!.position = { ...p.position }; t2!.carriedBy = null;
|
||||
const r = applyCommand(state, p.id, { type: "pickUpTreasure", treasureId: t2!.id });
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
expect(r.state.players.find((q) => q.id === p.id)!.carriedTreasureId).toBe(t2!.id);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
// The telepath guard: every event the engine emits must reach the player —
|
||||
// a log line, an effect, or a modal — and every targeted cast must be
|
||||
// aimable on the board. A new event or card that slips past the client
|
||||
// fails here, instead of surfacing one puzzled bug report at a time.
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const ENGINE = join(__dirname, "..", "src");
|
||||
const WEB = join(__dirname, "..", "..", "web", "src");
|
||||
const game = readFileSync(join(ENGINE, "game.ts"), "utf8");
|
||||
const app = readFileSync(join(WEB, "App.svelte"), "utf8");
|
||||
const clientSources = ["net.svelte.ts", "fx.ts", "local.svelte.ts", "Replay.svelte", "hints.ts"]
|
||||
.map((f) => readFileSync(join(WEB, f), "utf8"))
|
||||
.concat(app)
|
||||
.join("\n");
|
||||
|
||||
/** Events whose information reaches the player another way — each with the
|
||||
* reason. Add here ONLY with a reason; "I'll wire it later" is not one. */
|
||||
const SILENT_BY_DESIGN: Record<string, string> = {
|
||||
cardsDealt: "the opening deal: the hand tray is the reveal",
|
||||
cardsDealtPrivate: "same — your opening hand appears in the tray",
|
||||
boobytrapPlacedPrivate: "the board marks the caster's real token from the view",
|
||||
};
|
||||
|
||||
function eventTypes(): string[] {
|
||||
const start = game.indexOf("export type GameEvent =");
|
||||
const block = game.slice(start, game.indexOf("\nexport ", start + 10));
|
||||
return [...new Set([...block.matchAll(/\| \{ type: "([a-zA-Z]+)"/g)].map((m) => m[1]!))];
|
||||
}
|
||||
|
||||
describe("every engine event reaches the player", () => {
|
||||
it("is humanized, animated, handled in a modal, or silent by design", () => {
|
||||
const handled = new Set<string>();
|
||||
for (const m of clientSources.matchAll(/case "([a-zA-Z]+)"/g)) handled.add(m[1]!);
|
||||
for (const m of clientSources.matchAll(/type === "([a-zA-Z]+)"/g)) handled.add(m[1]!);
|
||||
const orphans = eventTypes().filter((e) => !handled.has(e) && !(e in SILENT_BY_DESIGN));
|
||||
expect(orphans, `events no client code touches: ${orphans.join(", ")}`).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps the silent-by-design list honest (no stale entries)", () => {
|
||||
const known = new Set(eventTypes());
|
||||
const stale = Object.keys(SILENT_BY_DESIGN).filter((e) => !known.has(e));
|
||||
expect(stale, `allowlisted events the engine no longer emits: ${stale.join(", ")}`).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("every targeted cast is aimable on the board", () => {
|
||||
/** A card's resolver demanding a cell/edge target must appear in App's
|
||||
* click-targeting sets, or selecting the card leaves it uncastable. */
|
||||
it("cell- and edge-demanding resolvers appear in CELL_CARDS / EDGE_CARDS", () => {
|
||||
const start = game.indexOf("const CARD_EFFECTS");
|
||||
const end = game.indexOf("\n};", start); // the table's own closing brace
|
||||
const entries = game.slice(start, end).split(/\n (?="?[a-z][a-z0-9-]*"?: \{)/);
|
||||
const appSet = (name: string) =>
|
||||
new Set([...(app.match(new RegExp(`${name} = new Set\\(\\[([^\\]]*)\\]`))?.[1] ?? "")
|
||||
.matchAll(/"([a-z-]+)"/g)].map((m) => m[1]!));
|
||||
const cellCards = appSet("CELL_CARDS");
|
||||
const edgeCards = appSet("EDGE_CARDS");
|
||||
const missing: string[] = [];
|
||||
for (const entry of entries) {
|
||||
const id = entry.match(/^"?([a-z][a-z0-9-]*)"?: \{/)?.[1];
|
||||
if (!id) continue;
|
||||
// Only demands stated as refusals bind: `target.kind !== "cell"` etc.
|
||||
// (an optional `target?.kind === ...` branch is not a requirement).
|
||||
if (/target \|\| cmd\.target\.kind !== "cell"|!cmd\.target \|\| cmd\.target\.kind !== "cell"/.test(entry) &&
|
||||
!cellCards.has(id)) missing.push(`${id} (cell)`);
|
||||
if (/target \|\| cmd\.target\.kind !== "edge"|!cmd\.target \|\| cmd\.target\.kind !== "edge"/.test(entry) &&
|
||||
!edgeCards.has(id)) missing.push(`${id} (edge)`);
|
||||
}
|
||||
expect(missing, `casts the board cannot aim: ${missing.join(", ")}`).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { applyCommand, activePlayer, boardView } from "../src/game";
|
||||
import { applyCommand, activePlayer, boardView, createGame } from "../src/game";
|
||||
import { cellKey, edgeKey, SIDES, type Cell, type Side } from "../src/board";
|
||||
import type { CardInstance } from "../src/cards";
|
||||
import { newExpansionGame as newGame, must, giveCard, toRound2, faceOff } from "./helpers";
|
||||
import { newExpansionGame as newGame, must, giveCard, toRound2, faceOff, plainRimWall } from "./helpers";
|
||||
|
||||
describe("magic wands", () => {
|
||||
it("blaster wand: charges on first use, once per turn, discards when spent", () => {
|
||||
@@ -12,11 +12,6 @@ describe("magic wands", () => {
|
||||
const wand = giveCard(state, attacker, "blaster-wand");
|
||||
giveCard(state, attacker, "number-2", "N", 1);
|
||||
|
||||
// First use without a number card is refused.
|
||||
expect(applyCommand(state, attacker, {
|
||||
type: "cast", instanceId: wand.instanceId, target: { kind: "player", playerId: defender },
|
||||
}).ok).toBe(false);
|
||||
|
||||
// Charged with a 2: fires (3 damage), one charge left, wand displayed.
|
||||
state = must(state, attacker, {
|
||||
type: "cast", instanceId: wand.instanceId, numberInstanceIds: ["number-2#N"],
|
||||
@@ -148,6 +143,31 @@ describe("magic wands", () => {
|
||||
expect(boardView(state).edges[key]).toBe("wall");
|
||||
});
|
||||
|
||||
it("warp wand bores the rim: a wraparound that closes at turn's end", () => {
|
||||
let { state } = createGame({
|
||||
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"],
|
||||
});
|
||||
const me = activePlayer(state);
|
||||
const rim = plainRimWall(state);
|
||||
me.position = { ...rim.cell };
|
||||
const warpsBefore = state.board.warps.length;
|
||||
const wand = giveCard(state, me.id, "warp-wand");
|
||||
giveCard(state, me.id, "number-2", "N", 1);
|
||||
state = must(state, me.id, {
|
||||
type: "cast", instanceId: wand.instanceId, numberInstanceIds: ["number-2#N"],
|
||||
target: { kind: "edge", cell: rim.cell, side: rim.side },
|
||||
});
|
||||
expect(state.board.warps.length).toBe(warpsBefore + 2);
|
||||
// The wraparound runs: step off the rim and land on the opposite edge.
|
||||
state = must(state, me.id, { type: "move", direction: rim.side });
|
||||
const across = state.players.find((p) => p.id === me.id)!.position;
|
||||
expect(cellKey(across)).not.toBe(cellKey(rim.cell));
|
||||
// Turn's end: the walls return and the warp closes with them.
|
||||
state = must(state, me.id, { type: "endTurn", draw: 0 });
|
||||
expect(boardView(state).edges[edgeKey(rim.cell, rim.side)]).toBe("wall");
|
||||
expect(state.board.warps.length).toBe(warpsBefore);
|
||||
});
|
||||
|
||||
it("deja-vu retrieves from the discard, but never a wand", () => {
|
||||
let { state } = newGame();
|
||||
const me = activePlayer(state);
|
||||
@@ -210,3 +230,20 @@ describe("dropping objects", () => {
|
||||
expect(refused.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a bare wand lights a single charge", () => {
|
||||
it("'played without a number card, its power is only 1': one shot, then dust", () => {
|
||||
let { state } = newGame();
|
||||
state = toRound2(state);
|
||||
const { attacker, defender } = faceOff(state);
|
||||
const bw = giveCard(state, attacker, "blaster-wand");
|
||||
state = must(state, attacker, {
|
||||
type: "cast", instanceId: bw.instanceId, target: { kind: "player", playerId: defender },
|
||||
});
|
||||
state = must(state, defender, { type: "pass" });
|
||||
// The single charge fired (3 flat damage) and the wand crumbled.
|
||||
expect(state.players.find((p) => p.id === defender)!.life).toBe(12);
|
||||
expect(state.players.find((p) => p.id === attacker)!.hand.some((c) => c.cardId === "blaster-wand")).toBe(false);
|
||||
expect(state.wandCharges[bw.instanceId]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,16 +9,22 @@
|
||||
// {type:"claimTransfer", code} claim a seat on a new device
|
||||
// {type:"catchUp", sinceSeq} replay of moves missed while away
|
||||
// {type:"chat", text} table talk to the room
|
||||
// {type:"rollDie"} the tabletop D4, published as talk
|
||||
// {type:"addBot", style?, tier?} host seats an automaton
|
||||
// {type:"watch", roomId} join the Peanut Gallery: nameless, read-only
|
||||
// {type:"leave"} detach this socket from table or gallery
|
||||
// {type:"myGames", seats} summaries for held seats
|
||||
// {type:"stats"} the engagement tally
|
||||
// {type:"hotseatReport", ...} anonymous hotseat game counts
|
||||
// server -> client:
|
||||
// {type:"welcome"} on connect
|
||||
// {type:"seat", playerId, token} your seat secret — keep it
|
||||
// {type:"room", roomId, players, hostId, started, colors}
|
||||
// {type:"events", events} redacted for this recipient
|
||||
// {type:"room", roomId, players, hostId, started, audience, colors, bots}
|
||||
// {type:"events", events, replayed?} redacted for this recipient
|
||||
// {type:"state", view, seq} redacted full view (after every change)
|
||||
// {type:"chat", player, text, at} one line of table talk
|
||||
// {type:"watching", roomId} you are seated in the gallery
|
||||
// {type:"audience", count} how many watch from the gallery
|
||||
// {type:"transferCode"|"transferClaimed"|"catchUp"|"games"|"stats"}
|
||||
// {type:"error", message}
|
||||
|
||||
@@ -35,6 +41,7 @@ import {
|
||||
getRoom,
|
||||
joinRoom,
|
||||
loadPersistedRooms,
|
||||
runningRooms,
|
||||
addAutomaton,
|
||||
addChat,
|
||||
driveOneAutomaton,
|
||||
@@ -43,6 +50,7 @@ import {
|
||||
roomCount,
|
||||
runCommand,
|
||||
seatTokenValid,
|
||||
SPECTATOR,
|
||||
startGame,
|
||||
summarize,
|
||||
viewForPlayer,
|
||||
@@ -58,6 +66,7 @@ const MAX_ROOMS_PER_CONN = 10; // rooms one connection may create
|
||||
const MAX_COMMAND_BYTES = 16384; // serialized game command
|
||||
const MAX_MYGAMES_SEATS = 50; // seats checked per myGames request
|
||||
const CATCHUP_COOLDOWN_MS = 3000; // full-game replays are CPU-heavy
|
||||
const MAX_AUDIENCE = 30; // gallery seats per room
|
||||
const NAME_MAX = 24;
|
||||
|
||||
/** Player/room names: printable, trimmed, bounded. */
|
||||
@@ -70,6 +79,11 @@ const port = Number(process.env.PORT ?? 8787);
|
||||
// is not reachable from the internet. Dev default stays LAN-friendly.
|
||||
const host = process.env.HOST ?? "0.0.0.0";
|
||||
loadPersistedRooms();
|
||||
// A restart can land mid-bot-turn: without a kick, a restored room whose
|
||||
// current actor is an automaton waits forever for a human to poke it.
|
||||
setTimeout(() => {
|
||||
for (const room of runningRooms()) runBots(room);
|
||||
}, 2000); // a beat for clients to reconnect before the clockwork stirs
|
||||
|
||||
// One process serves both the built client and the websocket, so production
|
||||
// needs only a TLS proxy in front (or nothing, on a LAN).
|
||||
@@ -138,6 +152,9 @@ interface Session {
|
||||
socket: WebSocket;
|
||||
playerId: PlayerId | null;
|
||||
roomId: string | null;
|
||||
/** In the Peanut Gallery: spectator implies playerId === null, so every
|
||||
* handler that requires a seat refuses this session by construction. */
|
||||
spectator: boolean;
|
||||
/** The raw seat token this connection authenticated with (memory only). */
|
||||
token: string | null;
|
||||
claimFails: number;
|
||||
@@ -165,6 +182,25 @@ function send(socket: WebSocket, message: unknown): void {
|
||||
if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify(message));
|
||||
}
|
||||
|
||||
function audienceCount(room: Room): number {
|
||||
let n = 0;
|
||||
for (const s of sessions) if (s.roomId === room.id && s.spectator) n++;
|
||||
return n;
|
||||
}
|
||||
|
||||
function broadcastAudience(room: Room): void {
|
||||
broadcast(room, () => ({ type: "audience", count: audienceCount(room) }));
|
||||
}
|
||||
|
||||
/** A watcher leaves the gallery (to sit down, watch elsewhere, or vanish). */
|
||||
function leaveGallery(session: Session): void {
|
||||
if (!session.spectator) return;
|
||||
session.spectator = false;
|
||||
const room = session.roomId ? getRoom(session.roomId) : undefined;
|
||||
session.roomId = null;
|
||||
if (room) broadcastAudience(room);
|
||||
}
|
||||
|
||||
function roomInfo(room: Room) {
|
||||
return {
|
||||
type: "room",
|
||||
@@ -172,18 +208,27 @@ function roomInfo(room: Room) {
|
||||
players: room.players,
|
||||
hostId: room.hostId,
|
||||
started: room.state !== null,
|
||||
audience: audienceCount(room),
|
||||
colors: Object.fromEntries(room.colorChoices),
|
||||
bots: Object.fromEntries(
|
||||
[...room.bots].map(([name, b]) => [name, `${b.tier} ${b.secret ? "mystery" : b.style}`]),
|
||||
// A mystery machine keeps its mood only while the game lives: once it
|
||||
// ends, the hands go face-up and so does the temperament.
|
||||
[...room.bots].map(([name, b]) => [
|
||||
name,
|
||||
room.state?.phase === "finished"
|
||||
? `${b.tier} ${b.style}${b.secret ? " 🎭" : ""}`
|
||||
: `${b.tier} ${b.secret ? "mystery" : b.style}`,
|
||||
]),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function broadcast(room: Room, makeMessage: (playerId: PlayerId) => unknown): void {
|
||||
for (const s of sessions) {
|
||||
if (s.roomId === room.id && s.playerId) {
|
||||
send(s.socket, makeMessage(s.playerId));
|
||||
}
|
||||
if (s.roomId !== room.id) continue;
|
||||
// The gallery hears everything too, redacted for the nameless viewer.
|
||||
if (s.playerId) send(s.socket, makeMessage(s.playerId));
|
||||
else if (s.spectator) send(s.socket, makeMessage(SPECTATOR));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,24 +236,27 @@ function broadcast(room: Room, makeMessage: (playerId: PlayerId) => unknown): vo
|
||||
* The clockwork plays at a watchable pace: one command every beat, each
|
||||
* broadcast as it lands, until the maze wants a human again.
|
||||
*/
|
||||
const BOT_STEP_MS = 1500;
|
||||
const BOT_STEP_MS = 1000;
|
||||
|
||||
/** What a step's event means to this bot — its own deed when it acted,
|
||||
* its own suffering when somebody else did. Null: nothing worth a word. */
|
||||
/** What a step's event means to this bot — its own deeds when it acted,
|
||||
* its own suffering whoever caused it. Null: nothing worth a word. */
|
||||
function banterTrigger(
|
||||
e: { type: string; [k: string]: unknown }, seat: string, actor: string,
|
||||
): BanterTrigger | null {
|
||||
if (actor === seat) {
|
||||
if (e.type === "treasurePickedUp" && e.player === seat) return "grabGold";
|
||||
if (e.type === "treasureDropped" && e.player === seat) return "deliverGold";
|
||||
if (e.type === "treasureDropped" && e.player === seat && e.onHomeOf != null) return "deliverGold";
|
||||
if (e.type === "damaged" && e.player !== seat) return "dealPain";
|
||||
if (e.type === "died" && e.killedBy === seat && e.player !== seat) return "kill";
|
||||
if (e.type === "creatureCreated" && e.controller === seat) return "summon";
|
||||
if (e.type === "wallCreated" && e.caster === seat) return "buildWall";
|
||||
if (e.type === "teleported" && e.player === seat && e.by === seat) return "escape";
|
||||
if (e.type === "trapSprung" && e.player === seat) return "springTrap";
|
||||
if (e.type === "gameWon" && e.player === seat) return "win";
|
||||
}
|
||||
// A counter-teleport escape resolves during the ATTACKER's step, and a
|
||||
// last-standing win can land on the victim's turn: self-referential
|
||||
// triggers hold whoever acted.
|
||||
if (e.type === "teleported" && e.player === seat && e.by === seat) return "escape";
|
||||
if (e.type === "gameWon" && e.player === seat) return "win";
|
||||
if (e.type === "damaged" && e.player === seat) return "takePain";
|
||||
if (e.type === "died" && e.player === seat) return "die";
|
||||
if (e.type === "attackMissed" && e.defender === seat) return "dodge";
|
||||
@@ -245,6 +293,8 @@ function runBots(room: Room): void {
|
||||
}
|
||||
broadcast(room, (playerId) => ({ type: "events", events: redactFor(step.events, playerId) }));
|
||||
broadcast(room, (playerId) => ({ type: "state", view: viewForPlayer(room, playerId), seq: room.log.length }));
|
||||
// The game's end unmasks the mystery machines in the roster.
|
||||
if (room.state?.phase === "finished") broadcast(room, () => roomInfo(room));
|
||||
botRemark(room, step.seat, step.events as { type: string }[]);
|
||||
setTimeout(tick, BOT_STEP_MS);
|
||||
};
|
||||
@@ -265,14 +315,17 @@ wss.on("connection", (socket) => {
|
||||
return;
|
||||
}
|
||||
const session: Session = {
|
||||
socket, playerId: null, roomId: null, token: null, claimFails: 0,
|
||||
socket, playerId: null, roomId: null, spectator: false, token: null, claimFails: 0,
|
||||
bucket: 30, lastRefill: Date.now(), overLimitStrikes: 0,
|
||||
roomsCreated: 0, lastCatchUpAt: 0, hotseatReports: 0,
|
||||
};
|
||||
sessions.add(session);
|
||||
send(socket, { type: "welcome", game: "wizwar" });
|
||||
|
||||
socket.on("close", () => sessions.delete(session));
|
||||
socket.on("close", () => {
|
||||
sessions.delete(session);
|
||||
leaveGallery(session); // an emptier gallery is news to the table
|
||||
});
|
||||
|
||||
socket.on("message", (data) => {
|
||||
if (!underRateLimit(session)) {
|
||||
@@ -295,6 +348,7 @@ wss.on("connection", (socket) => {
|
||||
return send(socket, { type: "error", message: "no new rooms right now — try again later" });
|
||||
}
|
||||
session.roomsCreated++;
|
||||
leaveGallery(session);
|
||||
const { room, token } = createRoom(name);
|
||||
session.playerId = name;
|
||||
session.roomId = room.id;
|
||||
@@ -311,6 +365,7 @@ wss.on("connection", (socket) => {
|
||||
if (!room) return send(socket, { type: "error", message: "no such room" });
|
||||
const result = joinRoom(room, name, typeof msg.token === "string" ? msg.token : null);
|
||||
if ("error" in result) return send(socket, { type: "error", message: result.error });
|
||||
leaveGallery(session);
|
||||
session.playerId = name;
|
||||
session.roomId = room.id;
|
||||
session.token = result.token;
|
||||
@@ -325,6 +380,39 @@ wss.on("connection", (socket) => {
|
||||
runBots(room);
|
||||
break;
|
||||
}
|
||||
case "watch": {
|
||||
// The Peanut Gallery: no name, no seat, no ledger line — a pure
|
||||
// reader of the public broadcast, counted but never identified.
|
||||
const roomId = String(msg.roomId ?? "").trim().slice(0, 8);
|
||||
if (!roomId) return send(socket, { type: "error", message: "roomId required" });
|
||||
const room = getRoom(roomId);
|
||||
if (!room) return send(socket, { type: "error", message: "no such room" });
|
||||
if (audienceCount(room) >= MAX_AUDIENCE) {
|
||||
return send(socket, { type: "error", message: "the gallery is packed — try again later" });
|
||||
}
|
||||
leaveGallery(session); // switching galleries updates the old room's count
|
||||
session.playerId = null;
|
||||
session.token = null;
|
||||
session.spectator = true;
|
||||
session.roomId = room.id;
|
||||
send(socket, { type: "watching", roomId: room.id });
|
||||
send(socket, roomInfo(room));
|
||||
if (room.state) {
|
||||
send(socket, { type: "events", events: redactFor(room.events, SPECTATOR), replayed: true });
|
||||
send(socket, { type: "state", view: viewForPlayer(room, SPECTATOR), seq: room.log.length });
|
||||
}
|
||||
broadcastAudience(room);
|
||||
break;
|
||||
}
|
||||
case "leave": {
|
||||
// Walk away from the table or the gallery: the seat itself (and
|
||||
// its token) survives for a later resume; only this socket detaches.
|
||||
leaveGallery(session);
|
||||
session.playerId = null;
|
||||
session.roomId = null;
|
||||
session.token = null;
|
||||
break;
|
||||
}
|
||||
case "addBot": {
|
||||
const room = session.roomId ? getRoom(session.roomId) : undefined;
|
||||
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
|
||||
@@ -359,6 +447,8 @@ wss.on("connection", (socket) => {
|
||||
if ("error" in result) return send(socket, { type: "error", message: result.error });
|
||||
broadcast(room, (playerId) => ({ type: "events", events: redactFor(result.events, playerId) }));
|
||||
broadcast(room, (playerId) => ({ type: "state", view: viewForPlayer(room, playerId), seq: room.log.length }));
|
||||
// The game's end unmasks the mystery machines in the roster.
|
||||
if (room.state?.phase === "finished") broadcast(room, () => roomInfo(room));
|
||||
botRemark(room, session.playerId, result.events as { type: string }[]);
|
||||
runBots(room);
|
||||
break;
|
||||
|
||||
@@ -53,8 +53,10 @@ export interface Room {
|
||||
|
||||
const rooms = new Map<string, Room>();
|
||||
|
||||
/** Rules revision new games are dealt under (stored games keep their own). */
|
||||
const RULES_REV = 14;
|
||||
/** Rules revision new games are dealt under (stored games keep their own).
|
||||
* A rules change while games are live must bump this and gate the engine;
|
||||
* local hotseat games ride the engine's default and follow in lockstep. */
|
||||
const RULES_REV = 1;
|
||||
|
||||
const ROOM_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||
|
||||
@@ -91,6 +93,11 @@ export function roomCount(): number {
|
||||
return rooms.size;
|
||||
}
|
||||
|
||||
/** Every restored, still-running room — so boot can wake their bot pumps. */
|
||||
export function runningRooms(): Room[] {
|
||||
return [...rooms.values()].filter((r) => r.state && r.state.phase === "playing");
|
||||
}
|
||||
|
||||
export function createRoom(hostId: PlayerId): { room: Room; token: string } {
|
||||
const token = randomBytes(16).toString("hex");
|
||||
const room: Room = {
|
||||
@@ -270,6 +277,7 @@ function actingSeat(room: Room): PlayerId | null {
|
||||
const s = room.state;
|
||||
if (!s || s.phase !== "playing") return null;
|
||||
return (
|
||||
s.wardPending?.ownerId ??
|
||||
s.stack?.waitingOn ??
|
||||
s.pendingDiscard ??
|
||||
s.chaosPending?.queue[0] ??
|
||||
@@ -355,6 +363,12 @@ export function viewForPlayer(room: Room, playerId: PlayerId): GameView | null {
|
||||
return room.state ? viewFor(room.state, playerId) : null;
|
||||
}
|
||||
|
||||
/** The Peanut Gallery's viewer id: the empty name can never hold a seat
|
||||
* (joins reject blank names), so a view built for it shows public knowledge
|
||||
* only — no hand, no ward, no ambushes, no boobytrap truths — and event
|
||||
* redaction drops everything marked visibleTo a player. */
|
||||
export const SPECTATOR: PlayerId = "";
|
||||
|
||||
export interface CatchUpStep {
|
||||
seq: number;
|
||||
actor: PlayerId;
|
||||
|
||||
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 111 KiB |
@@ -0,0 +1,12 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Alter Ego token">
|
||||
<defs>
|
||||
<filter id="w"><feTurbulence baseFrequency=".035" numOctaves="2" seed="4" result="n"/><feDisplacementMap in="SourceGraphic" in2="n" scale=".65"/></filter>
|
||||
<filter id="s"><feGaussianBlur stdDeviation="2"/></filter>
|
||||
<pattern id="paper" width="12" height="12" patternUnits="userSpaceOnUse"><rect width="12" height="12" fill="#f4efd9"/><circle cx="2" cy="4" r=".45" fill="#c8bfa4" opacity=".28"/><path d="M1 10l7-1" stroke="#d8cfb8" stroke-width=".35" opacity=".35"/></pattern>
|
||||
</defs>
|
||||
<rect x="2" y="2" width="140" height="146" rx="3" fill="url(#paper)" stroke="#242822" stroke-width="2"/>
|
||||
<text x="72" y="19" text-anchor="middle" font-family="Trebuchet MS,Arial,sans-serif" font-weight="700" font-size="14" fill="#20251f">Alter Ego</text>
|
||||
<g filter="url(#w)" stroke="#252923" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M35 52 Q65 40 80 66 Q86 88 67 105 Q45 111 31 90Z" fill="#e7c3a1"/>
|
||||
<path d="M40 73q9 -10 19 0M62 70q8 -7 15 1" fill="none"/><circle cx="53" cy="75" r="2.4" fill="#20251f" stroke="none"/><path d="M64 78q-8 8 3 11q-9 9 -19 2" fill="none"/><path d="M111 52 Q81 40 66 66 Q60 88 79 105 Q101 111 115 90Z" fill="#d9b08e"/>
|
||||
<path d="M106 73q9 -10 19 0M84 70q8 -7 15 1" fill="none"/><circle cx="93" cy="75" r="2.4" fill="#20251f" stroke="none"/><path d="M82 78q8 8 3 11q9 9 19 2" fill="none"/><path d="M69 48q4-12 8 0M47 115q25-14 51 0" fill="none"/><path d="M24 67l-10-6m108 5l9-8" fill="none"/></g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Boobytrap token">
|
||||
<defs>
|
||||
<filter id="w"><feTurbulence baseFrequency=".035" numOctaves="2" seed="4" result="n"/><feDisplacementMap in="SourceGraphic" in2="n" scale=".65"/></filter>
|
||||
<filter id="s"><feGaussianBlur stdDeviation="2"/></filter>
|
||||
<pattern id="paper" width="12" height="12" patternUnits="userSpaceOnUse"><rect width="12" height="12" fill="#f4efd9"/><circle cx="2" cy="4" r=".45" fill="#c8bfa4" opacity=".28"/><path d="M1 10l7-1" stroke="#d8cfb8" stroke-width=".35" opacity=".35"/></pattern>
|
||||
</defs>
|
||||
<rect x="2" y="2" width="140" height="146" rx="3" fill="url(#paper)" stroke="#242822" stroke-width="2"/>
|
||||
<text x="72" y="19" text-anchor="middle" font-family="Trebuchet MS,Arial,sans-serif" font-weight="700" font-size="14" fill="#20251f">Boobytrap</text>
|
||||
<g filter="url(#w)" stroke="#252923" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M28 105L70 49l43 56Z" fill="#f2ca4f"/><path d="M44 96l26-35 25 35Z" fill="#df5b36"/><path d="M71 70v14m0 7v2" stroke="#fff8da" stroke-width="5"/><path d="M25 62l12 5-9 8m92-11l-11 4 8 9" fill="none"/></g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Dagger token">
|
||||
<defs>
|
||||
<filter id="w"><feTurbulence baseFrequency=".035" numOctaves="2" seed="4" result="n"/><feDisplacementMap in="SourceGraphic" in2="n" scale=".65"/></filter>
|
||||
<filter id="s"><feGaussianBlur stdDeviation="2"/></filter>
|
||||
<pattern id="paper" width="12" height="12" patternUnits="userSpaceOnUse"><rect width="12" height="12" fill="#f4efd9"/><circle cx="2" cy="4" r=".45" fill="#c8bfa4" opacity=".28"/><path d="M1 10l7-1" stroke="#d8cfb8" stroke-width=".35" opacity=".35"/></pattern>
|
||||
</defs>
|
||||
<rect x="2" y="2" width="140" height="146" rx="3" fill="url(#paper)" stroke="#242822" stroke-width="2"/>
|
||||
<text x="72" y="19" text-anchor="middle" font-family="Trebuchet MS,Arial,sans-serif" font-weight="700" font-size="14" fill="#20251f">Dagger</text>
|
||||
<g filter="url(#w)" stroke="#252923" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M34 111L90 48l12 12-57 60Z" fill="#c9d5d2"/><path d="M87 51l10-13 13 13-10 10Z" fill="#6c5641"/><path d="M75 58l22 22"/><path d="M30 70l-13-5 10-8m82 36l15 3-9 9" fill="none"/></g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Democratic Monster token">
|
||||
<defs>
|
||||
<filter id="w"><feTurbulence baseFrequency=".035" numOctaves="2" seed="4" result="n"/><feDisplacementMap in="SourceGraphic" in2="n" scale=".65"/></filter>
|
||||
<filter id="s"><feGaussianBlur stdDeviation="2"/></filter>
|
||||
<pattern id="paper" width="12" height="12" patternUnits="userSpaceOnUse"><rect width="12" height="12" fill="#f4efd9"/><circle cx="2" cy="4" r=".45" fill="#c8bfa4" opacity=".28"/><path d="M1 10l7-1" stroke="#d8cfb8" stroke-width=".35" opacity=".35"/></pattern>
|
||||
</defs>
|
||||
<rect x="2" y="2" width="140" height="146" rx="3" fill="url(#paper)" stroke="#242822" stroke-width="2"/>
|
||||
<text x="72" y="19" text-anchor="middle" font-family="Trebuchet MS,Arial,sans-serif" font-weight="700" font-size="12" fill="#20251f">Democratic Monster</text>
|
||||
<g filter="url(#w)" stroke="#252923" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M71 120Q28 108 44 72q-18-14 1-25 17-8 26 8 12-23 29-10 14 13-2 29 20 29-27 46Z" fill="#df3f37"/><path d="M43 83l-20 8 18 7m57-16l23 10-21 8" fill="#df3f37"/><circle cx="62" cy="65" r="7" fill="#fff"/><circle cx="82" cy="63" r="7" fill="#fff"/><circle cx="64" cy="66" r="2"/><circle cx="80" cy="64" r="2"/><path d="M56 87q17 13 31-2" fill="none"/></g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Dimensional Warp token">
|
||||
<defs>
|
||||
<filter id="w"><feTurbulence baseFrequency=".035" numOctaves="2" seed="4" result="n"/><feDisplacementMap in="SourceGraphic" in2="n" scale=".65"/></filter>
|
||||
<filter id="s"><feGaussianBlur stdDeviation="2"/></filter>
|
||||
<pattern id="paper" width="12" height="12" patternUnits="userSpaceOnUse"><rect width="12" height="12" fill="#f4efd9"/><circle cx="2" cy="4" r=".45" fill="#c8bfa4" opacity=".28"/><path d="M1 10l7-1" stroke="#d8cfb8" stroke-width=".35" opacity=".35"/></pattern>
|
||||
</defs>
|
||||
<rect x="2" y="2" width="140" height="146" rx="3" fill="url(#paper)" stroke="#242822" stroke-width="2"/>
|
||||
<text x="72" y="19" text-anchor="middle" font-family="Trebuchet MS,Arial,sans-serif" font-weight="700" font-size="14" fill="#20251f">Dimensional Warp</text>
|
||||
<g filter="url(#w)" stroke="#252923" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><ellipse cx="72" cy="80" rx="47" ry="22" fill="#5c369b"/><ellipse cx="72" cy="80" rx="34" ry="14" fill="#e4bdff"/><ellipse cx="72" cy="80" rx="20" ry="7" fill="#252036"/><path d="M22 52q13-14 26-17m72 18q-13-14-27-17M22 108q13 14 27 17m71-17q-13 14-27 17" fill="none" stroke="#7849b1"/></g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Dustcloud token">
|
||||
<defs>
|
||||
<filter id="w"><feTurbulence baseFrequency=".035" numOctaves="2" seed="4" result="n"/><feDisplacementMap in="SourceGraphic" in2="n" scale=".65"/></filter>
|
||||
<filter id="s"><feGaussianBlur stdDeviation="2"/></filter>
|
||||
<pattern id="paper" width="12" height="12" patternUnits="userSpaceOnUse"><rect width="12" height="12" fill="#f4efd9"/><circle cx="2" cy="4" r=".45" fill="#c8bfa4" opacity=".28"/><path d="M1 10l7-1" stroke="#d8cfb8" stroke-width=".35" opacity=".35"/></pattern>
|
||||
</defs>
|
||||
<rect x="2" y="2" width="140" height="146" rx="3" fill="url(#paper)" stroke="#242822" stroke-width="2"/>
|
||||
<text x="72" y="19" text-anchor="middle" font-family="Trebuchet MS,Arial,sans-serif" font-weight="700" font-size="14" fill="#20251f">Dustcloud</text>
|
||||
<g filter="url(#w)" stroke="#252923" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M27 105q-15-17 7-27-9-24 18-24 8-23 30-9 19-10 28 9 24 2 16 25 17 18-5 30-35 15-74-4Z" fill="#68675d"/><path d="M42 80q15-14 27 1m12-12q15-9 26 5m-61 20q17-12 31 2" fill="none" stroke="#989383"/><circle cx="31" cy="117" r="3"/><circle cx="117" cy="118" r="2"/></g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Fire Imp token">
|
||||
<defs>
|
||||
<filter id="w"><feTurbulence baseFrequency=".035" numOctaves="2" seed="4" result="n"/><feDisplacementMap in="SourceGraphic" in2="n" scale=".65"/></filter>
|
||||
<filter id="s"><feGaussianBlur stdDeviation="2"/></filter>
|
||||
<pattern id="paper" width="12" height="12" patternUnits="userSpaceOnUse"><rect width="12" height="12" fill="#f4efd9"/><circle cx="2" cy="4" r=".45" fill="#c8bfa4" opacity=".28"/><path d="M1 10l7-1" stroke="#d8cfb8" stroke-width=".35" opacity=".35"/></pattern>
|
||||
</defs>
|
||||
<rect x="2" y="2" width="140" height="146" rx="3" fill="url(#paper)" stroke="#242822" stroke-width="2"/>
|
||||
<text x="72" y="19" text-anchor="middle" font-family="Trebuchet MS,Arial,sans-serif" font-weight="700" font-size="14" fill="#20251f">Fire Imp</text>
|
||||
<g filter="url(#w)" stroke="#252923" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M71 119q-29-9-26-42-14-16 5-21-2-23 19-12 18-20 24 7 22 8 7 27 8 32-29 41Z" fill="#e8494a"/><path d="M52 52L39 34l23 12m29 4l16-18-6 25" fill="#e8494a"/><circle cx="63" cy="70" r="5" fill="#ffe15a"/><circle cx="84" cy="68" r="5" fill="#ffe15a"/><path d="M59 91q14 10 28-3" fill="none"/><path d="M42 102l-20 13m79-14l20 13" fill="none" stroke="#e8494a" stroke-width="7"/></g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Killer Ooze token">
|
||||
<defs>
|
||||
<filter id="w"><feTurbulence baseFrequency=".035" numOctaves="2" seed="4" result="n"/><feDisplacementMap in="SourceGraphic" in2="n" scale=".65"/></filter>
|
||||
<filter id="s"><feGaussianBlur stdDeviation="2"/></filter>
|
||||
<pattern id="paper" width="12" height="12" patternUnits="userSpaceOnUse"><rect width="12" height="12" fill="#f4efd9"/><circle cx="2" cy="4" r=".45" fill="#c8bfa4" opacity=".28"/><path d="M1 10l7-1" stroke="#d8cfb8" stroke-width=".35" opacity=".35"/></pattern>
|
||||
</defs>
|
||||
<rect x="2" y="2" width="140" height="146" rx="3" fill="url(#paper)" stroke="#242822" stroke-width="2"/>
|
||||
<text x="72" y="19" text-anchor="middle" font-family="Trebuchet MS,Arial,sans-serif" font-weight="700" font-size="14" fill="#20251f">Killer Ooze</text>
|
||||
<g filter="url(#w)" stroke="#252923" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M26 115q-2-24 15-27-10-29 12-33 7-29 26-5 26-15 30 17 21 14 4 37 3 21-25 14-19 13-32-2-17 11-30-1Z" fill="#16834f"/><ellipse cx="60" cy="73" rx="10" ry="14" fill="#fff"/><ellipse cx="86" cy="72" rx="10" ry="14" fill="#fff"/><circle cx="64" cy="78" r="3"/><circle cx="82" cy="78" r="3"/><path d="M48 96q25-18 48 1l-8 15-8-11-9 12-8-12-8 11Z" fill="#173b2a"/></g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Magic Stone token">
|
||||
<defs>
|
||||
<filter id="w"><feTurbulence baseFrequency=".035" numOctaves="2" seed="4" result="n"/><feDisplacementMap in="SourceGraphic" in2="n" scale=".65"/></filter>
|
||||
<filter id="s"><feGaussianBlur stdDeviation="2"/></filter>
|
||||
<pattern id="paper" width="12" height="12" patternUnits="userSpaceOnUse"><rect width="12" height="12" fill="#f4efd9"/><circle cx="2" cy="4" r=".45" fill="#c8bfa4" opacity=".28"/><path d="M1 10l7-1" stroke="#d8cfb8" stroke-width=".35" opacity=".35"/></pattern>
|
||||
</defs>
|
||||
<rect x="2" y="2" width="140" height="146" rx="3" fill="url(#paper)" stroke="#242822" stroke-width="2"/>
|
||||
<text x="72" y="19" text-anchor="middle" font-family="Trebuchet MS,Arial,sans-serif" font-weight="700" font-size="14" fill="#20251f">Magic Stone</text>
|
||||
<g filter="url(#w)" stroke="#252923" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M72 46l25 23-11 37-32 6-18-31 14-30Z" fill="#d72e78"/><path d="M50 51l20 31 27-13M70 82l16 24M37 81l33 1" fill="none" stroke="#ff83b5"/><path d="M28 55l-12-13m95 16l15-12M25 109l-13 12m100-13l14 12" fill="none" stroke="#29252d" stroke-width="5"/></g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Magic Wand token">
|
||||
<defs>
|
||||
<filter id="w"><feTurbulence baseFrequency=".035" numOctaves="2" seed="4" result="n"/><feDisplacementMap in="SourceGraphic" in2="n" scale=".65"/></filter>
|
||||
<filter id="s"><feGaussianBlur stdDeviation="2"/></filter>
|
||||
<pattern id="paper" width="12" height="12" patternUnits="userSpaceOnUse"><rect width="12" height="12" fill="#f4efd9"/><circle cx="2" cy="4" r=".45" fill="#c8bfa4" opacity=".28"/><path d="M1 10l7-1" stroke="#d8cfb8" stroke-width=".35" opacity=".35"/></pattern>
|
||||
</defs>
|
||||
<rect x="2" y="2" width="140" height="146" rx="3" fill="url(#paper)" stroke="#242822" stroke-width="2"/>
|
||||
<text x="72" y="19" text-anchor="middle" font-family="Trebuchet MS,Arial,sans-serif" font-weight="700" font-size="14" fill="#20251f">Magic Wand</text>
|
||||
<g filter="url(#w)" stroke="#252923" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M37 111L94 50" stroke="#4a3a2a" stroke-width="9"/><path d="M34 114L91 53" stroke="#d8c6a7" stroke-width="3"/><circle cx="99" cy="43" r="14" fill="#f0c83e"/><path d="M99 23v-9m0 58v-9M79 43h-9m58 0h-9M85 29l-7-7m42 42l-7-7m0-28l7-7M85 57l-7 7" fill="none" stroke="#e7a81f"/></g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Master Key token">
|
||||
<defs>
|
||||
<filter id="w"><feTurbulence baseFrequency=".035" numOctaves="2" seed="4" result="n"/><feDisplacementMap in="SourceGraphic" in2="n" scale=".65"/></filter>
|
||||
<filter id="s"><feGaussianBlur stdDeviation="2"/></filter>
|
||||
<pattern id="paper" width="12" height="12" patternUnits="userSpaceOnUse"><rect width="12" height="12" fill="#f4efd9"/><circle cx="2" cy="4" r=".45" fill="#c8bfa4" opacity=".28"/><path d="M1 10l7-1" stroke="#d8cfb8" stroke-width=".35" opacity=".35"/></pattern>
|
||||
</defs>
|
||||
<rect x="2" y="2" width="140" height="146" rx="3" fill="url(#paper)" stroke="#242822" stroke-width="2"/>
|
||||
<text x="72" y="19" text-anchor="middle" font-family="Trebuchet MS,Arial,sans-serif" font-weight="700" font-size="14" fill="#20251f">Master Key</text>
|
||||
<g filter="url(#w)" stroke="#252923" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><circle cx="91" cy="63" r="27" fill="#d6d4c8"/><circle cx="91" cy="63" r="11" fill="#f4efd9"/><path d="M72 79L32 119" stroke="#c9c7bc" stroke-width="11"/><path d="M42 109l-13-13m24 2l-12-12"/><path d="M76 45l30 31M75 78l33-31" fill="none" stroke="#83857e"/></g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Pit token">
|
||||
<defs>
|
||||
<filter id="w"><feTurbulence baseFrequency=".035" numOctaves="2" seed="4" result="n"/><feDisplacementMap in="SourceGraphic" in2="n" scale=".65"/></filter>
|
||||
<filter id="s"><feGaussianBlur stdDeviation="2"/></filter>
|
||||
<pattern id="paper" width="12" height="12" patternUnits="userSpaceOnUse"><rect width="12" height="12" fill="#f4efd9"/><circle cx="2" cy="4" r=".45" fill="#c8bfa4" opacity=".28"/><path d="M1 10l7-1" stroke="#d8cfb8" stroke-width=".35" opacity=".35"/></pattern>
|
||||
</defs>
|
||||
<rect x="2" y="2" width="140" height="146" rx="3" fill="url(#paper)" stroke="#242822" stroke-width="2"/>
|
||||
<text x="72" y="19" text-anchor="middle" font-family="Trebuchet MS,Arial,sans-serif" font-weight="700" font-size="14" fill="#20251f">Pit</text>
|
||||
<g filter="url(#w)" stroke="#252923" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M24 77l55-28 43 28-52 42Z" fill="#45484b"/><path d="M35 77l43-19 32 19-40 29Z" fill="#171b20"/><path d="M35 77l35 29m8-48l-8 48m40-29l-40 29" fill="none" stroke="#737980"/></g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Rock token">
|
||||
<defs>
|
||||
<filter id="w"><feTurbulence baseFrequency=".035" numOctaves="2" seed="4" result="n"/><feDisplacementMap in="SourceGraphic" in2="n" scale=".65"/></filter>
|
||||
<filter id="s"><feGaussianBlur stdDeviation="2"/></filter>
|
||||
<pattern id="paper" width="12" height="12" patternUnits="userSpaceOnUse"><rect width="12" height="12" fill="#f4efd9"/><circle cx="2" cy="4" r=".45" fill="#c8bfa4" opacity=".28"/><path d="M1 10l7-1" stroke="#d8cfb8" stroke-width=".35" opacity=".35"/></pattern>
|
||||
</defs>
|
||||
<rect x="2" y="2" width="140" height="146" rx="3" fill="url(#paper)" stroke="#242822" stroke-width="2"/>
|
||||
<text x="72" y="19" text-anchor="middle" font-family="Trebuchet MS,Arial,sans-serif" font-weight="700" font-size="14" fill="#20251f">Rock</text>
|
||||
<g filter="url(#w)" stroke="#252923" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M30 112l8-46 26-22 34 10 18 38-22 28-43 1Z" fill="#666c68"/><path d="M38 66l30 12 30-24M68 78l-17 43m17-43l26 42" fill="none" stroke="#929994"/><ellipse cx="73" cy="122" rx="46" ry="6" fill="#252923" opacity=".25" stroke="none"/></g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Rosebush token">
|
||||
<defs>
|
||||
<filter id="w"><feTurbulence baseFrequency=".035" numOctaves="2" seed="4" result="n"/><feDisplacementMap in="SourceGraphic" in2="n" scale=".65"/></filter>
|
||||
<filter id="s"><feGaussianBlur stdDeviation="2"/></filter>
|
||||
<pattern id="paper" width="12" height="12" patternUnits="userSpaceOnUse"><rect width="12" height="12" fill="#f4efd9"/><circle cx="2" cy="4" r=".45" fill="#c8bfa4" opacity=".28"/><path d="M1 10l7-1" stroke="#d8cfb8" stroke-width=".35" opacity=".35"/></pattern>
|
||||
</defs>
|
||||
<rect x="2" y="2" width="140" height="146" rx="3" fill="url(#paper)" stroke="#242822" stroke-width="2"/>
|
||||
<text x="72" y="19" text-anchor="middle" font-family="Trebuchet MS,Arial,sans-serif" font-weight="700" font-size="14" fill="#20251f">Rosebush</text>
|
||||
<g filter="url(#w)" stroke="#252923" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M29 114q8-47 40-40-4-31 19-16 29-5 29 28 21 24-11 34Z" fill="#237148"/><path d="M42 111l52-47M50 72l45 39" fill="none" stroke="#174e32" stroke-width="5"/><g fill="#d6336c"><circle cx="45" cy="83" r="8"/><circle cx="78" cy="61" r="8"/><circle cx="102" cy="88" r="9"/><circle cx="75" cy="103" r="7"/></g></g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Safe token">
|
||||
<defs>
|
||||
<filter id="w"><feTurbulence baseFrequency=".035" numOctaves="2" seed="4" result="n"/><feDisplacementMap in="SourceGraphic" in2="n" scale=".65"/></filter>
|
||||
<filter id="s"><feGaussianBlur stdDeviation="2"/></filter>
|
||||
<pattern id="paper" width="12" height="12" patternUnits="userSpaceOnUse"><rect width="12" height="12" fill="#f4efd9"/><circle cx="2" cy="4" r=".45" fill="#c8bfa4" opacity=".28"/><path d="M1 10l7-1" stroke="#d8cfb8" stroke-width=".35" opacity=".35"/></pattern>
|
||||
</defs>
|
||||
<rect x="2" y="2" width="140" height="146" rx="3" fill="url(#paper)" stroke="#242822" stroke-width="2"/>
|
||||
<text x="72" y="19" text-anchor="middle" font-family="Trebuchet MS,Arial,sans-serif" font-weight="700" font-size="14" fill="#20251f">Safe</text>
|
||||
<g filter="url(#w)" stroke="#252923" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M38 53l61-12 14 71-66 11Z" fill="#767e80"/><path d="M51 62l40-7 8 49-43 8Z" fill="#aab1b0"/><circle cx="74" cy="82" r="13" fill="none"/><path d="M74 69v26M61 82h26m-13 0l11-8m-11 8l-9 10" fill="none"/><path d="M45 121l-2 8m62-18l4 9"/></g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Shadow token">
|
||||
<defs>
|
||||
<filter id="w"><feTurbulence baseFrequency=".035" numOctaves="2" seed="4" result="n"/><feDisplacementMap in="SourceGraphic" in2="n" scale=".65"/></filter>
|
||||
<filter id="s"><feGaussianBlur stdDeviation="2"/></filter>
|
||||
<pattern id="paper" width="12" height="12" patternUnits="userSpaceOnUse"><rect width="12" height="12" fill="#f4efd9"/><circle cx="2" cy="4" r=".45" fill="#c8bfa4" opacity=".28"/><path d="M1 10l7-1" stroke="#d8cfb8" stroke-width=".35" opacity=".35"/></pattern>
|
||||
</defs>
|
||||
<rect x="2" y="2" width="140" height="146" rx="3" fill="url(#paper)" stroke="#242822" stroke-width="2"/>
|
||||
<text x="72" y="19" text-anchor="middle" font-family="Trebuchet MS,Arial,sans-serif" font-weight="700" font-size="14" fill="#20251f">Shadow</text>
|
||||
<g filter="url(#w)" stroke="#252923" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M34 116q8-33 26-39-11-28 13-37 23 8 14 34 23 12 25 41Z" fill="#172a27"/><path d="M37 88L17 75m25 26l-23 8m88-20l20-13m-17 26l20 8" fill="none" stroke="#172a27" stroke-width="8"/><path d="M65 61l7-4 7 4" fill="none" stroke="#d6f2cb"/></g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Skeleton token">
|
||||
<defs>
|
||||
<filter id="w"><feTurbulence baseFrequency=".035" numOctaves="2" seed="4" result="n"/><feDisplacementMap in="SourceGraphic" in2="n" scale=".65"/></filter>
|
||||
<filter id="s"><feGaussianBlur stdDeviation="2"/></filter>
|
||||
<pattern id="paper" width="12" height="12" patternUnits="userSpaceOnUse"><rect width="12" height="12" fill="#f4efd9"/><circle cx="2" cy="4" r=".45" fill="#c8bfa4" opacity=".28"/><path d="M1 10l7-1" stroke="#d8cfb8" stroke-width=".35" opacity=".35"/></pattern>
|
||||
</defs>
|
||||
<rect x="2" y="2" width="140" height="146" rx="3" fill="url(#paper)" stroke="#242822" stroke-width="2"/>
|
||||
<text x="72" y="19" text-anchor="middle" font-family="Trebuchet MS,Arial,sans-serif" font-weight="700" font-size="14" fill="#20251f">Skeleton</text>
|
||||
<g filter="url(#w)" stroke="#252923" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><circle cx="72" cy="62" r="22" fill="#eee8d5"/><circle cx="64" cy="58" r="6"/><circle cx="81" cy="58" r="6"/><path d="M69 71l3-6 4 6m-14 8h21M72 84v33M53 94l19 9 20-10M72 117l-17 13m17-13l18 13" fill="none"/><path d="M30 62l-10-9m94 10l11-11M29 98l-13 7m99-7l14 8" fill="none"/></g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Slime token">
|
||||
<defs>
|
||||
<filter id="w"><feTurbulence baseFrequency=".035" numOctaves="2" seed="4" result="n"/><feDisplacementMap in="SourceGraphic" in2="n" scale=".65"/></filter>
|
||||
<filter id="s"><feGaussianBlur stdDeviation="2"/></filter>
|
||||
<pattern id="paper" width="12" height="12" patternUnits="userSpaceOnUse"><rect width="12" height="12" fill="#f4efd9"/><circle cx="2" cy="4" r=".45" fill="#c8bfa4" opacity=".28"/><path d="M1 10l7-1" stroke="#d8cfb8" stroke-width=".35" opacity=".35"/></pattern>
|
||||
</defs>
|
||||
<rect x="2" y="2" width="140" height="146" rx="3" fill="url(#paper)" stroke="#242822" stroke-width="2"/>
|
||||
<text x="72" y="19" text-anchor="middle" font-family="Trebuchet MS,Arial,sans-serif" font-weight="700" font-size="14" fill="#20251f">Slime</text>
|
||||
<g filter="url(#w)" stroke="#252923" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M25 56q23-14 48-3 24-12 47 4v46q-8 19-22 3-12 26-27 1-14 19-25 0-13 13-21-3Z" fill="#0e754a"/><path d="M25 56q23 9 48-3 24 10 47 4" fill="none" stroke="#55ac76"/><path d="M43 103v18m34-15v25m27-29v17" fill="none" stroke="#0e754a" stroke-width="8"/><ellipse cx="73" cy="129" rx="51" ry="7" fill="#3a9667" opacity=".6"/></g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Solid Stone token">
|
||||
<defs>
|
||||
<filter id="w"><feTurbulence baseFrequency=".035" numOctaves="2" seed="4" result="n"/><feDisplacementMap in="SourceGraphic" in2="n" scale=".65"/></filter>
|
||||
<filter id="s"><feGaussianBlur stdDeviation="2"/></filter>
|
||||
<pattern id="paper" width="12" height="12" patternUnits="userSpaceOnUse"><rect width="12" height="12" fill="#f4efd9"/><circle cx="2" cy="4" r=".45" fill="#c8bfa4" opacity=".28"/><path d="M1 10l7-1" stroke="#d8cfb8" stroke-width=".35" opacity=".35"/></pattern>
|
||||
</defs>
|
||||
<rect x="2" y="2" width="140" height="146" rx="3" fill="url(#paper)" stroke="#242822" stroke-width="2"/>
|
||||
<text x="72" y="19" text-anchor="middle" font-family="Trebuchet MS,Arial,sans-serif" font-weight="700" font-size="14" fill="#20251f">Solid Stone</text>
|
||||
<g filter="url(#w)" stroke="#252923" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M24 111l9-48 19-17 47 2 20 18-5 48Z" fill="#9da09a"/><path d="M33 63l28 9 38-24M61 72l-7 41m7-41l29 42m9-66l-9 66" fill="none" stroke="#666a66"/><path d="M25 111q44 8 89 3" fill="none"/></g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Tacks token">
|
||||
<defs>
|
||||
<filter id="w"><feTurbulence baseFrequency=".035" numOctaves="2" seed="4" result="n"/><feDisplacementMap in="SourceGraphic" in2="n" scale=".65"/></filter>
|
||||
<filter id="s"><feGaussianBlur stdDeviation="2"/></filter>
|
||||
<pattern id="paper" width="12" height="12" patternUnits="userSpaceOnUse"><rect width="12" height="12" fill="#f4efd9"/><circle cx="2" cy="4" r=".45" fill="#c8bfa4" opacity=".28"/><path d="M1 10l7-1" stroke="#d8cfb8" stroke-width=".35" opacity=".35"/></pattern>
|
||||
</defs>
|
||||
<rect x="2" y="2" width="140" height="146" rx="3" fill="url(#paper)" stroke="#242822" stroke-width="2"/>
|
||||
<text x="72" y="19" text-anchor="middle" font-family="Trebuchet MS,Arial,sans-serif" font-weight="700" font-size="14" fill="#20251f">Tacks</text>
|
||||
<g filter="url(#w)" stroke="#252923" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><g fill="#c9c7b9"><path d="M35 60l15 7-11 5 2 28-7-26-12 1Z"/><path d="M82 50l16 8-12 5 2 28-7-26-12 1Z"/><path d="M61 91l15 7-11 5 2 27-7-25-12 1Z"/><path d="M108 84l14 7-10 5 1 25-6-23-11 1Z"/></g><path d="M21 112q50 12 107 1" fill="none" stroke="#9b7d4b"/></g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Thorn Bush token">
|
||||
<defs>
|
||||
<filter id="w"><feTurbulence baseFrequency=".035" numOctaves="2" seed="4" result="n"/><feDisplacementMap in="SourceGraphic" in2="n" scale=".65"/></filter>
|
||||
<filter id="s"><feGaussianBlur stdDeviation="2"/></filter>
|
||||
<pattern id="paper" width="12" height="12" patternUnits="userSpaceOnUse"><rect width="12" height="12" fill="#f4efd9"/><circle cx="2" cy="4" r=".45" fill="#c8bfa4" opacity=".28"/><path d="M1 10l7-1" stroke="#d8cfb8" stroke-width=".35" opacity=".35"/></pattern>
|
||||
</defs>
|
||||
<rect x="2" y="2" width="140" height="146" rx="3" fill="url(#paper)" stroke="#242822" stroke-width="2"/>
|
||||
<text x="72" y="19" text-anchor="middle" font-family="Trebuchet MS,Arial,sans-serif" font-weight="700" font-size="14" fill="#20251f">Thorn Bush</text>
|
||||
<g filter="url(#w)" stroke="#252923" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M27 111q4-43 34-35-2-31 19-16 27-11 27 19 26 15 8 39Z" fill="#247348"/><path d="M39 106l55-41M47 73l51 40" fill="none" stroke="#174b31" stroke-width="5"/><path d="M41 90l-13-8m28 3l-5-16m33 11l8-16m-2 34l18-4m-50 17l-7 16" fill="none" stroke="#eee8d5"/></g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Treasure token">
|
||||
<defs>
|
||||
<filter id="w"><feTurbulence baseFrequency=".035" numOctaves="2" seed="4" result="n"/><feDisplacementMap in="SourceGraphic" in2="n" scale=".65"/></filter>
|
||||
<filter id="s"><feGaussianBlur stdDeviation="2"/></filter>
|
||||
<pattern id="paper" width="12" height="12" patternUnits="userSpaceOnUse"><rect width="12" height="12" fill="#f4efd9"/><circle cx="2" cy="4" r=".45" fill="#c8bfa4" opacity=".28"/><path d="M1 10l7-1" stroke="#d8cfb8" stroke-width=".35" opacity=".35"/></pattern>
|
||||
</defs>
|
||||
<rect x="2" y="2" width="140" height="146" rx="3" fill="url(#paper)" stroke="#242822" stroke-width="2"/>
|
||||
<text x="72" y="19" text-anchor="middle" font-family="Trebuchet MS,Arial,sans-serif" font-weight="700" font-size="14" fill="#20251f">Treasure</text>
|
||||
<g filter="url(#w)" stroke="#252923" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M35 67q37-25 74 0v20H35Z" fill="#329664"/><path d="M31 87h82v34H31Z" fill="#6d4a2b"/><path d="M35 93h74M51 88v32m42-32v32" fill="none" stroke="#e8c068"/><rect x="65" y="84" width="14" height="18" rx="2" fill="#f1c64d"/><circle cx="72" cy="78" r="7" fill="#ffd84d"/><path d="M43 91l-12 17m69-17l13 16M56 91l-5 24m37-24l6 23" fill="none" stroke="#d69d23"/></g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Treasure token">
|
||||
<defs>
|
||||
<filter id="w"><feTurbulence baseFrequency=".035" numOctaves="2" seed="4" result="n"/><feDisplacementMap in="SourceGraphic" in2="n" scale=".65"/></filter>
|
||||
<filter id="s"><feGaussianBlur stdDeviation="2"/></filter>
|
||||
<pattern id="paper" width="12" height="12" patternUnits="userSpaceOnUse"><rect width="12" height="12" fill="#f4efd9"/><circle cx="2" cy="4" r=".45" fill="#c8bfa4" opacity=".28"/><path d="M1 10l7-1" stroke="#d8cfb8" stroke-width=".35" opacity=".35"/></pattern>
|
||||
</defs>
|
||||
<rect x="2" y="2" width="140" height="146" rx="3" fill="url(#paper)" stroke="#242822" stroke-width="2"/>
|
||||
<text x="72" y="19" text-anchor="middle" font-family="Trebuchet MS,Arial,sans-serif" font-weight="700" font-size="14" fill="#20251f">Treasure</text>
|
||||
<g filter="url(#w)" stroke="#252923" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M35 67q37-25 74 0v20H35Z" fill="#e34a44"/><path d="M31 87h82v34H31Z" fill="#6d4a2b"/><path d="M35 93h74M51 88v32m42-32v32" fill="none" stroke="#e8c068"/><rect x="65" y="84" width="14" height="18" rx="2" fill="#f1c64d"/></g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Treasure token">
|
||||
<defs>
|
||||
<filter id="w"><feTurbulence baseFrequency=".035" numOctaves="2" seed="4" result="n"/><feDisplacementMap in="SourceGraphic" in2="n" scale=".65"/></filter>
|
||||
<filter id="s"><feGaussianBlur stdDeviation="2"/></filter>
|
||||
<pattern id="paper" width="12" height="12" patternUnits="userSpaceOnUse"><rect width="12" height="12" fill="#f4efd9"/><circle cx="2" cy="4" r=".45" fill="#c8bfa4" opacity=".28"/><path d="M1 10l7-1" stroke="#d8cfb8" stroke-width=".35" opacity=".35"/></pattern>
|
||||
</defs>
|
||||
<rect x="2" y="2" width="140" height="146" rx="3" fill="url(#paper)" stroke="#242822" stroke-width="2"/>
|
||||
<text x="72" y="19" text-anchor="middle" font-family="Trebuchet MS,Arial,sans-serif" font-weight="700" font-size="14" fill="#20251f">Treasure</text>
|
||||
<g filter="url(#w)" stroke="#252923" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M35 67q37-25 74 0v20H35Z" fill="#b72d6b"/><path d="M31 87h82v34H31Z" fill="#6d4a2b"/><path d="M35 93h74M51 88v32m42-32v32" fill="none" stroke="#e8c068"/><rect x="65" y="84" width="14" height="18" rx="2" fill="#f1c64d"/></g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Treasure token">
|
||||
<defs>
|
||||
<filter id="w"><feTurbulence baseFrequency=".035" numOctaves="2" seed="4" result="n"/><feDisplacementMap in="SourceGraphic" in2="n" scale=".65"/></filter>
|
||||
<filter id="s"><feGaussianBlur stdDeviation="2"/></filter>
|
||||
<pattern id="paper" width="12" height="12" patternUnits="userSpaceOnUse"><rect width="12" height="12" fill="#f4efd9"/><circle cx="2" cy="4" r=".45" fill="#c8bfa4" opacity=".28"/><path d="M1 10l7-1" stroke="#d8cfb8" stroke-width=".35" opacity=".35"/></pattern>
|
||||
</defs>
|
||||
<rect x="2" y="2" width="140" height="146" rx="3" fill="url(#paper)" stroke="#242822" stroke-width="2"/>
|
||||
<text x="72" y="19" text-anchor="middle" font-family="Trebuchet MS,Arial,sans-serif" font-weight="700" font-size="14" fill="#20251f">Treasure</text>
|
||||
<g filter="url(#w)" stroke="#252923" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M35 67q37-25 74 0v20H35Z" fill="#6c3eae"/><path d="M31 87h82v34H31Z" fill="#6d4a2b"/><path d="M35 93h74M51 88v32m42-32v32" fill="none" stroke="#e8c068"/><rect x="65" y="84" width="14" height="18" rx="2" fill="#f1c64d"/><circle cx="72" cy="78" r="7" fill="#ffd84d"/><path d="M43 91l-12 17m69-17l13 16M56 91l-5 24m37-24l6 23" fill="none" stroke="#d69d23"/></g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Treasure token">
|
||||
<defs>
|
||||
<filter id="w"><feTurbulence baseFrequency=".035" numOctaves="2" seed="4" result="n"/><feDisplacementMap in="SourceGraphic" in2="n" scale=".65"/></filter>
|
||||
<filter id="s"><feGaussianBlur stdDeviation="2"/></filter>
|
||||
<pattern id="paper" width="12" height="12" patternUnits="userSpaceOnUse"><rect width="12" height="12" fill="#f4efd9"/><circle cx="2" cy="4" r=".45" fill="#c8bfa4" opacity=".28"/><path d="M1 10l7-1" stroke="#d8cfb8" stroke-width=".35" opacity=".35"/></pattern>
|
||||
</defs>
|
||||
<rect x="2" y="2" width="140" height="146" rx="3" fill="url(#paper)" stroke="#242822" stroke-width="2"/>
|
||||
<text x="72" y="19" text-anchor="middle" font-family="Trebuchet MS,Arial,sans-serif" font-weight="700" font-size="14" fill="#20251f">Treasure</text>
|
||||
<g filter="url(#w)" stroke="#252923" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M35 67q37-25 74 0v20H35Z" fill="#2389a9"/><path d="M31 87h82v34H31Z" fill="#6d4a2b"/><path d="M35 93h74M51 88v32m42-32v32" fill="none" stroke="#e8c068"/><rect x="65" y="84" width="14" height="18" rx="2" fill="#f1c64d"/><circle cx="72" cy="78" r="7" fill="#ffd84d"/><path d="M43 91l-12 17m69-17l13 16M56 91l-5 24m37-24l6 23" fill="none" stroke="#d69d23"/></g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Treasure token">
|
||||
<defs>
|
||||
<filter id="w"><feTurbulence baseFrequency=".035" numOctaves="2" seed="4" result="n"/><feDisplacementMap in="SourceGraphic" in2="n" scale=".65"/></filter>
|
||||
<filter id="s"><feGaussianBlur stdDeviation="2"/></filter>
|
||||
<pattern id="paper" width="12" height="12" patternUnits="userSpaceOnUse"><rect width="12" height="12" fill="#f4efd9"/><circle cx="2" cy="4" r=".45" fill="#c8bfa4" opacity=".28"/><path d="M1 10l7-1" stroke="#d8cfb8" stroke-width=".35" opacity=".35"/></pattern>
|
||||
</defs>
|
||||
<rect x="2" y="2" width="140" height="146" rx="3" fill="url(#paper)" stroke="#242822" stroke-width="2"/>
|
||||
<text x="72" y="19" text-anchor="middle" font-family="Trebuchet MS,Arial,sans-serif" font-weight="700" font-size="14" fill="#20251f">Treasure</text>
|
||||
<g filter="url(#w)" stroke="#252923" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M35 67q37-25 74 0v20H35Z" fill="#e3bd26"/><path d="M31 87h82v34H31Z" fill="#6d4a2b"/><path d="M35 93h74M51 88v32m42-32v32" fill="none" stroke="#e8c068"/><rect x="65" y="84" width="14" height="18" rx="2" fill="#f1c64d"/></g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Troll token">
|
||||
<defs>
|
||||
<filter id="w"><feTurbulence baseFrequency=".035" numOctaves="2" seed="4" result="n"/><feDisplacementMap in="SourceGraphic" in2="n" scale=".65"/></filter>
|
||||
<filter id="s"><feGaussianBlur stdDeviation="2"/></filter>
|
||||
<pattern id="paper" width="12" height="12" patternUnits="userSpaceOnUse"><rect width="12" height="12" fill="#f4efd9"/><circle cx="2" cy="4" r=".45" fill="#c8bfa4" opacity=".28"/><path d="M1 10l7-1" stroke="#d8cfb8" stroke-width=".35" opacity=".35"/></pattern>
|
||||
</defs>
|
||||
<rect x="2" y="2" width="140" height="146" rx="3" fill="url(#paper)" stroke="#242822" stroke-width="2"/>
|
||||
<text x="72" y="19" text-anchor="middle" font-family="Trebuchet MS,Arial,sans-serif" font-weight="700" font-size="14" fill="#20251f">Troll</text>
|
||||
<g filter="url(#w)" stroke="#252923" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M30 112q8-46 30-45-9-25 14-31 27 3 18 31 24 3 25 44Z" fill="#d4d5b8"/><path d="M49 53l-17-15 7 27m56-12l18-15-8 28" fill="#d4d5b8"/><circle cx="62" cy="71" r="4"/><circle cx="85" cy="71" r="4"/><path d="M49 91q24 16 48 0" fill="none"/><path d="M52 94l7 11 7-8 8 12 8-12 8 9 7-13" fill="#fff"/><path d="M48 47q24-25 50 0" fill="none" stroke="#64328f" stroke-width="10"/></g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Wizard token">
|
||||
<defs>
|
||||
<filter id="w"><feTurbulence baseFrequency=".035" numOctaves="2" seed="4" result="n"/><feDisplacementMap in="SourceGraphic" in2="n" scale=".65"/></filter>
|
||||
<filter id="s"><feGaussianBlur stdDeviation="2"/></filter>
|
||||
<pattern id="paper" width="12" height="12" patternUnits="userSpaceOnUse"><rect width="12" height="12" fill="#f4efd9"/><circle cx="2" cy="4" r=".45" fill="#c8bfa4" opacity=".28"/><path d="M1 10l7-1" stroke="#d8cfb8" stroke-width=".35" opacity=".35"/></pattern>
|
||||
</defs>
|
||||
<rect x="2" y="2" width="140" height="146" rx="3" fill="url(#paper)" stroke="#242822" stroke-width="2"/>
|
||||
<text x="72" y="19" text-anchor="middle" font-family="Trebuchet MS,Arial,sans-serif" font-weight="700" font-size="14" fill="#20251f">Wizard</text>
|
||||
<g filter="url(#w)" stroke="#252923" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M41 124q31-28 62 0" fill="#23764b"/><ellipse cx="72" cy="76" rx="25" ry="30" fill="#edc9a8"/>
|
||||
<path d="M46 66q25-32 51 0v-8L38 25 38 61Z" fill="#23764b"/><path d="M49 69q23-14 47 0" fill="none" stroke="#f4d467" stroke-width="4"/>
|
||||
<path d="M52 74q8-11 16 0m9 0q8-11 15 0" fill="none"/><circle cx="63" cy="78" r="2.3"/><circle cx="84" cy="78" r="2.3"/>
|
||||
<path d="M73 81q-7 9 2 11m-12 8q11 5 22-1" fill="none"/><path d="M48 73q-7 21 8 35M96 71q8 23-9 38" fill="none" stroke="#e9e2c8" stroke-width="8"/>
|
||||
<path d="M40 56l-10-7m72 9l12-7M35 84l-13 3m83-2l15 4" fill="none" stroke="#23764b"/></g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Wizard token">
|
||||
<defs>
|
||||
<filter id="w"><feTurbulence baseFrequency=".035" numOctaves="2" seed="4" result="n"/><feDisplacementMap in="SourceGraphic" in2="n" scale=".65"/></filter>
|
||||
<filter id="s"><feGaussianBlur stdDeviation="2"/></filter>
|
||||
<pattern id="paper" width="12" height="12" patternUnits="userSpaceOnUse"><rect width="12" height="12" fill="#f4efd9"/><circle cx="2" cy="4" r=".45" fill="#c8bfa4" opacity=".28"/><path d="M1 10l7-1" stroke="#d8cfb8" stroke-width=".35" opacity=".35"/></pattern>
|
||||
</defs>
|
||||
<rect x="2" y="2" width="140" height="146" rx="3" fill="url(#paper)" stroke="#242822" stroke-width="2"/>
|
||||
<text x="72" y="19" text-anchor="middle" font-family="Trebuchet MS,Arial,sans-serif" font-weight="700" font-size="14" fill="#20251f">Wizard</text>
|
||||
<g filter="url(#w)" stroke="#252923" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M41 124q31-28 62 0" fill="#d54835"/><ellipse cx="72" cy="76" rx="25" ry="30" fill="#edc9a8"/>
|
||||
<path d="M46 66q25-32 51 0v-8L45 25 38 61Z" fill="#d54835"/><path d="M49 69q23-14 47 0" fill="none" stroke="#f4d467" stroke-width="4"/>
|
||||
<path d="M52 74q8-11 16 0m9 0q8-11 15 0" fill="none"/><circle cx="63" cy="78" r="2.3"/><circle cx="84" cy="78" r="2.3"/>
|
||||
<path d="M73 81q-7 9 2 11m-12 8q11 6 22-1" fill="none"/><path d="M48 73q-7 21 8 35M96 71q8 23-9 38" fill="none" stroke="#7e2b1f" stroke-width="8"/>
|
||||
<path d="M40 56l-10-7m72 9l12-7M35 84l-13 3m83-2l15 4" fill="none" stroke="#d54835"/></g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Wizard token">
|
||||
<defs>
|
||||
<filter id="w"><feTurbulence baseFrequency=".035" numOctaves="2" seed="4" result="n"/><feDisplacementMap in="SourceGraphic" in2="n" scale=".65"/></filter>
|
||||
<filter id="s"><feGaussianBlur stdDeviation="2"/></filter>
|
||||
<pattern id="paper" width="12" height="12" patternUnits="userSpaceOnUse"><rect width="12" height="12" fill="#f4efd9"/><circle cx="2" cy="4" r=".45" fill="#c8bfa4" opacity=".28"/><path d="M1 10l7-1" stroke="#d8cfb8" stroke-width=".35" opacity=".35"/></pattern>
|
||||
</defs>
|
||||
<rect x="2" y="2" width="140" height="146" rx="3" fill="url(#paper)" stroke="#242822" stroke-width="2"/>
|
||||
<text x="72" y="19" text-anchor="middle" font-family="Trebuchet MS,Arial,sans-serif" font-weight="700" font-size="14" fill="#20251f">Wizard</text>
|
||||
<g filter="url(#w)" stroke="#252923" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M41 124q31-28 62 0" fill="#b62f75"/><ellipse cx="72" cy="76" rx="25" ry="30" fill="#edc9a8"/>
|
||||
<path d="M46 66q25-32 51 0v-8L52 25 38 61Z" fill="#b62f75"/><path d="M49 69q23-14 47 0" fill="none" stroke="#f4d467" stroke-width="4"/>
|
||||
<path d="M52 74q8-11 16 0m9 0q8-11 15 0" fill="none"/><circle cx="63" cy="78" r="2.3"/><circle cx="84" cy="78" r="2.3"/>
|
||||
<path d="M73 81q-7 9 2 11m-12 8q11 7 22-1" fill="none"/><path d="M48 73q-7 21 8 35M96 71q8 23-9 38" fill="none" stroke="#9c235d" stroke-width="8"/>
|
||||
<path d="M40 56l-10-7m72 9l12-7M35 84l-13 3m83-2l15 4" fill="none" stroke="#b62f75"/></g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Wizard token">
|
||||
<defs>
|
||||
<filter id="w"><feTurbulence baseFrequency=".035" numOctaves="2" seed="4" result="n"/><feDisplacementMap in="SourceGraphic" in2="n" scale=".65"/></filter>
|
||||
<filter id="s"><feGaussianBlur stdDeviation="2"/></filter>
|
||||
<pattern id="paper" width="12" height="12" patternUnits="userSpaceOnUse"><rect width="12" height="12" fill="#f4efd9"/><circle cx="2" cy="4" r=".45" fill="#c8bfa4" opacity=".28"/><path d="M1 10l7-1" stroke="#d8cfb8" stroke-width=".35" opacity=".35"/></pattern>
|
||||
</defs>
|
||||
<rect x="2" y="2" width="140" height="146" rx="3" fill="url(#paper)" stroke="#242822" stroke-width="2"/>
|
||||
<text x="72" y="19" text-anchor="middle" font-family="Trebuchet MS,Arial,sans-serif" font-weight="700" font-size="14" fill="#20251f">Wizard</text>
|
||||
<g filter="url(#w)" stroke="#252923" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M41 124q31-28 62 0" fill="#29345f"/><ellipse cx="72" cy="76" rx="25" ry="30" fill="#edc9a8"/>
|
||||
<path d="M46 66q25-32 51 0v-8L38 25 38 61Z" fill="#29345f"/><path d="M49 69q23-14 47 0" fill="none" stroke="#f4d467" stroke-width="4"/>
|
||||
<path d="M52 74q8-11 16 0m9 0q8-11 15 0" fill="none"/><circle cx="63" cy="78" r="2.3"/><circle cx="84" cy="78" r="2.3"/>
|
||||
<path d="M73 81q-7 9 2 11m-12 8q11 5 22-1" fill="none"/><path d="M48 73q-7 21 8 35M96 71q8 23-9 38" fill="none" stroke="#25282b" stroke-width="8"/>
|
||||
<path d="M40 56l-10-7m72 9l12-7M35 84l-13 3m83-2l15 4" fill="none" stroke="#29345f"/></g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Wizard token">
|
||||
<defs>
|
||||
<filter id="w"><feTurbulence baseFrequency=".035" numOctaves="2" seed="4" result="n"/><feDisplacementMap in="SourceGraphic" in2="n" scale=".65"/></filter>
|
||||
<filter id="s"><feGaussianBlur stdDeviation="2"/></filter>
|
||||
<pattern id="paper" width="12" height="12" patternUnits="userSpaceOnUse"><rect width="12" height="12" fill="#f4efd9"/><circle cx="2" cy="4" r=".45" fill="#c8bfa4" opacity=".28"/><path d="M1 10l7-1" stroke="#d8cfb8" stroke-width=".35" opacity=".35"/></pattern>
|
||||
</defs>
|
||||
<rect x="2" y="2" width="140" height="146" rx="3" fill="url(#paper)" stroke="#242822" stroke-width="2"/>
|
||||
<text x="72" y="19" text-anchor="middle" font-family="Trebuchet MS,Arial,sans-serif" font-weight="700" font-size="14" fill="#20251f">Wizard</text>
|
||||
<g filter="url(#w)" stroke="#252923" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M41 124q31-28 62 0" fill="#1683a6"/><ellipse cx="72" cy="76" rx="25" ry="30" fill="#edc9a8"/>
|
||||
<path d="M46 66q25-32 51 0v-8L45 25 38 61Z" fill="#1683a6"/><path d="M49 69q23-14 47 0" fill="none" stroke="#f4d467" stroke-width="4"/>
|
||||
<path d="M52 74q8-11 16 0m9 0q8-11 15 0" fill="none"/><circle cx="63" cy="78" r="2.3"/><circle cx="84" cy="78" r="2.3"/>
|
||||
<path d="M73 81q-7 9 2 11m-12 8q11 6 22-1" fill="none"/><path d="M48 73q-7 21 8 35M96 71q8 23-9 38" fill="none" stroke="#e7e2cf" stroke-width="8"/>
|
||||
<path d="M40 56l-10-7m72 9l12-7M35 84l-13 3m83-2l15 4" fill="none" stroke="#1683a6"/></g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Wizard token">
|
||||
<defs>
|
||||
<filter id="w"><feTurbulence baseFrequency=".035" numOctaves="2" seed="4" result="n"/><feDisplacementMap in="SourceGraphic" in2="n" scale=".65"/></filter>
|
||||
<filter id="s"><feGaussianBlur stdDeviation="2"/></filter>
|
||||
<pattern id="paper" width="12" height="12" patternUnits="userSpaceOnUse"><rect width="12" height="12" fill="#f4efd9"/><circle cx="2" cy="4" r=".45" fill="#c8bfa4" opacity=".28"/><path d="M1 10l7-1" stroke="#d8cfb8" stroke-width=".35" opacity=".35"/></pattern>
|
||||
</defs>
|
||||
<rect x="2" y="2" width="140" height="146" rx="3" fill="url(#paper)" stroke="#242822" stroke-width="2"/>
|
||||
<text x="72" y="19" text-anchor="middle" font-family="Trebuchet MS,Arial,sans-serif" font-weight="700" font-size="14" fill="#20251f">Wizard</text>
|
||||
<g filter="url(#w)" stroke="#252923" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M41 124q31-28 62 0" fill="#e0a733"/><ellipse cx="72" cy="76" rx="25" ry="30" fill="#edc9a8"/>
|
||||
<path d="M46 66q25-32 51 0v-8L52 25 38 61Z" fill="#e0a733"/><path d="M49 69q23-14 47 0" fill="none" stroke="#f4d467" stroke-width="4"/>
|
||||
<path d="M52 74q8-11 16 0m9 0q8-11 15 0" fill="none"/><circle cx="63" cy="78" r="2.3"/><circle cx="84" cy="78" r="2.3"/>
|
||||
<path d="M73 81q-7 9 2 11m-12 8q11 7 22-1" fill="none"/><path d="M48 73q-7 21 8 35M96 71q8 23-9 38" fill="none" stroke="#292723" stroke-width="8"/>
|
||||
<path d="M40 56l-10-7m72 9l12-7M35 84l-13 3m83-2l15 4" fill="none" stroke="#e0a733"/></g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Wraith token">
|
||||
<defs>
|
||||
<filter id="w"><feTurbulence baseFrequency=".035" numOctaves="2" seed="4" result="n"/><feDisplacementMap in="SourceGraphic" in2="n" scale=".65"/></filter>
|
||||
<filter id="s"><feGaussianBlur stdDeviation="2"/></filter>
|
||||
<pattern id="paper" width="12" height="12" patternUnits="userSpaceOnUse"><rect width="12" height="12" fill="#f4efd9"/><circle cx="2" cy="4" r=".45" fill="#c8bfa4" opacity=".28"/><path d="M1 10l7-1" stroke="#d8cfb8" stroke-width=".35" opacity=".35"/></pattern>
|
||||
</defs>
|
||||
<rect x="2" y="2" width="140" height="146" rx="3" fill="url(#paper)" stroke="#242822" stroke-width="2"/>
|
||||
<text x="72" y="19" text-anchor="middle" font-family="Trebuchet MS,Arial,sans-serif" font-weight="700" font-size="14" fill="#20251f">Wraith</text>
|
||||
<g filter="url(#w)" stroke="#252923" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M72 38q24 2 25 30 22 9 18 32-13-7-20 7-9 25-23 30-15-7-22-30-8-15-22-7-4-23 19-32 0-27 25-30Z" fill="#172521"/><circle cx="64" cy="63" r="4" fill="#f5edc4" stroke="none"/><circle cx="81" cy="63" r="4" fill="#f5edc4" stroke="none"/><path d="M46 83L20 96m79-13l25 13" fill="none" stroke="#172521" stroke-width="13"/></g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 33 KiB |
@@ -1,7 +1,16 @@
|
||||
<script lang="ts">
|
||||
import type { GameView } from "@wizwar/engine";
|
||||
import type { Component } from "svelte";
|
||||
import type { GameView, SightTrace } from "@wizwar/engine";
|
||||
import type { Side } from "@wizwar/engine";
|
||||
import { dreadDistance } from "@wizwar/engine";
|
||||
import type { BoardFx } from "./fx";
|
||||
import { colorIndexOf as sharedColorIndex, wizardColor } from "./colors";
|
||||
import { CREATURE_ART, objectArt, TERRAIN_ART, tokenArt } from "./art";
|
||||
import EdgeGlyph, { type LockState } from "./EdgeGlyph.svelte";
|
||||
import FirewallEdge from "./FirewallEdge.svelte";
|
||||
import IllusionShimmer from "./IllusionShimmer.svelte";
|
||||
import SightTraceOverlay from "./SightTraceOverlay.svelte";
|
||||
import TokenArt from "./TokenArt.svelte";
|
||||
import { FX_SPRITES } from "./fx-sprites";
|
||||
|
||||
const CELL = 48;
|
||||
@@ -17,6 +26,8 @@
|
||||
onCreatureClick,
|
||||
onWarpClick,
|
||||
onCellPeek,
|
||||
onIllusionClick,
|
||||
onEdgePeek,
|
||||
markedCell = null,
|
||||
markedCells = null,
|
||||
litCells = null,
|
||||
@@ -24,6 +35,7 @@
|
||||
ghostSlots = null,
|
||||
onGhostClick,
|
||||
effects = null,
|
||||
sightTrace = null,
|
||||
}: {
|
||||
view: GameView;
|
||||
edgeSelectMode?: boolean;
|
||||
@@ -35,6 +47,10 @@
|
||||
onWarpClick?: (cell: { x: number; y: number }, side: Side) => void;
|
||||
/** Long-press on a square: read the card behind whatever occupies it. */
|
||||
onCellPeek?: (cell: { x: number; y: number }) => void;
|
||||
/** Tap on a shimmering (untested) illusion wall: offer the belief test. */
|
||||
onIllusionClick?: (cell: { x: number; y: number }, side: Side) => void;
|
||||
/** Press-and-hold a wall/door/fire segment: its tooltip, for touch. */
|
||||
onEdgePeek?: (tip: string) => void;
|
||||
/** First square of a two-square spell: marked so the click reads as taken. */
|
||||
markedCell?: { x: number; y: number } | null;
|
||||
/** A multi-square placement in progress (boobytrap tokens), in click
|
||||
@@ -49,11 +65,13 @@
|
||||
ghostSlots?: { x: number; y: number }[] | null;
|
||||
onGhostClick?: (origin: { x: number; y: number }) => void;
|
||||
/** Short-lived spell flourishes; purely cosmetic. */
|
||||
effects?: import("./fx").BoardFx[] | null;
|
||||
effects?: BoardFx[] | null;
|
||||
/** The sight line an attack in progress traveled — proof against "how
|
||||
* can he even see me?", drawn leg by leg through any warp mouth. */
|
||||
sightTrace?: { from: { x: number; y: number }; to: { x: number; y: number }; trace: SightTrace } | null;
|
||||
} = $props();
|
||||
|
||||
|
||||
|
||||
const SECTOR = 5;
|
||||
/** Ghost slots can lie beyond the assembled maze — on any side, including
|
||||
* negative coordinates (the maze renormalizes after the landing). The
|
||||
@@ -88,24 +106,6 @@
|
||||
// Real token art, cropped from the owner's physical set (public/tokens/).
|
||||
// Anything without an entry falls back to the vector rendering below.
|
||||
const USE_TOKEN_ART = true;
|
||||
const TERRAIN_ART: Record<string, string> = {
|
||||
stone: "solid-stone", thornbush: "thorn-bush", rosebush: "rosebush",
|
||||
ooze: "killer-ooze", dust: "dustcloud", slime: "slime",
|
||||
tacks: "tacks", pit: "pit", safe: "safe",
|
||||
};
|
||||
const CREATURE_ART: Record<string, string> = {
|
||||
skeleton: "skeleton", troll: "troll", wraith: "wraith",
|
||||
"fire-imp": "fire-imp", "democratic-monster": "democratic-monster",
|
||||
shadow: "shadow", "alter-ego": "alter-ego",
|
||||
};
|
||||
function objectArt(cardId: string): string | null {
|
||||
if (cardId === "dagger") return "dagger";
|
||||
if (cardId === "large-rock") return "rock";
|
||||
if (cardId === "master-key") return "master-key";
|
||||
if (cardId.endsWith("stone")) return "magic-stone";
|
||||
if (cardId.endsWith("-wand")) return "magic-wand";
|
||||
return null;
|
||||
}
|
||||
// Ongoing spells wear their look: ghostly when unseen, webbed when caught,
|
||||
// stone-gray under Medusa's gaze.
|
||||
function hasSpell(id: string, cardId: string): boolean {
|
||||
@@ -119,10 +119,10 @@
|
||||
return 1;
|
||||
}
|
||||
function wizardArt(id: string): string {
|
||||
return `/tokens/wizard-${sharedColorIndex(view, id) % 6}.png`;
|
||||
return tokenArt(`wizard-${sharedColorIndex(view, id) % 6}`, "players");
|
||||
}
|
||||
function treasureArt(owner: string): string {
|
||||
return `/tokens/treasure-${sharedColorIndex(view, owner) % 6}.png`;
|
||||
return tokenArt(`treasure-${sharedColorIndex(view, owner) % 6}`, "objects");
|
||||
}
|
||||
|
||||
function playerColor(id: string): string {
|
||||
@@ -145,7 +145,9 @@
|
||||
// A door's lock can be gone for good, jammed shut, or picked open
|
||||
// for the turn — each earns its own look.
|
||||
const lock = state === "door"
|
||||
? (view.doorStates[key] ?? (view.openDoorEdges.includes(key) ? "ajar" : null))
|
||||
? (view.doorStates[key] ??
|
||||
(view.heldDoorEdges.includes(key) ? "held"
|
||||
: view.openDoorEdges.includes(key) ? "ajar" : null))
|
||||
: null;
|
||||
return { kind, x, y, state, lock };
|
||||
}),
|
||||
@@ -163,9 +165,39 @@
|
||||
boxes.push({ cell: c, side: "S", x: c.x * CELL + 4, y: (c.y + 1) * CELL - 6, w: CELL - 8, h: 12 });
|
||||
}
|
||||
}
|
||||
// Warp mouths sit on the perimeter, with no far cell: still wallable.
|
||||
const seen = new Set(boxes.map((b) => `${b.cell.x},${b.cell.y},${b.side}`));
|
||||
for (const w of view.board.warps) {
|
||||
const c = w.from.cell;
|
||||
const side = w.from.side;
|
||||
const k = `${c.x},${c.y},${side}`;
|
||||
if (seen.has(k)) continue;
|
||||
seen.add(k);
|
||||
if (side === "E") boxes.push({ cell: c, side, x: (c.x + 1) * CELL - 6, y: c.y * CELL + 4, w: 12, h: CELL - 8 });
|
||||
if (side === "W") boxes.push({ cell: c, side, x: c.x * CELL - 6, y: c.y * CELL + 4, w: 12, h: CELL - 8 });
|
||||
if (side === "S") boxes.push({ cell: c, side, x: c.x * CELL + 4, y: (c.y + 1) * CELL - 6, w: CELL - 8, h: 12 });
|
||||
if (side === "N") boxes.push({ cell: c, side, x: c.x * CELL + 4, y: c.y * CELL - 6, w: CELL - 8, h: 12 });
|
||||
}
|
||||
return boxes;
|
||||
});
|
||||
|
||||
// FEAR's dread: three spaces in every direction, straight through walls
|
||||
// ("even if walls separate you"), never diagonally — and the maze wraps,
|
||||
// so the diamond continues from the opposite rim. The engine's own
|
||||
// yardstick, so the aura and the refusal can never disagree.
|
||||
const fearCells = $derived.by(() => {
|
||||
const out = new Set<string>();
|
||||
for (const fp of view.players) {
|
||||
if (!fp.alive) continue;
|
||||
if (!view.sustained.some((e) => e.cardId === "fear" && e.targetId === fp.id)) continue;
|
||||
for (const k of Object.keys(view.board.cells)) {
|
||||
const [x, y] = k.split(",").map(Number) as [number, number];
|
||||
if (dreadDistance(view.board, fp.position, { x, y }) <= 3) out.add(k);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
// Warp pairs share a letter, like the printed openings on the real boards.
|
||||
const warpLetters = $derived.by(() => {
|
||||
const letters = new Map<string, string>();
|
||||
@@ -180,6 +212,27 @@
|
||||
return letters;
|
||||
});
|
||||
|
||||
// Tokens glide between adjacent squares; anything farther — teleports,
|
||||
// warps, drags, sector moves — snaps, because sliding a wizard across the
|
||||
// whole maze would tell a false story. Keyed by identity so the DOM node
|
||||
// survives the move.
|
||||
const lastAt = new Map<string, { x: number; y: number }>();
|
||||
function snapsTo(id: string, x: number, y: number): boolean {
|
||||
const prev = lastAt.get(id);
|
||||
lastAt.set(id, { x, y });
|
||||
if (!prev) return true;
|
||||
return Math.abs(prev.x - x) + Math.abs(prev.y - y) > CELL * 1.6;
|
||||
}
|
||||
|
||||
const creaturesByCell = $derived.by(() => {
|
||||
const map = new Map<string, typeof view.creatures>();
|
||||
for (const c of view.creatures) {
|
||||
const k = `${c.position.x},${c.position.y}`;
|
||||
map.set(k, [...(map.get(k) ?? []), c]);
|
||||
}
|
||||
return map;
|
||||
});
|
||||
|
||||
// Group players by cell so co-located wizards fan out.
|
||||
const wizardsByCell = $derived.by(() => {
|
||||
const map = new Map<string, typeof view.players>();
|
||||
@@ -253,12 +306,11 @@
|
||||
{@const tx = t.position.x * CELL + CELL / 2 + (group.length > 1 ? (ti - (group.length - 1) / 2) * CELL * 0.3 : 0)}
|
||||
{@const ty = t.position.y * CELL + CELL * 0.72}
|
||||
{#if USE_TOKEN_ART}
|
||||
<image
|
||||
<TokenArt
|
||||
href={treasureArt(t.owner)}
|
||||
x={tx - CELL * 0.26} y={ty - CELL * 0.26}
|
||||
width={CELL * 0.52} height={CELL * 0.52}
|
||||
preserveAspectRatio="xMidYMid slice"
|
||||
class="token-art small"
|
||||
cls="token-art small"
|
||||
/>
|
||||
{:else}
|
||||
<g class="treasure-g">
|
||||
@@ -276,12 +328,11 @@
|
||||
{@const sx = Number(key.split(",")[0])}
|
||||
{@const sy = Number(key.split(",")[1])}
|
||||
{#if USE_TOKEN_ART && TERRAIN_ART[content.kind]}
|
||||
<image
|
||||
href={`/tokens/${TERRAIN_ART[content.kind]}.png`}
|
||||
<TokenArt
|
||||
href={tokenArt(TERRAIN_ART[content.kind]!, "terrain")}
|
||||
x={sx * CELL + 3} y={sy * CELL + 3}
|
||||
width={CELL - 6} height={CELL - 6}
|
||||
preserveAspectRatio="xMidYMid slice"
|
||||
class="token-art"
|
||||
cls="token-art"
|
||||
/>
|
||||
{:else if content.kind === "stone"}
|
||||
<rect x={sx * CELL + 2} y={sy * CELL + 2} width={CELL - 4} height={CELL - 4} class="stone" rx="4" />
|
||||
@@ -306,11 +357,11 @@
|
||||
{#each view.dimWarps as w, wi (wi)}
|
||||
{#each [w.a, w.b] as tok, i (i)}
|
||||
{#if USE_TOKEN_ART}
|
||||
<image
|
||||
href="/tokens/dimensional-warp.png"
|
||||
<TokenArt
|
||||
href={tokenArt("dimensional-warp", "terrain")}
|
||||
x={tok.x * CELL + CELL * 0.04} y={tok.y * CELL + CELL * 0.04}
|
||||
width={CELL * 0.44} height={CELL * 0.44}
|
||||
preserveAspectRatio="xMidYMid slice" class="token-art"
|
||||
cls="token-art"
|
||||
/>
|
||||
{:else}
|
||||
<circle cx={tok.x * CELL + CELL * 0.5} cy={tok.y * CELL + CELL * 0.5} r={CELL * 0.3}
|
||||
@@ -335,7 +386,7 @@
|
||||
{@const art = USE_TOKEN_ART ? objectArt(o.cardId) : null}
|
||||
{#if art}
|
||||
<image
|
||||
href={`/tokens/${art}.png`}
|
||||
href={tokenArt(art, "objects")}
|
||||
x={gx * CELL + 4 + i * 9} y={gy * CELL + CELL - CELL * 0.42 - 3}
|
||||
width={CELL * 0.4} height={CELL * 0.4}
|
||||
preserveAspectRatio="xMidYMid slice"
|
||||
@@ -356,22 +407,18 @@
|
||||
|
||||
<!-- walls & doors & firewalls -->
|
||||
{#each edges as e (`${e.kind}:${e.x},${e.y}`)}
|
||||
{@const cls = e.state === "door" ? `door${e.lock ? ` ${e.lock}` : ""}` : e.state === "firewall" ? "firewall" : "wall"}
|
||||
{#if e.state === "firewall"}
|
||||
<FirewallEdge x={e.x} y={e.y} kind={e.kind === "V" ? "V" : "H"} onpeek={onEdgePeek} />
|
||||
{:else}
|
||||
{@const lockTitle = e.lock === "removed" ? "lock removed — swings free"
|
||||
: e.lock === "jammed" ? "lock jammed — sealed for good"
|
||||
: e.lock === "held" ? "held open by a standing wizard"
|
||||
: e.lock === "ajar" ? "unlocked until end of turn" : null}
|
||||
{#if e.kind === "V"}
|
||||
<rect
|
||||
x={(e.x + 1) * CELL - WALL / 2} y={e.y * CELL - WALL / 2}
|
||||
width={WALL} height={CELL + WALL}
|
||||
class={cls}
|
||||
>{#if lockTitle}<title>{lockTitle}</title>{/if}</rect>
|
||||
{:else}
|
||||
<rect
|
||||
x={e.x * CELL - WALL / 2} y={(e.y + 1) * CELL - WALL / 2}
|
||||
width={CELL + WALL} height={WALL}
|
||||
class={cls}
|
||||
>{#if lockTitle}<title>{lockTitle}</title>{/if}</rect>
|
||||
<EdgeGlyph x={e.x} y={e.y} kind={e.kind === "V" ? "V" : "H"}
|
||||
state={e.state === "door" ? "door" : "wall"}
|
||||
lock={(e.lock ?? null) as LockState | null}
|
||||
title={lockTitle} damage={view.wallDamage[`${e.kind}:${e.x},${e.y}`] ?? 0}
|
||||
onpeek={onEdgePeek} />
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
@@ -383,10 +430,10 @@
|
||||
{@const frac = Math.min(1, dmg / 20)}
|
||||
{#if kind === "V"}
|
||||
<line x1={(wx + 1) * CELL} y1={wy * CELL + 3} x2={(wx + 1) * CELL} y2={(wy + 1) * CELL - 3}
|
||||
class="crack" style:opacity={0.35 + frac * 0.65} />
|
||||
class="crack" style:opacity={0.35 + frac * 0.65}><title>battle-scarred — {dmg} damage taken</title></line>
|
||||
{:else}
|
||||
<line x1={wx * CELL + 3} y1={(wy + 1) * CELL} x2={(wx + 1) * CELL - 3} y2={(wy + 1) * CELL}
|
||||
class="crack" style:opacity={0.35 + frac * 0.65} />
|
||||
class="crack" style:opacity={0.35 + frac * 0.65}><title>battle-scarred — {dmg} damage taken</title></line>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
@@ -396,9 +443,20 @@
|
||||
{@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" />
|
||||
<line x1={(ix + 1) * CELL} y1={iy * CELL} x2={(ix + 1) * CELL} y2={(iy + 1) * CELL} class="illusion"><title>an illusion — your eyes see through it</title></line>
|
||||
{:else}
|
||||
<line x1={ix * CELL} y1={(iy + 1) * CELL} x2={(ix + 1) * CELL} y2={(iy + 1) * CELL} class="illusion" />
|
||||
<line x1={ix * CELL} y1={(iy + 1) * CELL} x2={(ix + 1) * CELL} y2={(iy + 1) * CELL} class="illusion"><title>an illusion — your eyes see through it</title></line>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<!-- untested illusions: the wall stands, but it shimmers — click to doubt it -->
|
||||
{#each Object.entries(view.illusionEdges) as [key, verdict] (key)}
|
||||
{#if verdict === "untested"}
|
||||
{@const kind = key.split(":")[0] === "V" ? "V" as const : "H" as const}
|
||||
{@const ix = Number(key.split(":")[1]?.split(",")[0])}
|
||||
{@const iy = Number(key.split(":")[1]?.split(",")[1])}
|
||||
<IllusionShimmer x={ix} y={iy} {kind}
|
||||
onclick={() => onIllusionClick?.({ x: ix, y: iy }, kind === "V" ? "E" : "S")} />
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
@@ -468,11 +526,13 @@
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- wizards -->
|
||||
{#each [...wizardsByCell.entries()] as [key, group] (key)}
|
||||
{#each group as p, i (p.id)}
|
||||
{@const cx = p.position.x * CELL + CELL / 2 + (group.length > 1 ? (i - (group.length - 1) / 2) * 14 : 0)}
|
||||
<!-- wizards: identity-keyed movers, so steps glide square to square -->
|
||||
{#each view.players.filter((p) => p.alive) as p (p.id)}
|
||||
{@const group = wizardsByCell.get(`${p.position.x},${p.position.y}`) ?? [p]}
|
||||
{@const gi = group.findIndex((q) => q.id === p.id)}
|
||||
{@const cx = p.position.x * CELL + CELL / 2 + (group.length > 1 ? (gi - (group.length - 1) / 2) * 14 : 0)}
|
||||
{@const cy = p.position.y * CELL + CELL * 0.36}
|
||||
<g class="mover" class:snap={snapsTo(`w:${p.id}`, cx, cy)} style={`transform: translate(${cx}px, ${cy}px)`}>
|
||||
<g
|
||||
role="button" tabindex="-1"
|
||||
onclick={(ev) => { ev.stopPropagation(); if (peekFired) { peekFired = false; return; } onPlayerClick?.(p.id); }}
|
||||
@@ -484,47 +544,54 @@
|
||||
>
|
||||
{#if USE_TOKEN_ART}
|
||||
{@const half = CELL * 0.3 * wizardScale(p.id)}
|
||||
<image
|
||||
<TokenArt
|
||||
href={wizardArt(p.id)}
|
||||
x={cx - half} y={cy - half}
|
||||
x={-half} y={-half}
|
||||
width={half * 2} height={half * 2}
|
||||
preserveAspectRatio="xMidYMid slice"
|
||||
class="token-art hit"
|
||||
class:ghosted={hasSpell(p.id, "invisible") || hasSpell(p.id, "mist-body")}
|
||||
class:stone-gazed={hasSpell(p.id, "medusa")}
|
||||
cls={`token-art hit${hasSpell(p.id, "invisible") || hasSpell(p.id, "mist-body") ? " ghosted" : ""}${hasSpell(p.id, "medusa") ? " stone-gazed" : ""}`}
|
||||
/>
|
||||
<rect
|
||||
x={cx - half} y={cy - half}
|
||||
x={-half} y={-half}
|
||||
width={half * 2} height={half * 2}
|
||||
class="wizard-ring" stroke={playerColor(p.id)}
|
||||
/>
|
||||
{#if hasSpell(p.id, "sticky-web")}
|
||||
<g class="webbing">
|
||||
<line x1={cx - half} y1={cy - half * 0.4} x2={cx + half} y2={cy + half * 0.5} />
|
||||
<line x1={cx - half * 0.6} y1={cy + half} x2={cx + half * 0.7} y2={cy - half} />
|
||||
<line x1={cx - half} y1={cy + half * 0.7} x2={cx + half} y2={cy - half * 0.2} />
|
||||
<line x1={-half} y1={-half * 0.4} x2={half} y2={half * 0.5} />
|
||||
<line x1={-half * 0.6} y1={half} x2={half * 0.7} y2={-half} />
|
||||
<line x1={-half} y1={half * 0.7} x2={half} y2={-half * 0.2} />
|
||||
</g>
|
||||
{/if}
|
||||
{:else}
|
||||
<circle {cx} {cy} r={12 * wizardScale(p.id)} fill={playerColor(p.id)} stroke="#2b2218" stroke-width="2" />
|
||||
<text x={cx} y={cy + 4} class="wizard-label">{p.id[0]?.toUpperCase()}</text>
|
||||
<circle cx="0" cy="0" r={12 * wizardScale(p.id)} fill={playerColor(p.id)} stroke="#2b2218" stroke-width="2" />
|
||||
<text x="0" y="4" class="wizard-label">{p.id[0]?.toUpperCase()}</text>
|
||||
{/if}
|
||||
{#if p.carriedTreasureId}
|
||||
<circle cx={cx + CELL * 0.26} cy={cy + CELL * 0.26} r={5} class="carried" />
|
||||
{@const carriedT = view.treasures.find((t) => t.id === p.carriedTreasureId)}
|
||||
{#if carriedT}
|
||||
<!-- Whose gold: the owner's chest rides the carrier's shoulder.
|
||||
Always the drawn chest — legible at badge size, either art mode. -->
|
||||
<TokenArt
|
||||
href={`/tokens-svg/treasure-${sharedColorIndex(view, carriedT.owner) % 6}.svg`}
|
||||
x={CELL * 0.10} y={CELL * 0.06}
|
||||
width={CELL * 0.36} height={CELL * 0.36}
|
||||
cls="carried-chest"
|
||||
/>
|
||||
{:else}
|
||||
<circle cx={CELL * 0.26} cy={CELL * 0.26} r={5} class="carried" />
|
||||
{/if}
|
||||
{/if}
|
||||
</g>
|
||||
{/each}
|
||||
</g>
|
||||
{/each}
|
||||
|
||||
<!-- creatures (fanned when several share a square) -->
|
||||
{#each [...view.creatures.reduce((m, c) => {
|
||||
const k = `${c.position.x},${c.position.y}`;
|
||||
m.set(k, [...(m.get(k) ?? []), c]);
|
||||
return m;
|
||||
}, new Map()).entries()] as [cKey, cGroup] (cKey)}
|
||||
{#each cGroup as c, ci (c.id)}
|
||||
<!-- creatures: identity-keyed movers, fanned when several share a square -->
|
||||
{#each view.creatures as c (c.id)}
|
||||
{@const cGroup = creaturesByCell.get(`${c.position.x},${c.position.y}`) ?? [c]}
|
||||
{@const ci = cGroup.findIndex((q) => q.id === c.id)}
|
||||
{@const ccx = c.position.x * CELL + CELL * 0.72 - (cGroup.length > 1 ? ci * CELL * 0.26 : 0)}
|
||||
{@const ccy = c.position.y * CELL + CELL * 0.7}
|
||||
<g class="mover" class:snap={snapsTo(`c:${c.id}`, ccx, ccy)} style={`transform: translate(${ccx}px, ${ccy}px)`}>
|
||||
<g
|
||||
role="button" tabindex="-1" class="creature"
|
||||
onclick={(ev) => { ev.stopPropagation(); if (peekFired) { peekFired = false; return; } onCreatureClick?.(c.id); }}
|
||||
@@ -534,18 +601,15 @@
|
||||
onkeydown={() => {}}
|
||||
>
|
||||
{#if USE_TOKEN_ART && CREATURE_ART[c.kind]}
|
||||
<image
|
||||
href={`/tokens/${CREATURE_ART[c.kind]}.png`}
|
||||
x={ccx - CELL * 0.26} y={ccy - CELL * 0.26}
|
||||
<TokenArt
|
||||
href={tokenArt(CREATURE_ART[c.kind]!, "creatures")}
|
||||
x={-CELL * 0.26} y={-CELL * 0.26}
|
||||
width={CELL * 0.52} height={CELL * 0.52}
|
||||
preserveAspectRatio="xMidYMid slice"
|
||||
class="token-art hit"
|
||||
class:selected-art={c.id === selectedCreatureId}
|
||||
>
|
||||
<title>{c.kind} ({c.controllerId}) — {c.damage}/{Number.isFinite(c.maxDamage) ? c.maxDamage : "∞"} dmg</title>
|
||||
</image>
|
||||
cls={`token-art hit${c.id === selectedCreatureId ? " selected-art" : ""}`}
|
||||
title={`${c.kind} (${c.controllerId}) — ${c.damage}/${Number.isFinite(c.maxDamage) ? c.maxDamage : "∞"} dmg`}
|
||||
/>
|
||||
<rect
|
||||
x={ccx - CELL * 0.26} y={ccy - CELL * 0.26}
|
||||
x={-CELL * 0.26} y={-CELL * 0.26}
|
||||
width={CELL * 0.52} height={CELL * 0.52}
|
||||
class="creature-ring"
|
||||
class:selected={c.id === selectedCreatureId}
|
||||
@@ -553,18 +617,18 @@
|
||||
/>
|
||||
{:else}
|
||||
<rect
|
||||
x={ccx - 10} y={ccy - 10} width={20} height={20} rx="3"
|
||||
transform={`rotate(45 ${ccx} ${ccy})`}
|
||||
x={-10} y={-10} width={20} height={20} rx="3"
|
||||
transform="rotate(45 0 0)"
|
||||
class="creature-body"
|
||||
class:selected={c.id === selectedCreatureId}
|
||||
stroke={playerColor(c.controllerId)}
|
||||
>
|
||||
<title>{c.kind} ({c.controllerId}) — {c.damage}/{Number.isFinite(c.maxDamage) ? c.maxDamage : "∞"} dmg</title>
|
||||
</rect>
|
||||
<text x={ccx} y={ccy + 4} class="creature-label">{c.kind === "fire-imp" ? "I" : c.kind === "democratic-monster" ? "D" : c.kind[0]?.toUpperCase()}</text>
|
||||
<text x="0" y="4" class="creature-label">{c.kind === "fire-imp" ? "I" : c.kind === "democratic-monster" ? "D" : c.kind[0]?.toUpperCase()}</text>
|
||||
{/if}
|
||||
</g>
|
||||
{/each}
|
||||
</g>
|
||||
{/each}
|
||||
|
||||
<!-- edge selection hitboxes -->
|
||||
@@ -587,11 +651,24 @@
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
<!-- FEAR's bubble: the three-space diamond, through walls, wrapping the rim -->
|
||||
{#each fearCells as key (key)}
|
||||
{@const bx = Number(key.split(",")[0])}
|
||||
{@const by = Number(key.split(",")[1])}
|
||||
<rect x={bx * CELL + 1.5} y={by * CELL + 1.5} width={CELL - 3} height={CELL - 3}
|
||||
class="fear-aura" rx="4" />
|
||||
{/each}
|
||||
|
||||
<!-- the sight line an attack traveled, leg by leg through any warp mouth -->
|
||||
{#if sightTrace}
|
||||
<SightTraceOverlay from={sightTrace.from} to={sightTrace.to} trace={sightTrace.trace} />
|
||||
{/if}
|
||||
<!-- spell flourishes: one sprite component per effect (src/fx-sprites/) -->
|
||||
<g class="fx-layer" aria-hidden="true">
|
||||
{#each effects ?? [] as fx (fx.id)}
|
||||
{@const Sprite = FX_SPRITES[fx.kind]}
|
||||
<Sprite fx={fx as never} />
|
||||
<!-- One cast at the dispatch seam; the registry stays exhaustive. -->
|
||||
{@const Sprite = FX_SPRITES[fx.kind] as Component<{ fx: BoardFx }>}
|
||||
<Sprite {fx} />
|
||||
{/each}
|
||||
</g>
|
||||
</svg>
|
||||
@@ -619,33 +696,6 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
.floor:hover { fill: #efe8d2; }
|
||||
.wall {
|
||||
fill: #f2ecd8;
|
||||
stroke: #2b2218;
|
||||
stroke-width: 1.6;
|
||||
}
|
||||
.door {
|
||||
fill: #8b5a2b;
|
||||
stroke: #2b2218;
|
||||
stroke-width: 1.4;
|
||||
rx: 2;
|
||||
}
|
||||
/* Lock removed: pale, an open doorway forever. */
|
||||
.door.removed { fill: #d4bd8e; }
|
||||
/* Lock jammed: near-black, sealed. */
|
||||
.door.jammed { fill: #3a2a18; stroke: #14100b; }
|
||||
/* Picked or keyed open until end of turn. */
|
||||
.door.ajar { fill: #d4bd8e; stroke-dasharray: 5 3; }
|
||||
.firewall {
|
||||
fill: #d0342c;
|
||||
stroke: #7c1a14;
|
||||
stroke-width: 1.2;
|
||||
animation: firewall-lick 1.1s ease-in-out infinite alternate;
|
||||
}
|
||||
@keyframes firewall-lick {
|
||||
from { fill: #d0342c; filter: drop-shadow(0 0 2px rgba(255, 120, 20, 0.5)); }
|
||||
to { fill: #ef6c3a; filter: drop-shadow(0 0 6px rgba(255, 140, 30, 0.9)); }
|
||||
}
|
||||
.stone { fill: #6a6458; stroke: #3a362e; stroke-width: 2; }
|
||||
.bush { fill: #2e7d32; stroke: #1b4d1e; stroke-width: 2; }
|
||||
.rose { fill: #2e7d32; stroke: #b0245a; stroke-width: 3; }
|
||||
@@ -658,11 +708,11 @@
|
||||
.trap-token { fill: #513c22; stroke: #201709; stroke-width: 1.5; }
|
||||
.trap-real { stroke: #d3352b; stroke-width: 2.5; }
|
||||
.dimwarp { fill: none; stroke: #5b3f9e; stroke-width: 3.5; stroke-dasharray: 4 3; }
|
||||
.token-art {
|
||||
:global(.token-art) {
|
||||
filter: drop-shadow(0.5px 1.5px 1.5px rgba(10, 8, 4, 0.5));
|
||||
pointer-events: none;
|
||||
}
|
||||
.token-art.hit { pointer-events: auto; cursor: pointer; }
|
||||
:global(.token-art.hit) { pointer-events: auto; cursor: pointer; }
|
||||
.creature-ring, .wizard-ring {
|
||||
fill: none;
|
||||
stroke-width: 2.5;
|
||||
@@ -740,67 +790,30 @@
|
||||
.ghost-slot:hover { fill: rgba(122, 162, 122, 0.22); }
|
||||
|
||||
.fx-layer { pointer-events: none; }
|
||||
.fear-aura {
|
||||
fill: rgba(90, 44, 110, 0.05);
|
||||
stroke: #7a4a8a;
|
||||
stroke-width: 2;
|
||||
stroke-dasharray: 8 6;
|
||||
pointer-events: none;
|
||||
animation: fear-breathe 2.4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes fear-breathe {
|
||||
0%, 100% { opacity: 0.65; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
.mover { transition: transform 260ms cubic-bezier(0.25, 0.8, 0.35, 1); }
|
||||
.mover.snap { transition: none; }
|
||||
|
||||
/* --- persistent ambiance: ongoing spells wear their look ------------ */
|
||||
.token-art.ghosted { opacity: 0.4; animation: ghost-breathe 2.6s ease-in-out infinite; }
|
||||
@keyframes ghost-breathe { 50% { opacity: 0.22; } }
|
||||
.token-art.stone-gazed { filter: grayscale(0.9) brightness(0.8); }
|
||||
:global(.token-art.ghosted) { opacity: 0.4; animation: ghost-breathe 2.6s ease-in-out infinite; }
|
||||
@keyframes -global-ghost-breathe { 50% { opacity: 0.22; } }
|
||||
:global(.token-art.stone-gazed) { filter: grayscale(0.9) brightness(0.8); }
|
||||
.webbing line {
|
||||
stroke: rgba(220, 218, 205, 0.85);
|
||||
stroke-width: 1.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
.fx-dust { fill: rgba(160, 150, 130, 0.75); animation: fx-drift 0.7s ease-out forwards; }
|
||||
.fx-dust.late { animation-delay: 0.09s; }
|
||||
.fx-dust.later { animation-delay: 0.17s; }
|
||||
|
||||
@keyframes fx-fade { 70% { opacity: 1; } 100% { opacity: 0; } }
|
||||
@keyframes fx-flicker {
|
||||
0% { opacity: 0; } 15% { opacity: 1; } 40% { opacity: 0.3; }
|
||||
60% { opacity: 1; } 100% { opacity: 0; }
|
||||
}
|
||||
@keyframes fx-ring {
|
||||
0% { opacity: 0.95; transform: scale(1); }
|
||||
100% { opacity: 0; transform: scale(5); }
|
||||
}
|
||||
@keyframes fx-ring-small {
|
||||
0% { opacity: 0.9; transform: scale(1); }
|
||||
100% { opacity: 0; transform: scale(2.6); }
|
||||
}
|
||||
@keyframes fx-shield-pulse {
|
||||
0% { opacity: 0; transform: scale(0.6); }
|
||||
30% { opacity: 1; transform: scale(1.1); }
|
||||
60% { transform: scale(0.95); }
|
||||
100% { opacity: 0; transform: scale(1.15); }
|
||||
}
|
||||
@keyframes fx-drop {
|
||||
0% { opacity: 0.9; transform: translateY(0); }
|
||||
100% { opacity: 0; transform: translateY(-14px); }
|
||||
}
|
||||
@keyframes fx-drift {
|
||||
0% { opacity: 0.8; transform: translateY(0) scale(1); }
|
||||
100% { opacity: 0; transform: translateY(-10px) scale(1.8); }
|
||||
}
|
||||
@keyframes fx-pow-hit {
|
||||
0% { opacity: 0; transform: scale(0.3) rotate(-15deg); }
|
||||
25% { opacity: 1; transform: scale(1.25) rotate(5deg); }
|
||||
55% { transform: scale(1) rotate(0deg); }
|
||||
100% { opacity: 0; transform: scale(1.05); }
|
||||
}
|
||||
@keyframes fx-claw-rake {
|
||||
0% { opacity: 0; transform: translateY(-6px); }
|
||||
20% { opacity: 1; }
|
||||
100% { opacity: 0; transform: translateY(6px); }
|
||||
}
|
||||
@keyframes fx-swallow {
|
||||
0% { opacity: 0.95; transform: scale(1) rotate(0deg); }
|
||||
100% { opacity: 0; transform: scale(0.05) rotate(50deg); }
|
||||
}
|
||||
@keyframes fx-spin {
|
||||
0% { opacity: 0; transform: rotate(0deg) scale(0.5); }
|
||||
30% { opacity: 1; }
|
||||
100% { opacity: 0; transform: rotate(90deg) scale(1.1); }
|
||||
}
|
||||
.warp-dest {
|
||||
fill: none;
|
||||
stroke: #2e7d32;
|
||||
@@ -814,9 +827,10 @@
|
||||
50% { opacity: 0.35; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.marked-cell, .marked-sector, .ghost-slot, .warp-dest { animation: none; }
|
||||
.marked-cell, .marked-sector, .ghost-slot, .warp-dest, .fear-aura { animation: none; }
|
||||
.fx-layer { display: none; }
|
||||
.firewall, .token-art.ghosted { animation: none; }
|
||||
:global(.token-art.ghosted) { animation: none; }
|
||||
.mover { transition: none; }
|
||||
}
|
||||
.wizard { cursor: pointer; }
|
||||
.wizard-label {
|
||||
@@ -825,6 +839,10 @@
|
||||
text-anchor: middle; pointer-events: none;
|
||||
}
|
||||
.carried { fill: gold; stroke: #111; stroke-width: 1; }
|
||||
:global(.carried-chest) {
|
||||
filter: drop-shadow(0.5px 1px 1px rgba(0, 0, 0, 0.55));
|
||||
pointer-events: none;
|
||||
}
|
||||
.crack {
|
||||
stroke: #efe8d4;
|
||||
stroke-width: 2;
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
<script lang="ts" module>
|
||||
/** The color states a door can wear (a wall wears none). */
|
||||
export type LockState = "removed" | "jammed" | "held" | "ajar";
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { holdToPeek } from "./peek";
|
||||
// A wall or door on an edge, exactly as the board draws it. Wall-segment
|
||||
// effects (locks picked, jammed, removed; doors held) are color states of
|
||||
// this one glyph — the game has never used tokens for them.
|
||||
let { x, y, kind, state, lock = null, title = null, damage = 0, onpeek }: {
|
||||
x: number; y: number; kind: "V" | "H";
|
||||
state: "wall" | "door";
|
||||
lock?: LockState | null;
|
||||
title?: string | null;
|
||||
/** Battle damage taken so far (walls fall at 20, doors at 15). */
|
||||
damage?: number;
|
||||
/** Press-and-hold (touch has no hover): report the tooltip's text. */
|
||||
onpeek?: (tip: string) => void;
|
||||
} = $props();
|
||||
const CELL = 48;
|
||||
const WALL = 7;
|
||||
const cls = $derived(state === "door" ? `door${lock ? ` ${lock}` : ""}` : "wall");
|
||||
// Every segment tells its points — the game's own word: "A wall takes 20
|
||||
// points of damage to destroy; a door takes 15."
|
||||
const needed = $derived(state === "door" ? 15 : 20);
|
||||
const hp = $derived(damage > 0
|
||||
? `${Math.max(0, needed - damage)} of ${needed} points left`
|
||||
: `${needed} points to destroy`);
|
||||
const tip = $derived(`${title ?? (state === "door" ? "a locked door" : "a wall")} — ${hp}`);
|
||||
|
||||
const { press, release } = holdToPeek(() => onpeek?.(tip));
|
||||
</script>
|
||||
|
||||
{#if kind === "V"}
|
||||
<rect
|
||||
x={(x + 1) * CELL - WALL / 2} y={y * CELL - WALL / 2}
|
||||
width={WALL} height={CELL + WALL}
|
||||
class={cls}
|
||||
role="img" onpointerdown={press} onpointerup={release} onpointerleave={release}
|
||||
><title>{tip}</title></rect>
|
||||
{:else}
|
||||
<rect
|
||||
x={x * CELL - WALL / 2} y={(y + 1) * CELL - WALL / 2}
|
||||
width={CELL + WALL} height={WALL}
|
||||
class={cls}
|
||||
role="img" onpointerdown={press} onpointerup={release} onpointerleave={release}
|
||||
><title>{tip}</title></rect>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.wall {
|
||||
fill: #f2ecd8;
|
||||
stroke: #2b2218;
|
||||
stroke-width: 1.6;
|
||||
}
|
||||
.door {
|
||||
fill: #8b5a2b;
|
||||
stroke: #2b2218;
|
||||
stroke-width: 1.4;
|
||||
rx: 2;
|
||||
}
|
||||
/* Lock removed: pale, an open doorway forever. */
|
||||
.door.removed { fill: #d4bd8e; }
|
||||
/* Lock jammed: near-black, sealed. */
|
||||
.door.jammed { fill: #3a2a18; stroke: #14100b; }
|
||||
/* Picked or keyed open until end of turn. */
|
||||
.door.ajar { fill: #d4bd8e; stroke-dasharray: 5 3; }
|
||||
/* Held open: a wizard stands propping it. */
|
||||
.door.held { fill: #d4bd8e; stroke: #2e7d32; }
|
||||
</style>
|
||||
@@ -0,0 +1,110 @@
|
||||
<script lang="ts">
|
||||
// A standing WALL OF FIRE: board furniture, not a flourish — it burns for
|
||||
// as long as the spell holds, so the animation is pure CSS on a handful
|
||||
// of shapes (no turbulence filters; those are budgeted for one-shot fx).
|
||||
import { holdToPeek } from "./peek";
|
||||
let { x, y, kind, onpeek }: {
|
||||
x: number; y: number; kind: "V" | "H";
|
||||
/** Press-and-hold (touch has no hover): report what this fire is. */
|
||||
onpeek?: (tip: string) => void;
|
||||
} = $props();
|
||||
const TIP = "a wall of fire — passing through burns for 4";
|
||||
const { press, release } = holdToPeek(() => onpeek?.(TIP));
|
||||
const uid = $props.id();
|
||||
const CELL = 48;
|
||||
const WALL = 7;
|
||||
const cx = $derived(kind === "V" ? (x + 1) * CELL : x * CELL + CELL / 2);
|
||||
const cy = $derived(kind === "V" ? y * CELL + CELL / 2 : (y + 1) * CELL);
|
||||
// Tongues sit along the edge's axis; every flame rises screen-up.
|
||||
const TONGUES = [
|
||||
{ t: -0.38, h: 11, d: 0.0 },
|
||||
{ t: -0.19, h: 15, d: 0.45 },
|
||||
{ t: 0.0, h: 18, d: 0.15 },
|
||||
{ t: 0.21, h: 13, d: 0.6 },
|
||||
{ t: 0.4, h: 16, d: 0.3 },
|
||||
];
|
||||
const EMBERS = [
|
||||
{ t: -0.28, d: 0.0 },
|
||||
{ t: 0.08, d: 0.9 },
|
||||
{ t: 0.33, d: 1.7 },
|
||||
];
|
||||
const px = (t: number) => (kind === "V" ? 0 : t * CELL);
|
||||
const py = (t: number) => (kind === "V" ? t * CELL : 0);
|
||||
</script>
|
||||
|
||||
<g transform={`translate(${cx} ${cy})`} class="fw">
|
||||
<title>{TIP}</title>
|
||||
<defs>
|
||||
<linearGradient id={`fw-bar-${uid}`} x1="0" y1="1" x2="0" y2="0">
|
||||
<stop offset="0" stop-color="#7c1a14" />
|
||||
<stop offset="0.5" stop-color="#ef3b08" />
|
||||
<stop offset="1" stop-color="#ffad22" />
|
||||
</linearGradient>
|
||||
<linearGradient id={`fw-tongue-${uid}`} x1="0" y1="1" x2="0" y2="0">
|
||||
<stop offset="0" stop-color="#ef3b08" />
|
||||
<stop offset="0.55" stop-color="#ff9d16" />
|
||||
<stop offset="1" stop-color="#fff09a" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<!-- the burning bar itself, seated on the wall line -->
|
||||
{#if kind === "V"}
|
||||
<rect x={-WALL / 2} y={-CELL / 2 - 2} width={WALL} height={CELL + 4} rx="3"
|
||||
fill={`url(#fw-bar-${uid})`} class="fw-bar"
|
||||
role="img" onpointerdown={press} onpointerup={release} onpointerleave={release} />
|
||||
{:else}
|
||||
<rect x={-CELL / 2 - 2} y={-WALL / 2} width={CELL + 4} height={WALL} rx="3"
|
||||
fill={`url(#fw-bar-${uid})`} class="fw-bar"
|
||||
role="img" onpointerdown={press} onpointerup={release} onpointerleave={release} />
|
||||
{/if}
|
||||
<!-- tongues of flame, each licking on its own beat -->
|
||||
{#each TONGUES as f (f.t)}
|
||||
<path
|
||||
d={`M ${px(f.t) - 3.4} ${py(f.t) + 1}
|
||||
C ${px(f.t) - 3.6} ${py(f.t) - f.h * 0.45}, ${px(f.t) - 1.4} ${py(f.t) - f.h * 0.7}, ${px(f.t)} ${py(f.t) - f.h}
|
||||
C ${px(f.t) + 1.4} ${py(f.t) - f.h * 0.7}, ${px(f.t) + 3.6} ${py(f.t) - f.h * 0.45}, ${px(f.t) + 3.4} ${py(f.t) + 1} Z`}
|
||||
fill={`url(#fw-tongue-${uid})`}
|
||||
class="fw-tongue"
|
||||
style={`transform-origin: ${px(f.t)}px ${py(f.t) + 1}px; animation-delay: ${f.d}s`}
|
||||
/>
|
||||
{/each}
|
||||
<!-- embers drifting off the top -->
|
||||
{#each EMBERS as em (em.t)}
|
||||
<circle cx={px(em.t)} cy={py(em.t) - 6} r="1.4" class="fw-ember"
|
||||
style={`animation-delay: ${em.d}s`} />
|
||||
{/each}
|
||||
</g>
|
||||
|
||||
<style>
|
||||
.fw-tongue, .fw-ember { pointer-events: none; }
|
||||
.fw-bar {
|
||||
stroke: #7c1a14;
|
||||
stroke-width: 1;
|
||||
filter: drop-shadow(0 0 3px rgba(255, 140, 30, 0.7));
|
||||
animation: fw-glow 1.3s ease-in-out infinite alternate;
|
||||
}
|
||||
.fw-tongue {
|
||||
animation: fw-lick 0.9s ease-in-out infinite alternate;
|
||||
}
|
||||
.fw-ember {
|
||||
fill: #ffd166;
|
||||
opacity: 0;
|
||||
animation: fw-rise 2.6s ease-out infinite;
|
||||
}
|
||||
@keyframes fw-glow {
|
||||
from { filter: drop-shadow(0 0 2px rgba(255, 120, 20, 0.5)); }
|
||||
to { filter: drop-shadow(0 0 6px rgba(255, 150, 40, 0.95)); }
|
||||
}
|
||||
@keyframes fw-lick {
|
||||
from { transform: scale(0.95, 0.72); opacity: 0.82; }
|
||||
to { transform: scale(1.04, 1.12); opacity: 1; }
|
||||
}
|
||||
@keyframes fw-rise {
|
||||
0% { opacity: 0; transform: translateY(0); }
|
||||
15% { opacity: 0.9; }
|
||||
100% { opacity: 0; transform: translateY(-16px); }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.fw-bar, .fw-tongue, .fw-ember { animation: none; }
|
||||
.fw-ember { opacity: 0.6; }
|
||||
}
|
||||
</style>
|
||||
@@ -1,9 +1,13 @@
|
||||
<script lang="ts">
|
||||
// The flourish workshop: every effect sprite on a loop, labeled, against a
|
||||
// mini-maze. Open with /?fx while `npm run dev` runs — edits to any sprite
|
||||
// file hot-reload here mid-animation. Not linked from anywhere; harmless.
|
||||
// file hot-reload here mid-animation.
|
||||
import type { Component } from "svelte";
|
||||
import { FX_SPRITES } from "./fx-sprites";
|
||||
import type { BoardFx } from "./fx";
|
||||
import type { BoardFx, FxShape } from "./fx";
|
||||
import FirewallEdge from "./FirewallEdge.svelte";
|
||||
import IllusionShimmer from "./IllusionShimmer.svelte";
|
||||
import SightTraceOverlay from "./SightTraceOverlay.svelte";
|
||||
|
||||
let tick = $state(0);
|
||||
$effect(() => {
|
||||
@@ -14,9 +18,9 @@
|
||||
// One representative of every kind, staged on a 3x3 (sectors on a 5x5).
|
||||
const mid = { x: 1, y: 1 };
|
||||
const SAMPLES: BoardFx[] = ([
|
||||
{ kind: "fireball", from: { x: 0, y: 1 }, to: { x: 2, y: 1 } },
|
||||
{ kind: "waterbolt", from: { x: 0, y: 1 }, to: { x: 2, y: 1 } },
|
||||
{ kind: "bolt", from: { x: 0, y: 0 }, to: { x: 2, y: 2 } },
|
||||
{ kind: "fireball", a: { x: 24, y: 72 }, b: { x: 120, y: 72 } },
|
||||
{ kind: "waterbolt", a: { x: 24, y: 72 }, b: { x: 120, y: 72 } },
|
||||
{ kind: "bolt", a: { x: 24, y: 24 }, b: { x: 120, y: 120 } },
|
||||
{ kind: "burst", at: mid },
|
||||
{ kind: "splash", at: mid },
|
||||
{ kind: "shimmer", at: mid },
|
||||
@@ -29,10 +33,11 @@
|
||||
{ kind: "absorb", at: mid },
|
||||
{ kind: "portal", cell: mid, side: "E" },
|
||||
{ kind: "portal-cell", at: mid },
|
||||
{ kind: "streak", from: { x: 0, y: 2 }, to: { x: 2, y: 0 } },
|
||||
{ kind: "streak", a: { x: 24, y: 120 }, b: { x: 120, y: 24 } },
|
||||
{ kind: "soul", at: { x: 1, y: 2 } },
|
||||
{ kind: "fireworks", at: mid },
|
||||
{ kind: "chaos-swirl", at: mid },
|
||||
{ kind: "die-drop", at: { x: 1, y: 1 }, aim: { x: 0, y: 2 } },
|
||||
{ kind: "pit-fall", at: mid },
|
||||
{ kind: "ooze-slip", at: mid },
|
||||
{ kind: "tacks-ow", at: mid },
|
||||
@@ -42,7 +47,7 @@
|
||||
{ kind: "edge-dust", cell: mid, side: "S" },
|
||||
{ kind: "sector-spin", origin: { x: 0, y: 0 }, clockwise: true },
|
||||
{ kind: "sector-slide", from: { x: 0, y: 0 }, to: { x: 1, y: 0 } },
|
||||
] as unknown as BoardFx[]).map((f, i) => ({ ...f, id: i + 1 }));
|
||||
] as FxShape[]).map((f, i): BoardFx => ({ ...f, id: i + 1 }));
|
||||
|
||||
const isSector = (k: string) => k.startsWith("sector-");
|
||||
</script>
|
||||
@@ -52,10 +57,11 @@
|
||||
<p class="sub">
|
||||
Every effect replays each beat. Edit a file in <code>src/fx-sprites/</code>
|
||||
and it hot-reloads here.
|
||||
<a class="cross" href="/?tokens">→ the token workshop</a>
|
||||
</p>
|
||||
<div class="grid">
|
||||
{#each SAMPLES as fx (fx.kind)}
|
||||
{@const Sprite = FX_SPRITES[fx.kind]}
|
||||
{@const Sprite = FX_SPRITES[fx.kind] as Component<{ fx: BoardFx }>}
|
||||
<figure>
|
||||
<svg viewBox={isSector(fx.kind) ? "-4 -4 296 296" : "-4 -4 152 152"}>
|
||||
{#each Array.from({ length: isSector(fx.kind) ? 6 : 3 }, (_, cy) => cy) as cy (cy)}
|
||||
@@ -64,13 +70,70 @@
|
||||
{/each}
|
||||
{/each}
|
||||
{#key tick}
|
||||
<g class="fx-layer"><Sprite fx={fx as never} /></g>
|
||||
<g class="fx-layer"><Sprite {fx} /></g>
|
||||
{/key}
|
||||
</svg>
|
||||
<figcaption>{fx.kind}</figcaption>
|
||||
</figure>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<h1 class="furniture-head">The standing fires</h1>
|
||||
<p class="sub">
|
||||
Board furniture that burns as long as its spell holds — these loop by
|
||||
nature, no beat needed. Same components the live board mounts.
|
||||
</p>
|
||||
<div class="grid">
|
||||
<figure>
|
||||
<svg viewBox="-4 -4 152 152">
|
||||
{#each [0, 1, 2] as cy (cy)}{#each [0, 1, 2] as cx (cx)}
|
||||
<rect x={cx * 48} y={cy * 48} width="48" height="48" class="cell" />
|
||||
{/each}{/each}
|
||||
<FirewallEdge x={1} y={0} kind="H" />
|
||||
</svg>
|
||||
<figcaption>wall of fire (H)</figcaption>
|
||||
</figure>
|
||||
<figure>
|
||||
<svg viewBox="-4 -4 152 152">
|
||||
{#each [0, 1, 2] as cy (cy)}{#each [0, 1, 2] as cx (cx)}
|
||||
<rect x={cx * 48} y={cy * 48} width="48" height="48" class="cell" />
|
||||
{/each}{/each}
|
||||
<FirewallEdge x={0} y={1} kind="V" />
|
||||
</svg>
|
||||
<figcaption>wall of fire (V)</figcaption>
|
||||
</figure>
|
||||
<figure>
|
||||
<svg viewBox="-4 -4 152 152">
|
||||
{#each [0, 1, 2] as cy (cy)}{#each [0, 1, 2] as cx (cx)}
|
||||
<rect x={cx * 48} y={cy * 48} width="48" height="48" class="cell" />
|
||||
{/each}{/each}
|
||||
<rect x={93.5} y={44.5} width={7} height={55} class="demo-wall" />
|
||||
<IllusionShimmer x={1} y={1} kind="V" />
|
||||
</svg>
|
||||
<figcaption>illusion shimmer</figcaption>
|
||||
</figure>
|
||||
<figure>
|
||||
<svg viewBox="-4 -4 152 152">
|
||||
{#each [0, 1, 2] as cy (cy)}{#each [0, 1, 2] as cx (cx)}
|
||||
<rect x={cx * 48} y={cy * 48} width="48" height="48" class="cell" />
|
||||
{/each}{/each}
|
||||
<SightTraceOverlay from={{ x: 0, y: 2 }} to={{ x: 2, y: 0 }} trace={{ kind: "direct" }} />
|
||||
</svg>
|
||||
<figcaption>sight trace (direct)</figcaption>
|
||||
</figure>
|
||||
<figure>
|
||||
<svg viewBox="-4 -4 152 152">
|
||||
{#each [0, 1, 2] as cy (cy)}{#each [0, 1, 2] as cx (cx)}
|
||||
<rect x={cx * 48} y={cy * 48} width="48" height="48" class="cell" />
|
||||
{/each}{/each}
|
||||
<SightTraceOverlay from={{ x: 0, y: 2 }} to={{ x: 2, y: 1 }}
|
||||
trace={{ kind: "warp", mouthA: { cell: { x: 1, y: 0 }, side: "N" },
|
||||
mouthB: { cell: { x: 1, y: 2 }, side: "S" },
|
||||
entry: { x: 1.5, y: 0 }, exit: { x: 1.5, y: 3 } }} />
|
||||
</svg>
|
||||
<figcaption>sight trace (through a warp)</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@@ -90,7 +153,17 @@
|
||||
margin: 0 0 0.2rem;
|
||||
}
|
||||
.sub { color: #8d8672; margin: 0 0 1.2rem; }
|
||||
.furniture-head {
|
||||
font-family: "Oswald", sans-serif;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.14em;
|
||||
font-size: 1.2rem;
|
||||
color: #e9e1cb;
|
||||
margin: 1.6rem 0 0.2rem;
|
||||
}
|
||||
.demo-wall { fill: #4d4438; }
|
||||
.sub code { color: #c9a72a; }
|
||||
.sub .cross { color: #c9a72a; }
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import Faq from "./Faq.svelte";
|
||||
import { HOW_TO_PLAY, RULES_SECTIONS } from "./rules";
|
||||
import { FAQ_GENERAL } from "./faq-general";
|
||||
import { RULEBOOK_BASE, RULEBOOK_EXPANSION, RULEBOOK_COPYRIGHT } from "./rulebook";
|
||||
|
||||
let {
|
||||
onclose,
|
||||
@@ -146,6 +147,17 @@
|
||||
the edition currently in print</a>, and deal seven cards to
|
||||
somebody at a real table.
|
||||
</p>
|
||||
<h3>Behind the curtain</h3>
|
||||
<p>
|
||||
The workshops where this table's pieces are made are open to
|
||||
visitors:
|
||||
the <a href="/?tokens">token workshop</a> shows every token in both
|
||||
arts beside the wall textures and spell sprites;
|
||||
the <a href="/?fx">flourish workshop</a> plays each board effect on
|
||||
demand; and
|
||||
the <a href="/?fpv">first-person workshop</a> walks the maze through
|
||||
a wizard's own eyes (add <code>&demo=1</code> to watch a reel).
|
||||
</p>
|
||||
<h3>Send word</h3>
|
||||
<p>
|
||||
Feedback, bug reports, and faint praise all welcome — ravens fly to
|
||||
@@ -174,6 +186,19 @@
|
||||
<h3>{section.title}</h3>
|
||||
{#each section.body as p, i (i)}<p>{p}</p>{/each}
|
||||
{/each}
|
||||
<h2 class="faq-divider">The rulebook, verbatim</h2>
|
||||
<p class="colophon">
|
||||
The full text, word for word, for settling table disputes. {RULEBOOK_COPYRIGHT}
|
||||
</p>
|
||||
{#each RULEBOOK_BASE as section (section.title)}
|
||||
<h3>{section.title}</h3>
|
||||
{#each section.paragraphs as p, i (i)}<p>{p}</p>{/each}
|
||||
{/each}
|
||||
<h2 class="faq-divider">Expansion Set 1 — verbatim</h2>
|
||||
{#each RULEBOOK_EXPANSION as section (section.title)}
|
||||
<h3>{section.title}</h3>
|
||||
{#each section.paragraphs as p, i (i)}<p>{p}</p>{/each}
|
||||
{/each}
|
||||
<h2 class="faq-divider">Official FAQ — general rulings</h2>
|
||||
<p class="colophon">
|
||||
Tom Jolly's own answers (wizwar.com, September 2002), verbatim. Rulings
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<script lang="ts">
|
||||
// An UNTESTED illusion wall: the wall renders solid underneath (it is a
|
||||
// wall to you until your eyes rule) — this is the tell-tale shimmer laid
|
||||
// over it, and the tap target that offers the belief test.
|
||||
let { x, y, kind, onclick }: {
|
||||
x: number; y: number; kind: "V" | "H";
|
||||
onclick?: () => void;
|
||||
} = $props();
|
||||
const CELL = 48;
|
||||
const x1 = $derived(kind === "V" ? (x + 1) * CELL : x * CELL + 2);
|
||||
const y1 = $derived(kind === "V" ? y * CELL + 2 : (y + 1) * CELL);
|
||||
const x2 = $derived(kind === "V" ? (x + 1) * CELL : (x + 1) * CELL - 2);
|
||||
const y2 = $derived(kind === "V" ? (y + 1) * CELL - 2 : (y + 1) * CELL);
|
||||
</script>
|
||||
|
||||
<line {x1} {y1} {x2} {y2} class="illusion-shimmer" />
|
||||
{#if onclick}
|
||||
<line {x1} {y1} {x2} {y2} class="illusion-hit"
|
||||
role="button" tabindex="-1" aria-label="a shimmering wall — test your eyes"
|
||||
onclick={(ev) => { ev.stopPropagation(); onclick?.(); }}
|
||||
onkeydown={() => {}}><title>this wall shimmers oddly — tap to test your eyes</title></line>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.illusion-shimmer {
|
||||
stroke: #cfc3f0;
|
||||
stroke-width: 3;
|
||||
stroke-dasharray: 4 5;
|
||||
stroke-linecap: round;
|
||||
pointer-events: none;
|
||||
animation: shimmer-drift 1.4s linear infinite;
|
||||
}
|
||||
.illusion-hit { stroke: transparent; stroke-width: 14; cursor: pointer; }
|
||||
@keyframes shimmer-drift {
|
||||
0% { stroke-dashoffset: 0; opacity: 0.9; }
|
||||
50% { opacity: 0.4; }
|
||||
100% { stroke-dashoffset: -18; opacity: 0.9; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.illusion-shimmer { animation: none; opacity: 0.7; }
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,12 @@
|
||||
<script lang="ts">
|
||||
import Board from "./Board.svelte";
|
||||
import FirstPerson from "./fpv/FirstPerson.svelte";
|
||||
import { humanize } from "./net.svelte";
|
||||
import { fxForEvents, fxTtl, type BoardFx } from "./fx";
|
||||
import { scheduleFx, type BoardFx } from "./fx";
|
||||
import { fpFxForEvents, type FpFx } from "./fpv/fx3d";
|
||||
import { castRay } from "./fpv/raycast";
|
||||
import { prefs } from "./prefs.svelte";
|
||||
import { stackSightTrace } from "@wizwar/engine";
|
||||
import type { GameEvent, GameView } from "@wizwar/engine";
|
||||
|
||||
let {
|
||||
@@ -15,25 +20,50 @@
|
||||
let idx = $state(0);
|
||||
let playing = $state(true);
|
||||
let speed = $state(1);
|
||||
/** Watch the board from above, or relive it through your own eyes. */
|
||||
let fp = $state(false);
|
||||
const step = $derived(steps[Math.min(idx, steps.length - 1)]!);
|
||||
const lines = $derived(
|
||||
step.events.map(humanize).filter((l): l is string => l !== null),
|
||||
);
|
||||
const atEnd = $derived(idx >= steps.length - 1);
|
||||
|
||||
// The reel draws the same sight line the live table shows for an LOS
|
||||
// attack in progress, so a replay-watcher can see how a spell reached them.
|
||||
const sightTrace = $derived(stackSightTrace(step.view));
|
||||
|
||||
/** Each step's spells flare on the reel exactly as they did at the table. */
|
||||
let boardFx = $state<BoardFx[]>([]);
|
||||
$effect(() => {
|
||||
const step = steps[Math.min(idx, steps.length - 1)];
|
||||
if (!step) return;
|
||||
if (!step || !prefs.flourishes) return;
|
||||
const cancel = scheduleFx(
|
||||
step.events, step.view,
|
||||
(fx) => (boardFx = [...boardFx, fx]),
|
||||
(id) => (boardFx = boardFx.filter((f) => f.id !== id)),
|
||||
);
|
||||
return () => { cancel(); boardFx = []; };
|
||||
});
|
||||
|
||||
/** In first person the same events become projectiles, impacts,
|
||||
* flashes, and shakes — scheduled on this step's beat. */
|
||||
let fpFx = $state<FpFx[]>([]);
|
||||
$effect(() => {
|
||||
const st = steps[Math.min(idx, steps.length - 1)];
|
||||
if (!fp || !st || !prefs.flourishes) return;
|
||||
const timers: ReturnType<typeof setTimeout>[] = [];
|
||||
for (const { fx, delay } of fxForEvents(step.events, step.view)) {
|
||||
const started: number[] = [];
|
||||
for (const { fx, delay } of fpFxForEvents(st.events, st.view, st.view.you)) {
|
||||
timers.push(setTimeout(() => {
|
||||
boardFx = [...boardFx, fx];
|
||||
timers.push(setTimeout(() => (boardFx = boardFx.filter((f) => f.id !== fx.id)), fxTtl(fx.kind)));
|
||||
}, delay));
|
||||
started.push(fx.id);
|
||||
fpFx = [...fpFx, { ...fx, t0: performance.now() }];
|
||||
timers.push(setTimeout(() => (fpFx = fpFx.filter((f) => f.id !== fx.id)), fx.dur + 80));
|
||||
}, delay / speed));
|
||||
}
|
||||
return () => { timers.forEach(clearTimeout); boardFx = []; };
|
||||
return () => {
|
||||
timers.forEach(clearTimeout);
|
||||
started.forEach((id) => (fpFx = fpFx.filter((f) => f.id !== id)));
|
||||
};
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
@@ -45,6 +75,198 @@
|
||||
return () => clearInterval(t);
|
||||
});
|
||||
|
||||
/** Other bodies glide between steps instead of blinking cell to cell:
|
||||
* each step, anyone whose position changed a walkable distance tweens
|
||||
* from where the previous step's view had them. */
|
||||
let actorPos = $state<Record<string, { x: number; y: number }>>({});
|
||||
let prevView: GameView | null = null;
|
||||
$effect(() => {
|
||||
const st = steps[Math.min(idx, steps.length - 1)];
|
||||
if (!fp || !st) { prevView = null; actorPos = {}; return; }
|
||||
const v = st.view;
|
||||
const before = prevView;
|
||||
prevView = v;
|
||||
if (!before || before === v) return;
|
||||
const moves: { id: string; fx: number; fy: number; tx: number; ty: number }[] = [];
|
||||
const gather = (
|
||||
id: string,
|
||||
now: { x: number; y: number },
|
||||
was: { x: number; y: number } | undefined,
|
||||
) => {
|
||||
if (!was) return;
|
||||
const d = Math.hypot(now.x - was.x, now.y - was.y);
|
||||
// A single stride or shove glides; a leap across the maze is a
|
||||
// teleport and should simply be there.
|
||||
if (d > 0.05 && d <= 3.5) {
|
||||
moves.push({ id, fx: was.x + 0.5, fy: was.y + 0.5, tx: now.x + 0.5, ty: now.y + 0.5 });
|
||||
}
|
||||
};
|
||||
for (const p of v.players) {
|
||||
if (!p.alive || p.id === v.you) continue;
|
||||
const was = before.players.find((q) => q.id === p.id && q.alive);
|
||||
gather(p.id, p.position, was?.position);
|
||||
}
|
||||
for (const c of v.creatures) {
|
||||
gather(c.id, c.position, before.creatures.find((q) => q.id === c.id)?.position);
|
||||
}
|
||||
if (moves.length === 0) { actorPos = {}; return; }
|
||||
const t0 = performance.now();
|
||||
const dur = 400 / speed;
|
||||
let raf = 0;
|
||||
const tick = (now: number) => {
|
||||
const w = Math.min(1, Math.max(0, (now - t0) / dur));
|
||||
const ease = w * w * (3 - 2 * w);
|
||||
const next: Record<string, { x: number; y: number }> = {};
|
||||
for (const m of moves) {
|
||||
next[m.id] = { x: m.fx + (m.tx - m.fx) * ease, y: m.fy + (m.ty - m.fy) * ease };
|
||||
}
|
||||
actorPos = next;
|
||||
if (w < 1) raf = requestAnimationFrame(tick);
|
||||
else actorPos = {};
|
||||
};
|
||||
raf = requestAnimationFrame(tick);
|
||||
return () => cancelAnimationFrame(raf);
|
||||
});
|
||||
|
||||
// --- The first-person camera: your wizard's walk, relived. -------------
|
||||
// Each step the camera settles on your position. A step away tweens —
|
||||
// turn first, then stride; a leap (teleport, warp) cuts. When you stood
|
||||
// still, the eye turns toward whoever acted.
|
||||
const cam = $state({ x: 0, y: 0, facing: 0 });
|
||||
let camReady = false;
|
||||
function shortestArc(from: number, to: number): number {
|
||||
let d = (to - from) % (2 * Math.PI);
|
||||
if (d > Math.PI) d -= 2 * Math.PI;
|
||||
if (d < -Math.PI) d += 2 * Math.PI;
|
||||
return d;
|
||||
}
|
||||
$effect(() => {
|
||||
if (!fp) { camReady = false; return; }
|
||||
const v = step.view;
|
||||
const me = v.players.find((p) => p.id === v.you);
|
||||
if (!me) return;
|
||||
const tx = me.position.x + 0.5;
|
||||
const ty = me.position.y + 0.5;
|
||||
const dx = tx - cam.x;
|
||||
const dy = ty - cam.y;
|
||||
const dist = Math.hypot(dx, dy);
|
||||
// Where should the eye end up pointing? Along its own stride; at the
|
||||
// actor, when someone else moved the world; wherever it was, otherwise.
|
||||
// A blow that hurls the body glides fast and straight, however far,
|
||||
// eyes still where they were; only unexplained leaps (teleports) cut.
|
||||
const hurled = step.events.some((e) =>
|
||||
(e.type === "knockedBack" || e.type === "shoved" || e.type === "washedBack" ||
|
||||
e.type === "retreatedInHorror") && e.player === v.you);
|
||||
const actor = v.players.find((p) => p.id === step.actor);
|
||||
let targetFacing = cam.facing;
|
||||
if (dist > 0.05 && !hurled) targetFacing = Math.atan2(dy, dx);
|
||||
else if (actor && actor.id !== v.you &&
|
||||
(actor.position.x !== me.position.x || actor.position.y !== me.position.y)) {
|
||||
// Turn toward the actor — but only if these eyes could actually see
|
||||
// them: an unbroken straight sight line, no warps bending it.
|
||||
const ax = actor.position.x + 0.5, ay = actor.position.y + 0.5;
|
||||
const toActor = Math.hypot(ax - tx, ay - ty);
|
||||
const ray = castRay(v, tx, ty, Math.atan2(ay - ty, ax - tx));
|
||||
if (!ray.warped && ray.dist > toActor - 0.2) targetFacing = Math.atan2(ay - ty, ax - tx);
|
||||
}
|
||||
if (!camReady || (dist > 1.6 && !hurled)) {
|
||||
// First frame, or a leap the legs cannot explain: cut.
|
||||
cam.x = tx; cam.y = ty; cam.facing = targetFacing;
|
||||
camReady = true;
|
||||
return;
|
||||
}
|
||||
const fromX = cam.x, fromY = cam.y, fromF = cam.facing;
|
||||
const arc = shortestArc(fromF, targetFacing);
|
||||
const turnMs = (hurled ? 0 : Math.min(260, Math.abs(arc) * 180)) / speed;
|
||||
const walkMs = (dist > 0.05 ? (hurled ? 260 : 420) : 0) / speed;
|
||||
const t0 = performance.now();
|
||||
let raf = 0;
|
||||
const tick = (now: number) => {
|
||||
// rAF hands frame-start time, which can precede t0; and a
|
||||
// zero-length phase must never divide. Either poisons the facing
|
||||
// with NaN, which no later frame can wash out.
|
||||
const t = Math.max(0, now - t0);
|
||||
if (turnMs > 0 && t < turnMs) {
|
||||
cam.facing = fromF + arc * (t / turnMs);
|
||||
} else if (walkMs > 0 && t < turnMs + walkMs) {
|
||||
cam.facing = fromF + arc;
|
||||
const w = (t - turnMs) / walkMs;
|
||||
const ease = w * w * (3 - 2 * w);
|
||||
cam.x = fromX + (tx - fromX) * ease;
|
||||
cam.y = fromY + (ty - fromY) * ease;
|
||||
} else {
|
||||
cam.facing = fromF + arc;
|
||||
cam.x = tx; cam.y = ty;
|
||||
return;
|
||||
}
|
||||
raf = requestAnimationFrame(tick);
|
||||
};
|
||||
raf = requestAnimationFrame(tick);
|
||||
return () => cancelAnimationFrame(raf);
|
||||
});
|
||||
|
||||
// --- Save a video: the first-person view composited into a shareable
|
||||
// 1280x720 frame — the step's caption burned in at the bottom, the
|
||||
// game's name in the corner — so a downloaded clip explains itself.
|
||||
let stageEl: HTMLDivElement | undefined = $state();
|
||||
let recorder = $state<MediaRecorder | null>(null);
|
||||
function toggleRecord() {
|
||||
if (recorder) { recorder.stop(); return; }
|
||||
const cv = stageEl?.querySelector("canvas");
|
||||
if (!cv) return;
|
||||
const comp = document.createElement("canvas");
|
||||
comp.width = 1280;
|
||||
comp.height = 720;
|
||||
const g = comp.getContext("2d")!;
|
||||
let compRaf = 0;
|
||||
const drawComp = () => {
|
||||
g.imageSmoothingEnabled = false;
|
||||
g.drawImage(cv, 0, 0, 1280, 720);
|
||||
g.imageSmoothingEnabled = true;
|
||||
// The caption bar: who did what, in the reel's own words.
|
||||
g.fillStyle = "rgba(12, 10, 8, 0.78)";
|
||||
g.fillRect(0, 720 - 96, 1280, 96);
|
||||
g.fillStyle = "#e0b34a";
|
||||
g.font = "600 22px Oswald, sans-serif";
|
||||
g.fillText(step.actor.toUpperCase(), 28, 720 - 60, 400);
|
||||
g.fillStyle = "#efe8d4";
|
||||
g.font = "21px 'Courier Prime', monospace";
|
||||
const said = lines.slice(0, 2);
|
||||
if (said.length === 0) said.push("…considers the maze.");
|
||||
said.forEach((line, i) => g.fillText(line, 28, 720 - 32 + i * 26, 1224));
|
||||
// The colophon corner.
|
||||
g.fillStyle = "rgba(224, 179, 74, 0.6)";
|
||||
g.font = "600 20px Oswald, sans-serif";
|
||||
g.textAlign = "right";
|
||||
g.fillText("W I Z - W A R", 1280 - 24, 40);
|
||||
g.textAlign = "left";
|
||||
compRaf = requestAnimationFrame(drawComp);
|
||||
};
|
||||
compRaf = requestAnimationFrame(drawComp);
|
||||
const mime = MediaRecorder.isTypeSupported("video/webm;codecs=vp9")
|
||||
? "video/webm;codecs=vp9" : "video/webm";
|
||||
const rec = new MediaRecorder(comp.captureStream(60), { mimeType: mime });
|
||||
const chunks: Blob[] = [];
|
||||
rec.ondataavailable = (e) => { if (e.data.size) chunks.push(e.data); };
|
||||
rec.onstop = () => {
|
||||
cancelAnimationFrame(compRaf);
|
||||
const url = URL.createObjectURL(new Blob(chunks, { type: "video/webm" }));
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `wizwar-moves-${steps[0]?.seq ?? 0}-${steps[steps.length - 1]?.seq ?? 0}.webm`;
|
||||
a.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 2000);
|
||||
recorder = null;
|
||||
};
|
||||
rec.start();
|
||||
recorder = rec;
|
||||
playing = true;
|
||||
}
|
||||
// The reel running out ends the recording and hands over the file.
|
||||
$effect(() => {
|
||||
if (recorder && !playing && atEnd) recorder.stop();
|
||||
});
|
||||
|
||||
function onkeydown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") onclose();
|
||||
if (e.key === "ArrowRight") { playing = false; idx = Math.min(idx + 1, steps.length - 1); }
|
||||
@@ -60,10 +282,22 @@
|
||||
<header class="replay-head">
|
||||
<span class="replay-title">{steps[0]?.seq === 0 ? "The whole tale, from the deal" : "While you were away"}</span>
|
||||
<span class="replay-count">move {idx + 1} of {steps.length}</span>
|
||||
<button class="replay-eyes" class:lit={fp} onclick={() => (fp = !fp)}>
|
||||
{fp ? "⬒ the board" : "👁 your eyes"}</button>
|
||||
{#if fp}
|
||||
<button class="replay-eyes" class:lit={recorder !== null} onclick={toggleRecord}>
|
||||
{recorder ? "⏹ stop & save" : "⏺ save video"}</button>
|
||||
{/if}
|
||||
<button class="replay-skip" onclick={onclose}>{atEnd ? "back to the game" : "skip to now"}</button>
|
||||
</header>
|
||||
<div class="replay-board">
|
||||
<Board view={step.view} effects={boardFx} />
|
||||
<div class="replay-board" bind:this={stageEl}>
|
||||
{#if fp}
|
||||
<FirstPerson view={step.view} povId={step.view.you}
|
||||
x={cam.x} y={cam.y} facing={cam.facing} width={640} height={360}
|
||||
fx={fpFx} posOverride={actorPos} />
|
||||
{:else}
|
||||
<Board view={step.view} effects={boardFx} {sightTrace} />
|
||||
{/if}
|
||||
</div>
|
||||
<div class="replay-caption">
|
||||
<strong>{step.actor}</strong>
|
||||
@@ -122,8 +356,18 @@
|
||||
color: #e9e1cb;
|
||||
}
|
||||
.replay-count { font-size: 0.8rem; color: #8d8672; }
|
||||
.replay-skip {
|
||||
.replay-eyes {
|
||||
margin-left: auto;
|
||||
background: none;
|
||||
border: 1px solid #5a5342;
|
||||
border-radius: 3px;
|
||||
color: #a49c86;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
padding: 0.15rem 0.5rem;
|
||||
}
|
||||
.replay-eyes.lit { color: #e9e1cb; border-color: #a49c86; }
|
||||
.replay-skip {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #a49c86;
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
<script lang="ts">
|
||||
// The sight line an attack traveled — straight when direct, leg by leg
|
||||
// through both warp mouths (with pulsing rings) when the maze wrapped.
|
||||
import type { SightTrace } from "@wizwar/engine";
|
||||
let { from, to, trace }: {
|
||||
from: { x: number; y: number };
|
||||
to: { x: number; y: number };
|
||||
trace: SightTrace;
|
||||
} = $props();
|
||||
const CELL = 48;
|
||||
const A = $derived({ x: from.x * CELL + CELL / 2, y: from.y * CELL + CELL * 0.36 });
|
||||
const B = $derived({ x: to.x * CELL + CELL / 2, y: to.y * CELL + CELL * 0.36 });
|
||||
</script>
|
||||
|
||||
<g class="sight-layer" aria-hidden="true">
|
||||
{#if trace.kind === "direct"}
|
||||
<line x1={A.x} y1={A.y} x2={B.x} y2={B.y} class="sight-line" />
|
||||
{:else}
|
||||
{@const entry = { x: trace.entry.x * CELL, y: trace.entry.y * CELL }}
|
||||
{@const exit = { x: trace.exit.x * CELL, y: trace.exit.y * CELL }}
|
||||
<line x1={A.x} y1={A.y} x2={entry.x} y2={entry.y} class="sight-line" />
|
||||
<line x1={exit.x} y1={exit.y} x2={B.x} y2={B.y} class="sight-line" />
|
||||
<circle cx={entry.x} cy={entry.y} r={7} class="sight-mouth" />
|
||||
<circle cx={exit.x} cy={exit.y} r={7} class="sight-mouth" />
|
||||
{/if}
|
||||
</g>
|
||||
|
||||
<style>
|
||||
.sight-layer { pointer-events: none; }
|
||||
.sight-line {
|
||||
stroke: #c9a72a;
|
||||
stroke-width: 2.5;
|
||||
stroke-linecap: round;
|
||||
stroke-dasharray: 7 6;
|
||||
opacity: 0.85;
|
||||
animation: sight-march 0.8s linear infinite;
|
||||
}
|
||||
.sight-mouth {
|
||||
fill: none;
|
||||
stroke: #c9a72a;
|
||||
stroke-width: 2;
|
||||
animation: sight-pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
@keyframes sight-march {
|
||||
to { stroke-dashoffset: -13; }
|
||||
}
|
||||
@keyframes sight-pulse {
|
||||
0%, 100% { opacity: 0.9; }
|
||||
50% { opacity: 0.35; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.sight-line, .sight-mouth { animation: none; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,67 @@
|
||||
<script lang="ts" module>
|
||||
import { SvelteMap } from "svelte/reactivity";
|
||||
|
||||
// SVG art must be INLINED to stay vector: an svg referenced through an
|
||||
// <image> element is rasterized once at its layout size in user units and
|
||||
// scales as a bitmap — blurry the moment the board zooms. Fetched files
|
||||
// are cached per URL, with their ids namespaced so 45 tokens' worth of
|
||||
// filter/pattern defs cannot collide in one document.
|
||||
type Entry = { vb: string; inner: string };
|
||||
const cache = new SvelteMap<string, Entry | "pending" | "failed">();
|
||||
|
||||
function load(href: string): void {
|
||||
if (cache.has(href)) return;
|
||||
cache.set(href, "pending");
|
||||
fetch(href)
|
||||
.then((r) => r.text())
|
||||
.then((text) => {
|
||||
const ns = href.replace(/[^a-zA-Z0-9]/g, "");
|
||||
const t = text
|
||||
.replace(/id="([^"]+)"/g, (_, id) => `id="${id}-${ns}"`)
|
||||
.replace(/url\(#([^)]+)\)/g, (_, id) => `url(#${id}-${ns})`)
|
||||
.replace(/href="#([^"]+)"/g, (_, id) => `href="#${id}-${ns}"`);
|
||||
const m = /<svg([^>]*)>([\s\S]*)<\/svg>/i.exec(t);
|
||||
const vb = m ? (/viewBox="([^"]*)"/.exec(m[1]!)?.[1] ?? "0 0 144 150") : "0 0 144 150";
|
||||
cache.set(href, { vb, inner: m?.[2] ?? "" });
|
||||
})
|
||||
// A failed fetch stays failed: deleting would retrigger the effect
|
||||
// and hammer a missing file forever.
|
||||
.catch(() => cache.set(href, "failed"));
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
let {
|
||||
href, x, y, width, height, cls = "", title = null,
|
||||
}: {
|
||||
href: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
cls?: string;
|
||||
title?: string | null;
|
||||
} = $props();
|
||||
|
||||
const isSvg = $derived(href.endsWith(".svg"));
|
||||
$effect(() => {
|
||||
if (isSvg) load(href);
|
||||
});
|
||||
const entry = $derived(isSvg ? cache.get(href) : undefined);
|
||||
</script>
|
||||
|
||||
{#if isSvg}
|
||||
{#if entry && entry !== "pending" && entry !== "failed"}
|
||||
<svg {x} {y} {width} {height} viewBox={entry.vb}
|
||||
preserveAspectRatio="xMidYMid slice" class={cls}>
|
||||
{#if title}<title>{title}</title>{/if}
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags — our own art files -->
|
||||
{@html entry.inner}
|
||||
</svg>
|
||||
{/if}
|
||||
{:else}
|
||||
<image {href} {x} {y} {width} {height}
|
||||
preserveAspectRatio="xMidYMid slice" class={cls}>
|
||||
{#if title}<title>{title}</title>{/if}
|
||||
</image>
|
||||
{/if}
|
||||