Compare commits
@@ -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,19 @@
|
||||
#!/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
|
||||
out=$(npx tsx deploy/replay-verify.mjs "$f" 2>&1 | tail -1)
|
||||
case "$out" in
|
||||
FAIL*) echo "$out"; 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;
|
||||
@@ -62,8 +64,15 @@ export interface GameView {
|
||||
/** 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 (rules rev 29+): 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>;
|
||||
@@ -75,6 +84,8 @@ export interface GameView {
|
||||
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 (rev 31). */
|
||||
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,20 @@ 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.
|
||||
// From rules rev 29 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 rev29 = (state.config.deckRev ?? 1) >= 29;
|
||||
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";
|
||||
if (rev29) {
|
||||
illusionEdges[key] =
|
||||
wall.createdBy === playerId ? "mine"
|
||||
: wall.belief[playerId] ?? "untested";
|
||||
}
|
||||
if (knows) {
|
||||
knownIllusionEdges.push(key);
|
||||
} else {
|
||||
@@ -116,6 +136,7 @@ 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] : [],
|
||||
@@ -137,7 +158,10 @@ export function viewFor(state: GameState, playerId: PlayerId): GameView {
|
||||
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 } })),
|
||||
@@ -146,6 +170,7 @@ export function viewFor(state: GameState, playerId: PlayerId): GameView {
|
||||
? { 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 +195,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 (rules rev 15).
|
||||
let board = view.board;
|
||||
if (view.deckRev >= 15 && 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 +221,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 +368,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.
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
type GameState,
|
||||
type PlayerId,
|
||||
} from "../src/game";
|
||||
import { cellKey, edgeKey } from "../src/board";
|
||||
import { viewFor } from "../src/view";
|
||||
import { automatonCommand, automatonFallback, type AutomatonStyle, type AutomatonTier } from "../src/automaton";
|
||||
|
||||
@@ -117,8 +118,7 @@ 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) {
|
||||
function underAttackShared(attackId: string, defenderHand: { instanceId: string; cardId: string }[], rig?: (s: GameState, defender: string) => void) {
|
||||
let { state } = createGame({ playerIds: ["human", "bot"], seed: 42, sets: ["basic", "expansion1"], deckRev: 13 });
|
||||
// burn round 1
|
||||
for (let i = 0; i < 2; i++) {
|
||||
@@ -131,8 +131,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,10 +143,11 @@ 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", [
|
||||
const state = underAttackShared("drop-object", [
|
||||
{ instanceId: "absorb#T", cardId: "absorb" },
|
||||
{ instanceId: "blunt#T", cardId: "blunt" },
|
||||
{ instanceId: "dagger#T", cardId: "dagger" },
|
||||
@@ -156,12 +157,12 @@ describe("the clockwork does not waste counters on pointless targets", () => {
|
||||
});
|
||||
|
||||
it("shields a drop-object aimed at its carried treasure", () => {
|
||||
const state = underAttack("drop-object", [
|
||||
const state = underAttackShared("drop-object", [
|
||||
{ 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");
|
||||
@@ -180,7 +181,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 +204,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 = underAttackShared("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"], deckRev: 24 });
|
||||
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"], deckRev: 24 });
|
||||
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"], deckRev: 24 });
|
||||
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"], deckRev: 24 });
|
||||
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"], deckRev: 24 });
|
||||
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"], deckRev: 24 });
|
||||
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("round two of the owner's tactics", () => {
|
||||
it("exiles a thief carrying its gold to the far end of nowhere", () => {
|
||||
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic", "expansion1"], deckRev: 24 });
|
||||
// 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"], deckRev: 24 });
|
||||
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"], deckRev: 24 });
|
||||
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"], deckRev: 25 });
|
||||
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.
|
||||
state.sustained.push({
|
||||
id: "fx-test", cardId: "buddy", casterId: "bot", targetId: "other",
|
||||
turnsLeft: 1000, edge: undefined,
|
||||
} as never);
|
||||
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"], deckRev: 29 });
|
||||
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"], deckRev: 32 });
|
||||
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
|
||||
state.sustained.push({
|
||||
id: "fx-fear", cardId: "fear", casterId: "grim", targetId: "grim",
|
||||
remainingTurns: 5, data: {},
|
||||
} as never);
|
||||
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";
|
||||
@@ -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"], deckRev: 18 });
|
||||
// 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 (rules rev 19)", () => {
|
||||
it("destroying a perimeter wall opens both sides as a new warp", () => {
|
||||
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"], deckRev: 19 });
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -250,14 +250,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", () => {
|
||||
@@ -906,3 +898,211 @@ 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"], deckRev: 15 });
|
||||
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 (rules rev 16)", () => {
|
||||
it("a believed illusion bites both wizards", () => {
|
||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 16 });
|
||||
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 (rules rev 25)", () => {
|
||||
function reflectRig() {
|
||||
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"], deckRev: 25 });
|
||||
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);
|
||||
});
|
||||
|
||||
it("rev 24 lands the reflected blow instantly, as stored games replay", () => {
|
||||
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"], deckRev: 24 });
|
||||
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" });
|
||||
state = must(state, attacker, { type: "pass" });
|
||||
expect(state.stack).toBeNull();
|
||||
expect(state.players.find((p) => p.id === attacker)!.life).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a reflected lightning blast ends the caster's turn (rules rev 28)", () => {
|
||||
function boltRig(deckRev: number) {
|
||||
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"], deckRev });
|
||||
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(28);
|
||||
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);
|
||||
});
|
||||
|
||||
it("rev 27 keeps the old behavior: the caster plays on and draws", () => {
|
||||
let { state, attacker } = boltRig(27);
|
||||
expect(state.turn.actionsEnded).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).toBeGreaterThan(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fireball burns only the stones in play (rules rev 33)", () => {
|
||||
it("a displayed stone dies; a hidden one stays secret and safe", () => {
|
||||
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"], deckRev: 33 });
|
||||
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 (rules rev 34)", () => {
|
||||
it("hidden stones neither add damage nor betray their count", () => {
|
||||
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"], deckRev: 34 });
|
||||
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 = 3 damage... minus the displayed bloodstone? It is
|
||||
// hidden, so no soak either: 15 - 3 = 12.
|
||||
expect(state.players.find((p) => p.id === defender)!.life).toBe(12);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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";
|
||||
|
||||
@@ -466,28 +466,23 @@ 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 });
|
||||
let { state, me, victim, skeletonId } = creatureVsInvisible(13);
|
||||
const rngBefore = JSON.stringify(state.rng);
|
||||
state = must(state, me, { type: "creatureAttack", creatureId: skeletonId, 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);
|
||||
// The die was consumed, whichever way it landed.
|
||||
expect(JSON.stringify(state.rng)).not.toBe(rngBefore);
|
||||
});
|
||||
|
||||
it("earlier revisions keep the old certainty: no roll, the blow just lands", () => {
|
||||
let { state, me, victim, trollId } = creatureVsInvisible(12);
|
||||
let { state, me, victim, skeletonId } = creatureVsInvisible(12);
|
||||
const lifeBefore = state.players.find((p) => p.id === victim.id)!.life;
|
||||
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.
|
||||
@@ -526,3 +521,232 @@ 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"], deckRev: 16 });
|
||||
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(",").length ? 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 (rules rev 17)", () => {
|
||||
it("a cornered skeleton takes the waterwall crush", () => {
|
||||
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"], deckRev: 17 });
|
||||
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 (rules rev 18)", () => {
|
||||
it("refreshes each round even with the roll-off winner dead", () => {
|
||||
let { state } = createGame({ playerIds: ["a", "b", "c"], seed: 42, sets: ["basic", "expansion1"], deckRev: 18 });
|
||||
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 (rules rev 18)", () => {
|
||||
it("creation spends the turn, not the round", () => {
|
||||
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"], deckRev: 18 });
|
||||
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 (rules rev 21)", () => {
|
||||
function wallRig(deckRev: number) {
|
||||
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"], deckRev });
|
||||
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("rev 21: the adjacent troll takes the four points", () => {
|
||||
const state = wallRig(21);
|
||||
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);
|
||||
});
|
||||
|
||||
it("earlier revisions leave the troll dusty but unharmed", () => {
|
||||
const state = wallRig(20);
|
||||
expect(state.creatures.find((c) => c.id === "tr1")!.damage).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the self-stack resolves on a pass (rules rev 22)", () => {
|
||||
function selfTouch(deckRev: number) {
|
||||
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"], deckRev });
|
||||
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("rev 22: passing your own monster's touch takes the claw and moves on", () => {
|
||||
const { state, creator } = selfTouch(22);
|
||||
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);
|
||||
});
|
||||
|
||||
it("rev 21 keeps its recorded bounce, no-op as it was", () => {
|
||||
const { state, creator } = selfTouch(21);
|
||||
if (!state.stack) return;
|
||||
const r = applyCommand(state, creator, { type: "pass" });
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
expect(r.state.stack).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("fear holds off monsters and unwilling feet alike (rules rev 32)", () => {
|
||||
it("a commanded monster cannot close within three of the fearsome", () => {
|
||||
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"], deckRev: 32 });
|
||||
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.
|
||||
state.sustained.push({
|
||||
id: "fx-fear", cardId: "fear", casterId: "b", targetId: "b",
|
||||
remainingTurns: 5, data: {},
|
||||
} as never);
|
||||
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";
|
||||
@@ -346,3 +346,131 @@ describe("cast modifiers", () => {
|
||||
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"], deckRev: 14 });
|
||||
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 (rules rev 15)", () => {
|
||||
function sightRig(deckRev: number) {
|
||||
let { state } = createGame({ playerIds: ["holder", "pursuer"], seed: 42, sets: ["basic"], deckRev });
|
||||
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("rev 15: the holder blasts the pursuer through the doorway", () => {
|
||||
let { state, cell, side, holder, pursuer } = sightRig(15);
|
||||
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(15);
|
||||
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);
|
||||
});
|
||||
|
||||
it("earlier revisions keep the blocked sightline, hold or no", () => {
|
||||
let { state, cell, side, holder, pursuer } = sightRig(14);
|
||||
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 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";
|
||||
|
||||
@@ -161,6 +161,58 @@ describe("expansion combat cards", () => {
|
||||
expect(r.error).toMatch(/blocked/);
|
||||
}
|
||||
});
|
||||
|
||||
it("idiot at rev 23 forbids item handling and punches but allows counteractions", () => {
|
||||
let { state } = createGame({
|
||||
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 23,
|
||||
});
|
||||
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 at rev 23 has no effect on a victim carrying their own treasure", () => {
|
||||
let { state } = createGame({
|
||||
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 23,
|
||||
});
|
||||
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 +335,192 @@ describe("ambushes (async interrupts)", () => {
|
||||
expect(state.ambushes.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("swap meet trades carried items (rules rev 26)", () => {
|
||||
function rig(deckRev: number) {
|
||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev });
|
||||
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(26);
|
||||
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);
|
||||
});
|
||||
|
||||
it("older revisions matched only object-typed cards and replay so", () => {
|
||||
const { state, attacker, defender } = rig(25);
|
||||
expect(state.players.find((p) => p.id === attacker)!.hand.some((c) => c.cardId === "dagger")).toBe(true);
|
||||
expect(state.players.find((p) => p.id === defender)!.hand.some((c) => c.cardId === "blaster-wand")).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"], deckRev: 26 });
|
||||
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"], deckRev: 26 });
|
||||
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"], deckRev: 26 });
|
||||
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 (rev 27)", () => {
|
||||
function reflectedSwapRig(reflectorChoice?: string) {
|
||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 27 });
|
||||
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 (rules rev 36)", () => {
|
||||
it("a victim against a wall bends the line instead of standing still", () => {
|
||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 36 });
|
||||
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,6 +1,6 @@
|
||||
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 } 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";
|
||||
@@ -212,3 +212,44 @@ 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 (rules rev 24)", () => {
|
||||
/** 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(deckRev: number, behind: "open" | "wall") {
|
||||
const { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"], deckRev });
|
||||
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(24, "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(24, "wall");
|
||||
expect(cellKey(me.position)).toBe(cellKey(B));
|
||||
expect(me.life).toBe(14);
|
||||
});
|
||||
|
||||
it("older revisions keep the flat full-range wash for replay fidelity", () => {
|
||||
const { me, Bnn } = rig(23, "open");
|
||||
expect(cellKey(me.position)).toBe(cellKey(Bnn));
|
||||
expect(me.life).toBe(15);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,137 @@ describe("treasures and victory", () => {
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("elimination by lost treasures drops what the fallen carried (rev 30)", () => {
|
||||
it("the carried treasure lands where the wizard stood", () => {
|
||||
let { state } = createGame({ playerIds: ["a", "b", "c"], seed: 42, sets: ["basic"], deckRev: 30 });
|
||||
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 (rules rev 31)", () => {
|
||||
function grabRig() {
|
||||
let { state } = createGame({ playerIds: ["thief", "owner"], seed: 42, sets: ["basic"], deckRev: 31 });
|
||||
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 is refused in this vintage", () => {
|
||||
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"], deckRev: 31 });
|
||||
giveCard(state, state.players[state.turn.activeIndex]!.id, "ward", "W", 0);
|
||||
const r = applyCommand(state, state.players[state.turn.activeIndex]!.id, { type: "armWard", armed: true });
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("an ambushed teleport carries its destination (rules rev 35)", () => {
|
||||
it("the trap springs and the victim lands where the trapper said", () => {
|
||||
let { state } = createGame({ playerIds: ["trapper", "prey"], seed: 42, sets: ["basic", "expansion1"], deckRev: 35 });
|
||||
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;
|
||||
state = applyCommand(state, "trapper", { type: "endTurn", draw: 0 }).ok
|
||||
? (applyCommand(state, "trapper", { type: "endTurn", draw: 0 }) as { state: typeof state }).state : 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -435,3 +435,126 @@ describe("relocation past the origin (rules rev 11)", () => {
|
||||
expect(state.boobytraps[0]!.realKey).toBe(cellKey(moved));
|
||||
});
|
||||
});
|
||||
|
||||
describe("junction alterations roll for their sector (rules rev 20)", () => {
|
||||
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"], deckRev: 20 });
|
||||
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 (rules rev 29)", () => {
|
||||
function shimmerRig() {
|
||||
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"], deckRev: 29 });
|
||||
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"], deckRev: 16 });
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"],
|
||||
@@ -210,3 +205,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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
getRoom,
|
||||
joinRoom,
|
||||
loadPersistedRooms,
|
||||
runningRooms,
|
||||
addAutomaton,
|
||||
addChat,
|
||||
driveOneAutomaton,
|
||||
@@ -70,6 +71,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);
|
||||
|
||||
// One process serves both the built client and the websocket, so production
|
||||
// needs only a TLS proxy in front (or nothing, on a LAN).
|
||||
@@ -174,7 +180,14 @@ function roomInfo(room: Room) {
|
||||
started: room.state !== null,
|
||||
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}`,
|
||||
]),
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -193,22 +206,25 @@ function broadcast(room: Room, makeMessage: (playerId: PlayerId) => unknown): vo
|
||||
*/
|
||||
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 +261,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);
|
||||
};
|
||||
@@ -359,6 +377,7 @@ 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 }));
|
||||
if (room.state?.phase === "finished") broadcast(room, () => roomInfo(room));
|
||||
botRemark(room, session.playerId, result.events as { type: string }[]);
|
||||
runBots(room);
|
||||
break;
|
||||
|
||||
@@ -54,7 +54,7 @@ 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;
|
||||
const RULES_REV = 36;
|
||||
|
||||
const ROOM_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||
|
||||
@@ -91,6 +91,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 +275,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] ??
|
||||
|
||||
@@ -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,15 @@
|
||||
<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 type { BoardFx } from "./fx";
|
||||
import { colorIndexOf as sharedColorIndex, wizardColor } from "./colors";
|
||||
import { CREATURE_ART, objectArt, TERRAIN_ART, tokenArt } from "./art";
|
||||
import EdgeGlyph 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 +25,8 @@
|
||||
onCreatureClick,
|
||||
onWarpClick,
|
||||
onCellPeek,
|
||||
onIllusionClick,
|
||||
onEdgePeek,
|
||||
markedCell = null,
|
||||
markedCells = null,
|
||||
litCells = null,
|
||||
@@ -24,6 +34,7 @@
|
||||
ghostSlots = null,
|
||||
onGhostClick,
|
||||
effects = null,
|
||||
sightTrace = null,
|
||||
}: {
|
||||
view: GameView;
|
||||
edgeSelectMode?: boolean;
|
||||
@@ -35,6 +46,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 +64,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 +105,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 +118,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 +144,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 +164,45 @@
|
||||
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. Same measure the
|
||||
// engine's refusal takes.
|
||||
const fearCells = $derived.by(() => {
|
||||
const out = new Set<string>();
|
||||
const W = view.board.width;
|
||||
const H = view.board.height;
|
||||
for (const fp of view.players) {
|
||||
if (!fp.alive) continue;
|
||||
if (!view.sustained.some((e) => e.cardId === "fear" && e.targetId === fp.id)) continue;
|
||||
for (let dx = -3; dx <= 3; dx++) {
|
||||
for (let dy = -3 + Math.abs(dx); dy <= 3 - Math.abs(dx); dy++) {
|
||||
const wx = ((fp.position.x + dx) % W + W) % W;
|
||||
const wy = ((fp.position.y + dy) % H + H) % H;
|
||||
const k = `${wx},${wy}`;
|
||||
if (view.board.cells[k]) 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 +217,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 +311,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 +333,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 +362,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 +391,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 +412,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"}
|
||||
{@const lockTitle = e.lock === "removed" ? "lock removed — swings free"
|
||||
: e.lock === "jammed" ? "lock jammed — sealed for good"
|
||||
: 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>
|
||||
{#if e.state === "firewall"}
|
||||
<FirewallEdge x={e.x} y={e.y} kind={e.kind === "V" ? "V" : "H"} onpeek={onEdgePeek} />
|
||||
{: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>
|
||||
{@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}
|
||||
<EdgeGlyph x={e.x} y={e.y} kind={e.kind === "V" ? "V" : "H"}
|
||||
state={e.state === "door" ? "door" : "wall"}
|
||||
lock={(e.lock ?? null) as "removed" | "jammed" | "held" | "ajar" | null}
|
||||
title={lockTitle} damage={view.wallDamage[`${e.kind}:${e.x},${e.y}`] ?? 0}
|
||||
onpeek={onEdgePeek} />
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
@@ -383,10 +435,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 +448,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 +531,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)}
|
||||
{@const cy = p.position.y * CELL + CELL * 0.36}
|
||||
<!-- 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,87 +549,91 @@
|
||||
>
|
||||
{#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}
|
||||
{/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)}
|
||||
{@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
|
||||
role="button" tabindex="-1" class="creature"
|
||||
onclick={(ev) => { ev.stopPropagation(); if (peekFired) { peekFired = false; return; } onCreatureClick?.(c.id); }}
|
||||
onpointerdown={() => pressCell(c.position)}
|
||||
onpointerup={releasePress}
|
||||
onpointerleave={releasePress}
|
||||
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}
|
||||
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>
|
||||
<rect
|
||||
x={ccx - CELL * 0.26} y={ccy - CELL * 0.26}
|
||||
width={CELL * 0.52} height={CELL * 0.52}
|
||||
class="creature-ring"
|
||||
class:selected={c.id === selectedCreatureId}
|
||||
stroke={playerColor(c.controllerId)}
|
||||
/>
|
||||
{:else}
|
||||
<rect
|
||||
x={ccx - 10} y={ccy - 10} width={20} height={20} rx="3"
|
||||
transform={`rotate(45 ${ccx} ${ccy})`}
|
||||
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>
|
||||
{/if}
|
||||
</g>
|
||||
{/each}
|
||||
|
||||
<!-- 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); }}
|
||||
onpointerdown={() => pressCell(c.position)}
|
||||
onpointerup={releasePress}
|
||||
onpointerleave={releasePress}
|
||||
onkeydown={() => {}}
|
||||
>
|
||||
{#if USE_TOKEN_ART && CREATURE_ART[c.kind]}
|
||||
<TokenArt
|
||||
href={tokenArt(CREATURE_ART[c.kind]!, "creatures")}
|
||||
x={-CELL * 0.26} y={-CELL * 0.26}
|
||||
width={CELL * 0.52} height={CELL * 0.52}
|
||||
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={-CELL * 0.26} y={-CELL * 0.26}
|
||||
width={CELL * 0.52} height={CELL * 0.52}
|
||||
class="creature-ring"
|
||||
class:selected={c.id === selectedCreatureId}
|
||||
stroke={playerColor(c.controllerId)}
|
||||
/>
|
||||
{:else}
|
||||
<rect
|
||||
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="0" y="4" class="creature-label">{c.kind === "fire-imp" ? "I" : c.kind === "democratic-monster" ? "D" : c.kind[0]?.toUpperCase()}</text>
|
||||
{/if}
|
||||
</g>
|
||||
</g>
|
||||
{/each}
|
||||
|
||||
<!-- edge selection hitboxes -->
|
||||
@@ -587,11 +656,24 @@
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
<!-- FEAR's bubble: every square within three WALKED spaces (warps count) -->
|
||||
{#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 +701,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 +713,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 +795,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 +832,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 +844,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,74 @@
|
||||
<script lang="ts">
|
||||
// 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?: "removed" | "jammed" | "held" | "ajar" | 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}`);
|
||||
|
||||
// Press-and-hold peeks the segment, mirroring the board's cell gesture.
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
function press() {
|
||||
if (!onpeek) return;
|
||||
timer = setTimeout(() => onpeek?.(tip), 450);
|
||||
}
|
||||
function release() {
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
</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,117 @@
|
||||
<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).
|
||||
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";
|
||||
let peekTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
function press() {
|
||||
if (!onpeek) return;
|
||||
peekTimer = setTimeout(() => onpeek?.(TIP), 450);
|
||||
}
|
||||
function release() {
|
||||
if (peekTimer) clearTimeout(peekTimer);
|
||||
peekTimer = null;
|
||||
}
|
||||
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>a wall of fire — passing through burns for 4</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,
|
||||
@@ -174,6 +175,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,9 @@
|
||||
<script lang="ts">
|
||||
import Board from "./Board.svelte";
|
||||
import { humanize } from "./net.svelte";
|
||||
import { fxForEvents, fxTtl, type BoardFx } from "./fx";
|
||||
import { scheduleFx, type BoardFx } from "./fx";
|
||||
import { prefs } from "./prefs.svelte";
|
||||
import { stackSightTrace } from "@wizwar/engine";
|
||||
import type { GameEvent, GameView } from "@wizwar/engine";
|
||||
|
||||
let {
|
||||
@@ -21,19 +23,21 @@
|
||||
);
|
||||
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;
|
||||
const timers: ReturnType<typeof setTimeout>[] = [];
|
||||
for (const { fx, delay } of fxForEvents(step.events, step.view)) {
|
||||
timers.push(setTimeout(() => {
|
||||
boardFx = [...boardFx, fx];
|
||||
timers.push(setTimeout(() => (boardFx = boardFx.filter((f) => f.id !== fx.id)), fxTtl(fx.kind)));
|
||||
}, delay));
|
||||
}
|
||||
return () => { timers.forEach(clearTimeout); boardFx = []; };
|
||||
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 = []; };
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
@@ -63,7 +67,7 @@
|
||||
<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} />
|
||||
<Board view={step.view} effects={boardFx} {sightTrace} />
|
||||
</div>
|
||||
<div class="replay-caption">
|
||||
<strong>{step.actor}</strong>
|
||||
|
||||
@@ -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}
|
||||
@@ -0,0 +1,175 @@
|
||||
<script lang="ts">
|
||||
// The token workshop: every token in both arts, side by side — the
|
||||
// photographed physical set and the hand-drawn SVG contingency. Open
|
||||
// with /?tokens under `npm run dev`; edits to public/tokens-svg/*.svg
|
||||
// show on the next refresh. Board size and close-up for each.
|
||||
import TokenArt from "./TokenArt.svelte";
|
||||
|
||||
const GROUPS: { title: string; files: string[] }[] = [
|
||||
{ title: "Wizards", files: ["wizard-0", "wizard-1", "wizard-2", "wizard-3", "wizard-4", "wizard-5"] },
|
||||
{ title: "Treasures", files: ["treasure-0", "treasure-1", "treasure-2", "treasure-3", "treasure-4", "treasure-5"] },
|
||||
{
|
||||
title: "Monsters",
|
||||
files: ["troll", "skeleton", "wraith", "fire-imp", "democratic-monster", "shadow", "alter-ego"],
|
||||
},
|
||||
{
|
||||
title: "Terrain",
|
||||
files: [
|
||||
"solid-stone", "thorn-bush", "rosebush", "killer-ooze", "slime", "dustcloud",
|
||||
"tacks", "pit", "safe",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Objects & markers",
|
||||
files: [
|
||||
"dagger", "rock", "magic-stone", "magic-wand", "master-key", "boobytrap",
|
||||
"dimensional-warp",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// Wall-segment effects have never been tokens: the maze draws them as edge
|
||||
// treatments. Shown here exactly as the board mounts them.
|
||||
import EdgeGlyph from "./EdgeGlyph.svelte";
|
||||
import FirewallEdge from "./FirewallEdge.svelte";
|
||||
import IllusionShimmer from "./IllusionShimmer.svelte";
|
||||
const EDGE_PIECES: {
|
||||
caption: string;
|
||||
state?: "wall" | "door";
|
||||
lock?: "removed" | "jammed" | "held" | "ajar" | null;
|
||||
special?: "firewall" | "shimmer" | "known-illusion";
|
||||
}[] = [
|
||||
{ caption: "wall", state: "wall" },
|
||||
{ caption: "door (locked)", state: "door" },
|
||||
{ caption: "door, picked open (ajar)", state: "door", lock: "ajar" },
|
||||
{ caption: "door, held open", state: "door", lock: "held" },
|
||||
{ caption: "door, lock removed", state: "door", lock: "removed" },
|
||||
{ caption: "door, lock jammed", state: "door", lock: "jammed" },
|
||||
{ caption: "wall of fire", special: "firewall" },
|
||||
{ caption: "illusion wall (untested)", special: "shimmer" },
|
||||
{ caption: "illusion wall (you know it's fake)", special: "known-illusion" },
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="gallery">
|
||||
<h1>The Token Workshop</h1>
|
||||
<p class="sub">
|
||||
Every token, twice: the photographed physical set beside the hand-drawn
|
||||
contingency. Edit <code>public/tokens-svg/</code> and refresh. Small is
|
||||
board size; large is the close-up the peek modal shows.
|
||||
<a href="/?fx">→ the flourish workshop</a>
|
||||
</p>
|
||||
{#each GROUPS as group (group.title)}
|
||||
<h2>{group.title}</h2>
|
||||
<div class="grid">
|
||||
{#each group.files as f (f)}
|
||||
<figure>
|
||||
<div class="pair">
|
||||
<div class="col">
|
||||
<img class="big" src={`/tokens/${f}.png`} alt={`${f} (photo)`} />
|
||||
<img class="small" src={`/tokens/${f}.png`} alt="" />
|
||||
<span class="tag">photo</span>
|
||||
</div>
|
||||
<div class="col">
|
||||
<svg viewBox="0 0 96 96" class="big">
|
||||
<TokenArt href={`/tokens-svg/${f}.svg`} x={0} y={0} width={96} height={96} />
|
||||
</svg>
|
||||
<svg viewBox="0 0 28 28" class="small">
|
||||
<TokenArt href={`/tokens-svg/${f}.svg`} x={0} y={0} width={28} height={28} />
|
||||
</svg>
|
||||
<span class="tag">drawn</span>
|
||||
</div>
|
||||
</div>
|
||||
<figcaption>{f}</figcaption>
|
||||
</figure>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<h2>Walls & doors — as the maze draws them</h2>
|
||||
<p class="sub">
|
||||
Wall-segment effects have never been tokens: locks picked, jammed, and
|
||||
removed, held doors, standing fires, and illusions are edge treatments,
|
||||
shown here with the very components the board mounts.
|
||||
</p>
|
||||
<div class="grid">
|
||||
{#each EDGE_PIECES as piece (piece.caption)}
|
||||
<figure>
|
||||
<svg viewBox="20 20 104 104" class="edge-demo">
|
||||
{#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}
|
||||
{#if piece.special === "firewall"}
|
||||
<FirewallEdge x={1} y={0} kind="H" />
|
||||
{:else if piece.special === "shimmer"}
|
||||
<EdgeGlyph x={1} y={0} kind="H" state="wall" />
|
||||
<IllusionShimmer x={1} y={0} kind="H" />
|
||||
{:else if piece.special === "known-illusion"}
|
||||
<line x1={48} y1={48} x2={96} y2={48} class="known-illusion" />
|
||||
{:else}
|
||||
<EdgeGlyph x={1} y={0} kind="H" state={piece.state!} lock={piece.lock ?? null} />
|
||||
{/if}
|
||||
</svg>
|
||||
<figcaption>{piece.caption}</figcaption>
|
||||
</figure>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.gallery {
|
||||
min-height: 100vh;
|
||||
background: #171a20;
|
||||
color: #d8d2c0;
|
||||
font-family: "Archivo Narrow", system-ui, sans-serif;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
h1, h2 {
|
||||
font-family: "Oswald", sans-serif;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.14em;
|
||||
color: #e9e1cb;
|
||||
margin: 0 0 0.2rem;
|
||||
}
|
||||
h1 { font-size: 1.2rem; }
|
||||
h2 { font-size: 0.95rem; margin-top: 1.4rem; }
|
||||
.sub { color: #8d8672; margin: 0 0 1rem; }
|
||||
.sub code { color: #c9a72a; }
|
||||
.sub a { color: #c9a72a; }
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
|
||||
gap: 0.9rem;
|
||||
}
|
||||
figure {
|
||||
margin: 0;
|
||||
background: #efe8d4;
|
||||
border-radius: 4px;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
.pair { display: flex; gap: 0.6rem; justify-content: center; }
|
||||
.col { display: flex; flex-direction: column; align-items: center; gap: 0.25rem; }
|
||||
.big { width: 72px; height: 72px; display: block; }
|
||||
.small { width: 28px; height: 28px; display: block; }
|
||||
img.big, img.small { object-fit: cover; border-radius: 3px; }
|
||||
.tag {
|
||||
font-family: "Courier Prime", monospace;
|
||||
font-size: 0.6rem;
|
||||
color: #8a7a5e;
|
||||
}
|
||||
.edge-demo { width: 100%; display: block; }
|
||||
.cell {
|
||||
fill: #efe8d4;
|
||||
stroke: rgba(95, 74, 51, 0.25);
|
||||
stroke-width: 1;
|
||||
}
|
||||
/* Mirrors Board's known-illusion ghost: dashed, violet, see-through. */
|
||||
.known-illusion { stroke: #7a6f9a; stroke-width: 4; stroke-dasharray: 6 5; opacity: 0.7; }
|
||||
figcaption {
|
||||
font-family: "Courier Prime", monospace;
|
||||
font-size: 0.72rem;
|
||||
color: #43331f;
|
||||
text-align: center;
|
||||
padding-top: 0.35rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,38 @@
|
||||
// Which art set a token draws from: the player's preference, with the
|
||||
// workshop's per-category query params (?svgPlayers=true, ...) as a
|
||||
// deliberate override for editing sessions under `npm run dev`.
|
||||
import { prefs } from "./prefs.svelte";
|
||||
|
||||
export type ArtCategory = "players" | "creatures" | "terrain" | "objects";
|
||||
|
||||
const q = new URLSearchParams(typeof location === "undefined" ? "" : location.search);
|
||||
const PARAM: Record<ArtCategory, string> = {
|
||||
players: "svgPlayers", creatures: "svgCreatures", terrain: "svgTerrain", objects: "svgObjects",
|
||||
};
|
||||
|
||||
export function tokenArt(file: string, cat: ArtCategory): string {
|
||||
const override = q.get(PARAM[cat]);
|
||||
const drawn = override !== null ? override === "true" : prefs.art === "drawn";
|
||||
return drawn ? `/tokens-svg/${file}.svg` : `/tokens/${file}.png`;
|
||||
}
|
||||
|
||||
// Which token file depicts each board thing (shared by the board renderer
|
||||
// and the card-peek modal). Anything without an entry has no token art.
|
||||
export 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",
|
||||
};
|
||||
export 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",
|
||||
};
|
||||
export 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;
|
||||
}
|
||||
@@ -1,13 +1,12 @@
|
||||
<script lang="ts">
|
||||
import type { FxOf } from "../fx";
|
||||
import { center } from "./geom";
|
||||
|
||||
let { fx }: { fx: FxOf<"bolt"> } = $props();
|
||||
const uid = $props.id();
|
||||
|
||||
/** A fresh jagged path every strike, plus two forks off mid-joints. */
|
||||
const geom = $derived.by(() => {
|
||||
const a = center(fx.from), b = center(fx.to);
|
||||
const a = fx.a, b = fx.b;
|
||||
const segs = 6;
|
||||
const joints: { x: number; y: number }[] = [a];
|
||||
for (let i = 1; i < segs; i++) {
|
||||
@@ -52,7 +51,7 @@
|
||||
}
|
||||
.bolt .fork {
|
||||
fill: none;
|
||||
stroke: #fff6b0;
|
||||
stroke: #f5d54a;
|
||||
stroke-width: 1.6;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
@@ -62,7 +61,6 @@
|
||||
stroke-width: 2;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
.bolt .fork { stroke: #f5d54a; }
|
||||
.bolt { animation: bolt-flicker 0.5s steps(2, jump-none) forwards; }
|
||||
@keyframes bolt-flicker {
|
||||
0% { opacity: 0; } 15% { opacity: 1; } 40% { opacity: 0.3; }
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
<script lang="ts">
|
||||
import type { FxOf } from "../fx";
|
||||
import { center } from "./geom";
|
||||
let { fx }: { fx: FxOf<"die-drop"> } = $props();
|
||||
const c = $derived(center(fx.at));
|
||||
const aim = $derived(center(fx.aim));
|
||||
const drifted = $derived(fx.at.x !== fx.aim.x || fx.at.y !== fx.aim.y);
|
||||
const DEBRIS = [15, 80, 150, 210, 275, 340];
|
||||
</script>
|
||||
|
||||
<!-- The aiming mark: where the caster prayed it would land -->
|
||||
<circle cx={aim.x} cy={aim.y} r="15" class="aim" />
|
||||
{#if drifted}
|
||||
<line x1={aim.x} y1={aim.y} x2={c.x} y2={c.y} class="drift" />
|
||||
{/if}
|
||||
<!-- The gathering shadow, then the die itself, out of a clear sky -->
|
||||
<ellipse cx={c.x} cy={c.y + 8} rx="16" ry="7" class="die-shadow" />
|
||||
<g class="die" style={`transform-origin: ${c.x}px ${c.y}px`}>
|
||||
<g transform={`translate(${c.x} ${c.y})`}>
|
||||
<rect x="-15" y="-15" width="30" height="30" rx="6" class="die-body" />
|
||||
<!-- a four: the drift roll made manifest -->
|
||||
<circle cx="-7" cy="-7" r="3.2" class="pip" />
|
||||
<circle cx="7" cy="-7" r="3.2" class="pip" />
|
||||
<circle cx="-7" cy="7" r="3.2" class="pip" />
|
||||
<circle cx="7" cy="7" r="3.2" class="pip" />
|
||||
</g>
|
||||
</g>
|
||||
<!-- Impact: ring and flung grit -->
|
||||
<circle cx={c.x} cy={c.y} r="14" class="impact" />
|
||||
{#each DEBRIS as deg, i (deg)}
|
||||
<line
|
||||
x1={c.x + 14 * Math.cos((deg * Math.PI) / 180)}
|
||||
y1={c.y + 14 * Math.sin((deg * Math.PI) / 180)}
|
||||
x2={c.x + 26 * Math.cos((deg * Math.PI) / 180)}
|
||||
y2={c.y + 26 * Math.sin((deg * Math.PI) / 180)}
|
||||
class={`grit g${i % 3}`}
|
||||
style={`transform-origin: ${c.x}px ${c.y}px`}
|
||||
/>
|
||||
{/each}
|
||||
|
||||
<style>
|
||||
.aim {
|
||||
fill: none;
|
||||
stroke: #8d2f23;
|
||||
stroke-width: 2;
|
||||
stroke-dasharray: 5 4;
|
||||
opacity: 0;
|
||||
animation: aim-mark 0.5s ease-out forwards;
|
||||
}
|
||||
.drift {
|
||||
stroke: #8d2f23;
|
||||
stroke-width: 1.5;
|
||||
stroke-dasharray: 3 4;
|
||||
opacity: 0;
|
||||
animation: drift-line 0.5s ease-out 0.55s forwards;
|
||||
}
|
||||
.die-shadow {
|
||||
fill: rgba(43, 34, 24, 0.5);
|
||||
animation: shadow-loom 0.55s ease-in forwards;
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
}
|
||||
.die {
|
||||
animation: die-fall 0.55s cubic-bezier(0.5, 0, 0.9, 0.6) forwards,
|
||||
die-settle 0.9s ease-out 0.55s forwards;
|
||||
}
|
||||
.die-body {
|
||||
fill: #efe8d4;
|
||||
stroke: #2b2218;
|
||||
stroke-width: 2.5;
|
||||
}
|
||||
.pip { fill: #2b2218; }
|
||||
.impact {
|
||||
fill: none;
|
||||
stroke: #7c6a4f;
|
||||
stroke-width: 3;
|
||||
opacity: 0;
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
animation: impact-ring 0.5s ease-out 0.55s forwards;
|
||||
}
|
||||
.grit {
|
||||
stroke: #7c6a4f;
|
||||
stroke-width: 2;
|
||||
stroke-linecap: round;
|
||||
opacity: 0;
|
||||
animation: grit-fly 0.4s ease-out 0.58s forwards;
|
||||
}
|
||||
.grit.g1 { animation-delay: 0.62s; }
|
||||
.grit.g2 { animation-delay: 0.66s; }
|
||||
@keyframes aim-mark {
|
||||
0% { opacity: 0; transform: scale(1.6); transform-origin: center; transform-box: fill-box; }
|
||||
100% { opacity: 0.8; transform: scale(1); }
|
||||
}
|
||||
@keyframes drift-line {
|
||||
0% { opacity: 0; }
|
||||
100% { opacity: 0.7; }
|
||||
}
|
||||
@keyframes shadow-loom {
|
||||
0% { opacity: 0; transform: scale(0.2); }
|
||||
100% { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
@keyframes die-fall {
|
||||
0% { opacity: 0; transform: translateY(-150px) scale(2.4) rotate(35deg); }
|
||||
30% { opacity: 1; }
|
||||
100% { transform: translateY(0) scale(1) rotate(0deg); }
|
||||
}
|
||||
@keyframes die-settle {
|
||||
0% { transform: translateY(0) scale(1.15, 0.85); }
|
||||
25% { transform: translateY(-6px) scale(1); }
|
||||
45% { transform: translateY(0) scale(1.05, 0.95); }
|
||||
100% { transform: translateY(0) scale(1); }
|
||||
}
|
||||
@keyframes impact-ring {
|
||||
0% { opacity: 0.9; transform: scale(0.4); }
|
||||
100% { opacity: 0; transform: scale(2.4); }
|
||||
}
|
||||
@keyframes grit-fly {
|
||||
0% { opacity: 0.9; transform: scale(0.7); }
|
||||
100% { opacity: 0; transform: scale(1.8); }
|
||||
}
|
||||
</style>
|
||||
@@ -20,9 +20,9 @@
|
||||
</script>
|
||||
|
||||
<g style={`--ax: ${away.x}px; --ay: ${away.y}px; --bx: ${-away.x}px; --by: ${away.y}px`}>
|
||||
<circle cx={c.x - 6} cy={c.y} r="7" fill={`url(#dust-soft-${uid})`} class="puff there" />
|
||||
<circle cx={c.x - 6} cy={c.y} r="7" fill={`url(#dust-soft-${uid})`} class="puff" />
|
||||
<circle cx={c.x + 5} cy={c.y - 2} r="5" fill={`url(#dust-soft-${uid})`} class="puff back late" />
|
||||
<circle cx={c.x} cy={c.y + 3} r="6" fill={`url(#dust-soft-${uid})`} class="puff there later" />
|
||||
<circle cx={c.x} cy={c.y + 3} r="6" fill={`url(#dust-soft-${uid})`} class="puff later" />
|
||||
<g class="grit">
|
||||
<circle cx={c.x - 4} cy={c.y - 5} r="1.3" />
|
||||
<circle cx={c.x + 6} cy={c.y + 2} r="1.6" />
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
<script lang="ts">
|
||||
import type { FxOf } from "../fx";
|
||||
import { center } from "./geom";
|
||||
|
||||
let { fx }: { fx: FxOf<"fireball"> } = $props();
|
||||
const uid = $props.id();
|
||||
|
||||
const a = $derived(center(fx.from));
|
||||
const b = $derived(center(fx.to));
|
||||
const a = $derived(fx.a);
|
||||
const b = $derived(fx.b);
|
||||
</script>
|
||||
|
||||
<defs>
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
<script lang="ts">
|
||||
import type { FxOf } from "../fx";
|
||||
import { center } from "./geom";
|
||||
|
||||
let { fx }: { fx: FxOf<"streak"> } = $props();
|
||||
const a = $derived(center(fx.from));
|
||||
const b = $derived(center(fx.to));
|
||||
const a = $derived(fx.a);
|
||||
const b = $derived(fx.b);
|
||||
const d = $derived(`M ${a.x} ${a.y} L ${b.x} ${b.y}`);
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
<script lang="ts">
|
||||
import type { FxOf } from "../fx";
|
||||
import { center } from "./geom";
|
||||
|
||||
let { fx }: { fx: FxOf<"waterbolt"> } = $props();
|
||||
const uid = $props.id();
|
||||
|
||||
const a = $derived(center(fx.from));
|
||||
const b = $derived(center(fx.to));
|
||||
const a = $derived(fx.a);
|
||||
const b = $derived(fx.b);
|
||||
</script>
|
||||
|
||||
<defs>
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
// file; to add one, create the component, extend BoardFx in ../fx.ts, and
|
||||
// register it here.
|
||||
import type { Component } from "svelte";
|
||||
import type { BoardFx } from "../fx";
|
||||
import type { BoardFx, FxOf } from "../fx";
|
||||
import Absorb from "./Absorb.svelte";
|
||||
import Bolt from "./Bolt.svelte";
|
||||
import Burst from "./Burst.svelte";
|
||||
import ChaosSwirl from "./ChaosSwirl.svelte";
|
||||
import Claw from "./Claw.svelte";
|
||||
import DieDrop from "./DieDrop.svelte";
|
||||
import DustPuff from "./DustPuff.svelte";
|
||||
import Fireball from "./Fireball.svelte";
|
||||
import Fireworks from "./Fireworks.svelte";
|
||||
@@ -29,7 +30,7 @@ import ThornSnap from "./ThornSnap.svelte";
|
||||
import Waterbolt from "./Waterbolt.svelte";
|
||||
import Whiff from "./Whiff.svelte";
|
||||
|
||||
export const FX_SPRITES: Record<BoardFx["kind"], Component<{ fx: never }>> = {
|
||||
export const FX_SPRITES: { [K in BoardFx["kind"]]: Component<{ fx: FxOf<K> }> } = {
|
||||
fireball: Fireball,
|
||||
waterbolt: Waterbolt,
|
||||
bolt: Bolt,
|
||||
@@ -49,6 +50,7 @@ export const FX_SPRITES: Record<BoardFx["kind"], Component<{ fx: never }>> = {
|
||||
soul: Soul,
|
||||
fireworks: Fireworks,
|
||||
"chaos-swirl": ChaosSwirl,
|
||||
"die-drop": DieDrop,
|
||||
"pit-fall": PitFall,
|
||||
"ooze-slip": OozeSlip,
|
||||
"tacks-ow": TacksOw,
|
||||
@@ -58,4 +60,4 @@ export const FX_SPRITES: Record<BoardFx["kind"], Component<{ fx: never }>> = {
|
||||
"edge-dust": DustPuff,
|
||||
"sector-spin": SectorGrind,
|
||||
"sector-slide": SectorGrind,
|
||||
} as never;
|
||||
};
|
||||
|
||||
@@ -5,17 +5,25 @@
|
||||
import type { GameEvent, GameView } from "@wizwar/engine";
|
||||
|
||||
type Cell = { x: number; y: number };
|
||||
/** A point in board pixels (travelling effects anchor to token centers). */
|
||||
type Pt = { x: number; y: number };
|
||||
type Side = "N" | "E" | "S" | "W";
|
||||
|
||||
type FxShape =
|
||||
| { kind: "fireball" | "bolt" | "waterbolt" | "streak"; from: Cell; to: Cell }
|
||||
import { CELL } from "./fx-sprites/geom";
|
||||
const cellMid = (c: Cell): Pt => ({ x: c.x * CELL + CELL / 2, y: c.y * CELL + CELL / 2 });
|
||||
/** Where a wizard token's center sits in a cell (mirrors Board.svelte). */
|
||||
const wizMid = (c: Cell): Pt => ({ x: c.x * CELL + CELL / 2, y: c.y * CELL + CELL * 0.36 });
|
||||
|
||||
export type FxShape =
|
||||
| { kind: "fireball" | "bolt" | "waterbolt" | "streak"; a: Pt; b: Pt }
|
||||
| { kind: "burst" | "splash" | "shimmer" | "shield" | "sparkle" | "whiff" | "hit"
|
||||
| "pow" | "claw" | "absorb" | "portal-cell" | "soul" | "fireworks" | "chaos-swirl"
|
||||
| "pit-fall" | "ooze-slip" | "tacks-ow" | "thorn-snap" | "slime-stuck" | "dust-puff"; at: Cell }
|
||||
| { kind: "portal"; cell: Cell; side: Side }
|
||||
| { kind: "sector-spin"; origin: Cell; clockwise: boolean }
|
||||
| { kind: "sector-slide"; from: Cell; to: Cell }
|
||||
| { kind: "edge-dust"; cell: Cell; side: Side };
|
||||
| { kind: "edge-dust"; cell: Cell; side: Side }
|
||||
| { kind: "die-drop"; at: Cell; aim: Cell };
|
||||
export type BoardFx = FxShape & { id: number };
|
||||
/** The narrow type of one effect kind (for sprite components). */
|
||||
export type FxOf<K extends BoardFx["kind"]> = BoardFx & { kind: K };
|
||||
@@ -30,6 +38,7 @@ export function fxTtl(kind: BoardFx["kind"]): number {
|
||||
case "portal": case "portal-cell":
|
||||
case "soul": case "fireworks": case "chaos-swirl":
|
||||
case "sector-spin": case "sector-slide": return 1500;
|
||||
case "die-drop": return 1600;
|
||||
default: return 900;
|
||||
}
|
||||
}
|
||||
@@ -53,6 +62,48 @@ export function fxForEvents(
|
||||
const p = view.players.find((p) => p.id === playerId);
|
||||
return p ? { ...p.position } : null;
|
||||
};
|
||||
/** A wizard token's exact center, fan-out included (mirrors Board.svelte). */
|
||||
const wizardAnchor = (playerId: string): Pt | null => {
|
||||
const p = view.players.find((p) => p.id === playerId && p.alive);
|
||||
if (!p) return null;
|
||||
const group = view.players.filter(
|
||||
(q) => q.alive && q.position.x === p.position.x && q.position.y === p.position.y,
|
||||
);
|
||||
const i = group.findIndex((q) => q.id === playerId);
|
||||
return {
|
||||
x: p.position.x * CELL + CELL / 2 + (group.length > 1 ? (i - (group.length - 1) / 2) * 14 : 0),
|
||||
y: p.position.y * CELL + CELL * 0.36,
|
||||
};
|
||||
};
|
||||
const creatureAnchor = (creatureId: string): Pt | null => {
|
||||
const c = view.creatures.find((c) => c.id === creatureId);
|
||||
if (!c) return null;
|
||||
const group = view.creatures.filter(
|
||||
(q) => q.position.x === c.position.x && q.position.y === c.position.y,
|
||||
);
|
||||
const i = group.findIndex((q) => q.id === creatureId);
|
||||
return {
|
||||
x: c.position.x * CELL + CELL * 0.72 - (group.length > 1 ? i * CELL * 0.26 : 0),
|
||||
y: c.position.y * CELL + CELL * 0.7,
|
||||
};
|
||||
};
|
||||
/** Best anchor for a spot: the named token if it stands there, else the
|
||||
* token-height point of the cell, else its plain center. */
|
||||
const anchorAt = (cell: Cell, id?: string | null): Pt => {
|
||||
if (id) {
|
||||
const a = wizardAnchor(id) ?? creatureAnchor(id);
|
||||
if (a) return a;
|
||||
}
|
||||
const standing = view.players.find(
|
||||
(p) => p.alive && p.position.x === cell.x && p.position.y === cell.y,
|
||||
);
|
||||
if (standing) return wizardAnchor(standing.id) ?? wizMid(cell);
|
||||
const crouching = view.creatures.find(
|
||||
(c) => c.position.x === cell.x && c.position.y === cell.y,
|
||||
);
|
||||
if (crouching) return creatureAnchor(crouching.id) ?? cellMid(cell);
|
||||
return cellMid(cell);
|
||||
};
|
||||
let beat = 0; // successive visuals from one command stagger slightly
|
||||
const push = (fx: FxShape, extraDelay = 0) => {
|
||||
out.push({ fx: { ...fx, id: nextId++ }, delay: beat * 220 + extraDelay });
|
||||
@@ -69,7 +120,7 @@ export function fxForEvents(
|
||||
const to = e.targetCell ?? posOf(e.target);
|
||||
const projectile = PROJECTILES[e.cardId];
|
||||
if (projectile && to) {
|
||||
push({ kind: projectile, from: e.from, to });
|
||||
push({ kind: projectile, a: anchorAt(e.from, e.caster), b: anchorAt(to, e.target) });
|
||||
if (projectile === "fireball") push({ kind: "burst", at: to }, 380);
|
||||
if (projectile === "waterbolt") push({ kind: "splash", at: to }, 380);
|
||||
beat++;
|
||||
@@ -85,7 +136,7 @@ export function fxForEvents(
|
||||
break;
|
||||
case "creatureAttacked": {
|
||||
// The target may be a wizard or a fellow creature.
|
||||
const at = posOf(typeof e.target === "string" ? e.target : null) ??
|
||||
const at = posOf(e.target) ??
|
||||
(() => {
|
||||
const c = view.creatures.find((c) => c.id === e.target);
|
||||
return c ? { ...c.position } : null;
|
||||
@@ -119,7 +170,7 @@ export function fxForEvents(
|
||||
if ((e.redirected || e.reflectedDamage > 0) && back && stand) {
|
||||
// The spell turns in the air and goes home.
|
||||
const kind = (e.attackCardId && PROJECTILES[e.attackCardId]) || "bolt";
|
||||
push({ kind, from: stand, to: back });
|
||||
push({ kind, a: anchorAt(stand, e.defender), b: anchorAt(back, e.attacker) });
|
||||
push({ kind: "hit", at: back }, 380);
|
||||
beat++;
|
||||
} else if (e.fullyStopped && stand) {
|
||||
@@ -128,6 +179,11 @@ export function fxForEvents(
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "homeBasesSwapped":
|
||||
push({ kind: "shimmer", at: e.aHome });
|
||||
push({ kind: "shimmer", at: e.bHome }, 200);
|
||||
beat++;
|
||||
break;
|
||||
case "teleported":
|
||||
push({ kind: "shimmer", at: e.from });
|
||||
push({ kind: "shimmer", at: e.to }, 200);
|
||||
@@ -154,11 +210,37 @@ export function fxForEvents(
|
||||
push({ kind: "portal-cell", at: e.to }, 150);
|
||||
beat++;
|
||||
break;
|
||||
case "warpOpened":
|
||||
push({ kind: "portal", cell: e.a.cell, side: e.a.side });
|
||||
push({ kind: "portal", cell: e.b.cell, side: e.b.side }, 200);
|
||||
beat++;
|
||||
break;
|
||||
case "wallCreated":
|
||||
case "wallDestroyed":
|
||||
push({ kind: "edge-dust", cell: e.edge.cell, side: e.edge.side });
|
||||
beat++;
|
||||
break;
|
||||
case "stoneTurnedToWater": {
|
||||
// The wall (or block) bursts into water: splash both sides of the
|
||||
// vanished edge, or the freed square itself.
|
||||
if (e.at) {
|
||||
push({ kind: "splash", at: e.at });
|
||||
} else if (e.edge) {
|
||||
const n = {
|
||||
x: e.edge.cell.x + (e.edge.side === "E" ? 1 : e.edge.side === "W" ? -1 : 0),
|
||||
y: e.edge.cell.y + (e.edge.side === "S" ? 1 : e.edge.side === "N" ? -1 : 0),
|
||||
};
|
||||
push({ kind: "splash", at: e.edge.cell });
|
||||
push({ kind: "splash", at: n }, 120);
|
||||
}
|
||||
beat++;
|
||||
break;
|
||||
}
|
||||
case "washedBack":
|
||||
// The collapsing wave carries them: a streak per victim.
|
||||
push({ kind: "streak", a: wizMid(e.from), b: anchorAt(e.to, e.player) });
|
||||
beat++;
|
||||
break;
|
||||
case "died": {
|
||||
const at = posOf(e.player);
|
||||
if (at) { push({ kind: "soul", at }, 250); beat++; }
|
||||
@@ -176,21 +258,28 @@ export function fxForEvents(
|
||||
}
|
||||
case "knockedBack":
|
||||
case "shoved":
|
||||
push({ kind: "streak", from: e.from, to: e.to });
|
||||
push({ kind: "streak", a: wizMid(e.from), b: anchorAt(e.to, e.player) });
|
||||
beat++;
|
||||
break;
|
||||
case "objectDragged":
|
||||
push({ kind: "streak", from: e.from, to: e.to });
|
||||
push({ kind: "streak", a: cellMid(e.from), b: anchorAt(e.to, e.what) });
|
||||
beat++;
|
||||
break;
|
||||
case "jumpedPit":
|
||||
push({ kind: "streak", from: e.from, to: e.to });
|
||||
push({ kind: "streak", a: wizMid(e.from), b: anchorAt(e.to, e.player) });
|
||||
beat++;
|
||||
break;
|
||||
case "fellInPit":
|
||||
push({ kind: "pit-fall", at: e.at });
|
||||
beat++;
|
||||
break;
|
||||
case "thumbOfGod":
|
||||
push({ kind: "die-drop", at: e.landedAt, aim: e.aimedAt });
|
||||
beat += 2; // the scattered tokens' streaks follow the impact
|
||||
break;
|
||||
case "tokenScattered":
|
||||
push({ kind: "streak", a: cellMid(e.from), b: cellMid(e.to) });
|
||||
break;
|
||||
case "climbedFromPit": {
|
||||
if (e.success) {
|
||||
const at = posOf(e.player);
|
||||
@@ -221,13 +310,9 @@ export function fxForEvents(
|
||||
return out;
|
||||
}
|
||||
case "sectorRelocated": {
|
||||
// The event records pre-normalization origins; the view holds the
|
||||
// truth. Shift the recorded start by the same correction.
|
||||
const trueTo = view.board.placements[e.sectorIndex]?.origin;
|
||||
if (trueTo) {
|
||||
const dx = trueTo.x - e.to.x, dy = trueTo.y - e.to.y;
|
||||
push({ kind: "sector-slide", from: { x: e.from.x + dx, y: e.from.y + dy }, to: { ...trueTo } });
|
||||
}
|
||||
// The event carries origins in final coordinates — the view at fx
|
||||
// time is still the PRE-move board and cannot be trusted for this.
|
||||
push({ kind: "sector-slide", from: { ...e.finalFrom }, to: { ...e.finalTo } });
|
||||
return out;
|
||||
}
|
||||
default:
|
||||
@@ -236,3 +321,24 @@ export function fxForEvents(
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Schedule a batch's effects into `add`, expiring each after its run.
|
||||
* Returns a cancel that stops pending starts and sweeps what began. */
|
||||
export function scheduleFx(
|
||||
events: GameEvent[], view: GameView,
|
||||
add: (fx: BoardFx) => void, remove: (id: number) => void,
|
||||
): () => void {
|
||||
const timers: ReturnType<typeof setTimeout>[] = [];
|
||||
const started: number[] = [];
|
||||
for (const { fx, delay } of fxForEvents(events, view)) {
|
||||
timers.push(setTimeout(() => {
|
||||
started.push(fx.id);
|
||||
add(fx);
|
||||
timers.push(setTimeout(() => remove(fx.id), fxTtl(fx.kind)));
|
||||
}, delay));
|
||||
}
|
||||
return () => {
|
||||
timers.forEach(clearTimeout);
|
||||
started.forEach(remove);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
// The council of three: each automaton temperament reads YOUR view — the
|
||||
// same cards and board you see, nothing more — and says what it would do
|
||||
// in your robes. Purely advisory; nothing is dispatched.
|
||||
|
||||
import { automatonCommand, cardDef, type Command, type GameView } from "@wizwar/engine";
|
||||
import type { AutomatonStyle } from "@wizwar/engine";
|
||||
|
||||
const DIRECTION: Record<string, string> = { N: "north", S: "south", E: "east", W: "west" };
|
||||
|
||||
const VOICES: Record<AutomatonStyle, { name: string; glyph: string; mood: string }> = {
|
||||
hunter: { name: "The Hunter", glyph: "🏹", mood: "eyes on the gold" },
|
||||
berserker: { name: "The Berserker", glyph: "🔥", mood: "eyes on the blood" },
|
||||
worrier: { name: "The Worrier", glyph: "🛡", mood: "eyes on the exits" },
|
||||
};
|
||||
|
||||
function nameOf(view: GameView, instanceId: string): string {
|
||||
const c = view.yourHand.find((c) => c.instanceId === instanceId);
|
||||
return c ? cardDef(c.cardId).name : "a card";
|
||||
}
|
||||
|
||||
function describe(view: GameView, cmd: Command): string {
|
||||
switch (cmd.type) {
|
||||
case "move":
|
||||
return `step ${DIRECTION[cmd.direction]}`;
|
||||
case "playNumberForMovement":
|
||||
return `play the ${nameOf(view, cmd.instanceId)} for extra movement`;
|
||||
case "cast": {
|
||||
const card = nameOf(view, cmd.instanceId);
|
||||
const at =
|
||||
cmd.target?.kind === "player" ? ` at ${cmd.target.playerId}`
|
||||
: cmd.target?.kind === "cell" ? ` at the square (${cmd.target.cell.x}, ${cmd.target.cell.y})`
|
||||
: cmd.target?.kind === "edge" ? ` on the wall line beside (${cmd.target.cell.x}, ${cmd.target.cell.y})`
|
||||
: cmd.target?.kind === "creature" ? " on the monster"
|
||||
: "";
|
||||
const num = cmd.numberInstanceIds?.length
|
||||
? ` with ${cmd.numberInstanceIds.map((id) => nameOf(view, id)).join(" and ")}`
|
||||
: "";
|
||||
return `cast ${card}${at}${num}`;
|
||||
}
|
||||
case "counteract":
|
||||
return `answer with ${nameOf(view, cmd.instanceId)}`;
|
||||
case "pass":
|
||||
return "let it resolve — nothing in hand is worth spending on this";
|
||||
case "punch":
|
||||
return `punch ${cmd.targetId}`;
|
||||
case "punchWall":
|
||||
return "punch the wall";
|
||||
case "pickUpTreasure":
|
||||
return "pick up the treasure here (that ends the turn's actions)";
|
||||
case "pickUpObject":
|
||||
return "pick up the object here (that ends the turn's actions)";
|
||||
case "dropTreasure":
|
||||
return "drop the treasure here";
|
||||
case "dropObject":
|
||||
return `drop ${nameOf(view, cmd.instanceId)} here`;
|
||||
case "warpStep":
|
||||
return "step through the warp underfoot";
|
||||
case "testIllusion":
|
||||
return "test that shimmering wall — doubting it is free";
|
||||
case "moveCreature":
|
||||
return `march the monster ${DIRECTION[cmd.direction]}`;
|
||||
case "creatureAttack":
|
||||
return `set the monster on ${cmd.targetId}`;
|
||||
case "setAmbush":
|
||||
return `lay an ambush with ${nameOf(view, cmd.instanceId)}`;
|
||||
case "armWard":
|
||||
return cmd.armed ? "arm the Ward over the gold" : "stand the Ward down";
|
||||
case "discard":
|
||||
return `shed ${cmd.instanceIds.map((id) => nameOf(view, id)).join(", ")} to make room`;
|
||||
case "endTurn":
|
||||
return cmd.draw > 0 ? `end the turn and draw ${cmd.draw}` : "end the turn";
|
||||
default:
|
||||
return "bide";
|
||||
}
|
||||
}
|
||||
|
||||
export interface TableHint {
|
||||
name: string;
|
||||
glyph: string;
|
||||
mood: string;
|
||||
advice: string;
|
||||
}
|
||||
|
||||
/** What each temperament would do from this seat, phrased for a novice. */
|
||||
export function tableHints(view: GameView): TableHint[] {
|
||||
const styles: AutomatonStyle[] = ["hunter", "berserker", "worrier"];
|
||||
const out: TableHint[] = [];
|
||||
for (const s of styles) {
|
||||
const v = VOICES[s];
|
||||
let advice: string;
|
||||
try {
|
||||
const cmd = automatonCommand(view, s, "archmage");
|
||||
advice = cmd ? `I would ${describe(view, cmd)}.` : "Nothing calls for us right now — wait for your moment.";
|
||||
} catch {
|
||||
advice = "…I have no counsel here.";
|
||||
}
|
||||
out.push({ name: v.name, glyph: v.glyph, mood: v.mood, advice });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -48,11 +48,11 @@ class LocalGame {
|
||||
/** Set while the device should be handed to the named player. */
|
||||
handoffTo = $state<PlayerId | null>(null);
|
||||
log = $state<string[]>([]);
|
||||
/** The finished game as a reel, each step from its actor's own seat. */
|
||||
/** The opening roll-off, shown once as the boards flip. */
|
||||
openingRolls = $state<{ rolls: Record<string, number[]>; first: string; players: string[] } | null>(null);
|
||||
/** Board flourishes: the app hooks in to animate command results. */
|
||||
onFx: ((events: GameEvent[]) => void) | null = null;
|
||||
/** The finished game as a reel, each step from its actor's own seat. */
|
||||
replaySteps = $state<{ seq: number; actor: PlayerId; events: GameEvent[]; view: GameView }[] | null>(null);
|
||||
view = $derived(
|
||||
this.gameState && this.viewerId ? viewFor(this.gameState, this.viewerId) : null,
|
||||
@@ -136,7 +136,7 @@ class LocalGame {
|
||||
seed,
|
||||
sets: expansion ? ["basic", "expansion1"] : ["basic"],
|
||||
...(colors ? { colors } : {}),
|
||||
deckRev: 14,
|
||||
deckRev: 36,
|
||||
};
|
||||
const { state, events } = createGame(config);
|
||||
for (const e of events) {
|
||||
|
||||
@@ -39,8 +39,9 @@ export function humanize(e: GameEvent): string | null {
|
||||
case "punched": return `${e.attacker} punches ${e.target}!`;
|
||||
case "spellCast": {
|
||||
const num = e.numberValue ? ` with a ${e.numberValue}` : "";
|
||||
const amp = e.amplifies ? `, AMPLIFIED${e.amplifies > 1 ? ` ×${2 ** e.amplifies}` : ""},` : "";
|
||||
const at = e.target ? ` at ${e.target}` : "";
|
||||
return `${e.caster} casts ${cardDef(e.cardId).name}${num}${at}.`;
|
||||
return `${e.caster} casts ${cardDef(e.cardId).name}${num}${amp}${at}.`;
|
||||
}
|
||||
case "counteractionPlayed": return `${e.player} counters with ${cardDef(e.cardId).name}!`;
|
||||
case "counterNullified": return `${cardDef(e.card.cardId).name} is nullified by Anti-Anti!`;
|
||||
@@ -49,18 +50,27 @@ export function humanize(e: GameEvent): string | null {
|
||||
if (e.redirected) return `The spell is reflected back at ${e.attacker}!`;
|
||||
if (e.fullyStopped) return `The attack is completely stopped.`;
|
||||
return null; // the damaged event tells the story
|
||||
case "damaged": return `${e.player} takes ${e.amount} damage (${e.source}) — ${e.lifeAfter} life left.`;
|
||||
case "damaged": {
|
||||
const soak = e.soaks?.map((s) => `${s.what} soaks ${s.amount}`).join(", ");
|
||||
return `${e.player} takes ${e.amount} damage (${e.source}${soak ? ` — ${soak}` : ""}) — ${e.lifeAfter} life left.`;
|
||||
}
|
||||
case "stunned": return `${e.player} is stunned and loses a turn!`;
|
||||
case "knockedBack": return `${e.player} is knocked back ${e.squares} square(s)!`;
|
||||
case "stonesDestroyed": return `${e.player}'s magic stones are destroyed!`;
|
||||
case "wallCreated": return `A wall appears!`;
|
||||
case "wallDestroyed": return e.wasDoor ? `A door is blasted to rubble!` : `A wall crumbles!`;
|
||||
case "warpOpened": return `The outer wall breaches clean through — a new warp opens across the maze!`;
|
||||
case "extraTurnGranted": return `${e.player} speeds up — extra turn banked.`;
|
||||
case "trapSprung": return `${e.player} walked into an old TRAP! Lose a turn.`;
|
||||
case "wardWindow": return `The grab hangs in the air — ${e.owner} clutches something…`;
|
||||
case "trapSprung": return e.cardId === "gift-from-below"
|
||||
? `${e.player} draws GIFT FROM BELOW — it bites for 3, then deals again!`
|
||||
: `${e.player} walked into an old TRAP! Lose a turn.`;
|
||||
case "attackMissed": return e.because === "invisible"
|
||||
? `The attack passes through empty air — ${e.defender} is invisible!`
|
||||
: `${e.defender} is too small to hit — the attack misses!`;
|
||||
case "damageImmune": return `${e.player} is stone — the damage has no effect.`;
|
||||
case "damageImmune": return e.because === "bloodstone"
|
||||
? `${e.player}'s bloodstone drinks the whole blow — no damage.`
|
||||
: `${e.player} is stone — the damage has no effect.`;
|
||||
case "lifeGained": return `${e.player} gains ${e.amount} life (${e.source}) — now ${e.lifeAfter}.`;
|
||||
case "spellSustained": return `${spellName(e.cardId)} settles over ${e.target} (${e.turns} turn${e.turns === 1 ? "" : "s"}).`;
|
||||
case "spellExpired": return `${spellName(e.cardId)} wears off ${e.target}.`;
|
||||
@@ -68,12 +78,15 @@ export function humanize(e: GameEvent): string | null {
|
||||
? `${e.player} teleports across the maze!`
|
||||
: `${e.player} is teleported away by ${e.by}!`;
|
||||
case "positionsSwapped": return `${e.a} and ${e.b} swap places!`;
|
||||
case "homeBasesSwapped": return `${e.a} and ${e.b} swap home bases — the maze's loyalties shift!`;
|
||||
case "cardErased": return e.found
|
||||
? `${e.player}'s ${e.cardId ? cardDef(e.cardId).name : "card"} is erased from their mind!`
|
||||
: `${e.player} wasn't holding that card — the erasure fizzles.`;
|
||||
case "cardsStolen": return `${e.to} steals ${e.count} card(s) from ${e.from}'s thoughts!`;
|
||||
case "handRevealed": return `${e.to} reads ${e.player}'s mind — their hand is revealed.`;
|
||||
case "doorUnlocked": return `${e.player} unlocks a door.`;
|
||||
case "doorHeld": return `${e.player} holds the door open.`;
|
||||
case "doorReleased": return `The held door swings shut.`;
|
||||
case "doorsRelocked": return `The door swings shut and relocks.`;
|
||||
case "doorJammed": return `${e.player} jams a door's lock solid.`;
|
||||
case "lockRemoved": return `${e.player} removes a door's lock for good.`;
|
||||
@@ -130,6 +143,7 @@ export function humanize(e: GameEvent): string | null {
|
||||
case "illusionBelieved": return e.believed ? `${e.player} flinches — the illusion feels real!` : `${e.player} laughs off the illusion.`;
|
||||
case "itemStolen": return `${e.to} picks ${e.from}'s pocket.`;
|
||||
case "itemsSwapped": return `${e.a} and ${e.b} swap items.`;
|
||||
case "swapFizzled": return `${e.player}'s trade comes to nothing — the named items were not there to swap.`;
|
||||
case "wardSprung": return `${e.owner}'s treasure was WARDED — it bites ${e.victim}!`;
|
||||
case "curseRemoved": return `${e.caster} lifts a curse from ${e.target}.`;
|
||||
case "objectEnchanted": return `An object gleams with Swarthmore's enchantment.`;
|
||||
@@ -250,11 +264,11 @@ class Net {
|
||||
transferCode = $state<{ code: string; expiresAt: number } | null>(null);
|
||||
/** Moves you haven't watched yet in the current room. */
|
||||
missedMoves = $state(0);
|
||||
/** A catch-up reel delivered by the server. */
|
||||
/** The opening roll-off, shown once as the boards flip. */
|
||||
openingRolls = $state<{ rolls: Record<string, number[]>; first: string; players: string[] } | null>(null);
|
||||
/** Board flourishes: the app hooks in to animate live event batches. */
|
||||
onFx: ((events: GameEvent[]) => void) | null = null;
|
||||
/** A catch-up reel delivered by the server. */
|
||||
catchUp = $state<{ seq: number; actor: string; events: GameEvent[]; view: GameView; chat?: { player: string; text: string }[] }[] | null>(null);
|
||||
private seen: Record<string, number> = loadSeen();
|
||||
/** Room whose live stream this connection has already shown once: states
|
||||
@@ -326,8 +340,8 @@ class Net {
|
||||
// Only the FIRST state after arriving carries a gap worth
|
||||
// announcing. Later states were watched live: a caught-up
|
||||
// watcher stays caught up, and an announced gap stays FROZEN
|
||||
// (not grown, not wiped) until watched or skipped. A hidden
|
||||
// tab accumulates its gap honestly.
|
||||
// until watched or skipped. A hidden tab accumulates its gap
|
||||
// honestly.
|
||||
if (this.watching === this.roomId && document.visibilityState === "visible") {
|
||||
if (this.missedMoves === 0) this.markSeen();
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
// Player preferences: purely client-side taste, saved on this device.
|
||||
// Nothing here touches the rules — a preference may change what you SEE
|
||||
// or what routine commands the client sends on your behalf, never what
|
||||
// is legal.
|
||||
|
||||
export interface Prefs {
|
||||
/** Token art: the photographed cardboard, or the hand-drawn vectors. */
|
||||
art: "photo" | "drawn";
|
||||
/** Standing on lone grabbable gold at end of turn: pick it up first. */
|
||||
autoGrab: boolean;
|
||||
/** Spell flourishes (the animated effects layer). */
|
||||
flourishes: boolean;
|
||||
/** Pre-filled wizard name for creating and joining games. */
|
||||
wizardName: string;
|
||||
/** Preferred wizard color (0-5), claimed in lobbies when free. */
|
||||
color: number | null;
|
||||
}
|
||||
|
||||
const KEY = "wizwar-prefs";
|
||||
|
||||
function load(): Prefs {
|
||||
const fallback: Prefs = { art: "photo", autoGrab: false, flourishes: true, wizardName: "", color: null };
|
||||
try {
|
||||
const raw = localStorage.getItem(KEY);
|
||||
if (!raw) return fallback;
|
||||
const p = JSON.parse(raw) as Partial<Prefs>;
|
||||
return {
|
||||
art: p.art === "drawn" ? "drawn" : "photo",
|
||||
autoGrab: p.autoGrab === true,
|
||||
flourishes: p.flourishes !== false,
|
||||
wizardName: typeof p.wizardName === "string" ? p.wizardName.slice(0, 20) : "",
|
||||
color: typeof p.color === "number" && p.color >= 0 && p.color <= 5 ? p.color : null,
|
||||
};
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export const prefs = $state<Prefs>(load());
|
||||
|
||||
export function savePrefs(): void {
|
||||
try {
|
||||
localStorage.setItem(KEY, JSON.stringify(prefs));
|
||||
} catch {
|
||||
// A full or blocked localStorage loses persistence, not the session.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
// The rulebook, verbatim, as the table's court of final appeal. Base rules
|
||||
// are the 5th-edition text the 6th edition shipped with; expansion rules are
|
||||
// the Expansion Set 1 sections (this edition has no Expansion 2). Both from
|
||||
// the designer's own wizwar.com (archived 2003-2004).
|
||||
|
||||
export interface RuleSection {
|
||||
title: string;
|
||||
paragraphs: string[];
|
||||
}
|
||||
|
||||
export const RULEBOOK_BASE: RuleSection[] = [
|
||||
{
|
||||
"title": "INTRODUCTION",
|
||||
"paragraphs": [
|
||||
"Wiz War is a game for magical combat in a stone labyrinth. Players attempt to bring two of their opponent's treasures back to their home bases while at the same time guarding their own treasures against theft. A game usually takes from fifteen minutes to an hour to play, and can be played with two or more players."
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "WINNING",
|
||||
"paragraphs": [
|
||||
"There are two ways you can win Wiz War. One way is to eliminate all the other players in wizardly battle. The other way is to obtain two \"treasure chests\" from any of the other players (they need not both be from the same player), then return them, one at a time, to your home bases, and drop them there.",
|
||||
"Alternatively, it is also possible to lose the game by allowing both of the treasures that you protect to be taken to another players' home bases and left there. The moment that both of your treasures sit on other players' home bases, you are out of the game."
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "EQUIPMENT",
|
||||
"paragraphs": [
|
||||
"A four section playing board, a deck of 124 cards, one sheet of cardboard tokens, a rule booklet, and one die (numbered from 1-4). It may be 8-sided or 4-sided, depending on what game edition you have.",
|
||||
"*(6E: 125 cards, two sheets of tokens — see Section 1.)*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "STARTING THE GAME",
|
||||
"paragraphs": [
|
||||
"Each player chooses one of the four sectors, face down, at random. Put these sectors together in any of the setups shown on the last page, depending on the number of players, and turn all the sectors over simultaneously. This is now your playing board.",
|
||||
"Each player starts his playing piece in the exact center of the sector he chose. This center square is his HOME BASE, to which he will try to bring two of his opponents' treasures.",
|
||||
"In each player's sector, his two treasure chests are placed on the small circles evident on each sector board. For the rest of the game, the other players will try to steal these, and he will try to protect them. He in turn will try to steal theirs.",
|
||||
"Shuffle the cards and deal seven, face down, to each player. Put the remainder of the deck aside as a drawing stack. If \"TRAP!\" is drawn on the deal, discard it and redraw.",
|
||||
"Take a sheet of paper and write the players' names and the number \"15\" below each. These are your life-points, and you will lose or gain points as the game progresses. If you get down to \"0\" points, you are considered dead, and you are out of the game. There is no upper limit to the number of points you might gain through actions in the game.",
|
||||
"Turns go clockwise, starting with the player rolling highest on the die."
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "THE BOARD",
|
||||
"paragraphs": [
|
||||
"Notice the letters on the setup diagram on the last page of the rules. These boards have open sides; that is, if you leave a board at point \"A\" on either side, you will reenter at point \"A\" on the opposite side. By the same token, if you smash down a wall right next to the \"A\" exit, then you can enter the opposite board edge at the point relative to where you smashed the wall.",
|
||||
"The apparent double wall at the junction of any two sectors is not double at all; it should be treated as a single wall.",
|
||||
"The AUTO WARP is only used in the three-player game. It does not count as a space; you go directly from one end to the other. For all purposes, treat the connected board edges as though they are adjacent. It serves to connect the three sectors together in such a way as to prevent any unfair advantages for any particular board. If a sector gets RELOCATED through use of the RELOCATE card, then the AUTO WARP is discarded, and only opposite board edges connect. ROTATION has no effect on the AUTO WARP.",
|
||||
"If casting a spell, or checking line of sight through the AUTO WARP, treat it as a straight line, and the two connected boards as though they were adjacent.",
|
||||
"Doors are the narrow sections in the walls that look a bit like doors. They are considered locked at all times, and require special cards from the deck to be opened. After passage through a door, it automatically relocks itself.",
|
||||
"You can, if you like, cast a spell through an opened door without passing through yourself.",
|
||||
"You can't follow someone through a door unless they state they are holding it open for you. There is no way, besides a REMOVE LOCK card, to jam a door open."
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "MOVEMENT",
|
||||
"paragraphs": [
|
||||
"You may move up to three spaces per turn. If you wish, during any turn you may add one NUMBER card to your movement to increase your allowed movement for your turn. If the NUMBER card was \"4\", then you could move up to seven spaces that turn. The die is not used for movement."
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "OBJECTS",
|
||||
"paragraphs": [
|
||||
"**Fixed/Movable Objects:** Fixed objects cannot be moved. These include such things as walls, firewalls, illusion walls, thorn bushes, doors, and solid stone squares. Movable objects include magic stones, treasure chests, the DAGGER, the LARGE ROCK, the WIZARDBLADE, and other players. Other players cannot be picked up, of course, but they can be DRAGged with the DRAG spell. Generally speaking, movable objects do not block line of sight, while fixed objects do.",
|
||||
"**Dropping and Retrieving Objects:** Sometimes you will have objects in your card hand that you do not wish to carry at the time, since you are limited to seven cards in your hand and you may wish to get new cards. In that case, you can drop any or all of them. To do this, you merely state that you are dropping an object, and put an appropriate cardboard token representing that object on the space that your playing piece occupies. You can do this any time during your turn. Anyone else may pick this object up at a later time, if they land on it. The card designating the object, if there is one, is placed next to the sector where the object was dropped. Another player picking this object up must take the card for it into his own hand.",
|
||||
"If an object is thrown, it is considered to have landed in the targeted square unless some barrier, such as a THORNBUSH, is in the way. In that case the object lands immediately before the barrier.",
|
||||
"**YOUR TURN ENDS IF YOU PICK UP ANY OBJECT**, and you may perform no more actions that turn, except for drawing cards. You can't pick up an object out of turn, even if you TELEPORT onto it.",
|
||||
"Treasures are treated like other objects, but **YOU CAN ONLY CARRY ONE AT A TIME!** After all, these things are pretty heavy. You can carry any number of other objects along with the treasure, though. Treasures do not count as a card in your hand. Treasures may NOT be thrown.",
|
||||
"You can carry your own treasure, and may even go retrieve your treasure after someone has placed it on their own home base, in addition to taking yet another player's treasure off of someone else's home base. If you are carrying a treasure, keep the token with the playing piece and do not remove it from the board.",
|
||||
"Objects cannot be forcibly taken from you unless another player has a DROP OBJECT spell card that allows him to do so, or unless he kills you. He cannot paralyze you with MEDUSA, then take everything you have. He must use a card that directly applies to the removal or destruction of a physical object."
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "THE CARDS",
|
||||
"paragraphs": [
|
||||
"There are five different kinds of cards in the game. They are called ATTACK cards, NEUTRAL cards, COUNTER-ACTION cards, NUMBER cards, and MAGIC STONES.",
|
||||
"**ATTACK** cards say \"Attack\" in the upper left-hand corner of each card. **ONLY ONE ATTACK CAN BE USED DURING EACH PLAYER TURN.** In the upper right-hand corner, the abbreviation \"L.O.S.\" might appear. This means that a spell can only be used if a player has a clear line of sight to the person or object the spell is to be cast upon. Line of sight is defined as the line going from the center of the attacker's square to the center of the target's square. The center is the dot marked on each space. In the case of a CREATE WALL spell, the center would be the center of the wall to be created. If this line of sight is interrupted by any part of a wall, the spell cannot be cast.",
|
||||
"*(6E: the boards have no printed dots — eyeball the square centers. See Section 1.)*",
|
||||
"**NEUTRAL** cards are non-offensive or non-defensive spells. ANY NUMBER of Neutral spells can be cast during a player's turn. It is not impossible for a player to use his entire hand in one turn.",
|
||||
"**COUNTERACTION** cards are the only cards that can be used OUT OF TURN. They can be used (but aren't usually) during a normal turn as a NEUTRAL card, but their primary use is as a defense against attacks. They are played immediately following an attack by the player who was attacked. You CANNOT counteract a Neutral card. You can, however, use an ABSORB or BLUNT against indirect damage caused to you (e.g., by a falling DESTROYed WALL).",
|
||||
"More than one COUNTERACTION card can be used to defend against a single attack, either used sequentially or added together. It is also possible to COUNTERACT a COUNTERACTION card, so an attack may bounce back and forth a few times before coming to rest. In case a COUNTERACTION card only stops half of a spell, always round the resulting damage UP to the nearest whole number. If all damage from a spell is stopped, any secondary effects, such as a lost turn, are also stopped.",
|
||||
"A player is not obligated to play a COUNTERACTION card if he does not wish to.",
|
||||
"COUNTERACTIONS are instantaneous; they only affect the one attack spell they were played against.",
|
||||
"**NUMBER** cards have three purposes. First, they can add to your movement, as mentioned earlier. Second, they can affect the DURATION of a spell, the NUMBER then representing the number of turns that the spell lasts. A duration spell starts when cast and ends at the start of one of the caster's next turns. Duration-based spells are marked as such on each card. Third, they can represent the POWER of a spell, so that a LIGHTNING BLAST with a \"5\" NUMBER card would do five points of damage to an opponent (plus one turn lost due to stunning, as mentioned on the card). This damage would then be subtracted from the victim's point total.",
|
||||
"Only one NUMBER card can be played per action. So in one turn, a player may use a NUMBER card to enhance his movement, use another to ATTACK with, and use yet another to become INVISIBLE (if he has the card) for a certain number of turns, and so on.",
|
||||
"**MAGIC STONES** are magical gems that bestow a special power upon the holder. Stones may be discarded like any other card if a player does not wish to keep them, or they may be dropped. The cards are permanent, and the power they bestow may be used as often as the opportunity arises. The name of each magical stone contains the word \"STONE\" and the description on each card is prefaced by the word \"POWER\".",
|
||||
"Cards are used once, then discarded face up onto a discard pile. There are exceptions! These include physical objects such as MAGIC STONES, the DAGGER, the LARGE ROCK, the WIZARDBLADE, and the MASTER KEY. Physical objects are not removed from your hand unless they are dropped or thrown. Of all the physical objects, MAGIC STONES and the MASTER KEY have one other requirement: They must be displayed face up in front of you after you have used them. They are still considered part of the seven-card hand. If any cards are to be randomly lost or taken from a player's hand, these cards must be taken back into the hand before the choice is made.",
|
||||
"If you kill an opponent, you get all of his cards, but you must immediately discard enough to bring your hand down to seven cards.",
|
||||
"Some cards involve physical actions, like picking locks, removing locks, jamming locks, and throwing daggers. These are not spells."
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "THE TURN",
|
||||
"paragraphs": [
|
||||
"You can do a lot during your turn. You may move, attack once, and use as many NEUTRAL and COUNTERACTION cards as you like. As an example, you play a \"3\" NUMBER card and say \"I'm moving six spaces this turn.\" Then you move two spaces into L.O.S. with another player, cast an attack, move back three spaces, create a wall, shrink yourself, then move back one more space. The order you use doesn't matter, and you can break up your movement while performing other actions. If you pick up an object during your turn, your turn ends the moment you pick it up.",
|
||||
"Losing a turn means you can do NOTHING for that entire turn, except COUNTERACTION.",
|
||||
"For the purpose of lost turns, or spell duration, a \"turn\" starts when a spell is cast, and ends at the beginning of one of the caster's turns."
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "COMBAT",
|
||||
"paragraphs": [
|
||||
"There is no combat during the first round of turns. Combat consists of taking a card from your hand and playing it against your opponent during your turn. If that player has a COUNTERACTION card, he may play it then. If you want to COUNTERACT his COUNTERACTION, and have the card to do so, you may. You may attack only once per turn. Spells automatically hit their target if not COUNTERACTed. If a spell misses for some reason, it dissipates harmlessly, and does NOT hit some object behind the target!",
|
||||
"Any damage done to either player in the form of damage points is removed from the 15 points on his score sheet. If you get to \"0\" points, you are dead.",
|
||||
"Damage points, points, damage, and point-based spells all refer to the same thing: things that take away points from you, an opponent, or object (such as a wall or door). They have nothing to do with duration-based spells.",
|
||||
"If a person is eliminated from the game by being killed, then the last attacker gets all the dead person's cards. If a player is eliminated by having lost both his treasures to opponents' home bases, his cards are discarded.",
|
||||
"You cannot physically attack through any type of wall, firewall, or bush. You cannot attack yourself. You cannot attack on the first round of turns, even if you have the SPEED spell.",
|
||||
"*(6E: \"You cannot attack yourself\" is stated in the 6E rulebook; FAQ errata: the first-round restriction should read \"no ATTACKS during the first round\", not \"no combat\" — e.g. Destroy Wall next to someone on turn one is legal.)*",
|
||||
"ANY CARD MAY BE PLAYED WITHOUT A NUMBER CARD, but its power is only \"1\". There are no \"1\" NUMBER cards in the deck.",
|
||||
"When all else fails, you may PUNCH your opponent. This requires no card, but does constitute your attack for the turn. A punch does one point of damage, and you must be in the same square as your opponent to do it. If the object to be hit fills an entire square, you may be in an adjacent square to hit it. It is possible, though time-consuming, to punch a wall down. A wall takes 20 points of damage to destroy; a door takes 15. Any attack against an inanimate object counts as your one attack for the turn. The only inanimate objects you can attack are the walls, doors, and thornbushes.",
|
||||
"It is possible to walk by an opponent or cast a spell past him without affecting him at all, if you want to.",
|
||||
"Any deal can be made while in L.O.S. with another player, including, but not limited to, trading cards.",
|
||||
"PHYSICAL DAMAGE and MAGICAL DAMAGE both take damage points from a player, but on rare occasions (designated on the cards) they are treated differently. Physical damage would include things like damage from the LARGE ROCK, the DAGGER, a PUNCH from another player, or a wall that falls on you from a DESTROY WALL spell. Magical damage would include any damage caused as a direct result of a spell, such as walking through a FIREWALL, getting hit with the WIZARDBLADE, or being hit with a LIGHTNINGBLAST."
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "CARDS AND ACTIONS THAT CHANGE THE MAP",
|
||||
"paragraphs": [
|
||||
"If you add a wall, destroy a wall, create a bush or firewall, etc., then you must take an appropriate cardboard token representing this and place it where the item was created.",
|
||||
"You CANNOT create an object on a player's home base, or on any space already occupied by a player or object (unless it is a wall, which doesn't actually exist ON a space, but in between them). You cannot, for example, cast a FIREWALL where an ILLUSION WALL already exists, or two THORNBUSHES in the same square.",
|
||||
"Walls cannot be cast diagonally. They must be cast on the lines between spaces.",
|
||||
"If a card has no duration mentioned on it, the effect is either permanent (such as CREATE WALL) or instantaneous (such as FIREBALL).",
|
||||
"If someone has cast CREATE WALL or DESTROY WALL on a junction between sectors, and some other moron decides he wants to use ROTATE SECTOR on one of them, then roll the die for a 50-50% chance to see which one of the two sectors the \"alteration\" stays with."
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "GETTING NEW CARDS",
|
||||
"paragraphs": [
|
||||
"You start with seven cards. You may draw up to two per turn, but may never have more than seven cards in your hand at one time. If for some reason you do, you must immediately discard enough cards to bring your hand down to the seven card limit. Draw new cards only at the end of YOUR turn.",
|
||||
"If you want to discard cards from your hand just for the sake of getting new cards, you may. This includes cards which represent permanent objects, which give you the option of dropping the object or discarding it."
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "THE DIE",
|
||||
"paragraphs": [
|
||||
"This is referred to as a D4 in the game. It is either 8-sided (numbered from 1-4 twice) or 4-sided, depending on which game edition you have. It is NOT used to roll movement.",
|
||||
"Whenever a random direction is called for in the course of the game, pick a number representing the direction you are trying to go, or trying to cast a spell (if you are BLINKed), and then roll for that number.",
|
||||
"If you need to roll a 50-50% chance, just call 1,2 as low and 3,4 as high, determining which roll represents what, beforehand."
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "DEATH",
|
||||
"paragraphs": [
|
||||
"A dead player is out of the game and may not win even if two treasures are placed on his home base. His home base is no longer valid for the purpose of eliminating other players. His treasures, though, are still valid to use to win the game. A player who dies while performing an action that would normally win the game is considered to have died first, and is eliminated from the game.",
|
||||
"---"
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
export const RULEBOOK_EXPANSION: RuleSection[] = [
|
||||
{
|
||||
"title": "MONSTERS",
|
||||
"paragraphs": [
|
||||
"Expansion 1 introduces spells that create monsters. Any monster will follow the commands of its creator (unless otherwise noted on the cards, like the Imp), even when out of line-of-sight. They move any time during the controlling player's turn, so if a player gets an extra turn, or loses a turn, so does the monster (Imp excluded, again). Monsters, unless otherwise noted on the card, may not hurt their creators, carry items, cast spells or use cards from your hand. They are permanent creations, until killed. You may not add NUMBER cards to their movement, but you can cast NEUTRAL spells such as MIST-BODY and SPEED on them.",
|
||||
"For the few instances where MONSTERS can carry items, they can't use those items at all (unless the card specifically says otherwise).",
|
||||
"Monsters attack once per turn. They cannot attack the turn they are created, but may move on that turn. Creating a monster counts as your attack only on the turn it is created. You and your monster may both have attacks on later turns. Monsters can attack other monsters. Players may not attack a creature that they control. If a monster attacks a player and kills him, the controlling player DOES NOT get the dead player's cards.",
|
||||
"If you cast SHADOW and later lose a turn, and you wish to maintain the SHADOW during your lost turn, it will still cost you a life point. If there are other \"lasting\" effects on you that you have no control over, these also occur even if you've lost a turn.",
|
||||
"If you cast BUDDY, you can hurt the other player with a monster's attack without breaking the BUDDY spell, and he can do the same to you. A monster's attack is not YOUR attack.",
|
||||
"If a monster sees an illusion, he has the same chance to believe the illusion as a player does.",
|
||||
"A monster controlled by you does NOT count as a card in your hand. If you die, any monster controlled by you immediately disappears.",
|
||||
"Monsters, like Wizards, will suffer the ill effects of objects in the corridor (like a Thornbush) just like Wizards. Also, if a card specifies \"Opponent\" or \"Anyone\" as a target, this includes Monsters.",
|
||||
"Wizards can run past monsters without taking damage from them, just as they can run by other Wizards without getting punched. The obvious exceptions to this (like the Buck and the Fire Imp) are noted on the cards."
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "FLYING AND LEVITATING",
|
||||
"paragraphs": [
|
||||
"Things that fly, float, or levitate are immune to the effects of ground-based effects such as TACKS, KILLER OOZE, and WATERWALL."
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "MAGIC STICKS",
|
||||
"paragraphs": [
|
||||
"You can recognize a magic stick because the word \"Stick\" is in the name of the card. Each stick carries a number of \"charges\" set by the number card played when the stick is first used. This number tells you how many times the stick can be used before it gets discarded. Place a token on the card when played indicating how many charges the stick has, and leave the stick displayed. Any stick operates a maximum of once per turn. Once it is used up it is useless and may not be recharged, or retrieved from the discard pile. If ownership of a magic stick is taken by an opponent, then whatever charges are left on the stick are also transferred. If the initial number of charges has not been set, then the new owner may do so. AMPLIFY and ADD will both work when setting the initial number of charges.",
|
||||
"Magic Sticks do not burn. They do, of course, count as a card in your hand.",
|
||||
"SPEED or ADRENALINE will NOT allow you to use a Magic Stick more than once in your turn. While the spells speed YOU up (and your monsters, whose turns are slaved to you), they do not speed up the limits of the wand. This isolation of the wand from you allows you to use it even with NO SPELL cast on you.",
|
||||
"ABSORB SPELL, of course, has no effect on Sticks, since they are objects and not spells, even though what Magic Sticks produce are considered spells."
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "OTHER NOTES FOR THE FIRST EXPANSION",
|
||||
"paragraphs": [
|
||||
"There are tokens on the counter sheet marked with a number of Charges. These are to help keep track of how many charges a Magic Stick has left on it (place the token in front of you after you have charged up the stick). You needn't use them if you can keep it all straight in your head.",
|
||||
"If you don't like a card in your edition of Wiz War, don't use it.",
|
||||
"The tokens for Magic Sticks and Magic Stones aren't meant to be carried by a Wizard. They are there in case the player drops an item, to show where on the board it was dropped.",
|
||||
"If you REUSE an object-creating spell, there may not be enough tokens to represent the new object. In this case, you'll have to rough it. Use a blank token, for example, with your own drawing on it.",
|
||||
"The rules that let you discard cards that you don't want in your hand obviously do NOT apply to cursed objects (such as LOAD STONE). Naturally you CAN discard (crush) ARTIFACTS during your turn (or Magic Sticks, or Magic Stones, etc.). You may not discard or drop objects out-of-turn.",
|
||||
"Some card effects let you perform multiple attacks in a turn, but certain objects which can produce attacks are limited to one use per turn. Since this is a limitation on the object, this means you can't use the object multiple times, regardless of the effect that exists on your Wizard. He must use other spells or physical attacks to get his quota of attacks in."
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "TEAM PLAY OPTION (thanks to Jeff Ingalls)",
|
||||
"paragraphs": [
|
||||
"If you have 4 or 6 players, it may be fun to try team play. For 4 players, the 1st and 3rd are one team, the 2nd and 4th the other. For 6 players, use 3 teams of 2. Winning is determined by either killing the other players, or by getting 3 of the opponents' treasures on either of your team's home bases in any combination. You may not attack your teammates (unless a teammate is under someone else's control). Discussion of strategy is allowed, but you may not reveal or trade cards with your teammate unless in L.O.S."
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "TWO PLAYER VARIANT",
|
||||
"paragraphs": [
|
||||
"For better board access for two players, cross the B and C exits on the 2 boards (see the diagram in the basic set). Thus, each player has a B exit and a C exit in his sector. It's a lot more fun this way."
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "DOORS VARIANT",
|
||||
"paragraphs": [
|
||||
"You can, if you like, play with the optional rule that within each sector a Wizard can open his doors without a key. Suggestion by Tracy Johnston.",
|
||||
"---"
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
export const RULEBOOK_COPYRIGHT =
|
||||
"Base rules © 1985 Jolly Games. Expansion rules © 1991 Jolly Games. " +
|
||||
"Reproduced from the designer's own wizwar.com (archived 2003–2004). " +
|
||||
"Wiz-War's current edition is published by Steve Jackson Games.";
|
||||
@@ -119,4 +119,12 @@ export const HOW_TO_PLAY: RulesSection[] = [
|
||||
"Games are saved move-by-move on the server and survive anything. The lobby lists every game you're in and whose turn it is; the tab title and favicon flip when a turn is yours, and “notify me on my turn” adds browser notifications. “Transfer seat” hands a game to another device with a spoken phrase.",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "The automatons",
|
||||
body: [
|
||||
"Any empty seat can be filled with an automaton — a clockwork wizard that plays by the same rules and the same information a human seat gets. Add one in the lobby before the game starts, choosing its temperament: a HUNTER marches for treasure, a BERSERKER hunts wizards over gold, a WORRIER keeps its distance and guards what it has. Pick MYSTERY and the maze deals a temperament in secret — you'll have to read its play to guess its mood, and the roster only unmasks it (🎭) when the game ends.",
|
||||
"The difficulty picker sets how much the clockwork has to work with, never how fairly it plays: an APPRENTICE draws one card a turn and knows a modest spellbook, an ADEPT draws two and defends its gold, and an ARCHMAGE knows every trick in the deck — ambushes, amplifies, walls blasted through, roads denied. Every tier plays its cards correctly; none of them cheats, peeks, or rolls loaded dice.",
|
||||
"The automatons chat a little, hold grudges not at all, and take exactly one action per second so you can watch them think. If one does something that looks brilliant, it probably learned it from a human at this table.",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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="Destroy Wall 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">Destroy Wall</text>
|
||||
<g filter="url(#w)" stroke="#252923" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><g fill="#777d78"><path d="M25 76h28v19H23Z"/><path d="M56 70h31v22H56Z"/><path d="M91 78h28v19H88Z"/></g><path d="M72 58l-8 16 13-4-5 17 18-23-12 4 4-14Z" fill="#f1c340"/><path d="M27 54l-12-9m101 9l13-10M23 110l-12 7m108-7l12 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="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 |