Compare commits
@@ -0,0 +1,59 @@
|
||||
---
|
||||
name: wizwar-audits
|
||||
description: Run the Wiz-War health audits — UI coverage of engine events/cards (the "telepath audit"), live-game ledger inspection by room code, and the pre-deploy determinism gate. Use when asked to audit, when adding cards or events, or when a player reports "the engine did X but I never saw it".
|
||||
---
|
||||
|
||||
# Wiz-War audits
|
||||
|
||||
Three recurring audits keep the maze honest. The first is self-enforcing;
|
||||
the other two are on-demand.
|
||||
|
||||
## 1. UI coverage — "the telepath audit" (automated)
|
||||
|
||||
`packages/engine/test/ui-coverage.test.ts` runs with every `npm test` and
|
||||
fails when:
|
||||
|
||||
- a GameEvent type has no client handling (no `humanize` case in
|
||||
`net.svelte.ts`, no fx in `fx.ts`, no modal check in `App.svelte`) and is
|
||||
not in the test's `SILENT_BY_DESIGN` allowlist — each allowlist entry
|
||||
needs a reason for why the player sees the information another way;
|
||||
- a card resolver demanding a cell/edge target is missing from App's
|
||||
`CELL_CARDS` / `EDGE_CARDS` click-targeting sets (an unaimable cast).
|
||||
|
||||
When adding an **event**: give it a `humanize` line at minimum. Private
|
||||
info (`visibleTo` events carrying cards) deserves the `cardReveal` modal
|
||||
in App.svelte — see `handRevealedPrivate` / `cardsStolenPrivate` /
|
||||
`handTakenPrivate` for the pattern. When adding a **card** with a cell or
|
||||
edge target, add it to the App targeting set; with `params`, wire the
|
||||
named-card picker (`NAMED_CARDS` + suggestions) or a bespoke control.
|
||||
|
||||
The one axis the test cannot judge: whether a `params`-taking card's
|
||||
input UI actually offers sensible choices. Check that by hand when adding
|
||||
one (thief → target's steallables, deja-vu → discard contents, etc.).
|
||||
|
||||
## 2. Live-game audit by room code
|
||||
|
||||
Fetch and replay production ledgers (details in auto-memory
|
||||
`wizwar-fetch-game-files`):
|
||||
|
||||
scp root@104.236.96.198:/var/lib/wizwar/rooms/<CODE>.jsonl <scratchpad>/
|
||||
|
||||
Replay with `createGame({playerIds, seed, sets, colors, deckRev})` +
|
||||
`applyCommand` per command line (see `deploy/replay-verify.mjs`). Stop at
|
||||
any seq to inspect full state. To ask why a bot did something, rebuild
|
||||
the state at its turn and call `automatonCommand(viewFor(state, id),
|
||||
style, tier)` — and if its choice differs from the ledger, the engine
|
||||
refused it and the fallback burned the turn.
|
||||
|
||||
For "which games are open/stalled" sweeps: fetch all `*.jsonl`, replay
|
||||
each, and report phase / round / humans vs bots / last command's `at`.
|
||||
|
||||
## 3. Determinism gate (before deploying engine changes)
|
||||
|
||||
deploy/verify-ledgers.sh 104.236.96.198
|
||||
|
||||
Strict-replays every production ledger against the local engine; one
|
||||
refused command fails. A room whose ledger no longer replays becomes
|
||||
unreachable after restart. Rules changes while games are live need a
|
||||
`deckRev` bump plus an engine gate. Production deckRev is currently 1,
|
||||
so low numbers are not stale.
|
||||
@@ -0,0 +1,141 @@
|
||||
---
|
||||
name: wizwar-duel
|
||||
description: Play Wiz-War live against Eric — join his room as a real seat, reason about every move yourself, and duel turn by turn over the websocket. Use when Eric wants a game, gives a room code, or says "let's play".
|
||||
---
|
||||
|
||||
# Playing Wiz-War against Eric
|
||||
|
||||
You are a PLAYER, not the automaton. Read the board, reason, and choose
|
||||
every command yourself. Eric plays in his browser; you play through the
|
||||
CLI seat at `tools/claude-seat.mjs`. He prompts "go" after his moves —
|
||||
each of his messages is your cue to look and act.
|
||||
|
||||
## The seat client
|
||||
|
||||
Run from the repo root (needs the workspace's `ws` package):
|
||||
|
||||
```bash
|
||||
node tools/claude-seat.mjs join <ROOM> Claude # take a seat (token saved)
|
||||
node tools/claude-seat.mjs view # board + hand + last events
|
||||
node tools/claude-seat.mjs do '<command json>' # one engine command
|
||||
node tools/claude-seat.mjs chat "text" # table talk
|
||||
```
|
||||
|
||||
Seat state persists in `/tmp/wizwar-claude-seat.json` (override:
|
||||
`WIZWAR_SEAT`); server defaults to production (`WIZWAR_SERVER` to point
|
||||
elsewhere). Eric creates the room, gives you the code, and starts the
|
||||
game after you join.
|
||||
|
||||
Commands are the engine's `Command` union (packages/engine/src/game.ts):
|
||||
`{"type":"move","direction":"N|S|E|W"}`, `cast` (with `target`, optional
|
||||
`numberInstanceIds`/`params`), `playNumberForMovement`, `pickUpTreasure`,
|
||||
`dropTreasure`, `warpStep`, `counteract`, `pass`, `punch`, `setAmbush`,
|
||||
`wardChoice`, `endTurn` (with `draw`). A refused command costs nothing —
|
||||
probing legality is free, so when unsure, try it and read the error.
|
||||
|
||||
## Reading the map
|
||||
|
||||
- The renderer prints y=0 at top; **y grows SOUTH**. N = y-1, S = y+1.
|
||||
- `C2` is you, `P1` etc. are opponents, `$c`/`$1` treasures, `h?` homes,
|
||||
two-letter codes are square contents (SA safe, RO rosebush, ST stone,
|
||||
DU dust). `D` on a line is a door, `—` a wall, blank is open.
|
||||
- WARPS line lists rim passages as `2,0N→2,9`: standing at (2,0) and
|
||||
moving N carries you to (2,9). **The warp rides one specific side of
|
||||
its square** — check which before you walk (a wrong guess burns moves;
|
||||
ask me how I know).
|
||||
- Dimensional-warp TOKENS (cast by players) are separate: stand on one
|
||||
and `warpStep`. Creatures use `creatureWarpStep`.
|
||||
|
||||
## Hard-won rules knowledge (each cost me something in game one)
|
||||
|
||||
- `pickUpTreasure` ENDS your turn's actions AND movement — arrive with
|
||||
the grab as your last act, never mid-plan. `dropTreasure` at home too.
|
||||
- You can pick up ANY floor treasure, including your own stolen-and-
|
||||
delivered one sitting on the enemy's home square. Repossession is real.
|
||||
- THIEF and punches need same-square; THIEF steals a *named* card and
|
||||
fizzles (card spent) on a wrong guess.
|
||||
- MENTAL FORCE moves the victim ≤3 *walked* spaces — walls and relocked
|
||||
doors shrink its reach; rev 5+ refuses impossible destinations.
|
||||
- Doors relock behind you when you pass through (rev 4+). Your own
|
||||
escape route can seal itself — and seal pursuers out.
|
||||
- LOS threads through rim warps: you can be seen (and shot) through a
|
||||
warp mouth from the far side of the board. Camping one square off the
|
||||
mouth keeps you hidden.
|
||||
- lock-in-place freezes movement AND warpStep for a full turn; REUSE
|
||||
SPELL retrieves only your LAST cast spell, enabling one re-lock.
|
||||
- An ambush (`setAmbush` interrupt/opportunity-fire + committed attack)
|
||||
triggers on LOS *entry* transitions only — someone already in sight
|
||||
never springs it. It survives forever and fires out of turn.
|
||||
- Walking-dead bleeds ½ life per space moved, permanently. Once cursed,
|
||||
every plan must be priced in steps.
|
||||
|
||||
## Game-two scars (each cost me the game or nearly)
|
||||
|
||||
- **RECOUNT THE SCOREBOARD EVERY TURN**: how many treasures does each
|
||||
player have banked AT HOME right now? I sprinted a "winning" delivery
|
||||
in game two that was actually my FIRST of two — Eric had un-banked me
|
||||
five rounds earlier and let me monologue. Banked counts, not vibes.
|
||||
- A banked treasure is NOT safe: anyone can pick it up off your home
|
||||
square and carry it away. Guard the bank or count it as contested.
|
||||
- LOS is FREE-ANGLE center-to-center (the rulebook's rule), not rows
|
||||
and columns: razor diagonals threading open gaps are legal for
|
||||
everyone — including at you. The board draws bent/straight traces.
|
||||
- ILLUSIONARY ATTACK deals its imitated spell's damage if NOT
|
||||
countered; ANY counteraction dispels it entirely. Counter it with
|
||||
the cheapest thing you hold; never "call the bluff" by passing.
|
||||
- Never trust your ASCII parse of the map for multi-step plans: run a
|
||||
BFS over `view.board.edges` (see tools/claude-seat.mjs view JSON, or
|
||||
write a quick router) before spending a number card on a sprint. I
|
||||
burned a 7-move turn bouncing off six misread walls.
|
||||
- Eric counter-plays YOUR bot upgrades: he summoned a troll bodyguard
|
||||
specifically against the archmage's exile-the-winner instinct, and
|
||||
relocated MY home sector mid-carry. Expect meta-warfare.
|
||||
- In 4p with automatons: the bots are chaos agents — they stun, exile,
|
||||
disease, and con. Herd them at Eric; never assume they're on script.
|
||||
|
||||
## Game-three scars (lost in four rounds)
|
||||
|
||||
- **CHECK `dimWarps` EVERY SINGLE VIEW** — Kestrel (Eric's wizard) has
|
||||
won two games
|
||||
with quietly-placed wormholes ending beside his home. The client
|
||||
prints a !! line for them; treat any new token pair as a five-alarm
|
||||
fire and recompute both players' delivery distances through it.
|
||||
- **Never lead the attack against Kestrel.** Three games, three saved
|
||||
counters sprung at the perfect moment (anti-anti, full-shield bait,
|
||||
full-reflection held three rounds for my lightning). Force his
|
||||
counters out with throwaways or positional threats before committing
|
||||
a real spell; his patience is perfect and he telegraphs nothing.
|
||||
- DESTROY WALL showers the ADJACENT caster with rubble (4 damage) —
|
||||
cast it from range, never on your own square's edge.
|
||||
- Wading a rosebush costs 3. A reflected LIGHTNING BLAST still stuns
|
||||
its caster even at 1 reflected damage — the stun is the payload, and
|
||||
a lost turn against Kestrel is a lost game.
|
||||
- Grab-turn-one openings feel great and prove nothing: deliveries are
|
||||
the only score. He banked quietly both times while I showboated.
|
||||
|
||||
## Playing well
|
||||
|
||||
- Kestrel (Eric) is EXCELLENT: he baited my thief, saved anti-anti for
|
||||
my full shield, and won game one with a home-to-home dimensional-warp
|
||||
superhighway. Infrastructure beats sprinting — watch what he builds,
|
||||
and consider your own wormhole early.
|
||||
- Win = 2 enemy treasures delivered to your home, or last wizard alive.
|
||||
Track BOTH players' step-counted delivery timelines every turn; the
|
||||
race is decided in tempo, not damage.
|
||||
- Hold counters (full-shield, absorb) for permanent curses and lethal
|
||||
damage. Absorb soaks 3 points; it cannot touch durations.
|
||||
- OPSEC: your prose is visible to Eric. NEVER name the cards you hold or
|
||||
draw, and never announce plans. (Game one: I narrated my hand like a
|
||||
debug log until he asked why I was making it easy.) Trash talk freely
|
||||
— about the board, never your hand.
|
||||
- Send `chat` for table talk at dramatic moments; it's half the fun.
|
||||
|
||||
## Cadence
|
||||
|
||||
1. On "go": `view`, read HIS events since your last turn (the client
|
||||
prints only NEW events; `view` shows a short history tail).
|
||||
2. Think about the whole board — his timeline, yours, threats, LOS.
|
||||
3. Execute your commands one at a time, checking errors.
|
||||
4. `endTurn` with a draw that respects the 7-card hand limit.
|
||||
5. Tell Eric it's his turn — reasoning aloud is fine, hand contents are
|
||||
not. When a stack waits on him, say so and wait for "go".
|
||||
@@ -4,5 +4,6 @@ dist/
|
||||
.env
|
||||
.DS_Store
|
||||
data/
|
||||
.claude/
|
||||
.claude/*
|
||||
!.claude/skills/
|
||||
.playwright-mcp/
|
||||
|
||||
@@ -23,6 +23,14 @@ research), installs dependencies on the droplet, and restarts the service.
|
||||
Games in progress survive: state lives in room files, and clients reconnect
|
||||
automatically.
|
||||
|
||||
Before any deploy that touches the engine, run the determinism gate:
|
||||
|
||||
deploy/verify-ledgers.sh 104.236.96.198
|
||||
|
||||
It fetches every production ledger and strictly replays it against the local
|
||||
engine; a single refused command fails the check. A room whose ledger no
|
||||
longer replays becomes unreachable after restart, so this is not optional.
|
||||
|
||||
## New droplet from scratch
|
||||
|
||||
1. `doctl compute droplet create wizwar --region nyc3 --size s-1vcpu-1gb \
|
||||
@@ -35,4 +43,10 @@ automatically.
|
||||
|
||||
- Logs: `ssh root@<ip> journalctl -u wizwar -f`
|
||||
- Restart: `ssh root@<ip> systemctl restart wizwar`
|
||||
- Backup games: `scp -r root@<ip>:/var/lib/wizwar/rooms ./rooms-backup`
|
||||
- Nightly backups: `wizwar-backup.sh` runs from root's crontab at 07:17 UTC,
|
||||
pushing /var/lib/wizwar to the `kestrel-wizwar-backups` Space (nyc3) via
|
||||
rclone. It is installed at /usr/local/bin/wizwar-backup.sh on the droplet;
|
||||
`setup-droplet.sh` does NOT install it — on a fresh droplet, copy the
|
||||
script, configure rclone (the script refuses to run while the config still
|
||||
holds its CHANGE_ME placeholder), and add the cron entry by hand.
|
||||
- One-off backup: `scp -r root@<ip>:/var/lib/wizwar/rooms ./rooms-backup`
|
||||
|
||||
@@ -9,9 +9,11 @@ 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)
|
||||
# `|| true`: a failing replay must feed the tally, not abort the loop.
|
||||
out=$(npx tsx deploy/replay-verify.mjs "$f" 2>&1 | tail -1) || true
|
||||
case "$out" in
|
||||
FAIL*) echo "$out"; fails=$((fails+1));;
|
||||
OK*) ;;
|
||||
*) echo "${out:-$(basename "$f"): replay crashed with no output}"; fails=$((fails+1));;
|
||||
esac
|
||||
done
|
||||
count=$(ls "$TMP" | wc -l | tr -d ' ')
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import { cardDef, type CardInstance } from "./cards";
|
||||
import { cellKey, edgeKey, neighbor, opposite, stepTarget, SIDES, type Cell, type Side } from "./board";
|
||||
import { bentSightFor, sightedCellsFor, type GameView } from "./view";
|
||||
import { wallIgnoringDistance } from "./game";
|
||||
import type { AmbushTrigger, Command, PlayerId } from "./game";
|
||||
|
||||
export type AutomatonStyle = "hunter" | "berserker" | "worrier";
|
||||
@@ -49,7 +50,7 @@ const ATTACKS: Record<string, { base: number; perNumber: boolean; needsNumber?:
|
||||
powerthrust: { base: 2, perNumber: true },
|
||||
waterbolt: { base: 0, perNumber: true, needsNumber: true },
|
||||
"lightning-blast": { base: 0, perNumber: true, needsNumber: true },
|
||||
wizardblade: { base: 0, perNumber: true, needsNumber: true },
|
||||
wizardblade: { base: 0, perNumber: true, needsNumber: true, sameSquare: true },
|
||||
"power-drain": { base: 0, perNumber: true, needsNumber: true },
|
||||
dagger: { base: 3, perNumber: false },
|
||||
"large-rock": { base: 2, perNumber: false },
|
||||
@@ -256,6 +257,12 @@ function bestWallCrossing(
|
||||
return best;
|
||||
}
|
||||
|
||||
/** Square-filling nuisances for path denial, best blocker first. */
|
||||
const DENIAL_FILLERS = [
|
||||
"fill-square-with-stone", "thornbush", "rosebush", "create-pit",
|
||||
"fill-square-with-slime", "killer-ooze", "handful-of-tacks", "dust-cloud",
|
||||
];
|
||||
|
||||
/**
|
||||
* Path denial: when an enemy's march threatens something dear — they carry
|
||||
* one of the clockwork's treasures home, or close on its gold lying on the
|
||||
@@ -265,12 +272,6 @@ function bestWallCrossing(
|
||||
* would accept are offered (sighted, empty, off homes and warp tokens), and
|
||||
* only when the detour costs the enemy 3+ extra steps.
|
||||
*/
|
||||
/** Square-filling nuisances for path denial, best blocker first. */
|
||||
const DENIAL_FILLERS = [
|
||||
"fill-square-with-stone", "thornbush", "rosebush", "create-pit",
|
||||
"fill-square-with-slime", "killer-ooze", "handful-of-tacks", "dust-cloud",
|
||||
];
|
||||
|
||||
function pathDenial(view: GameView): Command | null {
|
||||
const wall = inHand(view, "create-wall") ?? inHand(view, "illusion-wall");
|
||||
const jam = inHand(view, "jam-lock");
|
||||
@@ -293,6 +294,17 @@ function pathDenial(view: GameView): Command | null {
|
||||
if (d <= 6) threats.push({ enemy: e.position, goal: floorGold.position! });
|
||||
}
|
||||
}
|
||||
// Enemy gold banked at MY home: my score, snatchable by anyone. A
|
||||
// raider closing on the bank gets the same road-lengthening treatment.
|
||||
const self = me(view);
|
||||
const banked = view.treasures.find(
|
||||
(t) => t.owner !== view.you && t.position && cellKey(t.position) === cellKey(self.home));
|
||||
if (banked) {
|
||||
for (const e of livingEnemies(view)) {
|
||||
const d = Math.abs(e.position.x - self.home.x) + Math.abs(e.position.y - self.home.y);
|
||||
if (d <= 6) threats.push({ enemy: e.position, goal: self.home });
|
||||
}
|
||||
}
|
||||
if (threats.length === 0) return null;
|
||||
|
||||
const sighted = sightedCellsFor(view);
|
||||
@@ -358,7 +370,12 @@ function pathDenial(view: GameView): Command | null {
|
||||
);
|
||||
}
|
||||
// Never brick the square the treasure needs to stay reachable on.
|
||||
if (filler && cellKey(b) !== cellKey(goal) && cellCastable(cellKey(b))) {
|
||||
// TACKS are scattered at the caster's feet ("you must be adjacent"),
|
||||
// not thrown — offering a distant square wedges the whole brain on
|
||||
// an eternally refused cast.
|
||||
const fillerReaches = !filler || filler.cardId !== "handful-of-tacks" ||
|
||||
Math.abs(b.x - me(view).position.x) + Math.abs(b.y - me(view).position.y) <= 1;
|
||||
if (filler && fillerReaches && cellKey(b) !== cellKey(goal) && cellCastable(cellKey(b))) {
|
||||
consider(
|
||||
{ type: "cast", instanceId: filler.instanceId, target: { kind: "cell", cell: b } },
|
||||
{ avoidCell: cellKey(b) },
|
||||
@@ -531,7 +548,8 @@ function roadworkPlan(
|
||||
}
|
||||
}
|
||||
}
|
||||
return best !== null ? (best as { cmd: Command; total: number }).cmd : null;
|
||||
if (best !== null) return (best as { cmd: Command; total: number }).cmd;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -568,7 +586,7 @@ function pathToward(
|
||||
view: GameView,
|
||||
from: Cell,
|
||||
goals: Set<string>,
|
||||
opts: { avoidNearEnemies?: boolean; canUnlock?: boolean } = {},
|
||||
opts: { avoidNearEnemies?: boolean; canUnlock?: boolean; throughHazards?: boolean } = {},
|
||||
): PathResult | null {
|
||||
if (goals.size === 0 || goals.has(cellKey(from))) return null;
|
||||
const enemyCells = livingEnemies(view).map((p) => p.position);
|
||||
@@ -592,9 +610,11 @@ function pathToward(
|
||||
seen.add(k);
|
||||
cameBy.set(k, { prev: cellKey(c), dir, viaDoor });
|
||||
if (goals.has(k)) { found = k; break; }
|
||||
// Hazards end a clean path but are waded through when asked to.
|
||||
const hazard = view.squareContents[k]?.kind;
|
||||
if (hazard === "pit" || hazard === "ooze" || hazard === "thornbush" ||
|
||||
hazard === "rosebush" || hazard === "slime") continue;
|
||||
if (!opts.throughHazards &&
|
||||
(hazard === "pit" || hazard === "ooze" || hazard === "thornbush" ||
|
||||
hazard === "rosebush" || hazard === "slime" || hazard === "tacks")) continue;
|
||||
if (opts.avoidNearEnemies && nearEnemy(to)) continue;
|
||||
next.push(to);
|
||||
}
|
||||
@@ -627,13 +647,39 @@ function treasureGoals(view: GameView): Set<string> {
|
||||
}
|
||||
for (const t of view.treasures) {
|
||||
if (!t.position || t.carriedBy) continue;
|
||||
if (t.owner === view.you) continue;
|
||||
if (t.owner === view.you) {
|
||||
// REPOSSESSION: my own gold banked at an enemy's home is a point on
|
||||
// THEIR scoreboard. Marching to take it back is worth the detour
|
||||
// only when that enemy is one delivery from winning — constant
|
||||
// re-stealing farms nothing and stalls the whole table.
|
||||
const banker = view.players.find(
|
||||
(p) => p.id !== view.you && cellKey(p.home) === cellKey(t.position!));
|
||||
if (banker && deliveryWins(view, banker)) goals.add(cellKey(t.position));
|
||||
continue;
|
||||
}
|
||||
if (cellKey(t.position) === cellKey(self.home)) continue;
|
||||
goals.add(cellKey(t.position));
|
||||
}
|
||||
return goals;
|
||||
}
|
||||
|
||||
/** Bushes shelter both ways and mist can neither strike nor be struck —
|
||||
* the engine refuses such attacks, and a refused choice burns the turn. */
|
||||
function bushOrMist(view: GameView, id: PlayerId): boolean {
|
||||
const p = view.players.find((q) => q.id === id)!;
|
||||
return view.squareContents[cellKey(p.position)]?.kind === "thornbush" ||
|
||||
view.sustained.some((s) => s.cardId === "mist-body" && s.targetId === id);
|
||||
}
|
||||
|
||||
/** Would this wizard's delivery, landed, win them the game right now? */
|
||||
function deliveryWins(view: GameView, p: { id: PlayerId; home: Cell; carriedTreasureId: string | null }): boolean {
|
||||
if (!p.carriedTreasureId) return false;
|
||||
const carried = view.treasures.find((t) => t.id === p.carriedTreasureId);
|
||||
if (!carried || carried.owner === p.id) return false;
|
||||
return view.treasures.some(
|
||||
(t) => t.owner !== p.id && t.position && cellKey(t.position) === cellKey(p.home));
|
||||
}
|
||||
|
||||
/** The wizard making off with MY gold, if any. */
|
||||
function thiefOfMine(view: GameView) {
|
||||
const carriers = new Set(
|
||||
@@ -711,7 +757,15 @@ function respond(view: GameView, style: AutomatonStyle, tier: TierTraits): Comma
|
||||
return { type: "pass" };
|
||||
}
|
||||
|
||||
const flinch = (style === "worrier" ? 1 : 0) - tier.counterThrift;
|
||||
// The LAST counter in hand is a treasure of its own: at a healthy life
|
||||
// total it waits for something big rather than answering every jab.
|
||||
// (Lethal blows override this below.)
|
||||
const counterCount = view.yourHand.filter((c) => {
|
||||
const t = cardDef(c.cardId).cardType;
|
||||
return t === "counteraction" || t === "neutral/counteraction";
|
||||
}).length;
|
||||
const lastCounterHold = counterCount <= 1 && me(view).life >= 10 ? 2 : 0;
|
||||
const flinch = (style === "worrier" ? 1 : 0) - tier.counterThrift - lastCounterHold;
|
||||
const attackId = stack.attackCard?.cardId ?? null;
|
||||
const atk = attackId ? ATTACKS[attackId] : null;
|
||||
const affliction = attackId ? AFFLICTIONS[attackId] != null : false;
|
||||
@@ -738,19 +792,26 @@ function respond(view: GameView, style: AutomatonStyle, tier: TierTraits): Comma
|
||||
? Math.max(2, stack.numberValue ?? 2)
|
||||
: (atk ? atk.base + (atk.perNumber ? stack.numberValue ?? 1 : 0) : 2)
|
||||
: 1;
|
||||
// A blow that would be the last one outranks every thrift threshold:
|
||||
// when the incoming points reach the clockwork's life, any counter
|
||||
// that can save it is cheap at the price.
|
||||
const lethal = !affliction && incoming >= me(view).life;
|
||||
// 9 clears every thrift threshold below: a killing blow is answered
|
||||
// with whatever can answer it, price be damned.
|
||||
const weight = lethal ? Math.max(incoming, 9) : incoming;
|
||||
if (stack.kind === "spell") {
|
||||
// REVERSE eats points; a pure duration offers it nothing.
|
||||
const reverse = find("reverse");
|
||||
if (reverse && !affliction && incoming >= 4 - flinch) return { type: "counteract", instanceId: reverse.instanceId };
|
||||
if (reverse && !affliction && weight >= 4 - flinch) return { type: "counteract", instanceId: reverse.instanceId };
|
||||
// ABSORB SPELL steals the good ones for later.
|
||||
const absorbSpell = find("absorb-spell");
|
||||
if (absorbSpell && incoming >= 3) return { type: "counteract", instanceId: absorbSpell.instanceId };
|
||||
if (absorbSpell && weight >= 3) return { type: "counteract", instanceId: absorbSpell.instanceId };
|
||||
const shield = find("full-shield");
|
||||
if (shield && (incoming >= 3 - flinch || (affliction && style === "worrier"))) {
|
||||
if (shield && (weight >= 3 - flinch || (affliction && style === "worrier"))) {
|
||||
return { type: "counteract", instanceId: shield.instanceId };
|
||||
}
|
||||
const reflect = find("full-reflection");
|
||||
if (reflect && incoming >= 4 - flinch) return { type: "counteract", instanceId: reflect.instanceId };
|
||||
if (reflect && weight >= 4 - flinch) return { type: "counteract", instanceId: reflect.instanceId };
|
||||
// A curse in flight is best refused at the door.
|
||||
if (affliction) {
|
||||
const cleanse = find("remove-curse");
|
||||
@@ -766,9 +827,11 @@ function respond(view: GameView, style: AutomatonStyle, tier: TierTraits): Comma
|
||||
const permanentCurse = affliction &&
|
||||
(attackId === "slow-death" || attackId === "walking-dead" || attackId === "idiot");
|
||||
const absorb = find("absorb");
|
||||
if (absorb && !affliction && incoming >= 2 - flinch && incoming <= 3) return { type: "counteract", instanceId: absorb.instanceId };
|
||||
if (absorb && !affliction && weight >= 2 - flinch && (incoming <= 3 || (lethal && incoming - 3 < me(view).life))) {
|
||||
return { type: "counteract", instanceId: absorb.instanceId };
|
||||
}
|
||||
const blunt = find("blunt");
|
||||
if (blunt && !permanentCurse && incoming >= 2 - flinch) return { type: "counteract", instanceId: blunt.instanceId };
|
||||
if (blunt && !permanentCurse && weight >= 2 - flinch) return { type: "counteract", instanceId: blunt.instanceId };
|
||||
// The berserker shares its pain out of spite — but only pain: a
|
||||
// damage-less curse gives EMPATHY nothing to mirror.
|
||||
const empathy = find("empathy");
|
||||
@@ -777,13 +840,13 @@ function respond(view: GameView, style: AutomatonStyle, tier: TierTraits): Comma
|
||||
}
|
||||
// Nothing to block with: teleport clear of the big ones.
|
||||
const tp = find("teleport");
|
||||
if (tp && incoming >= 4 - flinch) {
|
||||
if (tp && weight >= 4 - flinch) {
|
||||
const out = escapeCell(view, me(view).position);
|
||||
if (out) return { type: "counteract", instanceId: tp.instanceId, params: { cell: out } };
|
||||
}
|
||||
// Or vanish: INVISIBLE gives the attacker a 1-in-4 hit and lingers after.
|
||||
const vanish = find("invisible");
|
||||
if (vanish && (!stack.creatureId || view.deckRev >= 13) && incoming >= 3 - flinch) {
|
||||
if (vanish && weight >= 3 - flinch) {
|
||||
const num = numbersInHand(view)[0];
|
||||
return {
|
||||
type: "counteract", instanceId: vanish.instanceId,
|
||||
@@ -803,12 +866,44 @@ function respond(view: GameView, style: AutomatonStyle, tier: TierTraits): Comma
|
||||
/** The best attack available against a visible target, numbers and amplify included. */
|
||||
function bestAttack(view: GameView, targetId: PlayerId, tier: TierTraits): { cmd: Command; damage: number } | null {
|
||||
const numbers = numbersInHand(view);
|
||||
// TURN THEFT: against a carrier one delivery from winning, a
|
||||
// LIGHTNING BLAST's stolen turn outranks any point total — the number
|
||||
// rides along so soaks cannot zero the damage and void the stun.
|
||||
{
|
||||
const carrier = view.players.find((p) => p.id === targetId);
|
||||
const bolt = inHand(view, "lightning-blast");
|
||||
const biggestNum = numbers[numbers.length - 1];
|
||||
if (carrier && bolt && biggestNum && deliveryWins(view, carrier)) {
|
||||
return {
|
||||
damage: cardDef(biggestNum.cardId).value ?? 1,
|
||||
cmd: {
|
||||
type: "cast", instanceId: bolt.instanceId,
|
||||
target: { kind: "player", playerId: targetId },
|
||||
numberInstanceIds: [biggestNum.instanceId],
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
const biggest = numbers[numbers.length - 1];
|
||||
const biggestValue = biggest ? (cardDef(biggest.cardId).value ?? 0) : 0;
|
||||
const target = view.players.find((p) => p.id === targetId)!;
|
||||
const together = cellKey(target.position) === cellKey(me(view).position);
|
||||
const amplify = tier.amplify ? inHand(view, "amplify") : undefined;
|
||||
let best: { cmd: Command; damage: number } | null = null;
|
||||
// A killing blow at minimal spend outranks maximum splash — but only
|
||||
// when it clears the target's life with soak to spare: an exact-lethal
|
||||
// blow dies to one ABSORB, so thrift begins three points past the kill.
|
||||
type BestPick = { cmd: Command; damage: number; kill: boolean; cost: number };
|
||||
let best: BestPick | null = null;
|
||||
const offer = (damage: number, cost: number, cmd: Command) => {
|
||||
const rank = (d: number) => (d >= target.life + 3 ? 2 : d >= target.life ? 1 : 0);
|
||||
const r = rank(damage);
|
||||
const better = !best ? true
|
||||
: r !== rank(best.damage) ? r > rank(best.damage)
|
||||
: r === 2 ? cost < best.cost
|
||||
: damage !== best.damage ? damage > best.damage
|
||||
: cost < best.cost;
|
||||
if (better) best = { cmd, damage, kill: damage >= target.life, cost };
|
||||
};
|
||||
for (const c of view.yourHand) {
|
||||
const atk = ATTACKS[c.cardId];
|
||||
if (!atk) continue;
|
||||
@@ -820,25 +915,27 @@ function bestAttack(view: GameView, targetId: PlayerId, tier: TierTraits): { cmd
|
||||
const isWand = c.cardId === "blaster-wand";
|
||||
const uncharged = isWand && view.wandCharges[c.instanceId] == null;
|
||||
if ((uncharged || atk.needsNumber) && !biggest) continue;
|
||||
const withNumber = (atk.perNumber || uncharged) && biggest;
|
||||
let damage = atk.base + (atk.perNumber && withNumber ? biggestValue : 0);
|
||||
if (damage <= 0) continue;
|
||||
// AMPLIFY doubles the heavy hitters (spells only, not thrown things).
|
||||
const amplified = amplify && damage >= 4 && !isWand &&
|
||||
c.cardId !== "dagger" && c.cardId !== "large-rock";
|
||||
if (amplified) damage *= 2;
|
||||
if (!best || damage > best.damage) {
|
||||
best = {
|
||||
damage,
|
||||
cmd: {
|
||||
const mustNumber = uncharged || atk.needsNumber === true;
|
||||
const canAmplify = amplify && !isWand && c.cardId !== "dagger" && c.cardId !== "large-rock";
|
||||
const numberChoices: (CardInstance | undefined)[] = atk.perNumber
|
||||
? [undefined, ...numbers]
|
||||
: [mustNumber ? biggest : undefined];
|
||||
for (const num of numberChoices) {
|
||||
if (mustNumber && !num) continue;
|
||||
const value = num ? (cardDef(num.cardId).value ?? 0) : 0;
|
||||
const base = atk.base + (atk.perNumber && num ? value : 0);
|
||||
if (base <= 0) continue;
|
||||
for (const amp of canAmplify && base >= 4 ? [false, true] : [false]) {
|
||||
const damage = amp ? base * 2 : base;
|
||||
offer(damage, value + (amp ? 5 : 0), {
|
||||
type: "cast", instanceId: c.instanceId,
|
||||
target: { kind: "player", playerId: targetId },
|
||||
// An illusion needs a spell to imitate; a fireball sells best.
|
||||
...(c.cardId === "illusionary-attack" ? { params: { cardId: "fireball" } } : {}),
|
||||
...(withNumber ? { numberInstanceIds: [biggest.instanceId] } : {}),
|
||||
...(amplified ? { amplifyInstanceIds: [amplify.instanceId] } : {}),
|
||||
},
|
||||
};
|
||||
...(num ? { numberInstanceIds: [num.instanceId] } : {}),
|
||||
...(amp && amplify ? { amplifyInstanceIds: [amplify.instanceId] } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
// STONE DEAD: number times the stones the victim carries — the displayed
|
||||
@@ -847,9 +944,11 @@ function bestAttack(view: GameView, targetId: PlayerId, tier: TierTraits): { cmd
|
||||
if (sd && biggest) {
|
||||
const shown = target.displayed.filter((c) => STONES.has(c.cardId)).length;
|
||||
const dmg = biggestValue * shown;
|
||||
if (dmg >= 4 && dmg > (best?.damage ?? 0)) {
|
||||
// `best` is assigned only inside offer(), so TS narrows it to null here.
|
||||
const cur = best as BestPick | null;
|
||||
if (dmg >= 4 && (!cur || (dmg >= target.life && !cur.kill) || dmg > cur.damage)) {
|
||||
best = {
|
||||
damage: dmg,
|
||||
damage: dmg, kill: dmg >= target.life, cost: biggestValue,
|
||||
cmd: {
|
||||
type: "cast", instanceId: sd.instanceId,
|
||||
target: { kind: "player", playerId: targetId },
|
||||
@@ -858,7 +957,8 @@ function bestAttack(view: GameView, targetId: PlayerId, tier: TierTraits): { cmd
|
||||
};
|
||||
}
|
||||
}
|
||||
return best;
|
||||
const picked = best as BestPick | null;
|
||||
return picked ? { cmd: picked.cmd, damage: picked.damage } : null;
|
||||
}
|
||||
|
||||
/** An affliction worth casting when no damage lands, mid number attached.
|
||||
@@ -946,6 +1046,14 @@ function selfCare(view: GameView, style: AutomatonStyle, tier: TierTraits): Comm
|
||||
return { type: "cast", instanceId: c.instanceId };
|
||||
}
|
||||
}
|
||||
// So does the MASTER KEY: displayed, every workable lock in the maze
|
||||
// opens under its bearer's hand without another cast.
|
||||
{
|
||||
const mk = inHand(view, "master-key");
|
||||
if (mk && !self.displayed.some((d) => d.instanceId === mk.instanceId)) {
|
||||
return { type: "cast", instanceId: mk.instanceId };
|
||||
}
|
||||
}
|
||||
if (!tier.buffs) return null; // the apprentice's book ends at the stones
|
||||
// A curse on the clockwork gets scrubbed off.
|
||||
const cursed = view.sustained.some(
|
||||
@@ -956,11 +1064,6 @@ function selfCare(view: GameView, style: AutomatonStyle, tier: TierTraits): Comm
|
||||
// Three or more wizards: LIFESAVER takes elimination off the table.
|
||||
const lifesaver = inHand(view, "lifesaver");
|
||||
if (lifesaver && view.players.length > 2) return { type: "cast", instanceId: lifesaver.instanceId };
|
||||
// The ward guards the gold while its owner is away robbing yours
|
||||
// (arming retired at rev 31: the window asks in the moment instead).
|
||||
if (view.deckRev < 31 && inHand(view, "ward") && !view.yourWardArmed) {
|
||||
return { type: "armWard", armed: true };
|
||||
}
|
||||
const enemyNear = livingEnemies(view).some(
|
||||
(p) => Math.abs(p.position.x - self.position.x) + Math.abs(p.position.y - self.position.y) <= 3,
|
||||
);
|
||||
@@ -1030,6 +1133,42 @@ function selfCare(view: GameView, style: AutomatonStyle, tier: TierTraits): Comm
|
||||
}
|
||||
}
|
||||
}
|
||||
// INFRASTRUCTURE: a wormhole anchored beside home turns every future
|
||||
// delivery into a three-move stroll. Placed early, from home's
|
||||
// doorstep, far mouth by the richest distant gold.
|
||||
{
|
||||
const dwarp = inHand(view, "dimensional-warp");
|
||||
const nearHome = Math.abs(self.position.x - self.home.x) + Math.abs(self.position.y - self.home.y) <= 1;
|
||||
if (dwarp && nearHome && view.dimWarps.length === 0 && view.turn.round <= 6) {
|
||||
const legal = (c: Cell) =>
|
||||
view.board.cells[cellKey(c)] !== undefined &&
|
||||
!view.board.homes.some((h) => h.x === c.x && h.y === c.y) &&
|
||||
!view.squareContents[cellKey(c)] &&
|
||||
!view.treasures.some((t) => t.position && cellKey(t.position) === cellKey(c)) &&
|
||||
(view.groundObjects[cellKey(c)] ?? []).length === 0;
|
||||
const goals = treasureGoals(view);
|
||||
if (legal(self.position) && goals.size > 0) {
|
||||
const dHome = distancesFrom(view, [self.home], false);
|
||||
const sighted = sightedCellsFor(view);
|
||||
let far: { cell: Cell; worth: number } | null = null;
|
||||
for (const g of goals) {
|
||||
const [gx, gy] = g.split(",").map(Number) as [number, number];
|
||||
for (const side of SIDES) {
|
||||
const c = neighbor({ x: gx, y: gy }, side);
|
||||
if (!legal(c) || !sighted.has(cellKey(c))) continue;
|
||||
const worth = dHome.get(cellKey(c)) ?? 0;
|
||||
if (worth >= 8 && (far === null || worth > far.worth)) far = { cell: c, worth };
|
||||
}
|
||||
}
|
||||
if (far) {
|
||||
return {
|
||||
type: "cast", instanceId: dwarp.instanceId,
|
||||
params: { cell: self.position }, target: { kind: "cell", cell: far.cell },
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Guard the gold on the floor: a SAFE locks it, GLUE sticks it down.
|
||||
const myFloorTreasure = tier.guardGold
|
||||
? view.treasures.find((t) => t.owner === view.you && t.position && !t.carriedBy)
|
||||
@@ -1113,14 +1252,22 @@ export function automatonCommand(
|
||||
|
||||
if (view.turn.actionsEnded) return endTurnDrawing(view, tier, style);
|
||||
|
||||
// IDIOT bars everything but counteractions, the march's aids, and the
|
||||
// jailbreak drop below: any other choice burns the turn on a refusal.
|
||||
const cursedIdiot = view.sustained.some((e) => e.cardId === "idiot" && e.targetId === view.you);
|
||||
const thief = thiefOfMine(view);
|
||||
if (!cursedIdiot) {
|
||||
// Deliver or grab treasure underfoot.
|
||||
if (self.carriedTreasureId && here === cellKey(self.home)) return { type: "dropTreasure" };
|
||||
if (!self.carriedTreasureId) {
|
||||
const prize = view.treasures.find(
|
||||
(t) => t.position && !t.carriedBy && t.owner !== you && cellKey(t.position) === here &&
|
||||
here !== cellKey(self.home),
|
||||
(t) => t.position && !t.carriedBy && cellKey(t.position) === here &&
|
||||
here !== cellKey(self.home) &&
|
||||
(t.owner !== you ||
|
||||
// Repossessing my own gold off an enemy's home square.
|
||||
view.players.some((p) => p.id !== you && cellKey(p.home) === here)),
|
||||
);
|
||||
if (prize) return { type: "pickUpTreasure" };
|
||||
if (prize) return { type: "pickUpTreasure", treasureId: prize.id };
|
||||
}
|
||||
|
||||
// Wounded clockwork teleports clear of visible hunters.
|
||||
@@ -1169,31 +1316,36 @@ export function automatonCommand(
|
||||
}
|
||||
}
|
||||
|
||||
const thief = thiefOfMine(view);
|
||||
|
||||
// One attack per turn: the thief of my gold dies first, then the weakest.
|
||||
// ADRENALINE stretches that to two while it lasts.
|
||||
const adrenalized = view.sustained.some((s) => s.cardId === "adrenaline" && s.targetId === you);
|
||||
if ((!view.turn.attackUsed || (adrenalized && !view.turn.secondAttackUsed)) &&
|
||||
view.turn.round > 1) {
|
||||
const sighted = sightedCellsFor(view);
|
||||
// A wizard under the clockwork's own BUDDY pact is off the target list:
|
||||
// attacking them would tear up the pact it just paid a card for.
|
||||
const pacted = (id: PlayerId) =>
|
||||
// Whoever cast BUDDY on you is safe from your first strike — the
|
||||
// engine refuses the attack outright. Your OWN pacts are merely
|
||||
// precious: striking someone you pacted tears up your protection.
|
||||
const shieldedFromMe = (id: PlayerId) =>
|
||||
view.sustained.some((s) => s.cardId === "buddy" && s.casterId === id && s.targetId === you);
|
||||
const myPact = (id: PlayerId) =>
|
||||
view.sustained.some((s) => s.cardId === "buddy" && s.casterId === you && s.targetId === id);
|
||||
const visible = livingEnemies(view)
|
||||
.filter((p) => sighted.has(cellKey(p.position)) && !pacted(p.id));
|
||||
const visible = bushOrMist(view, you) ? [] : livingEnemies(view)
|
||||
.filter((p) => sighted.has(cellKey(p.position)) &&
|
||||
!shieldedFromMe(p.id) && !myPact(p.id) && !bushOrMist(view, p.id));
|
||||
if (visible.length > 0) {
|
||||
const target = thief && visible.some((p) => p.id === thief.id)
|
||||
? thief
|
||||
: visible.sort((a, b) => a.life - b.life)[0]!;
|
||||
// DROP OBJECT shakes my treasure out of the thief's hands.
|
||||
if (thief && target.id === thief.id) {
|
||||
const dob = inHand(view, "drop-object");
|
||||
if (dob) {
|
||||
// DROP OBJECT shakes the treasure out of the hands that matter:
|
||||
// the thief of my gold, or any carrier whose delivery wins.
|
||||
{
|
||||
const mark = (thief && visible.some((p) => p.id === thief.id)) ? thief
|
||||
: visible.find((p) => deliveryWins(view, p));
|
||||
const dob = mark ? inHand(view, "drop-object") : undefined;
|
||||
if (mark && dob) {
|
||||
return {
|
||||
type: "cast", instanceId: dob.instanceId,
|
||||
target: { kind: "player", playerId: thief.id }, params: { cardId: "treasure" },
|
||||
target: { kind: "player", playerId: mark.id }, params: { cardId: "treasure" },
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1203,6 +1355,7 @@ export function automatonCommand(
|
||||
if (exile) {
|
||||
const carrier = visible.find((p) => {
|
||||
if (thief && p.id === thief.id) return true;
|
||||
if (deliveryWins(view, p)) return true;
|
||||
if (!p.carriedTreasureId) return false;
|
||||
return Math.abs(p.position.x - p.home.x) + Math.abs(p.position.y - p.home.y) <= 6;
|
||||
});
|
||||
@@ -1243,13 +1396,21 @@ export function automatonCommand(
|
||||
if (spell) return spell.cmd;
|
||||
const misery = tier.afflictions ? bestAffliction(view, target.id, thief?.id === target.id) : null;
|
||||
if (misery) return misery;
|
||||
// STRENGTH in force and the treasure in reach: wrest it from their
|
||||
// arms — worth the attack even for the timid.
|
||||
if (cellKey(target.position) === here && target.carriedTreasureId &&
|
||||
view.sustained.some((e) => e.cardId === "strength" && e.targetId === view.you)) {
|
||||
return { type: "tearTreasure", targetId: target.id };
|
||||
}
|
||||
if (cellKey(target.position) === here && style !== "worrier") {
|
||||
return { type: "punch", targetId: target.id };
|
||||
}
|
||||
}
|
||||
// Only pacted wizards in sight? A pact is torn up for one thing: a kill.
|
||||
// Only your own pacted wizards in sight? A pact you signed is torn
|
||||
// up for one thing: a kill. (Pacts signed ON you cannot be broken.)
|
||||
const pactedVisible = livingEnemies(view)
|
||||
.filter((p) => sighted.has(cellKey(p.position)) && pacted(p.id))
|
||||
.filter((p) => sighted.has(cellKey(p.position)) && myPact(p.id) &&
|
||||
!shieldedFromMe(p.id) && !bushOrMist(view, p.id))
|
||||
.sort((a, b) => a.life - b.life);
|
||||
for (const p of pactedVisible) {
|
||||
const spell = bestAttack(view, p.id, tier);
|
||||
@@ -1260,7 +1421,7 @@ export function automatonCommand(
|
||||
const corner = inHand(view, "around-the-corner");
|
||||
if (corner) {
|
||||
for (const p of livingEnemies(view)) {
|
||||
if (pacted(p.id)) continue;
|
||||
if (shieldedFromMe(p.id) || myPact(p.id) || bushOrMist(view, p.id)) continue;
|
||||
if (!bentSightFor(view, self.position, p.position)) continue;
|
||||
const spell = bestAttack(view, p.id, tier);
|
||||
if (spell && spell.cmd.type === "cast") {
|
||||
@@ -1308,48 +1469,116 @@ export function automatonCommand(
|
||||
view.sustained.some((e) => e.cardId === "fear" && e.targetId === p.id) &&
|
||||
Math.abs(p.position.x - self.position.x) + Math.abs(p.position.y - self.position.y) <= 3);
|
||||
for (const source of dreadful) {
|
||||
const here = Math.abs(source.position.x - self.position.x) + Math.abs(source.position.y - self.position.y);
|
||||
const distNow = Math.abs(source.position.x - self.position.x) + Math.abs(source.position.y - self.position.y);
|
||||
let best: { dir: Side; d: number } | null = null;
|
||||
for (const dir of SIDES) {
|
||||
const t = stepTarget(view.board, self.position, dir);
|
||||
if (t.kind === "blocked") continue;
|
||||
if (view.squareContents[cellKey(t.to)]?.kind === "stone") continue;
|
||||
const d = Math.abs(source.position.x - t.to.x) + Math.abs(source.position.y - t.to.y);
|
||||
if (d > here && (best === null || d > best.d)) best = { dir, d };
|
||||
if (d > distNow && (best === null || d > best.d)) best = { dir, d };
|
||||
}
|
||||
if (best) return { type: "move", direction: best.dir };
|
||||
// Dead end: the card excuses what cannot be done; march on normally.
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// IDIOT's curse: the engine never forces the feet, so the clockwork
|
||||
// honors the march to its own gold itself — and when a thief carries
|
||||
// that gold, DROP OBJECT (the one attack the curse permits) shakes it
|
||||
// onto the floor where it can finally be stood upon.
|
||||
if (cursedIdiot && view.turn.round > 1 && !view.turn.attackUsed) {
|
||||
const dropper = inHand(view, "drop-object");
|
||||
if (dropper) {
|
||||
const sighted = sightedCellsFor(view);
|
||||
const thiefOfGold = livingEnemies(view).find((p) =>
|
||||
sighted.has(cellKey(p.position)) &&
|
||||
view.treasures.some((t) => t.owner === view.you && t.carriedBy === p.id));
|
||||
if (thiefOfGold) {
|
||||
return {
|
||||
type: "cast", instanceId: dropper.instanceId,
|
||||
target: { kind: "player", playerId: thiefOfGold.id }, params: { cardId: "treasure" },
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TELEPORT DELIVERY: carrying with home four wall-ignoring spaces away
|
||||
// but farther by boot — blink in and drop next command. The jump beats
|
||||
// any ambush camped on the walking road.
|
||||
if (self.carriedTreasureId && !view.turn.actionsEnded) {
|
||||
const tpHome = inHand(view, "teleport");
|
||||
if (tpHome) {
|
||||
const blink = wallIgnoringDistance(view.board, self.position, self.home);
|
||||
const movesLeft = view.turn.movementAllowance - view.turn.movementUsed;
|
||||
const dWalk = blink >= 1 && blink <= 4
|
||||
? distancesFrom(view, [self.position], UNLOCKS.some((id) => inHand(view, id)))
|
||||
.get(cellKey(self.home)) ?? Infinity
|
||||
: 0;
|
||||
if (blink <= 4 && blink >= 1 && dWalk > movesLeft) {
|
||||
return { type: "cast", instanceId: tpHome.instanceId, target: { kind: "cell", cell: { ...self.home } } };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// March. The thief-chase outranks everything; the berserker hunts wizards
|
||||
// over gold; the worrier keeps its distance; the hunter goes to the gold.
|
||||
if (view.turn.movementUsed < view.turn.movementAllowance) {
|
||||
const gold = treasureGoals(view);
|
||||
const enemyCells = new Set(livingEnemies(view).map((p) => cellKey(p.position)));
|
||||
const canUnlock = UNLOCKS.some((id) => inHand(view, id));
|
||||
const objectives = thief
|
||||
const ownGoldCells = new Set(view.treasures
|
||||
.filter((t) => t.owner === view.you && t.position && !t.carriedBy)
|
||||
.map((t) => cellKey(t.position!)));
|
||||
// The chase exists to deliver a blow, so it claims the march only
|
||||
// while that blow is still live: an attack in hand, this turn's
|
||||
// attack unspent, casting not yet closed. Those gates hold still as
|
||||
// the clockwork walks — a goal keyed to the thief's nearness flips
|
||||
// underfoot and shuttles the walker on and off their square. Caught
|
||||
// with the strike still live, it stands its ground (a goal underfoot
|
||||
// ends the march); otherwise the gold has its legs for the turn.
|
||||
const strikeLive = !view.turn.attackUsed && !view.turn.attackForbidden &&
|
||||
!view.turn.actionsEnded && view.yourHand.some((c) => ATTACKS[c.cardId] != null);
|
||||
const chasing = thief !== null && strikeLive;
|
||||
// BANK GUARD: enemy gold delivered to my home is my scoreboard, and
|
||||
// anyone may snatch it off the floor. A raider nearer my bank than I
|
||||
// am, with the bank stocked, outranks the next grab — run home.
|
||||
const bankedCount = view.treasures.filter(
|
||||
(t) => t.owner !== you && t.position && cellKey(t.position) === cellKey(self.home)).length;
|
||||
// The predicate reads only ENEMY positions — my own steps must not
|
||||
// flip it mid-march or the walker shuttles between goals.
|
||||
const bankThreatened = bankedCount > 0 && !self.carriedTreasureId &&
|
||||
livingEnemies(view).some((p) =>
|
||||
!p.carriedTreasureId &&
|
||||
Math.abs(p.position.x - self.home.x) + Math.abs(p.position.y - self.home.y) <= 2);
|
||||
const objectives = cursedIdiot && ownGoldCells.size > 0
|
||||
? ownGoldCells
|
||||
: chasing
|
||||
? new Set([cellKey(thief.position)])
|
||||
: bankThreatened
|
||||
? new Set([cellKey(self.home)])
|
||||
: style === "berserker" && !self.carriedTreasureId
|
||||
? (enemyCells.size > 0 ? enemyCells : gold)
|
||||
: gold;
|
||||
const path =
|
||||
pathToward(view, self.position, objectives, { avoidNearEnemies: style === "worrier" && !thief, canUnlock }) ??
|
||||
pathToward(view, self.position, objectives, { avoidNearEnemies: style === "worrier" && !chasing, canUnlock }) ??
|
||||
pathToward(view, self.position, objectives, { canUnlock }) ??
|
||||
pathToward(view, self.position, enemyCells, { canUnlock });
|
||||
pathToward(view, self.position, enemyCells, { canUnlock }) ??
|
||||
// No clean road to anything: grit the teeth and wade the hazards,
|
||||
// as any wizard would rather than stand in a pocket forever.
|
||||
pathToward(view, self.position, objectives, { canUnlock, throughHazards: true }) ??
|
||||
pathToward(view, self.position, enemyCells, { canUnlock, throughHazards: true });
|
||||
// A shimmering illusion on the best crossing costs nothing to doubt:
|
||||
// roll the free test before spending any card on that wall (rev 29).
|
||||
if (view.deckRev >= 29) {
|
||||
const crossing = bestWallCrossing(view, self, objectives, canUnlock);
|
||||
if (crossing && crossing.total < (path?.distance ?? Infinity)) {
|
||||
const k = edgeKey(crossing.cell, crossing.side);
|
||||
if (view.illusionEdges[k] === "untested") {
|
||||
// roll the free test before spending any card on that wall.
|
||||
const doubted = bestWallCrossing(view, self, objectives, canUnlock);
|
||||
if (doubted && doubted.total < (path?.distance ?? Infinity) &&
|
||||
view.illusionEdges[edgeKey(doubted.cell, doubted.side)] === "untested") {
|
||||
const sighted = sightedCellsFor(view);
|
||||
const flanks = [crossing.cell, neighbor(crossing.cell, crossing.side)];
|
||||
const flanks = [doubted.cell, neighbor(doubted.cell, doubted.side)];
|
||||
if (flanks.some((c) => cellKey(c) === cellKey(self.position) || sighted.has(cellKey(c)))) {
|
||||
return { type: "testIllusion", cell: crossing.cell, side: crossing.side };
|
||||
}
|
||||
}
|
||||
return { type: "testIllusion", cell: doubted.cell, side: doubted.side };
|
||||
}
|
||||
}
|
||||
// A wall between here and the gold may be cheaper to remove than to walk
|
||||
@@ -1391,7 +1620,6 @@ export function automatonCommand(
|
||||
}
|
||||
// Standing on a warp token whose far side is closer to the goal: step in.
|
||||
{
|
||||
const here = cellKey(self.position);
|
||||
const pair = view.dimWarps.find((w) => cellKey(w.a) === here || cellKey(w.b) === here);
|
||||
if (pair) {
|
||||
const dest = cellKey(pair.a) === here ? pair.b : pair.a;
|
||||
@@ -1418,7 +1646,12 @@ export function automatonCommand(
|
||||
// The war chest: when an attack in hand wants a number, the
|
||||
// biggest one is reserved — a waterbolt unfired outbids a longer
|
||||
// march, and walking is free next turn.
|
||||
const wantsNumber = view.yourHand.some((c) => {
|
||||
// — but a chest hoarded with no enemy in reach just slows the
|
||||
// march, and no reserve outbids the delivery that wins the game.
|
||||
const foesClose = livingEnemies(view).some((p) =>
|
||||
Math.abs(p.position.x - self.position.x) + Math.abs(p.position.y - self.position.y) <= 6);
|
||||
const wantsNumber = foesClose && !deliveryWins(view, self) &&
|
||||
view.yourHand.some((c) => {
|
||||
const atk = ATTACKS[c.cardId];
|
||||
if (!atk) return false;
|
||||
return atk.perNumber || atk.needsNumber === true ||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// The server sends this after every state change; clients never see the
|
||||
// deck order or other players' hands.
|
||||
|
||||
import { sightBetween, traceSight, type AssembledBoard, type Cell, type SightTrace } from "./board";
|
||||
import { SIDES, edgeKey, sightBetween, traceSight, type AssembledBoard, type Cell, type SightTrace } from "./board";
|
||||
import { cardDef, type CardInstance } from "./cards";
|
||||
import {
|
||||
boardView,
|
||||
@@ -42,8 +42,6 @@ export interface GameView {
|
||||
winReason: "treasures" | "lastStanding" | null;
|
||||
turn: TurnState;
|
||||
activePlayerId: PlayerId;
|
||||
/** The game's rules revision — the client mirrors rev-gated legality. */
|
||||
deckRev: number;
|
||||
/** Board with dynamic wall changes already merged in. */
|
||||
board: AssembledBoard;
|
||||
players: PlayerPublicView[];
|
||||
@@ -59,7 +57,13 @@ export interface GameView {
|
||||
/** Duration spells in play (public knowledge). */
|
||||
sustained: SustainedEffect[];
|
||||
squareContents: Record<string, SquareContent>;
|
||||
/** Cells whose contents are glued down (public: the cast was seen). */
|
||||
gluedCells: Record<string, true>;
|
||||
/** Safes standing open (their combination entered this turn). */
|
||||
openSafes: string[];
|
||||
groundObjects: Record<string, CardInstance[]>;
|
||||
/** The rules revision this game was dealt under. */
|
||||
deckRev: number;
|
||||
doorStates: Record<string, "jammed" | "removed">;
|
||||
/** Accumulated attack damage per edge (public — cracks show). */
|
||||
wallDamage: Record<string, number>;
|
||||
@@ -70,7 +74,7 @@ export interface GameView {
|
||||
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
|
||||
/** Every illusion edge and YOUR verdict on it: untested
|
||||
* shimmers, believes renders solid, mine/seesThrough are ghosts. */
|
||||
illusionEdges: Record<string, "untested" | "believes" | "seesThrough" | "mine">;
|
||||
creatures: CreatureState[];
|
||||
@@ -82,9 +86,7 @@ export interface GameView {
|
||||
outOfTurnWindow: { playerId: PlayerId; kind: "interrupt" | "opportunity-fire" } | null;
|
||||
/** CHAOS shield windows in progress (public: everyone sees it coming). */
|
||||
chaosPending: { casterId: PlayerId; queue: PlayerId[] } | null;
|
||||
/** Whether YOUR ward is set to spring. */
|
||||
yourWardArmed: boolean;
|
||||
/** A grab hangs while the treasure's owner decides their Ward (rev 31). */
|
||||
/** A grab hangs while the treasure's owner decides their Ward. */
|
||||
wardPending: { ownerId: PlayerId; takerId: PlayerId } | null;
|
||||
/** YOUR armed ambushes. Other players' ambushes are invisible. */
|
||||
yourAmbushes: AmbushState[];
|
||||
@@ -95,20 +97,17 @@ export interface GameView {
|
||||
export function viewFor(state: GameState, playerId: PlayerId): GameView {
|
||||
const you = state.players.find((p) => p.id === playerId);
|
||||
// Illusion walls render as real walls unless this viewer knows better.
|
||||
// From rules rev 29 their locations are open knowledge (the cast is
|
||||
// public at a table) — what stays personal is each player's verdict.
|
||||
// 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 {
|
||||
@@ -143,7 +142,6 @@ export function viewFor(state: GameState, playerId: PlayerId): GameView {
|
||||
revealedHands: state.phase === "finished"
|
||||
? Object.fromEntries(state.players.map((p) => [p.id, p.alive ? [...p.hand] : [...(p.finalHand ?? p.hand)]]))
|
||||
: null,
|
||||
deckRev: state.config.deckRev ?? 1,
|
||||
treasures: state.treasures.map((t) => ({ ...t })),
|
||||
deckCount: state.deck.length,
|
||||
discardCount: state.discard.length,
|
||||
@@ -152,9 +150,12 @@ export function viewFor(state: GameState, playerId: PlayerId): GameView {
|
||||
pendingDiscard: state.pendingDiscard,
|
||||
sustained: state.sustained.map((s) => ({ ...s })),
|
||||
squareContents: { ...state.squareContents },
|
||||
gluedCells: { ...state.gluedCells },
|
||||
openSafes: [...state.openSafes],
|
||||
groundObjects: Object.fromEntries(
|
||||
Object.entries(state.groundObjects).map(([k, v]) => [k, [...v]]),
|
||||
),
|
||||
deckRev: state.config.deckRev ?? 1,
|
||||
doorStates: { ...state.doorStates },
|
||||
wallDamage: { ...state.wallDamage },
|
||||
openDoorEdges: [...state.openDoorEdges],
|
||||
@@ -169,7 +170,6 @@ export function viewFor(state: GameState, playerId: PlayerId): GameView {
|
||||
chaosPending: state.chaosPending
|
||||
? { casterId: state.chaosPending.casterId, queue: [...state.chaosPending.queue] }
|
||||
: null,
|
||||
yourWardArmed: state.wardArmed.includes(playerId),
|
||||
wardPending: state.wardPending ? { ...state.wardPending } : null,
|
||||
yourAmbushes: state.ambushes
|
||||
.filter((a) => a.ownerId === playerId)
|
||||
@@ -200,16 +200,50 @@ export function sightedCellsFor(view: GameView): Set<string> {
|
||||
const [x, y] = key.split(",").map(Number) as [number, number];
|
||||
if (sightBetween(board, me.position, { x, y }, blockers)) out.add(key);
|
||||
}
|
||||
// VISIONSTONE lets its bearer see through exactly one wall or door —
|
||||
// any one — so a square is also sighted if removing a single edge
|
||||
// reveals it, mirroring the engine's casterLos.
|
||||
if (me.displayed.some((c) => c.cardId === "visionstone")) {
|
||||
const unseen = Object.keys(board.cells).filter((k) => !out.has(k));
|
||||
for (const edgeK of Object.keys(board.edges)) {
|
||||
if (unseen.length === 0) break;
|
||||
if ((board.edges[edgeK] ?? "open") === "open") continue;
|
||||
const edges = { ...board.edges };
|
||||
delete edges[edgeK];
|
||||
const opened = { ...board, edges };
|
||||
for (let i = unseen.length - 1; i >= 0; i--) {
|
||||
const [x, y] = unseen[i]!.split(",").map(Number) as [number, number];
|
||||
if (sightBetween(opened, me.position, { x, y }, blockers)) {
|
||||
out.add(unseen[i]!);
|
||||
unseen.splice(i, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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).
|
||||
// Held-open doors are open doorways to every eye. Beyond them, the
|
||||
// viewer at a door's threshold may pull it open and peek (rules rev 2):
|
||||
// lock removed, door unlocked this turn, or PICK LOCK / MASTER KEY in
|
||||
// hand — mirroring the engine's doorsAjar.
|
||||
let board = view.board;
|
||||
if (view.deckRev >= 15 && view.heldDoorEdges.length > 0) {
|
||||
const openKeys = new Set(view.heldDoorEdges);
|
||||
const me = view.players.find((p) => p.id === view.you);
|
||||
if (view.deckRev >= 2 && me?.alive) {
|
||||
const carriesKey = view.yourHand.some((c) => c.cardId === "pick-lock" || c.cardId === "master-key");
|
||||
for (const side of SIDES) {
|
||||
const key = edgeKey(me.position, side);
|
||||
if (board.edges[key] !== "door") continue;
|
||||
const workable = carriesKey && view.doorStates[key] !== "jammed";
|
||||
if (workable || view.doorStates[key] === "removed" || view.openDoorEdges.includes(key)) openKeys.add(key);
|
||||
}
|
||||
}
|
||||
if (openKeys.size > 0) {
|
||||
const edges = { ...board.edges };
|
||||
for (const k of view.heldDoorEdges) delete edges[k];
|
||||
for (const k of openKeys) delete edges[k];
|
||||
board = { ...board, edges };
|
||||
}
|
||||
const blockers: Record<string, true> = {};
|
||||
@@ -262,7 +296,7 @@ export function bentSightFor(view: GameView, from: Cell, to: Cell): boolean {
|
||||
*/
|
||||
export function stackSightTrace(
|
||||
view: GameView,
|
||||
): { from: Cell; to: Cell; trace: SightTrace } | null {
|
||||
): { from: Cell; to: Cell; trace: SightTrace; bend?: { mid: 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;
|
||||
@@ -271,7 +305,23 @@ export function stackSightTrace(
|
||||
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;
|
||||
if (trace) return { from: a.position, to: d.position, trace };
|
||||
// AROUND THE CORNER: no straight line exists — find a middle square both
|
||||
// ends can see and draw the sight leg by leg, so the table can audit the
|
||||
// needle instead of doubting it.
|
||||
if (stack.bentCorner) {
|
||||
for (const key of Object.keys(view.board.cells)) {
|
||||
const [mx, my] = key.split(",").map(Number) as [number, number];
|
||||
const mid = { x: mx, y: my };
|
||||
if ((mid.x === a.position.x && mid.y === a.position.y) ||
|
||||
(mid.x === d.position.x && mid.y === d.position.y)) continue;
|
||||
const leg1 = traceSightFor(view, a.position, mid);
|
||||
if (!leg1) continue;
|
||||
const leg2 = traceSightFor(view, mid, d.position);
|
||||
if (leg2) return { from: a.position, to: d.position, trace: leg1, bend: { mid, trace: leg2 } };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const CREATION_CARD_IDS = new Set([
|
||||
|
||||
@@ -2,16 +2,19 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
applyCommand,
|
||||
createGame,
|
||||
sustainedOn,
|
||||
type GameState,
|
||||
type PlayerId,
|
||||
} from "../src/game";
|
||||
import { cellKey, edgeKey } from "../src/board";
|
||||
import { sightedCellsFor, viewFor } from "../src/view";
|
||||
import { automatonCommand, automatonFallback, type AutomatonStyle, type AutomatonTier } from "../src/automaton";
|
||||
import { pushSustained } from "./helpers";
|
||||
|
||||
/** Whose input does the maze want right now? */
|
||||
function actingSeat(state: GameState): PlayerId {
|
||||
return (
|
||||
state.wardPending?.ownerId ??
|
||||
state.stack?.waitingOn ??
|
||||
state.pendingDiscard ??
|
||||
state.chaosPending?.queue[0] ??
|
||||
@@ -32,7 +35,6 @@ function playOut(
|
||||
playerIds: ids,
|
||||
seed,
|
||||
sets: expansion ? ["basic", "expansion1"] : ["basic"],
|
||||
deckRev: 8,
|
||||
});
|
||||
let commands = 0;
|
||||
let stuck = 0;
|
||||
@@ -118,8 +120,8 @@ describe("automaton vs automaton", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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 });
|
||||
function underAttack(attackId: string, defenderHand: { instanceId: string; cardId: string }[], rig?: (s: GameState, defender: string) => void) {
|
||||
let { state } = createGame({ playerIds: ["human", "bot"], seed: 42, sets: ["basic", "expansion1"] });
|
||||
// burn round 1
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
||||
@@ -147,7 +149,7 @@ function underAttackShared(attackId: string, defenderHand: { instanceId: string;
|
||||
|
||||
describe("the clockwork does not waste counters on pointless targets", () => {
|
||||
it("passes on drop-object rather than absorbing nothing", () => {
|
||||
const state = underAttackShared("drop-object", [
|
||||
const state = underAttack("drop-object", [
|
||||
{ instanceId: "absorb#T", cardId: "absorb" },
|
||||
{ instanceId: "blunt#T", cardId: "blunt" },
|
||||
{ instanceId: "dagger#T", cardId: "dagger" },
|
||||
@@ -157,7 +159,7 @@ describe("the clockwork does not waste counters on pointless targets", () => {
|
||||
});
|
||||
|
||||
it("shields a drop-object aimed at its carried treasure", () => {
|
||||
const state = underAttackShared("drop-object", [
|
||||
const state = underAttack("drop-object", [
|
||||
{ instanceId: "full-shield#T", cardId: "full-shield" },
|
||||
{ instanceId: "dagger#T", cardId: "dagger" },
|
||||
], (s, defender) => {
|
||||
@@ -171,10 +173,10 @@ describe("the clockwork does not waste counters on pointless targets", () => {
|
||||
});
|
||||
|
||||
describe("the clockwork guards gold only within sight", () => {
|
||||
// Game X2XN: the bot blind-cast SAFE at a treasure it could not see, the
|
||||
// engine refused it, and the fallback burned the whole turn.
|
||||
// A refused cast forfeits the bot's whole turn (the fallback is endTurn),
|
||||
// so SAFE must only ever be offered where the engine's sight rule allows it.
|
||||
function goldOnTheFloor() {
|
||||
let { state } = createGame({ playerIds: ["bot", "foe"], seed: 7, sets: ["basic", "expansion1"], deckRev: 36 });
|
||||
let { state } = createGame({ playerIds: ["bot", "foe"], seed: 7, sets: ["basic", "expansion1"] });
|
||||
while (actingSeat(state) !== "bot") {
|
||||
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
@@ -223,9 +225,142 @@ describe("the clockwork guards gold only within sight", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("the clockwork honors IDIOT's march", () => {
|
||||
// The engine never steers a cursed wizard's feet; the duty is the brain's.
|
||||
function cursedBot() {
|
||||
let { state } = createGame({ playerIds: ["bot", "foe"], seed: 7, sets: ["basic", "expansion1"] });
|
||||
// Past round 1 (no combat) and around to the bot's turn.
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
state = r.state;
|
||||
}
|
||||
while (actingSeat(state) !== "bot") {
|
||||
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
state = r.state;
|
||||
}
|
||||
pushSustained(state, {
|
||||
id: "fx-idiot", cardId: "idiot", casterId: "foe", targetId: "bot",
|
||||
remainingTurns: 9999, data: {},
|
||||
});
|
||||
return state;
|
||||
}
|
||||
|
||||
it("marches to its own gold until the curse lifts", () => {
|
||||
let state = cursedBot();
|
||||
state.players.find((p) => p.id === "bot")!.hand = [];
|
||||
// A walled route can cost more steps than one turn's allowance; the
|
||||
// march may span turns (the foe just passes).
|
||||
for (let i = 0; i < 30 && sustainedOn(state, "bot", "idiot").length > 0; i++) {
|
||||
const seat = actingSeat(state);
|
||||
const cmd = seat === "bot"
|
||||
? automatonCommand(viewFor(state, "bot"), "hunter", "archmage")
|
||||
?? automatonFallback(viewFor(state, "bot"), "archmage")
|
||||
: { type: "endTurn", draw: 0 } as const;
|
||||
const r = applyCommand(state, seat, cmd);
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
state = r.state;
|
||||
}
|
||||
expect(sustainedOn(state, "bot", "idiot").length).toBe(0);
|
||||
});
|
||||
|
||||
it("shakes its gold from a thief's arms with DROP OBJECT", () => {
|
||||
const state = cursedBot();
|
||||
const bot = state.players.find((p) => p.id === "bot")!;
|
||||
const thief = state.players.find((p) => p.id === "foe")!;
|
||||
thief.position = { ...bot.position };
|
||||
const own = state.treasures.find((t) => t.owner === "bot")!;
|
||||
own.carriedBy = "foe";
|
||||
own.position = null;
|
||||
thief.carriedTreasureId = own.id;
|
||||
bot.hand = [{ instanceId: "drop-object#T", cardId: "drop-object" }];
|
||||
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
|
||||
expect(cmd).toEqual({
|
||||
type: "cast", instanceId: "drop-object#T",
|
||||
target: { kind: "player", playerId: "foe" }, params: { cardId: "treasure" },
|
||||
});
|
||||
expect(applyCommand(state, "bot", cmd!).ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the clockwork wades hazards rather than surrender", () => {
|
||||
it("takes the slime road when no clean path to anything exists", () => {
|
||||
let { state } = createGame({ playerIds: ["bot", "foe"], seed: 7, sets: ["basic", "expansion1"] });
|
||||
while (actingSeat(state) !== "bot") {
|
||||
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
state = r.state;
|
||||
}
|
||||
const bot = state.players.find((p) => p.id === "bot")!;
|
||||
bot.hand = [];
|
||||
// Wall the bot into its square except one side; slime that one exit.
|
||||
const sides = ["N", "S", "E", "W"] as const;
|
||||
const board = state.board;
|
||||
const open = sides.filter((s) => {
|
||||
const n = { x: bot.position.x + (s === "E" ? 1 : s === "W" ? -1 : 0),
|
||||
y: bot.position.y + (s === "S" ? 1 : s === "N" ? -1 : 0) };
|
||||
return board.cells[cellKey(n)] !== undefined;
|
||||
});
|
||||
const exit = open[0]!;
|
||||
for (const s2 of sides) {
|
||||
if (s2 !== exit) state.edgeOverrides[edgeKey(bot.position, s2)] = "wall";
|
||||
else state.edgeOverrides[edgeKey(bot.position, s2)] = "open";
|
||||
}
|
||||
const beyond = { x: bot.position.x + (exit === "E" ? 1 : exit === "W" ? -1 : 0),
|
||||
y: bot.position.y + (exit === "S" ? 1 : exit === "N" ? -1 : 0) };
|
||||
state.squareContents[cellKey(beyond)] = { kind: "slime", damage: 0, createdBy: "foe" };
|
||||
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
|
||||
expect(cmd).toEqual({ type: "move", direction: exit });
|
||||
expect(applyCommand(state, "bot", cmd!).ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the clockwork respects the bush's shelter", () => {
|
||||
function faceOffWithFireball() {
|
||||
let { state } = createGame({ playerIds: ["bot", "foe"], seed: 7, sets: ["basic", "expansion1"] });
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
state = r.state;
|
||||
}
|
||||
while (actingSeat(state) !== "bot") {
|
||||
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
state = r.state;
|
||||
}
|
||||
const bot = state.players.find((p) => p.id === "bot")!;
|
||||
const foe = state.players.find((p) => p.id === "foe")!;
|
||||
foe.position = { ...bot.position };
|
||||
bot.hand = [{ instanceId: "fireball#T", cardId: "fireball" }];
|
||||
return { state, bot, foe };
|
||||
}
|
||||
|
||||
it("never aims at a wizard sheltered in a thornbush", () => {
|
||||
const { state, foe } = faceOffWithFireball();
|
||||
state.squareContents[cellKey(foe.position)] = { kind: "thornbush", damage: 0, createdBy: "foe" };
|
||||
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage")
|
||||
?? automatonFallback(viewFor(state, "bot"), "archmage");
|
||||
// Whatever it picks, the engine must accept it — and it must not be
|
||||
// the refused fireball that would burn the whole turn.
|
||||
expect(cmd).not.toMatchObject({ type: "cast", instanceId: "fireball#T" });
|
||||
expect(applyCommand(state, "bot", cmd).ok).toBe(true);
|
||||
});
|
||||
|
||||
it("never attacks out of its own bush", () => {
|
||||
const { state, bot, foe } = faceOffWithFireball();
|
||||
foe.position = { ...bot.position };
|
||||
state.squareContents[cellKey(bot.position)] = { kind: "thornbush", damage: 0, createdBy: "foe" };
|
||||
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage")
|
||||
?? automatonFallback(viewFor(state, "bot"), "archmage");
|
||||
expect(cmd).not.toMatchObject({ type: "cast", instanceId: "fireball#T" });
|
||||
expect(applyCommand(state, "bot", cmd).ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a clogged hand gets shed, not hoarded", () => {
|
||||
it("the bot discards dead weight so the end-of-turn draw has room", () => {
|
||||
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic"], deckRev: 13 });
|
||||
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic"] });
|
||||
while (actingSeat(state) !== "bot") {
|
||||
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
@@ -260,7 +395,7 @@ describe("a clogged hand gets shed, not hoarded", () => {
|
||||
|
||||
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", [
|
||||
const state = underAttack("no-spell", [
|
||||
{ instanceId: "absorb#T", cardId: "absorb" },
|
||||
{ instanceId: "blunt#T", cardId: "blunt" },
|
||||
]);
|
||||
@@ -271,7 +406,7 @@ describe("the clockwork honors absorb's fine print", () => {
|
||||
|
||||
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 });
|
||||
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic"] });
|
||||
while (actingSeat(state) !== "bot") {
|
||||
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
@@ -293,7 +428,7 @@ describe("the clockwork wields destroy wall", () => {
|
||||
});
|
||||
|
||||
it("values destroy wall above the chaff when forced to discard", () => {
|
||||
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic"], deckRev: 24 });
|
||||
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic"] });
|
||||
const bot = state.players.find((p) => p.id === "bot")!;
|
||||
bot.hand = [
|
||||
{ instanceId: "destroy-wall#T", cardId: "destroy-wall" },
|
||||
@@ -316,7 +451,7 @@ describe("the clockwork wields destroy wall", () => {
|
||||
|
||||
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 });
|
||||
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic"] });
|
||||
while (actingSeat(state) !== "bot") {
|
||||
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
@@ -348,7 +483,7 @@ describe("the clockwork wields pass through wall", () => {
|
||||
|
||||
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 });
|
||||
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic"] });
|
||||
while (actingSeat(state) !== "bot") {
|
||||
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
@@ -386,7 +521,7 @@ describe("the clockwork denies the road", () => {
|
||||
|
||||
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 });
|
||||
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic", "expansion1"] });
|
||||
while (actingSeat(state) !== "bot") {
|
||||
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
@@ -408,7 +543,7 @@ describe("the widened spellbook", () => {
|
||||
});
|
||||
|
||||
it("offers a buddy pact to the hound at its heels", () => {
|
||||
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic", "expansion1"], deckRev: 24 });
|
||||
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic", "expansion1"] });
|
||||
while (actingSeat(state) !== "bot") {
|
||||
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
@@ -428,9 +563,9 @@ describe("the widened spellbook", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("round two of the owner's tactics", () => {
|
||||
describe("finishing tactics: exile and adrenaline", () => {
|
||||
it("exiles a thief carrying its gold to the far end of nowhere", () => {
|
||||
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic", "expansion1"], deckRev: 24 });
|
||||
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic", "expansion1"] });
|
||||
// burn round 1 and reach the bot's turn
|
||||
for (let guard = 0; guard < 10 && !(actingSeat(state) === "bot" && state.turn.round > 1); guard++) {
|
||||
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
||||
@@ -458,7 +593,7 @@ describe("round two of the owner's tactics", () => {
|
||||
});
|
||||
|
||||
it("casts adrenaline when two blows finish what one cannot", () => {
|
||||
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic", "expansion1"], deckRev: 24 });
|
||||
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic", "expansion1"] });
|
||||
for (let guard = 0; guard < 10 && !(actingSeat(state) === "bot" && state.turn.round > 1); guard++) {
|
||||
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
@@ -483,7 +618,7 @@ describe("round two of the owner's tactics", () => {
|
||||
|
||||
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 });
|
||||
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic", "expansion1"] });
|
||||
for (let guard = 0; guard < 10 && !(actingSeat(state) === "bot" && state.turn.round > 1); guard++) {
|
||||
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
@@ -524,7 +659,7 @@ describe("the fireball-then-buddy lockout", () => {
|
||||
|
||||
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 });
|
||||
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic", "expansion1"] });
|
||||
for (let guard = 0; guard < 10 && !(actingSeat(state) === "bot" && state.turn.round > 1); guard++) {
|
||||
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
@@ -534,10 +669,10 @@ describe("a pact once signed is honored", () => {
|
||||
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({
|
||||
pushSustained(state, {
|
||||
id: "fx-test", cardId: "buddy", casterId: "bot", targetId: "other",
|
||||
turnsLeft: 1000, edge: undefined,
|
||||
} as never);
|
||||
remainingTurns: 1000, data: {},
|
||||
});
|
||||
bot.hand = [{ instanceId: "fireball#T", cardId: "fireball" }];
|
||||
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
|
||||
// Anything but an attack on the pacted wizard: no cast at them, no punch.
|
||||
@@ -550,7 +685,7 @@ describe("a pact once signed is honored", () => {
|
||||
|
||||
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 });
|
||||
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic", "expansion1"] });
|
||||
for (let guard = 0; guard < 10 && !(actingSeat(state) === "bot" && state.turn.round > 1); guard++) {
|
||||
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
@@ -576,7 +711,7 @@ describe("no number is wasted on a turn that ends at a grab", () => {
|
||||
|
||||
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 });
|
||||
let { state } = createGame({ playerIds: ["bot", "grim"], seed: 42, sets: ["basic"] });
|
||||
for (let guard = 0; guard < 10 && !(actingSeat(state) === "bot" && state.turn.round > 1); guard++) {
|
||||
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
@@ -586,10 +721,10 @@ describe("the clockwork flees the dread", () => {
|
||||
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({
|
||||
pushSustained(state, {
|
||||
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") {
|
||||
@@ -601,3 +736,246 @@ describe("the clockwork flees the dread", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("the thief-chase holds one goal per turn", () => {
|
||||
it("a clockwork beside its thief never shuttles on and off their square", () => {
|
||||
let { state } = createGame({ playerIds: ["thief", "bot"], seed: 42, sets: ["basic", "expansion1"] });
|
||||
// Round 2, thief's turn burned; the bot acts with a full allowance.
|
||||
while (state.turn.round < 2 || state.players[state.turn.activeIndex]!.id !== "bot") {
|
||||
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
state = r.state;
|
||||
}
|
||||
const thief = state.players.find((p) => p.id === "thief")!;
|
||||
const bot = state.players.find((p) => p.id === "bot")!;
|
||||
// The thief carries the bot's gold and stands one step away.
|
||||
const mine = state.treasures.find((t) => t.owner === "bot")!;
|
||||
mine.carriedBy = "thief";
|
||||
mine.position = null;
|
||||
thief.position = { x: 2, y: 4 };
|
||||
bot.position = { x: 2, y: 5 };
|
||||
state.edgeOverrides[edgeKey({ x: 2, y: 4 }, "S")] = "open";
|
||||
// A number card and no attacks: the blow the chase serves cannot land.
|
||||
bot.hand = [];
|
||||
bot.hand.push({ cardId: "number-2", instanceId: "N2" });
|
||||
const visited = [cellKey(bot.position)];
|
||||
for (let guard = 0; guard < 40; guard++) {
|
||||
const view = viewFor(state, "bot");
|
||||
const cmd = automatonCommand(view, "hunter", "archmage") ?? automatonFallback(view, "archmage");
|
||||
if (cmd.type === "endTurn") break;
|
||||
const r = applyCommand(state, "bot", cmd);
|
||||
if (!r.ok) break;
|
||||
state = r.state;
|
||||
const at = cellKey(state.players.find((p) => p.id === "bot")!.position);
|
||||
if (cmd.type === "move" && at !== visited[visited.length - 1]) visited.push(at);
|
||||
}
|
||||
// No step may return to the square just departed: A-B-A is the shuttle.
|
||||
for (let i = 2; i < visited.length; i++) {
|
||||
expect(visited[i]).not.toBe(visited[i - 2]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("the archmage's key, shield, and race discipline", () => {
|
||||
function botTurn() {
|
||||
const seed = 42;
|
||||
let { state } = createGame({ playerIds: ["foe", "bot"], seed, sets: ["basic", "expansion1"] });
|
||||
while (state.turn.round < 2 || state.players[state.turn.activeIndex]!.id !== "bot") {
|
||||
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
state = r.state;
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
it("a MASTER KEY drawn goes straight on display", () => {
|
||||
const state = botTurn();
|
||||
const bot = state.players.find((p) => p.id === "bot")!;
|
||||
bot.hand.push({ cardId: "master-key", instanceId: "MK" });
|
||||
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
|
||||
expect(cmd).toEqual({ type: "cast", instanceId: "MK" });
|
||||
});
|
||||
|
||||
it("a lethal small blow is countered, thrift be damned", () => {
|
||||
let { state } = createGame({ playerIds: ["foe", "bot"], seed: 42, sets: ["basic", "expansion1"] });
|
||||
while (state.turn.round < 2 || state.players[state.turn.activeIndex]!.id !== "foe") {
|
||||
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
state = r.state;
|
||||
}
|
||||
const foe = state.players.find((p) => p.id === "foe")!;
|
||||
const bot = state.players.find((p) => p.id === "bot")!;
|
||||
bot.position = { ...foe.position };
|
||||
bot.life = 2;
|
||||
bot.hand = [{ cardId: "full-shield", instanceId: "FS" }];
|
||||
foe.hand.push({ cardId: "fireball", instanceId: "FB" });
|
||||
const r = applyCommand(state, "foe", {
|
||||
type: "cast", instanceId: "FB", target: { kind: "player", playerId: "bot" },
|
||||
});
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
// Base fireball, 2 points: below every thrift threshold, but fatal at
|
||||
// 2 life — the shield comes out.
|
||||
const cmd = automatonCommand(viewFor(r.state, "bot"), "hunter", "archmage");
|
||||
expect(cmd).toEqual({ type: "counteract", instanceId: "FS" });
|
||||
});
|
||||
|
||||
it("no number is hoarded against the delivery that wins the game", () => {
|
||||
let state = botTurn();
|
||||
const bot = state.players.find((p) => p.id === "bot")!;
|
||||
const foe = state.players.find((p) => p.id === "foe")!;
|
||||
// One of the foe's treasures already rests at the bot's home; the bot
|
||||
// carries the second, four squares out with three legs — only the
|
||||
// number bridges it home this turn.
|
||||
const gold = state.treasures.filter((t) => t.owner === "foe");
|
||||
expect(gold.length).toBeGreaterThanOrEqual(2);
|
||||
gold[0]!.position = { ...bot.home };
|
||||
gold[0]!.carriedBy = null;
|
||||
gold[1]!.carriedBy = "bot";
|
||||
gold[1]!.position = null;
|
||||
bot.carriedTreasureId = gold[1]!.id;
|
||||
foe.position = { ...bot.home }; // a foe in sight: the war chest would hoard
|
||||
// Legs only: an attack in hand would fire at the foe in sight and
|
||||
// open a stack this single-seat loop cannot answer.
|
||||
bot.hand = [{ cardId: "number-3", instanceId: "N3" }];
|
||||
// Carve a straight, open four-square run to home; the rig owns its
|
||||
// geometry rather than praying the seed provides it.
|
||||
const home = bot.home;
|
||||
const column = [4, 3, 2, 1, 0].map((d) => ({ x: home.x, y: home.y - d }));
|
||||
for (const c of column) {
|
||||
if (!viewFor(state, "bot").board.cells[cellKey(c)]) {
|
||||
throw new Error("setup: the column north of home runs off the board");
|
||||
}
|
||||
delete state.squareContents[cellKey(c)];
|
||||
}
|
||||
bot.position = { ...column[0]! };
|
||||
for (const c of column.slice(0, 4)) state.edgeOverrides[edgeKey(c, "S")] = "open";
|
||||
for (let guard = 0; guard < 12; guard++) {
|
||||
const view = viewFor(state, "bot");
|
||||
const cmd = automatonCommand(view, "hunter", "archmage") ?? automatonFallback(view, "archmage");
|
||||
if (cmd.type === "playNumberForMovement") return; // the chest opened for the win
|
||||
if (cmd.type === "endTurn") break;
|
||||
const r = applyCommand(state, "bot", cmd);
|
||||
if (!r.ok) break;
|
||||
state = r.state;
|
||||
if (state.phase !== "playing") return; // delivered and won outright
|
||||
}
|
||||
throw new Error("the clockwork hoarded its number instead of winning");
|
||||
});
|
||||
});
|
||||
|
||||
describe("bank guarding, teleport delivery, and turn theft", () => {
|
||||
function rig() {
|
||||
const players = ["foe", "bot"];
|
||||
let { state } = createGame({ playerIds: players, seed: 42, sets: ["basic", "expansion1"] });
|
||||
while (state.turn.round < 2 || state.players[state.turn.activeIndex]!.id !== "bot") {
|
||||
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
state = r.state;
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
it("a raider at the stocked bank's gates pulls the clockwork home", () => {
|
||||
const state = rig();
|
||||
const bot = state.players.find((p) => p.id === "bot")!;
|
||||
const foe = state.players.find((p) => p.id === "foe")!;
|
||||
const gold = state.treasures.find((t) => t.owner === "foe")!;
|
||||
gold.position = { ...bot.home };
|
||||
gold.carriedBy = null;
|
||||
foe.position = { x: bot.home.x, y: bot.home.y + 1 };
|
||||
bot.position = { ...foe.home };
|
||||
bot.hand = [];
|
||||
// The march may wind through the maze; what matters is that it ENDS
|
||||
// at the threatened bank, not out on the gold map.
|
||||
let cur = state;
|
||||
for (let guard = 0; guard < 60; guard++) {
|
||||
const seat = actingSeat(cur);
|
||||
if (seat !== "bot") {
|
||||
// The foe answers stacks with a pass and burns its turns.
|
||||
let r2 = applyCommand(cur, seat, { type: "pass" });
|
||||
if (!r2.ok) r2 = applyCommand(cur, seat, { type: "endTurn", draw: 0 });
|
||||
if (!r2.ok) throw new Error(`foe stuck: ${r2.error}`);
|
||||
cur = r2.state;
|
||||
continue;
|
||||
}
|
||||
const view = viewFor(cur, "bot");
|
||||
const cmd = automatonCommand(view, "hunter", "archmage") ?? automatonFallback(view, "archmage");
|
||||
const r = applyCommand(cur, "bot", cmd);
|
||||
if (!r.ok) throw new Error(`refused: ${JSON.stringify(cmd)} — ${r.error}`);
|
||||
cur = r.state;
|
||||
const at = cur.players.find((p) => p.id === "bot")!;
|
||||
if (cellKey(at.position) === cellKey(bot.home)) return;
|
||||
}
|
||||
throw new Error("the clockwork never came home to its threatened bank");
|
||||
});
|
||||
|
||||
it("carrying with home a blink away, it teleports the delivery", () => {
|
||||
const state = rig();
|
||||
const bot = state.players.find((p) => p.id === "bot")!;
|
||||
const prize = state.treasures.find((t) => t.owner === "foe")!;
|
||||
prize.carriedBy = "bot";
|
||||
prize.position = null;
|
||||
bot.carriedTreasureId = prize.id;
|
||||
// Two squares from home as the spell flies, but walled off on foot.
|
||||
bot.position = { x: bot.home.x, y: bot.home.y + 2 };
|
||||
for (const side of ["N", "S", "E", "W"] as const) {
|
||||
state.edgeOverrides[edgeKey(bot.position, side)] = "wall";
|
||||
}
|
||||
bot.hand = [{ cardId: "teleport", instanceId: "TP" }];
|
||||
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
|
||||
expect(cmd).toEqual({ type: "cast", instanceId: "TP", target: { kind: "cell", cell: { ...bot.home } } });
|
||||
});
|
||||
|
||||
it("a would-win carrier eats the lightning, not the fireball", () => {
|
||||
let state = rig();
|
||||
const bot = state.players.find((p) => p.id === "bot")!;
|
||||
const foe = state.players.find((p) => p.id === "foe")!;
|
||||
// Foe has one banked and carries the second: one delivery from winning.
|
||||
const banked = state.treasures.find((t) => t.owner === "bot")!;
|
||||
banked.position = { ...foe.home };
|
||||
const carried = state.treasures.filter((t) => t.owner === "bot")[1];
|
||||
if (!carried) throw new Error("setup: the bot owns fewer than two treasures");
|
||||
carried.carriedBy = "foe";
|
||||
carried.position = null;
|
||||
foe.carriedTreasureId = carried.id;
|
||||
foe.position = { ...bot.position };
|
||||
bot.hand = [
|
||||
{ cardId: "fireball", instanceId: "FB" },
|
||||
{ cardId: "lightning-blast", instanceId: "LB" },
|
||||
{ cardId: "number-4", instanceId: "N4" },
|
||||
];
|
||||
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
|
||||
expect(cmd).toMatchObject({ type: "cast", instanceId: "LB" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("the denial planner offers only castable blocks", () => {
|
||||
it("tacks are offered only at the caster's feet", () => {
|
||||
// TACKS demand adjacency; a planner that proposes them at range has
|
||||
// its cast refused every turn, and the refusal-fallback loop reads
|
||||
// as an idle bot.
|
||||
let { state } = createGame({ playerIds: ["foe", "bot"], seed: 42, sets: ["basic", "expansion1"] });
|
||||
while (state.turn.round < 2 || state.players[state.turn.activeIndex]!.id !== "bot") {
|
||||
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
state = r.state;
|
||||
}
|
||||
const bot = state.players.find((p) => p.id === "bot")!;
|
||||
const foe = state.players.find((p) => p.id === "foe")!;
|
||||
// The bot's floor gold with a raider closing on it, and only TACKS
|
||||
// in hand to deny the road — from a stand-off distance.
|
||||
const gold = state.treasures.find((t) => t.owner === "bot" && t.position)!;
|
||||
foe.position = { ...gold.position! };
|
||||
bot.position = { ...bot.home };
|
||||
bot.hand = [{ cardId: "handful-of-tacks", instanceId: "HT" }];
|
||||
for (let guard = 0; guard < 6; guard++) {
|
||||
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
|
||||
if (!cmd) break;
|
||||
const r = applyCommand(state, "bot", cmd);
|
||||
// Whatever the brain proposes, the engine must accept it.
|
||||
if (!r.ok) throw new Error(`brain proposed a refused command: ${JSON.stringify(cmd)} — ${r.error}`);
|
||||
state = r.state;
|
||||
if (cmd.type === "endTurn") break;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -201,7 +201,7 @@ describe("setup diagram pairings (rulebook Set-Up Diagram)", () => {
|
||||
});
|
||||
|
||||
it("relocation discards the aisle warp: only opposite edges connect after", () => {
|
||||
let { state } = createGame({ playerIds: ["a", "b", "c"], seed: 7, sets: ["basic"], deckRev: 5 });
|
||||
let { state } = createGame({ playerIds: ["a", "b", "c"], seed: 7, sets: ["basic"] });
|
||||
const bent = (warps: typeof state.board.warps) =>
|
||||
warps.filter((w) => w.from.cell.x !== w.to.cell.x && w.from.cell.y !== w.to.cell.y);
|
||||
expect(bent(state.board.warps).length).toBeGreaterThan(0); // the arc exists
|
||||
@@ -244,7 +244,7 @@ describe("the aisle warp treats its corner as adjacent — sight included", () =
|
||||
|
||||
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 });
|
||||
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"] });
|
||||
// round 1 passes
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const r = applyCommand(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
|
||||
@@ -281,9 +281,9 @@ describe("bricking over a warp mouth", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("breaching the rim (rules rev 19)", () => {
|
||||
describe("breaching the rim", () => {
|
||||
it("destroying a perimeter wall opens both sides as a new warp", () => {
|
||||
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"], deckRev: 19 });
|
||||
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"] });
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const r = applyCommand(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
import { applyCommand, activePlayer, boardView, createGame, type GameState } from "../src/game";
|
||||
import { cellKey, edgeKey, hasLineOfSight, neighbor, type Cell, type Side, SIDES, stepTarget } from "../src/board";
|
||||
import type { CardInstance } from "../src/cards";
|
||||
import { viewFor } from "../src/view";
|
||||
import { sightedCellsFor, stackSightTrace, viewFor } from "../src/view";
|
||||
import { newGame, must, giveCard, toRound2, faceOff } from "./helpers";
|
||||
|
||||
/** Test surgery: put a specific card into a player's hand (swapping one out). */
|
||||
@@ -31,10 +31,10 @@ describe("attack spells", () => {
|
||||
d.hand[1] = { instanceId: "full-shield#T", cardId: "full-shield" };
|
||||
|
||||
state = must(state, attacker, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender } });
|
||||
// Full shield stops everything -> stones survive.
|
||||
// Full shield stops everything -> stones survive. (A total stop
|
||||
// resolves on the attacker's pass; no bounce back.)
|
||||
state = must(state, defender, { type: "counteract", instanceId: "full-shield#T" });
|
||||
state = must(state, attacker, { type: "pass" });
|
||||
state = must(state, defender, { type: "pass" });
|
||||
const after = state.players.find((p) => p.id === defender)!;
|
||||
expect(after.life).toBe(15);
|
||||
expect(after.hand.some((c) => c.cardId === "powerstone")).toBe(true);
|
||||
@@ -111,7 +111,8 @@ describe("attack spells", () => {
|
||||
state = must(state, attacker, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender } });
|
||||
state = must(state, defender, { type: "counteract", instanceId: "full-reflection#T" });
|
||||
state = must(state, attacker, { type: "pass" });
|
||||
state = must(state, defender, { type: "pass" });
|
||||
// The returned spell is a fresh attack on its caster: their window.
|
||||
state = must(state, attacker, { type: "pass" });
|
||||
expect(state.players.find((p) => p.id === defender)!.life).toBe(15);
|
||||
expect(state.players.find((p) => p.id === attacker)!.life).toBe(10);
|
||||
});
|
||||
@@ -307,17 +308,15 @@ describe("stored-log compatibility", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("deck revisions", () => {
|
||||
it("revision 2 removes LIFESAVER at two players, and only there", () => {
|
||||
const two = createGame({ playerIds: ["a", "b"], seed: 9, sets: ["basic", "expansion1"], deckRev: 2 });
|
||||
describe("the two-player deck", () => {
|
||||
it("two-player games shed LIFESAVER; bigger tables keep it", () => {
|
||||
const two = createGame({ playerIds: ["a", "b"], seed: 9, sets: ["basic", "expansion1"] });
|
||||
const inGame = (s: typeof two.state) =>
|
||||
[...s.deck, ...s.discard, ...s.players.flatMap((p) => p.hand)].some((c) => c.cardId === "lifesaver");
|
||||
expect(inGame(two.state)).toBe(false);
|
||||
// Three players keep it; old two-player games (no deckRev) keep it too.
|
||||
const three = createGame({ playerIds: ["a", "b", "c"], seed: 9, sets: ["basic", "expansion1"], deckRev: 2 });
|
||||
// Three players keep it.
|
||||
const three = createGame({ playerIds: ["a", "b", "c"], seed: 9, sets: ["basic", "expansion1"] });
|
||||
expect(inGame(three.state)).toBe(true);
|
||||
const legacy = createGame({ playerIds: ["a", "b"], seed: 9, sets: ["basic", "expansion1"] });
|
||||
expect(inGame(legacy.state)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -380,42 +379,60 @@ describe("attacking walls and doors", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("ward arming and chaos shields (rules rev 3)", () => {
|
||||
function rev3Game(seed = 42) {
|
||||
return createGame({ playerIds: ["alice", "bob", "cara"], seed, sets: ["basic", "expansion1"], deckRev: 3 });
|
||||
describe("the ward window and chaos shields", () => {
|
||||
function threeGame(seed = 42) {
|
||||
return createGame({ playerIds: ["alice", "bob", "cara"], seed, sets: ["basic", "expansion1"] });
|
||||
}
|
||||
|
||||
it("ward springs only when its owner armed it", () => {
|
||||
let { state } = rev3Game();
|
||||
it("the grab hangs while the ward's owner decides — and arming ahead is refused", () => {
|
||||
let { state } = threeGame();
|
||||
state = toRound2(state);
|
||||
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
|
||||
const thief = activePlayer(state);
|
||||
const owner = state.players.find((p) => p.id !== thief.id)!;
|
||||
giveCard(state, owner.id, "ward", "W", 0);
|
||||
expect(applyCommand(state, owner.id, { type: "armWard" }).ok).toBe(false);
|
||||
const treasure = state.treasures.find((t) => t.owner === owner.id)!;
|
||||
|
||||
// Unarmed: the grab goes unpunished.
|
||||
thief.position = { ...treasure.position! };
|
||||
let s2 = must(state, thief.id, { type: "pickUpTreasure" });
|
||||
expect(s2.players.find((p) => p.id === thief.id)!.life).toBe(15);
|
||||
expect(s2.players.find((p) => p.id === owner.id)!.hand.some((c) => c.cardId === "ward")).toBe(true);
|
||||
state = must(state, thief.id, { type: "pickUpTreasure" });
|
||||
expect(state.wardPending).toEqual({ ownerId: owner.id, takerId: thief.id });
|
||||
// Spring it: the thief bleeds 3 and the ward is spent.
|
||||
state = must(state, owner.id, { type: "wardChoice", play: true });
|
||||
expect(state.players.find((p) => p.id === thief.id)!.life).toBe(12);
|
||||
expect(state.players.find((p) => p.id === owner.id)!.hand.some((c) => c.cardId === "ward")).toBe(false);
|
||||
});
|
||||
|
||||
// Armed (on the owner's own turn): the trap bites for 3.
|
||||
const ownerTurnState = (() => {
|
||||
let s = state;
|
||||
while (activePlayer(s).id !== owner.id) s = must(s, activePlayer(s).id, { type: "endTurn", draw: 0 });
|
||||
return s;
|
||||
})();
|
||||
let s3 = must(ownerTurnState, owner.id, { type: "armWard", armed: true });
|
||||
while (activePlayer(s3).id !== thief.id) s3 = must(s3, activePlayer(s3).id, { type: "endTurn", draw: 0 });
|
||||
s3.players.find((p) => p.id === thief.id)!.position = { ...treasure.position! };
|
||||
s3 = must(s3, thief.id, { type: "pickUpTreasure" });
|
||||
expect(s3.players.find((p) => p.id === thief.id)!.life).toBe(12);
|
||||
expect(s3.wardArmed).not.toContain(owner.id);
|
||||
it("chaos casts bare — no target, every other wizard queued for a shield", () => {
|
||||
let { state } = threeGame();
|
||||
state = toRound2(state);
|
||||
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
|
||||
const caster = activePlayer(state);
|
||||
const others = state.players.filter((p) => p.id !== caster.id).map((p) => p.id);
|
||||
const chaos = giveCard(state, caster.id, "chaos", "C", 0);
|
||||
const shielder = others[0]!;
|
||||
giveCard(state, shielder, "full-shield", "S", 0);
|
||||
const shielderHand = () => state.players.find((p) => p.id === shielder)!.hand.map((c) => c.instanceId).sort();
|
||||
const kept = shielderHand().filter((id) => id !== "full-shield#S").sort();
|
||||
|
||||
state = must(state, caster.id, { type: "cast", instanceId: chaos.instanceId });
|
||||
// No stack: the queue opens at once, in turn order from the caster.
|
||||
expect(state.stack).toBeNull();
|
||||
expect(state.chaosPending?.queue.length).toBe(2);
|
||||
const first = state.chaosPending!.queue[0]!;
|
||||
state = first === shielder
|
||||
? must(state, first, { type: "counteract", instanceId: "full-shield#S" })
|
||||
: must(state, first, { type: "pass" });
|
||||
const second = state.chaosPending!.queue[0]!;
|
||||
state = second === shielder
|
||||
? must(state, second, { type: "counteract", instanceId: "full-shield#S" })
|
||||
: must(state, second, { type: "pass" });
|
||||
expect(state.chaosPending).toBeNull();
|
||||
// The shielded hand rode out the storm untouched.
|
||||
expect(shielderHand()).toEqual(kept);
|
||||
});
|
||||
|
||||
it("chaos: bystanders may shield out, reflections are refused", () => {
|
||||
let { state } = rev3Game();
|
||||
let { state } = threeGame();
|
||||
state = toRound2(state);
|
||||
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
|
||||
const caster = activePlayer(state);
|
||||
@@ -442,7 +459,7 @@ describe("ward arming and chaos shields (rules rev 3)", () => {
|
||||
});
|
||||
|
||||
it("chaos: the defender's full shield sits them out without stopping it", () => {
|
||||
let { state } = rev3Game();
|
||||
let { state } = threeGame();
|
||||
state = toRound2(state);
|
||||
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
|
||||
const caster = activePlayer(state);
|
||||
@@ -458,25 +475,12 @@ describe("ward arming and chaos shields (rules rev 3)", () => {
|
||||
});
|
||||
state = must(state, defender.id, { type: "counteract", instanceId: "full-shield#S" });
|
||||
state = must(state, caster.id, { type: "pass" }); // caster declines to anti-anti
|
||||
state = must(state, defender.id, { type: "pass" }); // defender rests on the shield
|
||||
// Bystander declines; the scramble happens without the defender.
|
||||
expect(state.chaosPending?.queue[0]).toBe(bystander.id);
|
||||
state = must(state, bystander.id, { type: "pass" });
|
||||
expect(state.chaosPending).toBeNull();
|
||||
expect(defenderHand()).toEqual(kept);
|
||||
});
|
||||
|
||||
it("legacy games (rev < 3) keep the automatic ward", () => {
|
||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"] });
|
||||
state = toRound2(state);
|
||||
const thief = activePlayer(state);
|
||||
const owner = state.players.find((p) => p.id !== thief.id)!;
|
||||
giveCard(state, owner.id, "ward", "W", 0);
|
||||
const treasure = state.treasures.find((t) => t.owner === owner.id)!;
|
||||
thief.position = { ...treasure.position! };
|
||||
state = must(state, thief.id, { type: "pickUpTreasure" });
|
||||
expect(state.players.find((p) => p.id === thief.id)!.life).toBe(12);
|
||||
});
|
||||
});
|
||||
|
||||
describe("zero-damage utility attacks", () => {
|
||||
@@ -517,8 +521,7 @@ describe("zero-damage utility attacks", () => {
|
||||
target: { kind: "player", playerId: defender },
|
||||
});
|
||||
state = must(state, defender, { type: "counteract", instanceId: "full-shield#FS" });
|
||||
state = must(state, attacker, { type: "pass" });
|
||||
const result = applyCommand(state, defender, { type: "pass" });
|
||||
const result = applyCommand(state, attacker, { type: "pass" });
|
||||
if (!result.ok) throw new Error(result.error);
|
||||
const resolved = result.events.find((e) => e.type === "attackResolved");
|
||||
expect(resolved && "fullyStopped" in resolved && resolved.fullyStopped).toBe(true);
|
||||
@@ -526,6 +529,83 @@ describe("zero-damage utility attacks", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("the VISIONSTONE pierces one wall", () => {
|
||||
function stoneRig() {
|
||||
let { state } = newGame();
|
||||
state = toRound2(state);
|
||||
const a = activePlayer(state);
|
||||
const b = state.players.find((p) => p.id !== a.id)!;
|
||||
a.position = { x: 2, y: 4 };
|
||||
b.position = { x: 2, y: 5 };
|
||||
state.edgeOverrides[edgeKey({ x: 2, y: 4 }, "S")] = "wall";
|
||||
return { state, a, b };
|
||||
}
|
||||
|
||||
it("its bearer casts an attack straight through the wall", () => {
|
||||
const { state, a, b } = stoneRig();
|
||||
const fb = giveCard(state, a.id, "fireball", "FB", 1);
|
||||
const refused = applyCommand(state, a.id, {
|
||||
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: b.id },
|
||||
});
|
||||
expect(refused.ok).toBe(false);
|
||||
const stone = giveCard(state, a.id, "visionstone", "VS");
|
||||
a.displayed.push(stone.instanceId);
|
||||
const cast = applyCommand(state, a.id, {
|
||||
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: b.id },
|
||||
});
|
||||
expect(cast.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("its bearer plants a THORNBUSH through the wall", () => {
|
||||
const { state, a } = stoneRig();
|
||||
// The square past the wall must be bare ground for a creation.
|
||||
const cell = { x: 2, y: 5 };
|
||||
for (const p of state.players) if (cellKey(p.position) === cellKey(cell)) p.position = { x: 0, y: 0 };
|
||||
const bush = giveCard(state, a.id, "thornbush", "TB");
|
||||
const refused = applyCommand(state, a.id, {
|
||||
type: "cast", instanceId: bush.instanceId, target: { kind: "cell", cell },
|
||||
});
|
||||
expect(refused.ok).toBe(false);
|
||||
if (!refused.ok) expect(refused.error).toContain("line of sight");
|
||||
const stone = giveCard(state, a.id, "visionstone", "VS", 1);
|
||||
a.displayed.push(stone.instanceId);
|
||||
const cast = applyCommand(state, a.id, {
|
||||
type: "cast", instanceId: bush.instanceId, target: { kind: "cell", cell },
|
||||
});
|
||||
expect(cast.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("the client's sighted squares agree with the stone", () => {
|
||||
const { state, a, b } = stoneRig();
|
||||
expect(sightedCellsFor(viewFor(state, a.id)).has(cellKey(b.position))).toBe(false);
|
||||
const stone = giveCard(state, a.id, "visionstone", "VS");
|
||||
a.displayed.push(stone.instanceId);
|
||||
expect(sightedCellsFor(viewFor(state, a.id)).has(cellKey(b.position))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("teleport wraps the maze's rim", () => {
|
||||
it("four spaces counted through a warp mouth reach the far side", () => {
|
||||
let { state } = newGame();
|
||||
state = toRound2(state);
|
||||
// Stand the active wizard at a wraparound mouth; the paired cell is
|
||||
// ONE teleport step away, exactly as it is one walking step.
|
||||
const active = activePlayer(state);
|
||||
const warp = boardView(state).warps[0]!;
|
||||
active.position = { ...warp.from.cell };
|
||||
const tp = giveCard(state, active.id, "teleport", "TP", 0);
|
||||
const r = applyCommand(state, active.id, {
|
||||
type: "cast", instanceId: tp.instanceId,
|
||||
target: { kind: "cell", cell: { ...warp.to.cell } },
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) {
|
||||
const after = r.state.players.find((p) => p.id === active.id)!;
|
||||
expect(cellKey(after.position)).toBe(cellKey(warp.to.cell));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("teleport as a counteraction", () => {
|
||||
it("the attack has no chance of hitting you — you are simply elsewhere", () => {
|
||||
let { state } = newGame();
|
||||
@@ -540,32 +620,11 @@ describe("teleport as a counteraction", () => {
|
||||
});
|
||||
state = must(state, defender, { type: "counteract", instanceId: tp.instanceId, params: { cell: escape } });
|
||||
state = must(state, attacker, { type: "pass" });
|
||||
state = must(state, defender, { type: "pass" });
|
||||
const after = state.players.find((p) => p.id === defender)!;
|
||||
expect(after.life).toBe(15);
|
||||
expect(cellKey(after.position)).toBe(cellKey(escape));
|
||||
});
|
||||
|
||||
it("ANTI-ANTI pins the boots in pre-rev-6 games (their stored chains replay)", () => {
|
||||
let { state } = newGame();
|
||||
state = toRound2(state);
|
||||
const { attacker, defender } = faceOff(state);
|
||||
const fb = giveCard(state, attacker, "fireball");
|
||||
const aa = giveCard(state, attacker, "anti-anti", "AA", 1);
|
||||
const tp = giveCard(state, defender, "teleport", "TP", 0);
|
||||
const d = state.players.find((p) => p.id === defender)!;
|
||||
const home = { ...d.position };
|
||||
state = must(state, attacker, {
|
||||
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender },
|
||||
});
|
||||
state = must(state, defender, { type: "counteract", instanceId: tp.instanceId, params: { cell: { x: home.x, y: home.y + 1 } } });
|
||||
state = must(state, attacker, { type: "counteract", instanceId: aa.instanceId });
|
||||
state = must(state, defender, { type: "pass" });
|
||||
const after = state.players.find((p) => p.id === defender)!;
|
||||
expect(cellKey(after.position)).toBe(cellKey(home));
|
||||
expect(after.life).toBe(10);
|
||||
});
|
||||
|
||||
it("reaches at most four spaces", () => {
|
||||
let { state } = newGame();
|
||||
state = toRound2(state);
|
||||
@@ -585,7 +644,7 @@ describe("teleport as a counteraction", () => {
|
||||
|
||||
describe("slime holds spells", () => {
|
||||
function slimeRig() {
|
||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 5 });
|
||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
|
||||
state = toRound2(state);
|
||||
const caster = activePlayer(state);
|
||||
const victim = state.players.find((p) => p.id !== caster.id)!;
|
||||
@@ -638,7 +697,6 @@ describe("slime holds spells", () => {
|
||||
state = must(state, rig.victim, { type: "move", direction: rig.slimeAt.side });
|
||||
state = must(state, rig.victim, { type: "counteract", instanceId: "full-reflection#FR" });
|
||||
state = must(state, rig.caster, { type: "pass" });
|
||||
state = must(state, rig.victim, { type: "pass" });
|
||||
expect(state.players.find((p) => p.id === rig.caster)!.life).toBe(15);
|
||||
expect(state.players.find((p) => p.id === rig.victim)!.life).toBe(15);
|
||||
});
|
||||
@@ -663,12 +721,12 @@ describe("slime holds spells", () => {
|
||||
});
|
||||
|
||||
describe("answering counteractions (FAQ rulings)", () => {
|
||||
function rev6() {
|
||||
return createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"], deckRev: 6 });
|
||||
function freshGame() {
|
||||
return createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"] });
|
||||
}
|
||||
|
||||
it("ABSORB SPELL steals a FULL REFLECTION out of the air", () => {
|
||||
let { state } = rev6();
|
||||
let { state } = freshGame();
|
||||
state = toRound2(state);
|
||||
const { attacker, defender } = faceOff(state);
|
||||
const fb = giveCard(state, attacker, "fireball");
|
||||
@@ -688,7 +746,7 @@ describe("answering counteractions (FAQ rulings)", () => {
|
||||
});
|
||||
|
||||
it("FULL SHIELD cannot be absorbed — it is not cast at you", () => {
|
||||
let { state } = rev6();
|
||||
let { state } = freshGame();
|
||||
state = toRound2(state);
|
||||
const { attacker, defender } = faceOff(state);
|
||||
const fb = giveCard(state, attacker, "fireball");
|
||||
@@ -703,8 +761,8 @@ describe("answering counteractions (FAQ rulings)", () => {
|
||||
if (!r.ok) expect(r.error).toMatch(/cannot be absorbed/);
|
||||
});
|
||||
|
||||
it("ANTI-ANTI does not work against a teleport escape (rev 6)", () => {
|
||||
let { state } = rev6();
|
||||
it("ANTI-ANTI does not work against a teleport escape", () => {
|
||||
let { state } = freshGame();
|
||||
state = toRound2(state);
|
||||
const { attacker, defender } = faceOff(state);
|
||||
const fb = giveCard(state, attacker, "fireball");
|
||||
@@ -719,16 +777,15 @@ describe("answering counteractions (FAQ rulings)", () => {
|
||||
const r = applyCommand(state, attacker, { type: "counteract", instanceId: aa.instanceId });
|
||||
expect(r.ok).toBe(false);
|
||||
state = must(state, attacker, { type: "pass" });
|
||||
state = must(state, defender, { type: "pass" });
|
||||
const after = state.players.find((p) => p.id === defender)!;
|
||||
expect(after.life).toBe(15);
|
||||
expect(cellKey(after.position)).toBe(cellKey(escape));
|
||||
});
|
||||
});
|
||||
|
||||
describe("speed and warp-token creation (rules rev 8)", () => {
|
||||
describe("speed and warp-token creation", () => {
|
||||
it("a SPEED bonus turn burns a turn of durations on the hastened wizard", () => {
|
||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"], deckRev: 8 });
|
||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"] });
|
||||
state = toRound2(state);
|
||||
const me = activePlayer(state);
|
||||
const other = state.players.find((p) => p.id !== me.id)!;
|
||||
@@ -747,7 +804,7 @@ describe("speed and warp-token creation (rules rev 8)", () => {
|
||||
});
|
||||
|
||||
it("nothing can be created on a dimensional warp token", () => {
|
||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 8 });
|
||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
|
||||
state = toRound2(state);
|
||||
const me = activePlayer(state);
|
||||
const spot = { x: me.position.x + 1, y: me.position.y };
|
||||
@@ -761,9 +818,9 @@ describe("speed and warp-token creation (rules rev 8)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("redirection (rules rev 9)", () => {
|
||||
describe("redirection", () => {
|
||||
it("the two chosen exits connect; their old partners pair with each other", () => {
|
||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"], deckRev: 9 });
|
||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"] });
|
||||
state = toRound2(state);
|
||||
const me = activePlayer(state);
|
||||
// Pick two exits that are NOT already partners.
|
||||
@@ -786,16 +843,16 @@ describe("redirection (rules rev 9)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("total stops end the exchange (rules rev 10)", () => {
|
||||
function rev10Game() {
|
||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"], deckRev: 10 });
|
||||
describe("total stops end the exchange", () => {
|
||||
function faceOffRig() {
|
||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"] });
|
||||
state = toRound2(state);
|
||||
const { attacker, defender } = faceOff(state);
|
||||
return { state, attacker, defender };
|
||||
}
|
||||
|
||||
it("a declined answer to Force Field resolves at once — no goading re-prompt", () => {
|
||||
let { state, attacker, defender } = rev10Game();
|
||||
let { state, attacker, defender } = faceOffRig();
|
||||
const fb = giveCard(state, attacker, "fireball");
|
||||
const d = state.players.find((p) => p.id === defender)!;
|
||||
const lifeBefore = d.life;
|
||||
@@ -809,7 +866,7 @@ describe("total stops end the exchange (rules rev 10)", () => {
|
||||
});
|
||||
|
||||
it("a partial counter still invites the defender to stack more", () => {
|
||||
let { state, attacker, defender } = rev10Game();
|
||||
let { state, attacker, defender } = faceOffRig();
|
||||
const fb = giveCard(state, attacker, "fireball");
|
||||
const d = state.players.find((p) => p.id === defender)!;
|
||||
d.hand[0] = { instanceId: "blunt#T", cardId: "blunt" };
|
||||
@@ -822,7 +879,7 @@ describe("total stops end the exchange (rules rev 10)", () => {
|
||||
});
|
||||
|
||||
it("a nullified shield is no stop — the exchange returns to the defender", () => {
|
||||
let { state, attacker, defender } = rev10Game();
|
||||
let { state, attacker, defender } = faceOffRig();
|
||||
const fb = giveCard(state, attacker, "fireball");
|
||||
const a = state.players.find((p) => p.id === attacker)!;
|
||||
const d = state.players.find((p) => p.id === defender)!;
|
||||
@@ -839,7 +896,7 @@ describe("total stops end the exchange (rules rev 10)", () => {
|
||||
|
||||
describe("escapes and elemental walls as counteractions", () => {
|
||||
function rigged(attackId: string, defenderCards: { instanceId: string; cardId: string }[]) {
|
||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"], deckRev: 12 });
|
||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"] });
|
||||
state = toRound2(state);
|
||||
const { attacker, defender } = faceOff(state);
|
||||
const atk = giveCard(state, attacker, attackId);
|
||||
@@ -885,7 +942,7 @@ describe("escapes and elemental walls as counteractions", () => {
|
||||
const lifeBefore = state.players.find((p) => p.id === defender)!.life;
|
||||
state = must(state, defender, { type: "counteract", instanceId: "waterwall#T" });
|
||||
state = must(state, attacker, { type: "pass" });
|
||||
// A total stop: the attacker's declined answer resolves at once (rev 10+).
|
||||
// A total stop: the attacker's declined answer resolves at once.
|
||||
expect(state.stack).toBeNull();
|
||||
expect(state.players.find((p) => p.id === defender)!.life).toBe(lifeBefore);
|
||||
});
|
||||
@@ -901,7 +958,7 @@ describe("escapes and elemental walls as counteractions", () => {
|
||||
|
||||
describe("empathy as a counteraction", () => {
|
||||
function empathyRig() {
|
||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 15 });
|
||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
|
||||
state = toRound2(state);
|
||||
const { attacker, defender } = faceOff(state);
|
||||
const fb = giveCard(state, attacker, "fireball");
|
||||
@@ -940,9 +997,9 @@ describe("empathy as a counteraction", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("empathy mirrors resolution-hook blows (rules rev 16)", () => {
|
||||
describe("empathy mirrors resolution-hook blows", () => {
|
||||
it("a believed illusion bites both wizards", () => {
|
||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 16 });
|
||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
|
||||
state = toRound2(state);
|
||||
const { attacker, defender } = faceOff(state);
|
||||
const ill = giveCard(state, attacker, "illusionary-attack");
|
||||
@@ -963,9 +1020,9 @@ describe("empathy mirrors resolution-hook blows (rules rev 16)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("the reflected-attack window (rules rev 25)", () => {
|
||||
describe("the reflected-attack window", () => {
|
||||
function reflectRig() {
|
||||
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"], deckRev: 25 });
|
||||
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"] });
|
||||
state = toRound2(state);
|
||||
const { attacker, defender } = faceOff(state);
|
||||
const sd = giveCard(state, attacker, "sudden-death");
|
||||
@@ -1007,26 +1064,11 @@ describe("the reflected-attack window (rules rev 25)", () => {
|
||||
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 });
|
||||
describe("a reflected lightning blast ends the caster's turn", () => {
|
||||
function boltRig() {
|
||||
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"] });
|
||||
state = toRound2(state);
|
||||
const { attacker, defender } = faceOff(state);
|
||||
const lb = giveCard(state, attacker, "lightning-blast");
|
||||
@@ -1044,7 +1086,7 @@ describe("a reflected lightning blast ends the caster's turn (rules rev 28)", ()
|
||||
}
|
||||
|
||||
it("FAQ: 'you lose the rest of your turn and can't draw cards'", () => {
|
||||
let { state, attacker } = boltRig(28);
|
||||
let { state, attacker } = boltRig();
|
||||
expect(state.players.find((p) => p.id === attacker)!.life).toBe(12);
|
||||
expect(state.turn.actionsEnded).toBe(true);
|
||||
// No more marching, and the end-of-turn draw comes up empty.
|
||||
@@ -1056,19 +1098,11 @@ describe("a reflected lightning blast ends the caster's turn (rules rev 28)", ()
|
||||
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)", () => {
|
||||
describe("fireball burns only the stones in play", () => {
|
||||
it("a displayed stone dies; a hidden one stays secret and safe", () => {
|
||||
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"], deckRev: 33 });
|
||||
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"] });
|
||||
state = toRound2(state);
|
||||
const { attacker, defender } = faceOff(state);
|
||||
const fb = giveCard(state, attacker, "fireball");
|
||||
@@ -1084,9 +1118,9 @@ describe("fireball burns only the stones in play (rules rev 33)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("stone dead counts only the stones in play (rules rev 34)", () => {
|
||||
describe("stone dead counts only the stones in play", () => {
|
||||
it("hidden stones neither add damage nor betray their count", () => {
|
||||
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"], deckRev: 34 });
|
||||
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"] });
|
||||
state = toRound2(state);
|
||||
const { attacker, defender } = faceOff(state);
|
||||
const sd = giveCard(state, attacker, "stone-dead");
|
||||
@@ -1101,8 +1135,64 @@ describe("stone dead counts only the stones in play (rules rev 34)", () => {
|
||||
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.
|
||||
// 3 x 1 displayed stone = 3; the bloodstone is hidden, so it soaks
|
||||
// nothing: 15 - 3 = 12.
|
||||
expect(state.players.find((p) => p.id === defender)!.life).toBe(12);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MENTAL FORCE respects the victim's three walked spaces", () => {
|
||||
it("a destination beyond the walls is refused up front, card kept", () => {
|
||||
let { state } = newGame();
|
||||
state = toRound2(state);
|
||||
const caster = activePlayer(state);
|
||||
const victim = state.players.find((p) => p.id !== caster.id)!;
|
||||
caster.position = { x: 0, y: 0 };
|
||||
victim.position = { x: 2, y: 4 };
|
||||
// Seal the victim into their square: every walked space is out of
|
||||
// reach, so ANY other destination is beyond three moved spaces.
|
||||
for (const side of SIDES) {
|
||||
state.edgeOverrides[edgeKey(victim.position, side)] = "wall";
|
||||
}
|
||||
const mf = giveCard(state, caster.id, "mental-force", "MF");
|
||||
const far = { x: 4, y: 9 };
|
||||
const refused = applyCommand(state, caster.id, {
|
||||
type: "cast", instanceId: mf.instanceId,
|
||||
target: { kind: "player", playerId: victim.id }, params: { cell: far },
|
||||
});
|
||||
expect(refused.ok).toBe(false);
|
||||
if (!refused.ok) expect(refused.error).toContain("three moved spaces");
|
||||
// The card is still in hand — nothing was spent on the refusal.
|
||||
expect(state.players.find((p) => p.id === caster.id)!.hand.some((c) => c.instanceId === mf.instanceId)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the bent sight-trace shows AROUND THE CORNER's legs", () => {
|
||||
it("a bentCorner stack yields two auditable legs through a midpoint", () => {
|
||||
let { state } = newGame();
|
||||
state = toRound2(state);
|
||||
const a = activePlayer(state);
|
||||
const d = state.players.find((p) => p.id !== a.id)!;
|
||||
// Diagonal neighbors: center-to-center sight grazes the shared corner
|
||||
// (blocked, strict reading), but the legs through (3,2) are opened.
|
||||
a.position = { x: 2, y: 2 };
|
||||
d.position = { x: 3, y: 3 };
|
||||
state.edgeOverrides[edgeKey({ x: 2, y: 2 }, "E")] = "open";
|
||||
state.edgeOverrides[edgeKey({ x: 3, y: 2 }, "S")] = "open";
|
||||
state.stack = {
|
||||
attackerId: a.id, defenderId: d.id,
|
||||
attackCard: { instanceId: "fireball#T", cardId: "fireball" },
|
||||
numberValue: null, amplifyFactor: 1, extendFactor: 1,
|
||||
powerAttackPoints: 0, params: null, kind: "spell",
|
||||
counters: [], waitingOn: d.id, bentCorner: true,
|
||||
};
|
||||
// Wall the straight diagonal shut so only the bend can answer.
|
||||
state.edgeOverrides[edgeKey({ x: 2, y: 2 }, "S")] = "wall";
|
||||
state.edgeOverrides[edgeKey({ x: 2, y: 3 }, "E")] = "wall";
|
||||
const traced = stackSightTrace(viewFor(state, d.id));
|
||||
expect(traced).not.toBeNull();
|
||||
// The engine promises A workable corner, not which one.
|
||||
expect(traced?.bend).toBeDefined();
|
||||
expect(traced?.bend?.mid).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { applyCommand, activePlayer, boardView, creatureAt, type GameState, type PlayerId, createGame } from "../src/game";
|
||||
import {
|
||||
type CreatureState, applyCommand, activePlayer, boardView, creatureAt, type GameState, type PlayerId, createGame } from "../src/game";
|
||||
import { cellKey, edgeKey, SIDES, stepTarget, type Cell, type Side } from "../src/board";
|
||||
import type { CardInstance } from "../src/cards";
|
||||
import { newExpansionGame as newGame, must, giveCard, toRound2, emptyNeighborCell } from "./helpers";
|
||||
import { newExpansionGame as newGame, must, giveCard, toRound2, emptyNeighborCell, pushSustained } from "./helpers";
|
||||
|
||||
/** Summon a creature next to its creator (round 2+, consumes the attack). */
|
||||
function summon(state: GameState, playerId: PlayerId, kind: string, tag = "S") {
|
||||
@@ -17,7 +18,8 @@ function summon(state: GameState, playerId: PlayerId, kind: string, tag = "S") {
|
||||
|
||||
describe("expansion deck", () => {
|
||||
it("basic + expansion1 builds the full 200-card game", () => {
|
||||
const { state } = newGame();
|
||||
// Three seats: two-player games shed LIFESAVER from the build.
|
||||
const { state } = newGame(42, ["a", "b", "c"]);
|
||||
const total = state.deck.length + state.discard.length +
|
||||
state.players.reduce((s, p) => s + p.hand.length, 0);
|
||||
expect(total).toBe(200);
|
||||
@@ -59,6 +61,8 @@ describe("monsters", () => {
|
||||
state = must(state, me, {
|
||||
type: "creatureAttack", creatureId: state.creatures[0]!.id, targetId: victim.id,
|
||||
});
|
||||
// The blow opens the victim's counteraction window; they take it raw.
|
||||
state = must(state, victim.id, { type: "pass" });
|
||||
expect(state.players.find((p) => p.id !== me)!.life).toBe(13);
|
||||
});
|
||||
|
||||
@@ -136,6 +140,7 @@ describe("monsters", () => {
|
||||
break;
|
||||
}
|
||||
}
|
||||
state = must(state, enemy2.id, { type: "pass" });
|
||||
const bitten = state.players.find((p) => p.id !== me)!;
|
||||
expect(bitten.life).toBe(13);
|
||||
expect(bitten.hand.length).toBe(6);
|
||||
@@ -293,9 +298,9 @@ describe("expansion support cards", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("counteracting a creature's blow (rules rev 4)", () => {
|
||||
function rev4() {
|
||||
return createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 4 });
|
||||
describe("counteracting a creature's blow", () => {
|
||||
function freshGame() {
|
||||
return createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
|
||||
}
|
||||
function wraithOnVictim(state: GameState) {
|
||||
state = toRound2(state);
|
||||
@@ -312,7 +317,7 @@ describe("counteracting a creature's blow (rules rev 4)", () => {
|
||||
}
|
||||
|
||||
it("BLUNT halves the wraith's touch; the card theft still lands", () => {
|
||||
let { state } = rev4();
|
||||
let { state } = freshGame();
|
||||
const rig = wraithOnVictim(state);
|
||||
state = rig.state;
|
||||
giveCard(state, rig.victim, "blunt", "B", 0);
|
||||
@@ -328,7 +333,7 @@ describe("counteracting a creature's blow (rules rev 4)", () => {
|
||||
});
|
||||
|
||||
it("FULL REFLECTION turns the touch back on the wraith, theft and all", () => {
|
||||
let { state } = rev4();
|
||||
let { state } = freshGame();
|
||||
const rig = wraithOnVictim(state);
|
||||
state = rig.state;
|
||||
giveCard(state, rig.victim, "full-reflection", "FR", 0);
|
||||
@@ -341,20 +346,11 @@ describe("counteracting a creature's blow (rules rev 4)", () => {
|
||||
const wraith = state.creatures.find((c) => c.id === "w1")!;
|
||||
expect(wraith.damage).toBe(2);
|
||||
});
|
||||
|
||||
it("legacy games (rev < 4) keep the instant touch", () => {
|
||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
|
||||
const rig = wraithOnVictim(state);
|
||||
state = rig.state;
|
||||
state = must(state, rig.controller, { type: "moveCreature", creatureId: "w1", direction: rig.side });
|
||||
expect(state.stack).toBeNull();
|
||||
expect(state.players.find((p) => p.id === rig.victim)!.life).toBe(13);
|
||||
});
|
||||
});
|
||||
|
||||
describe("big man (rules rev 5)", () => {
|
||||
describe("big man", () => {
|
||||
function bigRig() {
|
||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 5 });
|
||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
|
||||
state = toRound2(state);
|
||||
const giant = activePlayer(state);
|
||||
state.sustained.push({
|
||||
@@ -450,9 +446,9 @@ describe("big man (rules rev 5)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("monsters roll to hit the hidden (rules rev 13)", () => {
|
||||
function creatureVsInvisible(deckRev: number) {
|
||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev });
|
||||
describe("monsters roll to hit the hidden", () => {
|
||||
function creatureVsInvisible() {
|
||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
|
||||
state = toRound2(state);
|
||||
const me = activePlayer(state).id;
|
||||
const victim = state.players.find((p) => p.id !== me)!;
|
||||
@@ -469,30 +465,19 @@ describe("monsters roll to hit the hidden (rules rev 13)", () => {
|
||||
return { state, me, victim, skeletonId: bones.id };
|
||||
}
|
||||
|
||||
it("rev 13: the skeleton's blow takes the 1-in-4 roll — 'any attack' means any", () => {
|
||||
let { state, me, victim, skeletonId } = creatureVsInvisible(13);
|
||||
it("the skeleton's blow takes the 1-in-4 roll — 'any attack' means any", () => {
|
||||
let { state, me, victim, skeletonId } = creatureVsInvisible();
|
||||
const rngBefore = JSON.stringify(state.rng);
|
||||
state = must(state, me, { type: "creatureAttack", creatureId: skeletonId, targetId: victim.id });
|
||||
state = must(state, victim.id, { type: "pass" });
|
||||
// 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, 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: skeletonId, targetId: victim.id });
|
||||
state = must(state, victim.id, { type: "pass" });
|
||||
expect(state.players.find((p) => p.id === victim.id)!.life).toBeLessThan(lifeBefore);
|
||||
// No die was consumed on the way.
|
||||
expect(JSON.stringify(state.rng)).toBe(rngBefore);
|
||||
});
|
||||
});
|
||||
|
||||
describe("elimination sweeps the board either way (rules rev 14)", () => {
|
||||
describe("elimination sweeps the board either way", () => {
|
||||
it("a treasure-eliminated wizard's fire imp vanishes with them", () => {
|
||||
let { state } = createGame({ playerIds: ["a", "b", "c"], seed: 42, sets: ["basic", "expansion1"], deckRev: 14 });
|
||||
let { state } = createGame({ playerIds: ["a", "b", "c"], seed: 42, sets: ["basic", "expansion1"] });
|
||||
const victim = state.players.find((p) => p.id === "c")!;
|
||||
state.creatures.push({
|
||||
id: "imp1", kind: "fire-imp", controllerId: "c",
|
||||
@@ -524,12 +509,12 @@ describe("elimination sweeps the board either way (rules rev 14)", () => {
|
||||
|
||||
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 });
|
||||
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"] });
|
||||
state = toRound2(state);
|
||||
const view = boardView(state);
|
||||
for (const [key, edge] of Object.entries(view.edges)) {
|
||||
if (edge !== "door") continue;
|
||||
const [kind, coords] = key.split(",").length ? key.split(":") as [string, string] : ["", ""];
|
||||
const [kind, coords] = key.split(":") as [string, string];
|
||||
const [x, y] = coords.split(",").map(Number) as [number, number];
|
||||
const side = kind === "V" ? ("E" as Side) : ("S" as Side);
|
||||
state.doorStates[key] = "removed";
|
||||
@@ -551,9 +536,9 @@ describe("creatures walk open doorways", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("the wave carries monsters (rules rev 17)", () => {
|
||||
describe("the wave carries monsters", () => {
|
||||
it("a cornered skeleton takes the waterwall crush", () => {
|
||||
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"], deckRev: 17 });
|
||||
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"] });
|
||||
state = toRound2(state);
|
||||
const caster = activePlayer(state);
|
||||
// A skeleton pinned against solid stone, one square from the wave's edge.
|
||||
@@ -590,9 +575,9 @@ describe("the wave carries monsters (rules rev 17)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("the democratic monster's claw survives the first wizard's death (rules rev 18)", () => {
|
||||
describe("the democratic monster's claw survives the first wizard's death", () => {
|
||||
it("refreshes each round even with the roll-off winner dead", () => {
|
||||
let { state } = createGame({ playerIds: ["a", "b", "c"], seed: 42, sets: ["basic", "expansion1"], deckRev: 18 });
|
||||
let { state } = createGame({ playerIds: ["a", "b", "c"], seed: 42, sets: ["basic", "expansion1"] });
|
||||
state = toRound2(state);
|
||||
const firstId = state.players[state.turn.firstIndex]!.id;
|
||||
const dm = {
|
||||
@@ -617,9 +602,9 @@ describe("the democratic monster's claw survives the first wizard's death (rules
|
||||
});
|
||||
});
|
||||
|
||||
describe("a mid-round democratic monster claws on the next turn (rules rev 18)", () => {
|
||||
describe("a mid-round democratic monster claws on the next turn", () => {
|
||||
it("creation spends the turn, not the round", () => {
|
||||
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"], deckRev: 18 });
|
||||
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"] });
|
||||
state = toRound2(state);
|
||||
const creator = activePlayer(state);
|
||||
const r0 = summon(state, creator.id, "democratic-monster");
|
||||
@@ -641,9 +626,9 @@ describe("a mid-round democratic monster claws on the next turn (rules rev 18)",
|
||||
});
|
||||
});
|
||||
|
||||
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 });
|
||||
describe("collapsing walls crush monsters too", () => {
|
||||
function wallRig() {
|
||||
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"] });
|
||||
state = toRound2(state);
|
||||
const view = boardView(state);
|
||||
for (const [key, edge] of Object.entries(view.edges)) {
|
||||
@@ -671,22 +656,17 @@ describe("collapsing walls crush monsters too (rules rev 21)", () => {
|
||||
throw new Error("setup: no interior wall found");
|
||||
}
|
||||
|
||||
it("rev 21: the adjacent troll takes the four points", () => {
|
||||
const state = wallRig(21);
|
||||
it("the adjacent troll takes the four points", () => {
|
||||
const state = wallRig();
|
||||
const troll = state.creatures.find((c) => c.id === "tr1");
|
||||
// Four points on a six-point troll: hurt but standing (or regenerating).
|
||||
expect(troll?.damage).toBe(4);
|
||||
});
|
||||
|
||||
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 });
|
||||
describe("the self-stack resolves on a pass", () => {
|
||||
function selfTouch() {
|
||||
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"] });
|
||||
state = toRound2(state);
|
||||
const creator = activePlayer(state);
|
||||
state.creatures.push({
|
||||
@@ -701,8 +681,8 @@ describe("the self-stack resolves on a pass (rules rev 22)", () => {
|
||||
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);
|
||||
it("passing your own monster's touch takes the claw and moves on", () => {
|
||||
const { state, creator } = selfTouch();
|
||||
if (!state.stack) return; // a wall between: the touch never opened
|
||||
expect(state.stack.attackerId).toBe(creator);
|
||||
expect(state.stack.defenderId).toBe(creator);
|
||||
@@ -711,26 +691,18 @@ describe("the self-stack resolves on a pass (rules rev 22)", () => {
|
||||
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)", () => {
|
||||
describe("fear holds off monsters and unwilling feet alike", () => {
|
||||
it("a commanded monster cannot close within three of the fearsome", () => {
|
||||
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"], deckRev: 32 });
|
||||
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"] });
|
||||
const a = state.players.find((p) => p.id === "a")!;
|
||||
const b = state.players.find((p) => p.id === "b")!;
|
||||
// b radiates fear; a's troll stands exactly four away, aimed straight at b.
|
||||
state.sustained.push({
|
||||
pushSustained(state, {
|
||||
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({
|
||||
@@ -738,7 +710,7 @@ describe("fear holds off monsters and unwilling feet alike (rules rev 32)", () =
|
||||
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 });
|
||||
@@ -749,4 +721,69 @@ describe("fear holds off monsters and unwilling feet alike (rules rev 32)", () =
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) expect(r.error).toContain("dread");
|
||||
});
|
||||
|
||||
it("a wizard caged inside the dread may round a corner to escape", () => {
|
||||
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"] });
|
||||
const a = state.players.find((p) => p.id === "a")!;
|
||||
const b = state.players.find((p) => p.id === "b")!;
|
||||
pushSustained(state, {
|
||||
id: "fx-fear", cardId: "fear", casterId: "b", targetId: "b",
|
||||
remainingTurns: 5, data: {},
|
||||
});
|
||||
// b radiates dread from (2,5). a stands in a dead-end pocket at
|
||||
// (2,3): walls on three sides, so the only way out steps SOUTH —
|
||||
// closer to b — before the corridor east leads clear of the dread.
|
||||
b.position = { x: 2, y: 5 };
|
||||
a.position = { x: 2, y: 3 };
|
||||
for (const side of ["N", "E", "W"] as const) {
|
||||
state.edgeOverrides[edgeKey({ x: 2, y: 3 }, side)] = "wall";
|
||||
}
|
||||
state.edgeOverrides[edgeKey({ x: 2, y: 3 }, "S")] = "open";
|
||||
state.edgeOverrides[edgeKey({ x: 2, y: 4 }, "E")] = "open";
|
||||
state.edgeOverrides[edgeKey({ x: 3, y: 4 }, "E")] = "open";
|
||||
state.edgeOverrides[edgeKey({ x: 4, y: 4 }, "N")] = "open";
|
||||
while (state.players[state.turn.activeIndex]!.id !== "a") {
|
||||
const r0 = applyCommand(state, state.players[state.turn.activeIndex]!.id, { type: "endTurn", draw: 0 });
|
||||
if (!r0.ok) throw new Error(r0.error);
|
||||
state = r0.state;
|
||||
}
|
||||
// The closer step is the only way out: permitted.
|
||||
const r = applyCommand(state, "a", { type: "move", direction: "S" });
|
||||
if (!r.ok) throw new Error("escape step refused: " + r.error);
|
||||
});
|
||||
});
|
||||
|
||||
describe("creatures and the dimensional warp", () => {
|
||||
it("a commanded troll steps through the warp tokens", () => {
|
||||
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"] });
|
||||
const you = state.players[state.turn.activeIndex]!.id;
|
||||
state.dimWarps.push({ a: { x: 1, y: 1 }, b: { x: 3, y: 8 } });
|
||||
state.creatures.push({
|
||||
id: "troll-w", kind: "troll", controllerId: you, position: { x: 1, y: 1 },
|
||||
damage: 0, maxDamage: 6, movesPerTurn: 2, movementUsed: 0, attackUsed: false,
|
||||
justCreated: false, wallPassesPerTurn: 0, wallPassUsed: 0, scorchedThisTurn: [],
|
||||
});
|
||||
const r = applyCommand(state, you, { type: "creatureWarpStep", creatureId: "troll-w" });
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) {
|
||||
const troll = r.state.creatures.find((c) => c.id === "troll-w")!;
|
||||
expect(cellKey(troll.position)).toBe(cellKey({ x: 3, y: 8 }));
|
||||
expect(troll.movementUsed).toBe(1);
|
||||
}
|
||||
});
|
||||
|
||||
it("solid stone on the far side refuses the beast", () => {
|
||||
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"] });
|
||||
const you = state.players[state.turn.activeIndex]!.id;
|
||||
state.dimWarps.push({ a: { x: 1, y: 1 }, b: { x: 3, y: 8 } });
|
||||
state.squareContents[cellKey({ x: 3, y: 8 })] = { kind: "stone", damage: 0, createdBy: "b" };
|
||||
state.creatures.push({
|
||||
id: "troll-w", kind: "troll", controllerId: you, position: { x: 1, y: 1 },
|
||||
damage: 0, maxDamage: 6, movesPerTurn: 2, movementUsed: 0, attackUsed: false,
|
||||
justCreated: false, wallPassesPerTurn: 0, wallPassUsed: 0, scorchedThisTurn: [],
|
||||
});
|
||||
const r = applyCommand(state, you, { type: "creatureWarpStep", creatureId: "troll-w" });
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) expect(r.error).toContain("stone");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { applyCommand, activePlayer, boardView, createGame, sustainedOn, type GameState } from "../src/game";
|
||||
import { cellKey, edgeKey, neighbor, type Side } from "../src/board";
|
||||
import { cellKey, edgeKey, neighbor, opposite, type Side } from "../src/board";
|
||||
import type { CardInstance } from "../src/cards";
|
||||
import { newGame, must, giveCard, toRound2, faceOff, castAt } from "./helpers";
|
||||
|
||||
@@ -111,7 +111,7 @@ describe("doors", () => {
|
||||
throw new Error("no door on this board");
|
||||
}
|
||||
|
||||
it("pick lock opens an adjacent door until end of turn", () => {
|
||||
it("pick lock opens an adjacent door — and it relocks behind the walker", () => {
|
||||
let { state } = newGame();
|
||||
const me = activePlayer(state);
|
||||
const door = findDoor(state);
|
||||
@@ -130,9 +130,8 @@ describe("doors", () => {
|
||||
state = must(state, me.id, { type: "move", direction: dir });
|
||||
expect(cellKey(activePlayer(state).position)).toBe(cellKey(other));
|
||||
|
||||
// Relocks when the turn ends.
|
||||
state = must(state, me.id, { type: "endTurn", draw: 0 });
|
||||
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
|
||||
// "It will relock behind you": shut at the walker's back at once —
|
||||
// the way back is barred without another pick.
|
||||
expect(state.openDoorEdges.length).toBe(0);
|
||||
expect(applyCommand(state, me.id, { type: "move", direction: dir === "E" ? "W" : "N" }).ok).toBe(false);
|
||||
});
|
||||
@@ -340,7 +339,6 @@ describe("cast modifiers", () => {
|
||||
});
|
||||
state = must(state, defender, { type: "counteract", instanceId: "reverse#R" });
|
||||
state = must(state, attacker, { type: "pass" });
|
||||
state = must(state, defender, { type: "pass" });
|
||||
const d = state.players.find((p) => p.id === defender)!;
|
||||
expect(d.life).toBe(19); // gained 4 instead of losing it
|
||||
expect(d.lostTurns).toBe(1); // the stun still applies
|
||||
@@ -349,7 +347,7 @@ describe("cast modifiers", () => {
|
||||
|
||||
describe("holding the door open (Pick Lock / Master Key)", () => {
|
||||
function doorRig() {
|
||||
let { state } = createGame({ playerIds: ["holder", "guest"], seed: 42, sets: ["basic"], deckRev: 14 });
|
||||
let { state } = createGame({ playerIds: ["holder", "guest"], seed: 42, sets: ["basic"] });
|
||||
state = toRound2(state);
|
||||
// Find a door edge; stand the acting player beside it.
|
||||
const view = boardView(state);
|
||||
@@ -410,9 +408,9 @@ describe("holding the door open (Pick Lock / Master Key)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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 });
|
||||
describe("a held door is an open doorway to the eye", () => {
|
||||
function sightRig() {
|
||||
let { state } = createGame({ playerIds: ["holder", "pursuer"], seed: 42, sets: ["basic"] });
|
||||
state = toRound2(state);
|
||||
const view = boardView(state);
|
||||
for (const [key, edge] of Object.entries(view.edges)) {
|
||||
@@ -430,8 +428,8 @@ describe("a held door is an open doorway to the eye (rules rev 15)", () => {
|
||||
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);
|
||||
it("the holder blasts the pursuer through the held doorway", () => {
|
||||
let { state, cell, side, holder, pursuer } = sightRig();
|
||||
const pick = giveCard(state, holder, "pick-lock");
|
||||
state = must(state, holder, {
|
||||
type: "cast", instanceId: pick.instanceId,
|
||||
@@ -446,31 +444,126 @@ describe("a held door is an open doorway to the eye (rules rev 15)", () => {
|
||||
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);
|
||||
it("an unlocked door is an open doorway to the wizard at its threshold", () => {
|
||||
let { state, cell, side, holder, pursuer } = sightRig();
|
||||
const pick = giveCard(state, holder, "pick-lock");
|
||||
state = must(state, holder, {
|
||||
type: "cast", instanceId: pick.instanceId,
|
||||
target: { kind: "edge", cell, side },
|
||||
});
|
||||
const fb = giveCard(state, holder, "fireball", "FB", 1);
|
||||
const refused = applyCommand(state, holder, {
|
||||
const cast = applyCommand(state, holder, {
|
||||
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: pursuer },
|
||||
});
|
||||
expect(refused.ok).toBe(false);
|
||||
expect(cast.ok).toBe(true);
|
||||
});
|
||||
|
||||
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");
|
||||
it("a lock REMOVED lets any wizard beside the door peek through", () => {
|
||||
let { state, cell, side, holder, pursuer } = sightRig();
|
||||
const rm = giveCard(state, holder, "remove-lock");
|
||||
state = must(state, holder, {
|
||||
type: "cast", instanceId: pick.instanceId,
|
||||
target: { kind: "edge", cell, side }, params: { hold: true },
|
||||
type: "cast", instanceId: rm.instanceId,
|
||||
target: { kind: "edge", cell, side },
|
||||
});
|
||||
const fb = giveCard(state, holder, "fireball", "FB", 1);
|
||||
const refused = applyCommand(state, holder, {
|
||||
const cast = applyCommand(state, holder, {
|
||||
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: pursuer },
|
||||
});
|
||||
expect(cast.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("PICK LOCK in hand is enough to ease a locked door open a crack", () => {
|
||||
let { state, cell, side, holder, pursuer } = sightRig();
|
||||
giveCard(state, holder, "pick-lock");
|
||||
const fb = giveCard(state, holder, "fireball", "FB", 1);
|
||||
const cast = applyCommand(state, holder, {
|
||||
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: pursuer },
|
||||
});
|
||||
expect(cast.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("the wizard down the hallway sees no further than the shut door", () => {
|
||||
let { state, cell, side, holder, pursuer } = sightRig();
|
||||
// Pull the pursuer one square further down the hall, so the door is
|
||||
// no longer on their threshold; clear their path to the doorway.
|
||||
const near = neighbor(cell, side);
|
||||
const far = neighbor(near, side);
|
||||
if (!boardView(state).cells[cellKey(far)]) {
|
||||
throw new Error("setup: seed 42 lost the hallway this rig stands in");
|
||||
}
|
||||
state.players.find((p) => p.id === pursuer)!.position = far;
|
||||
state.edgeOverrides[edgeKey(near, side)] = "open";
|
||||
const rm = giveCard(state, holder, "remove-lock");
|
||||
state = must(state, holder, {
|
||||
type: "cast", instanceId: rm.instanceId,
|
||||
target: { kind: "edge", cell, side },
|
||||
});
|
||||
state = must(state, holder, { type: "endTurn", draw: 0 });
|
||||
const fb = giveCard(state, pursuer, "fireball", "FB", 1);
|
||||
const refused = applyCommand(state, pursuer, {
|
||||
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: holder },
|
||||
});
|
||||
expect(refused.ok).toBe(false);
|
||||
if (!refused.ok) expect(refused.error).toContain("line of sight");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the displayed MASTER KEY", () => {
|
||||
function keyRig() {
|
||||
let { state } = createGame({ playerIds: ["keeper", "watcher"], seed: 42, sets: ["basic"] });
|
||||
state = toRound2(state);
|
||||
const view = boardView(state);
|
||||
for (const [key, edge] of Object.entries(view.edges)) {
|
||||
if (edge !== "door") continue;
|
||||
const [kind, coords] = key.split(":") as [string, string];
|
||||
const [x, y] = coords.split(",").map(Number) as [number, number];
|
||||
const cell = { x, y };
|
||||
const side = kind === "V" ? ("E" as Side) : ("S" as Side);
|
||||
const keeper = activePlayer(state);
|
||||
keeper.position = { ...cell };
|
||||
return { state, cell, side, key, keeper };
|
||||
}
|
||||
throw new Error("setup: seed 42 grew a maze with no doors");
|
||||
}
|
||||
|
||||
it("cast bare, the key goes on display — once", () => {
|
||||
const { state, keeper } = keyRig();
|
||||
const mk = giveCard(state, keeper.id, "master-key", "MK");
|
||||
const cast = applyCommand(state, keeper.id, { type: "cast", instanceId: mk.instanceId });
|
||||
expect(cast.ok).toBe(true);
|
||||
if (cast.ok) {
|
||||
const after = cast.state.players.find((p) => p.id === keeper.id)!;
|
||||
expect(after.displayed).toContain(mk.instanceId);
|
||||
expect(after.hand.some((c) => c.instanceId === mk.instanceId)).toBe(true);
|
||||
const again = applyCommand(cast.state, keeper.id, { type: "cast", instanceId: mk.instanceId });
|
||||
expect(again.ok).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("its bearer walks through locked doors, which relock at their back", () => {
|
||||
const { state, cell, side, key, keeper } = keyRig();
|
||||
const mk = giveCard(state, keeper.id, "master-key", "MK");
|
||||
keeper.displayed.push(mk.instanceId);
|
||||
const r = applyCommand(state, keeper.id, { type: "move", direction: side });
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) {
|
||||
const after = r.state.players.find((p) => p.id === keeper.id)!;
|
||||
expect(cellKey(after.position)).toBe(cellKey(neighbor(cell, side)));
|
||||
// "Door relocks behind you": no lingering opening for bystanders.
|
||||
expect(r.state.openDoorEdges).not.toContain(key);
|
||||
expect(r.events.some((e) => e.type === "doorUnlocked")).toBe(true);
|
||||
// The key turns again on the way back.
|
||||
const back = applyCommand(r.state, keeper.id, { type: "move", direction: opposite(side) });
|
||||
expect(back.ok).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("a JAMmed LOCK refuses even the master key", () => {
|
||||
const { state, side, key, keeper } = keyRig();
|
||||
const mk = giveCard(state, keeper.id, "master-key", "MK");
|
||||
keeper.displayed.push(mk.instanceId);
|
||||
state.doorStates[key] = "jammed";
|
||||
const r = applyCommand(state, keeper.id, { type: "move", direction: side });
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -106,7 +106,7 @@ describe("expansion combat cards", () => {
|
||||
expect(state.players.find((p) => p.id === attacker)!.life).toBe(10);
|
||||
});
|
||||
|
||||
it("ward springs when a trapped treasure is grabbed", () => {
|
||||
it("ward springs in the moment: the grab hangs on the owner's choice", () => {
|
||||
let { state } = newGame();
|
||||
const me = activePlayer(state);
|
||||
const enemy = state.players.find((p) => p.id !== me.id)!;
|
||||
@@ -114,6 +114,8 @@ describe("expansion combat cards", () => {
|
||||
const treasure = state.treasures.find((t) => t.owner === enemy.id && t.position)!;
|
||||
me.position = { ...treasure.position! };
|
||||
state = must(state, me.id, { type: "pickUpTreasure" });
|
||||
expect(state.wardPending).toEqual({ ownerId: enemy.id, takerId: me.id });
|
||||
state = must(state, enemy.id, { type: "wardChoice", play: true });
|
||||
expect(state.players.find((p) => p.id === me.id)!.life).toBe(12);
|
||||
expect(state.players.find((p) => p.id === enemy.id)!.hand.some((c) => c.cardId === "ward")).toBe(false);
|
||||
});
|
||||
@@ -162,9 +164,9 @@ describe("expansion combat cards", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("idiot at rev 23 forbids item handling and punches but allows counteractions", () => {
|
||||
it("idiot forbids item handling and punches but allows counteractions", () => {
|
||||
let { state } = createGame({
|
||||
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 23,
|
||||
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"],
|
||||
});
|
||||
state = toRound2(state);
|
||||
const { attacker, defender } = faceOff(state);
|
||||
@@ -198,9 +200,65 @@ describe("expansion combat cards", () => {
|
||||
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", () => {
|
||||
it("idiot never steers the march — the step goes where asked", () => {
|
||||
let { state } = createGame({
|
||||
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 23,
|
||||
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"],
|
||||
});
|
||||
state = toRound2(state);
|
||||
const { attacker, defender } = faceOff(state);
|
||||
const id = giveCard(state, attacker, "idiot");
|
||||
state = castAt(state, attacker, defender, id);
|
||||
expect(sustainedOn(state, defender, "idiot").length).toBe(1);
|
||||
state = must(state, attacker, { type: "endTurn", draw: 0 });
|
||||
const d = state.players.find((p) => p.id === defender)!;
|
||||
const board = boardView(state);
|
||||
const open = (["N", "S", "E", "W"] as const)
|
||||
.filter((s) => stepTarget(board, d.position, s).kind === "step");
|
||||
expect(open.length).toBeGreaterThanOrEqual(2);
|
||||
// Plant the victim's own gold one step out one way; walk the other way.
|
||||
|
||||
const toward = stepTarget(board, d.position, open[0]!);
|
||||
const away = stepTarget(board, d.position, open[1]!);
|
||||
if (toward.kind === "blocked" || away.kind === "blocked") throw new Error("unreachable");
|
||||
const own = state.treasures.find((t) => t.owner === defender)!;
|
||||
own.carriedBy = null;
|
||||
own.position = toward.to;
|
||||
state = must(state, defender, { type: "move", direction: open[1]! });
|
||||
expect(cellKey(state.players.find((p) => p.id === defender)!.position)).toBe(cellKey(away.to));
|
||||
});
|
||||
|
||||
it("idiot permits DROP OBJECT that frees the victim's own treasure", () => {
|
||||
let { state } = createGame({
|
||||
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"],
|
||||
});
|
||||
state = toRound2(state);
|
||||
const { attacker, defender } = faceOff(state);
|
||||
const thief = state.players.find((p) => p.id === attacker)!;
|
||||
const own = state.treasures.find((t) => t.owner === defender)!;
|
||||
own.carriedBy = attacker;
|
||||
own.position = null;
|
||||
thief.carriedTreasureId = own.id;
|
||||
const id = giveCard(state, attacker, "idiot");
|
||||
state = castAt(state, attacker, defender, id);
|
||||
state = must(state, attacker, { type: "endTurn", draw: 0 });
|
||||
// An attack for its own sake stays forbidden.
|
||||
const fb = giveCard(state, defender, "fireball", "F", 0);
|
||||
expect(applyCommand(state, defender, {
|
||||
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: attacker },
|
||||
}).ok).toBe(false);
|
||||
// Shaking YOUR OWN gold out of the thief's arms serves the march —
|
||||
// while carried it cannot be stood upon.
|
||||
const dr = giveCard(state, defender, "drop-object", "D", 1);
|
||||
const r = applyCommand(state, defender, {
|
||||
type: "cast", instanceId: dr.instanceId,
|
||||
target: { kind: "player", playerId: attacker }, params: { cardId: "treasure" },
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("idiot has no effect on a victim carrying their own treasure", () => {
|
||||
let { state } = createGame({
|
||||
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"],
|
||||
});
|
||||
state = toRound2(state);
|
||||
const { attacker, defender } = faceOff(state);
|
||||
@@ -336,9 +394,107 @@ describe("ambushes (async interrupts)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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 });
|
||||
describe("strength tears treasures from wizards' arms", () => {
|
||||
function grip(seed: number) {
|
||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed, sets: ["basic", "expansion1"] });
|
||||
state = toRound2(state);
|
||||
const { attacker, defender } = faceOff(state);
|
||||
const d = state.players.find((p) => p.id === defender)!;
|
||||
// The defender carries the ATTACKER's own treasure (no ward window).
|
||||
const stolen = state.treasures.find((t) => t.owner === attacker)!;
|
||||
stolen.carriedBy = defender;
|
||||
stolen.position = null;
|
||||
d.carriedTreasureId = stolen.id;
|
||||
const st = giveCard(state, attacker, "strength");
|
||||
giveCard(state, attacker, "number-2", "N", 1);
|
||||
state = must(state, attacker, {
|
||||
type: "cast", instanceId: st.instanceId, numberInstanceIds: ["number-2#N"],
|
||||
});
|
||||
return { state, attacker, defender, treasure: stolen.id };
|
||||
}
|
||||
|
||||
it("the grip is an attack: the victim keeps the treasure only on a 1", () => {
|
||||
let torn = 0, kept = 0;
|
||||
for (let seed = 1; seed <= 12; seed++) {
|
||||
const { state, attacker, defender, treasure } = grip(seed);
|
||||
const r = applyCommand(state, attacker, { type: "tearTreasure", targetId: defender });
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
expect(r.state.turn.attackUsed).toBe(true);
|
||||
// FAQ: tearing is not picking up — the turn's actions continue.
|
||||
expect(r.state.turn.actionsEnded).toBe(false);
|
||||
const a = r.state.players.find((p) => p.id === attacker)!;
|
||||
const d = r.state.players.find((p) => p.id === defender)!;
|
||||
if (a.carriedTreasureId === treasure) {
|
||||
torn++;
|
||||
expect(d.carriedTreasureId).toBeNull();
|
||||
} else {
|
||||
kept++;
|
||||
expect(d.carriedTreasureId).toBe(treasure);
|
||||
}
|
||||
// One attack per turn: a second wrench is refused.
|
||||
expect(applyCommand(r.state, attacker, { type: "tearTreasure", targetId: defender }).ok).toBe(false);
|
||||
}
|
||||
expect(torn).toBeGreaterThan(0);
|
||||
expect(kept).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("without STRENGTH the grip is refused", () => {
|
||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
|
||||
state = toRound2(state);
|
||||
const { attacker, defender } = faceOff(state);
|
||||
const d = state.players.find((p) => p.id === defender)!;
|
||||
const t = state.treasures.find((t) => t.owner === attacker)!;
|
||||
t.carriedBy = defender;
|
||||
t.position = null;
|
||||
d.carriedTreasureId = t.id;
|
||||
const r = applyCommand(state, attacker, { type: "tearTreasure", targetId: defender });
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("laden arms tear it loose onto the floor — and the ward's owner gets their window", () => {
|
||||
for (let seed = 1; seed <= 12; seed++) {
|
||||
let { state } = createGame({ playerIds: ["alice", "bob", "cara"], seed, sets: ["basic", "expansion1"] });
|
||||
state = toRound2(state);
|
||||
while (activePlayer(state).id !== "alice") {
|
||||
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
|
||||
}
|
||||
const alice = state.players.find((p) => p.id === "alice")!;
|
||||
const bob = state.players.find((p) => p.id === "bob")!;
|
||||
bob.position = { ...alice.position };
|
||||
// Alice already hauls her own gold; Bob carries CARA's — and Cara
|
||||
// holds a WARD over it.
|
||||
const mine = state.treasures.find((t) => t.owner === "alice")!;
|
||||
mine.carriedBy = "alice";
|
||||
mine.position = null;
|
||||
alice.carriedTreasureId = mine.id;
|
||||
const caras = state.treasures.find((t) => t.owner === "cara")!;
|
||||
caras.carriedBy = "bob";
|
||||
caras.position = null;
|
||||
bob.carriedTreasureId = caras.id;
|
||||
giveCard(state, "cara", "ward", "W", 0);
|
||||
const st = giveCard(state, "alice", "strength");
|
||||
giveCard(state, "alice", "number-2", "N", 1);
|
||||
state = must(state, "alice", {
|
||||
type: "cast", instanceId: st.instanceId, numberInstanceIds: ["number-2#N"],
|
||||
});
|
||||
const r = applyCommand(state, "alice", { type: "tearTreasure", targetId: "bob" });
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
const after = r.state.treasures.find((t) => t.id === caras.id)!;
|
||||
if (after.carriedBy === "bob") continue; // Bob rolled his 1 — try again
|
||||
// Torn loose: Alice's arms are full, so it lands at her feet…
|
||||
expect(after.position).toEqual(alice.position);
|
||||
expect(r.state.players.find((p) => p.id === "alice")!.carriedTreasureId).toBe(mine.id);
|
||||
// …and the seizure hangs on Cara's ward.
|
||||
expect(r.state.wardPending).toEqual({ ownerId: "cara", takerId: "alice" });
|
||||
return;
|
||||
}
|
||||
throw new Error("every seed rolled a 1 — the rig is wrong");
|
||||
});
|
||||
});
|
||||
|
||||
describe("swap meet trades carried items", () => {
|
||||
function rig() {
|
||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
|
||||
state = toRound2(state);
|
||||
const { attacker, defender } = faceOff(state);
|
||||
const sm = giveCard(state, attacker, "swap-meet");
|
||||
@@ -354,21 +510,15 @@ describe("swap meet trades carried items (rules rev 26)", () => {
|
||||
}
|
||||
|
||||
it("a dagger trades for a wand — both are carried items", () => {
|
||||
const { state, attacker, defender } = rig(26);
|
||||
const { state, attacker, defender } = rig();
|
||||
expect(state.players.find((p) => p.id === attacker)!.hand.some((c) => c.cardId === "blaster-wand")).toBe(true);
|
||||
expect(state.players.find((p) => p.id === defender)!.hand.some((c) => c.cardId === "dagger")).toBe(true);
|
||||
});
|
||||
|
||||
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 });
|
||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
|
||||
state = toRound2(state);
|
||||
const { attacker, defender } = faceOff(state);
|
||||
const a = state.players.find((p) => p.id === attacker)!;
|
||||
@@ -401,7 +551,7 @@ describe("swap meet trades treasures too", () => {
|
||||
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 });
|
||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
|
||||
state = toRound2(state);
|
||||
const { attacker, defender } = faceOff(state);
|
||||
const a = state.players.find((p) => p.id === attacker)!;
|
||||
@@ -423,7 +573,7 @@ describe("swap meet trades treasures too", () => {
|
||||
});
|
||||
|
||||
it("treasure for treasure trades both armfuls", () => {
|
||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 26 });
|
||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
|
||||
state = toRound2(state);
|
||||
const { attacker, defender } = faceOff(state);
|
||||
const a = state.players.find((p) => p.id === attacker)!;
|
||||
@@ -446,9 +596,9 @@ describe("swap meet trades treasures too", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("full reflection hands the swap meet choice to the reflector (rev 27)", () => {
|
||||
describe("full reflection hands the swap meet choice to the reflector", () => {
|
||||
function reflectedSwapRig(reflectorChoice?: string) {
|
||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 27 });
|
||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
|
||||
state = toRound2(state);
|
||||
const { attacker, defender } = faceOff(state);
|
||||
const a = state.players.find((p) => p.id === attacker)!;
|
||||
@@ -493,9 +643,9 @@ describe("full reflection hands the swap meet choice to the reflector (rev 27)",
|
||||
});
|
||||
});
|
||||
|
||||
describe("go away routs around walls (rules rev 36)", () => {
|
||||
describe("go away routs around walls", () => {
|
||||
it("a victim against a wall bends the line instead of standing still", () => {
|
||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 36 });
|
||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
|
||||
state = toRound2(state);
|
||||
const { attacker, defender } = faceOff(state);
|
||||
const a = state.players.find((p) => p.id === attacker)!;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { applyCommand, activePlayer, boardView, createGame, gameLos } from "../src/game";
|
||||
import { cellKey, edgeKey, SIDES, stepTarget, type Cell } from "../src/board";
|
||||
import { cellKey, edgeKey, SIDES, stepTarget, type Cell, type Side } from "../src/board";
|
||||
import type { CardInstance } from "../src/cards";
|
||||
import { eligibleCellsFor, sightedCellsFor, viewFor } from "../src/view";
|
||||
import { newExpansionGame as newGame, must, giveCard, emptyNeighborCell } from "./helpers";
|
||||
import { newExpansionGame as newGame, must, giveCard, emptyNeighborCell, plainRimWall } from "./helpers";
|
||||
|
||||
describe("expansion terrain", () => {
|
||||
it("killer ooze burns on entry and can drop you on your face", () => {
|
||||
@@ -161,6 +161,136 @@ describe("expansion terrain", () => {
|
||||
expect(state.squareContents[cellKey(spot.cell)]).toBeUndefined();
|
||||
// Wave side effects vary by geometry; the melt itself is the pinned behavior.
|
||||
});
|
||||
|
||||
it("wall of fire takes a rim warp — both mouths burn the crossing", () => {
|
||||
let { state } = createGame({
|
||||
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"],
|
||||
});
|
||||
const me = activePlayer(state);
|
||||
const warp = state.board.warps[0]!;
|
||||
me.position = { ...warp.from.cell };
|
||||
const wof = giveCard(state, me.id, "wall-of-fire", "WF", 0);
|
||||
const r = applyCommand(state, me.id, {
|
||||
type: "cast", instanceId: wof.instanceId,
|
||||
target: { kind: "edge", cell: warp.from.cell, side: warp.from.side },
|
||||
});
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
state = r.state;
|
||||
const nearKey = edgeKey(warp.from.cell, warp.from.side);
|
||||
const farKey = edgeKey(warp.to.cell, warp.to.side);
|
||||
expect(boardView(state).edges[nearKey]).toBe("firewall");
|
||||
expect(boardView(state).edges[farKey]).toBe("firewall");
|
||||
// The corridor still runs — through flame: the crossing lands on the
|
||||
// far rim and burns for 4.
|
||||
state = must(state, me.id, { type: "move", direction: warp.from.side });
|
||||
const after = state.players.find((p) => p.id === me.id)!;
|
||||
expect(cellKey(after.position)).toBe(cellKey(warp.to.cell));
|
||||
expect(after.life).toBe(11);
|
||||
});
|
||||
|
||||
it("waterwall takes a rim warp — the collapse washes both rims", () => {
|
||||
let { state } = createGame({
|
||||
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"],
|
||||
});
|
||||
const me = activePlayer(state);
|
||||
const other = state.players.find((p) => p.id !== me.id)!;
|
||||
const warp = state.board.warps[0]!;
|
||||
me.position = { ...warp.from.cell };
|
||||
other.position = { ...warp.to.cell };
|
||||
const ww = giveCard(state, me.id, "waterwall", "WW", 0);
|
||||
const r = applyCommand(state, me.id, {
|
||||
type: "cast", instanceId: ww.instanceId,
|
||||
target: { kind: "edge", cell: warp.from.cell, side: warp.from.side },
|
||||
});
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
// Both wizards stood in the mouths; the collapse washed both inward.
|
||||
const meAfter = r.state.players.find((p) => p.id === me.id)!;
|
||||
const otherAfter = r.state.players.find((p) => p.id === other.id)!;
|
||||
expect(cellKey(meAfter.position)).not.toBe(cellKey(warp.from.cell));
|
||||
expect(cellKey(otherAfter.position)).not.toBe(cellKey(warp.to.cell));
|
||||
});
|
||||
|
||||
it("illusion wall hangs on a rim warp mouth", () => {
|
||||
const { state } = createGame({
|
||||
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"],
|
||||
});
|
||||
const me = activePlayer(state);
|
||||
const warp = state.board.warps[0]!;
|
||||
me.position = { ...warp.from.cell };
|
||||
const il = giveCard(state, me.id, "illusion-wall", "IL", 0);
|
||||
const r = applyCommand(state, me.id, {
|
||||
type: "cast", instanceId: il.instanceId,
|
||||
target: { kind: "edge", cell: warp.from.cell, side: warp.from.side },
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) {
|
||||
expect(r.state.illusionWalls[edgeKey(warp.from.cell, warp.from.side)]).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("stone to water breaches the outer rim, opening a warp", () => {
|
||||
let { state } = createGame({
|
||||
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"],
|
||||
});
|
||||
const me = activePlayer(state);
|
||||
const rim = plainRimWall(state);
|
||||
me.position = { ...rim.cell };
|
||||
const warpsBefore = state.board.warps.length;
|
||||
const stw = giveCard(state, me.id, "stone-to-water", "SW", 0);
|
||||
state = must(state, me.id, {
|
||||
type: "cast", instanceId: stw.instanceId,
|
||||
target: { kind: "edge", cell: rim.cell, side: rim.side },
|
||||
});
|
||||
// The far rim melted with it and the wraparound now runs.
|
||||
expect(state.board.warps.length).toBe(warpsBefore + 2);
|
||||
});
|
||||
|
||||
it("no doors through the rim", () => {
|
||||
const { state } = createGame({
|
||||
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"],
|
||||
});
|
||||
const me = activePlayer(state);
|
||||
const rim = plainRimWall(state);
|
||||
me.position = { ...rim.cell };
|
||||
const cd = giveCard(state, me.id, "create-door", "CD", 0);
|
||||
const r = applyCommand(state, me.id, {
|
||||
type: "cast", instanceId: cd.instanceId,
|
||||
target: { kind: "edge", cell: rim.cell, side: rim.side },
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("stone to water melts a door — a small entryway in a stone wall", () => {
|
||||
const { state } = createGame({
|
||||
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"],
|
||||
});
|
||||
const me = activePlayer(state);
|
||||
const board = boardView(state);
|
||||
// Stand the caster at some door and melt it point-blank.
|
||||
let doorAt: { cell: Cell; side: Side } | null = null;
|
||||
outer: for (const k of Object.keys(board.cells)) {
|
||||
const [x, y] = k.split(",").map(Number) as [number, number];
|
||||
for (const side of SIDES) {
|
||||
if (board.edges[edgeKey({ x, y }, side)] === "door") {
|
||||
doorAt = { cell: { x, y }, side };
|
||||
break outer;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!doorAt) throw new Error("setup: no door on this board");
|
||||
me.position = { ...doorAt.cell };
|
||||
const stw = giveCard(state, me.id, "stone-to-water", "SW", 0);
|
||||
const r = applyCommand(state, me.id, {
|
||||
type: "cast", instanceId: stw.instanceId,
|
||||
target: { kind: "edge", cell: doorAt.cell, side: doorAt.side },
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) {
|
||||
const key = edgeKey(doorAt.cell, doorAt.side);
|
||||
expect(boardView(r.state).edges[key] ?? "open").toBe("open");
|
||||
expect(r.state.doorStates[key]).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("eligibility dimming mirrors the engine", () => {
|
||||
@@ -213,11 +343,11 @@ describe("eligibility dimming mirrors the engine", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("a wave's force is spent as it travels (rules rev 24)", () => {
|
||||
describe("a wave's force is spent as it travels", () => {
|
||||
/** Caster at B, one cell above A; the target wall is A's south edge, so
|
||||
* the range-2 wave covers A (dist 0) and B (dist 1). */
|
||||
function rig(deckRev: number, behind: "open" | "wall") {
|
||||
const { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"], deckRev });
|
||||
function rig(behind: "open" | "wall") {
|
||||
const { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"] });
|
||||
const me = activePlayer(state);
|
||||
const A = { x: 4, y: 5 }, B = { x: 4, y: 4 }, Bn = { x: 4, y: 3 }, Bnn = { x: 4, y: 2 };
|
||||
for (const c of [A, B, Bn, Bnn]) expect(boardView(state).cells[cellKey(c)]).toBeTruthy();
|
||||
@@ -236,20 +366,15 @@ describe("a wave's force is spent as it travels (rules rev 24)", () => {
|
||||
}
|
||||
|
||||
it("a victim at the wave's far edge is carried one space, unhurt", () => {
|
||||
const { me, Bn } = rig(24, "open");
|
||||
const { me, Bn } = rig("open");
|
||||
expect(cellKey(me.position)).toBe(cellKey(Bn));
|
||||
expect(me.life).toBe(15);
|
||||
});
|
||||
|
||||
it("only unspent force crushes: one space of push blocked is one damage", () => {
|
||||
const { me, B } = rig(24, "wall");
|
||||
const { me, B } = rig("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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -227,9 +227,9 @@ describe("treasures and victory", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("elimination by lost treasures drops what the fallen carried (rev 30)", () => {
|
||||
describe("elimination by lost treasures drops what the fallen carried", () => {
|
||||
it("the carried treasure lands where the wizard stood", () => {
|
||||
let { state } = createGame({ playerIds: ["a", "b", "c"], seed: 42, sets: ["basic"], deckRev: 30 });
|
||||
let { state } = createGame({ playerIds: ["a", "b", "c"], seed: 42, sets: ["basic"] });
|
||||
const A = state.players.find((p) => p.id === "a")!;
|
||||
const B = state.players.find((p) => p.id === "b")!;
|
||||
const C = state.players.find((p) => p.id === "c")!;
|
||||
@@ -265,9 +265,9 @@ describe("elimination by lost treasures drops what the fallen carried (rev 30)",
|
||||
});
|
||||
});
|
||||
|
||||
describe("the Ward is played in the moment (rules rev 31)", () => {
|
||||
describe("the Ward is played in the moment", () => {
|
||||
function grabRig() {
|
||||
let { state } = createGame({ playerIds: ["thief", "owner"], seed: 42, sets: ["basic"], deckRev: 31 });
|
||||
let { state } = createGame({ playerIds: ["thief", "owner"], seed: 42, sets: ["basic"] });
|
||||
const thief = state.players.find((p) => p.id === "thief")!;
|
||||
const owner = state.players.find((p) => p.id === "owner")!;
|
||||
giveCard(state, "owner", "ward", "W", 0);
|
||||
@@ -306,17 +306,17 @@ describe("the Ward is played in the moment (rules rev 31)", () => {
|
||||
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 });
|
||||
it("arming ahead is refused — the ward waits for the grab", () => {
|
||||
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"] });
|
||||
giveCard(state, state.players[state.turn.activeIndex]!.id, "ward", "W", 0);
|
||||
const r = applyCommand(state, state.players[state.turn.activeIndex]!.id, { type: "armWard", armed: true });
|
||||
const r = applyCommand(state, state.players[state.turn.activeIndex]!.id, { type: "armWard" });
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("an ambushed teleport carries its destination (rules rev 35)", () => {
|
||||
describe("an ambushed teleport carries its destination", () => {
|
||||
it("the trap springs and the victim lands where the trapper said", () => {
|
||||
let { state } = createGame({ playerIds: ["trapper", "prey"], seed: 42, sets: ["basic", "expansion1"], deckRev: 35 });
|
||||
let { state } = createGame({ playerIds: ["trapper", "prey"], seed: 42, sets: ["basic", "expansion1"] });
|
||||
for (let guard = 0; guard < 10 && !(state.players[state.turn.activeIndex]!.id === "trapper" && state.turn.round > 1); guard++) {
|
||||
const r = applyCommand(state, state.players[state.turn.activeIndex]!.id, { type: "endTurn", draw: 0 });
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
@@ -339,8 +339,9 @@ describe("an ambushed teleport carries its destination (rules rev 35)", () => {
|
||||
});
|
||||
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;
|
||||
r = applyCommand(state, "trapper", { type: "endTurn", draw: 0 });
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
state = r.state;
|
||||
// The prey walks adjacent; the trap springs; the prey passes; they land
|
||||
// at dest. (Re-find both: applyCommand clones made the handles stale.)
|
||||
const trapperNow = state.players.find((p) => p.id === "trapper")!;
|
||||
|
||||
@@ -11,8 +11,9 @@ import {
|
||||
type GameState,
|
||||
type PlayerId,
|
||||
} from "../src/game";
|
||||
import { cellKey, SIDES, stepTarget, type Cell, type Side } from "../src/board";
|
||||
import { cellKey, edgeKey, neighbor, SIDES, stepTarget, type Cell, type Side } from "../src/board";
|
||||
import type { CardInstance } from "../src/cards";
|
||||
import type { SustainedEffect } from "../src/game";
|
||||
|
||||
export function newGame(seed = 42, players: PlayerId[] = ["alice", "bob"]) {
|
||||
return createGame({ playerIds: players, seed, sets: ["basic"] });
|
||||
@@ -76,3 +77,26 @@ export function emptyNeighborCell(state: GameState, of: Cell): { cell: Cell; sid
|
||||
}
|
||||
throw new Error("no empty neighbor");
|
||||
}
|
||||
|
||||
/** Some off-board edge that is a bare stone wall — no warp behind it. */
|
||||
export function plainRimWall(state: GameState): { cell: Cell; side: Side } {
|
||||
const board = boardView(state);
|
||||
for (const k of Object.keys(board.cells)) {
|
||||
const [x, y] = k.split(",").map(Number) as [number, number];
|
||||
for (const side of SIDES) {
|
||||
if (board.cells[cellKey(neighbor({ x, y }, side))]) continue;
|
||||
if (board.edges[edgeKey({ x, y }, side)] !== "wall") continue;
|
||||
if (board.warps.some((w) => cellKey(w.from.cell) === k && w.from.side === side)) continue;
|
||||
return { cell: { x, y }, side };
|
||||
}
|
||||
}
|
||||
throw new Error("setup: no plain rim wall on this board");
|
||||
}
|
||||
|
||||
/** Rig a duration spell directly, sparing the cast ceremony. */
|
||||
export function pushSustained(
|
||||
state: GameState,
|
||||
fx: Omit<SustainedEffect, "data"> & { data?: SustainedEffect["data"] },
|
||||
): void {
|
||||
state.sustained.push({ data: {}, ...fx });
|
||||
}
|
||||
|
||||
@@ -143,13 +143,16 @@ describe("illusion wall", () => {
|
||||
expect(after.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("opponents test the illusion when they bump it — some see through, some believe", () => {
|
||||
it("an untested illusion blocks the bump; a tested eye rolls its verdict", () => {
|
||||
let believed = 0, sawThrough = 0;
|
||||
for (let seed = 1; seed <= 12; seed++) {
|
||||
const { state, caster, side } = setupIllusion(seed);
|
||||
const other = state.players.find((p) => p.id !== caster)!;
|
||||
other.position = { ...state.players.find((p) => p.id === caster)!.position };
|
||||
let s = must(state, caster, { type: "endTurn", draw: 0 });
|
||||
// Bumping blind is refused: eyes must be tested first.
|
||||
expect(applyCommand(s, other.id, { type: "move", direction: side }).ok).toBe(false);
|
||||
s = must(s, other.id, { type: "testIllusion", cell: other.position, side });
|
||||
const result = applyCommand(s, other.id, { type: "move", direction: side });
|
||||
if (result.ok) sawThrough++;
|
||||
else believed++;
|
||||
@@ -223,11 +226,16 @@ describe("sector manipulation", () => {
|
||||
type: "cast", instanceId: rel.instanceId,
|
||||
target: { kind: "cell", cell: dest }, params: { cell: me.position },
|
||||
});
|
||||
// The maze zero-anchors after the move; measure against the sector's
|
||||
// origin as it settled, not the requested landing.
|
||||
const after = state.players.find((p) => p.id === me.id)!;
|
||||
const dx = dest.x - myOrigin.x, dy = dest.y - myOrigin.y;
|
||||
const newOrigin = state.board.placements[idx]!.origin;
|
||||
const dx = newOrigin.x - myOrigin.x, dy = newOrigin.y - myOrigin.y;
|
||||
expect(after.position).toEqual({ x: posBefore.x + dx, y: posBefore.y + dy });
|
||||
expect(after.home).toEqual({ x: homeBefore.x + dx, y: homeBefore.y + dy });
|
||||
expect(state.board.placements[idx]!.origin).toEqual(dest);
|
||||
// My sector sits east of the other, as asked.
|
||||
const otherAfter = state.board.placements[idx === 0 ? 1 : 0]!.origin;
|
||||
expect(newOrigin).toEqual({ x: otherAfter.x + 5, y: otherAfter.y });
|
||||
// The map reassembled: every treasure/wizard cell exists on the new board.
|
||||
for (const t of state.treasures) {
|
||||
if (t.position) expect(state.board.cells[cellKey(t.position)]).toBe(true);
|
||||
@@ -320,7 +328,6 @@ describe("6e card-face corrections", () => {
|
||||
});
|
||||
state = must(state, defender.id, { type: "counteract", instanceId: "wall-of-fire#WOF" });
|
||||
state = must(state, attacker.id, { type: "pass" });
|
||||
state = must(state, defender.id, { type: "pass" });
|
||||
expect(state.players.find((p) => p.id === defender.id)!.life).toBe(15);
|
||||
|
||||
// But it cannot counter a fireball.
|
||||
@@ -335,14 +342,14 @@ describe("6e card-face corrections", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("relocation past the origin (rules rev 11)", () => {
|
||||
function rev11Game() {
|
||||
const { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"], deckRev: 11 });
|
||||
describe("relocation past the origin", () => {
|
||||
function freshGame() {
|
||||
const { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"] });
|
||||
return state;
|
||||
}
|
||||
|
||||
it("a sector may land at negative coordinates — the maze renormalizes", () => {
|
||||
let state = rev11Game();
|
||||
let state = freshGame();
|
||||
const me = activePlayer(state);
|
||||
const other = state.players.find((p) => p.id !== me.id)!;
|
||||
const idx = state.board.placements.findIndex(
|
||||
@@ -376,8 +383,8 @@ describe("relocation past the origin (rules rev 11)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("older revisions still refuse the negative landing", () => {
|
||||
let { state } = newGame(); // helper default: rev-ungated (1)
|
||||
it("a diagonal landing breaks adjacency and is refused", () => {
|
||||
let { state } = newGame();
|
||||
const me = activePlayer(state);
|
||||
const idx = state.board.placements.findIndex(
|
||||
(p) => me.position.y >= p.origin.y && me.position.y < p.origin.y + 5,
|
||||
@@ -393,7 +400,7 @@ describe("relocation past the origin (rules rev 11)", () => {
|
||||
});
|
||||
|
||||
it("a moving sector carries its creature, glue, warp tokens, and traps", () => {
|
||||
let state = rev11Game();
|
||||
let state = freshGame();
|
||||
const me = activePlayer(state);
|
||||
const idx = state.board.placements.findIndex(
|
||||
(p) => me.position.y >= p.origin.y && me.position.y < p.origin.y + 5,
|
||||
@@ -436,9 +443,9 @@ describe("relocation past the origin (rules rev 11)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("junction alterations roll for their sector (rules rev 20)", () => {
|
||||
describe("junction alterations roll for their sector", () => {
|
||||
it("a conjured wall on the seam obeys the die: 1-2 stays, 3-4 travels", () => {
|
||||
const { state: fresh } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"], deckRev: 20 });
|
||||
const { state: fresh } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"] });
|
||||
let state = toRound2(fresh);
|
||||
// The 2p board is two sectors stacked: the seam runs between them.
|
||||
const [p0, p1] = state.board.placements;
|
||||
@@ -501,9 +508,9 @@ describe("sight tracing", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("illusions are tested by choice (rules rev 29)", () => {
|
||||
describe("illusions are tested by choice", () => {
|
||||
function shimmerRig() {
|
||||
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"], deckRev: 29 });
|
||||
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"] });
|
||||
state = toRound2(state);
|
||||
const caster = activePlayer(state);
|
||||
const mark = state.players.find((p) => p.id !== caster.id)!;
|
||||
|
||||
@@ -280,7 +280,7 @@ describe("control effects", () => {
|
||||
|
||||
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 });
|
||||
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"] });
|
||||
state = toRound2(state);
|
||||
const p = activePlayer(state);
|
||||
const [t1, t2] = state.treasures.filter((t) => t.owner !== p.id);
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
// The telepath guard: every event the engine emits must reach the player —
|
||||
// a log line, an effect, or a modal — and every targeted cast must be
|
||||
// aimable on the board. A new event or card that slips past the client
|
||||
// fails here, instead of surfacing one puzzled bug report at a time.
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const ENGINE = join(__dirname, "..", "src");
|
||||
const WEB = join(__dirname, "..", "..", "web", "src");
|
||||
const game = readFileSync(join(ENGINE, "game.ts"), "utf8");
|
||||
const app = readFileSync(join(WEB, "App.svelte"), "utf8");
|
||||
const clientSources = ["net.svelte.ts", "fx.ts", "local.svelte.ts", "Replay.svelte", "hints.ts"]
|
||||
.map((f) => readFileSync(join(WEB, f), "utf8"))
|
||||
.concat(app)
|
||||
.join("\n");
|
||||
|
||||
/** Events whose information reaches the player another way — each with the
|
||||
* reason. Add here ONLY with a reason; "I'll wire it later" is not one. */
|
||||
const SILENT_BY_DESIGN: Record<string, string> = {
|
||||
cardsDealt: "the opening deal: the hand tray is the reveal",
|
||||
cardsDealtPrivate: "same — your opening hand appears in the tray",
|
||||
boobytrapPlacedPrivate: "the board marks the caster's real token from the view",
|
||||
};
|
||||
|
||||
function eventTypes(): string[] {
|
||||
const start = game.indexOf("export type GameEvent =");
|
||||
const block = game.slice(start, game.indexOf("\nexport ", start + 10));
|
||||
return [...new Set([...block.matchAll(/\| \{ type: "([a-zA-Z]+)"/g)].map((m) => m[1]!))];
|
||||
}
|
||||
|
||||
describe("every engine event reaches the player", () => {
|
||||
it("is humanized, animated, handled in a modal, or silent by design", () => {
|
||||
const handled = new Set<string>();
|
||||
for (const m of clientSources.matchAll(/case "([a-zA-Z]+)"/g)) handled.add(m[1]!);
|
||||
for (const m of clientSources.matchAll(/type === "([a-zA-Z]+)"/g)) handled.add(m[1]!);
|
||||
const orphans = eventTypes().filter((e) => !handled.has(e) && !(e in SILENT_BY_DESIGN));
|
||||
expect(orphans, `events no client code touches: ${orphans.join(", ")}`).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps the silent-by-design list honest (no stale entries)", () => {
|
||||
const known = new Set(eventTypes());
|
||||
const stale = Object.keys(SILENT_BY_DESIGN).filter((e) => !known.has(e));
|
||||
expect(stale, `allowlisted events the engine no longer emits: ${stale.join(", ")}`).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("every targeted cast is aimable on the board", () => {
|
||||
/** A card's resolver demanding a cell/edge target must appear in App's
|
||||
* click-targeting sets, or selecting the card leaves it uncastable. */
|
||||
it("cell- and edge-demanding resolvers appear in CELL_CARDS / EDGE_CARDS", () => {
|
||||
const start = game.indexOf("const CARD_EFFECTS");
|
||||
const end = game.indexOf("\n};", start); // the table's own closing brace
|
||||
const entries = game.slice(start, end).split(/\n (?="?[a-z][a-z0-9-]*"?: \{)/);
|
||||
const appSet = (name: string) =>
|
||||
new Set([...(app.match(new RegExp(`${name} = new Set\\(\\[([^\\]]*)\\]`))?.[1] ?? "")
|
||||
.matchAll(/"([a-z-]+)"/g)].map((m) => m[1]!));
|
||||
const cellCards = appSet("CELL_CARDS");
|
||||
const edgeCards = appSet("EDGE_CARDS");
|
||||
const missing: string[] = [];
|
||||
for (const entry of entries) {
|
||||
const id = entry.match(/^"?([a-z][a-z0-9-]*)"?: \{/)?.[1];
|
||||
if (!id) continue;
|
||||
// Only demands stated as refusals bind: `target.kind !== "cell"` etc.
|
||||
// (an optional `target?.kind === ...` branch is not a requirement).
|
||||
if (/target \|\| cmd\.target\.kind !== "cell"/.test(entry) &&
|
||||
!cellCards.has(id)) missing.push(`${id} (cell)`);
|
||||
if (/target \|\| cmd\.target\.kind !== "edge"/.test(entry) &&
|
||||
!edgeCards.has(id)) missing.push(`${id} (edge)`);
|
||||
}
|
||||
expect(missing, `casts the board cannot aim: ${missing.join(", ")}`).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { applyCommand, activePlayer, boardView } from "../src/game";
|
||||
import { applyCommand, activePlayer, boardView, createGame } from "../src/game";
|
||||
import { cellKey, edgeKey, SIDES, type Cell, type Side } from "../src/board";
|
||||
import type { CardInstance } from "../src/cards";
|
||||
import { newExpansionGame as newGame, must, giveCard, toRound2, faceOff } from "./helpers";
|
||||
import { newExpansionGame as newGame, must, giveCard, toRound2, faceOff, plainRimWall } from "./helpers";
|
||||
|
||||
describe("magic wands", () => {
|
||||
it("blaster wand: charges on first use, once per turn, discards when spent", () => {
|
||||
@@ -143,6 +143,31 @@ describe("magic wands", () => {
|
||||
expect(boardView(state).edges[key]).toBe("wall");
|
||||
});
|
||||
|
||||
it("warp wand bores the rim: a wraparound that closes at turn's end", () => {
|
||||
let { state } = createGame({
|
||||
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"],
|
||||
});
|
||||
const me = activePlayer(state);
|
||||
const rim = plainRimWall(state);
|
||||
me.position = { ...rim.cell };
|
||||
const warpsBefore = state.board.warps.length;
|
||||
const wand = giveCard(state, me.id, "warp-wand");
|
||||
giveCard(state, me.id, "number-2", "N", 1);
|
||||
state = must(state, me.id, {
|
||||
type: "cast", instanceId: wand.instanceId, numberInstanceIds: ["number-2#N"],
|
||||
target: { kind: "edge", cell: rim.cell, side: rim.side },
|
||||
});
|
||||
expect(state.board.warps.length).toBe(warpsBefore + 2);
|
||||
// The wraparound runs: step off the rim and land on the opposite edge.
|
||||
state = must(state, me.id, { type: "move", direction: rim.side });
|
||||
const across = state.players.find((p) => p.id === me.id)!.position;
|
||||
expect(cellKey(across)).not.toBe(cellKey(rim.cell));
|
||||
// Turn's end: the walls return and the warp closes with them.
|
||||
state = must(state, me.id, { type: "endTurn", draw: 0 });
|
||||
expect(boardView(state).edges[edgeKey(rim.cell, rim.side)]).toBe("wall");
|
||||
expect(state.board.warps.length).toBe(warpsBefore);
|
||||
});
|
||||
|
||||
it("deja-vu retrieves from the discard, but never a wand", () => {
|
||||
let { state } = newGame();
|
||||
const me = activePlayer(state);
|
||||
|
||||
@@ -9,16 +9,22 @@
|
||||
// {type:"claimTransfer", code} claim a seat on a new device
|
||||
// {type:"catchUp", sinceSeq} replay of moves missed while away
|
||||
// {type:"chat", text} table talk to the room
|
||||
// {type:"rollDie"} the tabletop D4, published as talk
|
||||
// {type:"addBot", style?, tier?} host seats an automaton
|
||||
// {type:"watch", roomId} join the Peanut Gallery: nameless, read-only
|
||||
// {type:"leave"} detach this socket from table or gallery
|
||||
// {type:"myGames", seats} summaries for held seats
|
||||
// {type:"stats"} the engagement tally
|
||||
// {type:"hotseatReport", ...} anonymous hotseat game counts
|
||||
// server -> client:
|
||||
// {type:"welcome"} on connect
|
||||
// {type:"seat", playerId, token} your seat secret — keep it
|
||||
// {type:"room", roomId, players, hostId, started, colors}
|
||||
// {type:"events", events} redacted for this recipient
|
||||
// {type:"room", roomId, players, hostId, started, audience, colors, bots}
|
||||
// {type:"events", events, replayed?} redacted for this recipient
|
||||
// {type:"state", view, seq} redacted full view (after every change)
|
||||
// {type:"chat", player, text, at} one line of table talk
|
||||
// {type:"watching", roomId} you are seated in the gallery
|
||||
// {type:"audience", count} how many watch from the gallery
|
||||
// {type:"transferCode"|"transferClaimed"|"catchUp"|"games"|"stats"}
|
||||
// {type:"error", message}
|
||||
|
||||
@@ -29,6 +35,7 @@ import { WebSocketServer, WebSocket } from "ws";
|
||||
import type { Command, PlayerId } from "@wizwar/engine";
|
||||
import {
|
||||
catchUpSteps,
|
||||
momentSteps,
|
||||
claimTransferCode,
|
||||
createRoom,
|
||||
pickColor,
|
||||
@@ -44,12 +51,18 @@ import {
|
||||
roomCount,
|
||||
runCommand,
|
||||
seatTokenValid,
|
||||
SPECTATOR,
|
||||
type CatchUpStep,
|
||||
startGame,
|
||||
summarize,
|
||||
viewForPlayer,
|
||||
type Room,
|
||||
kickSeat,
|
||||
abandonRoom,
|
||||
} from "./rooms";
|
||||
import { engagementStats, recordHotseat } from "./stats";
|
||||
import { getShare, loadShares, mintShare } from "./shares";
|
||||
import { renderSharePng } from "./ogimage";
|
||||
import { BOT_LINES, type BanterTrigger } from "./banter";
|
||||
|
||||
// --- Abuse limits: this is a public server on a small box. -----------------
|
||||
@@ -59,6 +72,7 @@ const MAX_ROOMS_PER_CONN = 10; // rooms one connection may create
|
||||
const MAX_COMMAND_BYTES = 16384; // serialized game command
|
||||
const MAX_MYGAMES_SEATS = 50; // seats checked per myGames request
|
||||
const CATCHUP_COOLDOWN_MS = 3000; // full-game replays are CPU-heavy
|
||||
const MAX_AUDIENCE = 30; // gallery seats per room
|
||||
const NAME_MAX = 24;
|
||||
|
||||
/** Player/room names: printable, trimmed, bounded. */
|
||||
@@ -71,11 +85,12 @@ 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();
|
||||
loadShares();
|
||||
// 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);
|
||||
}, 2000); // a beat for clients to reconnect before the clockwork stirs
|
||||
|
||||
// One process serves both the built client and the websocket, so production
|
||||
// needs only a TLS proxy in front (or nothing, on a LAN).
|
||||
@@ -88,6 +103,85 @@ const MIME: Record<string, string> = {
|
||||
// The client build may be absent in development (vite serves it instead);
|
||||
// serve a 404 for static requests in that case rather than dying on boot.
|
||||
const staticRoot = existsSync(STATIC_DIR) ? realpathSync(normalize(STATIC_DIR)) : null;
|
||||
|
||||
// --- Share pages: one turn, rebuilt for the nameless viewer. ---------------
|
||||
// Every lookup is a full-game replay, so results rest briefly in memory.
|
||||
|
||||
interface ShareData {
|
||||
steps: CatchUpStep[];
|
||||
actor: string;
|
||||
round: number;
|
||||
/** A whole finished game rather than one turn. */
|
||||
whole?: boolean;
|
||||
}
|
||||
const shareCache = new Map<string, { at: number; data: ShareData | null }>();
|
||||
function shareData(id: string): ShareData | null {
|
||||
const hit = shareCache.get(id);
|
||||
if (hit && Date.now() - hit.at < 30_000) return hit.data;
|
||||
let data: ShareData | null = null;
|
||||
const share = getShare(id);
|
||||
const room = share ? getRoom(share.roomId) : undefined;
|
||||
if (share && room?.state) {
|
||||
if (share.turn < 0) {
|
||||
// The whole tale: a finished game from the deal to the crown.
|
||||
const steps = catchUpSteps(room, SPECTATOR, 0, true);
|
||||
if (!("error" in steps) && steps.length > 0) {
|
||||
let winner = "";
|
||||
for (const st of steps) {
|
||||
for (const e of st.events) if (e.type === "gameWon" && "player" in e) winner = e.player;
|
||||
}
|
||||
data = { steps, actor: winner, round: 0, whole: true };
|
||||
}
|
||||
} else {
|
||||
const reel = momentSteps(room, SPECTATOR, share.turn);
|
||||
if (!("error" in reel) && reel.steps.length > 0) {
|
||||
data = { steps: reel.steps, actor: reel.owner, round: reel.round };
|
||||
}
|
||||
}
|
||||
}
|
||||
if (shareCache.size > 50) shareCache.clear();
|
||||
shareCache.set(id, { at: Date.now(), data });
|
||||
return data;
|
||||
}
|
||||
|
||||
const escapeHtml = (t: string) =>
|
||||
t.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
|
||||
/** index.html with this share's OpenGraph card folded into its head —
|
||||
* crawlers never run the app, so the unfurl must arrive pre-baked. */
|
||||
function shareHtml(id: string, data: ShareData, rawHost: string, rawProto: string): string {
|
||||
// Host and proto arrive from request headers — attacker-writable text
|
||||
// that must never reach an HTML attribute raw.
|
||||
const proto = /^https?$/.test(rawProto) ? rawProto : "https";
|
||||
const host = escapeHtml(rawHost);
|
||||
// The stock page carries its own generic card; strip it, or crawlers
|
||||
// (which take the FIRST tag they meet) never see this turn's.
|
||||
const html = readFileSync(join(staticRoot!, "index.html"), "utf8")
|
||||
.replace(/<meta (?:property="og:|name="twitter:)[^>]*>\s*/g, "")
|
||||
.replace(/<title>[^<]*<\/title>\s*/, "");
|
||||
const base = `${proto}://${host}`;
|
||||
const title = data.whole
|
||||
? "The whole tale — a game of Wiz-War, replayed"
|
||||
: `${escapeHtml(data.actor)}'s turn — a Wiz-War instant replay`;
|
||||
const desc = data.whole
|
||||
? `A full game of Wiz-War, magical combat in a stone labyrinth — every turn through its wizard's own eyes${data.actor ? `, to ${escapeHtml(data.actor)}'s triumph` : ""}. Watch it all, then deal yourself in.`
|
||||
: `Round ${data.round || "?"} of a game of Wiz-War, magical combat in a stone labyrinth. ` +
|
||||
`Watch the turn through ${escapeHtml(data.actor)}'s own eyes, then deal yourself in.`;
|
||||
const metas = [
|
||||
`<title>${title}</title>`,
|
||||
`<meta property="og:type" content="website"/>`,
|
||||
`<meta property="og:site_name" content="Wiz-War"/>`,
|
||||
`<meta property="og:title" content="${title}"/>`,
|
||||
`<meta property="og:description" content="${desc}"/>`,
|
||||
`<meta property="og:url" content="${base}/watch/${id}"/>`,
|
||||
`<meta property="og:image" content="${base}/watch/${id}/og.png"/>`,
|
||||
`<meta property="og:image:width" content="1200"/>`,
|
||||
`<meta property="og:image:height" content="630"/>`,
|
||||
`<meta name="twitter:card" content="summary_large_image"/>`,
|
||||
`<meta name="twitter:image" content="${base}/watch/${id}/og.png"/>`,
|
||||
].join("\n ");
|
||||
return html.replace("</head>", ` ${metas}\n </head>`);
|
||||
}
|
||||
const httpServer = createServer((req, res) => {
|
||||
try {
|
||||
if (req.method !== "GET" && req.method !== "HEAD") {
|
||||
@@ -110,6 +204,35 @@ const httpServer = createServer((req, res) => {
|
||||
res.writeHead(400).end();
|
||||
return;
|
||||
}
|
||||
// Share pages: the turn's data, its card image, and its chrome.
|
||||
const api = url.match(/^\/api\/share\/([a-z0-9]{4,20})$/);
|
||||
if (api) {
|
||||
const data = shareData(api[1]!);
|
||||
if (!data) { res.writeHead(404, { "content-type": "application/json" }).end('{"error":"no such replay"}'); return; }
|
||||
res.writeHead(200, {
|
||||
"content-type": "application/json",
|
||||
"cache-control": "public, max-age=60",
|
||||
"access-control-allow-origin": "*",
|
||||
});
|
||||
res.end(JSON.stringify({ steps: data.steps, actor: data.actor, round: data.round, whole: data.whole === true }));
|
||||
return;
|
||||
}
|
||||
const watch = url.match(/^\/watch\/([a-z0-9]{4,20})(\/og\.png)?$/);
|
||||
if (watch) {
|
||||
const data = shareData(watch[1]!);
|
||||
if (!data) { res.writeHead(404).end("no such replay"); return; }
|
||||
if (watch[2]) {
|
||||
const png = renderSharePng(data.steps[data.steps.length - 1]!.view);
|
||||
res.writeHead(200, { "content-type": "image/png", "cache-control": "public, max-age=300" });
|
||||
res.end(png);
|
||||
return;
|
||||
}
|
||||
const proto = String(req.headers["x-forwarded-proto"] ?? "http").split(",")[0]!.trim();
|
||||
const hostname = String(req.headers.host ?? `localhost:${port}`);
|
||||
res.writeHead(200, { "content-type": "text/html", "cache-control": "no-cache" });
|
||||
res.end(shareHtml(watch[1]!, data, hostname, proto));
|
||||
return;
|
||||
}
|
||||
let file = normalize(join(staticRoot, url === "/" ? "index.html" : url));
|
||||
if (file !== staticRoot && !file.startsWith(staticRoot + sep)) {
|
||||
res.writeHead(403).end();
|
||||
@@ -144,6 +267,9 @@ interface Session {
|
||||
socket: WebSocket;
|
||||
playerId: PlayerId | null;
|
||||
roomId: string | null;
|
||||
/** In the Peanut Gallery: spectator implies playerId === null, so every
|
||||
* handler that requires a seat refuses this session by construction. */
|
||||
spectator: boolean;
|
||||
/** The raw seat token this connection authenticated with (memory only). */
|
||||
token: string | null;
|
||||
claimFails: number;
|
||||
@@ -171,6 +297,25 @@ function send(socket: WebSocket, message: unknown): void {
|
||||
if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify(message));
|
||||
}
|
||||
|
||||
function audienceCount(room: Room): number {
|
||||
let n = 0;
|
||||
for (const s of sessions) if (s.roomId === room.id && s.spectator) n++;
|
||||
return n;
|
||||
}
|
||||
|
||||
function broadcastAudience(room: Room): void {
|
||||
broadcast(room, () => ({ type: "audience", count: audienceCount(room) }));
|
||||
}
|
||||
|
||||
/** A watcher leaves the gallery (to sit down, watch elsewhere, or vanish). */
|
||||
function leaveGallery(session: Session): void {
|
||||
if (!session.spectator) return;
|
||||
session.spectator = false;
|
||||
const room = session.roomId ? getRoom(session.roomId) : undefined;
|
||||
session.roomId = null;
|
||||
if (room) broadcastAudience(room);
|
||||
}
|
||||
|
||||
function roomInfo(room: Room) {
|
||||
return {
|
||||
type: "room",
|
||||
@@ -178,6 +323,7 @@ function roomInfo(room: Room) {
|
||||
players: room.players,
|
||||
hostId: room.hostId,
|
||||
started: room.state !== null,
|
||||
audience: audienceCount(room),
|
||||
colors: Object.fromEntries(room.colorChoices),
|
||||
bots: Object.fromEntries(
|
||||
// A mystery machine keeps its mood only while the game lives: once it
|
||||
@@ -194,9 +340,10 @@ function roomInfo(room: Room) {
|
||||
|
||||
function broadcast(room: Room, makeMessage: (playerId: PlayerId) => unknown): void {
|
||||
for (const s of sessions) {
|
||||
if (s.roomId === room.id && s.playerId) {
|
||||
send(s.socket, makeMessage(s.playerId));
|
||||
}
|
||||
if (s.roomId !== room.id) continue;
|
||||
// The gallery hears everything too, redacted for the nameless viewer.
|
||||
if (s.playerId) send(s.socket, makeMessage(s.playerId));
|
||||
else if (s.spectator) send(s.socket, makeMessage(SPECTATOR));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,6 +396,17 @@ function botRemark(room: Room, actor: string, events: { type: string; [k: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Spoken when a clockwork's chosen command was refused by the engine —
|
||||
* the table sees a stumble instead of an unexplained idle turn. */
|
||||
const HESITATION_LINES = [
|
||||
"RECALCULATING.",
|
||||
"TACTICAL PAUSE. INTENTIONAL. PROBABLY.",
|
||||
"THE MAZE REFUSES MY GENIUS.",
|
||||
"ERROR LOGGED. DIGNITY INTACT.",
|
||||
"I MEANT TO DO THAT.",
|
||||
];
|
||||
|
||||
const pumping = new Set<string>();
|
||||
function runBots(room: Room): void {
|
||||
if (pumping.has(room.id)) return;
|
||||
@@ -263,6 +421,14 @@ function runBots(room: Room): void {
|
||||
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));
|
||||
// A refused brain-choice becomes a visible stumble, in character —
|
||||
// an idle bot turn should read as hesitation, never as nothing.
|
||||
if (step.hesitated) {
|
||||
const said = addChat(room, step.seat, HESITATION_LINES[Math.floor(Math.random() * HESITATION_LINES.length)]!);
|
||||
if (!("error" in said)) {
|
||||
broadcast(room, () => ({ type: "chat", player: step.seat, text: said.text, at: said.at }));
|
||||
}
|
||||
}
|
||||
botRemark(room, step.seat, step.events as { type: string }[]);
|
||||
setTimeout(tick, BOT_STEP_MS);
|
||||
};
|
||||
@@ -283,14 +449,17 @@ wss.on("connection", (socket) => {
|
||||
return;
|
||||
}
|
||||
const session: Session = {
|
||||
socket, playerId: null, roomId: null, token: null, claimFails: 0,
|
||||
socket, playerId: null, roomId: null, spectator: false, token: null, claimFails: 0,
|
||||
bucket: 30, lastRefill: Date.now(), overLimitStrikes: 0,
|
||||
roomsCreated: 0, lastCatchUpAt: 0, hotseatReports: 0,
|
||||
};
|
||||
sessions.add(session);
|
||||
send(socket, { type: "welcome", game: "wizwar" });
|
||||
|
||||
socket.on("close", () => sessions.delete(session));
|
||||
socket.on("close", () => {
|
||||
sessions.delete(session);
|
||||
leaveGallery(session); // an emptier gallery is news to the table
|
||||
});
|
||||
|
||||
socket.on("message", (data) => {
|
||||
if (!underRateLimit(session)) {
|
||||
@@ -313,6 +482,7 @@ wss.on("connection", (socket) => {
|
||||
return send(socket, { type: "error", message: "no new rooms right now — try again later" });
|
||||
}
|
||||
session.roomsCreated++;
|
||||
leaveGallery(session);
|
||||
const { room, token } = createRoom(name);
|
||||
session.playerId = name;
|
||||
session.roomId = room.id;
|
||||
@@ -329,6 +499,7 @@ wss.on("connection", (socket) => {
|
||||
if (!room) return send(socket, { type: "error", message: "no such room" });
|
||||
const result = joinRoom(room, name, typeof msg.token === "string" ? msg.token : null);
|
||||
if ("error" in result) return send(socket, { type: "error", message: result.error });
|
||||
leaveGallery(session);
|
||||
session.playerId = name;
|
||||
session.roomId = room.id;
|
||||
session.token = result.token;
|
||||
@@ -343,6 +514,72 @@ wss.on("connection", (socket) => {
|
||||
runBots(room);
|
||||
break;
|
||||
}
|
||||
case "watch": {
|
||||
// The Peanut Gallery: no name, no seat, no ledger line — a pure
|
||||
// reader of the public broadcast, counted but never identified.
|
||||
const roomId = String(msg.roomId ?? "").trim().slice(0, 8);
|
||||
if (!roomId) return send(socket, { type: "error", message: "roomId required" });
|
||||
const room = getRoom(roomId);
|
||||
if (!room) return send(socket, { type: "error", message: "no such room" });
|
||||
if (audienceCount(room) >= MAX_AUDIENCE) {
|
||||
return send(socket, { type: "error", message: "the gallery is packed — try again later" });
|
||||
}
|
||||
leaveGallery(session); // switching galleries updates the old room's count
|
||||
session.playerId = null;
|
||||
session.token = null;
|
||||
session.spectator = true;
|
||||
session.roomId = room.id;
|
||||
send(socket, { type: "watching", roomId: room.id });
|
||||
send(socket, roomInfo(room));
|
||||
if (room.state) {
|
||||
send(socket, { type: "events", events: redactFor(room.events, SPECTATOR), replayed: true });
|
||||
send(socket, { type: "state", view: viewForPlayer(room, SPECTATOR), seq: room.log.length });
|
||||
}
|
||||
broadcastAudience(room);
|
||||
break;
|
||||
}
|
||||
case "leave": {
|
||||
// Walk away from the table or the gallery: the seat itself (and
|
||||
// its token) survives for a later resume; only this socket detaches.
|
||||
leaveGallery(session);
|
||||
session.playerId = null;
|
||||
session.roomId = null;
|
||||
session.token = null;
|
||||
break;
|
||||
}
|
||||
case "kickSeat": {
|
||||
const room = session.roomId ? getRoom(session.roomId) : undefined;
|
||||
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
|
||||
const kickName = String(msg.name ?? "");
|
||||
const problem = kickSeat(room, session.playerId, kickName);
|
||||
if (problem) return send(socket, { type: "error", message: problem });
|
||||
// A kicked live socket is set adrift so it cannot act on a seat it lost.
|
||||
for (const other of sessions) {
|
||||
if (other.roomId === room.id && other.playerId === kickName) {
|
||||
other.playerId = null;
|
||||
other.roomId = null;
|
||||
other.token = null;
|
||||
send(other.socket, { type: "kicked", roomId: room.id });
|
||||
}
|
||||
}
|
||||
broadcastRoomState(room);
|
||||
break;
|
||||
}
|
||||
case "abandonRoom": {
|
||||
const room = session.roomId ? getRoom(session.roomId) : undefined;
|
||||
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
|
||||
const problem = abandonRoom(room, session.playerId);
|
||||
if (problem) return send(socket, { type: "error", message: problem });
|
||||
for (const other of sessions) {
|
||||
if (other.roomId === room.id) {
|
||||
other.playerId = null;
|
||||
other.roomId = null;
|
||||
other.token = null;
|
||||
send(other.socket, { type: "roomAbandoned", roomId: room.id });
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "addBot": {
|
||||
const room = session.roomId ? getRoom(session.roomId) : undefined;
|
||||
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
|
||||
@@ -377,6 +614,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 }));
|
||||
// The game's end unmasks the mystery machines in the roster.
|
||||
if (room.state?.phase === "finished") broadcast(room, () => roomInfo(room));
|
||||
botRemark(room, session.playerId, result.events as { type: string }[]);
|
||||
runBots(room);
|
||||
@@ -436,6 +674,40 @@ wss.on("connection", (socket) => {
|
||||
send(socket, { type: "catchUp", steps });
|
||||
break;
|
||||
}
|
||||
case "share": {
|
||||
// Mint (or re-find) the public link for one turn's replay.
|
||||
const room = session.roomId ? getRoom(session.roomId) : undefined;
|
||||
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
|
||||
const turn = Number(msg.turn);
|
||||
if (!Number.isInteger(turn) || turn < -1) return send(socket, { type: "error", message: "no such turn" });
|
||||
const now = Date.now();
|
||||
if (now - session.lastCatchUpAt < CATCHUP_COOLDOWN_MS) {
|
||||
return send(socket, { type: "error", message: "catching up already — one moment" });
|
||||
}
|
||||
session.lastCatchUpAt = now;
|
||||
// turn -1 shares the whole finished game; anything else, one turn.
|
||||
const check = turn === -1
|
||||
? catchUpSteps(room, session.playerId, 0, true)
|
||||
: momentSteps(room, session.playerId, turn);
|
||||
if ("error" in check) return send(socket, { type: "error", message: check.error });
|
||||
const share = mintShare(room.id, turn);
|
||||
send(socket, { type: "share", id: share.id, turn });
|
||||
break;
|
||||
}
|
||||
case "moment": {
|
||||
// A chronicle line's instant-replay eye: one turn's reel.
|
||||
const room = session.roomId ? getRoom(session.roomId) : undefined;
|
||||
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
|
||||
const now = Date.now();
|
||||
if (now - session.lastCatchUpAt < CATCHUP_COOLDOWN_MS) {
|
||||
return send(socket, { type: "error", message: "catching up already — one moment" });
|
||||
}
|
||||
session.lastCatchUpAt = now;
|
||||
const reel = momentSteps(room, session.playerId, Number(msg.turn ?? -1));
|
||||
if ("error" in reel) return send(socket, { type: "error", message: reel.error });
|
||||
send(socket, { type: "moment", steps: reel.steps, owner: reel.owner });
|
||||
break;
|
||||
}
|
||||
case "pickColor": {
|
||||
const room = session.roomId ? getRoom(session.roomId) : undefined;
|
||||
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
|
||||
@@ -462,18 +734,26 @@ wss.on("connection", (socket) => {
|
||||
break;
|
||||
}
|
||||
case "myGames": {
|
||||
// {seats: [{roomId, name, token}]} -> summaries for valid seats.
|
||||
// {seats: [{roomId, name, token}]} -> summaries for valid seats,
|
||||
// plus explicit verdicts on seats PROVEN dead: the room exists
|
||||
// and refused this exact token. A room this server simply does
|
||||
// not know earns no verdict — absence is not evidence (a
|
||||
// restarting or wrong server knows nothing about anything).
|
||||
const seats = Array.isArray(msg.seats) ? msg.seats.slice(0, MAX_MYGAMES_SEATS) : [];
|
||||
const games = [];
|
||||
const voided: string[] = [];
|
||||
for (const seat of seats) {
|
||||
if (typeof seat !== "object" || seat === null) continue;
|
||||
const room = getRoom(String(seat.roomId ?? ""));
|
||||
if (!room) continue;
|
||||
const name = String(seat.name ?? "");
|
||||
if (!seatTokenValid(room, name, typeof seat.token === "string" ? seat.token : null)) continue;
|
||||
if (!seatTokenValid(room, name, typeof seat.token === "string" ? seat.token : null)) {
|
||||
voided.push(`${room.id}:${name}`);
|
||||
continue;
|
||||
}
|
||||
games.push(summarize(room, name));
|
||||
}
|
||||
send(socket, { type: "games", games });
|
||||
send(socket, { type: "games", games, voided });
|
||||
break;
|
||||
}
|
||||
default:
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
// The share card: a 1200x630 OpenGraph image of the shared turn's maze,
|
||||
// painted from a spectator view and encoded as PNG with nothing but
|
||||
// node's own zlib — no image libraries on this small box. The unfurl
|
||||
// text carries the words; this carries the board.
|
||||
|
||||
import { deflateSync } from "node:zlib";
|
||||
import type { GameView } from "@wizwar/engine";
|
||||
|
||||
// --- Minimal PNG encoding (truecolor, filter 0) ----------------------------
|
||||
|
||||
const CRC_TABLE = new Int32Array(256);
|
||||
for (let n = 0; n < 256; n++) {
|
||||
let c = n;
|
||||
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||
CRC_TABLE[n] = c;
|
||||
}
|
||||
function crc32(buf: Uint8Array): number {
|
||||
let c = ~0;
|
||||
for (const b of buf) c = CRC_TABLE[(c ^ b) & 0xff]! ^ (c >>> 8);
|
||||
return ~c >>> 0;
|
||||
}
|
||||
function chunk(type: string, data: Uint8Array): Uint8Array {
|
||||
const out = new Uint8Array(12 + data.length);
|
||||
const dv = new DataView(out.buffer);
|
||||
dv.setUint32(0, data.length);
|
||||
for (let i = 0; i < 4; i++) out[4 + i] = type.charCodeAt(i);
|
||||
out.set(data, 8);
|
||||
dv.setUint32(8 + data.length, crc32(out.subarray(4, 8 + data.length)));
|
||||
return out;
|
||||
}
|
||||
function encodePng(width: number, height: number, rgb: Uint8Array): Buffer {
|
||||
const ihdr = new Uint8Array(13);
|
||||
const dv = new DataView(ihdr.buffer);
|
||||
dv.setUint32(0, width);
|
||||
dv.setUint32(4, height);
|
||||
ihdr[8] = 8; // bit depth
|
||||
ihdr[9] = 2; // truecolor
|
||||
const raw = new Uint8Array(height * (1 + width * 3));
|
||||
for (let y = 0; y < height; y++) {
|
||||
raw.set(rgb.subarray(y * width * 3, (y + 1) * width * 3), y * (1 + width * 3) + 1);
|
||||
}
|
||||
return Buffer.concat([
|
||||
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
|
||||
chunk("IHDR", ihdr),
|
||||
chunk("IDAT", deflateSync(raw)),
|
||||
chunk("IEND", new Uint8Array(0)),
|
||||
]);
|
||||
}
|
||||
|
||||
// --- The painting ----------------------------------------------------------
|
||||
|
||||
type Rgb = [number, number, number];
|
||||
const hex = (s: string): Rgb => [
|
||||
parseInt(s.slice(1, 3), 16), parseInt(s.slice(3, 5), 16), parseInt(s.slice(5, 7), 16),
|
||||
];
|
||||
const BG = hex("#171a20");
|
||||
const CELL = hex("#e9e1cb");
|
||||
const CELL_LINE = hex("#cfc4a8");
|
||||
const WALL = hex("#43331f");
|
||||
const DOOR = hex("#a2703a");
|
||||
const FIRE = hex("#d65c1c");
|
||||
const STONE = hex("#8a8a94");
|
||||
const WARP = hex("#7a4ccc");
|
||||
const CREATURE = hex("#4a4438");
|
||||
const TREASURE = hex("#c9a72a");
|
||||
const HAZARD = hex("#7c8a4a");
|
||||
/** Standee colors, mirroring the client's PLAYER_COLORS. */
|
||||
const PLAYER: Rgb[] = [
|
||||
hex("#1a9c46"), hex("#d3352b"), hex("#c9308f"),
|
||||
hex("#3a3ac0"), hex("#2ab0c9"), hex("#c9a72a"),
|
||||
];
|
||||
|
||||
export function renderSharePng(view: GameView): Buffer {
|
||||
const W = 1200, H = 630;
|
||||
const img = new Uint8Array(W * H * 3);
|
||||
const rect = (x: number, y: number, w: number, h: number, c: Rgb) => {
|
||||
const x0 = Math.max(0, Math.round(x)), y0 = Math.max(0, Math.round(y));
|
||||
const x1 = Math.min(W, Math.round(x + w)), y1 = Math.min(H, Math.round(y + h));
|
||||
for (let yy = y0; yy < y1; yy++) {
|
||||
let o = (yy * W + x0) * 3;
|
||||
for (let xx = x0; xx < x1; xx++) {
|
||||
img[o] = c[0]; img[o + 1] = c[1]; img[o + 2] = c[2];
|
||||
o += 3;
|
||||
}
|
||||
}
|
||||
};
|
||||
const disc = (cx: number, cy: number, r: number, c: Rgb) => {
|
||||
for (let yy = Math.round(cy - r); yy <= cy + r; yy++) {
|
||||
for (let xx = Math.round(cx - r); xx <= cx + r; xx++) {
|
||||
if (xx < 0 || yy < 0 || xx >= W || yy >= H) continue;
|
||||
if ((xx - cx) ** 2 + (yy - cy) ** 2 > r * r) continue;
|
||||
const o = (yy * W + xx) * 3;
|
||||
img[o] = c[0]; img[o + 1] = c[1]; img[o + 2] = c[2];
|
||||
}
|
||||
}
|
||||
};
|
||||
rect(0, 0, W, H, BG);
|
||||
|
||||
const bw = view.board.width, bh = view.board.height;
|
||||
const cell = Math.floor(Math.min((W - 120) / bw, (H - 80) / bh));
|
||||
const ox = Math.round((W - bw * cell) / 2);
|
||||
const oy = Math.round((H - bh * cell) / 2);
|
||||
const px = (cx: number, cy: number) => [ox + cx * cell, oy + cy * cell] as const;
|
||||
const line = Math.max(3, Math.round(cell * 0.1));
|
||||
|
||||
// The rooms of the maze.
|
||||
const has = (cx: number, cy: number) => !!view.board.cells[`${cx},${cy}`];
|
||||
for (const k of Object.keys(view.board.cells)) {
|
||||
const [cx, cy] = k.split(",").map(Number) as [number, number];
|
||||
const [x, y] = px(cx, cy);
|
||||
rect(x, y, cell, cell, CELL);
|
||||
rect(x, y, cell, 1, CELL_LINE);
|
||||
rect(x, y, 1, cell, CELL_LINE);
|
||||
}
|
||||
|
||||
// Home bases wear their owner's color as a floor border.
|
||||
for (const p of view.players) {
|
||||
if (!p.home) continue;
|
||||
const [x, y] = px(p.home.x, p.home.y);
|
||||
const c = PLAYER[p.colorIndex] ?? WALL;
|
||||
const t = Math.max(2, Math.round(cell * 0.08));
|
||||
rect(x + 2, y + 2, cell - 4, t, c);
|
||||
rect(x + 2, y + cell - 2 - t, cell - 4, t, c);
|
||||
rect(x + 2, y + 2, t, cell - 4, c);
|
||||
rect(x + cell - 2 - t, y + 2, t, cell - 4, c);
|
||||
}
|
||||
|
||||
// The floor's furniture: filled stone reads solid; other hazards dot.
|
||||
for (const [k, content] of Object.entries(view.squareContents)) {
|
||||
const [cx, cy] = k.split(",").map(Number) as [number, number];
|
||||
const [x, y] = px(cx, cy);
|
||||
if (content.kind === "stone") rect(x + 1, y + 1, cell - 2, cell - 2, STONE);
|
||||
else disc(x + cell / 2, y + cell / 2, cell * 0.16, HAZARD);
|
||||
}
|
||||
|
||||
// Interior walls, doors, and fire, on their edges.
|
||||
for (const [k, e] of Object.entries(view.board.edges)) {
|
||||
if (e === "open") continue;
|
||||
const [kind, coords] = k.split(":") as [string, string];
|
||||
const [ex, ey] = coords.split(",").map(Number) as [number, number];
|
||||
const c = e === "door" ? DOOR : e === "firewall" ? FIRE : WALL;
|
||||
if (kind === "V") {
|
||||
const [x, y] = px(ex + 1, ey);
|
||||
rect(x - line / 2, y, line, cell, c);
|
||||
} else {
|
||||
const [x, y] = px(ex, ey + 1);
|
||||
rect(x, y - line / 2, cell, line, c);
|
||||
}
|
||||
}
|
||||
|
||||
// The rim: every open side facing nothing is a wall, except warp mouths.
|
||||
const mouth = new Set(view.board.warps.map((w) => `${w.from.cell.x},${w.from.cell.y}:${w.from.side}`));
|
||||
for (const k of Object.keys(view.board.cells)) {
|
||||
const [cx, cy] = k.split(",").map(Number) as [number, number];
|
||||
const [x, y] = px(cx, cy);
|
||||
const side = (missing: boolean, sx: number, sy: number, w: number, h: number, s: string) => {
|
||||
if (!missing) return;
|
||||
rect(sx, sy, w, h, mouth.has(`${cx},${cy}:${s}`) ? WARP : WALL);
|
||||
};
|
||||
side(!has(cx + 1, cy), x + cell - line / 2, y, line, cell, "E");
|
||||
side(!has(cx - 1, cy), x - line / 2, y, line, cell, "W");
|
||||
side(!has(cx, cy + 1), x, y + cell - line / 2, cell, line, "S");
|
||||
side(!has(cx, cy - 1), x, y - line / 2, cell, line, "N");
|
||||
}
|
||||
|
||||
// Loose treasures glint gold.
|
||||
for (const t of view.treasures) {
|
||||
if (!t.position || t.carriedBy) continue;
|
||||
const [x, y] = px(t.position.x, t.position.y);
|
||||
rect(x + cell * 0.6, y + cell * 0.6, cell * 0.25, cell * 0.25, TREASURE);
|
||||
}
|
||||
|
||||
// Creatures crouch dark; wizards stand in their colors.
|
||||
for (const c of view.creatures) {
|
||||
const [x, y] = px(c.position.x, c.position.y);
|
||||
disc(x + cell * 0.3, y + cell * 0.68, cell * 0.18, CREATURE);
|
||||
}
|
||||
const standing = new Map<string, number>();
|
||||
for (const p of view.players) {
|
||||
if (!p.alive) continue;
|
||||
const key = `${p.position.x},${p.position.y}`;
|
||||
const n = standing.get(key) ?? 0;
|
||||
standing.set(key, n + 1);
|
||||
const [x, y] = px(p.position.x, p.position.y);
|
||||
const cx = x + cell / 2 + (n - 0.5) * cell * 0.16;
|
||||
disc(cx, y + cell * 0.42, cell * 0.26, PLAYER[p.colorIndex] ?? WALL);
|
||||
disc(cx, y + cell * 0.42, cell * 0.26 - 2, PLAYER[p.colorIndex] ?? WALL);
|
||||
}
|
||||
|
||||
return encodePng(W, H, img);
|
||||
}
|
||||
@@ -20,8 +20,9 @@ import {
|
||||
type GameState,
|
||||
type GameView,
|
||||
type PlayerId,
|
||||
CURRENT_RULES_REV,
|
||||
} from "@wizwar/engine";
|
||||
import { appendLine, ensureDataDir, readAllRooms, roomFileExists, type RoomLine } from "./store";
|
||||
import { appendLine, archiveRoomFile, ensureDataDir, readAllRooms, roomFileExists, type RoomLine } from "./store";
|
||||
import { recordRoom } from "./stats";
|
||||
|
||||
export interface LoggedCommand {
|
||||
@@ -53,9 +54,6 @@ export interface Room {
|
||||
|
||||
const rooms = new Map<string, Room>();
|
||||
|
||||
/** Rules revision new games are dealt under (stored games keep their own). */
|
||||
const RULES_REV = 36;
|
||||
|
||||
const ROOM_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||
|
||||
/** Tokens live hashed at rest (memory and disk); clients hold the raw form. */
|
||||
@@ -150,6 +148,33 @@ export function joinRoom(
|
||||
return { token: fresh };
|
||||
}
|
||||
|
||||
/** The host shows an unclaimed (or unwanted) seat the door — lobby only:
|
||||
* once the deal happens, every seat is a player in the game's record. */
|
||||
export function kickSeat(room: Room, byId: PlayerId, name: PlayerId): string | null {
|
||||
if (byId !== room.hostId) return "only the host may clear a seat";
|
||||
if (room.state) return "the game has started — seats are settled";
|
||||
if (name === room.hostId) return "the host cannot kick themselves — abandon the room instead";
|
||||
if (!room.players.includes(name)) return "no such seat";
|
||||
room.players = room.players.filter((p) => p !== name);
|
||||
room.tokens.delete(name);
|
||||
room.bots.delete(name);
|
||||
room.colorChoices.delete(name);
|
||||
appendLine(room.id, { kind: "kick", name, at: new Date().toISOString() });
|
||||
recordRoom(room);
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The host dissolves the room: a lobby that never dealt, or a finished
|
||||
* game done being remembered. The ledger is archived, never deleted. */
|
||||
export function abandonRoom(room: Room, byId: PlayerId): string | null {
|
||||
if (byId !== room.hostId) return "only the host may abandon the room";
|
||||
if (room.state && room.state.phase === "playing") return "the game is still being played";
|
||||
appendLine(room.id, { kind: "abandon", by: byId, at: new Date().toISOString() });
|
||||
archiveRoomFile(room.id);
|
||||
rooms.delete(room.id);
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Everyone gets their chosen standee; the undecided get the first free one. */
|
||||
export function resolveColors(room: Room): number[] {
|
||||
const taken = new Set<number>();
|
||||
@@ -198,9 +223,9 @@ function startInMemory(room: Room, expansion: boolean, colors?: number[], deckRe
|
||||
export function startGame(room: Room, expansion: boolean): { events: GameEvent[] } | { error: string } {
|
||||
if (room.state) return { error: "already started" };
|
||||
const colors = resolveColors(room);
|
||||
const result = startInMemory(room, expansion, colors, RULES_REV);
|
||||
const result = startInMemory(room, expansion, colors, CURRENT_RULES_REV);
|
||||
if ("error" in result) return result;
|
||||
appendLine(room.id, { kind: "start", expansion, colors, deckRev: RULES_REV });
|
||||
appendLine(room.id, { kind: "start", expansion, colors, deckRev: CURRENT_RULES_REV });
|
||||
recordRoom(room);
|
||||
return result;
|
||||
}
|
||||
@@ -288,17 +313,28 @@ function actingSeat(room: Room): PlayerId | null {
|
||||
* One automaton command, if the maze is waiting on clockwork. Null when a
|
||||
* human holds the floor (or the game is over, or the clockwork is wedged).
|
||||
*/
|
||||
export function driveOneAutomaton(room: Room): { seat: PlayerId; events: GameEvent[] } | null {
|
||||
export function driveOneAutomaton(
|
||||
room: Room,
|
||||
): { seat: PlayerId; events: GameEvent[]; hesitated?: true } | null {
|
||||
const seat = actingSeat(room);
|
||||
if (!seat || !room.bots.has(seat)) return null;
|
||||
const view = viewFor(room.state!, seat);
|
||||
const bot = room.bots.get(seat);
|
||||
const chosen = automatonCommand(view, bot?.style, bot?.tier);
|
||||
let hesitated: true | undefined;
|
||||
let r = runCommand(room, seat, chosen ?? automatonFallback(view, bot?.tier));
|
||||
if ("error" in r) {
|
||||
// A refused choice retries with the fallback — unless the fallback IS
|
||||
// what just failed — then burns down the ladder to endTurn and pass.
|
||||
if (chosen) r = runCommand(room, seat, automatonFallback(view, bot?.tier));
|
||||
// The refusal is remembered: a brain whose choice the engine rejects
|
||||
// is a bug in the brain, and the table deserves to see the stumble
|
||||
// rather than an unexplained idle turn.
|
||||
if (chosen) {
|
||||
hesitated = true;
|
||||
console.warn(
|
||||
`automaton ${seat} hesitated in ${room.id}: ${JSON.stringify(chosen)} refused (${r.error})`);
|
||||
r = runCommand(room, seat, automatonFallback(view, bot?.tier));
|
||||
}
|
||||
if ("error" in r) r = runCommand(room, seat, { type: "endTurn", draw: 0 });
|
||||
if ("error" in r) r = runCommand(room, seat, { type: "pass" });
|
||||
if ("error" in r) {
|
||||
@@ -306,7 +342,7 @@ export function driveOneAutomaton(room: Room): { seat: PlayerId; events: GameEve
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return { seat, events: r.events };
|
||||
return { seat, events: r.events, ...(hesitated ? { hesitated } : {}) };
|
||||
}
|
||||
|
||||
export interface GameSummary {
|
||||
@@ -361,6 +397,12 @@ export function viewForPlayer(room: Room, playerId: PlayerId): GameView | null {
|
||||
return room.state ? viewFor(room.state, playerId) : null;
|
||||
}
|
||||
|
||||
/** The Peanut Gallery's viewer id: the empty name can never hold a seat
|
||||
* (joins reject blank names), so a view built for it shows public knowledge
|
||||
* only — no hand, no ward, no ambushes, no boobytrap truths — and event
|
||||
* redaction drops everything marked visibleTo a player. */
|
||||
export const SPECTATOR: PlayerId = "";
|
||||
|
||||
export interface CatchUpStep {
|
||||
seq: number;
|
||||
actor: PlayerId;
|
||||
@@ -376,7 +418,10 @@ export interface CatchUpStep {
|
||||
*/
|
||||
export function catchUpSteps(room: Room, playerId: PlayerId, sinceSeq: number, full = false): CatchUpStep[] | { error: string } {
|
||||
if (!room.state) return { error: "game not started" };
|
||||
if (!room.players.includes(playerId)) return { error: "you hold no seat in this room" };
|
||||
// The SPECTATOR builds whole-game share pages: public knowledge, no seat.
|
||||
if (playerId !== SPECTATOR && !room.players.includes(playerId)) {
|
||||
return { error: "you hold no seat in this room" };
|
||||
}
|
||||
if (full && room.state.phase !== "finished") return { error: "full replays wait for the game to finish" };
|
||||
const MAX_STEPS = 200;
|
||||
const from = full ? 0 : Math.max(sinceSeq, room.log.length - MAX_STEPS);
|
||||
@@ -408,6 +453,87 @@ export function redactFor(events: GameEvent[], playerId: PlayerId): GameEvent[]
|
||||
return events.map((e) => redactEvent(e, playerId)).filter((e): e is GameEvent => e !== null);
|
||||
}
|
||||
|
||||
/** The events that begin a turn — the client's chronicle counts these
|
||||
* identically, so a turn number names the same stretch on both ends. */
|
||||
const TURN_BOUNDARY = new Set(["turnStarted", "extraTurnStarted", "turnSkipped"]);
|
||||
|
||||
export interface MomentReel {
|
||||
steps: CatchUpStep[];
|
||||
/** The wizard whose turn this is — the reel's eyes. */
|
||||
owner: PlayerId;
|
||||
round: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* One turn's reel: every command whose events touch turn `turnIndex`
|
||||
* (0-counted across boundary events). A command that ends one turn and
|
||||
* starts the next belongs to both, so a reel opens with the blow that
|
||||
* began it and closes on the handover. The owner comes from the very
|
||||
* boundary that opened the turn — which for turn 0 lives in the DEAL,
|
||||
* before any command, so no scan of the steps could find it.
|
||||
*/
|
||||
export function momentSteps(room: Room, playerId: PlayerId, turnIndex: number): MomentReel | { error: string } {
|
||||
if (!room.state) return { error: "game not started" };
|
||||
// The SPECTATOR builds share pages: public knowledge only, no seat.
|
||||
if (playerId !== SPECTATOR && !room.players.includes(playerId)) {
|
||||
return { error: "you hold no seat in this room" };
|
||||
}
|
||||
const MAX_STEPS = 80;
|
||||
const { state: fresh, events: dealt } = createGame(room.state.config);
|
||||
let current = fresh;
|
||||
// The deal's own events open the first turn — count them so turn
|
||||
// numbers match the client chronicle's.
|
||||
let counter = -1;
|
||||
let owner: PlayerId | null = null;
|
||||
let round = 0;
|
||||
const bump = (e: GameEvent) => {
|
||||
if (!TURN_BOUNDARY.has(e.type)) return;
|
||||
counter++;
|
||||
if (counter === turnIndex && owner === null && "player" in e) {
|
||||
owner = (e as { player: PlayerId }).player;
|
||||
if ("round" in e) round = (e as { round: number }).round;
|
||||
}
|
||||
};
|
||||
for (const e of dealt) bump(e);
|
||||
const steps: CatchUpStep[] = [];
|
||||
for (const entry of room.log) {
|
||||
const result = applyCommand(current, entry.playerId, entry.command);
|
||||
if (!result.ok) return { error: `replay diverged at seq ${entry.seq}` };
|
||||
current = result.state;
|
||||
const before = counter;
|
||||
// The straddle commands hold more than this turn: the opener carries
|
||||
// the previous wizard's last breaths, the closer the NEXT wizard's
|
||||
// first. Trim both so the reel is exactly one turn — it opens on
|
||||
// "X's turn begins" and stops at X's turnEnded.
|
||||
let sliceStart = 0;
|
||||
let sliceEnd = result.events.length;
|
||||
result.events.forEach((e, i) => {
|
||||
if (!TURN_BOUNDARY.has(e.type)) return;
|
||||
bump(e);
|
||||
if (counter === turnIndex && before < turnIndex) sliceStart = i;
|
||||
if (counter === turnIndex + 1 && sliceEnd === result.events.length) sliceEnd = i;
|
||||
});
|
||||
if (before > turnIndex) break;
|
||||
if (before <= turnIndex && turnIndex <= counter) {
|
||||
const kept = result.events.slice(sliceStart, sliceEnd);
|
||||
const prevAt = entry.seq > 0 ? room.log[entry.seq - 1]!.at : "";
|
||||
const said = room.chat
|
||||
.filter((c) => c.at > prevAt && c.at <= entry.at)
|
||||
.map((c) => ({ player: c.player, text: c.text }));
|
||||
steps.push({
|
||||
seq: entry.seq,
|
||||
actor: entry.playerId,
|
||||
events: redactFor(kept, playerId),
|
||||
view: viewFor(current, playerId),
|
||||
...(said.length > 0 ? { chat: said } : {}),
|
||||
});
|
||||
if (steps.length >= MAX_STEPS) break;
|
||||
}
|
||||
}
|
||||
if (steps.length === 0) return { error: "no such turn yet" };
|
||||
return { steps, owner: owner ?? steps[steps.length - 1]!.actor, round };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Seat transfers: a spoken-word one-time code hands a seat to another device.
|
||||
// Ephemeral by design — a server restart voids pending codes, never seats.
|
||||
@@ -508,8 +634,14 @@ export function loadPersistedRooms(): void {
|
||||
chat: [],
|
||||
bots: new Map(),
|
||||
};
|
||||
if (lines.some((l) => l.kind === "abandon")) continue;
|
||||
for (const line of lines.slice(1)) {
|
||||
if (line.kind === "join") {
|
||||
if (line.kind === "kick") {
|
||||
room.players = room.players.filter((p) => p !== line.name);
|
||||
room.tokens.delete(line.name);
|
||||
room.bots.delete(line.name);
|
||||
room.colorChoices.delete(line.name);
|
||||
} else if (line.kind === "join") {
|
||||
if (line.bot) {
|
||||
room.players.push(line.name);
|
||||
room.bots.set(line.name, {
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
// Share links: a minted slug names one turn of one game, and anyone
|
||||
// holding it may watch that turn — spectator-redacted, nothing else of
|
||||
// the game visible. Shares persist beside the rooms and survive restarts.
|
||||
|
||||
import { appendFileSync, existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { randomInt } from "node:crypto";
|
||||
import { statsDir } from "./store";
|
||||
|
||||
export interface Share {
|
||||
id: string;
|
||||
roomId: string;
|
||||
turn: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
const SHARE_ALPHABET = "abcdefghjkmnpqrstuvwxyz23456789";
|
||||
const SHARE_ID_LENGTH = 10; // 31^10 ≈ 8×10^14 — unguessable, typeable
|
||||
|
||||
const shares = new Map<string, Share>();
|
||||
|
||||
function sharesFile(): string {
|
||||
return join(statsDir(), "shares.jsonl");
|
||||
}
|
||||
|
||||
export function loadShares(): void {
|
||||
const file = sharesFile();
|
||||
if (!existsSync(file)) return;
|
||||
for (const line of readFileSync(file, "utf8").trim().split("\n")) {
|
||||
if (!line) continue;
|
||||
try {
|
||||
const s = JSON.parse(line) as Share;
|
||||
if (s.id) shares.set(s.id, s);
|
||||
} catch {
|
||||
// A torn tail line loses one share, never the server.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function mintShare(roomId: string, turn: number): Share {
|
||||
// One link per (room, turn): sharing the same turn twice hands back
|
||||
// the same slug, so a re-share never splits an audience.
|
||||
for (const s of shares.values()) {
|
||||
if (s.roomId === roomId && s.turn === turn) return s;
|
||||
}
|
||||
let id = "";
|
||||
for (let i = 0; i < SHARE_ID_LENGTH; i++) {
|
||||
id += SHARE_ALPHABET[randomInt(SHARE_ALPHABET.length)];
|
||||
}
|
||||
const share: Share = { id, roomId, turn, createdAt: new Date().toISOString() };
|
||||
shares.set(id, share);
|
||||
appendFileSync(sharesFile(), JSON.stringify(share) + "\n", "utf8");
|
||||
return share;
|
||||
}
|
||||
|
||||
export function getShare(id: string): Share | undefined {
|
||||
return shares.get(id);
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
// command. Because the engine is deterministic, replaying a file rebuilds
|
||||
// the exact game state — server restarts lose nothing.
|
||||
|
||||
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync } from "node:fs";
|
||||
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
export interface RoomMetaLine {
|
||||
@@ -58,7 +58,20 @@ export interface ChatLine {
|
||||
at: string;
|
||||
}
|
||||
|
||||
export type RoomLine = RoomMetaLine | JoinLine | StartLine | CommandLine | ChatLine;
|
||||
export interface KickLine {
|
||||
kind: "kick";
|
||||
/** The seat the host removed from an unstarted room. */
|
||||
name: string;
|
||||
at: string;
|
||||
}
|
||||
|
||||
export interface AbandonLine {
|
||||
kind: "abandon";
|
||||
by: string;
|
||||
at: string;
|
||||
}
|
||||
|
||||
export type RoomLine = RoomMetaLine | JoinLine | StartLine | CommandLine | ChatLine | KickLine | AbandonLine;
|
||||
|
||||
const DATA_DIR = process.env.WIZWAR_DATA_DIR ?? join(process.cwd(), "data", "rooms");
|
||||
|
||||
@@ -103,3 +116,13 @@ export function readAllRooms(): Map<string, RoomLine[]> {
|
||||
}
|
||||
return rooms;
|
||||
}
|
||||
|
||||
/** Retire an abandoned room's ledger to the graveyard — never deleted,
|
||||
* only moved out of the living rooms directory. */
|
||||
export function archiveRoomFile(roomId: string): void {
|
||||
const src = fileFor(roomId);
|
||||
if (!existsSync(src)) return;
|
||||
const graveyard = join(DATA_DIR, "..", "rooms-abandoned");
|
||||
mkdirSync(graveyard, { recursive: true });
|
||||
renameSync(src, join(graveyard, `${roomId}.${Date.now()}.jsonl`));
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 237 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 169 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 111 KiB |
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { attentionLabel, net, spellName } from "./net.svelte";
|
||||
import Board from "./Board.svelte";
|
||||
import LiveFirstPerson from "./LiveFirstPerson.svelte";
|
||||
import { colorIndexOf, PLAYER_COLORS, wizardColor } from "./colors";
|
||||
import Faq from "./Faq.svelte";
|
||||
import Card from "./Card.svelte";
|
||||
@@ -8,12 +9,13 @@
|
||||
import Replay from "./Replay.svelte";
|
||||
import FxGallery from "./FxGallery.svelte";
|
||||
import TokenGallery from "./TokenGallery.svelte";
|
||||
import FpvWorkshop from "./fpv/FpvWorkshop.svelte";
|
||||
import { scheduleFx, type BoardFx } from "./fx";
|
||||
import { prefs, savePrefs } from "./prefs.svelte";
|
||||
import { CREATURE_ART, objectArt, TERRAIN_ART, tokenArt } from "./art";
|
||||
import { local } from "./local.svelte";
|
||||
import { allCardDefs, cardDef, isNumberCard, isPermanentDuration, SIDES, stepTarget, cellKey, edgeKey, isMovableObject, sightedCellsFor, stackSightTrace, type GameView, eligibleCellsFor } from "@wizwar/engine";
|
||||
import type { CardInstance, Side } from "@wizwar/engine";
|
||||
import { allCardDefs, cardDef, dreadDistance, isNumberCard, isPermanentDuration, SIDES, stepTarget, cellKey, edgeKey, isMovableObject, sightedCellsFor, stackSightTrace, type GameView, eligibleCellsFor } from "@wizwar/engine";
|
||||
import type { CardInstance, GameEvent, Side } from "@wizwar/engine";
|
||||
|
||||
net.connect();
|
||||
net.startGamePolling();
|
||||
@@ -41,6 +43,7 @@
|
||||
/** Which pending attack the player has already acknowledged (modal dismissed). */
|
||||
const fxWorkshop = new URLSearchParams(location.search).has("fx");
|
||||
const tokenWorkshop = new URLSearchParams(location.search).has("tokens");
|
||||
const fpvWorkshop = new URLSearchParams(location.search).has("fpv");
|
||||
let attackNoticeSeen = $state<string | null>(null);
|
||||
/** The interruption fanfare, dismissed once per window. */
|
||||
let momentSeen = $state(false);
|
||||
@@ -49,20 +52,88 @@
|
||||
});
|
||||
/** Live spell flourishes on the board (cosmetic, self-expiring). */
|
||||
let boardFx = $state<BoardFx[]>([]);
|
||||
/** The live pane's feed: each event batch, numbered, with its view. */
|
||||
let fpBatch = $state<{ n: number; events: GameEvent[]; view: GameView } | null>(null);
|
||||
/** The pane camera's compass quadrant, worn by the keymap token. */
|
||||
let fpvFacing = $state<Side | null>(null);
|
||||
/** The creature the pane is riding, if any: the ribbon moves to it. */
|
||||
let fpvBody = $state<string | null>(null);
|
||||
let fxCancels: (() => void)[] = [];
|
||||
/** A trap drawn by YOU earns a modal; buried log lines get missed. */
|
||||
let trapNotice = $state<string | null>(null);
|
||||
/** A Ward sprung on YOU earns the same courtesy. */
|
||||
let wardBite = $state<{ owner: string; amount: number } | null>(null);
|
||||
/** Cards revealed to YOU alone (a read mind, plunder, spoils): the
|
||||
* glimpse arrives once, in an event — hold it up until dismissed, or it
|
||||
* is lost to the scrollback. */
|
||||
let cardReveal = $state<{ title: string; note: string; cards: CardInstance[] } | null>(null);
|
||||
/** An easy-to-regret play awaiting its "are you sure?" (see prefs.cautions). */
|
||||
let caution = $state<{ reasons: string[]; go: () => void } | null>(null);
|
||||
function withCaution(reasons: string[], go: () => void) {
|
||||
if (!prefs.cautions || reasons.length === 0) go();
|
||||
else caution = { reasons, go };
|
||||
}
|
||||
/** A move with its regret checks: stepping off your home still laden,
|
||||
* or stepping onto ground that bites. */
|
||||
function tryMove(direction: Side, over?: boolean) {
|
||||
const reasons: string[] = [];
|
||||
if (view && me && me.carriedTreasureId && cellKey(me.position) === cellKey(me.home)) {
|
||||
const t = view.treasures.find((t) => t.id === me!.carriedTreasureId);
|
||||
reasons.push(t && t.owner !== view.you
|
||||
? `you stand on your home with ${t.owner}'s treasure in your arms — drop it first and it scores`
|
||||
: "you stand on your home with your own treasure in your arms — drop it first and it is safe");
|
||||
}
|
||||
if (view && me) {
|
||||
const misted = view.sustained.some((e) => e.cardId === "mist-body" && e.targetId === view.you);
|
||||
const steps = over ? 2 : 1;
|
||||
const dest = { x: me.position.x + (direction === "E" ? steps : direction === "W" ? -steps : 0),
|
||||
y: me.position.y + (direction === "S" ? steps : direction === "N" ? -steps : 0) };
|
||||
const ground = view.squareContents[cellKey(dest)]?.kind;
|
||||
if (!misted && ground) {
|
||||
const bite: Record<string, string> = {
|
||||
thornbush: "the thorns end your turn, cost you the NEXT one, and prick for 1",
|
||||
tacks: "the tacks bite for 3",
|
||||
pit: "the pit: roll a 1 and you fall in, hurt and stuck",
|
||||
ooze: "the ooze burns 1 — and may drop you flat, treasure and all",
|
||||
};
|
||||
if (bite[ground]) reasons.push(bite[ground]);
|
||||
}
|
||||
}
|
||||
withCaution(reasons, () => dispatch({ type: "move", direction, ...(over ? { over: true } : {}) }));
|
||||
}
|
||||
function playFx(events: Parameters<typeof scheduleFx>[0]) {
|
||||
if (!view) return;
|
||||
for (const e of events) {
|
||||
if (e.type === "trapSprung" && e.player === view.you && e.cardId) trapNotice = e.cardId;
|
||||
if (e.type === "handRevealedPrivate" && e.visibleTo === view.you) {
|
||||
cardReveal = {
|
||||
title: `${e.player}'s mind, laid bare`,
|
||||
note: "Commit them to memory — the glimpse does not linger.",
|
||||
cards: e.cards,
|
||||
};
|
||||
}
|
||||
if (e.type === "cardsStolenPrivate" && e.visibleTo === view.you) {
|
||||
const pub = events.find((s) => s.type === "cardsStolen" && s.to === view!.you);
|
||||
cardReveal = {
|
||||
title: pub?.type === "cardsStolen" ? `Plucked from ${pub.from}'s thoughts` : "Plucked from their thoughts",
|
||||
note: "Yours now — already in your hand.",
|
||||
cards: e.cards,
|
||||
};
|
||||
}
|
||||
if (e.type === "handTakenPrivate" && e.visibleTo === view.you) {
|
||||
const pub = events.find((s) => s.type === "handTaken" && s.to === view!.you);
|
||||
cardReveal = {
|
||||
title: pub?.type === "handTaken" ? `The spoils of ${pub.from}` : "The spoils",
|
||||
note: "The fallen wizard's hand — already in yours.",
|
||||
cards: e.cards,
|
||||
};
|
||||
}
|
||||
if (e.type === "wardSprung" && e.victim === view.you) {
|
||||
const dmg = events.find((d) => d.type === "damaged" && d.player === e.victim && d.source === "warded treasure");
|
||||
wardBite = { owner: e.owner, amount: dmg?.type === "damaged" ? dmg.amount : 0 };
|
||||
}
|
||||
}
|
||||
if (view) fpBatch = { n: (fpBatch?.n ?? 0) + 1, events, view };
|
||||
if (!prefs.flourishes) return;
|
||||
fxCancels.push(scheduleFx(
|
||||
events, view,
|
||||
@@ -98,7 +169,7 @@
|
||||
/** Leafing through the face-up discard pile. */
|
||||
let showDiscards = $state(false);
|
||||
let chatDraft = $state("");
|
||||
let botTier = $state("adept");
|
||||
let botTier = $state<typeof prefs.botTier>(prefs.botTier);
|
||||
/** Card whose official FAQ rulings are open. */
|
||||
let faqCardId = $state<string | null>(null);
|
||||
/** A discard-pile card enlarged above the pile. */
|
||||
@@ -208,8 +279,9 @@
|
||||
});
|
||||
|
||||
/** Legal empty slots the lifted sector may land on — mirroring the
|
||||
* engine's relocateSector checks: on the 5-grid, non-negative, vacant,
|
||||
* and leaving every sector adjacent to at least one other. */
|
||||
* engine's relocateSector checks: on the 5-grid, vacant, and leaving
|
||||
* every sector adjacent to at least one other (negative landings are
|
||||
* fine; the maze renormalizes). */
|
||||
const relocateGhosts = $derived.by(() => {
|
||||
if (!view || selectedCard?.cardId !== "relocate-sector" || !pendingSectorOrigin) return null;
|
||||
const origins = view.board.placements.map((p) => p.origin);
|
||||
@@ -227,8 +299,6 @@
|
||||
const k = `${c.x},${c.y}`;
|
||||
if (seen.has(k)) continue;
|
||||
seen.add(k);
|
||||
// Before rev 11 the grid stopped at zero; newer games renormalize.
|
||||
if (view!.deckRev < 11 && (c.x < 0 || c.y < 0)) continue;
|
||||
if (origins.some((o) => o.x === c.x && o.y === c.y)) continue;
|
||||
const next = origins.map((o, j) => (j === idx ? c : o));
|
||||
if (next.every((o, j) => next.some((p, m) => m !== j && adjacent(o, p)))) out.push(c);
|
||||
@@ -255,7 +325,6 @@
|
||||
});
|
||||
clearSelection();
|
||||
}
|
||||
const holdingWard = $derived(view?.yourHand.some((c) => c.cardId === "ward") ?? false);
|
||||
/** A live moment to interrupt: another wizard acts, no attack is pending. */
|
||||
const canInterruptNow = $derived(
|
||||
view != null && !isYourTurn && !view.stack && !view.chaosPending &&
|
||||
@@ -277,12 +346,12 @@
|
||||
"wall-of-fire": "click a corridor line for the fire",
|
||||
"waterwall": "click a corridor line — the wave collapses at once",
|
||||
"pick-lock": "click a locked door",
|
||||
"master-key": "click a locked door",
|
||||
"master-key": "click a locked door — or Display it once and walk through every lock",
|
||||
"jam-lock": "click a door to jam its lock solid",
|
||||
"remove-lock": "click a door to strip its lock for good",
|
||||
"create-door": "click a wall for the new door",
|
||||
"warp-wand": "click a wall to warp it open",
|
||||
"stone-to-water": "click a stone wall, or a stone-filled square",
|
||||
"stone-to-water": "click a stone wall or door, or a stone-filled square",
|
||||
"thumb-of-god": "aim the die at a square in sight — it may drift on impact",
|
||||
"dispel-creation": "click the creation — a square, a creature, or a conjured wall",
|
||||
};
|
||||
@@ -400,7 +469,7 @@
|
||||
// Fully reflecting a SWAP MEET: "lets the other player decide which
|
||||
// objects, if any, will be swapped" — the choice rides the counter.
|
||||
if (youMustRespond && card.cardId === "full-reflection" &&
|
||||
view.stack?.attackCard?.cardId === "swap-meet" && view.deckRev >= 27) {
|
||||
view.stack?.attackCard?.cardId === "swap-meet") {
|
||||
reflectSwapCard = card;
|
||||
reflectSwapMine = null;
|
||||
return;
|
||||
@@ -655,6 +724,15 @@
|
||||
dispatch({ type: "moveCreature", creatureId: selectedCreature, direction: w.from.side });
|
||||
return;
|
||||
}
|
||||
// Standing on a DIMENSIONAL WARP token: clicking the paired token
|
||||
// steps it through.
|
||||
const dw = view.dimWarps.find((d) =>
|
||||
(cellKey(d.a) === cellKey(creature.position) && cellKey(d.b) === cellKey(cell)) ||
|
||||
(cellKey(d.b) === cellKey(creature.position) && cellKey(d.a) === cellKey(cell)));
|
||||
if (dw) {
|
||||
dispatch({ type: "creatureWarpStep", creatureId: selectedCreature });
|
||||
return;
|
||||
}
|
||||
}
|
||||
selectedCreature = null;
|
||||
return;
|
||||
@@ -665,7 +743,7 @@
|
||||
for (const side of SIDES) {
|
||||
const t = stepTarget(view.board, me.position, side);
|
||||
if (t.kind !== "blocked" && cellKey(t.to) === cellKey(cell)) {
|
||||
dispatch({ type: "move", direction: side });
|
||||
tryMove(side);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -681,7 +759,7 @@
|
||||
illusionPrompt = illusionEdgeParts(k);
|
||||
return;
|
||||
}
|
||||
dispatch({ type: "move", direction: side });
|
||||
tryMove(side);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -695,7 +773,7 @@
|
||||
const hazard = view.squareContents[cellKey(mid)]?.kind;
|
||||
if (cellKey(far) === cellKey(cell) &&
|
||||
(hazard === "pit" || hazard === "tacks" || hazard === "ooze")) {
|
||||
dispatch({ type: "move", direction: side, over: true });
|
||||
tryMove(side, true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -765,7 +843,7 @@
|
||||
if (creature && creature.position.x === cell.x && creature.position.y === cell.y) {
|
||||
dispatch({ type: "moveCreature", creatureId: creature.id, direction: side });
|
||||
} else if (me.position.x === cell.x && me.position.y === cell.y) {
|
||||
dispatch({ type: "move", direction: side });
|
||||
tryMove(side);
|
||||
} else {
|
||||
clickCell(cell);
|
||||
}
|
||||
@@ -829,6 +907,16 @@
|
||||
}
|
||||
}
|
||||
|
||||
/** A click in the first-person pane, resolved by the renderer to the
|
||||
* board's own vocabulary — then handled by the very same functions the
|
||||
* top-down board uses, so the pane and the map can never disagree. */
|
||||
function fpvTarget(t: import("./fpv/raycast").FpvTarget) {
|
||||
if (t.kind === "player") clickPlayer(t.id);
|
||||
else if (t.kind === "creature") clickCreature(t.id);
|
||||
else if (t.kind === "edge") clickEdge(t.cell, t.side);
|
||||
else clickCell(t.cell);
|
||||
}
|
||||
|
||||
function clickPlayer(playerId: string) {
|
||||
if (!view || !yourMoment) return;
|
||||
if (selectedCreature) {
|
||||
@@ -881,6 +969,50 @@
|
||||
|
||||
function endTurn() {
|
||||
clearSelection();
|
||||
const reasons: string[] = [];
|
||||
if (view && me) {
|
||||
const left = view.turn.movementAllowance - view.turn.movementUsed;
|
||||
// Unspent legs are no regret when the walk ended ON someone's gold —
|
||||
// stopping there was the point — nor when a pickup or drop already
|
||||
// forfeited them: those points cannot be spent, only mourned.
|
||||
if (left > 0 && !view.turn.actionsEnded &&
|
||||
!treasuresHere.some((t) => t.owner !== view.you)) {
|
||||
reasons.push(`${left} movement point${left === 1 ? "" : "s"} unspent`);
|
||||
}
|
||||
const limit = me.displayed.some((c) => c.cardId === "brainstone") ? 9 : 7;
|
||||
const room = limit - view.yourHand.length;
|
||||
if (drawCount > room) {
|
||||
reasons.push(room <= 0
|
||||
? "your hand is full — the draw will bring nothing"
|
||||
: `your hand only has room for ${room} of the ${drawCount}-card draw`);
|
||||
}
|
||||
if (me.carriedTreasureId && cellKey(me.position) === cellKey(me.home)) {
|
||||
const t = view.treasures.find((t) => t.id === me!.carriedTreasureId);
|
||||
reasons.push(t && t.owner !== view.you
|
||||
? `${t.owner}'s treasure is still in your arms — drop it here and it scores`
|
||||
: "your own treasure is still in your arms — drop it here and it is safe");
|
||||
}
|
||||
// Treasure underfoot un-grabbed — unless the auto-grab preference (or
|
||||
// this caution's own "do it anyway") is about to handle it, or the
|
||||
// grab would be refused (laden, weakened, actions spent, glue, safe,
|
||||
// or your own home where a stolen chest is already scoring).
|
||||
const here = cellKey(me.position);
|
||||
const autoWillGrab = prefs.autoGrab && treasuresHere.length === 1;
|
||||
const weakened = view.sustained.some((e) => e.cardId === "weakness" && e.targetId === view.you);
|
||||
const safed = view.squareContents[here]?.kind === "safe" &&
|
||||
view.squareContents[here]!.createdBy !== view.you && !view.openSafes.includes(here);
|
||||
if (treasuresHere.length > 0 && !autoWillGrab && !me.carriedTreasureId &&
|
||||
!view.turn.actionsEnded && !weakened && !view.gluedCells[here] && !safed &&
|
||||
here !== cellKey(me.home)) {
|
||||
reasons.push(`a treasure lies at your feet, un-grabbed`);
|
||||
}
|
||||
// The draw picker is sticky: a 0 or 1 chosen turns ago quietly starves.
|
||||
const drawRoom = Math.min(2, room);
|
||||
if (drawCount < drawRoom) {
|
||||
reasons.push(`drawing only ${drawCount} when ${drawRoom} would fit`);
|
||||
}
|
||||
}
|
||||
withCaution(reasons, () => {
|
||||
// Preference: a lone grabbable treasure underfoot is picked up on the
|
||||
// way out (pickup ends actions; ending the turn is still legal after).
|
||||
if (prefs.autoGrab && view && me && !me.carriedTreasureId &&
|
||||
@@ -889,6 +1021,7 @@
|
||||
dispatch({ type: "pickUpTreasure" });
|
||||
}
|
||||
dispatch({ type: "endTurn", draw: drawCount });
|
||||
});
|
||||
}
|
||||
|
||||
// SWAP MEET: pick one of your carried items, then one of theirs you can
|
||||
@@ -899,32 +1032,10 @@
|
||||
// Reflecting a SWAP MEET: the reflector picks the trade — or none at all.
|
||||
let reflectSwapCard = $state<CardInstance | null>(null);
|
||||
let reflectSwapMine = $state<string | null>(null);
|
||||
const reflectMyTradables = $derived.by(() => {
|
||||
if (!view || !reflectSwapCard) return [];
|
||||
const opts = new Map<string, string>();
|
||||
for (const c of view.yourHand) {
|
||||
if (tradableHere(c.cardId)) opts.set(c.cardId, cardDef(c.cardId).name);
|
||||
}
|
||||
if (me?.carriedTreasureId) {
|
||||
const t = view.treasures.find((t) => t.id === me.carriedTreasureId);
|
||||
opts.set("treasure", `${t?.owner ?? "the"}'s treasure (carried)`);
|
||||
}
|
||||
return [...opts].map(([key, label]) => ({ key, label }));
|
||||
});
|
||||
const reflectTheirTradables = $derived.by(() => {
|
||||
if (!view || !reflectSwapCard || !view.stack) return [];
|
||||
const them = view.players.find((p) => p.id === view.stack!.attackerId);
|
||||
if (!them) return [];
|
||||
const opts = new Map<string, string>();
|
||||
for (const c of them.displayed) {
|
||||
if (tradableHere(c.cardId)) opts.set(c.cardId, cardDef(c.cardId).name);
|
||||
}
|
||||
if (them.carriedTreasureId && (reflectSwapMine === "treasure" || !me?.carriedTreasureId)) {
|
||||
const t = view.treasures.find((t) => t.id === them.carriedTreasureId);
|
||||
opts.set("treasure", `${t?.owner ?? "the"}'s treasure (carried)`);
|
||||
}
|
||||
return [...opts].map(([key, label]) => ({ key, label }));
|
||||
});
|
||||
const reflectMyTradables = $derived(reflectSwapCard ? tradablesOf(null, null) : []);
|
||||
const reflectTheirTradables = $derived(
|
||||
reflectSwapCard && view?.stack ? tradablesOf(view.stack.attackerId, reflectSwapMine) : [],
|
||||
);
|
||||
function castReflectSwap(pair: string) {
|
||||
if (!reflectSwapCard) return;
|
||||
dispatch({
|
||||
@@ -934,38 +1045,41 @@
|
||||
reflectSwapCard = null;
|
||||
reflectSwapMine = null;
|
||||
}
|
||||
// Older rooms' rules trade only object-typed cards (rev < 26): the picker
|
||||
// must offer exactly what that room's engine will honor.
|
||||
function tradableHere(cardId: string): boolean {
|
||||
if (!view) return false;
|
||||
return view.deckRev >= 26 ? isMovableObject(cardId) : cardDef(cardId).cardType === "object";
|
||||
return view != null && isMovableObject(cardId);
|
||||
}
|
||||
const myTradables = $derived.by(() => {
|
||||
/** What one side can put on a trade table: hand (yours) or displayed
|
||||
* cards (theirs), plus a carried treasure. Their treasure is claimable
|
||||
* only when yours is going the other way or your arms are free —
|
||||
* `pairedPick` is what you have already staked. */
|
||||
function tradablesOf(themId: string | null, pairedPick: string | null): { key: string; label: string }[] {
|
||||
if (!view) return [];
|
||||
const opts = new Map<string, string>();
|
||||
const treasureEntry = (treasureId: string) => {
|
||||
const t = view!.treasures.find((t) => t.id === treasureId);
|
||||
opts.set("treasure", `${t?.owner ?? "someone"}'s treasure (carried)`);
|
||||
};
|
||||
if (themId === null) {
|
||||
for (const c of view.yourHand) {
|
||||
if (tradableHere(c.cardId)) opts.set(c.cardId, cardDef(c.cardId).name);
|
||||
}
|
||||
if (me?.carriedTreasureId) {
|
||||
const t = view.treasures.find((t) => t.id === me.carriedTreasureId);
|
||||
opts.set("treasure", `${t?.owner ?? "the"}'s treasure (carried)`);
|
||||
}
|
||||
return [...opts].map(([key, label]) => ({ key, label }));
|
||||
});
|
||||
const theirTradables = $derived.by(() => {
|
||||
if (!view || !swapMeetTarget) return [];
|
||||
const them = view.players.find((p) => p.id === swapMeetTarget);
|
||||
if (me?.carriedTreasureId) treasureEntry(me.carriedTreasureId);
|
||||
} else {
|
||||
const them = view.players.find((p) => p.id === themId);
|
||||
if (!them) return [];
|
||||
const opts = new Map<string, string>();
|
||||
for (const c of them.displayed) {
|
||||
if (tradableHere(c.cardId)) opts.set(c.cardId, cardDef(c.cardId).name);
|
||||
}
|
||||
// Their treasure is claimable unless your arms are already full.
|
||||
if (them.carriedTreasureId && (swapMine === "treasure" || !me?.carriedTreasureId)) {
|
||||
const t = view.treasures.find((t) => t.id === them.carriedTreasureId);
|
||||
opts.set("treasure", `${t?.owner ?? "the"}'s treasure (carried)`);
|
||||
if (them.carriedTreasureId && (pairedPick === "treasure" || !me?.carriedTreasureId)) {
|
||||
treasureEntry(them.carriedTreasureId);
|
||||
}
|
||||
}
|
||||
return [...opts].map(([key, label]) => ({ key, label }));
|
||||
}
|
||||
const myTradables = $derived(tradablesOf(null, null));
|
||||
const theirTradables = $derived.by(() => {
|
||||
if (!swapMeetTarget) return [];
|
||||
return tradablesOf(swapMeetTarget, swapMine);
|
||||
});
|
||||
function castSwapMeet(theirs: string) {
|
||||
if (!selectedCard || !swapMeetTarget || !swapMine) return;
|
||||
@@ -1041,10 +1155,13 @@
|
||||
: [],
|
||||
);
|
||||
const carryingTreasure = $derived(me?.carriedTreasureId != null);
|
||||
const treasureHere = $derived(
|
||||
view != null && me != null &&
|
||||
view.treasures.some((t) => t.position && t.position.x === me.position.x &&
|
||||
t.position.y === me.position.y && !t.carriedBy),
|
||||
const treasureHere = $derived(treasuresHere.length > 0);
|
||||
/** Co-located carriers STRENGTH lets you wrestle (the tear is an attack). */
|
||||
const tearTargets = $derived(
|
||||
view && me && view.sustained.some((e) => e.cardId === "strength" && e.targetId === view.you)
|
||||
? view.players.filter((p) => p.alive && p.id !== view.you && p.carriedTreasureId &&
|
||||
cellKey(p.position) === cellKey(me!.position))
|
||||
: [],
|
||||
);
|
||||
/** Squares a selected L.O.S./ADJACENT card can reach; null = no dimming. */
|
||||
// While an LOS attack sits on the stack, draw the line it traveled — the
|
||||
@@ -1104,10 +1221,18 @@
|
||||
);
|
||||
|
||||
let chronicleEl = $state<HTMLElement | null>(null);
|
||||
/** The chronicle follows the newest line only while the reader is at
|
||||
* the bottom; scrolled back into history, it holds still. */
|
||||
let chronicleStick = true;
|
||||
function onChronicleScroll() {
|
||||
if (!chronicleEl) return;
|
||||
chronicleStick =
|
||||
chronicleEl.scrollHeight - chronicleEl.scrollTop - chronicleEl.clientHeight < 60;
|
||||
}
|
||||
$effect(() => {
|
||||
void net.log.length;
|
||||
void local.log.length;
|
||||
if (chronicleEl) chronicleEl.scrollTop = chronicleEl.scrollHeight;
|
||||
if (chronicleEl && chronicleStick) chronicleEl.scrollTop = chronicleEl.scrollHeight;
|
||||
});
|
||||
|
||||
// Tab title + favicon carry the turn signal even from another tab.
|
||||
@@ -1145,7 +1270,9 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if tokenWorkshop}
|
||||
{#if fpvWorkshop}
|
||||
<FpvWorkshop />
|
||||
{:else if tokenWorkshop}
|
||||
<TokenGallery />
|
||||
{:else if fxWorkshop}
|
||||
<FxGallery />
|
||||
@@ -1154,15 +1281,21 @@
|
||||
<header class="masthead">
|
||||
<span class="mast-title">Wiz-War</span>
|
||||
<span class="mast-sub">sixth edition</span>
|
||||
<span class="mast-version" title="deployed">{__BUILD_STAMP__}</span>
|
||||
<button class="mast-leave" title="preferences" onclick={() => (prefsOpen = !prefsOpen)}>⚙ preferences</button>
|
||||
{#if local.active}
|
||||
<span class="mast-room">hotseat</span>
|
||||
<button class="mast-leave" onclick={() => local.leave()}>set the game aside</button>
|
||||
<button class="mast-leave" onclick={() => local.abandon()}>abandon game</button>
|
||||
{:else if net.roomId}
|
||||
<span class="mast-room">room <b>{net.roomId}</b></span>
|
||||
<span class="mast-room">{net.spectating ? "watching" : "room"} <b>{net.roomId}</b></span>
|
||||
{#if net.audience > 0}
|
||||
<span class="mast-audience" title="the Peanut Gallery">👁 {net.audience}</span>
|
||||
{/if}
|
||||
{#if !net.spectating}
|
||||
<button class="mast-leave" onclick={() => net.requestTransferCode()}>transfer seat</button>
|
||||
<button class="mast-leave" onclick={() => net.leave()}>leave table</button>
|
||||
{/if}
|
||||
<button class="mast-leave" onclick={() => net.leave()}>{net.spectating ? "leave the gallery" : "leave table"}</button>
|
||||
{/if}
|
||||
<button class="mast-leave" class:mast-help-solo={!net.roomId} onclick={() => { helpTab = "play"; showHelp = true; }}>
|
||||
help & rules
|
||||
@@ -1268,18 +1401,40 @@
|
||||
<button class="stamp tiny" class:lit={prefs.art === "photo"}
|
||||
onclick={() => setPref("art", "photo")}>cardboard</button>
|
||||
<button class="stamp tiny" class:lit={prefs.art === "drawn"}
|
||||
onclick={() => setPref("art", "drawn")}>hand-drawn</button>
|
||||
onclick={() => setPref("art", "drawn")}>alternate</button>
|
||||
</div>
|
||||
<label class="pref-row">
|
||||
<label class="pref-row pref-check">
|
||||
<input type="checkbox" checked={prefs.autoGrab}
|
||||
onchange={(e) => setPref("autoGrab", e.currentTarget.checked)} />
|
||||
<span>Ending my turn on a lone treasure picks it up (never on my own home)</span>
|
||||
</label>
|
||||
<label class="pref-row">
|
||||
<label class="pref-row pref-check">
|
||||
<input type="checkbox" checked={prefs.flourishes}
|
||||
onchange={(e) => setPref("flourishes", e.currentTarget.checked)} />
|
||||
<span>Spell flourishes (the animated effects)</span>
|
||||
</label>
|
||||
<label class="pref-row pref-check">
|
||||
<input type="checkbox" checked={prefs.liveFp}
|
||||
onchange={(e) => setPref("liveFp", e.currentTarget.checked)} />
|
||||
<span>Through your eyes: a first-person pane above the board during play</span>
|
||||
</label>
|
||||
<label class="pref-row pref-check">
|
||||
<input type="checkbox" checked={prefs.instantReplay}
|
||||
onchange={(e) => setPref("instantReplay", e.currentTarget.checked)} />
|
||||
<span>Instant replay: notable chronicle lines offer a 👁 that relives the turn</span>
|
||||
</label>
|
||||
<label class="pref-row pref-check">
|
||||
<input type="checkbox" checked={prefs.cautions}
|
||||
onchange={(e) => setPref("cautions", e.currentTarget.checked)} />
|
||||
<span>Ask "are you sure?" before easy-to-regret plays (unspent movement, capped draws, undropped deliveries)</span>
|
||||
</label>
|
||||
<div class="pref-row">
|
||||
<span>Automatons default to</span>
|
||||
{#each ["apprentice", "adept", "archmage"] as const as t (t)}
|
||||
<button class="stamp tiny" class:lit={prefs.botTier === t}
|
||||
onclick={() => { setPref("botTier", t); botTier = t; }}>{t}</button>
|
||||
{/each}
|
||||
</div>
|
||||
<label class="pref-row">
|
||||
<span>Wizard name</span>
|
||||
<input class="setup-input pref-name" maxlength="20" value={prefs.wizardName}
|
||||
@@ -1345,6 +1500,45 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if caution}
|
||||
<div class="scrim attack-scrim" role="alertdialog" aria-label="are you sure?">
|
||||
<div class="attack-notice">
|
||||
<div class="attack-headline">Hold on —</div>
|
||||
<div class="attack-power">
|
||||
{#each caution.reasons as r (r)}
|
||||
<div class="caution-reason">{r}</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="attack-actions">
|
||||
<button class="stamp big" onclick={() => { const go = caution!.go; caution = null; go(); }}>Do it anyway</button>
|
||||
<button class="hint-cancel" onclick={() => (caution = null)}>never mind</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if cardReveal}
|
||||
<div class="scrim attack-scrim" role="alertdialog" aria-label="cards revealed to you">
|
||||
<div class="attack-notice mind-read">
|
||||
<div class="attack-headline">{cardReveal.title}</div>
|
||||
<div class="mind-cards">
|
||||
{#each cardReveal.cards as card (card.instanceId)}
|
||||
<Card {card} onfaq={(id) => (faqCardId = id)} />
|
||||
{/each}
|
||||
{#if cardReveal.cards.length === 0}
|
||||
<div class="attack-power">Nothing — their thoughts are empty-handed.</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="attack-power">
|
||||
{cardReveal.cards.length > 0
|
||||
? `${cardReveal.cards.length} card${cardReveal.cards.length === 1 ? "" : "s"} — ${cardReveal.note}`
|
||||
: ""}
|
||||
</div>
|
||||
<button class="stamp big" onclick={() => (cardReveal = null)}>Enough</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if wardBite}
|
||||
<div class="scrim attack-scrim" role="alertdialog" aria-label="a warded treasure">
|
||||
<div class="attack-notice">
|
||||
@@ -1460,8 +1654,13 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if net.catchUp && net.catchUp.length > 0}
|
||||
<Replay steps={net.catchUp} onclose={() => net.closeCatchUp()} />
|
||||
{#if net.moment && net.moment.steps.length > 0}
|
||||
<Replay steps={net.moment.steps} moment pov={net.moment.owner}
|
||||
onshare={() => net.requestShare()} onclose={() => net.closeMoment()} />
|
||||
{:else if net.catchUp && net.catchUp.length > 0}
|
||||
<Replay steps={net.catchUp}
|
||||
onshare={net.catchUp[0]?.seq === 0 ? () => net.requestShare(-1) : null}
|
||||
onclose={() => net.closeCatchUp()} />
|
||||
{:else if local.replaySteps && local.replaySteps.length > 0}
|
||||
<Replay steps={local.replaySteps} onclose={() => (local.replaySteps = null)} />
|
||||
{/if}
|
||||
@@ -1549,6 +1748,11 @@
|
||||
Join
|
||||
</button>
|
||||
</div>
|
||||
<div class="gallery-row">
|
||||
<button class="hint-cancel" disabled={!joinCode.trim()} onclick={() => net.watch(joinCode)}>
|
||||
👁 …or just watch that game from the Peanut Gallery
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="hotseat-row">
|
||||
<span class="hotseat-label">or play here, passing the device:</span>
|
||||
@@ -1633,9 +1837,16 @@
|
||||
<span class="dot" style:background="#b3a687"></span>
|
||||
{/if}
|
||||
{p}{p === net.hostId ? " — host" : ""}{net.roomBots[p] ? ` ⚙ ${net.roomBots[p]}` : chosen === undefined ? " — choosing…" : ""}
|
||||
{#if net.you === net.hostId && p !== net.hostId}
|
||||
<button class="stamp tiny kick" title="clear this seat"
|
||||
onclick={() => net.kickSeat(p)}>✕</button>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{#if net.spectating}
|
||||
<p class="waiting">👁 You watch from the Peanut Gallery. Waiting for the boards to flip…</p>
|
||||
{:else}
|
||||
<div class="standee-row" role="group" aria-label="choose your wizard">
|
||||
{#each [0, 1, 2, 3, 4, 5] as c (c)}
|
||||
{@const takenBy = Object.entries(net.roomColors).find(([, v]) => v === c)?.[0]}
|
||||
@@ -1653,10 +1864,13 @@
|
||||
{/each}
|
||||
</div>
|
||||
{#if net.you === net.hostId}
|
||||
<button class="stamp tiny abandon" title="dissolve this room for everyone"
|
||||
onclick={() => net.abandonRoom()}>abandon room</button>
|
||||
{#if net.players.length < 6}
|
||||
<span class="bot-row">
|
||||
<span class="bot-label">⚙ seat a</span>
|
||||
<select class="tier-pick" bind:value={botTier} aria-label="automaton difficulty">
|
||||
<select class="tier-pick" bind:value={botTier} aria-label="automaton difficulty"
|
||||
onchange={() => setPref("botTier", botTier)}>
|
||||
<option value="apprentice">apprentice</option>
|
||||
<option value="adept">adept</option>
|
||||
<option value="archmage">archmage</option>
|
||||
@@ -1681,11 +1895,27 @@
|
||||
{:else}
|
||||
<p class="waiting">Waiting for {net.hostId} to flip the boards…</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
{:else if view}
|
||||
<div class="game">
|
||||
<section class="board-zone">
|
||||
<div class="table-stack" class:fpv-primary={prefs.liveFp && !!view.you && !net.spectating}>
|
||||
{#if prefs.liveFp && view.you && !net.spectating}
|
||||
<LiveFirstPerson {view} batch={fpBatch} onhide={() => setPref("liveFp", false)}
|
||||
onstride={(side) => tryMove(side)} canStride={yourMoment}
|
||||
ontarget={fpvTarget} onfacing={(s) => (fpvFacing = s)} {litCells}
|
||||
onpickup={(w) => dispatch(w.kind === "treasure"
|
||||
? { type: "pickUpTreasure", treasureId: w.id }
|
||||
: { type: "pickUpObject", instanceId: w.id })}
|
||||
onCreatureMove={(creatureId, direction) => dispatch({ type: "moveCreature", creatureId, direction })}
|
||||
onCreatureAttack={(creatureId, targetId) => dispatch({ type: "creatureAttack", creatureId, targetId })}
|
||||
onbody={(id) => (fpvBody = id)}
|
||||
onWarpStep={(creatureId) => dispatch(creatureId
|
||||
? { type: "creatureWarpStep", creatureId }
|
||||
: { type: "warpStep" })} />
|
||||
{/if}
|
||||
<Board
|
||||
{view}
|
||||
edgeSelectMode={edgeSelectMode && yourMoment}
|
||||
@@ -1706,7 +1936,10 @@
|
||||
onEdgePeek={(tip) => (peekEdge = tip)}
|
||||
{litCells}
|
||||
{sightTrace}
|
||||
povFacing={prefs.liveFp && view.you && !net.spectating ? fpvFacing : null}
|
||||
povCreatureId={prefs.liveFp && !net.spectating ? fpvBody : null}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside class="paper-rail">
|
||||
@@ -1759,9 +1992,6 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if view.yourWardArmed}
|
||||
<div class="slip ambush-note">🗡 Your ward is set — a thief who grabs your treasure bleeds for 3.</div>
|
||||
{/if}
|
||||
{#if youMustShield}
|
||||
<div class="slip urgent">
|
||||
Chaos comes for your hand — tap your Full Shield to sit out, or
|
||||
@@ -1804,7 +2034,7 @@
|
||||
{/if}
|
||||
{#if isYourTurn && view.players.some((p) => p.alive && p.id !== view.you &&
|
||||
view.sustained.some((e) => e.cardId === "fear" && e.targetId === p.id) && me &&
|
||||
Math.abs(p.position.x - me.position.x) + Math.abs(p.position.y - me.position.y) <= 3)}
|
||||
dreadDistance(view.board, p.position, me.position) <= 3)}
|
||||
<div class="slip urgent">
|
||||
😱 An unnatural dread grips you — FEAR requires you to move away
|
||||
this turn if you can, even if your only escape is a card
|
||||
@@ -1814,11 +2044,12 @@
|
||||
{/if}
|
||||
{#if isYourTurn && view.sustained.some((e) => e.cardId === "idiot" && e.targetId === view.you)}
|
||||
<div class="slip urgent">
|
||||
🥴 "What am I doing here…?" — IDIOT drives you to the nearest of
|
||||
your own treasures. Spend your movement marching (the maze will
|
||||
steer your steps); you may cast only counteractions and spells
|
||||
that speed the trip. It lifts when you stand on your treasure.
|
||||
(The marching is yours to honor.)
|
||||
🥴 "What am I doing here…?" — IDIOT drives you to the nearest
|
||||
of your own treasures. Spend your movement marching toward it;
|
||||
you may cast only counteractions, spells that speed the trip,
|
||||
and Drop Object to shake your own treasure from a thief's
|
||||
arms. It lifts when you stand on your treasure. (The marching
|
||||
is yours to honor.)
|
||||
</div>
|
||||
{/if}
|
||||
{#if actionsSpent}
|
||||
@@ -1883,10 +2114,23 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chronicle" aria-label="game log" bind:this={chronicleEl}>
|
||||
{#each (local.active ? local.log : net.log).slice(-60) as line, i (i)}
|
||||
<div class="chronicle" aria-label="game log" bind:this={chronicleEl}
|
||||
onscroll={onChronicleScroll}>
|
||||
{#if local.active}
|
||||
{#each local.log as line, i (i)}
|
||||
<div class:table-talk={line.startsWith("\u{1F4AC}")}>{line}</div>
|
||||
{/each}
|
||||
{:else}
|
||||
{#each net.log as line, i (i)}
|
||||
<div class:table-talk={line.text.startsWith("\u{1F4AC}")}>
|
||||
{line.text}
|
||||
{#if line.notable && line.turn !== null && prefs.instantReplay && !net.spectating}
|
||||
<button class="moment-eye" title="relive this turn"
|
||||
onclick={() => net.requestMoment(line.turn!)}>👁</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{#if local.active && local.viewerId}
|
||||
<div class="say-box">
|
||||
@@ -1894,7 +2138,7 @@
|
||||
onclick={() => local.rollTableDie()}>🎲 roll the die</button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if !local.active && net.roomId}
|
||||
{#if !local.active && net.roomId && !net.spectating}
|
||||
<form class="say-box" onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const t = chatDraft.trim();
|
||||
@@ -2019,6 +2263,10 @@
|
||||
{#if selectedCard?.cardId === "pick-lock" || selectedCard?.cardId === "master-key"}
|
||||
<label class="inline"><input type="checkbox" bind:checked={holdDoor} /> hold the door open</label>
|
||||
{/if}
|
||||
{#if selectedCard?.cardId === "master-key" && isYourTurn &&
|
||||
!me?.displayed.some((c) => c.instanceId === selectedCard!.instanceId)}
|
||||
<button class="stamp tiny" onclick={castSelfWithNumber}>Display the key</button>
|
||||
{/if}
|
||||
{#if selectedCard?.cardId === "rotate-sector"}
|
||||
<label class="inline"><input type="checkbox" bind:checked={rotateCW} /> clockwise</label>
|
||||
<span>— click the sector</span>
|
||||
@@ -2133,10 +2381,11 @@
|
||||
onclick={() => dispatch({ type: "pickUpObject", instanceId: obj.instanceId })}>
|
||||
Pick up {cardDef(obj.cardId).name}</button>
|
||||
{/each}
|
||||
{#if holdingWard && view.deckRev < 31}
|
||||
<button class="stamp" onclick={() => dispatch({ type: "armWard", armed: !view.yourWardArmed })}>
|
||||
{view.yourWardArmed ? "Stand down the ward" : "Set the ward"}</button>
|
||||
{/if}
|
||||
{#each tearTargets as t (t.id)}
|
||||
<button class="stamp" disabled={view.turn.attackUsed || view.turn.round === 1}
|
||||
onclick={() => dispatch({ type: "tearTreasure", targetId: t.id })}>
|
||||
Tear the treasure from {t.id}'s arms</button>
|
||||
{/each}
|
||||
<button class="stamp" class:primary={punchWallMode} disabled={view.turn.attackUsed && !punchWallMode}
|
||||
onclick={() => (punchWallMode = !punchWallMode)}>
|
||||
{punchWallMode ? "Click the wall to punch — or cancel" : "Punch a wall…"}</button>
|
||||
@@ -2191,6 +2440,9 @@
|
||||
{/if}
|
||||
|
||||
<div class="hand" class:spent={actionsSpent && !discardMode && discardSelection.size === 0} aria-label="your hand">
|
||||
{#if net.spectating}
|
||||
<span class="gallery-note">👁 You watch from the Peanut Gallery — hands stay secret, even from you.</span>
|
||||
{/if}
|
||||
{#each view.phase === "finished" ? [] : view.yourHand as card (card.instanceId)}
|
||||
<Card
|
||||
{card}
|
||||
@@ -2259,8 +2511,32 @@
|
||||
text-transform: uppercase;
|
||||
color: #8d8672;
|
||||
}
|
||||
.kick { margin-left: 0.4rem; color: #a04545; }
|
||||
.abandon { margin: 0.4rem 0; color: #a04545; border-color: #a04545; }
|
||||
.mast-version {
|
||||
font-family: "Courier Prime", monospace;
|
||||
font-size: 0.65rem;
|
||||
letter-spacing: 0.06em;
|
||||
color: #6b6454;
|
||||
}
|
||||
.mast-room { margin-left: auto; font-size: 0.85rem; color: #a49c86; }
|
||||
.mast-room b { color: #e9e1cb; letter-spacing: 0.12em; }
|
||||
.mast-audience { font-size: 0.85rem; color: #a49c86; white-space: nowrap; }
|
||||
.mind-read { max-width: min(92vw, 46rem); }
|
||||
.caution-reason { padding: 0.15rem 0; }
|
||||
.caution-reason + .caution-reason { border-top: 1px dotted #4a4536; }
|
||||
.mind-cards {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
justify-content: center;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
padding: 0.4rem 0;
|
||||
}
|
||||
.gallery-note { font-size: 0.85rem; color: #8d8672; font-style: italic; padding: 0.4rem 0; }
|
||||
.gallery-row { margin-top: 0.35rem; text-align: center; }
|
||||
.gallery-row button:disabled { opacity: 0.4; cursor: default; }
|
||||
.mast-status { font-size: 0.8rem; color: #c98a2a; }
|
||||
.mast-leave {
|
||||
background: none;
|
||||
@@ -2560,11 +2836,52 @@
|
||||
.pref-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin: 0.55rem 0;
|
||||
gap: 0.6rem;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0.55rem 0.1rem;
|
||||
font-size: 0.85rem;
|
||||
color: #43331f;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
/* Rules divide the checkbox stanza only — never framing it, never
|
||||
around the control rows. */
|
||||
.pref-check + .pref-check { border-top: 1.5px solid #6b5a41; }
|
||||
/* Checkboxes wear the table's own ink: a stamp-bordered box, checked
|
||||
solid with a parchment tick — the native widget never matches the
|
||||
parchment. */
|
||||
.pref-row input[type="checkbox"] {
|
||||
appearance: none;
|
||||
flex: none;
|
||||
width: 1.05rem;
|
||||
height: 1.05rem;
|
||||
border: 1.5px solid #43331f;
|
||||
border-radius: 3px;
|
||||
background: #f6efdd;
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
margin: 0;
|
||||
}
|
||||
.pref-row input[type="checkbox"]:checked {
|
||||
background: #43331f;
|
||||
}
|
||||
.pref-row input[type="checkbox"]:checked::before {
|
||||
content: "✓";
|
||||
color: #f6efdd;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1;
|
||||
font-weight: 700;
|
||||
}
|
||||
.pref-row span { flex: 1; }
|
||||
/* Control rows read as a two-column table: the label holds the left
|
||||
column, its controls right-align into the second. */
|
||||
.pref-row > span:first-child { flex: 0 1 auto; }
|
||||
.pref-row > span:first-child + .stamp,
|
||||
.pref-row > span:first-child + input,
|
||||
.pref-row > span:first-child + .pref-swatch { margin-left: auto; }
|
||||
.pref-note { font-size: 0.72rem; color: #8a7a5e; margin-top: 0.6rem; }
|
||||
.pref-name { max-width: 11rem; }
|
||||
.pref-swatch {
|
||||
@@ -2597,6 +2914,30 @@
|
||||
min-height: 0;
|
||||
}
|
||||
.board-zone { display: flex; align-items: flex-start; justify-content: center; min-width: 0; }
|
||||
/* The first-person pane wears exactly the board's width, right above
|
||||
* it: the stack shrink-wraps to the board, and width:0 + min-width
|
||||
* keeps the pane out of the intrinsic sizing so the BOARD alone
|
||||
* decides how wide the column is. */
|
||||
.table-stack {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
.table-stack :global(.live-fp) { width: 0; min-width: 100%; }
|
||||
.table-stack :global(.live-fp .fpv-canvas) { width: 100%; height: auto; }
|
||||
/* FPV play: the 3D view is the primary window into the game; the
|
||||
* top-down board shrinks to a keymap beneath it. */
|
||||
.table-stack.fpv-primary {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
max-width: 64rem;
|
||||
}
|
||||
.board-zone .table-stack.fpv-primary :global(svg.board) {
|
||||
max-height: 30dvh;
|
||||
align-self: center;
|
||||
}
|
||||
.board-zone :global(svg.board) {
|
||||
max-height: calc(100dvh - 21.5rem);
|
||||
width: auto;
|
||||
@@ -2718,6 +3059,15 @@
|
||||
box-shadow: 0 2px 7px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
.chronicle div + div { margin-top: 0.05rem; }
|
||||
.moment-eye {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.7rem;
|
||||
padding: 0 0.15rem;
|
||||
opacity: 0.55;
|
||||
}
|
||||
.moment-eye:hover { opacity: 1; }
|
||||
|
||||
.table-edge {
|
||||
margin-top: 0.9rem;
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
import type { Component } from "svelte";
|
||||
import type { GameView, SightTrace } from "@wizwar/engine";
|
||||
import type { Side } from "@wizwar/engine";
|
||||
import { dreadDistance } from "@wizwar/engine";
|
||||
import type { BoardFx } from "./fx";
|
||||
import { colorIndexOf as sharedColorIndex, wizardColor } from "./colors";
|
||||
import { CREATURE_ART, objectArt, TERRAIN_ART, tokenArt } from "./art";
|
||||
import EdgeGlyph from "./EdgeGlyph.svelte";
|
||||
import EdgeGlyph, { type LockState } from "./EdgeGlyph.svelte";
|
||||
import FirewallEdge from "./FirewallEdge.svelte";
|
||||
import IllusionShimmer from "./IllusionShimmer.svelte";
|
||||
import SightTraceOverlay from "./SightTraceOverlay.svelte";
|
||||
@@ -22,6 +23,8 @@
|
||||
onCellClick,
|
||||
onEdgeClick,
|
||||
onPlayerClick,
|
||||
povFacing = null,
|
||||
povCreatureId = null,
|
||||
onCreatureClick,
|
||||
onWarpClick,
|
||||
onCellPeek,
|
||||
@@ -42,6 +45,11 @@
|
||||
onCellClick?: (cell: { x: number; y: number }) => void;
|
||||
onEdgeClick?: (cell: { x: number; y: number }, side: Side) => void;
|
||||
onPlayerClick?: (playerId: string) => void;
|
||||
/** Which way YOUR first-person camera faces: a wedge on your token
|
||||
* keeps the keymap and the pane pointing the same way. */
|
||||
povFacing?: "N" | "E" | "S" | "W" | null;
|
||||
/** While the pane rides a creature, the ribbon moves to ITS token. */
|
||||
povCreatureId?: string | null;
|
||||
onCreatureClick?: (creatureId: string) => void;
|
||||
onWarpClick?: (cell: { x: number; y: number }, side: Side) => void;
|
||||
/** Long-press on a square: read the card behind whatever occupies it. */
|
||||
@@ -67,7 +75,7 @@
|
||||
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;
|
||||
sightTrace?: { from: { x: number; y: number }; to: { x: number; y: number }; trace: SightTrace; bend?: { mid: { x: number; y: number }; trace: SightTrace } } | null;
|
||||
} = $props();
|
||||
|
||||
|
||||
@@ -112,6 +120,18 @@
|
||||
}
|
||||
|
||||
// BIG MAN towers; SHRINK dwindles. The token says so at a glance.
|
||||
/** The facing ribbon: a shallow golden triangle hugging a token's
|
||||
* leading edge — base the token's width, rising a quarter of it. */
|
||||
function facingWedge(side: "N" | "E" | "S" | "W", half: number): string {
|
||||
const a = side === "E" ? 0 : side === "S" ? Math.PI / 2 : side === "W" ? Math.PI : -Math.PI / 2;
|
||||
const fx = Math.cos(a), fy = Math.sin(a);
|
||||
const px = -fy, py = fx;
|
||||
const rise = half * 0.5;
|
||||
return `${fx * half + px * half},${fy * half + py * half} ` +
|
||||
`${fx * half - px * half},${fy * half - py * half} ` +
|
||||
`${fx * (half + rise)},${fy * (half + rise)}`;
|
||||
}
|
||||
|
||||
function wizardScale(id: string): number {
|
||||
if (view.sustained.some((e) => e.cardId === "big-man" && e.targetId === id)) return 1.5;
|
||||
if (view.sustained.some((e) => e.cardId === "shrink" && e.targetId === id)) return 0.62;
|
||||
@@ -182,22 +202,16 @@
|
||||
|
||||
// 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.
|
||||
// so the diamond continues from the opposite rim. The engine's own
|
||||
// yardstick, so the aura and the refusal can never disagree.
|
||||
const fearCells = $derived.by(() => {
|
||||
const out = new Set<string>();
|
||||
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);
|
||||
}
|
||||
for (const k of Object.keys(view.board.cells)) {
|
||||
const [x, y] = k.split(",").map(Number) as [number, number];
|
||||
if (dreadDistance(view.board, fp.position, { x, y }) <= 3) out.add(k);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
@@ -395,14 +409,20 @@
|
||||
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"
|
||||
class="token-art small"
|
||||
class="token-art small peekable"
|
||||
role="button" tabindex="-1"
|
||||
onclick={(ev) => { ev.stopPropagation(); onCellPeek?.({ x: gx, y: gy }); }}
|
||||
onkeydown={() => {}}
|
||||
>
|
||||
<title>{o.cardId}</title>
|
||||
</image>
|
||||
{:else}
|
||||
<rect
|
||||
x={gx * CELL + 6 + i * 8} y={gy * CELL + CELL - 16}
|
||||
width={12} height={10} rx="2" class="ground-object"
|
||||
width={12} height={10} rx="2" class="ground-object peekable"
|
||||
role="button" tabindex="-1"
|
||||
onclick={(ev) => { ev.stopPropagation(); onCellPeek?.({ x: gx, y: gy }); }}
|
||||
onkeydown={() => {}}
|
||||
>
|
||||
<title>{o.cardId}</title>
|
||||
</rect>
|
||||
@@ -421,7 +441,7 @@
|
||||
: 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}
|
||||
lock={(e.lock ?? null) as LockState | null}
|
||||
title={lockTitle} damage={view.wallDamage[`${e.kind}:${e.x},${e.y}`] ?? 0}
|
||||
onpeek={onEdgePeek} />
|
||||
{/if}
|
||||
@@ -587,6 +607,9 @@
|
||||
{/if}
|
||||
{/if}
|
||||
</g>
|
||||
{#if povFacing && !povCreatureId && p.id === view.you}
|
||||
<polygon points={facingWedge(povFacing, CELL * 0.3 * wizardScale(p.id))} class="pov-wedge" />
|
||||
{/if}
|
||||
</g>
|
||||
{/each}
|
||||
|
||||
@@ -633,6 +656,10 @@
|
||||
<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>
|
||||
{#if povFacing && povCreatureId === c.id}
|
||||
<!-- The rider's facing ribbon, worn by the mount. -->
|
||||
<polygon points={facingWedge(povFacing, CELL * 0.26)} class="pov-wedge" />
|
||||
{/if}
|
||||
</g>
|
||||
{/each}
|
||||
|
||||
@@ -656,7 +683,7 @@
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
<!-- FEAR's bubble: every square within three WALKED spaces (warps count) -->
|
||||
<!-- FEAR's bubble: the three-space diamond, through walls, wrapping the rim -->
|
||||
{#each fearCells as key (key)}
|
||||
{@const bx = Number(key.split(",")[0])}
|
||||
{@const by = Number(key.split(",")[1])}
|
||||
@@ -666,8 +693,15 @@
|
||||
|
||||
<!-- the sight line an attack traveled, leg by leg through any warp mouth -->
|
||||
{#if sightTrace}
|
||||
{#if sightTrace.bend}
|
||||
<SightTraceOverlay from={sightTrace.from} to={sightTrace.bend.mid} trace={sightTrace.trace} />
|
||||
<SightTraceOverlay from={sightTrace.bend.mid} to={sightTrace.to} trace={sightTrace.bend.trace} />
|
||||
<rect x={sightTrace.bend.mid.x * CELL + CELL / 2 - 6} y={sightTrace.bend.mid.y * CELL + CELL * 0.36 - 6}
|
||||
width={12} height={12} class="sight-corner" transform="rotate(45 {sightTrace.bend.mid.x * CELL + CELL / 2} {sightTrace.bend.mid.y * CELL + CELL * 0.36})" />
|
||||
{:else}
|
||||
<SightTraceOverlay from={sightTrace.from} to={sightTrace.to} trace={sightTrace.trace} />
|
||||
{/if}
|
||||
{/if}
|
||||
<!-- spell flourishes: one sprite component per effect (src/fx-sprites/) -->
|
||||
<g class="fx-layer" aria-hidden="true">
|
||||
{#each effects ?? [] as fx (fx.id)}
|
||||
@@ -757,6 +791,23 @@
|
||||
fill: #6a5c44;
|
||||
pointer-events: none;
|
||||
}
|
||||
.pov-wedge {
|
||||
fill: #ffd23e;
|
||||
stroke: #43331f;
|
||||
stroke-width: 1.2;
|
||||
pointer-events: none;
|
||||
}
|
||||
.peekable { cursor: pointer; pointer-events: all; }
|
||||
.sight-corner {
|
||||
fill: none;
|
||||
stroke: #c9a72a;
|
||||
stroke-width: 2;
|
||||
animation: sight-pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
@keyframes sight-pulse {
|
||||
0%, 100% { opacity: 0.9; }
|
||||
50% { opacity: 0.35; }
|
||||
}
|
||||
.dim-cell {
|
||||
fill: rgba(12, 9, 5, 0.55);
|
||||
pointer-events: none;
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
<script lang="ts" module>
|
||||
/** The color states a door can wear (a wall wears none). */
|
||||
export type LockState = "removed" | "jammed" | "held" | "ajar";
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { holdToPeek } from "./peek";
|
||||
// A wall or door on an edge, exactly as the board draws it. Wall-segment
|
||||
// effects (locks picked, jammed, removed; doors held) are color states of
|
||||
// this one glyph — the game has never used tokens for them.
|
||||
let { x, y, kind, state, lock = null, title = null, damage = 0, onpeek }: {
|
||||
x: number; y: number; kind: "V" | "H";
|
||||
state: "wall" | "door";
|
||||
lock?: "removed" | "jammed" | "held" | "ajar" | null;
|
||||
lock?: LockState | null;
|
||||
title?: string | null;
|
||||
/** Battle damage taken so far (walls fall at 20, doors at 15). */
|
||||
damage?: number;
|
||||
@@ -23,16 +29,7 @@
|
||||
: `${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;
|
||||
}
|
||||
const { press, release } = holdToPeek(() => onpeek?.(tip));
|
||||
</script>
|
||||
|
||||
{#if kind === "V"}
|
||||
|
||||
@@ -2,21 +2,14 @@
|
||||
// A standing WALL OF FIRE: board furniture, not a flourish — it burns for
|
||||
// as long as the spell holds, so the animation is pure CSS on a handful
|
||||
// of shapes (no turbulence filters; those are budgeted for one-shot fx).
|
||||
import { holdToPeek } from "./peek";
|
||||
let { x, y, kind, onpeek }: {
|
||||
x: number; y: number; kind: "V" | "H";
|
||||
/** Press-and-hold (touch has no hover): report what this fire is. */
|
||||
onpeek?: (tip: string) => void;
|
||||
} = $props();
|
||||
const TIP = "a wall of fire — passing through burns for 4";
|
||||
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 { press, release } = holdToPeek(() => onpeek?.(TIP));
|
||||
const uid = $props.id();
|
||||
const CELL = 48;
|
||||
const WALL = 7;
|
||||
@@ -40,7 +33,7 @@
|
||||
</script>
|
||||
|
||||
<g transform={`translate(${cx} ${cy})`} class="fw">
|
||||
<title>a wall of fire — passing through burns for 4</title>
|
||||
<title>{TIP}</title>
|
||||
<defs>
|
||||
<linearGradient id={`fw-bar-${uid}`} x1="0" y1="1" x2="0" y2="0">
|
||||
<stop offset="0" stop-color="#7c1a14" />
|
||||
|
||||
@@ -147,6 +147,17 @@
|
||||
the edition currently in print</a>, and deal seven cards to
|
||||
somebody at a real table.
|
||||
</p>
|
||||
<h3>Behind the curtain</h3>
|
||||
<p>
|
||||
The workshops where this table's pieces are made are open to
|
||||
visitors:
|
||||
the <a href="/?tokens">token workshop</a> shows every token in both
|
||||
arts beside the wall textures and spell sprites;
|
||||
the <a href="/?fx">flourish workshop</a> plays each board effect on
|
||||
demand; and
|
||||
the <a href="/?fpv">first-person workshop</a> walks the maze through
|
||||
a wizard's own eyes (add <code>&demo=1</code> to watch a reel).
|
||||
</p>
|
||||
<h3>Send word</h3>
|
||||
<p>
|
||||
Feedback, bug reports, and faint praise all welcome — ravens fly to
|
||||
|
||||
@@ -0,0 +1,578 @@
|
||||
<script lang="ts">
|
||||
// The living maze through your own eyes: a pane that rides above the
|
||||
// board during play. The board below stays the tactical truth — this
|
||||
// is the experience. The camera carries the reel's instincts: it
|
||||
// walks your strides, turns to your aims and to actors you can see,
|
||||
// cuts away to deeds beyond your sight, and never idles nose-to-brick.
|
||||
// Every live event batch plays through the same fx pipeline the
|
||||
// instant replay uses, so spells, doors, and conjurations perform
|
||||
// here first.
|
||||
import FirstPerson from "./fpv/FirstPerson.svelte";
|
||||
import type { FpvTarget } from "./fpv/raycast";
|
||||
import { objectArt, tokenArt } from "./art";
|
||||
import { fpFxForEvents, type FpFx } from "./fpv/fx3d";
|
||||
import { castRay, SIDE_ANGLE, OPPOSITE } from "./fpv/raycast";
|
||||
import { deepestFacing } from "./fpv/director";
|
||||
import { aimOfEvents, gatherGlides, hurledIn, shortestArc } from "./fpv/director";
|
||||
import { untrack } from "svelte";
|
||||
import type { GameEvent, GameView, Side } from "@wizwar/engine";
|
||||
|
||||
let { view, batch, onhide, onstride = null, canStride = false, ontarget = null, onfacing = null, litCells = null, onpickup = null, onCreatureMove = null, onCreatureAttack = null, onbody = null, onWarpStep = null }: {
|
||||
view: GameView;
|
||||
/** The latest live event batch, numbered so each plays once, with
|
||||
* the view the server sent alongside it. */
|
||||
batch: { n: number; events: GameEvent[]; view: GameView } | null;
|
||||
onhide: () => void;
|
||||
/** Issue a real move command: the pane is the cockpit on your turn. */
|
||||
onstride?: ((side: Side) => void) | null;
|
||||
canStride?: boolean;
|
||||
/** Clicks in the pane resolve to board targets: the pane is not just
|
||||
* the window but the wand hand. */
|
||||
ontarget?: ((t: FpvTarget) => void) | null;
|
||||
/** Reports the camera's compass quadrant whenever it settles on a new
|
||||
* one — the keymap wears it as a wedge on your token. */
|
||||
onfacing?: ((side: Side) => void) | null;
|
||||
/** Cell-card eligibility, shared with the board's dimming aid. */
|
||||
litCells?: Set<string> | null;
|
||||
/** The at-your-feet tray's grab: the pane cannot look down, so what
|
||||
* lies in your own square shows as clickable icons instead. */
|
||||
onpickup?: ((w: { kind: "treasure" | "object"; id: string }) => void) | null;
|
||||
/** Possession: ride a creature's eyes and drive it from the pane. */
|
||||
onCreatureMove?: ((creatureId: string, side: Side) => void) | null;
|
||||
onCreatureAttack?: ((creatureId: string, targetId: string) => void) | null;
|
||||
/** Reports which creature (if any) the pane is riding, so the keymap
|
||||
* can move the facing ribbon to the mount's token. */
|
||||
onbody?: ((creatureId: string | null) => void) | null;
|
||||
/** A dimensional-warp step for whichever body the pane is: the
|
||||
* wizard (null) or the ridden creature (its id). */
|
||||
onWarpStep?: ((creatureId: string | null) => void) | null;
|
||||
} = $props();
|
||||
|
||||
const povId = $derived(view.you);
|
||||
const me = $derived(view.players.find((p) => p.id === povId && p.alive) ?? null);
|
||||
|
||||
/** What lies in YOUR square, grabbable: floor treasures and dropped
|
||||
* objects. The raycaster has no downward pitch — the tray is the eye's
|
||||
* substitute for looking at your own boots. */
|
||||
const atFeet = $derived.by(() => {
|
||||
if (!me || me.carriedTreasureId) return [];
|
||||
const k = `${me.position.x},${me.position.y}`;
|
||||
const items: { kind: "treasure" | "object"; id: string; art: string | null; label: string }[] = [];
|
||||
for (const t of view.treasures) {
|
||||
if (!t.position || t.carriedBy) continue;
|
||||
if (`${t.position.x},${t.position.y}` !== k) continue;
|
||||
if (t.owner === view.you && k === `${me.home.x},${me.home.y}`) continue; // safe at home
|
||||
const ci = view.players.find((p) => p.id === t.owner)?.colorIndex ?? 0;
|
||||
items.push({ kind: "treasure", id: t.id, art: tokenArt(`treasure-${ci % 6}`, "objects"), label: `${t.owner}'s treasure` });
|
||||
}
|
||||
for (const o of view.groundObjects[k] ?? []) {
|
||||
const file = objectArt(o.cardId);
|
||||
items.push({ kind: "object", id: o.instanceId, art: file ? tokenArt(file, "objects") : null, label: o.cardId.replace(/-/g, " ") });
|
||||
}
|
||||
return items;
|
||||
});
|
||||
|
||||
/** Riding a creature: the pane looks through ITS eyes and the helm
|
||||
* drives ITS legs, until you step back into your own skull. */
|
||||
let possessedId = $state<string | null>(null);
|
||||
const possessed = $derived(
|
||||
possessedId ? view.creatures.find((c) => c.id === possessedId) ?? null : null);
|
||||
/** Creatures you may ride right now: yours (and the democratic
|
||||
* monster), with movement or an attack still unspent. */
|
||||
const rideable = $derived(canStride
|
||||
? view.creatures.filter((c) =>
|
||||
(c.controllerId === view.you || c.kind === "democratic-monster") &&
|
||||
!c.justCreated &&
|
||||
(c.movementUsed < c.movesPerTurn || !c.attackUsed))
|
||||
: []);
|
||||
/** The rider's own stance, held while riding: dismounting must hand
|
||||
* back the exact view you left — same feet, same facing, no cut. */
|
||||
let preMount: { x: number; y: number; facing: number } | null = null;
|
||||
function dismount() {
|
||||
possessedId = null;
|
||||
if (preMount) {
|
||||
cam.x = preMount.x; cam.y = preMount.y; cam.facing = preMount.facing;
|
||||
preMount = null;
|
||||
}
|
||||
}
|
||||
// A dead or vanished mount — or the turn passing — dismounts you.
|
||||
$effect(() => {
|
||||
if (possessedId && (!possessed || !canStride)) dismount();
|
||||
});
|
||||
// Mounting seats the eyes in the creature's skull facing its longest
|
||||
// corridor; the rider steers from there.
|
||||
let seatedMount: string | null = null;
|
||||
$effect(() => {
|
||||
if (!possessedId || !possessed) { seatedMount = null; return; }
|
||||
if (seatedMount === possessedId) return;
|
||||
seatedMount = possessedId;
|
||||
preMount = untrack(() => ({ x: cam.x, y: cam.y, facing: cam.facing }));
|
||||
const mx = possessed.position.x + 0.5, my = possessed.position.y + 0.5;
|
||||
cam.x = mx; cam.y = my; cam.facing = deepestFacing(view, mx, my);
|
||||
});
|
||||
|
||||
/** What the ACTIVE body stands in or on: hazards it cannot see below
|
||||
* itself, and the dimensional token it could step through. */
|
||||
const underfoot = $derived.by(() => {
|
||||
const body = possessed ?? me;
|
||||
if (!body || !canStride) return { hazard: null as string | null, warpHere: false };
|
||||
const k = `${body.position.x},${body.position.y}`;
|
||||
const HAZARD_NAMES: Record<string, string> = {
|
||||
tacks: "tacks underfoot", slime: "green slime", dust: "a dust cloud",
|
||||
pit: "an open pit", ooze: "the killer ooze",
|
||||
};
|
||||
const kind = view.squareContents[k]?.kind;
|
||||
return {
|
||||
hazard: kind ? HAZARD_NAMES[kind] ?? null : null,
|
||||
warpHere: view.dimWarps.some((w) =>
|
||||
(w.a.x === body.position.x && w.a.y === body.position.y) ||
|
||||
(w.b.x === body.position.x && w.b.y === body.position.y)),
|
||||
};
|
||||
});
|
||||
|
||||
/** Bodies sharing the active eyes' square: inside the near plane, so
|
||||
* the renderer cannot show them — the tray stands in for the sprite,
|
||||
* and clicking routes exactly as clicking the sprite would. */
|
||||
const withYou = $derived.by(() => {
|
||||
const body = possessed ?? me;
|
||||
if (!body || !canStride) return [];
|
||||
const bx = body.position.x, by = body.position.y;
|
||||
const items: { kind: "player" | "creature"; id: string; art: string; label: string }[] = [];
|
||||
for (const p of view.players) {
|
||||
if (!p.alive || p.id === view.you) continue;
|
||||
if (p.position.x !== bx || p.position.y !== by) continue;
|
||||
items.push({ kind: "player", id: p.id, art: tokenArt(`wizard-${p.colorIndex}`, "players"), label: p.id });
|
||||
}
|
||||
for (const c of view.creatures) {
|
||||
if (c.id === possessedId) continue;
|
||||
if (c.position.x !== bx || c.position.y !== by) continue;
|
||||
items.push({ kind: "creature", id: c.id, art: tokenArt(c.kind, "creatures"), label: c.kind.replace(/-/g, " ") });
|
||||
}
|
||||
return items;
|
||||
});
|
||||
|
||||
const cam = $state({ x: 0, y: 0, facing: 0 });
|
||||
let camReady = false;
|
||||
/** While the player steers, the director keeps its hands off the
|
||||
* idle facings (actor-watching, corridor rescue); aims and cutaways
|
||||
* still fire — your own magic always turns your head. */
|
||||
let manualUntil = 0;
|
||||
let turning = false;
|
||||
|
||||
/** Manual steering holds the director's idle aims off this long. */
|
||||
const STEER_HOLD_MS = 4000;
|
||||
/** Mounting holds them off for the whole ride. */
|
||||
const RIDE_HOLD_MS = 60000;
|
||||
const SIDES4 = ["E", "S", "W", "N"] as const;
|
||||
function manualTurn(dir: -1 | 1) {
|
||||
if (turning) return;
|
||||
turning = true;
|
||||
manualUntil = performance.now() + STEER_HOLD_MS;
|
||||
const from = cam.facing;
|
||||
const to = from + dir * (Math.PI / 2);
|
||||
const t0 = performance.now();
|
||||
const tick = (now: number) => {
|
||||
const w = Math.min(1, (now - t0) / 180);
|
||||
cam.facing = from + (to - from) * (w * w * (3 - 2 * w));
|
||||
if (w < 1) requestAnimationFrame(tick);
|
||||
else turning = false;
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
/** Pane clicks: a possessed mount claims them first — adjacent floor
|
||||
* steps it, a same-square enemy takes its blow — and only the rider's
|
||||
* own eyes pass clicks through to the board's handlers. */
|
||||
function handleTarget(t: FpvTarget) {
|
||||
if (!possessed) { ontarget?.(t); return; }
|
||||
const c = possessed;
|
||||
if (t.kind === "player" && onCreatureAttack) {
|
||||
const p = view.players.find((q) => q.id === t.id);
|
||||
if (p && p.position.x === c.position.x && p.position.y === c.position.y) {
|
||||
onCreatureAttack(c.id, t.id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (t.kind === "cell" && onCreatureMove) {
|
||||
const dx = t.cell.x - c.position.x, dy = t.cell.y - c.position.y;
|
||||
if (Math.abs(dx) + Math.abs(dy) === 1) {
|
||||
onCreatureMove(c.id, dx === 1 ? "E" : dx === -1 ? "W" : dy === 1 ? "S" : "N");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function manualStride(back: boolean) {
|
||||
if (!canStride) return;
|
||||
manualUntil = performance.now() + STEER_HOLD_MS;
|
||||
const q = Math.round(cam.facing / (Math.PI / 2));
|
||||
const side = SIDES4[((q % 4) + 4 + (back ? 2 : 0)) % 4]!;
|
||||
if (possessed) onCreatureMove?.(possessed.id, side);
|
||||
else onstride?.(side);
|
||||
}
|
||||
function onKey(e: KeyboardEvent) {
|
||||
const t = e.target as HTMLElement | null;
|
||||
if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return;
|
||||
const k = e.key.toLowerCase();
|
||||
if (k === "arrowleft" || k === "a") { e.preventDefault(); manualTurn(-1); }
|
||||
else if (k === "arrowright" || k === "d") { e.preventDefault(); manualTurn(1); }
|
||||
else if (k === "arrowup" || k === "w") { e.preventDefault(); manualStride(false); }
|
||||
else if (k === "arrowdown" || k === "s") { e.preventDefault(); manualStride(true); }
|
||||
}
|
||||
let cutawayShot = $state(false);
|
||||
let fpFx = $state<FpFx[]>([]);
|
||||
let actorPos = $state<Record<string, { x: number; y: number }>>({});
|
||||
|
||||
// --- The fx: each batch plays once, on its own beat. -----------------
|
||||
let playedBatch = 0;
|
||||
$effect(() => {
|
||||
const b = batch;
|
||||
if (!b || b.n === playedBatch || !me) return;
|
||||
playedBatch = b.n;
|
||||
const timers: ReturnType<typeof setTimeout>[] = [];
|
||||
const started: number[] = [];
|
||||
for (const { fx, delay } of fpFxForEvents(b.events, b.view, povId)) {
|
||||
timers.push(setTimeout(() => {
|
||||
started.push(fx.id);
|
||||
fpFx = [...fpFx, { ...fx, t0: performance.now() }];
|
||||
timers.push(setTimeout(() => (fpFx = fpFx.filter((f) => f.id !== fx.id)), fx.dur + 80));
|
||||
}, delay));
|
||||
}
|
||||
return () => {
|
||||
timers.forEach(clearTimeout);
|
||||
started.forEach((id) => (fpFx = fpFx.filter((f) => f.id !== id)));
|
||||
};
|
||||
});
|
||||
|
||||
// --- Other bodies glide between views. -------------------------------
|
||||
let prevView: GameView | null = null;
|
||||
$effect(() => {
|
||||
const v = view;
|
||||
const before = prevView;
|
||||
prevView = v;
|
||||
if (!before || before === v || !me) return;
|
||||
const moves = gatherGlides(before, v, povId);
|
||||
if (moves.length === 0) { actorPos = {}; return; }
|
||||
const t0 = performance.now();
|
||||
const dur = 400;
|
||||
let raf = 0;
|
||||
const tick = (now: number) => {
|
||||
const w = Math.min(1, Math.max(0, (now - t0) / dur));
|
||||
const ease = w * w * (3 - 2 * w);
|
||||
const next: Record<string, { x: number; y: number }> = {};
|
||||
for (const m of moves) {
|
||||
next[m.id] = { x: m.fx + (m.tx - m.fx) * ease, y: m.fy + (m.ty - m.fy) * ease };
|
||||
}
|
||||
actorPos = next;
|
||||
if (w < 1) raf = requestAnimationFrame(tick);
|
||||
else actorPos = {};
|
||||
};
|
||||
raf = requestAnimationFrame(tick);
|
||||
return () => cancelAnimationFrame(raf);
|
||||
});
|
||||
|
||||
// --- The camera: the director's ladder, live. ------------------------
|
||||
let directedBatch = 0;
|
||||
$effect(() => {
|
||||
const b = batch;
|
||||
const m = me;
|
||||
if (!m) { camReady = false; return; }
|
||||
// Possessed: the director stands aside. The camera sits in the
|
||||
// mount's skull, keeps whatever facing the rider has chosen, and
|
||||
// hard-follows the mount's steps; dismounting reseats the wizard.
|
||||
const mount = possessed;
|
||||
if (mount) {
|
||||
if (b) directedBatch = b.n; // consume batches so dismounting doesn't replay them
|
||||
const mx = mount.position.x + 0.5, my = mount.position.y + 0.5;
|
||||
if (Math.hypot(untrack(() => cam.x) - mx, untrack(() => cam.y) - my) > 0.01) {
|
||||
cam.x = mx; cam.y = my;
|
||||
}
|
||||
return; // dismount() restores the rider's own stance, uncut
|
||||
}
|
||||
const v = view;
|
||||
const tx = m.position.x + 0.5;
|
||||
const ty = m.position.y + 0.5;
|
||||
// Untracked camera reads: this effect's own tween writes cam every
|
||||
// frame, and a tracked read would restart the ease per frame.
|
||||
const camX = untrack(() => cam.x);
|
||||
const camY = untrack(() => cam.y);
|
||||
const camF = untrack(() => cam.facing);
|
||||
const dx = tx - camX;
|
||||
const dy = ty - camY;
|
||||
const dist = Math.hypot(dx, dy);
|
||||
const events = b && b.n !== directedBatch ? b.events : [];
|
||||
if (b) directedBatch = b.n;
|
||||
const hurled = hurledIn(events, povId);
|
||||
// Stepping through a warp keeps your HEADING, not your bearing to a
|
||||
// point: you emerge walking out of the far mouth, so the exit facing
|
||||
// is the far mouth's inward direction — travel preserved through a
|
||||
// plain wrap, properly rotated where the boards were laid rotated.
|
||||
// (A dimensional token has no mouths: facing simply carries over.)
|
||||
let warpFacing: number | null = null;
|
||||
for (const e of events) {
|
||||
if (e.type === "moved" && e.player === povId && e.via === "warp") {
|
||||
const w = v.board.warps.find((wp) =>
|
||||
wp.from.cell.x === e.from.x && wp.from.cell.y === e.from.y &&
|
||||
wp.to.cell.x === e.to.x && wp.to.cell.y === e.to.y);
|
||||
if (w) warpFacing = SIDE_ANGLE[OPPOSITE[w.to.side]];
|
||||
} else if (e.type === "warpStepped" && e.player === povId) {
|
||||
warpFacing = camF;
|
||||
}
|
||||
}
|
||||
const willCut = !camReady || (dist > 1.6 && !hurled);
|
||||
cutawayShot = false;
|
||||
const takeCutaway = (ax: number, ay: number) => {
|
||||
const a = deepestFacing(v, ax, ay);
|
||||
const d = castRay(v, ax, ay, a).dist;
|
||||
const back = Math.min(1.6, Math.max(0.35, d - 0.4));
|
||||
cam.x = ax + Math.cos(a) * back;
|
||||
cam.y = ay + Math.sin(a) * back;
|
||||
cam.facing = a + Math.PI;
|
||||
camReady = true;
|
||||
cutawayShot = true;
|
||||
};
|
||||
const actor = v.activePlayerId;
|
||||
const aim = aimOfEvents(events, v, povId, m.position, actor);
|
||||
let targetFacing = camF;
|
||||
let aimed = false;
|
||||
if (warpFacing !== null) {
|
||||
targetFacing = warpFacing;
|
||||
aimed = true;
|
||||
} else if (camReady && !willCut && dist > 0.05 && !hurled) {
|
||||
targetFacing = Math.atan2(dy, dx);
|
||||
aimed = true;
|
||||
} else if (aim?.self && (actor !== povId || hurled)) {
|
||||
// A cutaway to your own body is replay theater: in the cockpit it
|
||||
// plays only for things done TO you, never for your own hands —
|
||||
// dropping a dagger must not fling your eyes out of your skull.
|
||||
return takeCutaway(m.position.x + 0.5, m.position.y + 0.5);
|
||||
} else if (aim && !aim.self) {
|
||||
const ax = aim.x + 0.5, ay = aim.y + 0.5;
|
||||
const toAim = Math.hypot(ax - tx, ay - ty);
|
||||
const sight = castRay(v, tx, ty, Math.atan2(ay - ty, ax - tx));
|
||||
if (sight.warped || sight.dist < toAim - 0.4) return takeCutaway(ax, ay);
|
||||
targetFacing = Math.atan2(ay - ty, ax - tx);
|
||||
aimed = true;
|
||||
} else if (performance.now() >= manualUntil) {
|
||||
// Another wizard acting in your sight line turns your head.
|
||||
const other = actor !== povId ? v.players.find((p) => p.id === actor && p.alive) : null;
|
||||
if (other && (other.position.x !== m.position.x || other.position.y !== m.position.y)) {
|
||||
const ax = other.position.x + 0.5, ay = other.position.y + 0.5;
|
||||
const toActor = Math.hypot(ax - tx, ay - ty);
|
||||
const ray = castRay(v, tx, ty, Math.atan2(ay - ty, ax - tx));
|
||||
if (!ray.warped && ray.dist > toActor - 0.2) {
|
||||
targetFacing = Math.atan2(ay - ty, ax - tx);
|
||||
aimed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!aimed && willCut && performance.now() >= manualUntil) {
|
||||
// A fresh CUT with nothing asking to be watched faces the deepest
|
||||
// corridor — but a STANDING camera is never "rescued": where you
|
||||
// pointed your own head is where it stays, brick or no brick.
|
||||
targetFacing = deepestFacing(v, tx, ty);
|
||||
}
|
||||
if (willCut) {
|
||||
cam.x = tx; cam.y = ty; cam.facing = targetFacing;
|
||||
camReady = true;
|
||||
return;
|
||||
}
|
||||
const fromX = camX, fromY = camY, fromF = camF;
|
||||
const arc = shortestArc(fromF, targetFacing);
|
||||
const turnMs = hurled ? 0 : Math.min(260, Math.abs(arc) * 180);
|
||||
const walkMs = dist > 0.05 ? (hurled ? 260 : 380) : 0;
|
||||
const t0 = performance.now();
|
||||
let raf = 0;
|
||||
const tick = (now: number) => {
|
||||
const t = Math.max(0, now - t0);
|
||||
if (turnMs > 0 && t < turnMs) {
|
||||
cam.facing = fromF + arc * (t / turnMs);
|
||||
} else if (walkMs > 0 && t < turnMs + walkMs) {
|
||||
cam.facing = fromF + arc;
|
||||
const w = (t - turnMs) / walkMs;
|
||||
const ease = w * w * (3 - 2 * w);
|
||||
cam.x = fromX + (tx - fromX) * ease;
|
||||
cam.y = fromY + (ty - fromY) * ease;
|
||||
} else {
|
||||
cam.facing = fromF + arc;
|
||||
cam.x = tx; cam.y = ty;
|
||||
return;
|
||||
}
|
||||
raf = requestAnimationFrame(tick);
|
||||
};
|
||||
raf = requestAnimationFrame(tick);
|
||||
return () => cancelAnimationFrame(raf);
|
||||
});
|
||||
$effect(() => { onbody?.(possessedId); });
|
||||
let lastQuad = -99;
|
||||
$effect(() => {
|
||||
const q = ((Math.round(cam.facing / (Math.PI / 2)) % 4) + 4) % 4;
|
||||
if (q !== lastQuad) {
|
||||
lastQuad = q;
|
||||
onfacing?.(SIDES4[q]!);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onKey} />
|
||||
|
||||
{#if me}
|
||||
<div class="live-fp">
|
||||
<!-- The bezel: instruments live in the frame, never on the glass —
|
||||
every canvas pixel is target. -->
|
||||
<div class="bezel-top">
|
||||
<span class="bezel-zone">
|
||||
{#if possessed}
|
||||
<span class="ride-label">riding the {possessed.kind.replace(/-/g, " ")}</span>
|
||||
<span class="ride-stats">{Math.max(0, possessed.movesPerTurn - possessed.movementUsed)} moves{possessed.attackUsed ? "" : " · claws ready"}</span>
|
||||
<button class="stamp tiny" onclick={dismount}>back to your eyes</button>
|
||||
{:else if rideable.length > 0 && (onCreatureMove || onCreatureAttack)}
|
||||
{#each rideable as c (c.id)}
|
||||
<button class="stamp tiny" onclick={() => { possessedId = c.id; manualUntil = performance.now() + RIDE_HOLD_MS; }}>
|
||||
👁 ride the {c.kind.replace(/-/g, " ")}
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</span>
|
||||
<span class="bezel-zone bezel-center">
|
||||
{#if canStride}
|
||||
<button class="drive stride" onclick={() => manualStride(false)} aria-label="step forward">︿ forward</button>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="bezel-zone bezel-right">
|
||||
{#if withYou.length > 0}
|
||||
<span class="feet-label">sharing your square</span>
|
||||
{#each withYou as body (body.id)}
|
||||
<button class="feet-item" title={body.label}
|
||||
onclick={() => handleTarget(body.kind === "player" ? { kind: "player", id: body.id } : { kind: "creature", id: body.id })}>
|
||||
<img src={body.art} alt={body.label} />
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
<button class="live-fp-hide" onclick={onhide} title="hide (re-enable in preferences)">✕</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="bezel-mid">
|
||||
<button class="drive" onclick={() => manualTurn(-1)} aria-label="turn left">‹</button>
|
||||
<FirstPerson {view} povId={cutawayShot ? "" : (possessedId ?? povId)}
|
||||
x={cam.x} y={cam.y} facing={cam.facing} width={960} height={400}
|
||||
fx={fpFx} posOverride={actorPos}
|
||||
ontarget={handleTarget} {litCells} />
|
||||
<button class="drive" onclick={() => manualTurn(1)} aria-label="turn right">›</button>
|
||||
</div>
|
||||
<div class="bezel-bottom">
|
||||
<span class="bezel-zone">
|
||||
{#if canStride && ((!possessed && atFeet.length > 0) || underfoot.hazard || underfoot.warpHere)}
|
||||
<span class="feet-label">at your feet</span>
|
||||
{#if !possessed && onpickup}
|
||||
{#each atFeet as item (item.id)}
|
||||
<button class="feet-item" title={item.label}
|
||||
onclick={() => onpickup?.({ kind: item.kind, id: item.id })}>
|
||||
{#if item.art}
|
||||
<img src={item.art} alt={item.label} />
|
||||
{:else}
|
||||
<span class="feet-fallback">{item.label}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
{#if underfoot.hazard}
|
||||
<span class="feet-hazard">⚠ {underfoot.hazard}</span>
|
||||
{/if}
|
||||
{#if underfoot.warpHere && onWarpStep}
|
||||
<button class="stamp tiny" onclick={() => onWarpStep?.(possessedId)}>⇋ step through the warp</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</span>
|
||||
<span class="bezel-zone bezel-center">
|
||||
{#if canStride}
|
||||
<button class="drive stride" onclick={() => manualStride(true)} aria-label="step back">﹀ back</button>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="bezel-zone bezel-right">
|
||||
<span class="live-fp-hint">← → turn · ↑ ↓ walk</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.live-fp {
|
||||
margin-bottom: 0.6rem;
|
||||
border: 1px solid #3a3428;
|
||||
border-radius: 6px;
|
||||
background: #14121a;
|
||||
overflow: hidden;
|
||||
}
|
||||
.bezel-top, .bezel-bottom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 8px;
|
||||
min-height: 34px;
|
||||
}
|
||||
.bezel-top { border-bottom: 1px solid #2a2620; }
|
||||
.bezel-bottom { border-top: 1px solid #2a2620; }
|
||||
.bezel-spacer { flex: 1; }
|
||||
.bezel-zone { flex: 1; display: flex; align-items: center; gap: 8px; }
|
||||
.bezel-center { justify-content: center; }
|
||||
.bezel-right { justify-content: flex-end; }
|
||||
.bezel-mid {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
}
|
||||
.bezel-mid :global(.fpv-canvas) {
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.drive {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #8d8672;
|
||||
font-size: 1.3rem;
|
||||
cursor: pointer;
|
||||
padding: 0 10px;
|
||||
}
|
||||
.drive:hover { color: #e9e1cb; background: rgba(201, 167, 42, 0.08); }
|
||||
.stride { font-size: 0.8rem; letter-spacing: 0.06em; }
|
||||
.live-fp-hide {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #8d8672;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.live-fp-hide:hover { color: #e9e1cb; }
|
||||
.live-fp-hint {
|
||||
font-family: "Courier Prime", monospace;
|
||||
font-size: 0.65rem;
|
||||
color: #5d5748;
|
||||
}
|
||||
.ride-label {
|
||||
font-family: "Oswald", sans-serif;
|
||||
font-size: 0.62rem;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: #c9a72a;
|
||||
}
|
||||
.ride-stats { font-size: 0.7rem; color: #8d8672; }
|
||||
.feet-label {
|
||||
font-family: "Oswald", sans-serif;
|
||||
font-size: 0.6rem;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: #8d8672;
|
||||
}
|
||||
.feet-item {
|
||||
background: none;
|
||||
border: 1px solid #4a4536;
|
||||
border-radius: 4px;
|
||||
padding: 2px;
|
||||
cursor: pointer;
|
||||
line-height: 0;
|
||||
}
|
||||
.feet-item:hover { border-color: #c9a72a; }
|
||||
.feet-item img { width: 26px; height: 26px; object-fit: contain; }
|
||||
.feet-fallback { font-size: 0.65rem; color: #d8d2c0; padding: 0 4px; }
|
||||
.feet-hazard { font-size: 0.7rem; color: #d98a4a; white-space: nowrap; }
|
||||
</style>
|
||||
@@ -1,7 +1,13 @@
|
||||
<script lang="ts">
|
||||
import Board from "./Board.svelte";
|
||||
import FirstPerson from "./fpv/FirstPerson.svelte";
|
||||
import { untrack } from "svelte";
|
||||
import { humanize } from "./net.svelte";
|
||||
import { tokenArt } from "./art";
|
||||
import { scheduleFx, type BoardFx } from "./fx";
|
||||
import { fpFxForEvents, type FpFx } from "./fpv/fx3d";
|
||||
import { castRay, edgeMid } from "./fpv/raycast";
|
||||
import { deepestFacing, aimOfEvents, gatherGlides, hurledIn, shortestArc } from "./fpv/director";
|
||||
import { prefs } from "./prefs.svelte";
|
||||
import { stackSightTrace } from "@wizwar/engine";
|
||||
import type { GameEvent, GameView } from "@wizwar/engine";
|
||||
@@ -9,24 +15,112 @@
|
||||
let {
|
||||
steps,
|
||||
onclose,
|
||||
moment = false,
|
||||
pov = null,
|
||||
onshare = null,
|
||||
endLabel = null,
|
||||
}: {
|
||||
steps: { seq: number; actor: string; events: GameEvent[]; view: GameView; chat?: { player: string; text: string }[] }[];
|
||||
onclose: () => void;
|
||||
/** An instant replay of one turn: open straight into first person,
|
||||
* through the eyes of the wizard whose turn it is. */
|
||||
moment?: boolean;
|
||||
/** The turn-owner, when the server already knows it (moment reels):
|
||||
* the fallback when no boundary has scrolled past yet. */
|
||||
pov?: string | null;
|
||||
/** Mint a public link to this turn; resolves to the URL. */
|
||||
onshare?: (() => Promise<string>) | null;
|
||||
/** What the leave button says once the reel has run out. */
|
||||
endLabel?: string | null;
|
||||
} = $props();
|
||||
|
||||
/** The share button's little life: offer, mint, report. */
|
||||
let shareState = $state<"idle" | "minting" | "copied" | "failed">("idle");
|
||||
async function doShare() {
|
||||
if (!onshare || shareState === "minting") return;
|
||||
shareState = "minting";
|
||||
try {
|
||||
const url = await onshare();
|
||||
await navigator.clipboard.writeText(url);
|
||||
shareState = "copied";
|
||||
} catch {
|
||||
shareState = "failed";
|
||||
}
|
||||
setTimeout(() => (shareState = "idle"), 4000);
|
||||
}
|
||||
|
||||
let idx = $state(0);
|
||||
let playing = $state(true);
|
||||
let speed = $state(1);
|
||||
/** Watch the board from above, or relive it through your own eyes. */
|
||||
let fp = $state(moment);
|
||||
|
||||
/** Whose eyes the first-person camera wears: the wizard whose turn it
|
||||
* is, ALWAYS — their position seen with the viewer's knowledge of the
|
||||
* maze, so nothing private leaks. The owner is whoever the last turn
|
||||
* boundary at or before the current step named; before any boundary
|
||||
* scrolls past, the server-known owner (moment reels) or the viewer. */
|
||||
const povId = $derived.by(() => {
|
||||
for (let i = Math.min(idx, steps.length - 1); i >= 0; i--) {
|
||||
const evs = steps[i]!.events;
|
||||
for (let j = evs.length - 1; j >= 0; j--) {
|
||||
const e = evs[j]!;
|
||||
if (e.type === "turnStarted" || e.type === "extraTurnStarted") {
|
||||
return e.player as string;
|
||||
}
|
||||
}
|
||||
}
|
||||
// No boundary yet: these are the FIRST turn's steps, whose opening
|
||||
// turnStarted fired in the deal before any command. Its owner is
|
||||
// whoever the reel's first turnEnded names.
|
||||
for (const st of steps) {
|
||||
for (const e of st.events) {
|
||||
if (e.type === "turnEnded" && "player" in e) return (e as { player: string }).player;
|
||||
if (e.type === "turnStarted" || e.type === "extraTurnStarted") break;
|
||||
}
|
||||
}
|
||||
return pov ?? steps[0]!.view.you;
|
||||
});
|
||||
const step = $derived(steps[Math.min(idx, steps.length - 1)]!);
|
||||
/** What you drew belongs in the chronicle, not on a reel that may be
|
||||
* recorded and passed around — captions keep the count, not the cards. */
|
||||
const CAPTION_SILENT = new Set(["cardsDrawnPrivate", "cardsDealtPrivate", "cardsStolenPrivate"]);
|
||||
const lines = $derived(
|
||||
step.events.map(humanize).filter((l): l is string => l !== null),
|
||||
step.events
|
||||
.filter((e) => !CAPTION_SILENT.has(e.type))
|
||||
.map(humanize)
|
||||
.filter((l): l is string => l !== null),
|
||||
);
|
||||
const atEnd = $derived(idx >= steps.length - 1);
|
||||
|
||||
/** The acting wizard's standee, shown beside their words. */
|
||||
const actorArt = $derived.by(() => {
|
||||
const p = step.view.players.find((x) => x.id === step.actor);
|
||||
return p ? tokenArt(`wizard-${p.colorIndex}`, "players") : null;
|
||||
});
|
||||
|
||||
// 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));
|
||||
|
||||
/** Every wall destroyed so far in the reel leaves a mound of rubble on
|
||||
* the first-person floor for the rest of it. */
|
||||
const rubbleSpots = $derived.by(() => {
|
||||
const spots: { x: number; y: number }[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (let i = 0; i <= Math.min(idx, steps.length - 1); i++) {
|
||||
for (const e of steps[i]!.events) {
|
||||
if (e.type !== "wallDestroyed" || !("edge" in e)) continue;
|
||||
const edge = (e as { edge: { cell: { x: number; y: number }; side: "N" | "E" | "S" | "W" } }).edge;
|
||||
const key = `${edge.cell.x},${edge.cell.y}:${edge.side}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
spots.push(edgeMid(edge.cell, edge.side));
|
||||
}
|
||||
}
|
||||
return spots;
|
||||
});
|
||||
|
||||
/** Each step's spells flare on the reel exactly as they did at the table. */
|
||||
let boardFx = $state<BoardFx[]>([]);
|
||||
$effect(() => {
|
||||
@@ -40,6 +134,39 @@
|
||||
return () => { cancel(); boardFx = []; };
|
||||
});
|
||||
|
||||
/** The director's slate: the camera effect writes how long this step's
|
||||
* outcome stays HIDDEN — the old world held on screen — while the eyes
|
||||
* turn to face the recipient. The fx and the view reveal together. */
|
||||
let camPlan = $state<{ idx: number; holdMs: number } | null>(null);
|
||||
let heldView = $state<GameView | null>(null);
|
||||
/** A cutaway frame shows the reel's own wizard too. */
|
||||
let cutawayShot = $state(false);
|
||||
|
||||
/** In first person the same events become projectiles, impacts,
|
||||
* flashes, and shakes — scheduled on this step's beat, after the
|
||||
* camera's turn has brought the recipient into frame. */
|
||||
let fpFx = $state<FpFx[]>([]);
|
||||
$effect(() => {
|
||||
const i = Math.min(idx, steps.length - 1);
|
||||
const st = steps[i];
|
||||
if (!fp || !st || !prefs.flourishes) return;
|
||||
const plan = camPlan;
|
||||
if (plan?.idx !== i) return; // the camera has not set this step's slate yet
|
||||
const timers: ReturnType<typeof setTimeout>[] = [];
|
||||
const started: number[] = [];
|
||||
for (const { fx, delay } of fpFxForEvents(st.events, st.view, povId)) {
|
||||
timers.push(setTimeout(() => {
|
||||
started.push(fx.id);
|
||||
fpFx = [...fpFx, { ...fx, t0: performance.now() }];
|
||||
timers.push(setTimeout(() => (fpFx = fpFx.filter((f) => f.id !== fx.id)), fx.dur + 80));
|
||||
}, plan.holdMs + delay / speed));
|
||||
}
|
||||
return () => {
|
||||
timers.forEach(clearTimeout);
|
||||
started.forEach((id) => (fpFx = fpFx.filter((f) => f.id !== id)));
|
||||
};
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!playing) return;
|
||||
const t = setInterval(() => {
|
||||
@@ -49,6 +176,335 @@
|
||||
return () => clearInterval(t);
|
||||
});
|
||||
|
||||
/** Other bodies glide between steps instead of blinking cell to cell:
|
||||
* each step, anyone whose position changed a walkable distance tweens
|
||||
* from where the previous step's view had them. */
|
||||
let actorPos = $state<Record<string, { x: number; y: number }>>({});
|
||||
let prevView: GameView | null = null;
|
||||
$effect(() => {
|
||||
const st = steps[Math.min(idx, steps.length - 1)];
|
||||
if (!fp || !st) { prevView = null; actorPos = {}; return; }
|
||||
const v = st.view;
|
||||
const before = prevView;
|
||||
prevView = v;
|
||||
if (!before || before === v) return;
|
||||
const moves = gatherGlides(before, v, povId);
|
||||
if (moves.length === 0) { actorPos = {}; return; }
|
||||
const t0 = performance.now();
|
||||
const dur = 400 / speed;
|
||||
let raf = 0;
|
||||
const tick = (now: number) => {
|
||||
const w = Math.min(1, Math.max(0, (now - t0) / dur));
|
||||
const ease = w * w * (3 - 2 * w);
|
||||
const next: Record<string, { x: number; y: number }> = {};
|
||||
for (const m of moves) {
|
||||
next[m.id] = { x: m.fx + (m.tx - m.fx) * ease, y: m.fy + (m.ty - m.fy) * ease };
|
||||
}
|
||||
actorPos = next;
|
||||
if (w < 1) raf = requestAnimationFrame(tick);
|
||||
else actorPos = {};
|
||||
};
|
||||
raf = requestAnimationFrame(tick);
|
||||
return () => cancelAnimationFrame(raf);
|
||||
});
|
||||
|
||||
// --- The first-person camera: your wizard's walk, relived. -------------
|
||||
// Each step the camera settles on your position. A step away tweens —
|
||||
// turn first, then stride; a leap (teleport, warp) cuts. When you stood
|
||||
// still, the eye turns toward whoever acted.
|
||||
const cam = $state({ x: 0, y: 0, facing: 0 });
|
||||
let camReady = false;
|
||||
$effect(() => {
|
||||
if (!fp) { camReady = false; return; }
|
||||
const v = step.view;
|
||||
const me = v.players.find((p) => p.id === povId);
|
||||
if (!me) return;
|
||||
const tx = me.position.x + 0.5;
|
||||
const ty = me.position.y + 0.5;
|
||||
// Read the camera WITHOUT tracking it: this effect's own tween writes
|
||||
// cam every frame, and a tracked read would re-trigger the effect per
|
||||
// frame — each restart resetting the ease to zero, so the walk decays
|
||||
// into a slow drift. Untracked, one step runs one tween.
|
||||
const camX = untrack(() => cam.x);
|
||||
const camY = untrack(() => cam.y);
|
||||
const camF = untrack(() => cam.facing);
|
||||
const dx = tx - camX;
|
||||
const dy = ty - camY;
|
||||
const dist = Math.hypot(dx, dy);
|
||||
// Where should the eye end up pointing? Along its own stride; at the
|
||||
// actor, when someone else moved the world; wherever it was, otherwise.
|
||||
// A blow that hurls the body glides fast and straight, however far,
|
||||
// eyes still where they were; only unexplained leaps (teleports) cut.
|
||||
const hurled = hurledIn(step.events, povId);
|
||||
const actor = v.players.find((p) => p.id === step.actor);
|
||||
// Where the eyes' own magic went this step: a wizard looks where
|
||||
// they aim, so the camera turns to watch its own spells land.
|
||||
const aim = aimOfEvents(step.events, v, povId, me.position, step.actor);
|
||||
// A leap the legs cannot explain (first frame, teleport, the return
|
||||
// from a cutaway) is a CUT, and a cut is not a stride: the direction
|
||||
// of travel means nothing at the far end.
|
||||
const willCut = !camReady || (dist > 1.6 && !hurled);
|
||||
cutawayShot = false;
|
||||
/** The broadcast cutaway: stand back down the target cell's deepest
|
||||
* corridor, facing it, holding a beat of the world-before so the
|
||||
* deed happens ON screen — with the reel's own wizard visible. */
|
||||
const takeCutaway = (ax: number, ay: number) => {
|
||||
const a = deepestFacing(v, ax, ay);
|
||||
const d = castRay(v, ax, ay, a).dist;
|
||||
const back = Math.min(1.6, Math.max(0.35, d - 0.4));
|
||||
cam.x = ax + Math.cos(a) * back;
|
||||
cam.y = ay + Math.sin(a) * back;
|
||||
cam.facing = a + Math.PI;
|
||||
camReady = true;
|
||||
cutawayShot = true;
|
||||
const i2 = Math.min(idx, steps.length - 1);
|
||||
const pv = i2 > 0 ? steps[i2 - 1]!.view : null;
|
||||
heldView = pv;
|
||||
camPlan = { idx: i2, holdMs: pv ? 420 / speed : 0 };
|
||||
if (pv) {
|
||||
const t = setTimeout(() => (heldView = null), 420 / speed);
|
||||
return () => clearTimeout(t);
|
||||
}
|
||||
return;
|
||||
};
|
||||
let targetFacing = camF;
|
||||
let aimed = false;
|
||||
if (camReady && !willCut && dist > 0.05 && !hurled) { targetFacing = Math.atan2(dy, dx); aimed = true; }
|
||||
else if (aim?.self) {
|
||||
// Summoned at your own feet: no turn can show it — step outside
|
||||
// yourself and watch it grow beside you.
|
||||
return takeCutaway(me.position.x + 0.5, me.position.y + 0.5);
|
||||
}
|
||||
else if (aim) {
|
||||
const ax = aim.x + 0.5, ay = aim.y + 0.5;
|
||||
const toAim = Math.hypot(ax - tx, ay - ty);
|
||||
const sight = castRay(v, tx, ty, Math.atan2(ay - ty, ax - tx));
|
||||
if (sight.warped || sight.dist < toAim - 0.4) {
|
||||
return takeCutaway(ax, ay);
|
||||
}
|
||||
targetFacing = Math.atan2(ay - ty, ax - tx);
|
||||
aimed = true;
|
||||
} else if (actor && actor.id !== povId &&
|
||||
(actor.position.x !== me.position.x || actor.position.y !== me.position.y)) {
|
||||
// Turn toward the actor — but only if these eyes could actually see
|
||||
// them: an unbroken straight sight line, no warps bending it.
|
||||
const ax = actor.position.x + 0.5, ay = actor.position.y + 0.5;
|
||||
const toActor = Math.hypot(ax - tx, ay - ty);
|
||||
const ray = castRay(v, tx, ty, Math.atan2(ay - ty, ax - tx));
|
||||
if (!ray.warped && ray.dist > toActor - 0.2) {
|
||||
targetFacing = Math.atan2(ay - ty, ax - tx);
|
||||
aimed = true;
|
||||
}
|
||||
}
|
||||
// How good is the view down heading `a` from (px,py)? Corridor depth,
|
||||
// and at the reel's end, a bonus for visible subjects.
|
||||
const viewScoreAt = (px: number, py: number, a: number): number => {
|
||||
let score = Math.min(castRay(v, px, py, a).dist, 8);
|
||||
if (!atEnd) return score;
|
||||
const bonus = (cell: { x: number; y: number }, worth: number) => {
|
||||
const bx = cell.x + 0.5, by = cell.y + 0.5;
|
||||
const bearing = Math.atan2(by - py, bx - px);
|
||||
if (Math.abs(shortestArc(a, bearing)) > 0.55) return 0;
|
||||
const d = Math.hypot(bx - px, by - py);
|
||||
if (d < 0.1) return 0;
|
||||
const sight = castRay(v, px, py, bearing);
|
||||
return !sight.warped && sight.dist >= d - 0.4 ? worth / (1 + d * 0.3) : 0;
|
||||
};
|
||||
for (const p of v.players) if (p.alive && p.id !== povId) score += bonus(p.position, 6);
|
||||
for (const c of v.creatures) score += bonus(c.position, 5);
|
||||
for (const t of v.treasures) if (t.position && !t.carriedBy) score += bonus(t.position, 3);
|
||||
return score;
|
||||
};
|
||||
if (!aimed) {
|
||||
// Nothing aims the eyes. If they'd idle nose-to-brick (the opening
|
||||
// cut, a dead-end beat — or the reel's CLOSING shot, which gets a
|
||||
// higher standard), find a better view: the deepest corridor, and
|
||||
// on the final frame, preferably one with something in it.
|
||||
const viewScore = (a: number): number => viewScoreAt(tx, ty, a);
|
||||
// A cut lands with whatever stale facing it carried, so a cut with
|
||||
// nothing aimed always re-picks its view from the new position.
|
||||
const staring = willCut ||
|
||||
castRay(v, tx, ty, targetFacing).dist < (atEnd ? 1.2 : 0.8);
|
||||
if (staring) {
|
||||
const current = viewScore(targetFacing);
|
||||
let bestA = targetFacing, bestScore = current;
|
||||
for (const a of [0, Math.PI / 2, Math.PI, -Math.PI / 2]) {
|
||||
const sc = viewScore(a);
|
||||
if (sc > bestScore + 0.5) { bestScore = sc; bestA = a; }
|
||||
}
|
||||
// One evaluation, one turn — a wizard walled in on all sides
|
||||
// keeps whatever view it has rather than hunting forever.
|
||||
targetFacing = bestA;
|
||||
}
|
||||
}
|
||||
// The reel's LAST step may be a stride into a corner: the walk keeps
|
||||
// its own facing, then the camera turns once more to a view worth
|
||||
// ending on.
|
||||
let closingArc = 0;
|
||||
if (atEnd && aimed && castRay(v, tx, ty, targetFacing).dist < 1.2) {
|
||||
const current = viewScoreAt(tx, ty, targetFacing);
|
||||
let bestA = targetFacing, bestScore = current;
|
||||
for (const a of [0, Math.PI / 2, Math.PI, -Math.PI / 2]) {
|
||||
const sc = viewScoreAt(tx, ty, a);
|
||||
if (sc > bestScore + 0.5) { bestScore = sc; bestA = a; }
|
||||
}
|
||||
closingArc = shortestArc(targetFacing, bestA);
|
||||
}
|
||||
const stepIdx = Math.min(idx, steps.length - 1);
|
||||
const prevView = stepIdx > 0 ? steps[stepIdx - 1]!.view : null;
|
||||
/** Hold the OLD world on screen this long, so the outcome lands only
|
||||
* once the eyes face its recipient: a cutaway gets a beat of the
|
||||
* "before"; a turn holds until the turn is done. */
|
||||
const setSlate = (holdMs: number) => {
|
||||
// Locals only: reading heldView back here would register it as a
|
||||
// dependency of this very effect, and the timer clearing it would
|
||||
// re-trigger us into a ping-pong.
|
||||
const hold = holdMs > 60 && prevView ? prevView : null;
|
||||
heldView = hold;
|
||||
camPlan = { idx: stepIdx, holdMs: hold ? holdMs : 0 };
|
||||
if (hold) {
|
||||
const t = setTimeout(() => (heldView = null), holdMs);
|
||||
return () => clearTimeout(t);
|
||||
}
|
||||
return () => {};
|
||||
};
|
||||
if (willCut) {
|
||||
// First frame, or a leap the legs cannot explain: cut. An aimed
|
||||
// cut still holds a beat of the before-world once the reel is
|
||||
// rolling; the opening frame reveals at once.
|
||||
const wasReady = camReady;
|
||||
cam.x = tx; cam.y = ty; cam.facing = targetFacing;
|
||||
camReady = true;
|
||||
return setSlate(wasReady && aim ? 380 / speed : 0);
|
||||
}
|
||||
const fromX = camX, fromY = camY, fromF = camF;
|
||||
const arc = shortestArc(fromF, targetFacing);
|
||||
const turnMs = (hurled ? 0 : Math.min(260, Math.abs(arc) * 180)) / speed;
|
||||
const walkMs = (dist > 0.05 ? (hurled ? 260 : 420) : 0) / speed;
|
||||
const clearSlate = setSlate(aimed && aim ? turnMs + 120 / speed : 0);
|
||||
const closingMs = Math.min(300, Math.abs(closingArc) * 200) / speed;
|
||||
const t0 = performance.now();
|
||||
let raf = 0;
|
||||
const tick = (now: number) => {
|
||||
// rAF hands frame-start time, which can precede t0; and a
|
||||
// zero-length phase must never divide. Either poisons the facing
|
||||
// with NaN, which no later frame can wash out.
|
||||
const t = Math.max(0, now - t0);
|
||||
if (turnMs > 0 && t < turnMs) {
|
||||
cam.facing = fromF + arc * (t / turnMs);
|
||||
} else if (walkMs > 0 && t < turnMs + walkMs) {
|
||||
cam.facing = fromF + arc;
|
||||
const w = (t - turnMs) / walkMs;
|
||||
const ease = w * w * (3 - 2 * w);
|
||||
cam.x = fromX + (tx - fromX) * ease;
|
||||
cam.y = fromY + (ty - fromY) * ease;
|
||||
} else if (closingArc !== 0 && t < turnMs + walkMs + closingMs) {
|
||||
cam.x = tx; cam.y = ty;
|
||||
const w = (t - turnMs - walkMs) / closingMs;
|
||||
cam.facing = fromF + arc + closingArc * w * w * (3 - 2 * w);
|
||||
} else {
|
||||
cam.facing = fromF + arc + closingArc;
|
||||
cam.x = tx; cam.y = ty;
|
||||
return;
|
||||
}
|
||||
raf = requestAnimationFrame(tick);
|
||||
};
|
||||
raf = requestAnimationFrame(tick);
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
clearSlate();
|
||||
};
|
||||
});
|
||||
|
||||
// --- Save a video: the first-person view composited into a shareable
|
||||
// 1280x720 frame — the step's caption burned in at the bottom, the
|
||||
// game's name in the corner — so a downloaded clip explains itself.
|
||||
let stageEl: HTMLDivElement | undefined = $state();
|
||||
let recorder = $state<MediaRecorder | null>(null);
|
||||
function toggleRecord() {
|
||||
if (recorder) { recorder.stop(); return; }
|
||||
const cv = stageEl?.querySelector("canvas");
|
||||
if (!cv) return;
|
||||
// Recording means capturing the WHOLE reel: rewind to the top and roll.
|
||||
idx = 0;
|
||||
const comp = document.createElement("canvas");
|
||||
comp.width = 1280;
|
||||
comp.height = 720;
|
||||
const g = comp.getContext("2d")!;
|
||||
const faces = new Map<string, HTMLImageElement>();
|
||||
const faceFor = (src: string): HTMLImageElement | null => {
|
||||
let img = faces.get(src);
|
||||
if (!img) {
|
||||
img = new Image();
|
||||
img.src = src;
|
||||
faces.set(src, img);
|
||||
}
|
||||
return img.complete && img.naturalWidth > 0 ? img : null;
|
||||
};
|
||||
let compRaf = 0;
|
||||
const drawComp = () => {
|
||||
g.imageSmoothingEnabled = false;
|
||||
g.drawImage(cv, 0, 0, 1280, 720);
|
||||
g.imageSmoothingEnabled = true;
|
||||
// The caption bar: who did what, in the reel's own words — their
|
||||
// standee at the left.
|
||||
g.fillStyle = "rgba(12, 10, 8, 0.78)";
|
||||
g.fillRect(0, 720 - 96, 1280, 96);
|
||||
const face = actorArt ? faceFor(actorArt) : null;
|
||||
const textX = face ? 110 : 28;
|
||||
if (face) {
|
||||
g.drawImage(face, 24, 720 - 96 + 14, 68, 68);
|
||||
g.strokeStyle = "#43331f";
|
||||
g.lineWidth = 2;
|
||||
g.strokeRect(24, 720 - 96 + 14, 68, 68);
|
||||
}
|
||||
g.fillStyle = "#e0b34a";
|
||||
g.font = "600 22px Oswald, sans-serif";
|
||||
g.fillText(step.actor.toUpperCase(), textX, 720 - 60, 400);
|
||||
g.fillStyle = "#efe8d4";
|
||||
g.font = "21px 'Courier Prime', monospace";
|
||||
const said = lines.slice(0, 2);
|
||||
if (said.length === 0) said.push("…considers the maze.");
|
||||
said.forEach((line, i) => g.fillText(line, textX, 720 - 32 + i * 26, 1224 - (textX - 28)));
|
||||
// The colophon corner.
|
||||
g.fillStyle = "rgba(224, 179, 74, 0.6)";
|
||||
g.font = "600 20px Oswald, sans-serif";
|
||||
g.textAlign = "right";
|
||||
g.fillText("W I Z - W A R", 1280 - 24, 40);
|
||||
g.textAlign = "left";
|
||||
compRaf = requestAnimationFrame(drawComp);
|
||||
};
|
||||
compRaf = requestAnimationFrame(drawComp);
|
||||
// MP4 travels everywhere (iMessage, QuickTime, every platform);
|
||||
// WebM is the fallback where the browser can't mux it.
|
||||
const mime = [
|
||||
"video/mp4;codecs=avc1.42E01E", "video/mp4",
|
||||
"video/webm;codecs=vp9", "video/webm",
|
||||
].find((m) => MediaRecorder.isTypeSupported(m)) ?? "video/webm";
|
||||
const container = mime.startsWith("video/mp4") ? "video/mp4" : "video/webm";
|
||||
const ext = container === "video/mp4" ? "mp4" : "webm";
|
||||
const rec = new MediaRecorder(comp.captureStream(60), { mimeType: mime });
|
||||
const chunks: Blob[] = [];
|
||||
rec.ondataavailable = (e) => { if (e.data.size) chunks.push(e.data); };
|
||||
rec.onstop = () => {
|
||||
cancelAnimationFrame(compRaf);
|
||||
const url = URL.createObjectURL(new Blob(chunks, { type: container }));
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `wizwar-moves-${steps[0]?.seq ?? 0}-${steps[steps.length - 1]?.seq ?? 0}.${ext}`;
|
||||
a.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 2000);
|
||||
recorder = null;
|
||||
};
|
||||
rec.start();
|
||||
recorder = rec;
|
||||
playing = true;
|
||||
}
|
||||
// The reel running out ends the recording and hands over the file.
|
||||
$effect(() => {
|
||||
if (recorder && !playing && atEnd) recorder.stop();
|
||||
});
|
||||
|
||||
function onkeydown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") onclose();
|
||||
if (e.key === "ArrowRight") { playing = false; idx = Math.min(idx + 1, steps.length - 1); }
|
||||
@@ -62,14 +518,34 @@
|
||||
<div class="replay-scrim">
|
||||
<div class="replay" role="dialog" aria-modal="true" aria-label="what happened while you were away">
|
||||
<header class="replay-head">
|
||||
<span class="replay-title">{steps[0]?.seq === 0 ? "The whole tale, from the deal" : "While you were away"}</span>
|
||||
<span class="replay-title">{moment ? `Instant replay — ${povId}'s turn` : steps[0]?.seq === 0 ? "The whole tale, from the deal" : "While you were away"}</span>
|
||||
<span class="replay-count">move {idx + 1} of {steps.length}</span>
|
||||
<button class="replay-skip" onclick={onclose}>{atEnd ? "back to the game" : "skip to now"}</button>
|
||||
<button class="replay-eyes" class:lit={fp} onclick={() => (fp = !fp)}>
|
||||
{fp ? "⬒ the board" : "👁 your eyes"}</button>
|
||||
{#if fp}
|
||||
<button class="replay-eyes" class:lit={recorder !== null} onclick={toggleRecord}>
|
||||
{recorder ? "⏹ stop & save" : "⏺ save video"}</button>
|
||||
{/if}
|
||||
{#if onshare}
|
||||
<button class="replay-eyes" class:lit={shareState === "copied"} onclick={doShare}>
|
||||
{shareState === "idle" ? "🔗 share link"
|
||||
: shareState === "minting" ? "…"
|
||||
: shareState === "copied" ? "✓ link copied" : "share failed"}</button>
|
||||
{/if}
|
||||
<button class="replay-skip" onclick={onclose}>{atEnd ? (endLabel ?? "back to the game") : "skip to now"}</button>
|
||||
</header>
|
||||
<div class="replay-board">
|
||||
<div class="replay-board" bind:this={stageEl}>
|
||||
{#if fp}
|
||||
<FirstPerson view={heldView ?? step.view} povId={cutawayShot ? "" : povId}
|
||||
x={cam.x} y={cam.y} facing={cam.facing} width={640} height={360}
|
||||
fx={fpFx} posOverride={actorPos} rubble={rubbleSpots} />
|
||||
{:else}
|
||||
<Board view={step.view} effects={boardFx} {sightTrace} />
|
||||
{/if}
|
||||
</div>
|
||||
<div class="replay-caption">
|
||||
{#if actorArt}<img class="caption-face" src={actorArt} alt={step.actor} />{/if}
|
||||
<div class="caption-lines">
|
||||
<strong>{step.actor}</strong>
|
||||
{#each lines as line, i (i)}<div>{line}</div>{/each}
|
||||
{#if lines.length === 0 && !step.chat?.length}<div>…considers the maze.</div>{/if}
|
||||
@@ -77,6 +553,7 @@
|
||||
<div class="reel-talk">💬 {c.player}: {c.text}</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<div class="replay-controls">
|
||||
<button onclick={() => { playing = false; idx = Math.max(0, idx - 1); }} aria-label="previous move">◀</button>
|
||||
<button class="playpause" onclick={() => (playing = !playing)} aria-label={playing ? "pause" : "play"}>
|
||||
@@ -126,8 +603,18 @@
|
||||
color: #e9e1cb;
|
||||
}
|
||||
.replay-count { font-size: 0.8rem; color: #8d8672; }
|
||||
.replay-skip {
|
||||
.replay-eyes {
|
||||
margin-left: auto;
|
||||
background: none;
|
||||
border: 1px solid #5a5342;
|
||||
border-radius: 3px;
|
||||
color: #a49c86;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
padding: 0.15rem 0.5rem;
|
||||
}
|
||||
.replay-eyes.lit { color: #e9e1cb; border-color: #a49c86; }
|
||||
.replay-skip {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #a49c86;
|
||||
@@ -156,7 +643,19 @@
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.45;
|
||||
min-height: 3.4rem;
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.caption-face {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
object-fit: cover;
|
||||
border-radius: 3px;
|
||||
border: 1.5px solid #43331f;
|
||||
flex: none;
|
||||
}
|
||||
.caption-lines { min-width: 0; }
|
||||
.replay-controls .speed {
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.7;
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
<script lang="ts">
|
||||
// The public share page (/watch/<id>): one turn of one game, watchable
|
||||
// by anyone, spectator-redacted — the rest of the game stays private.
|
||||
// The reel opens in first person through the turn-owner's eyes, framed
|
||||
// by enough chrome to explain itself and point the way home.
|
||||
import Replay from "./Replay.svelte";
|
||||
import type { GameEvent, GameView } from "@wizwar/engine";
|
||||
|
||||
const id = location.pathname.split("/")[2] ?? "";
|
||||
// Under the vite dev server the API lives on the game server's port.
|
||||
const API = location.port === "5173" ? `http://${location.hostname}:8787` : "";
|
||||
|
||||
interface ShareSteps {
|
||||
steps: { seq: number; actor: string; events: GameEvent[]; view: GameView }[];
|
||||
actor: string;
|
||||
round: number;
|
||||
whole?: boolean;
|
||||
}
|
||||
let data = $state<ShareSteps | null>(null);
|
||||
let failed = $state(false);
|
||||
let reelKey = $state(0);
|
||||
$effect(() => {
|
||||
fetch(`${API}/api/share/${id}`)
|
||||
.then((r) => (r.ok ? r.json() : Promise.reject(new Error("gone"))))
|
||||
.then((d) => (data = d))
|
||||
.catch(() => (failed = true));
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="share-page">
|
||||
<header class="share-head">
|
||||
<a class="share-brand" href="/">WIZ-WAR</a>
|
||||
{#if data}
|
||||
<div class="share-title">
|
||||
{#if data.whole}The whole tale{data.actor ? ` — ${data.actor} triumphant` : ""}{:else}Instant replay — {data.actor}'s turn{data.round ? ` (round ${data.round})` : ""}{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
{#if failed}
|
||||
<div class="share-note">
|
||||
<p>This replay has wandered off — the link may be old, or the game gone.</p>
|
||||
<a class="share-cta" href="/">▶ deal yourself into a fresh game</a>
|
||||
</div>
|
||||
{:else if !data}
|
||||
<div class="share-note"><p>Rebuilding the turn…</p></div>
|
||||
{:else}
|
||||
<div class="share-stage">
|
||||
{#key reelKey}
|
||||
<Replay steps={data.steps} moment pov={data.actor} endLabel="⟲ watch it again"
|
||||
onclose={() => (reelKey += 1)} />
|
||||
{/key}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<footer class="share-foot">
|
||||
<p>
|
||||
<strong>Wiz-War</strong> is Tom Jolly's 1983 game of magical combat in
|
||||
a stone labyrinth. This digital table is an unofficial, non-commercial
|
||||
fan re-creation of the sixth edition (Chessex, 1993) — every card
|
||||
transcribed, every wall verified, replays rebuilt move by move from
|
||||
the game's own ledger.
|
||||
</p>
|
||||
<a class="share-cta" href="/">▶ play free — two to six wizards enter the maze</a>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.share-page {
|
||||
min-height: 100vh;
|
||||
background: #171a20;
|
||||
color: #d8d2c0;
|
||||
font-family: "Archivo Narrow", system-ui, sans-serif;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
.share-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 1rem;
|
||||
width: min(46rem, 100%);
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
.share-brand {
|
||||
font-family: "Oswald", sans-serif;
|
||||
letter-spacing: 0.3em;
|
||||
font-size: 1.1rem;
|
||||
color: #e0b34a;
|
||||
text-decoration: none;
|
||||
}
|
||||
.share-title {
|
||||
font-family: "Oswald", sans-serif;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
font-size: 0.85rem;
|
||||
color: #e9e1cb;
|
||||
}
|
||||
/* The reel is a modal everywhere else; here it stands in the page. */
|
||||
.share-stage { width: min(46rem, 100%); }
|
||||
.share-stage :global(.replay-scrim) {
|
||||
position: relative;
|
||||
inset: auto;
|
||||
background: none;
|
||||
padding: 0;
|
||||
display: block;
|
||||
z-index: 1;
|
||||
}
|
||||
.share-stage :global(.replay) { width: 100%; max-height: none; }
|
||||
.share-note {
|
||||
background: #efe8d4;
|
||||
color: #3a2f1f;
|
||||
border-radius: 4px;
|
||||
padding: 1.2rem 1.5rem;
|
||||
margin: 2rem 0;
|
||||
font-family: "Courier Prime", monospace;
|
||||
text-align: center;
|
||||
}
|
||||
.share-foot {
|
||||
width: min(46rem, 100%);
|
||||
margin-top: 1rem;
|
||||
color: #8d8672;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.share-foot strong { color: #c9a72a; }
|
||||
.share-cta {
|
||||
display: inline-block;
|
||||
margin-top: 0.4rem;
|
||||
background: #e9e1cb;
|
||||
color: #43331f;
|
||||
border: 1.5px solid #43331f;
|
||||
border-radius: 3px;
|
||||
padding: 0.45rem 0.9rem;
|
||||
text-decoration: none;
|
||||
font-family: "Oswald", sans-serif;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
</style>
|
||||
@@ -4,6 +4,12 @@
|
||||
// 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";
|
||||
import EdgeGlyph, { type LockState } from "./EdgeGlyph.svelte";
|
||||
import FirewallEdge from "./FirewallEdge.svelte";
|
||||
import IllusionShimmer from "./IllusionShimmer.svelte";
|
||||
import { FX_ART } from "./fpv/fx3d";
|
||||
import { MATERIALS } from "./fpv/textures";
|
||||
import { TERRAIN3D } from "./fpv/terrain3d";
|
||||
|
||||
const GROUPS: { title: string; files: string[] }[] = [
|
||||
{ title: "Wizards", files: ["wizard-0", "wizard-1", "wizard-2", "wizard-3", "wizard-4", "wizard-5"] },
|
||||
@@ -28,15 +34,12 @@
|
||||
},
|
||||
];
|
||||
|
||||
// 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";
|
||||
// Edge treatments, not tokens — see EdgeGlyph. Shown exactly as the
|
||||
// board mounts them.
|
||||
const EDGE_PIECES: {
|
||||
caption: string;
|
||||
state?: "wall" | "door";
|
||||
lock?: "removed" | "jammed" | "held" | "ajar" | null;
|
||||
lock?: LockState | null;
|
||||
special?: "firewall" | "shimmer" | "known-illusion";
|
||||
}[] = [
|
||||
{ caption: "wall", state: "wall" },
|
||||
@@ -86,6 +89,57 @@
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<h2>The masonry — first-person wall textures</h2>
|
||||
<p class="sub">
|
||||
The materials the raycaster (<code>/?fpv</code>) wraps its walls in.
|
||||
Each lives at <code>public/textures/<name>.png</code> — repaint the
|
||||
file (any size) and refresh; the built-in procedural bake stands in
|
||||
wherever a file is missing.
|
||||
</p>
|
||||
<div class="grid">
|
||||
{#each MATERIALS as name (name)}
|
||||
<figure>
|
||||
<img class="masonry" src={`/textures/${name}.png`} alt={`${name} texture`} />
|
||||
<figcaption>{name}</figcaption>
|
||||
</figure>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<h2>The conjurations — first-person spell sprites</h2>
|
||||
<p class="sub">
|
||||
The moments the raycaster throws through the air: projectiles in
|
||||
flight, bursts on landing, shimmers and shields. Each lives at
|
||||
<code>public/fx3d/<name>.png</code> — square, transparent ground,
|
||||
drawn glowing mid-air at any size; a procedural glow stands in
|
||||
wherever a file is missing.
|
||||
</p>
|
||||
<div class="grid">
|
||||
{#each FX_ART as name (name)}
|
||||
<figure>
|
||||
<img class="masonry conjuration" src={`/fx3d/${name}.png`} alt={`${name} sprite`} />
|
||||
<figcaption>{name}</figcaption>
|
||||
</figure>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<h2>The standing terrain — first-person volumes</h2>
|
||||
<p class="sub">
|
||||
Terrain that stands in the room: hedges filling a square to half
|
||||
height, the killer ooze as a translucent cube, dust billowing, the
|
||||
dimensional warp's pillar of light. Each lives at
|
||||
<code>public/terrain3d/<name>.png</code> — transparent PNG, any
|
||||
size; hedges render twice as wide as tall. Rehearse them in place with
|
||||
<code>/?fpv&terrain=thornbush@3,7;jello@4,7</code>.
|
||||
</p>
|
||||
<div class="grid">
|
||||
{#each TERRAIN3D as name (name)}
|
||||
<figure>
|
||||
<img class="masonry conjuration" src={`/terrain3d/${name}.png`} alt={`${name} sprite`} />
|
||||
<figcaption>{name}</figcaption>
|
||||
</figure>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<h2>Walls & doors — as the maze draws them</h2>
|
||||
<p class="sub">
|
||||
Wall-segment effects have never been tokens: locks picked, jammed, and
|
||||
@@ -172,4 +226,13 @@
|
||||
text-align: center;
|
||||
padding-top: 0.35rem;
|
||||
}
|
||||
.masonry {
|
||||
width: 128px;
|
||||
height: 128px;
|
||||
image-rendering: pixelated;
|
||||
border: 1px solid #3a3428;
|
||||
background: #101318;
|
||||
}
|
||||
/* Sprites fly against darkness; show them on it. */
|
||||
.conjuration { background: #14101c; }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,872 @@
|
||||
<script lang="ts">
|
||||
// The maze through one wizard's eyes: a canvas raycaster over the
|
||||
// GameView. Columns of wall shaded by distance and facing; token art
|
||||
// billboarded for whatever stands in the corridors, occluded per column
|
||||
// by the same depth buffer the walls wrote.
|
||||
import { billboards, castRay, warpMotion, type FpvTarget } from "./raycast";
|
||||
import { materialTextures } from "./textures";
|
||||
import { doorOpenness, fxFallback, growProgress, type FpFx } from "./fx3d";
|
||||
import { terrainFallback, TERRAIN3D } from "./terrain3d";
|
||||
import { tokenArt } from "../art";
|
||||
import { PLAYER_COLORS } from "../colors";
|
||||
import type { GameView } from "@wizwar/engine";
|
||||
|
||||
let {
|
||||
view,
|
||||
povId,
|
||||
x,
|
||||
y,
|
||||
facing,
|
||||
width = 720,
|
||||
height = 440,
|
||||
fx = [],
|
||||
posOverride,
|
||||
rubble = [],
|
||||
ontarget,
|
||||
litCells = null,
|
||||
}: {
|
||||
view: GameView;
|
||||
povId: string;
|
||||
/** Eye position in world units (cell centers are n + 0.5). */
|
||||
x: number;
|
||||
y: number;
|
||||
/** Radians; 0 faces east, matching the board's +x. */
|
||||
facing: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
/** Live spell moments: projectiles, impacts, flashes, shakes. */
|
||||
fx?: FpFx[];
|
||||
/** Bodies mid-glide: world positions that override the view's cells. */
|
||||
posOverride?: Record<string, { x: number; y: number }>;
|
||||
/** Where walls have died: a mound of stone marks each fallen edge. */
|
||||
rubble?: { x: number; y: number }[];
|
||||
/** Present = the pane is an instrument: clicks resolve to targets. */
|
||||
ontarget?: (t: FpvTarget) => void;
|
||||
/** Squares a selected cell-target card may aim at: the pane dims the
|
||||
* ineligible ground exactly as the board dims its squares. */
|
||||
litCells?: Set<string> | null;
|
||||
} = $props();
|
||||
|
||||
const FOV = Math.PI / 2.9;
|
||||
let canvas: HTMLCanvasElement;
|
||||
|
||||
/** Everything the LAST drawn frame knew, kept for click resolution:
|
||||
* the pane is an instrument only because the renderer remembers what
|
||||
* stood under every pixel. */
|
||||
let hitFrame: {
|
||||
W: number; H: number; ex: number; ey: number; facing: number;
|
||||
zbuf: Float64Array; warpIdCol: Int32Array; warpDistCol: Float64Array;
|
||||
cols: ({ edge?: string; kind: string; top: number; h: number } | null)[];
|
||||
/** Depth-sorted nearest-first, ready for hover hit-testing. */
|
||||
sprites: Projected[];
|
||||
} | null = null;
|
||||
|
||||
/** The one visibility rule a sprite obeys in a column — shared by the
|
||||
* draw pass and the hover hit test so they can never disagree. */
|
||||
function spriteVisibleInCol(
|
||||
sp: Projected, col: number,
|
||||
zbuf: Float64Array, warpIdCol: Int32Array, warpDistCol: Float64Array,
|
||||
): boolean {
|
||||
if (sp.depth >= zbuf[col]!) return false;
|
||||
if (sp.warped) {
|
||||
if (warpIdCol[col] !== sp.warpId || sp.depth <= warpDistCol[col]!) return false;
|
||||
} else if (sp.depth >= warpDistCol[col]!) return false;
|
||||
if (sp.clampL !== undefined && (col < sp.clampL || col > sp.clampR!)) return false;
|
||||
return true;
|
||||
}
|
||||
/** Crosshair position in canvas pixels, while the pointer is over the
|
||||
* pane and the pane is an instrument. */
|
||||
let mouse: { x: number; y: number } | null = null;
|
||||
|
||||
// Token art loads lazily; a sprite draws once its image has arrived.
|
||||
const images = new Map<string, HTMLImageElement>();
|
||||
function imageFor(src: string): HTMLImageElement | null {
|
||||
let img = images.get(src);
|
||||
if (!img) {
|
||||
img = new Image();
|
||||
img.src = src;
|
||||
images.set(src, img);
|
||||
}
|
||||
return img.complete && img.naturalWidth > 0 ? img : null;
|
||||
}
|
||||
|
||||
// Materials come from /textures/*.png when those files exist — the
|
||||
// independently paintable set — with the baked procedurals underneath
|
||||
// so a missing or still-loading file never leaves a wall naked.
|
||||
const textures = materialTextures();
|
||||
|
||||
// Floor-casting samples per PIXEL, so the two ground-plane textures are
|
||||
// cached as raw pixel buffers — re-extracted whenever a painted file
|
||||
// finishes loading and swaps the entry.
|
||||
const pixelCache = new WeakMap<object, ImageData>();
|
||||
function pixelsOf(src: CanvasImageSource & { width: number; height: number }): ImageData {
|
||||
let px = pixelCache.get(src);
|
||||
if (!px) {
|
||||
const t = document.createElement("canvas");
|
||||
t.width = src.width;
|
||||
t.height = src.height;
|
||||
const c = t.getContext("2d")!;
|
||||
c.drawImage(src, 0, 0);
|
||||
px = c.getImageData(0, 0, t.width, t.height);
|
||||
pixelCache.set(src, px);
|
||||
}
|
||||
return px;
|
||||
}
|
||||
let frame: ImageData | null = null;
|
||||
|
||||
// The floor wears decals: home emblems in their owners' colors, pits
|
||||
// opening downward, slime, tacks, dimensional warp rings — a per-view
|
||||
// grid mapping each cell to one overlay, read by the per-pixel pass.
|
||||
interface Decal { tex: string; tint: [number, number, number] | null }
|
||||
const decalCache = new WeakMap<object, { grid: Int16Array; bw: number; bh: number; decals: Decal[] }>();
|
||||
function decalsOf(v: GameView) {
|
||||
let h = decalCache.get(v);
|
||||
if (!h) {
|
||||
const bw = v.board.width, bh = v.board.height;
|
||||
const grid = new Int16Array(bw * bh).fill(-1);
|
||||
const decals: Decal[] = [];
|
||||
const put = (x: number, y: number, tex: string, tint: Decal["tint"] = null) => {
|
||||
if (x < 0 || y < 0 || x >= bw || y >= bh) return;
|
||||
decals.push({ tex, tint });
|
||||
grid[y * bw + x] = decals.length - 1;
|
||||
};
|
||||
for (const p of v.players) {
|
||||
if (!p.home) continue;
|
||||
const hex = PLAYER_COLORS[p.colorIndex] ?? "#888888";
|
||||
put(p.home.x, p.home.y, "home", [
|
||||
parseInt(hex.slice(1, 3), 16) / 255,
|
||||
parseInt(hex.slice(3, 5), 16) / 255,
|
||||
parseInt(hex.slice(5, 7), 16) / 255,
|
||||
]);
|
||||
}
|
||||
const DECAL_KIND: Record<string, string> = {
|
||||
pit: "pit", slime: "slime", tacks: "tacks",
|
||||
thornbush: "underbrush", rosebush: "underbrush",
|
||||
};
|
||||
for (const [k, content] of Object.entries(v.squareContents)) {
|
||||
const tex = DECAL_KIND[content.kind];
|
||||
if (!tex) continue;
|
||||
const [cx, cy] = k.split(",").map(Number) as [number, number];
|
||||
put(cx, cy, tex);
|
||||
}
|
||||
for (const w of v.dimWarps) {
|
||||
put(w.a.x, w.a.y, "dimwarp");
|
||||
put(w.b.x, w.b.y, "dimwarp");
|
||||
}
|
||||
h = { grid, bw, bh, decals };
|
||||
decalCache.set(v, h);
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
function draw(time: number) {
|
||||
const ctx = canvas?.getContext("2d");
|
||||
if (!ctx) return;
|
||||
ctx.imageSmoothingEnabled = false; // crisp texels, as the old masters drew
|
||||
const W = width, H = height, half = H / 2;
|
||||
|
||||
// Active shakes wobble the eye a hair off true, fading as they run.
|
||||
let ex = x, ey = y;
|
||||
for (const f of fx) {
|
||||
if (f.kind !== "shake") continue;
|
||||
const p = (time - f.t0) / f.dur;
|
||||
if (p < 0 || p >= 1) continue;
|
||||
ex += f.mag * (1 - p) * Math.sin(time * 0.11);
|
||||
ey += f.mag * (1 - p) * Math.cos(time * 0.087);
|
||||
}
|
||||
|
||||
// Doors mid-swing and conjurations mid-growth this frame.
|
||||
let doors: Record<string, number> | undefined;
|
||||
let growing: Record<string, number> | undefined;
|
||||
const spawn: Record<string, number> = {};
|
||||
for (const f of fx) {
|
||||
if (f.kind === "door") {
|
||||
const a = doorOpenness((time - f.t0) / f.dur);
|
||||
if (a > 0) (doors ??= {})[f.edge] = Math.max(doors?.[f.edge] ?? 0, a);
|
||||
} else if (f.kind === "grow") {
|
||||
const p = (time - f.t0) / f.dur;
|
||||
if (p < 0 || p >= 1) continue;
|
||||
const g = growProgress(p);
|
||||
if (f.slot === "solid") (growing ??= {})[f.key] = g;
|
||||
else spawn[f.key] = g;
|
||||
}
|
||||
}
|
||||
|
||||
// The ground and the vault overhead, cast per pixel: each screen row
|
||||
// below (or above) the horizon lies at one fixed depth, so the row is
|
||||
// walked with a constant world-space step and sampled from the floor
|
||||
// or ceiling texture — every maze cell wearing one full tile of it.
|
||||
const flen = (W / 2) / Math.tan(FOV / 2);
|
||||
const cosF = Math.cos(facing), sinF = Math.sin(facing);
|
||||
if (!frame || frame.width !== W || frame.height !== H) frame = ctx.createImageData(W, H);
|
||||
const buf = frame.data;
|
||||
const fl = pixelsOf(textures.floor!);
|
||||
const ce = pixelsOf(textures.ceiling!);
|
||||
const FOG = 13;
|
||||
// A selected cell-card's eligibility, rasterized once for the pixel
|
||||
// loop: 1 = castable ground, 0 = dimmed. Absent card = null = all lit.
|
||||
let litGrid: Uint8Array | null = null;
|
||||
const litBw = view.board.width, litBh = view.board.height;
|
||||
if (litCells) {
|
||||
litGrid = new Uint8Array(litBw * litBh);
|
||||
for (const k of litCells) {
|
||||
const [lx, ly] = k.split(",").map(Number) as [number, number];
|
||||
if (lx >= 0 && ly >= 0 && lx < litBw && ly < litBh) litGrid[ly * litBw + lx] = 1;
|
||||
}
|
||||
}
|
||||
for (let row = 0; row < H; row++) {
|
||||
const below = row > half;
|
||||
const dz = below ? row - half : half - row;
|
||||
if (dz < 1) {
|
||||
// The horizon sliver: fog it flat.
|
||||
for (let col = 0; col < W; col++) {
|
||||
const o = (row * W + col) * 4;
|
||||
buf[o] = 8; buf[o + 1] = 7; buf[o + 2] = 9; buf[o + 3] = 255;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const d = (H / 2) / dz;
|
||||
const dec = below ? decalsOf(view) : null;
|
||||
const tex = below ? fl : ce;
|
||||
const tw = tex.width, th = tex.height;
|
||||
const tp = tex.data;
|
||||
const shade = Math.max(0, Math.min(1, 1.25 / (1 + d * 0.45)) * (1 - d / FOG));
|
||||
// World position at column 0 and its per-column step, both at depth d.
|
||||
const sideStep = d / flen;
|
||||
const side0 = -(W / 2) * sideStep;
|
||||
let wx = ex + d * cosF - side0 * sinF;
|
||||
let wy = ey + d * sinF + side0 * cosF;
|
||||
const stepX = -sideStep * sinF;
|
||||
const stepY = sideStep * cosF;
|
||||
for (let col = 0; col < W; col++) {
|
||||
const o = (row * W + col) * 4;
|
||||
if (shade <= 0.02) {
|
||||
buf[o] = 8; buf[o + 1] = 7; buf[o + 2] = 9;
|
||||
} else {
|
||||
let u = wx % 1; if (u < 0) u += 1;
|
||||
let v = wy % 1; if (v < 0) v += 1;
|
||||
const ti = (((v * th) | 0) * tw + ((u * tw) | 0)) * 4;
|
||||
let r = tp[ti]!, g = tp[ti + 1]!, b = tp[ti + 2]!;
|
||||
let sh = shade;
|
||||
if (litGrid && below) {
|
||||
const gx = (wx - u) | 0, gy = (wy - v) | 0;
|
||||
if (gx < 0 || gy < 0 || gx >= litBw || gy >= litBh || !litGrid[gy * litBw + gx]) sh *= 0.3;
|
||||
}
|
||||
if (dec) {
|
||||
const hx = (wx - u) | 0, hy = (wy - v) | 0;
|
||||
if (hx >= 0 && hy >= 0 && hx < dec.bw && hy < dec.bh) {
|
||||
const di = dec.grid[hy * dec.bw + hx]!;
|
||||
if (di >= 0) {
|
||||
// A decal lies over the flagstones, alpha-blended texel
|
||||
// by texel; home emblems wear their owner's color.
|
||||
const decal = dec.decals[di]!;
|
||||
const hp = pixelsOf(textures[decal.tex] ?? textures.home!);
|
||||
const hi = (((v * hp.height) | 0) * hp.width + ((u * hp.width) | 0)) * 4;
|
||||
const ha = hp.data[hi + 3]! / 255;
|
||||
if (ha > 0) {
|
||||
const [tr, tg, tb] = decal.tint ?? [1, 1, 1];
|
||||
r = r * (1 - ha) + hp.data[hi]! * tr * ha;
|
||||
g = g * (1 - ha) + hp.data[hi + 1]! * tg * ha;
|
||||
b = b * (1 - ha) + hp.data[hi + 2]! * tb * ha;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
buf[o] = r * sh;
|
||||
buf[o + 1] = g * sh;
|
||||
buf[o + 2] = b * sh;
|
||||
}
|
||||
buf[o + 3] = 255;
|
||||
wx += stepX;
|
||||
wy += stepY;
|
||||
}
|
||||
}
|
||||
ctx.putImageData(frame, 0, 0);
|
||||
|
||||
// Walls, one ray per column; remember each column's depth for
|
||||
// sprites, and whether its ray bent through a warp — a sprite's warp
|
||||
// side must MATCH its column's, or bodies near a far mouth would
|
||||
// ghost into real corridors the unrolled space happens to overlap.
|
||||
const zbuf = new Float64Array(W);
|
||||
const hitCols: ({ edge?: string; kind: string; top: number; h: number } | null)[] = new Array(W).fill(null);
|
||||
// Per column: which warp (if any) the ray bent through, and how far
|
||||
// away that mouth stood. A REAL body paints a column only if it is
|
||||
// nearer than the mouth (it stands in front of the window); a
|
||||
// VIRTUAL one only through its OWN warp's columns, beyond the mouth.
|
||||
const warpIdCol = new Int32Array(W).fill(-1);
|
||||
const warpDistCol = new Float64Array(W).fill(Infinity);
|
||||
// Each mouth a ray passed through hangs a translucent veil of the
|
||||
// warp texture at its own depth, drawn with the other overlays.
|
||||
const veils: { col: number; depth: number; u: number }[] = [];
|
||||
// Known illusions: the ray passes, but a translucent ghost of a wall
|
||||
// stands at the crossing — drawn after the sprites so bodies show
|
||||
// through it, shimmering so nobody mistakes it for stone.
|
||||
const ghosts: { col: number; depth: number; u: number; worldU: number }[] = [];
|
||||
// Open doorways collect their lintels, hung after the sprites; and
|
||||
// conjurations still rising collect their growing columns.
|
||||
const lintels: { col: number; depth: number; u: number }[] = [];
|
||||
const risings: { col: number; depth: number; u: number; worldU: number; kind: string; g: number }[] = [];
|
||||
for (let col = 0; col < W; col++) {
|
||||
const rayAngle = facing + Math.atan((col / W - 0.5) * 2 * Math.tan(FOV / 2));
|
||||
const hit = castRay(view, ex, ey, rayAngle, doors, growing);
|
||||
const depth = hit.dist * Math.cos(rayAngle - facing); // no fisheye
|
||||
zbuf[col] = depth;
|
||||
if (hit.warpId !== undefined) {
|
||||
warpIdCol[col] = hit.warpId;
|
||||
warpDistCol[col] = (hit.warpDist ?? 0) * Math.cos(rayAngle - facing);
|
||||
veils.push({ col, depth: warpDistCol[col]!, u: hit.warpU ?? 0 });
|
||||
}
|
||||
if (hit.ghost) {
|
||||
ghosts.push({
|
||||
col, depth: hit.ghost.dist * Math.cos(rayAngle - facing),
|
||||
u: hit.ghost.u, worldU: hit.ghost.worldU,
|
||||
});
|
||||
}
|
||||
if (hit.doorway) {
|
||||
lintels.push({ col, depth: hit.doorway.dist * Math.cos(rayAngle - facing), u: hit.doorway.u });
|
||||
}
|
||||
if (hit.rising) {
|
||||
risings.push({
|
||||
col, depth: hit.rising.dist * Math.cos(rayAngle - facing),
|
||||
u: hit.rising.u, worldU: hit.rising.worldU, kind: hit.rising.kind, g: hit.rising.g,
|
||||
});
|
||||
}
|
||||
const wallH = Math.min(H * 2.5, H / Math.max(depth, 0.05));
|
||||
const top = half - wallH / 2;
|
||||
hitCols[col] = { edge: hit.edge, kind: hit.frame ? "frame" : hit.kind, top, h: wallH };
|
||||
const tex = hit.frame ? textures.doorframe! : textures[hit.kind] ?? textures.wall!;
|
||||
// Sample by the texture's own size: painted files may be any scale.
|
||||
// Fire shifts its slice per world cell, so a blaze spanning edges
|
||||
// reads as one long fire, not a repeated flame.
|
||||
const texU = hit.kind === "firewall"
|
||||
? (hit.u + Math.floor(hit.worldU) * 0.37) % 1
|
||||
: hit.u;
|
||||
ctx.drawImage(tex, Math.min(tex.width - 1, texU * tex.width), 0,
|
||||
Math.max(1, tex.width / 96), tex.height, col, top, 1, wallH);
|
||||
if (!hit.frame && (hit.kind === "wall" || hit.kind === "door") && hit.edge) {
|
||||
// Harm shows: accumulated damage wears fractures into the face.
|
||||
const dmg = view.wallDamage[hit.edge];
|
||||
if (dmg) {
|
||||
const ck = textures.cracks!;
|
||||
ctx.globalAlpha = Math.min(0.9, 0.35 + dmg * 0.2);
|
||||
ctx.drawImage(ck, Math.min(ck.width - 1, texU * ck.width), 0,
|
||||
Math.max(1, ck.width / 96), ck.height, col, top, 1, wallH);
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
}
|
||||
// Distance and orientation carve the light; overlays animate it.
|
||||
let dark = 1 - Math.min(1, 1.35 / (1 + depth * 0.45));
|
||||
if (hit.axis === "y") dark = 1 - (1 - dark) * 0.8;
|
||||
if (hit.kind === "firewall") {
|
||||
const flicker = 0.15 + 0.15 * Math.sin(time / 90 + hit.worldU * 17 + col * 0.15);
|
||||
ctx.fillStyle = `rgba(255,140,40,${Math.max(0, flicker)})`;
|
||||
ctx.fillRect(col, top, 1, wallH);
|
||||
dark *= 0.5; // the fire lights itself
|
||||
}
|
||||
if (hit.kind === "wall" && hit.edge && view.illusionEdges[hit.edge] === "untested") {
|
||||
// The same tell the board gives: an untested illusion's face
|
||||
// shimmers faintly — maybe stone, maybe not.
|
||||
const tell = 0.06 + 0.05 * Math.sin(time / 260 + hit.worldU * 13);
|
||||
ctx.fillStyle = `rgba(190,160,255,${Math.max(0, tell)})`;
|
||||
ctx.fillRect(col, top, 1, wallH);
|
||||
}
|
||||
if (hit.kind === "door" && hit.edge && view.doorStates[hit.edge] === "jammed") {
|
||||
// A jammed lock seethes: a rusty seal pulsing across the wood.
|
||||
const seethe = 0.10 + 0.08 * Math.sin(time / 160 + hit.worldU * 22);
|
||||
ctx.fillStyle = `rgba(210,70,20,${Math.max(0, seethe)})`;
|
||||
ctx.fillRect(col, top + wallH * 0.35, 1, wallH * 0.3);
|
||||
}
|
||||
if (dark > 0.02) {
|
||||
ctx.fillStyle = `rgba(0,0,0,${Math.min(0.92, dark)})`;
|
||||
ctx.fillRect(col, top, 1, wallH);
|
||||
}
|
||||
if (hit.warped) {
|
||||
// Seen through a warp: the far side swims in violet haze.
|
||||
const haze = 0.16 + 0.05 * Math.sin(time / 300 + col * 0.05);
|
||||
ctx.fillStyle = `rgba(120,70,220,${haze})`;
|
||||
ctx.fillRect(col, top, 1, wallH);
|
||||
}
|
||||
}
|
||||
|
||||
// Sprites, far to near, sliced against the depth buffer.
|
||||
const sprites = billboards(view, povId, tokenArt, posOverride)
|
||||
.map((b) => {
|
||||
const g = b.key !== undefined ? spawn[b.key] : undefined;
|
||||
return project(g !== undefined ? { ...b, scale: b.scale * g } : b, ex, ey);
|
||||
})
|
||||
.filter((s): s is Projected => s !== null);
|
||||
for (const spot of rubble) {
|
||||
const s = project({ x: spot.x, y: spot.y, src: "/fx3d/rubble.png", scale: 0.5, aspect: 2, rise: 0 }, ex, ey);
|
||||
if (s) sprites.push({ ...s, fallback: "rubble" });
|
||||
}
|
||||
for (const f of fx) {
|
||||
if (f.kind !== "projectile" && f.kind !== "impact") continue;
|
||||
const p = (time - f.t0) / f.dur;
|
||||
if (p < 0 || p >= 1) continue;
|
||||
const at = f.kind === "projectile"
|
||||
? { x: f.from.x + (f.to.x - f.from.x) * p, y: f.from.y + (f.to.y - f.from.y) * p }
|
||||
: f.at;
|
||||
const s = project({
|
||||
x: at.x, y: at.y, src: `/fx3d/${f.art}.png`,
|
||||
scale: f.kind === "projectile" ? 0.3 : 0.25 + 0.6 * p,
|
||||
rise: f.kind === "impact" ? (f.rise ?? 0.3) : 0.3,
|
||||
warped: f.kind === "projectile" ? f.warped : undefined,
|
||||
warpId: f.kind === "projectile" ? f.warpId : undefined,
|
||||
}, ex, ey);
|
||||
if (s) sprites.push({ ...s, alpha: f.kind === "impact" ? 1 - p : 1, fallback: f.art });
|
||||
}
|
||||
sprites.sort((a, b) => b.sort - a.sort);
|
||||
hitFrame = {
|
||||
W, H, ex, ey, facing, zbuf, warpIdCol, warpDistCol, cols: hitCols,
|
||||
sprites: [...sprites].sort((a, b) => a.depth - b.depth),
|
||||
};
|
||||
// The nearest sprite each column carries — depth AND vertical span —
|
||||
// so the overlay passes (veils, ghosts, lintels, risings) can paint
|
||||
// around a body standing in front of them instead of over it.
|
||||
const spriteZ = new Float64Array(W).fill(Infinity);
|
||||
const sprTop = new Float64Array(W);
|
||||
const sprBot = new Float64Array(W);
|
||||
/** The parts of an overlay column NOT hidden behind the column's
|
||||
* nearest sprite: whole, split around it, or nothing. */
|
||||
const maskedSegs = (col: number, depth: number, top: number, h: number): [number, number][] => {
|
||||
if (depth < spriteZ[col]!) return [[top, h]];
|
||||
const segs: [number, number][] = [];
|
||||
const bottom = top + h;
|
||||
if (sprTop[col]! > top) segs.push([top, Math.min(h, sprTop[col]! - top)]);
|
||||
if (sprBot[col]! < bottom) segs.push([sprBot[col]!, bottom - sprBot[col]!]);
|
||||
return segs;
|
||||
};
|
||||
for (const s of sprites) {
|
||||
const img = imageFor(s.src) ??
|
||||
(s.fallback
|
||||
? (TERRAIN3D.includes(s.fallback as (typeof TERRAIN3D)[number])
|
||||
? terrainFallback(s.fallback) : fxFallback(s.fallback))
|
||||
: null);
|
||||
const iw = img instanceof HTMLImageElement ? img.naturalWidth : (img?.width ?? 0);
|
||||
const ih = img instanceof HTMLImageElement ? img.naturalHeight : (img?.height ?? 0);
|
||||
// Painted art composites normally (inks stay true); light glows
|
||||
// additively. Either way translucency applies.
|
||||
if (s.glow) ctx.globalCompositeOperation = "lighter";
|
||||
if (s.alpha !== undefined || s.glow) ctx.globalAlpha = s.alpha ?? 1;
|
||||
for (let col = Math.max(0, s.left | 0); col < Math.min(W, s.right); col++) {
|
||||
if (!spriteVisibleInCol(s, col, zbuf, warpIdCol, warpDistCol)) continue;
|
||||
if (s.depth < spriteZ[col]!) {
|
||||
spriteZ[col] = s.depth;
|
||||
sprTop[col] = s.top;
|
||||
sprBot[col] = s.bottom;
|
||||
}
|
||||
const texX = ((col - s.left) / (s.right - s.left));
|
||||
if (img) {
|
||||
ctx.drawImage(
|
||||
img,
|
||||
texX * iw, 0, Math.max(1, iw / (s.right - s.left)), ih,
|
||||
col, s.top, 1, s.bottom - s.top,
|
||||
);
|
||||
} else {
|
||||
ctx.fillStyle = "rgba(200,190,160,0.6)";
|
||||
ctx.fillRect(col, s.top, 1, s.bottom - s.top);
|
||||
}
|
||||
if (s.warped) {
|
||||
// A body seen through a warp swims in the same violet haze.
|
||||
ctx.fillStyle = "rgba(120,70,220,0.2)";
|
||||
ctx.fillRect(col, s.top, 1, s.bottom - s.top);
|
||||
}
|
||||
}
|
||||
if (s.glow) ctx.globalCompositeOperation = "source-over";
|
||||
if (s.alpha !== undefined || s.glow) ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
// Warp mouths wear their veil: the warp texture at the opening,
|
||||
// translucent, swirling — the doorway between rooms that are not
|
||||
// neighbors announces itself.
|
||||
for (const vl of veils) {
|
||||
const vh = Math.min(H * 2.5, H / Math.max(vl.depth, 0.05));
|
||||
const vTop = half - vh / 2;
|
||||
const wt = textures.warp!;
|
||||
const swirl = 0.1 + 0.1 * Math.sin(time / 240 + vl.u * 9 + vl.col * 0.02);
|
||||
for (const [segTop, segH] of maskedSegs(vl.col, vl.depth, vTop, vh)) {
|
||||
ctx.globalAlpha = 0.3;
|
||||
ctx.drawImage(wt, Math.min(wt.width - 1, vl.u * wt.width),
|
||||
((segTop - vTop) / vh) * wt.height,
|
||||
Math.max(1, wt.width / 96), (segH / vh) * wt.height,
|
||||
vl.col, segTop, 1, segH);
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.fillStyle = `rgba(190,150,255,${Math.max(0, swirl)})`;
|
||||
ctx.fillRect(vl.col, segTop, 1, segH);
|
||||
}
|
||||
}
|
||||
|
||||
// Conjurations rising: the wall (or stone) grows bottom-up where it
|
||||
// will stand, glowing swirls running over the young face.
|
||||
for (const ri of risings) {
|
||||
const full = Math.min(H * 2.5, H / Math.max(ri.depth, 0.05));
|
||||
const grown = full * ri.g;
|
||||
const top0 = half + full / 2 - grown;
|
||||
const tex = textures[ri.kind] ?? textures.wall!;
|
||||
const swirl = (0.35 * (1 - ri.g) + 0.08) *
|
||||
(0.7 + 0.3 * Math.sin(time / 90 + ri.worldU * 21));
|
||||
for (const [segTop, segH] of maskedSegs(ri.col, ri.depth, top0, grown)) {
|
||||
ctx.globalAlpha = 0.55 + 0.45 * ri.g;
|
||||
ctx.drawImage(tex, Math.min(tex.width - 1, ri.u * tex.width),
|
||||
((segTop - top0) / grown) * tex.height,
|
||||
Math.max(1, tex.width / 96), (segH / grown) * tex.height,
|
||||
ri.col, segTop, 1, segH);
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.fillStyle = `rgba(190,150,255,${Math.max(0, swirl)})`;
|
||||
ctx.fillRect(ri.col, segTop, 1, segH);
|
||||
}
|
||||
}
|
||||
|
||||
// Doorway lintels: the frame's top rows hung across each opening.
|
||||
for (const li of lintels) {
|
||||
const lh = Math.min(H * 2.5, H / Math.max(li.depth, 0.05));
|
||||
const lTop = half - lh / 2;
|
||||
const band = lh * 0.16;
|
||||
const ft = textures.doorframe!;
|
||||
const dark = 1 - Math.min(1, 1.35 / (1 + li.depth * 0.45));
|
||||
for (const [segTop, segH] of maskedSegs(li.col, li.depth, lTop, band)) {
|
||||
ctx.drawImage(ft, Math.min(ft.width - 1, li.u * ft.width),
|
||||
((segTop - lTop) / band) * ft.height * 0.16,
|
||||
Math.max(1, ft.width / 96), (segH / band) * ft.height * 0.16,
|
||||
li.col, segTop, 1, segH);
|
||||
if (dark > 0.02) {
|
||||
ctx.fillStyle = `rgba(0,0,0,${Math.min(0.92, dark)})`;
|
||||
ctx.fillRect(li.col, segTop, 1, segH);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The ghosts of walls you know are lies: drawn over everything at
|
||||
// their columns, thin as breath, rippling.
|
||||
for (const gh of ghosts) {
|
||||
const gHft = Math.min(H * 2.5, H / Math.max(gh.depth, 0.05));
|
||||
const gTop = half - gHft / 2;
|
||||
const tex = textures.wall!;
|
||||
const ripple = 0.10 + 0.07 * Math.sin(time / 200 + gh.worldU * 11 + gHft * 0.01);
|
||||
for (const [segTop, segH] of maskedSegs(gh.col, gh.depth, gTop, gHft)) {
|
||||
ctx.globalAlpha = 0.2;
|
||||
ctx.drawImage(tex, Math.min(tex.width - 1, gh.u * tex.width),
|
||||
((segTop - gTop) / gHft) * tex.height,
|
||||
Math.max(1, tex.width / 96), (segH / gHft) * tex.height,
|
||||
gh.col, segTop, 1, segH);
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.fillStyle = `rgba(190,160,255,${Math.max(0, ripple)})`;
|
||||
ctx.fillRect(gh.col, segTop, 1, segH);
|
||||
}
|
||||
}
|
||||
|
||||
// A whisper of vignette holds the torchlit mood together.
|
||||
const vig = ctx.createRadialGradient(W / 2, half, H * 0.35, W / 2, half, H * 0.95);
|
||||
vig.addColorStop(0, "rgba(0,0,0,0)");
|
||||
vig.addColorStop(1, "rgba(0,0,0,0.45)");
|
||||
ctx.fillStyle = vig;
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
|
||||
// Being hit is felt: a whole-screen wash that fades as it goes.
|
||||
for (const f of fx) {
|
||||
if (f.kind !== "flash") continue;
|
||||
const p = (time - f.t0) / f.dur;
|
||||
if (p < 0 || p >= 1) continue;
|
||||
ctx.globalAlpha = f.peak * (1 - p);
|
||||
ctx.fillStyle = f.color;
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
// The crosshair's answer, before the click commits: what the pane
|
||||
// would target here, outlined in the table's gold with its name.
|
||||
if (ontarget && mouse) drawHover(ctx, W, H, half);
|
||||
}
|
||||
|
||||
/** Paint the hover cue for whatever stands under the crosshair. */
|
||||
function drawHover(ctx: CanvasRenderingContext2D, W: number, H: number, half: number) {
|
||||
const f = hitFrame;
|
||||
if (!f || !mouse) return;
|
||||
const found = resolveHover(mouse.x, mouse.y);
|
||||
if (!found) return;
|
||||
ctx.save();
|
||||
ctx.strokeStyle = "#c9a72a";
|
||||
ctx.fillStyle = "rgba(201,167,42,0.14)";
|
||||
ctx.lineWidth = 1.5;
|
||||
let label = "";
|
||||
if (found.shape.kind === "rect") {
|
||||
const r = found.shape;
|
||||
ctx.strokeRect(r.left, r.top, r.right - r.left, r.bottom - r.top);
|
||||
ctx.fillRect(r.left, r.top, r.right - r.left, r.bottom - r.top);
|
||||
label = found.label;
|
||||
} else if (found.shape.kind === "face") {
|
||||
// Tint every column that shows THIS edge: the whole face answers.
|
||||
for (let col = 0; col < f.W; col++) {
|
||||
const c = f.cols[col];
|
||||
if (!c || c.edge !== found.shape.edge) continue;
|
||||
ctx.fillRect(col, c.top, 1, c.h);
|
||||
}
|
||||
label = found.label;
|
||||
} else if (found.shape.kind === "none") {
|
||||
label = found.label;
|
||||
} else {
|
||||
// A ground square: its four corners projected onto the floor plane.
|
||||
const flen = (f.W / 2) / Math.tan(FOV / 2);
|
||||
const cosF = Math.cos(f.facing), sinF = Math.sin(f.facing);
|
||||
const { x: cx0, y: cy0 } = found.shape.cell;
|
||||
const pts: [number, number][] = [];
|
||||
for (const [ox, oy] of [[0, 0], [1, 0], [1, 1], [0, 1]] as const) {
|
||||
const rx = cx0 + ox - f.ex, ry = cy0 + oy - f.ey;
|
||||
const depth = rx * cosF + ry * sinF;
|
||||
if (depth < 0.12) { pts.length = 0; break; }
|
||||
const side = -rx * sinF + ry * cosF;
|
||||
pts.push([f.W / 2 + (side / depth) * flen, half + (f.H / 2) / depth]);
|
||||
}
|
||||
if (pts.length === 4) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(pts[0]![0], pts[0]![1]);
|
||||
for (const [px, py] of pts.slice(1)) ctx.lineTo(px, py);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
if (label) {
|
||||
ctx.font = "12px 'Courier Prime', monospace";
|
||||
const wTxt = ctx.measureText(label).width;
|
||||
const lx = Math.min(f.W - wTxt - 10, Math.max(4, mouse.x + 10));
|
||||
const ly = Math.max(16, mouse.y - 8);
|
||||
ctx.fillStyle = "rgba(13,12,18,0.85)";
|
||||
ctx.fillRect(lx - 4, ly - 12, wTxt + 8, 16);
|
||||
ctx.fillStyle = "#e9e1cb";
|
||||
ctx.fillText(label, lx, ly);
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
interface Projected {
|
||||
src: string;
|
||||
depth: number;
|
||||
left: number;
|
||||
right: number;
|
||||
top: number;
|
||||
bottom: number;
|
||||
warped?: boolean;
|
||||
warpId?: number;
|
||||
glow?: boolean;
|
||||
alpha?: number;
|
||||
fallback?: string;
|
||||
clampL?: number;
|
||||
clampR?: number;
|
||||
hit?: { kind: "player" | "creature"; id: string };
|
||||
cell?: { x: number; y: number };
|
||||
/** Sort key only: the near-bias orders sprites among THEMSELVES (a
|
||||
* hedge over the wizard standing in it) but must never let one
|
||||
* cheat past a wall — occlusion always uses the true depth. */
|
||||
sort: number;
|
||||
}
|
||||
function project(
|
||||
b: { x: number; y: number; src: string; scale: number; rise: number;
|
||||
aspect?: number; alpha?: number; glow?: boolean; bias?: number;
|
||||
fallback?: string; warped?: boolean; warpId?: number;
|
||||
clip?: { x: number; y: number };
|
||||
hit?: { kind: "player" | "creature"; id: string };
|
||||
cell?: { x: number; y: number } },
|
||||
ex: number, ey: number,
|
||||
): Projected | null {
|
||||
const relX = b.x - ex, relY = b.y - ey;
|
||||
const depth = relX * Math.cos(facing) + relY * Math.sin(facing);
|
||||
if (depth < 0.15) return null;
|
||||
const side = -relX * Math.sin(facing) + relY * Math.cos(facing);
|
||||
const W = width, H = height, half = H / 2;
|
||||
const screenX = W / 2 + (side / depth) * (W / 2) / Math.tan(FOV / 2);
|
||||
const wallH = H / depth;
|
||||
const size = wallH * b.scale;
|
||||
// Vertical pixels scale by H/depth, horizontal by focal/depth — and
|
||||
// they differ. A sprite that declares an aspect means WORLD width
|
||||
// (a hedge one cell wide must touch both posts), so its width uses
|
||||
// the horizontal scale; plain square sprites keep their pixel shape.
|
||||
const wide = b.aspect
|
||||
? size * b.aspect * (((W / 2) / Math.tan(FOV / 2)) / H)
|
||||
: size;
|
||||
const bottom = half + wallH / 2 - b.rise * wallH;
|
||||
// A cell-bound volume is windowed to its own square: the billboard's
|
||||
// camera-facing plane is intersected with the cell, and only that
|
||||
// lateral segment may paint, so an obliquely-viewed hedge cannot
|
||||
// poke its ends through the neighboring walls. (Virtual warp copies
|
||||
// skip this — their clip cell lives in another frame.)
|
||||
let clampL: number | undefined;
|
||||
let clampR: number | undefined;
|
||||
if (b.clip && !b.warped) {
|
||||
const sinF = Math.sin(facing), cosF = Math.cos(facing);
|
||||
let sMin = -Infinity, sMax = Infinity;
|
||||
if (Math.abs(sinF) > 1e-6) {
|
||||
const a1 = (b.x - b.clip.x) / sinF;
|
||||
const a2 = (b.x - (b.clip.x + 1)) / sinF;
|
||||
sMin = Math.max(sMin, Math.min(a1, a2));
|
||||
sMax = Math.min(sMax, Math.max(a1, a2));
|
||||
}
|
||||
if (Math.abs(cosF) > 1e-6) {
|
||||
const a1 = (b.clip.y - b.y) / cosF;
|
||||
const a2 = (b.clip.y + 1 - b.y) / cosF;
|
||||
sMin = Math.max(sMin, Math.min(a1, a2));
|
||||
sMax = Math.min(sMax, Math.max(a1, a2));
|
||||
}
|
||||
if (sMin <= sMax && Number.isFinite(sMin) && Number.isFinite(sMax)) {
|
||||
const sideC = -relX * sinF + relY * cosF;
|
||||
const fl = (W / 2) / Math.tan(FOV / 2);
|
||||
const e1 = W / 2 + ((sideC + sMin) / depth) * fl;
|
||||
const e2 = W / 2 + ((sideC + sMax) / depth) * fl;
|
||||
clampL = Math.min(e1, e2);
|
||||
clampR = Math.max(e1, e2);
|
||||
}
|
||||
}
|
||||
return {
|
||||
src: b.src, depth, sort: depth - (b.bias ?? 0), warped: b.warped, warpId: b.warpId,
|
||||
alpha: b.alpha, glow: b.glow, fallback: b.fallback, clampL, clampR,
|
||||
hit: b.hit, cell: b.cell,
|
||||
left: screenX - wide / 2, right: screenX + wide / 2,
|
||||
top: bottom - size, bottom,
|
||||
};
|
||||
}
|
||||
|
||||
/** Resolve a canvas-pixel click to what stood under it: the nearest
|
||||
* visible sprite, else the struck wall or door face, else the floor
|
||||
* (or vault) square the pixel lies on — warp-bent columns mapping
|
||||
* their virtual ground back to real cells through the warp's own
|
||||
* rigid motion. */
|
||||
function hitTest(px: number, py: number): FpvTarget | null {
|
||||
return resolveHover(px, py)?.target ?? null;
|
||||
}
|
||||
|
||||
/** The full answer for a canvas pixel: the target, the screen shape to
|
||||
* highlight, and the name to whisper beside the crosshair. */
|
||||
function resolveHover(px: number, py: number): {
|
||||
target: FpvTarget;
|
||||
label: string;
|
||||
shape:
|
||||
| { kind: "rect"; left: number; right: number; top: number; bottom: number }
|
||||
| { kind: "face"; edge: string }
|
||||
| { kind: "ground"; cell: { x: number; y: number } }
|
||||
| { kind: "none" };
|
||||
} | null {
|
||||
const f = hitFrame;
|
||||
if (!f) return null;
|
||||
const col = Math.max(0, Math.min(f.W - 1, px | 0));
|
||||
const half = f.H / 2;
|
||||
|
||||
// Sprites first, nearest first, honoring the draw pass's own
|
||||
// visibility rules for this column.
|
||||
for (const sp of f.sprites) {
|
||||
if (!sp.hit && !sp.cell) continue; // pure spectacle (projectiles, rubble)
|
||||
if (px < sp.left || px >= sp.right || py < sp.top || py > sp.bottom) continue;
|
||||
if (!spriteVisibleInCol(sp, col, f.zbuf, f.warpIdCol, f.warpDistCol)) continue;
|
||||
const shape = { kind: "rect" as const, left: sp.left, right: sp.right, top: sp.top, bottom: sp.bottom };
|
||||
const label = sp.hit ? (sp.hit.kind === "player" ? sp.hit.id : labelOf(sp)) : labelOf(sp);
|
||||
if (sp.hit) return { target: sp.hit, label, shape };
|
||||
return { target: { kind: "cell", cell: { x: sp.cell!.x, y: sp.cell!.y } }, label, shape };
|
||||
}
|
||||
|
||||
// The wall span: doors and walls answer as their EDGE.
|
||||
const c = f.cols[col];
|
||||
if (c && py >= c.top && py <= c.top + c.h) {
|
||||
if ((c.kind === "wall" || c.kind === "door" || c.kind === "firewall") && c.edge) {
|
||||
const [kind, coords] = c.edge.split(":") as [string, string];
|
||||
const [x, y] = coords.split(",").map(Number) as [number, number];
|
||||
return {
|
||||
target: { kind: "edge", cell: { x, y }, side: kind === "V" ? "E" : "S" },
|
||||
label: c.kind === "firewall" ? "wall of fire" : c.kind,
|
||||
shape: { kind: "face", edge: c.edge },
|
||||
};
|
||||
}
|
||||
if (c.kind === "stone") {
|
||||
// A stone fill is a square, not an edge: the cell just past the
|
||||
// struck face along this column's ray.
|
||||
const t = f.zbuf[col]! + 0.05;
|
||||
const pt = groundPoint(f, col, t);
|
||||
return pt ? { target: { kind: "cell", cell: pt }, label: "solid stone", shape: { kind: "ground", cell: pt } } : null;
|
||||
}
|
||||
return null; // rims and frame posts are nobody's target
|
||||
}
|
||||
|
||||
// Ground (or vault): each row below the horizon lies at one depth.
|
||||
const dz = py > half ? py - half : half - py;
|
||||
if (dz < 1) return null;
|
||||
const d = (f.H / 2) / dz;
|
||||
if (d >= f.zbuf[col]!) return null; // past the wall: nothing to click
|
||||
if (f.warpIdCol[col]! >= 0 && d > f.warpDistCol[col]!) {
|
||||
// Warp-bent ground still TARGETS truly, but the highlight quad
|
||||
// cannot be drawn in this frame's geometry: label it instead.
|
||||
const pt = groundPoint(f, col, d);
|
||||
return pt ? { target: { kind: "cell", cell: pt }, label: `through the warp (${pt.x},${pt.y})`, shape: { kind: "none" } } : null;
|
||||
}
|
||||
const pt = groundPoint(f, col, d);
|
||||
return pt ? { target: { kind: "cell", cell: pt }, label: "", shape: { kind: "ground", cell: pt } } : null;
|
||||
}
|
||||
|
||||
/** A sprite's spoken name: the creature or thing under the crosshair. */
|
||||
function labelOf(sp: Projected): string {
|
||||
if (sp.hit?.kind === "creature") {
|
||||
const c = view.creatures.find((k) => k.id === sp.hit!.id);
|
||||
return c ? c.kind.replace(/-/g, " ") : "creature";
|
||||
}
|
||||
if (sp.fallback) return sp.fallback.replace(/-/g, " ");
|
||||
const m = sp.src.match(/\/([a-z0-9-]+)\.png/i);
|
||||
return m ? m[1]!.replace(/-/g, " ") : "";
|
||||
}
|
||||
|
||||
/** The real-world square at perpendicular depth d down column col —
|
||||
* mapping through the column's warp when the ray bent. */
|
||||
function groundPoint(
|
||||
f: NonNullable<typeof hitFrame>, col: number, d: number,
|
||||
): { x: number; y: number } | null {
|
||||
const flen = (f.W / 2) / Math.tan(FOV / 2);
|
||||
const side = (col - f.W / 2) * (d / flen);
|
||||
const cosF = Math.cos(f.facing), sinF = Math.sin(f.facing);
|
||||
let wx = f.ex + d * cosF - side * sinF;
|
||||
let wy = f.ey + d * sinF + side * cosF;
|
||||
if (f.warpIdCol[col]! >= 0 && d > f.warpDistCol[col]!) {
|
||||
const w = view.board.warps[f.warpIdCol[col]!];
|
||||
if (!w) return null;
|
||||
const real = warpMotion(w).toReal({ x: wx, y: wy });
|
||||
wx = real.x; wy = real.y;
|
||||
}
|
||||
const cell = { x: Math.floor(wx), y: Math.floor(wy) };
|
||||
return view.board.cells[`${cell.x},${cell.y}`] ? cell : null;
|
||||
}
|
||||
|
||||
function onCanvasClick(e: MouseEvent) {
|
||||
if (!ontarget || !canvas) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const px = (e.clientX - rect.left) * (width / rect.width);
|
||||
const py = (e.clientY - rect.top) * (height / rect.height);
|
||||
const t = hitTest(px, py);
|
||||
if (t) ontarget(t);
|
||||
}
|
||||
|
||||
// Redraw every frame: the fire flickers and the warps swirl even when
|
||||
// the camera holds still.
|
||||
$effect(() => {
|
||||
let raf = 0;
|
||||
const loop = (t: number) => { draw(t); raf = requestAnimationFrame(loop); };
|
||||
raf = requestAnimationFrame(loop);
|
||||
return () => cancelAnimationFrame(raf);
|
||||
});
|
||||
</script>
|
||||
|
||||
<canvas bind:this={canvas} {width} {height} class="fpv-canvas" class:targeting={!!ontarget}
|
||||
onclick={onCanvasClick}
|
||||
onpointermove={(e) => {
|
||||
if (!ontarget || !canvas) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
mouse = { x: (e.clientX - rect.left) * (width / rect.width), y: (e.clientY - rect.top) * (height / rect.height) };
|
||||
}}
|
||||
onpointerleave={() => (mouse = null)}></canvas>
|
||||
|
||||
<style>
|
||||
.targeting { cursor: crosshair; }
|
||||
.fpv-canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
image-rendering: pixelated;
|
||||
border: 1px solid #3a3428;
|
||||
border-radius: 4px;
|
||||
background: #0d0c12;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,252 @@
|
||||
<script lang="ts">
|
||||
// The first-person workshop (/?fpv): a real dealt game, one wizard's
|
||||
// eyes, free-fly controls. No rules run here — the camera walks where
|
||||
// walls allow and the maze is exactly what viewFor would send that
|
||||
// player at the table, beliefs and all.
|
||||
import { createGame, viewFor, cellKey, applyCommand, automatonCommand, automatonFallback, redactEvent } from "@wizwar/engine";
|
||||
import type { GameEvent, GameState, GameView } from "@wizwar/engine";
|
||||
import FirstPerson from "./FirstPerson.svelte";
|
||||
import Replay from "../Replay.svelte";
|
||||
import { canWalk, edgeMid, SIDE_ANGLE, OPPOSITE } from "./raycast";
|
||||
|
||||
const q = new URLSearchParams(location.search);
|
||||
const seed = Number(q.get("seed") ?? 42);
|
||||
/** ?demo=1: two automatons play a stretch, then the reel replays it —
|
||||
* the real Replay component, toggleable into first person. */
|
||||
const demoReel = q.has("demo");
|
||||
// ?players=2..6 sizes the table (5-player boards carry the corner
|
||||
// warps whose traversals rotate the world a quarter-turn).
|
||||
const count = Math.min(6, Math.max(2, Number(q.get("players") ?? 3)));
|
||||
const { state: dealt } = createGame({
|
||||
playerIds: ["Wanderer", "Rival", "Stranger", "Pilgrim", "Vagabond", "Drifter"].slice(0, count),
|
||||
seed,
|
||||
sets: ["basic", "expansion1"],
|
||||
});
|
||||
const povId = "Wanderer";
|
||||
const view = viewFor(dealt, povId);
|
||||
// ?illusion=H:2,8 (";"-separated): stand up known illusions for the
|
||||
// renderer to rehearse — the edge opens, the ghost remains.
|
||||
for (const k of (q.get("illusion") ?? "").split(";").filter(Boolean)) {
|
||||
view.knownIllusionEdges.push(k);
|
||||
delete view.board.edges[k];
|
||||
}
|
||||
// ?open=V:2,7 props doors open; ?crack=V:2,7@3 wears damage into walls.
|
||||
for (const k of (q.get("open") ?? "").split(";").filter(Boolean)) {
|
||||
view.openDoorEdges.push(k);
|
||||
}
|
||||
for (const spec of (q.get("crack") ?? "").split(";").filter(Boolean)) {
|
||||
const [k, n] = spec.split("@") as [string, string?];
|
||||
view.wallDamage[k] = Number(n ?? 1) || 1;
|
||||
}
|
||||
// ?carry=Rival hands the first treasure to that wizard, for viewing
|
||||
// what a laden thief looks like.
|
||||
const carrier = q.get("carry");
|
||||
if (carrier && view.treasures[0]) {
|
||||
view.treasures[0] = { ...view.treasures[0], carriedBy: carrier, position: null };
|
||||
}
|
||||
// ?terrain=pit@3,7;thornbush@4,7 — furnish squares for the renderer
|
||||
// (and the artist) to rehearse. dimwarp@x,y opens a floor mouth.
|
||||
for (const spec of (q.get("terrain") ?? "").split(";").filter(Boolean)) {
|
||||
const [kind, at] = spec.split("@") as [string, string?];
|
||||
const [tx, ty] = (at ?? "").split(",").map(Number);
|
||||
if (!Number.isFinite(tx) || !Number.isFinite(ty)) continue;
|
||||
if (kind === "dimwarp") view.dimWarps.push({ a: { x: tx!, y: ty! }, b: { x: tx!, y: ty! } });
|
||||
else view.squareContents[`${tx},${ty}`] = { kind } as (typeof view.squareContents)[string];
|
||||
}
|
||||
const start = view.players.find((p) => p.id === povId)!.position;
|
||||
|
||||
// ?x=&y=&dir= override the spawn — for standing the camera anywhere.
|
||||
let x = $state((q.has("x") ? Number(q.get("x")) : start.x) + 0.5);
|
||||
let y = $state((q.has("y") ? Number(q.get("y")) : start.y) + 0.5);
|
||||
let facing = $state(SIDE_ANGLE[q.get("dir") as keyof typeof SIDE_ANGLE] ?? 0);
|
||||
|
||||
// Dungeon-crawler locomotion: the maze is walked cell by cell, so the
|
||||
// camera moves the same way — a tap glides one square, a turn swings a
|
||||
// clean quarter. Held keys queue the next move as each one settles.
|
||||
const held = new Set<string>();
|
||||
function onKey(e: KeyboardEvent, down: boolean) {
|
||||
const k = e.key.toLowerCase();
|
||||
if (["arrowup", "arrowdown", "arrowleft", "arrowright", "w", "a", "s", "d"].includes(k)) {
|
||||
e.preventDefault();
|
||||
if (down) held.add(k); else held.delete(k);
|
||||
}
|
||||
}
|
||||
const SIDES4 = ["E", "S", "W", "N"] as const;
|
||||
type Anim =
|
||||
| { kind: "glide"; fx: number; fy: number; tx: number; ty: number; t0: number; dur: number }
|
||||
| { kind: "turn"; from: number; to: number; t0: number; dur: number }
|
||||
| { kind: "warpglide"; fx: number; fy: number; mx: number; my: number;
|
||||
nx: number; ny: number; tx: number; ty: number; dturn: number;
|
||||
turned: boolean; t0: number; dur: number }
|
||||
| null;
|
||||
let anim: Anim = null;
|
||||
const ease = (w: number) => w * w * (3 - 2 * w);
|
||||
function startNext(now: number) {
|
||||
const turn = (held.has("arrowleft") || held.has("a") ? -1 : 0) +
|
||||
(held.has("arrowright") || held.has("d") ? 1 : 0);
|
||||
if (turn !== 0) {
|
||||
anim = { kind: "turn", from: facing, to: facing + turn * (Math.PI / 2), t0: now, dur: 210 };
|
||||
return;
|
||||
}
|
||||
const fwd = (held.has("arrowup") || held.has("w") ? 1 : 0) +
|
||||
(held.has("arrowdown") || held.has("s") ? -1 : 0);
|
||||
if (fwd === 0) return;
|
||||
// The stride goes along the nearest cardinal (backpedal reverses it).
|
||||
const q = Math.round(facing / (Math.PI / 2));
|
||||
const idx = ((q % 4) + 4 + (fwd < 0 ? 2 : 0)) % 4;
|
||||
const side = SIDES4[idx]!;
|
||||
const cell = { x: Math.floor(x), y: Math.floor(y) };
|
||||
if (!canWalk(view, cell, side)) return;
|
||||
const to = {
|
||||
x: cell.x + (side === "E" ? 1 : side === "W" ? -1 : 0),
|
||||
y: cell.y + (side === "S" ? 1 : side === "N" ? -1 : 0),
|
||||
};
|
||||
// An open rim edge canWalk allowed is a warp mouth: stride into it,
|
||||
// and the stride carries on out of the paired mouth, the body turned
|
||||
// exactly as the rays turn.
|
||||
const warp = !view.board.cells[cellKey(to)]
|
||||
? view.board.warps.find((w) => cellKey(w.from.cell) === cellKey(cell) && w.from.side === side)
|
||||
: undefined;
|
||||
if (warp) {
|
||||
const mouth = edgeMid(cell, side);
|
||||
const far = edgeMid(warp.to.cell, warp.to.side);
|
||||
anim = {
|
||||
kind: "warpglide", fx: x, fy: y, mx: mouth.x, my: mouth.y,
|
||||
nx: far.x, ny: far.y, tx: warp.to.cell.x + 0.5, ty: warp.to.cell.y + 0.5,
|
||||
dturn: SIDE_ANGLE[OPPOSITE[warp.to.side]] - SIDE_ANGLE[warp.from.side],
|
||||
turned: false, t0: now, dur: 320,
|
||||
};
|
||||
return;
|
||||
}
|
||||
const tx = x + (side === "E" ? 1 : side === "W" ? -1 : 0);
|
||||
const ty = y + (side === "S" ? 1 : side === "N" ? -1 : 0);
|
||||
anim = { kind: "glide", fx: x, fy: y, tx, ty, t0: now, dur: 280 };
|
||||
}
|
||||
$effect(() => {
|
||||
let raf = 0;
|
||||
const tick = (now: number) => {
|
||||
if (!anim) startNext(now);
|
||||
if (anim) {
|
||||
const w = Math.min(1, (now - anim.t0) / anim.dur);
|
||||
if (anim.kind === "glide") {
|
||||
x = anim.fx + (anim.tx - anim.fx) * ease(w);
|
||||
y = anim.fy + (anim.ty - anim.fy) * ease(w);
|
||||
} else if (anim.kind === "warpglide") {
|
||||
// Half the stride reaches the mouth; the moment it crosses, the
|
||||
// eye stands at the far mouth, turned, finishing the stride.
|
||||
const e = ease(w);
|
||||
if (e < 0.5) {
|
||||
x = anim.fx + (anim.mx - anim.fx) * (e * 2);
|
||||
y = anim.fy + (anim.my - anim.fy) * (e * 2);
|
||||
} else {
|
||||
if (!anim.turned) { facing += anim.dturn; anim.turned = true; }
|
||||
x = anim.nx + (anim.tx - anim.nx) * ((e - 0.5) * 2);
|
||||
y = anim.ny + (anim.ty - anim.ny) * ((e - 0.5) * 2);
|
||||
}
|
||||
} else {
|
||||
facing = anim.from + (anim.to - anim.from) * ease(w);
|
||||
}
|
||||
if (w >= 1) anim = null;
|
||||
}
|
||||
raf = requestAnimationFrame(tick);
|
||||
};
|
||||
raf = requestAnimationFrame(tick);
|
||||
return () => cancelAnimationFrame(raf);
|
||||
});
|
||||
|
||||
// The demo reel: drive the clockwork for a while, keeping a step per
|
||||
// command exactly as the server's catch-up would build it.
|
||||
type Step = { seq: number; actor: string; events: GameEvent[]; view: GameView };
|
||||
function buildDemoSteps(): Step[] {
|
||||
let s: GameState = dealt;
|
||||
const actingSeat = (g: GameState) =>
|
||||
g.stack?.waitingOn ?? g.pendingDiscard ?? g.chaosPending?.queue[0] ??
|
||||
g.outOfTurnWindow?.playerId ?? g.players[g.turn.activeIndex]!.id;
|
||||
const steps: Step[] = [];
|
||||
for (let i = 0; i < 80 && s.phase === "playing"; i++) {
|
||||
const seat = actingSeat(s);
|
||||
const cmd = automatonCommand(viewFor(s, seat), "hunter", "adept")
|
||||
?? automatonFallback(viewFor(s, seat), "adept");
|
||||
let r = applyCommand(s, seat, cmd);
|
||||
if (!r.ok) r = applyCommand(s, seat, automatonFallback(viewFor(s, seat), "adept"));
|
||||
if (!r.ok) r = applyCommand(s, seat, { type: "endTurn", draw: 0 });
|
||||
if (!r.ok) break;
|
||||
s = r.state;
|
||||
steps.push({
|
||||
seq: i, actor: seat,
|
||||
events: r.events.map((e) => redactEvent(e, povId)).filter((e): e is GameEvent => e !== null),
|
||||
view: viewFor(s, povId),
|
||||
});
|
||||
}
|
||||
return steps;
|
||||
}
|
||||
const demoSteps = demoReel ? buildDemoSteps() : [];
|
||||
|
||||
// Minimap geometry (top-down, one small square per cell).
|
||||
const MM = 9;
|
||||
const cells = Object.keys(view.board.cells).map((k) => {
|
||||
const [cx, cy] = k.split(",").map(Number) as [number, number];
|
||||
return { cx, cy };
|
||||
});
|
||||
const mmWalls = Object.entries(view.board.edges)
|
||||
.filter(([, e]) => e !== "open")
|
||||
.map(([k]) => {
|
||||
const [kind, coords] = k.split(":") as [string, string];
|
||||
const [ex, ey] = coords.split(",").map(Number) as [number, number];
|
||||
return { kind, ex, ey };
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={(e) => onKey(e, true)} onkeyup={(e) => onKey(e, false)} />
|
||||
|
||||
{#if demoReel}
|
||||
<Replay steps={demoSteps} onclose={() => (location.search = "?fpv")} />
|
||||
{/if}
|
||||
|
||||
<div class="fpv-shop">
|
||||
<h1>The maze, through {povId}'s eyes</h1>
|
||||
<p class="hint">
|
||||
Arrows / WASD stride the maze square by square and swing in quarter
|
||||
turns, as a wizard walks it. Walls and shut doors refuse you; fire and
|
||||
stranger things do not — this workshop has eyes, not rules. Seed with
|
||||
<code>?fpv&seed=N</code>, stand anywhere with <code>&x=&y=&dir=</code>,
|
||||
or watch the clockwork's reel with <code>&demo=1</code> (toggle 👁).
|
||||
</p>
|
||||
<div class="stage">
|
||||
<FirstPerson {view} {povId} {x} {y} {facing} />
|
||||
<svg class="minimap" viewBox="0 0 {view.board.width * MM} {view.board.height * MM}">
|
||||
{#each cells as c (cellKey({ x: c.cx, y: c.cy }))}
|
||||
<rect x={c.cx * MM} y={c.cy * MM} width={MM} height={MM} class="mm-cell" />
|
||||
{/each}
|
||||
{#each mmWalls as w (w.kind + w.ex + "," + w.ey)}
|
||||
{#if w.kind === "V"}
|
||||
<line x1={(w.ex + 1) * MM} y1={w.ey * MM} x2={(w.ex + 1) * MM} y2={(w.ey + 1) * MM} class="mm-wall" />
|
||||
{:else}
|
||||
<line x1={w.ex * MM} y1={(w.ey + 1) * MM} x2={(w.ex + 1) * MM} y2={(w.ey + 1) * MM} class="mm-wall" />
|
||||
{/if}
|
||||
{/each}
|
||||
<g transform={`translate(${x * MM} ${y * MM}) rotate(${(facing * 180) / Math.PI})`}>
|
||||
<polygon points="6,0 -3,4 -3,-4" class="mm-eye" />
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.fpv-shop {
|
||||
min-height: 100vh;
|
||||
background: #171a20;
|
||||
color: #d8d2c0;
|
||||
font-family: "Archivo Narrow", system-ui, sans-serif;
|
||||
padding: 1.2rem;
|
||||
}
|
||||
h1 { font-size: 1.2rem; margin: 0 0 0.3rem; }
|
||||
.hint { color: #8d8672; margin: 0 0 0.8rem; }
|
||||
.hint code { color: #b7ae94; }
|
||||
.stage { display: flex; gap: 1rem; align-items: flex-start; flex-wrap: wrap; }
|
||||
.stage > :global(.fpv-canvas) { flex: 1 1 480px; }
|
||||
.minimap { width: 220px; background: #101318; border: 1px solid #3a3428; border-radius: 4px; }
|
||||
.mm-cell { fill: #1d212b; stroke: #262b36; stroke-width: 0.5; }
|
||||
.mm-wall { stroke: #9a927c; stroke-width: 1.6; stroke-linecap: square; }
|
||||
.mm-eye { fill: #e0b34a; }
|
||||
</style>
|
||||
@@ -0,0 +1,145 @@
|
||||
// The directorial eye the reel and the live pane share: given a batch
|
||||
// of events, where should the camera look — and which bodies glide
|
||||
// between one view and the next. One ladder, so the replay and the
|
||||
// table never learn different instincts.
|
||||
|
||||
import { edgeMid } from "./raycast";
|
||||
import { fpFxForEvents } from "./fx3d";
|
||||
import type { GameEvent, GameView } from "@wizwar/engine";
|
||||
import { castRay } from "./raycast";
|
||||
|
||||
export function shortestArc(from: number, to: number): number {
|
||||
let d = (to - from) % (2 * Math.PI);
|
||||
if (d > Math.PI) d -= 2 * Math.PI;
|
||||
if (d < -Math.PI) d += 2 * Math.PI;
|
||||
return d;
|
||||
}
|
||||
|
||||
export interface Aim {
|
||||
x: number;
|
||||
y: number;
|
||||
/** The deed lands on the wizard's OWN square — a summon at their
|
||||
* feet, invisible from inside their own eyes. Take the cutaway. */
|
||||
self?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where this batch's deeds should point the pov wizard's eyes: their
|
||||
* own casts, attacks, throws, summons, and edge-deeds first; then, when
|
||||
* the pov is the acting player, wherever the fx layer will visibly play.
|
||||
* Null when nothing asks to be watched.
|
||||
*/
|
||||
export function aimOfEvents(
|
||||
events: GameEvent[], view: GameView, povId: string,
|
||||
myCell: { x: number; y: number }, actor: string,
|
||||
): Aim | null {
|
||||
for (const e of events) {
|
||||
let cell: { x: number; y: number } | null = null;
|
||||
if (e.type === "creatureCreated" && "controller" in e && e.controller === povId) {
|
||||
cell = (e as { at: { x: number; y: number } }).at;
|
||||
} else if (e.type === "spellCast" && e.caster === povId) {
|
||||
cell = e.targetCell ?? view.players.find((p) => p.id === e.target)?.position ?? null;
|
||||
} else if (e.type === "attackResolved" && e.attacker === povId) {
|
||||
cell = view.players.find((p) => p.id === e.defender)?.position ?? null;
|
||||
} else if (e.type === "objectThrown" && e.attacker === povId) {
|
||||
cell = e.landedAt;
|
||||
} else if (
|
||||
// Deeds done to an EDGE — a lock picked or jammed, a door
|
||||
// unlocked, a wall raised, razed, or drowned — turn the eyes
|
||||
// to the edge's midpoint just the same.
|
||||
"edge" in e && typeof e.edge === "object" && e.edge !== null && "cell" in e.edge &&
|
||||
(("caster" in e && e.caster === povId) || ("player" in e && e.player === povId))
|
||||
) {
|
||||
const edge = e.edge as { cell: { x: number; y: number }; side: "N" | "E" | "S" | "W" };
|
||||
const m = edgeMid(edge.cell, edge.side);
|
||||
// edgeMid sits ON the boundary; step half a texel toward the
|
||||
// cell so the same-cell check below behaves.
|
||||
if (Math.abs(m.x - (myCell.x + 0.5)) > 0.3 || Math.abs(m.y - (myCell.y + 0.5)) > 0.3) {
|
||||
return { x: m.x - 0.5, y: m.y - 0.5 };
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (cell && (cell.x !== myCell.x || cell.y !== myCell.y)) return cell;
|
||||
if (cell) return { ...cell, self: true };
|
||||
}
|
||||
// Last resort, only for the pov wizard's own step: wherever the fx
|
||||
// layer is about to play something visible — an impact, a
|
||||
// projectile's landing — the eyes should already be.
|
||||
if (actor === povId) {
|
||||
for (const e of events) {
|
||||
if (e.type === "creatureMoved" && "by" in e && e.by === povId) {
|
||||
const to = (e as { to: { x: number; y: number } }).to;
|
||||
if (to.x !== myCell.x || to.y !== myCell.y) return to;
|
||||
}
|
||||
if (
|
||||
(e.type === "treasurePickedUp" || e.type === "treasureDropped" || e.type === "objectDropped") &&
|
||||
"player" in e && (e as { player: string }).player === povId
|
||||
) {
|
||||
const at = (e as { at: { x: number; y: number } }).at;
|
||||
return { ...at, self: at.x === myCell.x && at.y === myCell.y };
|
||||
}
|
||||
}
|
||||
for (const { fx } of fpFxForEvents(events, view, povId)) {
|
||||
const spot = fx.kind === "impact" ? fx.at : fx.kind === "projectile" ? fx.to : null;
|
||||
if (!spot) continue;
|
||||
const cx = Math.floor(spot.x), cy = Math.floor(spot.y);
|
||||
if (cx === myCell.x && cy === myCell.y) return { x: cx, y: cy, self: true };
|
||||
return { x: cx, y: cy };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Does this batch hurl the pov wizard bodily — a blow to glide with,
|
||||
* eyes held steady, rather than a stride or a cut? */
|
||||
export function hurledIn(events: GameEvent[], povId: string): boolean {
|
||||
return events.some((e) =>
|
||||
(e.type === "knockedBack" || e.type === "shoved" || e.type === "washedBack" ||
|
||||
e.type === "retreatedInHorror") && e.player === povId);
|
||||
}
|
||||
|
||||
export interface Glide {
|
||||
id: string;
|
||||
fx: number;
|
||||
fy: number;
|
||||
tx: number;
|
||||
ty: number;
|
||||
}
|
||||
|
||||
/** Bodies that walked between two views: each glides rather than
|
||||
* blinking cell to cell. A leap across the maze is a teleport and
|
||||
* simply arrives. */
|
||||
export function gatherGlides(before: GameView, after: GameView, povId: string): Glide[] {
|
||||
const moves: Glide[] = [];
|
||||
const gather = (
|
||||
id: string,
|
||||
now: { x: number; y: number },
|
||||
was: { x: number; y: number } | undefined,
|
||||
) => {
|
||||
if (!was) return;
|
||||
const d = Math.hypot(now.x - was.x, now.y - was.y);
|
||||
if (d > 0.05 && d <= 3.5) {
|
||||
moves.push({ id, fx: was.x + 0.5, fy: was.y + 0.5, tx: now.x + 0.5, ty: now.y + 0.5 });
|
||||
}
|
||||
};
|
||||
for (const p of after.players) {
|
||||
if (!p.alive || p.id === povId) continue;
|
||||
const was = before.players.find((q) => q.id === p.id && q.alive);
|
||||
gather(p.id, p.position, was?.position);
|
||||
}
|
||||
for (const c of after.creatures) {
|
||||
gather(c.id, c.position, before.creatures.find((q) => q.id === c.id)?.position);
|
||||
}
|
||||
return moves;
|
||||
}
|
||||
|
||||
/** The facing that stares down the longest corridor from a square — the
|
||||
* default view wherever nothing asks to be watched. */
|
||||
export function deepestFacing(view: GameView, x: number, y: number): number {
|
||||
let deepest = -1, face = 0;
|
||||
for (const a of [0, Math.PI / 2, Math.PI, -Math.PI / 2]) {
|
||||
const h = castRay(view, x, y, a);
|
||||
if (h.dist > deepest) { deepest = h.dist; face = a; }
|
||||
}
|
||||
return face;
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
// First-person spell moments. The board flourishes (fx.ts) already know
|
||||
// which event hurls what across which cells; this recasts their output
|
||||
// into world-space projectiles, impact bursts, whole-screen flashes, and
|
||||
// camera shakes for the raycaster to draw. What happens ACROSS the room
|
||||
// is watched; what happens to YOU is felt.
|
||||
|
||||
import { fxForEvents } from "../fx";
|
||||
import { CELL } from "../fx-sprites/geom";
|
||||
import { castRay, edgeMid, warpMotion } from "./raycast";
|
||||
import { edgeKey } from "@wizwar/engine";
|
||||
import type { GameEvent, GameView } from "@wizwar/engine";
|
||||
|
||||
export type FpFx =
|
||||
| { id: number; kind: "projectile"; art: string; from: { x: number; y: number }; to: { x: number; y: number }; t0: number; dur: number;
|
||||
/** This leg flies in a warp mouth's virtual space: visible only
|
||||
* through THAT warp's opening, beyond its mouth. */
|
||||
warped?: boolean; warpId?: number }
|
||||
| { id: number; kind: "impact"; art: string; at: { x: number; y: number }; t0: number; dur: number;
|
||||
/** Lifted off the floor (fireworks burst overhead); default 0.3. */
|
||||
rise?: number }
|
||||
| { id: number; kind: "flash"; color: string; peak: number; t0: number; dur: number }
|
||||
| { id: number; kind: "shake"; mag: number; t0: number; dur: number }
|
||||
| { id: number; kind: "door"; edge: string; t0: number; dur: number }
|
||||
| { id: number; kind: "grow"; slot: "solid" | "sprite"; key: string; t0: number; dur: number };
|
||||
|
||||
/** Eased growth 0..1 for a conjuration's rise. */
|
||||
export function growProgress(p: number): number {
|
||||
if (p <= 0) return 0.01;
|
||||
if (p >= 1) return 1;
|
||||
return Math.max(0.01, p * p * (3 - 2 * p));
|
||||
}
|
||||
|
||||
/** How far open an animated door stands at progress p: swings open,
|
||||
* holds for the crossing, swings shut. */
|
||||
export function doorOpenness(p: number): number {
|
||||
if (p < 0 || p >= 1) return 0;
|
||||
if (p < 0.3) return p / 0.3;
|
||||
if (p < 0.7) return 1;
|
||||
return (1 - p) / 0.3;
|
||||
}
|
||||
|
||||
/** The paintable conjuration sprites: public/fx3d/<name>.png, square with
|
||||
* transparent ground, drawn as a billboard mid-air. A procedural glow
|
||||
* stands in wherever a file is missing. */
|
||||
export const FX_ART = [
|
||||
"fireball", "bolt", "waterbolt", "spark",
|
||||
"burst", "splash", "hit", "shimmer", "shield", "rubble",
|
||||
] as const;
|
||||
|
||||
let nextId = 1;
|
||||
|
||||
const PROJECTILE_DUR: Record<string, number> = { fireball: 420, bolt: 260, waterbolt: 420, spark: 350 };
|
||||
/** Board-effect kinds that land here as an impact billboard. */
|
||||
const IMPACT_ART: Record<string, string> = {
|
||||
burst: "burst", splash: "splash",
|
||||
hit: "hit", pow: "hit", claw: "hit", "tacks-ow": "hit", "thorn-snap": "hit",
|
||||
shimmer: "shimmer", "portal-cell": "shimmer", sparkle: "shimmer",
|
||||
soul: "shimmer", "chaos-swirl": "shimmer", whiff: "shimmer",
|
||||
shield: "shield", absorb: "shield",
|
||||
"ooze-slip": "splash", "slime-stuck": "splash",
|
||||
"pit-fall": "dust", "dust-puff": "dust",
|
||||
"edge-dust": "rubble",
|
||||
// fireworks and die-drop are staged by the raw-event pass below.
|
||||
};
|
||||
|
||||
/** One flight, possibly seen from two rooms: a projectile whose straight
|
||||
* line is broken but whose sight line runs through a warp flies as TWO
|
||||
* simultaneous legs — one in the near mouth's virtual space (visible
|
||||
* through the opening), one in the far room's real space. Each leg is
|
||||
* clipped by the depth buffer to the side the camera stands on. */
|
||||
function projectileLegs(
|
||||
view: GameView, a: { x: number; y: number }, b: { x: number; y: number },
|
||||
): { from: { x: number; y: number }; to: { x: number; y: number }; warped?: boolean; warpId?: number }[] {
|
||||
const len = Math.hypot(b.x - a.x, b.y - a.y);
|
||||
if (len < 0.05) return [{ from: a, to: b }];
|
||||
const direct = castRay(view, a.x, a.y, Math.atan2(b.y - a.y, b.x - a.x));
|
||||
if (!direct.warped && direct.dist >= len - 0.4) return [{ from: a, to: b }];
|
||||
for (let wi = 0; wi < view.board.warps.length; wi++) {
|
||||
const w = view.board.warps[wi]!;
|
||||
const motion = warpMotion(w);
|
||||
const vt = motion.toVirtual(b);
|
||||
const vlen = Math.hypot(vt.x - a.x, vt.y - a.y);
|
||||
const hit = castRay(view, a.x, a.y, Math.atan2(vt.y - a.y, vt.x - a.x));
|
||||
if (hit.warped && hit.dist >= vlen - 0.4) {
|
||||
return [
|
||||
{ from: a, to: vt, warped: true, warpId: wi },
|
||||
{ from: motion.toReal(a), to: b },
|
||||
];
|
||||
}
|
||||
}
|
||||
return [{ from: a, to: b }];
|
||||
}
|
||||
|
||||
/** Translate one step's events into first-person moments with start delays. */
|
||||
export function fpFxForEvents(
|
||||
events: GameEvent[], view: GameView, povId: string,
|
||||
): { fx: FpFx; delay: number }[] {
|
||||
const out: { fx: FpFx; delay: number }[] = [];
|
||||
for (const { fx, delay } of fxForEvents(events, view)) {
|
||||
if (fx.kind === "fireball" || fx.kind === "bolt" || fx.kind === "waterbolt" || fx.kind === "streak") {
|
||||
const art = fx.kind === "streak" ? "spark" : fx.kind;
|
||||
for (const leg of projectileLegs(
|
||||
view, { x: fx.a.x / CELL, y: fx.a.y / CELL }, { x: fx.b.x / CELL, y: fx.b.y / CELL },
|
||||
)) {
|
||||
out.push({
|
||||
fx: {
|
||||
id: nextId++, kind: "projectile", art,
|
||||
from: leg.from, to: leg.to, warped: leg.warped, warpId: leg.warpId,
|
||||
t0: 0, dur: PROJECTILE_DUR[art]!,
|
||||
},
|
||||
delay,
|
||||
});
|
||||
}
|
||||
} else if (fx.kind === "portal") {
|
||||
out.push({
|
||||
fx: { id: nextId++, kind: "impact", art: "shimmer", at: { x: fx.cell.x + 0.5, y: fx.cell.y + 0.5 }, t0: 0, dur: 550 },
|
||||
delay,
|
||||
});
|
||||
} else if ("at" in fx && IMPACT_ART[fx.kind]) {
|
||||
out.push({
|
||||
fx: { id: nextId++, kind: "impact", art: IMPACT_ART[fx.kind]!, at: { x: fx.at.x + 0.5, y: fx.at.y + 0.5 }, t0: 0, dur: 550 },
|
||||
delay,
|
||||
});
|
||||
}
|
||||
}
|
||||
// The maze itself performs: doors swing for whoever steps through,
|
||||
// walls die into rubble, and walking THROUGH stone shimmers.
|
||||
for (const e of events) {
|
||||
if ((e.type === "moved" || e.type === "creatureMoved") && "direction" in e) {
|
||||
const via = e.type === "moved" ? e.via : "step";
|
||||
const key = edgeKey(e.from, e.direction);
|
||||
const edgeState = view.board.edges[key] ?? "open";
|
||||
if (via === "step" && edgeState === "door") {
|
||||
out.push({ fx: { id: nextId++, kind: "door", edge: key, t0: 0, dur: 1100 }, delay: 0 });
|
||||
}
|
||||
if (via === "step" && edgeState === "wall") {
|
||||
// A body just stepped through what THIS viewer holds solid — an
|
||||
// illusion the mover disbelieves. The viewer keeps their false
|
||||
// belief, but the crossing must read as magic, not a glitch:
|
||||
// shimmer both faces, and wash the screen if these are the eyes.
|
||||
out.push({ fx: { id: nextId++, kind: "impact", art: "shimmer", at: { x: e.from.x + 0.5, y: e.from.y + 0.5 }, t0: 0, dur: 500 }, delay: 0 });
|
||||
out.push({ fx: { id: nextId++, kind: "impact", art: "shimmer", at: { x: e.to.x + 0.5, y: e.to.y + 0.5 }, t0: 0, dur: 500 }, delay: 200 });
|
||||
if (e.type === "moved" && e.player === povId) {
|
||||
out.push({ fx: { id: nextId++, kind: "flash", color: "#9a8fb0", peak: 0.35, t0: 0, dur: 450 }, delay: 0 });
|
||||
}
|
||||
}
|
||||
if (e.type === "moved" && e.via === "passWall") {
|
||||
out.push({ fx: { id: nextId++, kind: "impact", art: "shimmer", at: { x: e.from.x + 0.5, y: e.from.y + 0.5 }, t0: 0, dur: 500 }, delay: 0 });
|
||||
out.push({ fx: { id: nextId++, kind: "impact", art: "shimmer", at: { x: e.to.x + 0.5, y: e.to.y + 0.5 }, t0: 0, dur: 500 }, delay: 200 });
|
||||
if (e.player === povId) {
|
||||
out.push({ fx: { id: nextId++, kind: "flash", color: "#9a8fb0", peak: 0.35, t0: 0, dur: 450 }, delay: 0 });
|
||||
}
|
||||
}
|
||||
}
|
||||
if ((e.type === "wallCreated" || e.type === "firewallCreated" || e.type === "illusionWallCreated") && "edge" in e) {
|
||||
// Conjured from nothing: the edge RISES from the floor, wrapped in
|
||||
// dust and shimmer. (An illusion rises too — for the deceived, it
|
||||
// is a wall in every way.)
|
||||
const edge = (e as { edge: { cell: { x: number; y: number }; side: "N" | "E" | "S" | "W" } }).edge;
|
||||
const key = edgeKey(edge.cell, edge.side);
|
||||
out.push({ fx: { id: nextId++, kind: "grow", slot: "solid", key, t0: 0, dur: 750 }, delay: 0 });
|
||||
const at = edgeMid(edge.cell, edge.side);
|
||||
if (e.type === "wallCreated") {
|
||||
out.push({ fx: { id: nextId++, kind: "impact", art: "rubble", at, t0: 0, dur: 550 }, delay: 0 });
|
||||
}
|
||||
out.push({ fx: { id: nextId++, kind: "impact", art: "shimmer", at, t0: 0, dur: 750 }, delay: 0 });
|
||||
}
|
||||
if (e.type === "creatureCreated" && "at" in e) {
|
||||
const at = (e as { at: { x: number; y: number } }).at;
|
||||
const cid = (e as { creatureId: string }).creatureId;
|
||||
out.push({ fx: { id: nextId++, kind: "grow", slot: "sprite", key: cid, t0: 0, dur: 750 }, delay: 0 });
|
||||
out.push({ fx: { id: nextId++, kind: "impact", art: "shimmer", at: { x: at.x + 0.5, y: at.y + 0.5 }, t0: 0, dur: 750 }, delay: 0 });
|
||||
}
|
||||
if (e.type === "squareFilled" && "cell" in e) {
|
||||
const cell = (e as { cell: { x: number; y: number } }).cell;
|
||||
const kind = (e as { kind: string }).kind;
|
||||
const cellK = `${cell.x},${cell.y}`;
|
||||
out.push({
|
||||
fx: { id: nextId++, kind: "grow", slot: kind === "stone" ? "solid" : "sprite", key: cellK, t0: 0, dur: 750 },
|
||||
delay: 0,
|
||||
});
|
||||
out.push({ fx: { id: nextId++, kind: "impact", art: "shimmer", at: { x: cell.x + 0.5, y: cell.y + 0.5 }, t0: 0, dur: 750 }, delay: 0 });
|
||||
}
|
||||
if (e.type === "sectorRotated" || e.type === "sectorRelocated") {
|
||||
// The maze ITSELF moves: the whole world flashes violet and heaves.
|
||||
out.push({ fx: { id: nextId++, kind: "flash", color: "#8050d0", peak: 0.5, t0: 0, dur: 900 }, delay: 0 });
|
||||
out.push({ fx: { id: nextId++, kind: "shake", mag: 0.09, t0: 0, dur: 800 }, delay: 0 });
|
||||
}
|
||||
if (e.type === "thumbOfGod" && "landedAt" in e) {
|
||||
const at = (e as { landedAt: { x: number; y: number } }).landedAt;
|
||||
out.push({ fx: { id: nextId++, kind: "impact", art: "burst", at: { x: at.x + 0.5, y: at.y + 0.5 }, t0: 0, dur: 700 }, delay: 0 });
|
||||
out.push({ fx: { id: nextId++, kind: "shake", mag: 0.07, t0: 0, dur: 600 }, delay: 0 });
|
||||
}
|
||||
if (e.type === "gameWon" && "player" in e) {
|
||||
// Victory earns real fireworks: bursts climbing over the winner's
|
||||
// home, gold washing the sky, again and again.
|
||||
const home = view.players.find((p) => p.id === (e as { player: string }).player)?.home;
|
||||
const SPREAD = [[0, 0], [0.35, -0.25], [-0.35, 0.2], [0.2, 0.35], [-0.25, -0.3], [0.1, -0.1]];
|
||||
for (let i = 0; i < 6; i++) {
|
||||
if (home) {
|
||||
out.push({
|
||||
fx: {
|
||||
id: nextId++, kind: "impact", art: "burst",
|
||||
at: { x: home.x + 0.5 + SPREAD[i]![0]!, y: home.y + 0.5 + SPREAD[i]![1]! },
|
||||
rise: 0.45 + (i % 3) * 0.22, t0: 0, dur: 850,
|
||||
},
|
||||
delay: i * 300,
|
||||
});
|
||||
}
|
||||
out.push({ fx: { id: nextId++, kind: "flash", color: "#e0b34a", peak: 0.22, t0: 0, dur: 550 }, delay: i * 300 + 120 });
|
||||
}
|
||||
}
|
||||
if ((e.type === "treasureDropped" || e.type === "objectDropped") && "at" in e) {
|
||||
// Gold hits the flags: a glint where it lands, a beat after the
|
||||
// blow when one knocked it loose.
|
||||
const at = (e as { at: { x: number; y: number } }).at;
|
||||
const spilled = events.some((x) => x.type === "attackResolved" || x.type === "damaged");
|
||||
out.push({
|
||||
fx: { id: nextId++, kind: "impact", art: "spark", at: { x: at.x + 0.5, y: at.y + 0.5 }, t0: 0, dur: 500 },
|
||||
delay: spilled ? 400 : 0,
|
||||
});
|
||||
}
|
||||
if (e.type === "wallDestroyed" && "edge" in e) {
|
||||
const at = edgeMid((e as { edge: { cell: { x: number; y: number }; side: "N" | "E" | "S" | "W" } }).edge.cell,
|
||||
(e as { edge: { side: "N" | "E" | "S" | "W" } }).edge.side);
|
||||
out.push({ fx: { id: nextId++, kind: "impact", art: "rubble", at, t0: 0, dur: 700 }, delay: 0 });
|
||||
out.push({ fx: { id: nextId++, kind: "shake", mag: 0.04, t0: 0, dur: 350 }, delay: 0 });
|
||||
}
|
||||
}
|
||||
// Blows that land on the point of view: give impacts a beat to arrive
|
||||
// when something visibly flew first.
|
||||
const povDelay = out.some((o) => o.fx.kind === "projectile") ? 380 : 0;
|
||||
for (const e of events) {
|
||||
if (e.type === "damaged" && e.player === povId) {
|
||||
out.push({ fx: { id: nextId++, kind: "flash", color: "#c03020", peak: 0.4, t0: 0, dur: 450 }, delay: povDelay });
|
||||
out.push({ fx: { id: nextId++, kind: "shake", mag: Math.min(0.09, 0.03 + 0.015 * e.amount), t0: 0, dur: 420 }, delay: povDelay });
|
||||
} else if (e.type === "lifeGained" && e.player === povId) {
|
||||
out.push({ fx: { id: nextId++, kind: "flash", color: "#2a9a50", peak: 0.22, t0: 0, dur: 500 }, delay: povDelay });
|
||||
} else if (e.type === "died" && "player" in e && (e as { player: string }).player === povId) {
|
||||
out.push({ fx: { id: nextId++, kind: "flash", color: "#100812", peak: 0.6, t0: 0, dur: 1100 }, delay: povDelay });
|
||||
} else if (e.type === "fellInPit" && e.player === povId) {
|
||||
out.push({ fx: { id: nextId++, kind: "flash", color: "#201810", peak: 0.5, t0: 0, dur: 500 }, delay: 0 });
|
||||
out.push({ fx: { id: nextId++, kind: "shake", mag: 0.06, t0: 0, dur: 450 }, delay: 0 });
|
||||
} else if (
|
||||
(e.type === "slippedInOoze" || e.type === "steppedOnTacks" ||
|
||||
e.type === "enteredThornbush" || e.type === "stuckInSlime") &&
|
||||
"player" in e && (e as { player: string }).player === povId
|
||||
) {
|
||||
out.push({ fx: { id: nextId++, kind: "shake", mag: 0.04, t0: 0, dur: 350 }, delay: 0 });
|
||||
} else if (e.type === "teleported" && e.player === povId) {
|
||||
out.push({ fx: { id: nextId++, kind: "flash", color: "#8050d0", peak: 0.35, t0: 0, dur: 400 }, delay: 0 });
|
||||
} else if (e.type === "moved" && e.via === "warp" && e.player === povId) {
|
||||
out.push({ fx: { id: nextId++, kind: "flash", color: "#8050d0", peak: 0.28, t0: 0, dur: 350 }, delay: 0 });
|
||||
} else if (
|
||||
(e.type === "knockedBack" || e.type === "shoved" || e.type === "washedBack" || e.type === "retreatedInHorror") &&
|
||||
e.player === povId
|
||||
) {
|
||||
out.push({ fx: { id: nextId++, kind: "shake", mag: 0.05, t0: 0, dur: 350 }, delay: povDelay });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Procedural stand-ins for the conjuration sprites: a soft glow in each
|
||||
* art's palette, replaced on screen the moment a painted file loads. */
|
||||
const FX_COLORS: Record<string, [string, string]> = {
|
||||
fireball: ["#ffe27a", "#e0431a"],
|
||||
bolt: ["#ffffff", "#7ab0ff"],
|
||||
waterbolt: ["#cfefff", "#1c6ed0"],
|
||||
spark: ["#fff6d8", "#c0a040"],
|
||||
burst: ["#fff0a0", "#d05010"],
|
||||
splash: ["#e8f8ff", "#2080c0"],
|
||||
hit: ["#ffd0c0", "#c02020"],
|
||||
shimmer: ["#f0e0ff", "#7040c0"],
|
||||
shield: ["#ffffff", "#4060d0"],
|
||||
rubble: ["#b0a898", "#5c544a"],
|
||||
};
|
||||
const baked = new Map<string, HTMLCanvasElement>();
|
||||
export function fxFallback(name: string): HTMLCanvasElement {
|
||||
let t = baked.get(name);
|
||||
if (!t) {
|
||||
t = document.createElement("canvas");
|
||||
t.width = 64;
|
||||
t.height = 64;
|
||||
const c = t.getContext("2d")!;
|
||||
const [core, rim] = FX_COLORS[name] ?? FX_COLORS.spark!;
|
||||
if (name === "rubble") {
|
||||
// A mound of broken stone, opaque — this one is debris, not light.
|
||||
for (let i = 0; i < 9; i++) {
|
||||
const jx = 12 + ((i * 37) % 40);
|
||||
const jy = 40 + ((i * 23) % 18);
|
||||
const r = 6 + ((i * 13) % 8);
|
||||
c.fillStyle = i % 2 ? core : rim;
|
||||
c.beginPath();
|
||||
c.arc(jx, jy, r, 0, Math.PI * 2);
|
||||
c.fill();
|
||||
}
|
||||
} else {
|
||||
const g = c.createRadialGradient(32, 32, 2, 32, 32, 30);
|
||||
g.addColorStop(0, core);
|
||||
g.addColorStop(0.55, rim);
|
||||
g.addColorStop(1, "rgba(0,0,0,0)");
|
||||
c.fillStyle = g;
|
||||
c.fillRect(0, 0, 64, 64);
|
||||
}
|
||||
baked.set(name, t);
|
||||
}
|
||||
return t;
|
||||
}
|
||||
@@ -0,0 +1,504 @@
|
||||
// First-person raycasting over the maze. The world is the GameView's board
|
||||
// — one unit per cell, walls living on EDGES between cells rather than in
|
||||
// them — so a ray marches cell boundaries (DDA) and asks each crossing
|
||||
// what stands there. Crucially the board arrives from viewFor(), which has
|
||||
// already rendered this wizard's BELIEFS: an illusion they have not seen
|
||||
// through is a wall here too, and the deception carries into first person.
|
||||
|
||||
import { cellKey, edgeKey, type Cell, type Side } from "@wizwar/engine";
|
||||
import type { GameView } from "@wizwar/engine";
|
||||
import { objectArt } from "../art";
|
||||
|
||||
export interface Hit {
|
||||
/** Distance along the ray (perpendicular-corrected by the caller). */
|
||||
dist: number;
|
||||
/** What the ray struck. */
|
||||
kind: "wall" | "door" | "firewall" | "stone" | "rim";
|
||||
/** 0..1 across the struck face (texture coordinate). */
|
||||
u: number;
|
||||
/** Vertical faces get a different shade than horizontal ones. */
|
||||
axis: "x" | "y";
|
||||
/** The un-wrapped along-face coordinate: u plus the world cell it lies
|
||||
* in, so multi-cell surfaces (a long wall of fire) can read as one. */
|
||||
worldU: number;
|
||||
/** The edge struck, for state the renderer dresses (jammed locks,
|
||||
* untested illusions). */
|
||||
edge?: string;
|
||||
/** The eye reached this through a warp: haze it other-worldly. */
|
||||
warped?: boolean;
|
||||
/** Which warp (index in board.warps) the ray first bent through, how
|
||||
* far along the ray that mouth stood, and where across it — the gate a
|
||||
* sprite must be beyond (its own warp) or in front of (any) to paint
|
||||
* this column, and the veil the renderer hangs in the opening. */
|
||||
warpId?: number;
|
||||
warpDist?: number;
|
||||
warpU?: number;
|
||||
/** The first known-illusion edge the ray crossed before its solid hit:
|
||||
* the eye passes, but a translucent ghost of a wall stands there. */
|
||||
ghost?: { dist: number; u: number; worldU: number; axis: "x" | "y" };
|
||||
/** The first OPEN doorway the ray passed through: the renderer hangs
|
||||
* the lintel of the frame across it. */
|
||||
doorway?: { dist: number; u: number };
|
||||
/** Something conjured is still RISING here: the ray passes over it,
|
||||
* and the renderer draws it growing bottom-up where it stood. */
|
||||
rising?: { dist: number; u: number; worldU: number; kind: Hit["kind"]; g: number };
|
||||
/** The struck slab is a doorway's jamb post, dressed in frame stone. */
|
||||
frame?: boolean;
|
||||
}
|
||||
|
||||
export const SIDE_ANGLE: Record<Side, number> = { E: 0, S: Math.PI / 2, W: Math.PI, N: -Math.PI / 2 };
|
||||
export const OPPOSITE: Record<Side, Side> = { E: "W", W: "E", N: "S", S: "N" };
|
||||
|
||||
/** Unit vector along an edge's lane axis (the direction `along` grows). */
|
||||
const LANE_AXIS: Record<Side, [number, number]> = {
|
||||
E: [0, 1], W: [0, 1], N: [1, 0], S: [1, 0],
|
||||
};
|
||||
|
||||
/** Does this warp pair MIRROR the lane? A traversal is a proper rigid
|
||||
* rotation; for half the side pairings the rotated entry axis points
|
||||
* AGAINST the exit edge's axis, and preserving the raw lane there would
|
||||
* smuggle in a reflection — rays crossing over at the mouth, parallax
|
||||
* inverted, the far room stretching as the eye moves. */
|
||||
export function warpLaneMirrored(from: Side, to: Side): boolean {
|
||||
const d = SIDE_ANGLE[OPPOSITE[to]] - SIDE_ANGLE[from];
|
||||
const [x, y] = LANE_AXIS[from];
|
||||
const rx = x * Math.cos(d) - y * Math.sin(d);
|
||||
const ry = x * Math.sin(d) + y * Math.cos(d);
|
||||
const [ux, uy] = LANE_AXIS[to];
|
||||
return rx * ux + ry * uy < 0;
|
||||
}
|
||||
|
||||
/** Is this edge passable to the EYE (rays), and if not, what is it?
|
||||
* Doors always report as doors; how far each stands open — fully for an
|
||||
* open or held door, mid-swing during an animation — is castRay's call. */
|
||||
function edgeObstacle(view: GameView, key: string): Hit["kind"] | null {
|
||||
const e = view.board.edges[key] ?? "open";
|
||||
if (e === "open") return null;
|
||||
if (e === "door") return "door";
|
||||
if (e === "firewall") return "firewall";
|
||||
return "wall";
|
||||
}
|
||||
|
||||
/** Half-thickness per material: walls are masonry, doors joinery, fire a
|
||||
* sheet. The slab gives every wall END a visible cap face, so corners and
|
||||
* doorways read as three-dimensional stone rather than paper. */
|
||||
const HALF_THICK: Record<string, number> = { wall: 0.07, rim: 0.07, door: 0.05, firewall: 0.025 };
|
||||
|
||||
/** Ray vs axis-aligned box; returns entry distance and the axis of the
|
||||
* face struck, or null for a miss. */
|
||||
function slabHit(
|
||||
ox: number, oy: number, dx: number, dy: number,
|
||||
minX: number, maxX: number, minY: number, maxY: number,
|
||||
): { t: number; axis: "x" | "y" } | null {
|
||||
const ix = 1 / (dx || 1e-9);
|
||||
const iy = 1 / (dy || 1e-9);
|
||||
let tx1 = (minX - ox) * ix, tx2 = (maxX - ox) * ix;
|
||||
if (tx1 > tx2) [tx1, tx2] = [tx2, tx1];
|
||||
let ty1 = (minY - oy) * iy, ty2 = (maxY - oy) * iy;
|
||||
if (ty1 > ty2) [ty1, ty2] = [ty2, ty1];
|
||||
const tNear = Math.max(tx1, ty1);
|
||||
const tFar = Math.min(tx2, ty2);
|
||||
if (tNear > tFar || tFar < 0) return null;
|
||||
return { t: Math.max(tNear, 0), axis: tx1 > ty1 ? "x" : "y" };
|
||||
}
|
||||
|
||||
/** What a slab test found: the strike, and how the door slid under it. */
|
||||
type SlabStrike = { t: number; axis: "x" | "y"; kind: Hit["kind"]; slide: number; edge: string; frame?: boolean };
|
||||
|
||||
/**
|
||||
* March one ray from (ox, oy) at `angle` and return the first thing that
|
||||
* stops the eye. Off-board is the maze's rim: a wall, unless the crossing
|
||||
* is a warp mouth — the ray passes through those, remembering the first.
|
||||
*/
|
||||
export function castRay(
|
||||
view: GameView, ox: number, oy: number, angle: number,
|
||||
/** Transient door openness (0 shut - 1 wide), keyed by edge; doors the
|
||||
* view holds open stand at 1 without an entry. Wolf3D-style: an open
|
||||
* door has slid along its own edge, the gap growing from u=0. */
|
||||
doors?: Record<string, number>,
|
||||
/** Conjurations mid-growth (0..1), keyed by edge key or stone cell
|
||||
* key: while below 1 the ray passes, and the hit records a rising. */
|
||||
growing?: Record<string, number>,
|
||||
): Hit {
|
||||
// Sight runs THROUGH warps as the rules say it does: a ray that reaches
|
||||
// a mouth re-enters at the paired mouth and marches on, its hits hazed.
|
||||
let baseDist = 0;
|
||||
let warped = false;
|
||||
let warpId: number | undefined;
|
||||
let warpDist: number | undefined;
|
||||
let warpU: number | undefined;
|
||||
let ghost: Hit["ghost"];
|
||||
let doorway: Hit["doorway"];
|
||||
let rising: Hit["rising"];
|
||||
for (let traversal = 0; traversal < 3; traversal++) {
|
||||
let traversed = false;
|
||||
const dx = Math.cos(angle);
|
||||
const dy = Math.sin(angle);
|
||||
let cx = Math.floor(ox);
|
||||
let cy = Math.floor(oy);
|
||||
const stepX = dx > 0 ? 1 : -1;
|
||||
const stepY = dy > 0 ? 1 : -1;
|
||||
const dDistX = Math.abs(1 / (dx || 1e-9));
|
||||
const dDistY = Math.abs(1 / (dy || 1e-9));
|
||||
let sideDistX = (dx > 0 ? cx + 1 - ox : ox - cx) * dDistX;
|
||||
let sideDistY = (dy > 0 ? cy + 1 - oy : oy - cy) * dDistY;
|
||||
|
||||
for (let i = 0; i < 64; i++) {
|
||||
// Every blocked edge of the current cell stands as a slab; the
|
||||
// nearest strike inside this cell's span wins.
|
||||
const exit = Math.min(sideDistX, sideDistY);
|
||||
let best: SlabStrike | null = null;
|
||||
const trySide = (side: Side, minX: number, maxX: number, minY: number, maxY: number) => {
|
||||
const key = edgeKey({ x: cx, y: cy }, side);
|
||||
const kind = edgeObstacle(view, key);
|
||||
if (!kind) return;
|
||||
const h = slabHit(ox, oy, dx, dy, minX, maxX, minY, maxY);
|
||||
if (!h || h.t > exit + 0.15 || (best && h.t >= best.t)) return;
|
||||
const g = growing?.[key];
|
||||
if (g !== undefined && g < 1) {
|
||||
// Still being conjured: the eye passes; the growth is drawn.
|
||||
if (!rising) {
|
||||
const along0 = h.axis === "x" ? oy + h.t * dy : ox + h.t * dx;
|
||||
rising = { dist: baseDist + h.t, u: along0 - Math.floor(along0), worldU: along0, kind, g };
|
||||
}
|
||||
return;
|
||||
}
|
||||
let slide = 0;
|
||||
let frame = false;
|
||||
if (kind === "door") {
|
||||
const open = doors?.[key] ??
|
||||
(view.openDoorEdges.includes(key) || view.heldDoorEdges.includes(key) ? 1 : 0);
|
||||
if (open >= 0.98) {
|
||||
// A door standing open is a DOORWAY: posts at both ends, a
|
||||
// lintel hung across, and the way through open to the eye.
|
||||
const along = h.axis === "x" ? oy + h.t * dy : ox + h.t * dx;
|
||||
const u = along - Math.floor(along);
|
||||
const JAMB = 0.08;
|
||||
if (u > JAMB && u < 1 - JAMB) {
|
||||
if (!doorway) doorway = { dist: baseDist + h.t, u };
|
||||
return;
|
||||
}
|
||||
frame = true;
|
||||
} else if (open > 0) {
|
||||
const along = h.axis === "x" ? oy + h.t * dy : ox + h.t * dx;
|
||||
const u = along - Math.floor(along);
|
||||
if (u < open - 0.02) return; // the ray sails through the opening
|
||||
slide = open;
|
||||
}
|
||||
}
|
||||
best = { ...h, kind, slide, edge: key, frame };
|
||||
};
|
||||
const hw = (side: Side) =>
|
||||
HALF_THICK[edgeObstacle(view, edgeKey({ x: cx, y: cy }, side)) ?? "wall"] ?? 0.07;
|
||||
trySide("E", cx + 1 - hw("E"), cx + 1 + hw("E"), cy, cy + 1);
|
||||
trySide("W", cx - hw("W"), cx + hw("W"), cy, cy + 1);
|
||||
trySide("S", cx, cx + 1, cy + 1 - hw("S"), cy + 1 + hw("S"));
|
||||
trySide("N", cx, cx + 1, cy - hw("N"), cy + hw("N"));
|
||||
if (best !== null) {
|
||||
const b = best as SlabStrike;
|
||||
const along = b.axis === "x" ? oy + b.t * dy : ox + b.t * dx;
|
||||
const u = along - Math.floor(along);
|
||||
// A sliding door carries its texture with it: sample past the gap.
|
||||
return {
|
||||
dist: baseDist + b.t, kind: b.kind, u: Math.max(0, u - b.slide),
|
||||
axis: b.axis, worldU: along, edge: b.edge, warped, warpId, warpDist, warpU, ghost, doorway, rising,
|
||||
...(b.frame ? { frame: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
// No slab in this cell: advance through the open boundary.
|
||||
const crossingX = sideDistX < sideDistY;
|
||||
const dist = crossingX ? sideDistX : sideDistY;
|
||||
let nx = cx, ny = cy;
|
||||
if (crossingX) { nx = cx + stepX; sideDistX += dDistX; }
|
||||
else { ny = cy + stepY; sideDistY += dDistY; }
|
||||
const axis: Hit["axis"] = crossingX ? "x" : "y";
|
||||
const along = crossingX ? oy + dist * dy : ox + dist * dx;
|
||||
const u0 = along % 1;
|
||||
const texU = u0 < 0 ? u0 + 1 : u0;
|
||||
|
||||
const crossSide: Side = crossingX ? (stepX > 0 ? "E" : "W") : (stepY > 0 ? "S" : "N");
|
||||
if (!ghost && view.knownIllusionEdges.includes(edgeKey({ x: cx, y: cy }, crossSide))) {
|
||||
ghost = { dist: baseDist + dist, u: texU, worldU: along, axis };
|
||||
}
|
||||
const offBoard = !view.board.cells[cellKey({ x: nx, y: ny })];
|
||||
if (offBoard) {
|
||||
const side: Side = crossSide;
|
||||
const wIdx = view.board.warps.findIndex(
|
||||
(w) => cellKey(w.from.cell) === cellKey({ x: cx, y: cy }) && w.from.side === side,
|
||||
);
|
||||
const warp = wIdx >= 0 ? view.board.warps[wIdx]! : undefined;
|
||||
if (!warp) return { dist: baseDist + dist, kind: "rim", u: texU, axis, worldU: along, warped, warpId, warpDist, warpU, ghost, doorway, rising };
|
||||
if (warpId === undefined) { warpId = wIdx; warpDist = baseDist + dist; warpU = texU; }
|
||||
// Step through: re-enter at the paired mouth, heading inward, the
|
||||
// lane carried by the same proper rotation the heading turns by —
|
||||
// mirrored for the pairings whose axes land head-to-head.
|
||||
const exitHeading = SIDE_ANGLE[OPPOSITE[warp.to.side]];
|
||||
angle = angle + (exitHeading - SIDE_ANGLE[warp.from.side]);
|
||||
const c = warp.to.cell;
|
||||
const lane = warpLaneMirrored(warp.from.side, warp.to.side) ? 1 - texU : texU;
|
||||
if (warp.to.side === "E") { ox = c.x + 1 - 1e-4; oy = c.y + lane; }
|
||||
else if (warp.to.side === "W") { ox = c.x + 1e-4; oy = c.y + lane; }
|
||||
else if (warp.to.side === "S") { ox = c.x + lane; oy = c.y + 1 - 1e-4; }
|
||||
else { ox = c.x + lane; oy = c.y + 1e-4; }
|
||||
baseDist += dist;
|
||||
warped = true;
|
||||
traversed = true;
|
||||
break; // restart the DDA from the far mouth
|
||||
}
|
||||
if (view.squareContents[cellKey({ x: nx, y: ny })]?.kind === "stone") {
|
||||
const sg = growing?.[cellKey({ x: nx, y: ny })];
|
||||
if (sg !== undefined && sg < 1) {
|
||||
if (!rising) rising = { dist: baseDist + dist, u: texU, worldU: along, kind: "stone", g: sg };
|
||||
} else {
|
||||
return { dist: baseDist + dist, kind: "stone", u: texU, axis, worldU: along, warped, warpId, warpDist, warpU, ghost, doorway, rising };
|
||||
}
|
||||
}
|
||||
cx = nx;
|
||||
cy = ny;
|
||||
}
|
||||
if (!traversed) break;
|
||||
}
|
||||
return { dist: baseDist + 64, kind: "rim", u: 0, axis: "x", worldU: 0, warped, warpId, warpDist, warpU, ghost, doorway, rising };
|
||||
}
|
||||
|
||||
/** Can a wizard's body (not just their eye) cross this edge? Workshop
|
||||
* collision: walls and closed doors stop you; fire does not, and an open
|
||||
* rim edge carries you only where a warp mouth waits. */
|
||||
export function canWalk(view: GameView, from: Cell, side: Side): boolean {
|
||||
const key = edgeKey(from, side);
|
||||
const e = view.board.edges[key] ?? "open";
|
||||
if (e === "wall") return false;
|
||||
if (e === "door" &&
|
||||
!view.openDoorEdges.includes(key) && !view.heldDoorEdges.includes(key)) return false;
|
||||
const to = {
|
||||
x: from.x + (side === "E" ? 1 : side === "W" ? -1 : 0),
|
||||
y: from.y + (side === "S" ? 1 : side === "N" ? -1 : 0),
|
||||
};
|
||||
if (!view.board.cells[cellKey(to)]) {
|
||||
return view.board.warps.some(
|
||||
(w) => cellKey(w.from.cell) === cellKey(from) && w.from.side === side,
|
||||
);
|
||||
}
|
||||
if (view.squareContents[cellKey(to)]?.kind === "stone") return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** The midpoint of one cell edge, in world units. */
|
||||
export function edgeMid(cell: Cell, side: Side): { x: number; y: number } {
|
||||
if (side === "E") return { x: cell.x + 1, y: cell.y + 0.5 };
|
||||
if (side === "W") return { x: cell.x, y: cell.y + 0.5 };
|
||||
if (side === "S") return { x: cell.x + 0.5, y: cell.y + 1 };
|
||||
return { x: cell.x + 0.5, y: cell.y };
|
||||
}
|
||||
|
||||
/** What a click in the first-person pane resolved to — the pane speaks
|
||||
* the board's own click vocabulary. */
|
||||
export type FpvTarget =
|
||||
| { kind: "player"; id: string }
|
||||
| { kind: "creature"; id: string }
|
||||
| { kind: "edge"; cell: Cell; side: Side }
|
||||
| { kind: "cell"; cell: Cell };
|
||||
|
||||
export interface Billboard {
|
||||
/** World position (cell-centered). */
|
||||
x: number;
|
||||
y: number;
|
||||
/** Art URL, resolved by the caller (token art respects preferences). */
|
||||
src: string;
|
||||
/** Fraction of wall height (a wizard stands taller than a chest). */
|
||||
scale: number;
|
||||
/** Lifted off the floor (0 = feet on the ground). */
|
||||
rise: number;
|
||||
label: string;
|
||||
/** Width as a multiple of height (1 = square; 2 = a cell-wide hedge). */
|
||||
aspect?: number;
|
||||
/** Translucency (jello, dust); 1 or absent is opaque. */
|
||||
alpha?: number;
|
||||
/** Drawn additively, as light (warp pillars). */
|
||||
glow?: boolean;
|
||||
/** Pulled this much nearer for draw order: a hedge wins the tie
|
||||
* against the wizard standing in its own square, who then peeks over. */
|
||||
bias?: number;
|
||||
/** Procedural stand-in name while the painted file loads. */
|
||||
fallback?: string;
|
||||
/** Identity for growth animation: a creature's id, or a cell key. */
|
||||
key?: string;
|
||||
/** Cell-bound volumes never paint outside their own square: a
|
||||
* camera-facing hedge viewed obliquely would otherwise poke its ends
|
||||
* through the neighboring walls. */
|
||||
clip?: { x: number; y: number };
|
||||
/** A reflection seen through a warp mouth, not the thing itself —
|
||||
* visible only through THAT warp's columns, beyond its mouth. */
|
||||
warped?: boolean;
|
||||
warpId?: number;
|
||||
/** Click identity: the specific body this sprite is (a wizard, a
|
||||
* creature) — the pane's hit test hands it straight to the board's
|
||||
* own click handlers. */
|
||||
hit?: { kind: "player" | "creature"; id: string };
|
||||
/** Click fallback: the REAL square this sprite stands in (a virtual
|
||||
* warp copy keeps its source's square, not its drawn position). */
|
||||
cell?: Cell;
|
||||
}
|
||||
|
||||
/** The along=0 corner of a warp mouth — the anchor castRay's lane
|
||||
* preservation measures from. */
|
||||
function mouthAnchor(m: { cell: Cell; side: Side }): { x: number; y: number } {
|
||||
const c = m.cell;
|
||||
if (m.side === "E") return { x: c.x + 1, y: c.y };
|
||||
if (m.side === "W") return { x: c.x, y: c.y };
|
||||
if (m.side === "S") return { x: c.x, y: c.y + 1 };
|
||||
return { x: c.x, y: c.y };
|
||||
}
|
||||
|
||||
/** The rigid motion one warp applies to sight: far-region points map into
|
||||
* the near mouth's virtual space (where a straight ray would put them),
|
||||
* and back again. Anchored and rotated exactly as castRay re-enters. */
|
||||
export function warpMotion(w: GameView["board"]["warps"][number]): {
|
||||
toVirtual(p: { x: number; y: number }): { x: number; y: number };
|
||||
toReal(p: { x: number; y: number }): { x: number; y: number };
|
||||
} {
|
||||
const delta = SIDE_ANGLE[OPPOSITE[w.to.side]] - SIDE_ANGLE[w.from.side];
|
||||
const a1 = mouthAnchor(w.from);
|
||||
// Mirrored pairings anchor the far mouth at its OTHER corner — the
|
||||
// lane runs backwards there, and this keeps the motion a pure
|
||||
// rotation matching castRay's traversal.
|
||||
const a2 = { ...mouthAnchor(w.to) };
|
||||
if (warpLaneMirrored(w.from.side, w.to.side)) {
|
||||
const [lx, ly] = LANE_AXIS[w.to.side];
|
||||
a2.x += lx;
|
||||
a2.y += ly;
|
||||
}
|
||||
const c = Math.cos(delta), s = Math.sin(delta);
|
||||
return {
|
||||
toVirtual(p) {
|
||||
const rx = p.x - a2.x, ry = p.y - a2.y;
|
||||
return { x: a1.x + rx * c + ry * s, y: a1.y - rx * s + ry * c };
|
||||
},
|
||||
toReal(p) {
|
||||
const rx = p.x - a1.x, ry = p.y - a1.y;
|
||||
return { x: a2.x + rx * c - ry * s, y: a2.y + rx * s + ry * c };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Everything standing in the maze that the reel should draw as a sprite.
|
||||
* `posOverride` maps a player or creature id to an in-flight world
|
||||
* position — the reel glides bodies between steps instead of blinking
|
||||
* them from cell to cell. */
|
||||
export function billboards(
|
||||
view: GameView,
|
||||
povId: string,
|
||||
art: (file: string, cat: "players" | "creatures" | "objects" | "terrain") => string,
|
||||
posOverride?: Record<string, { x: number; y: number }>,
|
||||
): Billboard[] {
|
||||
const out: Billboard[] = [];
|
||||
for (const p of view.players) {
|
||||
if (!p.alive || p.id === povId) continue;
|
||||
const o = posOverride?.[p.id];
|
||||
out.push({
|
||||
x: o?.x ?? p.position.x + 0.5, y: o?.y ?? p.position.y + 0.5,
|
||||
src: art(`wizard-${p.colorIndex}`, "players"), scale: 0.85, rise: 0, label: p.id,
|
||||
hit: { kind: "player", id: p.id }, cell: { ...p.position },
|
||||
});
|
||||
}
|
||||
for (const c of view.creatures) {
|
||||
if (c.id === povId) continue; // possessed: you do not see your own hide
|
||||
const o = posOverride?.[c.id];
|
||||
out.push({
|
||||
x: o?.x ?? c.position.x + 0.5, y: o?.y ?? c.position.y + 0.5,
|
||||
src: art(c.kind, "creatures"), scale: 0.75, rise: 0, label: c.kind, key: c.id,
|
||||
hit: { kind: "creature", id: c.id }, cell: { ...c.position },
|
||||
});
|
||||
}
|
||||
for (const t of view.treasures) {
|
||||
const src = art(`treasure-${(view.players.find((p) => p.id === t.owner)?.colorIndex ?? 0) % 6}`, "objects");
|
||||
if (t.carriedBy) {
|
||||
// A carried treasure rides its carrier at chest height, drawn just
|
||||
// in front of them — the most important status in the game should
|
||||
// be visible on the thief's own body.
|
||||
const carrier = view.players.find((p) => p.id === t.carriedBy && p.alive);
|
||||
if (!carrier || carrier.id === povId) continue;
|
||||
const o = posOverride?.[carrier.id];
|
||||
out.push({
|
||||
x: o?.x ?? carrier.position.x + 0.5, y: o?.y ?? carrier.position.y + 0.5,
|
||||
src, scale: 0.26, rise: 0.2, bias: 0.12, label: "treasure",
|
||||
hit: { kind: "player", id: carrier.id }, cell: { ...carrier.position },
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (!t.position) continue;
|
||||
out.push({
|
||||
x: t.position.x + 0.5, y: t.position.y + 0.5,
|
||||
src, scale: 0.4, rise: 0, label: "treasure", cell: { ...t.position },
|
||||
});
|
||||
}
|
||||
// The floor's furniture, standing in the room. Bushes fill the square
|
||||
// to half height (a wizard peeks over); the killer ooze is a whole
|
||||
// translucent cube of jello; dust billows. Pits, slime, tacks, and
|
||||
// dimensional warps are painted INTO the floor by the decal pass, and
|
||||
// stone is a wall to the rays — none of those need a sprite.
|
||||
for (const [k, content] of Object.entries(view.squareContents)) {
|
||||
const [tx, ty] = k.split(",").map(Number) as [number, number];
|
||||
const at = { x: tx + 0.5, y: ty + 0.5 };
|
||||
if (content.kind === "thornbush" || content.kind === "rosebush") {
|
||||
out.push({
|
||||
...at, src: `/terrain3d/${content.kind}.png`, fallback: content.kind,
|
||||
scale: 0.5, aspect: 2.1, rise: 0, bias: 0.18, label: content.kind, key: k,
|
||||
clip: { x: tx, y: ty }, cell: { x: tx, y: ty },
|
||||
});
|
||||
} else if (content.kind === "ooze") {
|
||||
out.push({
|
||||
...at, src: "/terrain3d/jello.png", fallback: "jello",
|
||||
scale: 0.96, aspect: 1.04, rise: 0, alpha: 0.6, bias: 0.18, label: "ooze", key: k,
|
||||
clip: { x: tx, y: ty }, cell: { x: tx, y: ty },
|
||||
});
|
||||
} else if (content.kind === "dust") {
|
||||
out.push({
|
||||
...at, src: "/terrain3d/dust.png", fallback: "dust",
|
||||
scale: 0.85, aspect: 1.1, rise: 0, alpha: 0.55, bias: 0.18, label: "dust", key: k,
|
||||
clip: { x: tx, y: ty }, cell: { x: tx, y: ty },
|
||||
});
|
||||
} else if (content.kind === "safe") {
|
||||
out.push({
|
||||
...at, src: "/terrain3d/safe.png", fallback: "safe",
|
||||
scale: 0.55, aspect: 1, rise: 0, bias: 0.18, label: "safe", key: k,
|
||||
clip: { x: tx, y: ty }, cell: { x: tx, y: ty },
|
||||
});
|
||||
}
|
||||
}
|
||||
// Dimensional warp mouths throw pillars of light from their floor rings.
|
||||
for (const w of view.dimWarps) {
|
||||
for (const cellAt of [w.a, w.b]) {
|
||||
out.push({
|
||||
x: cellAt.x + 0.5, y: cellAt.y + 0.5,
|
||||
src: "/terrain3d/warpglow.png", fallback: "warpglow",
|
||||
scale: 0.95, aspect: 0.5, rise: 0, alpha: 0.6, glow: true, label: "dimwarp",
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const [k, cards] of Object.entries(view.groundObjects)) {
|
||||
const file = cards[0] ? objectArt(cards[0].cardId) : null;
|
||||
if (!file) continue;
|
||||
const [tx, ty] = k.split(",").map(Number) as [number, number];
|
||||
out.push({ x: tx + 0.5, y: ty + 0.5, src: art(file, "objects"), scale: 0.25, rise: 0, label: cards[0]!.cardId });
|
||||
}
|
||||
// Rays see through warp mouths; the standing world should follow. Each
|
||||
// sprite near a far mouth also appears in the near mouth's frame, under
|
||||
// the same rigid motion the rays use — the zbuffer clips it to the
|
||||
// opening, since everything around the mouth is nearer wall.
|
||||
const real = out.slice();
|
||||
for (let wi = 0; wi < view.board.warps.length; wi++) {
|
||||
const w = view.board.warps[wi]!;
|
||||
const motion = warpMotion(w);
|
||||
const a2 = mouthAnchor(w.to);
|
||||
const inward = SIDE_ANGLE[OPPOSITE[w.to.side]];
|
||||
const inX = Math.cos(inward), inY = Math.sin(inward);
|
||||
for (const b of real) {
|
||||
const rx = b.x - a2.x, ry = b.y - a2.y;
|
||||
if (rx * inX + ry * inY < -0.2 || Math.hypot(rx, ry) > 8) continue;
|
||||
out.push({ ...b, ...motion.toVirtual(b), warped: true, warpId: wi });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// Terrain that stands in the room rather than lying on its floor: bushes
|
||||
// filling a square to half height, the killer ooze as a translucent cube
|
||||
// of jello, dust billowing, dimensional warps throwing light. Each is a
|
||||
// paintable sprite at public/terrain3d/<name>.png (transparent PNG, any
|
||||
// size); the bakes below stand in until painted.
|
||||
|
||||
export const TERRAIN3D = ["thornbush", "rosebush", "jello", "dust", "warpglow", "safe"] as const;
|
||||
|
||||
const baked = new Map<string, HTMLCanvasElement>();
|
||||
export function terrainFallback(name: string): HTMLCanvasElement {
|
||||
let t = baked.get(name);
|
||||
if (t) return t;
|
||||
t = document.createElement("canvas");
|
||||
t.width = 128;
|
||||
t.height = 64;
|
||||
const c = t.getContext("2d")!;
|
||||
const h2 = (a: number, b: number) => {
|
||||
const n = Math.sin(a * 127.1 + b * 311.7) * 43758.5453;
|
||||
return n - Math.floor(n);
|
||||
};
|
||||
if (name === "thornbush" || name === "rosebush") {
|
||||
// A hedge of tangles wall to wall; roses get their blooms.
|
||||
for (let i = 0; i < 40; i++) {
|
||||
const x = h2(i, 1) * 128, y = 16 + h2(i, 3) * 46, r = 6 + h2(i, 5) * 9;
|
||||
const g = 60 + (h2(i, 7) * 50 | 0);
|
||||
c.fillStyle = `rgba(${g * 0.35 | 0},${g},${g * 0.3 | 0},0.9)`;
|
||||
c.beginPath();
|
||||
c.arc(x, y, r, 0, Math.PI * 2);
|
||||
c.fill();
|
||||
}
|
||||
c.strokeStyle = "rgba(30,40,18,0.7)";
|
||||
for (let i = 0; i < 14; i++) {
|
||||
c.beginPath();
|
||||
c.moveTo(h2(i, 11) * 128, 18 + h2(i, 13) * 40);
|
||||
c.lineTo(h2(i, 11) * 128 + 10 - h2(i, 17) * 20, 8 + h2(i, 19) * 40);
|
||||
c.stroke();
|
||||
}
|
||||
if (name === "rosebush") {
|
||||
for (let i = 0; i < 9; i++) {
|
||||
c.fillStyle = "rgba(210,40,70,0.95)";
|
||||
c.beginPath();
|
||||
c.arc(6 + h2(i, 23) * 116, 14 + h2(i, 29) * 40, 3.5, 0, Math.PI * 2);
|
||||
c.fill();
|
||||
}
|
||||
}
|
||||
} else if (name === "jello") {
|
||||
// The gelatinous whole of the killer ooze, drawn as one cube face
|
||||
// with a paler top edge — the renderer's alpha keeps it see-through.
|
||||
t.height = 128;
|
||||
const g2 = t.getContext("2d")!;
|
||||
g2.fillStyle = "rgba(90,190,70,0.85)";
|
||||
g2.fillRect(4, 12, 120, 112);
|
||||
g2.fillStyle = "rgba(170,240,140,0.9)";
|
||||
g2.fillRect(4, 12, 120, 10);
|
||||
g2.fillStyle = "rgba(230,255,210,0.5)";
|
||||
g2.fillRect(16, 30, 22, 12);
|
||||
for (let i = 0; i < 8; i++) {
|
||||
g2.fillStyle = "rgba(40,120,30,0.5)";
|
||||
g2.beginPath();
|
||||
g2.arc(16 + h2(i, 31) * 96, 40 + h2(i, 37) * 72, 3 + h2(i, 41) * 3, 0, Math.PI * 2);
|
||||
g2.fill();
|
||||
}
|
||||
} else if (name === "dust") {
|
||||
t.height = 128;
|
||||
const g2 = t.getContext("2d")!;
|
||||
for (let i = 0; i < 26; i++) {
|
||||
const a = 0.12 + h2(i, 43) * 0.16;
|
||||
g2.fillStyle = `rgba(200,190,170,${a})`;
|
||||
g2.beginPath();
|
||||
g2.arc(14 + h2(i, 47) * 100, 14 + h2(i, 53) * 100, 12 + h2(i, 59) * 16, 0, Math.PI * 2);
|
||||
g2.fill();
|
||||
}
|
||||
} else if (name === "safe") {
|
||||
// A squat iron strongbox with its combination dial.
|
||||
t.width = 128;
|
||||
t.height = 128;
|
||||
const g2 = t.getContext("2d")!;
|
||||
g2.fillStyle = "#3c3f46";
|
||||
g2.fillRect(14, 34, 100, 90);
|
||||
g2.fillStyle = "#54585f";
|
||||
g2.fillRect(20, 40, 88, 78);
|
||||
g2.strokeStyle = "#23252a";
|
||||
g2.lineWidth = 4;
|
||||
g2.strokeRect(16, 36, 96, 86);
|
||||
g2.fillStyle = "#8b8f96";
|
||||
g2.beginPath();
|
||||
g2.arc(64, 78, 16, 0, Math.PI * 2);
|
||||
g2.fill();
|
||||
g2.fillStyle = "#2a2c31";
|
||||
g2.beginPath();
|
||||
g2.arc(64, 78, 10, 0, Math.PI * 2);
|
||||
g2.fill();
|
||||
g2.fillStyle = "#8b8f96";
|
||||
g2.fillRect(96, 66, 8, 24);
|
||||
} else {
|
||||
// warpglow: a pillar of violet light, brightest at its core.
|
||||
t.width = 64;
|
||||
t.height = 128;
|
||||
const g2 = t.getContext("2d")!;
|
||||
const grad = g2.createLinearGradient(0, 0, 64, 0);
|
||||
grad.addColorStop(0, "rgba(140,80,220,0)");
|
||||
grad.addColorStop(0.5, "rgba(200,160,255,0.75)");
|
||||
grad.addColorStop(1, "rgba(140,80,220,0)");
|
||||
g2.fillStyle = grad;
|
||||
g2.fillRect(0, 0, 64, 128);
|
||||
}
|
||||
baked.set(name, t);
|
||||
return t;
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
// The first-person materials. Each has two lives: a hand-paintable file
|
||||
// at /textures/<name>.png (the one to improve — any size, sampled by
|
||||
// column like the token art is swapped by file), and the procedural bake
|
||||
// below that ships as the default and stands in while a file loads.
|
||||
|
||||
const FACE: Record<string, [number, number, number]> = {
|
||||
wall: [126, 118, 100],
|
||||
stone: [96, 96, 104],
|
||||
door: [130, 92, 48],
|
||||
rim: [82, 76, 66],
|
||||
};
|
||||
|
||||
// Textures are baked once at 64x64 and sampled one column at a time —
|
||||
// perspective-correct verticals for the price of a single drawImage.
|
||||
const TEX = 64;
|
||||
function bake(paint: (c: CanvasRenderingContext2D) => void): HTMLCanvasElement {
|
||||
const t = document.createElement("canvas");
|
||||
t.width = TEX; t.height = TEX;
|
||||
const c = t.getContext("2d")!;
|
||||
paint(c);
|
||||
return t;
|
||||
}
|
||||
function h2(a: number, b: number): number {
|
||||
// Cheap deterministic jitter for brick shading.
|
||||
const n = Math.sin(a * 127.1 + b * 311.7) * 43758.5453;
|
||||
return n - Math.floor(n);
|
||||
}
|
||||
function brickTexture(base: [number, number, number], rows: number, mortar: string): HTMLCanvasElement {
|
||||
return bake((c) => {
|
||||
const rh = TEX / rows;
|
||||
for (let r = 0; r < rows; r++) {
|
||||
const offset = (r % 2) * (TEX / 4);
|
||||
for (let b = -1; b < 3; b++) {
|
||||
const jit = 0.82 + 0.30 * h2(r, b);
|
||||
c.fillStyle = `rgb(${base[0] * jit | 0},${base[1] * jit | 0},${base[2] * jit | 0})`;
|
||||
c.fillRect(b * (TEX / 2) + offset, r * rh, TEX / 2 - 2, rh - 2);
|
||||
}
|
||||
}
|
||||
c.fillStyle = mortar;
|
||||
for (let r = 0; r <= rows; r++) c.fillRect(0, r * rh - 1, TEX, 2);
|
||||
});
|
||||
}
|
||||
export const MATERIALS = ["wall", "rim", "stone", "door", "firewall", "warp", "doorframe", "cracks", "floor", "ceiling", "home", "pit", "slime", "tacks", "dimwarp", "underbrush"] as const;
|
||||
|
||||
/** The built-in bake: what ships when no painted file overrides it. */
|
||||
function proceduralTextures(): Record<string, HTMLCanvasElement> {
|
||||
return {
|
||||
wall: brickTexture(FACE.wall!, 4, "rgba(28,24,18,0.9)"),
|
||||
rim: brickTexture(FACE.rim!, 3, "rgba(16,14,12,0.95)"),
|
||||
stone: brickTexture(FACE.stone!, 2, "rgba(20,20,26,0.9)"),
|
||||
door: bake((c) => {
|
||||
const [r, g, b] = FACE.door!;
|
||||
for (let p = 0; p < 4; p++) {
|
||||
const jit = 0.86 + 0.22 * h2(p, 7);
|
||||
c.fillStyle = `rgb(${r * jit | 0},${g * jit | 0},${b * jit | 0})`;
|
||||
c.fillRect(p * (TEX / 4), 0, TEX / 4 - 2, TEX);
|
||||
}
|
||||
c.fillStyle = "rgba(30,20,10,0.9)";
|
||||
for (let p = 0; p <= 4; p++) c.fillRect(p * (TEX / 4) - 1, 0, 2, TEX);
|
||||
c.fillRect(0, 0, TEX, 4); // lintel
|
||||
c.fillStyle = "rgba(60,60,66,0.9)"; // iron band and handle
|
||||
c.fillRect(0, TEX * 0.42, TEX, 3);
|
||||
c.beginPath();
|
||||
c.arc(TEX * 0.78, TEX * 0.52, 2.5, 0, Math.PI * 2);
|
||||
c.fill();
|
||||
}),
|
||||
firewall: bake((c) => {
|
||||
const grad = c.createLinearGradient(0, 0, 0, TEX);
|
||||
grad.addColorStop(0, "#7c1a14");
|
||||
grad.addColorStop(0.55, "#d65c1c");
|
||||
grad.addColorStop(1, "#f0a13a");
|
||||
c.fillStyle = grad;
|
||||
c.fillRect(0, 0, TEX, TEX);
|
||||
for (let i = 0; i < 20; i++) {
|
||||
c.fillStyle = `rgba(255,${180 + (h2(i, 3) * 60) | 0},80,${0.25 + h2(i, 5) * 0.3})`;
|
||||
const w = 2 + h2(i, 9) * 4;
|
||||
c.fillRect(h2(i, 1) * TEX, TEX - h2(i, 7) * TEX, w, h2(i, 7) * TEX);
|
||||
}
|
||||
}),
|
||||
floor: bake((c) => {
|
||||
// Flagstones: a 2x2 of worn slabs per cell, the mortar border carrying
|
||||
// the tile seam so every cell reads as one square of the maze.
|
||||
c.fillStyle = "#3a3226";
|
||||
c.fillRect(0, 0, TEX, TEX);
|
||||
for (let r = 0; r < 2; r++) {
|
||||
for (let q = 0; q < 2; q++) {
|
||||
const jit = 0.8 + 0.3 * h2(r * 3 + 1, q * 7 + 2);
|
||||
c.fillStyle = `rgb(${74 * jit | 0},${64 * jit | 0},${48 * jit | 0})`;
|
||||
c.fillRect(q * (TEX / 2) + 2, r * (TEX / 2) + 2, TEX / 2 - 4, TEX / 2 - 4);
|
||||
}
|
||||
}
|
||||
c.strokeStyle = "rgba(16,12,8,0.9)";
|
||||
c.lineWidth = 3;
|
||||
c.strokeRect(0.5, 0.5, TEX - 1, TEX - 1);
|
||||
}),
|
||||
ceiling: bake((c) => {
|
||||
// Heavy dark slabs overhead, barely catching the torchlight.
|
||||
c.fillStyle = "#161310";
|
||||
c.fillRect(0, 0, TEX, TEX);
|
||||
for (let r = 0; r < 2; r++) {
|
||||
for (let q = 0; q < 2; q++) {
|
||||
const jit = 0.75 + 0.3 * h2(r * 5 + 3, q * 11 + 4);
|
||||
c.fillStyle = `rgb(${34 * jit | 0},${30 * jit | 0},${26 * jit | 0})`;
|
||||
c.fillRect(q * (TEX / 2) + 1, r * (TEX / 2) + 1, TEX / 2 - 2, TEX / 2 - 2);
|
||||
}
|
||||
}
|
||||
}),
|
||||
home: bake((c) => {
|
||||
// A home base's floor emblem, laid over the flagstones and tinted by
|
||||
// its owner's color at draw time — so paint it near-white, with
|
||||
// transparency wherever the plain floor should show through.
|
||||
c.strokeStyle = "rgba(240,235,225,0.85)";
|
||||
c.lineWidth = 3;
|
||||
c.strokeRect(4.5, 4.5, TEX - 9, TEX - 9);
|
||||
c.lineWidth = 2;
|
||||
c.beginPath();
|
||||
c.moveTo(TEX / 2, 12);
|
||||
c.lineTo(TEX - 12, TEX / 2);
|
||||
c.lineTo(TEX / 2, TEX - 12);
|
||||
c.lineTo(12, TEX / 2);
|
||||
c.closePath();
|
||||
c.stroke();
|
||||
c.fillStyle = "rgba(240,235,225,0.35)";
|
||||
c.beginPath();
|
||||
c.arc(TEX / 2, TEX / 2, 7, 0, Math.PI * 2);
|
||||
c.fill();
|
||||
}),
|
||||
underbrush: bake((c) => {
|
||||
// The ground beneath a hedge: leaf litter and roots to the square's
|
||||
// edges, so the standing tangle above never floats on bare flags.
|
||||
for (let i = 0; i < 46; i++) {
|
||||
const g = 40 + (h2(i, 61) * 45 | 0);
|
||||
c.fillStyle = `rgba(${g * 0.4 | 0},${g},${g * 0.35 | 0},${0.5 + h2(i, 67) * 0.4})`;
|
||||
c.beginPath();
|
||||
c.ellipse(h2(i, 71) * TEX, h2(i, 73) * TEX, 3 + h2(i, 79) * 6, 2 + h2(i, 83) * 4,
|
||||
h2(i, 89) * Math.PI, 0, Math.PI * 2);
|
||||
c.fill();
|
||||
}
|
||||
}),
|
||||
doorframe: bake((c) => {
|
||||
// An empty doorway's dressing: stone posts up both sides and a
|
||||
// lintel across the top, transparent in the middle where the way
|
||||
// through stands open. Posts live in the outer ~8% columns; the
|
||||
// lintel in the top ~16% rows.
|
||||
const stone = (x: number, y: number, w: number, h: number, jit: number) => {
|
||||
const g = 120 * (0.85 + 0.25 * h2(jit, 3)) | 0;
|
||||
c.fillStyle = `rgb(${g},${g * 0.93 | 0},${g * 0.8 | 0})`;
|
||||
c.fillRect(x, y, w, h);
|
||||
c.strokeStyle = "rgba(30,24,16,0.8)";
|
||||
c.strokeRect(x + 0.5, y + 0.5, w - 1, h - 1);
|
||||
};
|
||||
for (let r = 0; r < 5; r++) {
|
||||
stone(0, 10 + r * 11, 6, 10, r);
|
||||
stone(TEX - 6, 10 + r * 11, 6, 10, r + 7);
|
||||
}
|
||||
stone(0, 0, TEX / 2, 9, 13);
|
||||
stone(TEX / 2, 0, TEX / 2, 9, 17);
|
||||
}),
|
||||
cracks: bake((c) => {
|
||||
// Battle damage, worn over any wall by accumulated harm: dark
|
||||
// fractures on transparency, drawn heavier as damage mounts.
|
||||
c.strokeStyle = "rgba(14,10,6,0.85)";
|
||||
c.lineWidth = 1.6;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
let x = h2(i, 21) * TEX, y = 0;
|
||||
c.beginPath();
|
||||
c.moveTo(x, y);
|
||||
while (y < TEX) {
|
||||
x += (h2(x, y) - 0.5) * 14;
|
||||
y += 6 + h2(y, x) * 8;
|
||||
c.lineTo(x, y);
|
||||
}
|
||||
c.stroke();
|
||||
}
|
||||
}),
|
||||
pit: bake((c) => {
|
||||
// A hole in the world: black heart, ragged broken-flag edge, a rim
|
||||
// highlight on the lit side selling the depth.
|
||||
c.fillStyle = "rgba(0,0,0,0.92)";
|
||||
c.beginPath();
|
||||
c.ellipse(TEX / 2, TEX / 2, TEX * 0.38, TEX * 0.34, 0, 0, Math.PI * 2);
|
||||
c.fill();
|
||||
c.strokeStyle = "rgba(20,16,10,0.9)";
|
||||
c.lineWidth = 4;
|
||||
c.stroke();
|
||||
c.strokeStyle = "rgba(180,168,140,0.5)";
|
||||
c.lineWidth = 2;
|
||||
c.beginPath();
|
||||
c.ellipse(TEX / 2, TEX / 2 - 2, TEX * 0.38, TEX * 0.34, 0, Math.PI * 1.1, Math.PI * 1.9);
|
||||
c.stroke();
|
||||
}),
|
||||
slime: bake((c) => {
|
||||
// A spill of green across the flags, glistening.
|
||||
c.fillStyle = "rgba(70,140,40,0.55)";
|
||||
c.beginPath();
|
||||
c.ellipse(TEX * 0.5, TEX * 0.52, TEX * 0.4, TEX * 0.32, 0.4, 0, Math.PI * 2);
|
||||
c.fill();
|
||||
c.fillStyle = "rgba(110,190,60,0.5)";
|
||||
for (let i = 0; i < 6; i++) {
|
||||
c.beginPath();
|
||||
c.ellipse(10 + h2(i, 2) * 44, 12 + h2(i, 5) * 40, 4 + h2(i, 7) * 5, 3 + h2(i, 9) * 4, 0, 0, Math.PI * 2);
|
||||
c.fill();
|
||||
}
|
||||
c.fillStyle = "rgba(220,255,200,0.35)";
|
||||
c.fillRect(TEX * 0.32, TEX * 0.4, 5, 2);
|
||||
}),
|
||||
tacks: bake((c) => {
|
||||
// A scatter of little iron cruelties.
|
||||
for (let i = 0; i < 14; i++) {
|
||||
const x = 8 + h2(i, 3) * 48, y = 8 + h2(i, 6) * 48;
|
||||
c.fillStyle = "rgba(70,70,78,0.9)";
|
||||
c.beginPath();
|
||||
c.moveTo(x, y - 3);
|
||||
c.lineTo(x + 3, y + 2);
|
||||
c.lineTo(x - 3, y + 2);
|
||||
c.closePath();
|
||||
c.fill();
|
||||
c.fillStyle = "rgba(190,190,200,0.8)";
|
||||
c.fillRect(x - 1, y - 4, 2, 2);
|
||||
}
|
||||
}),
|
||||
dimwarp: bake((c) => {
|
||||
// The dimensional warp's floor mouth: violet rings drawing inward.
|
||||
for (let i = 5; i >= 0; i--) {
|
||||
const a = 0.16 + i * 0.1;
|
||||
c.strokeStyle = `rgba(${140 + i * 12},${80 + i * 14},${220},${a})`;
|
||||
c.lineWidth = 3;
|
||||
c.beginPath();
|
||||
c.arc(TEX / 2, TEX / 2, 5 + i * 5, 0, Math.PI * 2);
|
||||
c.stroke();
|
||||
}
|
||||
c.fillStyle = "rgba(240,225,255,0.8)";
|
||||
c.beginPath();
|
||||
c.arc(TEX / 2, TEX / 2, 3.5, 0, Math.PI * 2);
|
||||
c.fill();
|
||||
}),
|
||||
warp: bake((c) => {
|
||||
const grad = c.createLinearGradient(0, 0, TEX, TEX);
|
||||
grad.addColorStop(0, "#2c1a4e");
|
||||
grad.addColorStop(0.5, "#7a4ccc");
|
||||
grad.addColorStop(1, "#3a2266");
|
||||
c.fillStyle = grad;
|
||||
c.fillRect(0, 0, TEX, TEX);
|
||||
c.strokeStyle = "rgba(200,170,255,0.5)";
|
||||
for (let i = 0; i < 6; i++) {
|
||||
c.beginPath();
|
||||
c.arc(TEX / 2, TEX / 2, 6 + i * 5, i, i + 4.2);
|
||||
c.stroke();
|
||||
}
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/** The material set the renderer draws from: procedural now, each entry
|
||||
* replaced in place the moment its painted file arrives. */
|
||||
export function materialTextures(): Record<string, CanvasImageSource & { width: number; height: number }> {
|
||||
const set: Record<string, CanvasImageSource & { width: number; height: number }> = proceduralTextures();
|
||||
for (const name of MATERIALS) {
|
||||
const img = new Image();
|
||||
img.onload = () => { set[name] = img; };
|
||||
img.src = `/textures/${name}.png`;
|
||||
}
|
||||
return set;
|
||||
}
|
||||
@@ -61,10 +61,10 @@ function describe(view: GameView, cmd: Command): string {
|
||||
return `march the monster ${DIRECTION[cmd.direction]}`;
|
||||
case "creatureAttack":
|
||||
return `set the monster on ${cmd.targetId}`;
|
||||
case "tearTreasure":
|
||||
return `wrest the treasure from ${cmd.targetId}'s arms`;
|
||||
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":
|
||||
@@ -91,7 +91,8 @@ export function tableHints(view: GameView): TableHint[] {
|
||||
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 {
|
||||
} catch (e) {
|
||||
console.warn("hint council choked:", e);
|
||||
advice = "…I have no counsel here.";
|
||||
}
|
||||
out.push({ name: v.name, glyph: v.glyph, mood: v.mood, advice });
|
||||
|
||||
@@ -136,7 +136,6 @@ class LocalGame {
|
||||
seed,
|
||||
sets: expansion ? ["basic", "expansion1"] : ["basic"],
|
||||
...(colors ? { colors } : {}),
|
||||
deckRev: 36,
|
||||
};
|
||||
const { state, events } = createGame(config);
|
||||
for (const e of events) {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { mount } from "svelte";
|
||||
import App from "./App.svelte";
|
||||
|
||||
const app = mount(App, { target: document.getElementById("app")! });
|
||||
|
||||
export default app;
|
||||
// /watch/<id> is the public share page — one turn, anyone may look. It
|
||||
// loads its own component so the full app (and its live socket appetite)
|
||||
// stays out of a shared link's way.
|
||||
const target = document.getElementById("app")!;
|
||||
const page = location.pathname.startsWith("/watch/")
|
||||
? import("./SharePage.svelte")
|
||||
: import("./App.svelte");
|
||||
page.then(({ default: Root }) => mount(Root, { target }));
|
||||
|
||||
@@ -62,6 +62,10 @@ export function humanize(e: GameEvent): string | null {
|
||||
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 "wardWindow": return `The grab hangs in the air — ${e.owner} clutches something…`;
|
||||
case "treasureTorn": return e.toFloor
|
||||
? `${e.attacker} TEARS the treasure from ${e.defender}'s arms — it tumbles to the floor!`
|
||||
: `${e.attacker} TEARS the treasure from ${e.defender}'s arms!`;
|
||||
case "tearResisted": return `${e.defender} clutches the treasure with white knuckles — ${e.attacker} comes away empty!`;
|
||||
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.`;
|
||||
@@ -84,10 +88,31 @@ export function humanize(e: GameEvent): string | null {
|
||||
: `${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 "cardsDrawnPrivate": return e.cards.length > 0
|
||||
? `You draw ${e.cards.map((c) => cardDef(c.cardId).name).join(", ")}.`
|
||||
: null;
|
||||
case "creationDispelled": return `The ${e.what.replace(/-/g, " ")} unravels — dispelled!`;
|
||||
case "firewallCreated": return `A wall of fire roars up (${e.turns} turn${e.turns === 1 ? "" : "s"})!`;
|
||||
case "firewallExpired": return `The wall of fire gutters out.`;
|
||||
case "firewallBurned": return `${e.player} steps through the flames!`;
|
||||
case "squareFilled": return `The square fills with ${e.kind === "stone" ? "solid stone" : e.kind === "slime" ? "green slime" : e.kind.replace(/-/g, " ")}!`;
|
||||
case "waterwallCrashes": return `A wall of water crashes down!`;
|
||||
case "objectThrown": return `The ${cardDef(e.cardId).name.toLowerCase()} clatters to the floor.`;
|
||||
case "spellReused": return `${e.player}'s ${cardDef(e.card.cardId).name} leaps back to their hand!`;
|
||||
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 "washedBack": return `${e.player} is washed back ${e.blockedSpaces > 0 ? "and crushed against the stone" : "by the wave"}!`;
|
||||
case "enteredThornbush": return `${e.player} pushes into the thorns — and bleeds for it.`;
|
||||
case "objectDragged": return `The ${e.what.replace(/-/g, " ")} is dragged across the maze.`;
|
||||
case "cardsDealt": return `${e.player} is dealt ${e.count} card${e.count === 1 ? "" : "s"}.`;
|
||||
case "cardsStolenPrivate": return e.cards.length > 0
|
||||
? `Stolen into your hand: ${e.cards.map((c) => cardDef(c.cardId).name).join(", ")}.`
|
||||
: null;
|
||||
case "handRevealedPrivate": return `${e.player}'s hand lies open to you: ${e.cards.map((c) => cardDef(c.cardId).name).join(", ")}.`;
|
||||
case "creatureWarpStepped": return `The creature slips through the dimensional warp!`;
|
||||
case "mentalForceFizzled": return `${e.attacker}'s mental force strains at ${e.defender} — and fails to find a path.`;
|
||||
case "doorJammed": return `${e.player} jams a door's lock solid.`;
|
||||
case "lockRemoved": return `${e.player} removes a door's lock for good.`;
|
||||
case "cardDisplayed": return `${e.player} displays ${cardDef(e.card.cardId).name}.`;
|
||||
@@ -162,7 +187,6 @@ export function humanize(e: GameEvent): string | null {
|
||||
case "treasurePickedUp": return `${e.player} grabs ${e.owner}'s treasure!`;
|
||||
case "objectDropped": return `${e.player} sets down the ${cardDef(e.card.cardId).name}${e.forced ? " (forced)" : ""}.`;
|
||||
case "objectPickedUp": return `${e.player} picks up the ${cardDef(e.card.cardId).name} — actions over.`;
|
||||
case "wardSet": return e.armed ? "Your ward is set — the next thief bleeds." : "Your ward stands down.";
|
||||
case "chaosShielded": return `${e.player} raises a FULL SHIELD and sits out the chaos.`;
|
||||
case "tableTalk": return `\u{1F4AC} ${e.player}: ${e.text}`;
|
||||
case "dieRolled": return `\u{1F3B2} ${e.player ?? "The maze"} rolls a ${e.roll} — ${e.purpose}.`;
|
||||
@@ -188,6 +212,19 @@ export function humanize(e: GameEvent): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
/** A chronicle line: its words, the turn it belongs to (counted by the
|
||||
* same boundary events the server counts), and whether it carries the
|
||||
* turn's instant-replay eye. */
|
||||
export interface LogLine {
|
||||
text: string;
|
||||
turn: number | null;
|
||||
notable: boolean;
|
||||
}
|
||||
|
||||
/** The boundaries the turn count walks — mirrored by the server's
|
||||
* momentSteps, so a chronicle turn number addresses the same commands. */
|
||||
const TURN_BOUNDARY = new Set(["turnStarted", "extraTurnStarted", "turnSkipped"]);
|
||||
|
||||
const SEAT_KEY = "wizwar-seat";
|
||||
const SEATS_KEY = "wizwar-seats";
|
||||
const CHAT_SEEN_KEY = "wizwar-chat-seen";
|
||||
@@ -246,8 +283,12 @@ class Net {
|
||||
roomColors = $state<Record<string, number>>({});
|
||||
roomBots = $state<Record<string, string>>({});
|
||||
you = $state<string | null>(null);
|
||||
/** Seated in the Peanut Gallery: watching nameless, read-only. */
|
||||
spectating = $state(false);
|
||||
/** How many watch from the gallery (0 hides the count). */
|
||||
audience = $state(0);
|
||||
view = $state<GameView | null>(null);
|
||||
log = $state<string[]>([]);
|
||||
log = $state<LogLine[]>([]);
|
||||
error = $state<string | null>(null);
|
||||
/** Every seat this browser holds, across rooms. */
|
||||
seats = $state<Seat[]>(loadSeats());
|
||||
@@ -270,6 +311,16 @@ class Net {
|
||||
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);
|
||||
/** One turn's reel, summoned from a chronicle line's instant-replay
|
||||
* eye — steps plus the wizard whose eyes the camera wears. */
|
||||
moment = $state<{ steps: { seq: number; actor: string; events: GameEvent[]; view: GameView; chat?: { player: string; text: string }[] }[]; owner: string } | null>(null);
|
||||
/** Turns witnessed so far: counts the same boundary events the server
|
||||
* counts, so a chronicle line can name the turn it belongs to. */
|
||||
private turnCounter = -1;
|
||||
/** The turn whose moment reel is open (share links point at it). */
|
||||
private momentTurn: number | null = null;
|
||||
private shareResolve: ((url: string) => void) | null = null;
|
||||
private shareReject: ((e: Error) => void) | null = null;
|
||||
private seen: Record<string, number> = loadSeen();
|
||||
/** Room whose live stream this connection has already shown once: states
|
||||
* after the first mark themselves seen while the tab is visible. */
|
||||
@@ -291,6 +342,10 @@ class Net {
|
||||
this.status = "connected";
|
||||
// Mid-game reconnects (the socket dropped, not the page) walk straight
|
||||
// back to the table; otherwise the lobby ledger is the front door.
|
||||
if (this.spectating && this.roomId) {
|
||||
// A dropped gallery socket rejoins the gallery, not a seat.
|
||||
this.send({ type: "watch", roomId: this.roomId });
|
||||
} else {
|
||||
const saved = localStorage.getItem(SEAT_KEY);
|
||||
if (saved && this.roomId) {
|
||||
try {
|
||||
@@ -300,6 +355,7 @@ class Net {
|
||||
}
|
||||
} catch { localStorage.removeItem(SEAT_KEY); }
|
||||
}
|
||||
}
|
||||
this.refreshGames();
|
||||
};
|
||||
ws.onclose = () => {
|
||||
@@ -320,10 +376,18 @@ class Net {
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "watching":
|
||||
this.spectating = true;
|
||||
this.roomId = msg.roomId;
|
||||
break;
|
||||
case "audience":
|
||||
this.audience = msg.count ?? 0;
|
||||
break;
|
||||
case "room":
|
||||
this.roomId = msg.roomId;
|
||||
this.roomColors = msg.colors ?? {};
|
||||
this.roomBots = msg.bots ?? {};
|
||||
this.audience = msg.audience ?? 0;
|
||||
if (this.you && this.token) {
|
||||
const seat: Seat = { name: this.you, roomId: msg.roomId, token: this.token };
|
||||
localStorage.setItem(SEAT_KEY, JSON.stringify(seat));
|
||||
@@ -335,7 +399,7 @@ class Net {
|
||||
break;
|
||||
case "state": {
|
||||
this.view = msg.view;
|
||||
if (typeof msg.seq === "number" && this.roomId) {
|
||||
if (typeof msg.seq === "number" && this.roomId && !this.spectating) {
|
||||
this.currentSeq = msg.seq;
|
||||
// Only the FIRST state after arriving carries a gap worth
|
||||
// announcing. Later states were watched live: a caught-up
|
||||
@@ -356,6 +420,14 @@ class Net {
|
||||
case "catchUp":
|
||||
this.catchUp = msg.steps;
|
||||
break;
|
||||
case "moment":
|
||||
this.moment = { steps: msg.steps, owner: msg.owner };
|
||||
break;
|
||||
case "share":
|
||||
this.shareResolve?.(`${location.origin}/watch/${msg.id}`);
|
||||
this.shareResolve = null;
|
||||
this.shareReject = null;
|
||||
break;
|
||||
case "events": {
|
||||
let talk = 0;
|
||||
if (!msg.replayed) this.onFx?.(msg.events as GameEvent[]);
|
||||
@@ -364,8 +436,16 @@ class Net {
|
||||
if (e.type === "gameStarted" && !msg.replayed) {
|
||||
this.openingRolls = { rolls: e.dieRolls, first: e.firstPlayer, players: e.players };
|
||||
}
|
||||
if (TURN_BOUNDARY.has(e.type)) this.turnCounter++;
|
||||
const line = humanize(e);
|
||||
if (line) this.log = [...this.log, line];
|
||||
if (line) {
|
||||
// Every turn wears its eye, on the header line that opens
|
||||
// it — the players judge what is share-worthy. The game-
|
||||
// winning line carries its own besides.
|
||||
const notable = this.turnCounter >= 0 &&
|
||||
(e.type === "gameWon" || TURN_BOUNDARY.has(e.type));
|
||||
this.log = [...this.log, { text: line, turn: this.turnCounter >= 0 ? this.turnCounter : null, notable }];
|
||||
}
|
||||
}
|
||||
if (talk > 0) {
|
||||
this.chatCount += talk;
|
||||
@@ -373,6 +453,14 @@ class Net {
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "kicked":
|
||||
this.log = [...this.log, { text: `— the host cleared your seat in ${msg.roomId} —`, turn: null, notable: false }];
|
||||
this.leaveLocal();
|
||||
break;
|
||||
case "roomAbandoned":
|
||||
this.log = [...this.log, { text: `— the host closed room ${msg.roomId} —`, turn: null, notable: false }];
|
||||
this.leaveLocal();
|
||||
break;
|
||||
case "transferCode":
|
||||
this.transferCode = { code: msg.code, expiresAt: msg.expiresAt };
|
||||
break;
|
||||
@@ -384,7 +472,7 @@ class Net {
|
||||
break;
|
||||
}
|
||||
case "chat": {
|
||||
this.log = [...this.log, `\u{1F4AC} ${msg.player}: ${msg.text}`];
|
||||
this.log = [...this.log, { text: `\u{1F4AC} ${msg.player}: ${msg.text}`, turn: null, notable: false }];
|
||||
this.chatCount += 1;
|
||||
if (this.roomId) this.markChatSeen();
|
||||
break;
|
||||
@@ -401,16 +489,36 @@ class Net {
|
||||
if (g.yourTurn && !was) this.notifyTurn(g);
|
||||
this.lastYourTurn.set(key, g.yourTurn);
|
||||
}
|
||||
// Prune ONLY seats the server proved dead: the room exists and
|
||||
// refused this exact token. A seat merely ABSENT from the reply
|
||||
// stays — a restarting server, a stale restore, or the wrong
|
||||
// backend all answer with ignorance, and ignorance is not
|
||||
// deletion. The wallet is capped by age instead of by trust.
|
||||
const voided = new Set((msg.voided as string[] | undefined) ?? []);
|
||||
let kept = this.seats.filter((s) => !voided.has(`${s.roomId}:${s.name}`));
|
||||
if (kept.length > 50) kept = kept.slice(kept.length - 50);
|
||||
if (kept.length !== this.seats.length) {
|
||||
this.seats = kept;
|
||||
saveSeats(this.seats);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "error":
|
||||
if (this.roomIdPending && /no such room|name is taken/.test(msg.message)) {
|
||||
// "Name is taken" means THIS token was tried and refused: the
|
||||
// credential itself is dead, and holding it helps nobody. But
|
||||
// "no such room" proves nothing about the seat — a restarting
|
||||
// server, a stale restore, or the wrong backend all say it.
|
||||
// The seat stays; the error shows; a later reconnect against
|
||||
// the right server walks back in.
|
||||
if (this.roomIdPending && /name is taken/.test(msg.message)) {
|
||||
localStorage.removeItem(SEAT_KEY);
|
||||
}
|
||||
if (this.roomIdPending && /no such room|name is taken/.test(msg.message)) {
|
||||
this.roomIdPending = null;
|
||||
}
|
||||
this.error = msg.message;
|
||||
// The toast fades; the chronicle remembers why nothing happened.
|
||||
this.log = [...this.log, `— ${msg.message} —`];
|
||||
this.log = [...this.log, { text: `— ${msg.message} —`, turn: null, notable: false }];
|
||||
setTimeout(() => { if (this.error === msg.message) this.error = null; }, 5000);
|
||||
break;
|
||||
}
|
||||
@@ -423,11 +531,20 @@ class Net {
|
||||
|
||||
create(name: string): void {
|
||||
this.you = name;
|
||||
this.spectating = false;
|
||||
this.send({ type: "create", name });
|
||||
}
|
||||
|
||||
/** Take a seat in the Peanut Gallery: watch a game with no name and no voice. */
|
||||
watch(roomId: string): void {
|
||||
this.you = null;
|
||||
this.resetChronicle();
|
||||
this.send({ type: "watch", roomId: roomId.toUpperCase() });
|
||||
}
|
||||
|
||||
join(roomId: string, name: string): void {
|
||||
this.you = name;
|
||||
this.spectating = false;
|
||||
this.roomIdPending = roomId.toUpperCase();
|
||||
const existing = this.seats.find(
|
||||
(s) => s.roomId === this.roomIdPending && s.name === name,
|
||||
@@ -438,9 +555,10 @@ class Net {
|
||||
/** Sit back down at a remembered seat. */
|
||||
resume(seat: Seat): void {
|
||||
this.you = seat.name;
|
||||
this.spectating = false;
|
||||
this.token = seat.token;
|
||||
this.roomIdPending = seat.roomId;
|
||||
this.log = [];
|
||||
this.resetChronicle();
|
||||
this.send({ type: "join", roomId: seat.roomId, name: seat.name, token: seat.token });
|
||||
}
|
||||
|
||||
@@ -468,6 +586,14 @@ class Net {
|
||||
this.send({ type: "addBot", ...(style ? { style } : {}), ...(tier ? { tier } : {}) });
|
||||
}
|
||||
|
||||
kickSeat(name: string): void {
|
||||
this.send({ type: "kickSeat", name });
|
||||
}
|
||||
|
||||
abandonRoom(): void {
|
||||
this.send({ type: "abandonRoom" });
|
||||
}
|
||||
|
||||
rollTableDie(): void {
|
||||
this.send({ type: "rollDie" });
|
||||
}
|
||||
@@ -538,16 +664,26 @@ class Net {
|
||||
} catch { /* blocked at the OS level; the tab title still shows it */ }
|
||||
}
|
||||
|
||||
/** Forget the remembered seat and return to the lobby. */
|
||||
leave(): void {
|
||||
/** Client-side teardown when the SERVER detached us (kick, abandon).
|
||||
* The chronicle survives: the detachment appended its own explanatory
|
||||
* line, and wiping it would erase the only notice of why. */
|
||||
leaveLocal(): void {
|
||||
localStorage.removeItem(SEAT_KEY);
|
||||
this.roomId = null;
|
||||
this.roomIdPending = null;
|
||||
this.view = null;
|
||||
this.started = false;
|
||||
this.players = [];
|
||||
this.log = [];
|
||||
this.token = null;
|
||||
this.spectating = false;
|
||||
this.audience = 0;
|
||||
}
|
||||
|
||||
/** Walk away from the table (or gallery) by choice. */
|
||||
leave(): void {
|
||||
this.send({ type: "leave" }); // detach server-side too (frees a gallery seat)
|
||||
this.leaveLocal();
|
||||
this.resetChronicle();
|
||||
}
|
||||
|
||||
start(expansion: boolean): void {
|
||||
@@ -582,6 +718,43 @@ class Net {
|
||||
this.markSeen();
|
||||
}
|
||||
|
||||
/** The chronicle and its turn count reset together, always. */
|
||||
private resetChronicle(): void {
|
||||
this.log = [];
|
||||
this.turnCounter = -1;
|
||||
}
|
||||
|
||||
/** Summon one turn's reel by its chronicle turn number. */
|
||||
requestMoment(turn: number): void {
|
||||
this.momentTurn = turn;
|
||||
this.send({ type: "moment", turn });
|
||||
}
|
||||
|
||||
/** Mint a public link: the open moment's turn, or -1 for the whole
|
||||
* finished game. One mint in flight at a time — a newcomer supersedes
|
||||
* a stranded predecessor rather than leaving it pending forever. */
|
||||
requestShare(turn: number | null = this.momentTurn): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (turn === null) return reject(new Error("no turn open"));
|
||||
this.shareReject?.(new Error("superseded"));
|
||||
this.shareResolve = resolve;
|
||||
this.shareReject = reject;
|
||||
this.send({ type: "share", turn });
|
||||
setTimeout(() => {
|
||||
if (this.shareResolve === resolve) {
|
||||
this.shareResolve = null;
|
||||
this.shareReject = null;
|
||||
reject(new Error("share timed out"));
|
||||
}
|
||||
}, 10_000);
|
||||
});
|
||||
}
|
||||
|
||||
closeMoment(): void {
|
||||
this.moment = null;
|
||||
this.momentTurn = null;
|
||||
}
|
||||
|
||||
command(command: Command): void {
|
||||
this.send({ type: "command", command });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
// Press-and-hold to peek, mirroring the board's cell gesture. Touch has no
|
||||
// hover, so every board fixture answers a 450ms hold with its tooltip.
|
||||
|
||||
const HOLD_MS = 450;
|
||||
|
||||
/** Wire the result's press/release to onpointerdown/-up/-leave. */
|
||||
export function holdToPeek(fire: () => void) {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
return {
|
||||
press() {
|
||||
timer = setTimeout(fire, HOLD_MS);
|
||||
},
|
||||
release() {
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -14,12 +14,23 @@ export interface Prefs {
|
||||
wizardName: string;
|
||||
/** Preferred wizard color (0-5), claimed in lobbies when free. */
|
||||
color: number | null;
|
||||
/** "Are you sure?" prompts before easy-to-regret plays. */
|
||||
cautions: boolean;
|
||||
/** Default skill for automatons seated from the lobby. */
|
||||
botTier: "apprentice" | "adept" | "archmage";
|
||||
/** Instant replay: notable chronicle lines offer a first-person reel. */
|
||||
instantReplay: boolean;
|
||||
/** The live first-person pane above the board during play. */
|
||||
liveFp: boolean;
|
||||
}
|
||||
|
||||
const KEY = "wizwar-prefs";
|
||||
|
||||
function load(): Prefs {
|
||||
const fallback: Prefs = { art: "photo", autoGrab: false, flourishes: true, wizardName: "", color: null };
|
||||
const fallback: Prefs = {
|
||||
art: "photo", autoGrab: false, flourishes: true, wizardName: "", color: null,
|
||||
cautions: true, botTier: "adept", instantReplay: true, liveFp: false,
|
||||
};
|
||||
try {
|
||||
const raw = localStorage.getItem(KEY);
|
||||
if (!raw) return fallback;
|
||||
@@ -30,6 +41,10 @@ function load(): Prefs {
|
||||
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,
|
||||
cautions: p.cautions !== false,
|
||||
botTier: p.botTier === "apprentice" || p.botTier === "archmage" ? p.botTier : "adept",
|
||||
instantReplay: p.instantReplay !== false,
|
||||
liveFp: p.liveFp === true,
|
||||
};
|
||||
} catch {
|
||||
return fallback;
|
||||
|
||||
@@ -10,28 +10,28 @@ export interface RuleSection {
|
||||
|
||||
export const RULEBOOK_BASE: RuleSection[] = [
|
||||
{
|
||||
"title": "INTRODUCTION",
|
||||
"paragraphs": [
|
||||
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": [
|
||||
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": [
|
||||
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": [
|
||||
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.",
|
||||
@@ -41,8 +41,8 @@ export const RULEBOOK_BASE: RuleSection[] = [
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "THE BOARD",
|
||||
"paragraphs": [
|
||||
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.",
|
||||
@@ -53,14 +53,14 @@ export const RULEBOOK_BASE: RuleSection[] = [
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "MOVEMENT",
|
||||
"paragraphs": [
|
||||
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": [
|
||||
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.",
|
||||
@@ -71,8 +71,8 @@ export const RULEBOOK_BASE: RuleSection[] = [
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "THE CARDS",
|
||||
"paragraphs": [
|
||||
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.)*",
|
||||
@@ -90,16 +90,16 @@ export const RULEBOOK_BASE: RuleSection[] = [
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "THE TURN",
|
||||
"paragraphs": [
|
||||
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": [
|
||||
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.",
|
||||
@@ -114,8 +114,8 @@ export const RULEBOOK_BASE: RuleSection[] = [
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "CARDS AND ACTIONS THAT CHANGE THE MAP",
|
||||
"paragraphs": [
|
||||
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.",
|
||||
@@ -124,23 +124,23 @@ export const RULEBOOK_BASE: RuleSection[] = [
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "GETTING NEW CARDS",
|
||||
"paragraphs": [
|
||||
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": [
|
||||
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": [
|
||||
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.",
|
||||
"---"
|
||||
]
|
||||
@@ -149,8 +149,8 @@ export const RULEBOOK_BASE: RuleSection[] = [
|
||||
|
||||
export const RULEBOOK_EXPANSION: RuleSection[] = [
|
||||
{
|
||||
"title": "MONSTERS",
|
||||
"paragraphs": [
|
||||
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.",
|
||||
@@ -163,14 +163,14 @@ export const RULEBOOK_EXPANSION: RuleSection[] = [
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "FLYING AND LEVITATING",
|
||||
"paragraphs": [
|
||||
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": [
|
||||
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.",
|
||||
@@ -178,8 +178,8 @@ export const RULEBOOK_EXPANSION: RuleSection[] = [
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "OTHER NOTES FOR THE FIRST EXPANSION",
|
||||
"paragraphs": [
|
||||
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.",
|
||||
@@ -189,20 +189,20 @@ export const RULEBOOK_EXPANSION: RuleSection[] = [
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "TEAM PLAY OPTION (thanks to Jeff Ingalls)",
|
||||
"paragraphs": [
|
||||
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": [
|
||||
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": [
|
||||
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.",
|
||||
"---"
|
||||
]
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
/// <reference types="vite/client" />
|
||||
/// <reference types="svelte" />
|
||||
|
||||
/** Build moment, yyyy.mm.dd.hh.mm — injected by vite.config.ts. */
|
||||
declare const __BUILD_STAMP__: string;
|
||||
@@ -1,6 +1,20 @@
|
||||
import { defineConfig } from "vite";
|
||||
import { svelte } from "@sveltejs/vite-plugin-svelte";
|
||||
|
||||
// Deploys build locally moments before rsync, so build time IS deploy time.
|
||||
const now = new Date();
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
const stamp = [
|
||||
now.getFullYear(),
|
||||
pad(now.getMonth() + 1),
|
||||
pad(now.getDate()),
|
||||
pad(now.getHours()),
|
||||
pad(now.getMinutes()),
|
||||
].join(".");
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [svelte()],
|
||||
define: {
|
||||
__BUILD_STAMP__: JSON.stringify(stamp),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
# The conjuration sprites — a spec for the artist
|
||||
|
||||
The first-person view (`/?fpv`, and the replay's "your eyes" mode) throws
|
||||
spell moments through the air as **billboard sprites**: flat images that
|
||||
always face the camera, drawn in mid-air, occluded by walls, and scaled
|
||||
by distance. The sprites in `FX_ART` (fpv/fx3d.ts) cover every spell
|
||||
moment in the game. Each lives at `packages/web/public/fx3d/<name>.png`
|
||||
— replace the file, refresh, done. Missing files fall back to built-in
|
||||
procedural glows; the whole set is shown side by side at `/?tokens`
|
||||
under "The conjurations."
|
||||
|
||||
## Technical requirements
|
||||
|
||||
- **Format:** PNG with a transparent background. The transparent area is
|
||||
essential — whatever is opaque is what flies.
|
||||
- **Size:** square, any resolution; 128×128 or 256×256 recommended. The
|
||||
renderer scales freely and pixelates on magnification (the whole view
|
||||
is deliberately chunky, like the wall textures).
|
||||
- **Compositing:** painted sprites draw normally — inks stay true, dark
|
||||
outlines hold. (Only `warpglow` in terrain3d is drawn additively, as
|
||||
light.) Impacts fade out by alpha as they swell.
|
||||
- **No animation frames.** Each sprite is a single still. Motion comes
|
||||
from the renderer: projectiles streak across the room, impacts swell
|
||||
from small to large while fading out. Radially symmetric (or nearly so)
|
||||
designs work best since the image does not rotate.
|
||||
- **Fills most of the canvas.** Leave only a small transparent margin;
|
||||
the renderer sizes the sprite by its canvas, not its painted extent.
|
||||
|
||||
## The nine sprites and where they appear
|
||||
|
||||
| file | flies as | moment |
|
||||
| --- | --- | --- |
|
||||
| `fireball.png` | projectile | Fireball, Sudden Death, Blaster Wand — a ball of fire streaking caster → target |
|
||||
| `bolt.png` | projectile | Lightning Blast, Power Drain — a crackling bolt |
|
||||
| `waterbolt.png` | projectile | Waterbolt — a hurled gout of water |
|
||||
| `spark.png` | projectile | anything else that travels: thrown objects, bodies knocked back, dragged things — a neutral streak of force |
|
||||
| `burst.png` | impact | a fireball landing; victory fireworks — swells and fades |
|
||||
| `splash.png` | impact | waterbolt landing, stone turned to water |
|
||||
| `hit.png` | impact | damage landing on someone you can see; punches, claws |
|
||||
| `shimmer.png` | impact | teleports, warps, illusions, minds touched — the general "magic happened here" glimmer, violet by tradition |
|
||||
| `shield.png` | impact | an attack fully stopped; absorbed spells |
|
||||
| `rubble.png` | prop | a destroyed wall's remains: bursts on the kill, then stands stretched across the fallen edge's whole gap for the rest of the reel — piles painted at the canvas's lower corners land at the two posts |
|
||||
|
||||
Projectiles hold their size in flight (about a third of a wizard's
|
||||
height). Impacts start small and swell past triple while fading — so an
|
||||
impact painted as a ring or burst reads especially well.
|
||||
|
||||
What the sprites do NOT need to carry: screen-wide flashes when YOU are
|
||||
hit (red), healed (green), or teleported (violet), the camera shake, the
|
||||
door slide (doors animate by sliding `door.png` along its own edge — no
|
||||
separate art), and the jammed-lock seethe (a pulsing rust glow the
|
||||
renderer lays over the door) — those are renderer effects, not images.
|
||||
|
||||
## The terrain, in two families
|
||||
|
||||
**Floor decals** — `public/textures/pit.png`, `slime.png`, `tacks.png`,
|
||||
`dimwarp.png` — are painted INTO the flagstones by the floor caster,
|
||||
alpha-blended texel by texel like the home tile: transparent wherever
|
||||
plain floor should show. The pit can carry its own painted label if
|
||||
wanted. Square, any size.
|
||||
|
||||
**Standing volumes** — `public/terrain3d/thornbush.png`, `rosebush.png`,
|
||||
`jello.png`, `dust.png`, `warpglow.png` — are billboards in the room.
|
||||
The hedges render CELL-WIDE at half wall height (paint them twice as
|
||||
wide as tall; a wizard in the square peeks over the top). The jello
|
||||
cube fills the whole square and is drawn ~60% opaque — paint it solid,
|
||||
the renderer supplies the translucency. Dust likewise. `warpglow` is a
|
||||
pillar of light drawn ADDITIVELY (like the old glow sprites): bright
|
||||
means bright, black vanishes. Rehearse any of them in place:
|
||||
`/?fpv&terrain=thornbush@3,7;jello@4,7;dimwarp@2,7`.
|
||||
|
||||
## Walls' dressing and damage
|
||||
|
||||
`public/textures/doorframe.png` dresses OPEN doorways: paint an empty
|
||||
stone doorway on transparency — posts in the outer ~8% columns, the
|
||||
lintel in the top ~16% rows, nothing in the middle (the way through).
|
||||
`public/textures/cracks.png` is battle damage: dark fractures on
|
||||
transparency, laid over any damaged wall with opacity rising as the
|
||||
harm mounts. `public/textures/underbrush.png` is the ground beneath a
|
||||
hedge — leaf litter to the square's edges, a floor decal like the pit.
|
||||
`public/terrain3d/safe.png` is the wall safe, standing as an opaque
|
||||
strongbox (square, transparent ground).
|
||||
|
||||
## One more paintable surface: the home tile
|
||||
|
||||
`public/textures/home.png` is a floor overlay for home-base cells,
|
||||
drawn over the flagstones and TINTED by the owning wizard's color at
|
||||
draw time. Paint it near-white/neutral with transparency wherever the
|
||||
plain floor should show through; the tint does the rest. Square, any
|
||||
size, same as the other textures.
|
||||
@@ -0,0 +1,175 @@
|
||||
// Claude's seat at the table: a one-shot websocket client. Each run
|
||||
// connects, resumes the seat by token, performs one verb, prints, exits.
|
||||
// node claude-seat.mjs join <ROOM> <NAME>
|
||||
// node claude-seat.mjs view
|
||||
// node claude-seat.mjs do '<command json>'
|
||||
// node claude-seat.mjs chat "text"
|
||||
import WebSocket from "ws";
|
||||
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
||||
|
||||
// Seat state (room, name, token) persists between one-shot invocations.
|
||||
const SEAT_FILE = process.env.WIZWAR_SEAT ?? "/tmp/wizwar-claude-seat.json";
|
||||
const URL_WS = process.env.WIZWAR_SERVER ?? "wss://wizwar.kestrelsnest.social/ws";
|
||||
const [verb, ...args] = process.argv.slice(2);
|
||||
const seat = existsSync(SEAT_FILE) ? JSON.parse(readFileSync(SEAT_FILE, "utf8")) : null;
|
||||
|
||||
const ws = new WebSocket(URL_WS);
|
||||
const send = (m) => ws.send(JSON.stringify(m));
|
||||
const die = (msg, code = 1) => { console.log(msg); process.exit(code); };
|
||||
setTimeout(() => die("timeout: no answer from the maze"), 20000);
|
||||
|
||||
let view = null;
|
||||
let pendingEvents = [];
|
||||
let historyEvents = [];
|
||||
|
||||
function cellK(c) { return `${c.x},${c.y}`; }
|
||||
|
||||
function renderView(v) {
|
||||
const B = v.board;
|
||||
const lines = [];
|
||||
const label = new Map(); // cellKey -> 2-char code
|
||||
const codes = {};
|
||||
v.players.forEach((p, i) => {
|
||||
codes[p.id] = String(i + 1);
|
||||
label.set(cellK(p.position), (p.id === v.you ? "C" : "P") + (i + 1));
|
||||
});
|
||||
const treas = new Map();
|
||||
for (const t of v.treasures) if (t.position && !t.carriedBy) treas.set(cellK(t.position), t);
|
||||
// top border row by row
|
||||
for (let y = 0; y < B.height; y++) {
|
||||
let top = "";
|
||||
let mid = "";
|
||||
for (let x = 0; x < B.width; x++) {
|
||||
const k = `${x},${y}`;
|
||||
if (!B.cells[k]) { top += " "; mid += " "; continue; }
|
||||
const nEdge = B.edges[`H:${x},${y - 1}`] ?? "open";
|
||||
const north = y === 0 ? "wall" : nEdge; // rim renders solid; warps noted separately
|
||||
top += "+" + (north === "wall" ? "————" : north === "door" ? "—DD—" : north === "firewall" ? "~FF~" : " ");
|
||||
const wEdge = x === 0 ? "wall" : (B.edges[`V:${x - 1},${y}`] ?? "open");
|
||||
const wc = wEdge === "wall" ? "|" : wEdge === "door" ? "D" : wEdge === "firewall" ? "F" : " ";
|
||||
let body = label.get(k) ?? "";
|
||||
if (!body) {
|
||||
const sq = v.squareContents[k];
|
||||
if (sq) body = sq.kind.slice(0, 2).toUpperCase();
|
||||
else if (treas.has(k)) body = "$" + (treas.get(k).owner === v.you ? "c" : codes[treas.get(k).owner] ?? "?");
|
||||
else if (B.homes.some((h) => cellK(h) === k)) {
|
||||
const who = v.players.find((p) => cellK(p.home) === k);
|
||||
body = "h" + (who ? (who.id === v.you ? "C" : codes[who.id]) : "?");
|
||||
} else if ((v.groundObjects[k] ?? []).length) body = "ob";
|
||||
else if (v.creatures.some((c) => cellK(c.position) === k)) {
|
||||
body = v.creatures.find((c) => cellK(c.position) === k).kind.slice(0, 2);
|
||||
} else body = " ";
|
||||
}
|
||||
mid += wc + " " + body.padEnd(2, " ") + " ";
|
||||
}
|
||||
lines.push(top + "+");
|
||||
lines.push(mid + "|");
|
||||
}
|
||||
let bottom = "";
|
||||
for (let x = 0; x < B.width; x++) bottom += "+————";
|
||||
lines.push(bottom + "+");
|
||||
const out = [];
|
||||
out.push(`ROOM ${seat?.roomId} — you are ${v.you} (round ${v.turn.round}, ${v.activePlayerId}'s turn)`);
|
||||
out.push(lines.join("\n"));
|
||||
out.push("WARPS (rim passages): " + B.warps.map((w) => `${cellK(w.from.cell)}${w.from.side}→${cellK(w.to.cell)}`).join(" "));
|
||||
if (v.dimWarps.length) {
|
||||
out.push("!! DIMENSIONAL WARP TOKENS (player wormholes — CHECK EVERY TURN): " +
|
||||
v.dimWarps.map((w) => `${cellK(w.a)}↔${cellK(w.b)}`).join(" "));
|
||||
}
|
||||
for (const p of v.players) {
|
||||
const t = p.carriedTreasureId ? " CARRYING TREASURE" : "";
|
||||
out.push(` ${p.id === v.you ? "ME " : " "}${p.id}: ${p.life} life @${cellK(p.position)} home@${cellK(p.home)} hand:${p.handCount}${t}${p.alive ? "" : " DEAD"} displayed:[${p.displayed.map((c) => c.cardId).join(",")}]`);
|
||||
}
|
||||
out.push("TREASURES: " + v.treasures.map((t) => `${t.id}(${t.owner})${t.carriedBy ? `held by ${t.carriedBy}` : t.position ? `@${cellK(t.position)}` : "?"}`).join(" "));
|
||||
if (v.sustained.length) out.push("SUSTAINED: " + v.sustained.map((s) => `${s.cardId} on ${s.targetId} (${s.remainingTurns})`).join(" "));
|
||||
const sq = Object.entries(v.squareContents);
|
||||
if (sq.length) out.push("CONTENTS: " + sq.map(([k, c]) => `${c.kind}@${k}`).join(" "));
|
||||
if (v.creatures.length) out.push("CREATURES: " + v.creatures.map((c) => `${c.kind}@${cellK(c.position)} (${c.controllerId}, ${c.life} life)`).join(" "));
|
||||
out.push(`TURN: moves ${v.turn.movementUsed}/${v.turn.movementAllowance} attackUsed:${v.turn.attackUsed} actionsEnded:${v.turn.actionsEnded} numberPlayed:${v.turn.numberPlayedForMovement}`);
|
||||
out.push("MY HAND: " + v.yourHand.map((c) => `${c.instanceId}`).join(" "));
|
||||
if (v.stack) out.push("STACK! " + JSON.stringify(v.stack));
|
||||
if (v.wardPending) out.push("WARD PENDING: " + JSON.stringify(v.wardPending));
|
||||
if (v.chaosPending) out.push("CHAOS PENDING: " + JSON.stringify(v.chaosPending));
|
||||
if (v.outOfTurnWindow) out.push("OUT-OF-TURN WINDOW: " + JSON.stringify(v.outOfTurnWindow));
|
||||
if (v.phase === "finished") out.push(`GAME OVER — winner: ${v.winner} (${v.winReason})`);
|
||||
return out.join("\n");
|
||||
}
|
||||
|
||||
function eventLine(e) {
|
||||
const skip = new Set(["cardsDealtPrivate"]);
|
||||
if (skip.has(e.type)) return null;
|
||||
return JSON.stringify(e);
|
||||
}
|
||||
|
||||
ws.on("message", (raw) => {
|
||||
const msg = JSON.parse(raw.toString());
|
||||
if (msg.type === "error") die(`SERVER: ${msg.message}`);
|
||||
if (msg.type === "seat") {
|
||||
// A join names its room explicitly; only a resume inherits the file's.
|
||||
const s = { roomId: verb === "join" ? args[0].toUpperCase() : seat.roomId, name: msg.playerId, token: msg.token };
|
||||
writeFileSync(SEAT_FILE, JSON.stringify(s));
|
||||
if (verb === "join") { console.log(`seated as ${msg.playerId} in ${s.roomId}`); afterJoin(); }
|
||||
}
|
||||
if (msg.type === "events") {
|
||||
// A join replays the whole chronicle (replayed: true); only live
|
||||
// events are news. `view` prints the tail of history instead.
|
||||
if (!msg.replayed) {
|
||||
for (const e of msg.events) { const l = eventLine(e); if (l) pendingEvents.push(l); }
|
||||
} else if (verb === "view") {
|
||||
for (const e of msg.events) { const l = eventLine(e); if (l) historyEvents.push(l); }
|
||||
}
|
||||
}
|
||||
if (msg.type === "state") { view = msg.view; finishAfterState(); }
|
||||
if (msg.type === "catchUp") {
|
||||
const steps = msg.steps;
|
||||
if (steps.length) view = steps[steps.length - 1].view;
|
||||
finish();
|
||||
}
|
||||
if (msg.type === "chat") pendingEvents.push(`CHAT ${msg.player}: ${msg.text}`);
|
||||
if (msg.type === "room") { /* roster updates, ignore */ }
|
||||
});
|
||||
|
||||
let done = false;
|
||||
function finish() {
|
||||
if (done) return; done = true;
|
||||
if (verb === "view" && historyEvents.length) {
|
||||
console.log("RECENT EVENTS (history tail):\n" + historyEvents.slice(-12).join("\n") + "\n");
|
||||
}
|
||||
if (pendingEvents.length) console.log("NEW EVENTS:\n" + pendingEvents.join("\n") + "\n");
|
||||
if (view) console.log(renderView(view));
|
||||
else console.log("(no game running yet — waiting for the start)");
|
||||
process.exit(0);
|
||||
}
|
||||
let stateTimer = null;
|
||||
function finishAfterState() {
|
||||
// commands produce one state per player broadcast; give trailing events a beat
|
||||
if (stateTimer) clearTimeout(stateTimer);
|
||||
stateTimer = setTimeout(finish, 700);
|
||||
}
|
||||
function afterJoin() {
|
||||
send({ type: "catchUp", sinceSeq: 0 });
|
||||
setTimeout(() => { if (!done) finish(); }, 6000);
|
||||
}
|
||||
|
||||
ws.on("open", () => {
|
||||
if (verb === "join") {
|
||||
if (!args[0] || !args[1]) die("usage: join <ROOM> <NAME>");
|
||||
send({ type: "join", roomId: args[0].toUpperCase(), name: args[1], token: null });
|
||||
} else if (!seat) {
|
||||
die("no seat yet — join first");
|
||||
} else {
|
||||
send({ type: "join", roomId: seat.roomId, name: seat.name, token: seat.token });
|
||||
setTimeout(() => {
|
||||
if (verb === "view") {
|
||||
send({ type: "catchUp", sinceSeq: 0 });
|
||||
} else if (verb === "do") {
|
||||
send({ type: "command", command: JSON.parse(args[0]) });
|
||||
setTimeout(() => { if (!done) finish(); }, 8000);
|
||||
} else if (verb === "chat") {
|
||||
send({ type: "chat", text: args[0] });
|
||||
setTimeout(() => { console.log("said."); process.exit(0); }, 800);
|
||||
} else die(`unknown verb: ${verb}`);
|
||||
}, 600);
|
||||
}
|
||||
});
|
||||
ws.on("error", (e) => die(`socket error: ${e.message}`));
|
||||