7 Commits
Author SHA1 Message Date
Eric WagonerandClaude Fable 5.1 6e49fc9020 Credibility pass: comments say what the code can't, and the seams are gone
Engine: pit-rim exits live in one helper (pitRimExits) shared by the
resolver and the automaton; chargeReach names the six-stride cap; dust
sight is inDust + dustAtEnd; PushPending is exported once; the force-field
counter builds its events in one list; drawUnderSlowDeath says what it
draws under. Test groups are named for the rule they pin, not the
revision that introduced it.

Client: one smoothstep; one edgeScar per battle-scarred edge; one
board-zone rule (which also seats the caption strip on phones); the
compass names live in SIDE_NAMES; net.flash() is the one toast; MediaQuery
replaces two hand-rolled matchMedia listeners; sprites carry their sizes
as plain numbers and their CSS in one rule each; canvas helpers are
formatted like the rest of the tree. Comments that told how a cel or a
fallback used to look now say what it draws. The actions-over notice no
longer blames a pickup when slime ended the turn.

Server and ops: dataRoot() is the one data directory; headlineOf builds
its deeds without a cast; the rollup and skills read the same way the
code behaves.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-16 00:21:34 -04:00
Eric WagonerandClaude Fable 5.1 85690f0e23 Rev 21: LIFESAVER saves, and FORCE FIELD stands for the turn
An audit of every card in the deck, prompted by the Alter Ego, found
two more whose cast worked but whose promise was never kept.
LIFESAVER — "immune to the effects of losing both of your treasures"
— was cast and remembered and never asked where a wizard is
eliminated for exactly that; twenty-three recorded casts never
mattered. FORCE FIELD stopped the spell and vanished, though the card
keeps it standing until the opponent's turn ends, barring them from
entering its caster's square or casting on or past them. Both are
honored from rev 21, the field on every side where the card says one.
Older games replay as they were played. Tests pin both readings.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-15 23:57:38 -04:00
Eric WagonerandClaude Fable 5.1 10d5c5815d The Alter Ego casts from its own square; the first-stride cue leaves the maze
Randy raised his double, selected it, and could cast nothing through
it: the card's whole point — "it may use any of the spells in your
hand, and you need not be in its L.O.S." — had never been wired. A
cast may now name the double, and the double's square becomes the
spell's origin: sight, reach, the launch point the reel films, and
the direction a knockback or rout pushes. The hand, the attack, and
every answer to it stay the caster's. Spells that move the caster's
own body — a goat's charge, a swap — do not travel through it. On the
board, the double's sight lights the eligible squares; selecting the
double and then a card aims from where it stands. A test hides the
caster and fires through the double.

The first-stride caption sat on the maze and hid the walls beside the
wizard; it now stands above the board with a color chip for "you."

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-15 23:38:58 -04:00
Eric WagonerandClaude Fable 5.1 5f58a0955a A notice taller than the screen scrolls inside its frame
Preferences on a phone ran off the bottom with no way to reach the
rest. Every notice now caps at the window's height and scrolls within.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-15 23:29:18 -04:00
Eric WagonerandClaude Fable 5.1 ff35e8ef8e The table fits the window: the hand stays in reach, the maze takes the height
On a desktop the game grid is now exactly the window's height. The
board and the paper rail share the top row, the rail scrolling in
place, and the hand with its End turn sit pinned beneath — nothing a
first turn needs is ever below the fold. The board's window fills
whatever height the row allows instead of a fixed subtraction. A
wide, short window — a laptop in landscape — stands the hand beside
the board on its own until the player chooses a side, since a tall
maze under a bottom hand left a postage stamp. A short window shrinks
the bottom hand's cards a little. The eyes toggle floats over the
board's corner so it costs no height, and the masthead's "invite
friends" hides on phones where it wrapped.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-15 23:19:59 -04:00
Eric WagonerandClaude Fable 5.1 4cc0423846 The first minute, for a stranger: what the game is, what to do, and how to join
The landing says the pitch before it asks for a name — steal treasure,
blast through walls, outwit your friends — and states the goal. The
coach says "select a card, then choose its target," which is what the
cards actually want. The turn banner counts treasures home beside the
moves left. The council speaks once when its three voices agree. A
friend arriving on an invite link meets "Join Eric's game," a name
field, and a seat, with the gallery an afterthought; the masthead
says "invite friends" beside the room code. The first-person view has
a toggle beside the board instead of a checkbox in preferences. How
to play opens with winning. And a shared moment's card is titled by
the turn's story — the treasure carried home, the blow that landed,
the spell cast — instead of whose turn it was.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-15 23:03:17 -04:00
Eric WagonerandClaude Fable 5.1 a071494f4a Reduced motion is honored everywhere the table moves
One shared motion policy: the OS's reduce-motion setting, read live
through Svelte's own media query, folds into the flourishes switch.
Under it the board plays no flourishes, the reel skips its die
interlude and slate wipes, bodies jump between squares instead of
gliding, the canvas holds its ambient shimmers still, and the camera
cuts wherever the director would have panned — in the cockpit as in
the reel, so the eye still lands on what the director chose to watch.
Timers and animation frames are cleared when the policy flips or the
pane closes. A first turn and two reels stepped clean under the
setting with no errors.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-15 22:42:08 -04:00
43 changed files with 795 additions and 428 deletions
+12 -15
View File
@@ -34,26 +34,23 @@ quiet, say so in one line before the details.
written at 00:10 by /usr/local/bin/wizwar-rollup.sh; counts only, no
addresses): the last 7 rows give requests, human vs bot addresses,
socket connects, path mix (/, /clips, /watch, /ws), external
referrers (reddit.com, boardgamegeek.com after the 2026-09-03
announcement), new rooms, human seats and names, reports/replies,
referrers, new rooms, human seats and names, reports/replies,
and vitals (memMB, memPeakMB, diskPct, load1, protocolErrors,
serviceStarts, ledgerMB). Report day-over-day direction, not raw
rows. Its Sentry cron monitor is slug `wizwar-rollup` (a missed
check-in there means the rollup itself is broken). Re-roll a day by
hand: `wizwar-rollup.sh YYYY-MM-DD`. `campaigns` counts visitors
(one per address per day) whose link carried `?ref=<name>` — the tags
on Eric's posts (reddit, bgg, …) — or a Facebook fbclid; referrers
(one per address per day) whose link carried a tag the rollup header
names (`?ref=<name>`, a utm_source, Facebook's fbclid); referrers
alone can't attribute Reddit or BGG, which send none.
**Right now** (on request, or when the announcement is fresh): live
sockets `ss -tn state established '( sport = :8787 )' | tail -n +2 | wc -l`,
rooms touched in the last hour `find /var/lib/wizwar/rooms -name '*.jsonl' -mmin -60 | wc -l`,
and today so far `wizwar-rollup.sh $(date -u +%F)` (a partial row,
replaced again at 00:10; the vitals in it are the moment's).
**Right now** (on request): `wizwar-visitors.sh` prints it — live
sockets, rooms touched in the last hour, and today's partial row —
see /wizwar-visitors.
5. **The table itself**:
- Room count and growth: `ls /var/lib/wizwar/rooms/*.jsonl | wc -l`
- New human names this week: room ledgers' join lines minus bots
(Automaton*) and known names, on rooms created in the last 7 days
(meta line `createdAt`)
- New human names this week: the rollup rows' `humanNames` (host and
joiners alike, bots excluded) minus the regulars the visitors skill
lists
- Unanswered feedback count from /var/lib/wizwar/feedback.jsonl —
if any, offer to run the reports desk (`/wizwar-reports`)
- Access-log pulse: `wc -l /var/lib/caddy/access.log` and a count of
@@ -71,9 +68,9 @@ action, or "nothing needs you."
- The service restarts cleanly (ledgers replay); restarts in the journal
during deploys are normal, not incidents.
- Room memory is evicted after idle (finished 30 min, others 24 h);
boot-restores load everything then shed, so post-deploy memory spikes
settle within the hour.
- Room memory is evicted after idle (finished 30 min, others 24 h), each
sleeper leaving a stub on disk; a boot replays only rooms whose stub is
stale and leaves the rest asleep, so a deploy costs no memory spike.
- Unattended-upgrades reboots the box at 09:30 UTC when a kernel patch
requires it; a reboot there is maintenance, not an outage.
- Caddy access logs live at /var/lib/caddy/access.log (self-rotating,
+4 -3
View File
@@ -19,8 +19,9 @@ first, so the traffic line is current to the minute. Its output:
"right now" line. Two sockets at a quiet hour is Eric plus one.
- **traffic** — today's row of `/var/lib/wizwar/rollup.jsonl`:
addresses of people (bots split out), front-page vs gallery vs
replay views, campaigns (`?ref=` tags on posted links, Facebook's
fbclid) and referrers. Reddit and BGG send no referrer.
replay views, campaigns (the tags a link carries: `?ref=`, a
utm_source, Facebook's fbclid) and referrers. Reddit and BGG send no
referrer.
- **players today / NEW today** — names seated in rooms created today;
NEW means the name's first room ever is today. Known regulars:
Kestrel, Reuben, Jason, Yohon, and "Claude" (the duel seat).
@@ -37,7 +38,7 @@ Lead with new names and whether anyone is at the table now. Then, for
each stranger, say how far they got and where they stopped — the last
human move and the span tell it: a room that is "lobby only" stopped
before the start button; a game of under five minutes ending on
pickUpTreasure is the treasure wall the slips now explain; a long span
pickUpTreasure is the treasure wall (the in-game slips explain it); a long span
with many moves is a real session, resumable from their lobby. Quote
human chat verbatim. Then the traffic line as a single sentence with
the day-over-day direction if a previous row exists.
+1
View File
@@ -1,2 +1,3 @@
# Nightly rollup of yesterday's traffic, table, and vitals (UTC). Installed by deploy.sh.
# The time is also SCHEDULE in wizwar-rollup.sh, which tells Sentry to expect it; change both.
10 0 * * * root /usr/local/bin/wizwar-rollup.sh
+16 -10
View File
@@ -4,22 +4,26 @@
# the room ledgers, stats.json and feedback.jsonl), and the box's vitals.
# Counts only: no addresses are kept. The pulse reads it for trends the
# self-rotating access log cannot keep. Referrers are what browsers send
# (Reddit and BGG send none); campaigns are the ?ref=<name> tags on links
# Eric posts, one count per address per day, plus Facebook's fbclid.
# Cron: 10 0 * * * (UTC), see /etc/cron.d/wizwar-rollup (deploy.sh installs both).
# (Reddit and BGG send none); campaigns are the tags a link itself carries
# — ?ref=<name> on a link Eric posts, a utm_source, or Facebook's fbclid
# one count per address per day.
# Cron: see SCHEDULE below and /etc/cron.d/wizwar-rollup (deploy.sh installs both).
# Sentry Crons check-in: /root/.wizwar-sentry-cron-rollup holds the URL
# (absent = no check-ins).
set -u
OUT="/var/lib/wizwar/rollup.jsonl"
LOG="/var/log/wizwar/rollup.log"
DAY="${1:-$(date -u -d 'yesterday' +%Y-%m-%d)}"
# The crontab line, as /etc/cron.d/wizwar-rollup spells it: the Sentry
# monitor is told the same shape, so a missed night is noticed.
SCHEDULE="10 0 * * *"
CRON_URL=$(cat /root/.wizwar-sentry-cron-rollup 2>/dev/null || true)
# The check-in carries the schedule: Sentry only keeps a monitor it has
# been told the shape of, and a bare ping to an unknown slug is accepted
# and dropped without a word.
checkin() {
[ -n "$CRON_URL" ] && curl -sf -o /dev/null -X POST -H "Content-Type: application/json" \
-d "{\"status\":\"$1\",\"monitor_config\":{\"schedule\":{\"type\":\"crontab\",\"value\":\"10 0 * * *\"},\"checkin_margin\":30,\"max_runtime\":10,\"timezone\":\"UTC\"}}" \
-d "{\"status\":\"$1\",\"monitor_config\":{\"schedule\":{\"type\":\"crontab\",\"value\":\"$SCHEDULE\"},\"checkin_margin\":30,\"max_runtime\":10,\"timezone\":\"UTC\"}}" \
"$CRON_URL" || true
}
@@ -31,7 +35,9 @@ d0 = datetime.datetime.fromisoformat(day).replace(tzinfo=datetime.timezone.utc)
t0, t1 = d0.timestamp(), (d0 + datetime.timedelta(days=1)).timestamp()
BOT = ("bot", "crawl", "spider", "facebookexternalhit", "slack", "discord", "twitter", "preview", "curl", "python", "go-http", "wget", "headless")
# --- traffic: every access log file touched since the day began ---
# --- traffic: every access log file touched since the day began. Address
# and request counts split people from bots; the path mix and referrers
# count people only. ---
req = 0; human = 0; bot = 0; hips = set(); bips = set()
paths = collections.Counter(); status = collections.Counter(); refs = collections.Counter(); ws = 0
# Where a visit came from, when the link itself says so: ?ref=<name> on a
@@ -63,15 +69,15 @@ for f in sorted(glob.glob("/var/lib/caddy/access*.log*")):
key = ("/clips" if uri.startswith("/clips") else "/watch" if uri.startswith("/watch")
else "(assets)" if uri.startswith("/assets") or re.search(r"\.(png|jpg|svg|ico|css|js|webmanifest|mp4)$", uri)
else uri if uri in ("/", "/ws", "/robots.txt") else "(other)")
if not isbot: paths[key] += 1 # the path mix is what people looked at
if not isbot: paths[key] += 1
status[str(r.get("status", 0))[0] + "xx"] += 1
ref = " ".join(h.get("Referer", [""]))
m = re.match(r"https?://([^/]+)", ref)
host = m.group(1) if m else ""
ours = host.endswith("kestrelsnest.social") or "104.236.96.198" in host
# A referrer on /.env is a scanner's costume (a forged google.com),
# not a reader — and the app answers unknown paths with the page,
# so the status cannot tell them apart. Only the pages count.
# Scanners forge referrers on paths like /.env, and the app answers
# unknown paths with the page itself, so the status cannot tell a
# probe from a reader. Only the pages count.
if host and not ours and not isbot and key in ("/", "/clips", "/watch"): refs[host] += 1
# --- the table ---
@@ -107,7 +113,7 @@ except OSError: pass
# --- vitals (now, at rollup time) ---
def sh(cmd):
try: return subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=20).stdout.strip()
except Exception: return ""
except Exception: return "?"
mem = sh("systemctl show wizwar -p MemoryCurrent --value"); peak = sh("systemctl show wizwar -p MemoryPeak --value")
disk = sh("df --output=pcent / | tail -1").strip().rstrip("%")
load = sh("cut -d' ' -f1 /proc/loadavg")
+1 -1
View File
@@ -7,7 +7,7 @@ set -u
DAY="${1:-$(date -u +%F)}"
/usr/local/bin/wizwar-rollup.sh "$DAY" >/dev/null 2>&1
python3 - "$DAY" <<'PY'
import json, sys, glob, os, datetime, subprocess
import json, sys, glob, datetime, subprocess
day = sys.argv[1]
now = datetime.datetime.now(datetime.timezone.utc)
print("as of %s UTC, day %s" % (now.strftime("%H:%M"), day))
+13 -25
View File
@@ -7,7 +7,7 @@
// BERSERKER for blood, the WORRIER for the shadows between the two.
import { cardDef, type CardInstance } from "./cards";
import { cellKey, edgeKey, neighbor, opposite, stepTarget, SIDES, type Cell, type Side } from "./board";
import { cellKey, edgeKey, neighbor, opposite, stepTarget, SIDES, type Cell, type Side, pitRimExits } from "./board";
import { bentSightFor, sightedCellsFor, type GameView } from "./view";
import { wallIgnoringDistance } from "./game";
import type { AmbushTrigger, Command, PlayerId } from "./game";
@@ -584,31 +584,19 @@ function wallBlastTarget(
return { cell: best.cell, side: best.side };
}
/** The sides a walker may leave a pit by, having entered it heading
* `dir`: neighbouring squares first, a warp mouth on the rim only when
* no square offers — the one reading legal under every deckRev. */
function pitExits(view: GameView, pit: Cell, dir: Side): Side[] {
const { floor, mouths } = pitRimExits(view.board, pit, dir, (c) => view.squareContents[cellKey(c)]?.kind === "stone");
return floor.length > 0 ? floor : mouths;
}
/**
* BFS over walkable steps toward the nearest goal. Doors count as passable
* when the clockwork can unlock them; the first such door is reported so the
* key gets used before the boot.
*/
/** Can a wizard entering the pit at `pit` heading `dir` get off its rim
* — the maze's own test: some side other than the way back with a cell
* there, no wall between, and no stone on it. */
/** The sides a walker may leave a pit by, having entered it heading
* `dir`: neighbouring squares first; a warp mouth on the rim only when
* no square offers, the reading every revision accepts. */
function pitExits(view: GameView, pit: Cell, dir: Side): Side[] {
const footing = (d: Side) => {
if (d === opposite(dir)) return null;
const t = stepTarget(view.board, pit, d);
if (t.kind === "blocked" || view.squareContents[cellKey(t.to)]?.kind === "stone") return null;
return t.kind;
};
const floor = SIDES.filter((d) => footing(d) === "step");
return floor.length > 0 ? floor : SIDES.filter((d) => footing(d) === "warp");
}
function pitLandable(view: GameView, pit: Cell, dir: Side): boolean {
return pitExits(view, pit, dir).length > 0;
}
export function pathToward(
view: GameView,
@@ -634,12 +622,12 @@ export function pathToward(
const { to, viaDoor } = step;
const k = cellKey(to);
if (seen.has(k)) continue;
if (view.squareContents[k]?.kind === "stone") continue;
const hazard = view.squareContents[k]?.kind;
if (hazard === "stone") continue;
// A pit is entered by leaping to the square beyond it in the same
// direction; with nothing to land on, the maze bounces the leaper
// back and charges the stride. Goal or waypoint, that is no road.
if (hazard === "pit" && !pitLandable(view, to, dir)) continue;
if (hazard === "pit" && pitExits(view, to, dir).length === 0) continue;
// Out of a pit only by one of its exits.
if (view.squareContents[cellKey(c)]?.kind === "pit") {
const entry = cameBy.get(cellKey(c))?.dir;
@@ -766,7 +754,7 @@ function overLimit(view: GameView): number {
* clogged hand never refreshes; only cards the brain has no play for are
* shed (good counters and attacks are hoarded, as a human would). */
function endTurnDrawing(view: GameView, tier: TierTraits, style?: AutomatonStyle): Command {
const draw = drawUnderCurses(view, tier.draw);
const draw = drawUnderSlowDeath(view, tier.draw);
const deficit = draw - (handLimitOf(view) - view.yourHand.length);
if (tier.sheds && deficit > 0) {
const shed = [...view.yourHand]
@@ -782,7 +770,7 @@ function endTurnDrawing(view: GameView, tier: TierTraits, style?: AutomatonStyle
/** SLOW DEATH bleeds a point per card drawn: the clockwork draws only what
* it can afford, keeping four life in hand, and nothing at all when it
* cannot — a bare hand beats a bare grave. */
export function drawUnderCurses(view: GameView, draw: number): number {
export function drawUnderSlowDeath(view: GameView, draw: number): number {
const slow = view.sustained.some((e) => e.cardId === "slow-death" && e.targetId === view.you && !e.data?.reversed);
if (!slow) return draw;
return Math.max(0, Math.min(draw, me(view).life - 4));
+18
View File
@@ -253,6 +253,24 @@ export function stepTarget(
return { kind: "blocked", by: "wall" };
}
/** The sides a walker may leave a pit by, having entered it heading
* `entry`: the neighbouring squares that offer footing, and the warp
* mouths on the rim, the way back excluded. `filled` names the squares
* nothing can land on. */
export function pitRimExits(
board: AssembledBoard, pit: Cell, entry: Side, filled: (c: Cell) => boolean,
): { floor: Side[]; mouths: Side[] } {
const floor: Side[] = [];
const mouths: Side[] = [];
for (const d of SIDES) {
if (d === opposite(entry)) continue;
const t = stepTarget(board, pit, d);
if (t.kind === "blocked" || filled(t.to)) continue;
(t.kind === "step" ? floor : mouths).push(d);
}
return { floor, mouths };
}
/**
* Line of sight from the center of `from` to the center of `to`, blocked by
* wall/door/firewall edges the segment crosses and by any `blockedCells`
+123 -71
View File
@@ -27,7 +27,7 @@ import {
bentSightThroughGap,
neighbor,
stepTarget,
opposite,
opposite, pitRimExits,
} from "./board";
import {
buildDeck,
@@ -80,6 +80,12 @@ export interface PlayerState {
}
/** A duration spell in play. Expires at the START of the caster's turns. */
/** BIG MAN has pushed a wizard to a fork: the giant's stride hangs while
* the pushed one picks which way to go (rev 15). */
export interface PushPending {
giantId: PlayerId; pusheeId: PlayerId; direction: Side; over: boolean; exit?: Side; exits: Side[];
}
export interface SustainedEffect {
id: string;
cardId: string;
@@ -212,6 +218,9 @@ export interface CastStack {
reflectedBase?: { damage: number; duration: number };
/** The wraith's touch also steals a random card if damage lands. */
creatureTouch?: "wraith" | "claw";
/** Where the spell left from when not the attacker's own square: an
* ALTER EGO's. Knockbacks and routs push away from here. */
origin?: Cell;
/** A spell freed from slime: counteractions cannot touch its caster. */
trapped?: boolean;
/** STRENGTH's tear: "this would be an attack" the grab rides the stack
@@ -238,7 +247,7 @@ export interface CastParams {
}
/** The revision new games are dealt under; GameConfig.deckRev pins it per game. */
export const CURRENT_RULES_REV = 20;
export const CURRENT_RULES_REV = 21;
/** Every rulings revision since the baseline, newest last the entries a
* game's deckRev freezes it before or after. Shown to players as the house
@@ -263,6 +272,7 @@ export const RULES_REVISIONS: { rev: number; note: string }[] = [
{ rev: 18, note: "REFLECTION's returning half is an attack on the caster in its own right, with the caster's own counteraction window — an ABSORB or a BLUNT meets it as it would any blow. Before, the half landed the instant the spell resolved." },
{ rev: 19, note: "DUST CLOUD blinds whoever stands in it: no LOS spell may be cast from inside a cloud, nor at anyone standing in one, and VISIONSTONE does not see through it. Spells cast on oneself still work. Before, the cloud blocked only sight lines passing through it." },
{ rev: 20, note: "A waterwall's wave names its victims before it pushes any of them. Before, a wave walking the way it pushed could catch a wizard it had just shoved and shove them again with the force left — one square into a wall cost two points instead of one." },
{ rev: 21, note: "Two cards keep their whole promise. LIFESAVER's holder is not eliminated for losing both treasures. FORCE FIELD, after stopping the spell, stands until the end of the opponent's turn: they may not enter its caster's square, nor cast on or past them — on every side, where the card says one." },
];
export interface GameConfig {
@@ -294,11 +304,7 @@ export interface GameState {
/** A treasure was just grabbed and its owner holds WARD:
* the table waits while they choose to play it "at that time" or not. */
wardPending: { ownerId: PlayerId; takerId: PlayerId } | null;
/** BIG MAN has pushed a wizard to a fork: the giant's stride hangs while
* the pushed one picks which way to go (rev 15). */
pushPending: {
giantId: PlayerId; pusheeId: PlayerId; direction: Side; over: boolean; exit?: Side; exits: Side[];
} | null;
pushPending: PushPending | null;
/** SLOW DEATH's bites hang while the victim weighs counteractions:
* the draw's total is one blow ABSORB soaks up to three of it,
* BLUNT halves it rounding up (table ruling). */
@@ -450,10 +456,24 @@ function dustAtEnd(state: GameState, from: Cell, to: Cell): boolean {
return state.squareContents[cellKey(from)]?.kind === "dust" || state.squareContents[cellKey(to)]?.kind === "dust";
}
/** A FORCE FIELD raised against `id` on the wizard standing at `cell`,
* if any (rev 21): they may not enter it, nor cast on or past it. */
function fieldAgainst(state: GameState, id: PlayerId, cell: Cell): boolean {
if ((state.config.deckRev ?? 1) < 21) return false;
const idx = state.players.findIndex((q) => q.id === id);
return state.players.some((h) => h.alive && h.id !== id && cellKey(h.position) === cellKey(cell) &&
sustainedOn(state, h.id, "force-field").some((f) => f.data.against === idx));
}
function losWith(state: GameState, from: Cell, to: Cell, viewerId: PlayerId | undefined, stone: boolean): boolean {
if (dustAtEnd(state, from, to)) return false;
const board = doorsAjar(state, viewerId, openedDoors(state, boardView(state)));
const blockers = losBlockers(state);
if (viewerId) {
for (const h of state.players) {
if (h.alive && h.id !== viewerId && fieldAgainst(state, viewerId, h.position)) blockers[cellKey(h.position)] = true;
}
}
if (sightBetween(board, from, to, blockers)) return true;
if (!stone) return false;
const viewer = viewerId ? state.players.find((p) => p.id === viewerId) : undefined;
@@ -836,6 +856,10 @@ export type Command =
powerAttackPoints?: number;
target?: CastTarget;
params?: CastParams;
/** ALTER EGO: the double's creature id, when the spell leaves from
* its square "it may use any of the spells in your hand, and you
* need not be in its L.O.S. to do so." */
via?: string;
}
| { type: "setAmbush"; instanceId: string; trigger: AmbushTrigger; spellInstanceId: string; numberInstanceIds?: string[]; cell?: Cell }
| { type: "cancelAmbush"; ambushId: string }
@@ -903,6 +927,8 @@ interface Magnitude {
}
interface ResolutionContext {
/** The square the blow came from: the attacker's, or their double's. */
origin: Cell;
state: GameState;
events: GameEvent[];
attacker: PlayerState;
@@ -1069,7 +1095,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
onResolved: (ctx) => {
const knock = ctx.stack.params?.knockback ?? 0;
if (knock <= 0 || ctx.fullyStopped || !ctx.defender.alive) return;
knockBack(ctx.state, ctx.events, ctx.attacker, ctx.defender, knock);
knockBack(ctx.state, ctx.events, ctx.attacker, ctx.defender, knock, ctx.origin);
},
},
"sudden-death": { kind: "attack", requiresLos: true, baseDamage: () => 10 },
@@ -1114,7 +1140,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
onResolved: (ctx) => {
if (ctx.fullyStopped || !ctx.defender.alive) return;
const n = ctx.stack.numberValue ?? 1;
goAwayRout(ctx.state, ctx.events, ctx.attacker, ctx.defender, n);
goAwayRout(ctx.state, ctx.events, ctx.attacker, ctx.defender, n, ctx.origin);
ctx.defender.lostTurns++;
ctx.events.push({ type: "stunned", player: ctx.defender.id, turnsLost: 1 });
},
@@ -2377,11 +2403,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
if ((state.config.deckRev ?? 1) < 10 || !target) return null;
const caster = activePlayer(state);
const legs = state.turn.movementAllowance - state.turn.movementUsed;
// Rev 16: the charge is measured as far as the legs reach. Before it,
// the search stopped at six steps and called anything past that
// unreachable — a goat with eight legs refused a seven-step ram.
const reach = (state.config.deckRev ?? 1) >= 16 ? legs : 6;
const d = walkingDistance(state, caster.position, target.position, reach);
const d = walkingDistance(state, caster.position, target.position, chargeReach(state, legs));
if (d > legs) return "the goat charges on legs, not wings — you cannot walk that far this turn";
return null;
},
@@ -2389,8 +2411,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
if (ctx.fullyStopped || !ctx.defender.alive || !ctx.attacker.alive) return;
if ((ctx.state.config.deckRev ?? 1) >= 10) {
const legs = ctx.state.turn.movementAllowance - ctx.state.turn.movementUsed;
const reach = (ctx.state.config.deckRev ?? 1) >= 16 ? legs : 6;
const d = walkingDistance(ctx.state, ctx.attacker.position, ctx.defender.position, reach);
const d = walkingDistance(ctx.state, ctx.attacker.position, ctx.defender.position, chargeReach(ctx.state, legs));
if (d === 0) return;
if (d > legs) {
// The target slipped beyond the charge (a counter-teleport, say):
@@ -2879,10 +2900,8 @@ function waveForce(range: number, dist: number): number {
/** One line of a wave's sweep: the squares it reaches, nearest first, with
* the force left at each. The victims are named before any is pushed
* (rev 20). Before it the sweep, walking the way it pushed, could find a
* victim it had just shoved and shove them again with the force left: a
* wizard washed one square into a wall was crushed twice. Older games
* replay that sweep. */
* (rev 20); older games sweep square by square, so a wizard shoved along
* the sweep's own line is caught and shoved again. */
function sweepWave(
state: GameState, events: GameEvent[], line: { cell: Cell; force: number }[], dir: Side, reason: string,
): void {
@@ -3700,9 +3719,16 @@ function attachSustained(
});
}
/** BFS steps between cells respecting walls (MENTAL FORCE's 3 moved spaces). */
/** Steps along real corridors from one cell to another, searched out to
* `limit` steps; beyond that the answer is Infinity. */
/** How far a BUTT-HEAD charge is measured: as far as the goat's legs
* reach (rev 16); older games search six steps and call the rest
* unreachable. */
function chargeReach(state: GameState, legs: number): number {
return (state.config.deckRev ?? 1) >= 16 ? legs : 6;
}
/** Steps along real corridors from one cell to another (MENTAL FORCE's
* three moved spaces, a goat's charge), searched out to `limit` steps;
* beyond that the answer is Infinity. */
export function walkingDistance(state: GameState, from: Cell, to: Cell, limit = 6): number {
if (cellKey(from) === cellKey(to)) return 0;
const view = boardView(state);
@@ -4230,6 +4256,7 @@ function doMove(prev: GameState, direction: Side, over = false, exit?: Side, sho
// BLIND (or a DUST CLOUD): "must roll direction on D4 if attempting to
// move ... bumping into a wall counts as one space of movement."
// A resumed push carries the direction its pushee chose: no stagger.
if (isBlinded(state, p) && shove === undefined) {
direction = SIDES[rollD4(state, events, p.id, "a blind stagger picks the direction") - 1]!;
}
@@ -4337,6 +4364,7 @@ function doMove(prev: GameState, direction: Side, over = false, exit?: Side, sho
// Square contents at the destination.
let content = state.squareContents[cellKey(p.position)];
if (content?.kind === "stone") return err("that square is solid stone");
if (fieldAgainst(state, p.id, p.position)) return err("a force field bars the way");
// BIG MAN: nobody enters his square — except a SHRUNK wizard, small
// enough to slip between the giant's boots (table ruling).
if (sustainedOn(state, p.id, "shrink").length === 0) {
@@ -4372,10 +4400,9 @@ function doMove(prev: GameState, direction: Side, over = false, exit?: Side, sho
// the other player gets to decide which direction to go." The
// pushed one leaves by any open side but the way the giant came;
// one open side is taken, several are theirs to choose — the stride
// hangs until they do. Older games shoved straight ahead only.
// A game dealt before rev 15 shoves straight ahead when it can, as it
// always did; the corner and the fork were refusals before, so every
// game may round a corner now.
// hangs until they do. Older games (deckRev < 15) shove straight
// ahead when that way is open; rounding a corner is allowed in every
// game.
const fork = (state.config.deckRev ?? 1) >= 15;
let landing: Cell | null = null;
const exits = SIDES.filter((d) => d !== opposite(direction) && canShoveTo(d) !== null);
@@ -4436,19 +4463,12 @@ function doMove(prev: GameState, direction: Side, over = false, exit?: Side, sho
let landingWarp = false;
if (ledge) {
const pitCell = p.position;
const footing = (d: Side): { kind: "step" | "warp"; to: Cell } | null => {
const t = stepTarget(view, pitCell, d);
if (t.kind === "blocked" || state.squareContents[cellKey(t.to)]?.kind === "stone") return null;
return t;
};
const ways = SIDES.filter((d) => d !== opposite(direction));
const floor = ways.filter((d) => footing(d)?.kind === "step");
const mouths = ways.filter((d) => footing(d)?.kind === "warp");
const { floor, mouths } = pitRimExits(view, pitCell, direction,
(c) => state.squareContents[cellKey(c)]?.kind === "stone");
// A warp mouth on the rim is a way off it like any square, and may
// always be named. Only what a bare step does is a matter of
// revision: from rev 17 a square beside a mouth is a fork to name;
// before it the step took the square, and takes it still, so the
// recorded games replay as they were played.
// before it a bare step takes the square.
const exits = [...floor, ...mouths];
const bare = (state.config.deckRev ?? 1) >= 17 || floor.length === 0 ? exits : floor;
if (exits.length === 0) {
@@ -4464,7 +4484,7 @@ function doMove(prev: GameState, direction: Side, over = false, exit?: Side, sho
p.position = from;
return err(`the rim leads more than one way — click the square to land on: ${exits.join(" or ")}`);
}
const foot = footing(landing)!;
const foot = stepTarget(view, pitCell, landing) as { kind: "step" | "warp"; to: Cell };
landingCell = foot.to;
landingWarp = foot.kind === "warp";
}
@@ -5243,6 +5263,19 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
const inHand = caster.hand.find((c) => c.instanceId === cmd.instanceId);
if (!inHand) return err("card not in hand");
const def = cardDef(inHand.cardId);
// ALTER EGO: "It may use any of the spells in your hand, and you need
// not be in its L.O.S. to do so." The double's square is where the
// spell leaves from — sight, reach, and the blow's direction are its —
// while the hand, the attack, and any answer to it stay the caster's.
// Spells that move the caster's own body do not travel through it.
let origin: Cell = caster.position;
if (cmd.via) {
const ego = state.creatures.find((c) => c.id === cmd.via);
if (!ego || ego.kind !== "alter-ego" || ego.controllerId !== caster.id) return err("that is not your double");
if (!cmd.target) return err("your double casts only at a target");
if (inHand.cardId === "butt-head" || inHand.cardId === "swap") return err(`${def.name} moves your own body — not your double's to cast`);
origin = ego.position;
}
const effect = CARD_EFFECTS[inHand.cardId];
if (!effect) return err(`${def.name} is not implemented yet`);
if (effect.kind === "counter") {
@@ -5328,7 +5361,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
const events: GameEvent[] = [{
type: "spellCast", caster: caster.id, card: inHand, cardId: inHand.cardId,
numberCards: mods.numbers, numberValue: mods.magnitude.numberValue,
from: caster.position, target: null, targetCell: null,
from: origin, target: null, targetCell: null,
}];
attachSustained(state, events, "disease", caster.id, caster.id, mods.magnitude.duration);
return { ok: true, state, events };
@@ -5342,12 +5375,12 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
if (cmd.target?.kind === "creature") {
const creature = state.creatures.find((c) => c.id === (cmd.target as { creatureId: string }).creatureId);
if (!creature) return err("no such creature");
if (effect.sameSquare && cellKey(creature.position) !== cellKey(caster.position)) {
if (effect.sameSquare && cellKey(creature.position) !== cellKey(origin)) {
return err("you must be in the same square");
}
if (effect.requiresLos && !(mods.aroundCorner
? bentLos(state, caster, caster.position, creature.position)
: casterLos(state, caster, caster.position, creature.position))) {
? bentLos(state, caster, origin, creature.position)
: casterLos(state, caster, origin, creature.position))) {
return err("no line of sight to the creature");
}
const wandEvents: GameEvent[] = [];
@@ -5362,7 +5395,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
const events: GameEvent[] = [...wandEvents, {
type: "spellCast", caster: caster.id, card: inHand, cardId: inHand.cardId,
numberCards: mods.numbers, numberValue: mods.magnitude.numberValue,
from: caster.position, target: null, targetCell: creature.position,
from: origin, target: null, targetCell: creature.position,
}];
// "Any WATERBOLT or WATERWALL will destroy it" (FIRE IMP).
if (creature.kind === "fire-imp" && inHand.cardId === "waterbolt") {
@@ -5383,7 +5416,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
if (cmd.target?.kind === "cell" &&
state.squareContents[cellKey(cmd.target.cell)]?.kind === "safe") {
const cell = cmd.target.cell;
if (effect.sameSquare && cellKey(cell) !== cellKey(caster.position)) {
if (effect.sameSquare && cellKey(cell) !== cellKey(origin)) {
return err("you must be in the same square");
}
// castSight carries the whole sight law: straight lines, the
@@ -5405,7 +5438,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
const events: GameEvent[] = [...wandEvents, {
type: "spellCast", caster: caster.id, card: inHand, cardId: inHand.cardId,
numberCards: mods.numbers, numberValue: mods.magnitude.numberValue,
from: caster.position, target: null, targetCell: { ...cell },
from: origin, target: null, targetCell: { ...cell },
}];
const box = state.squareContents[cellKey(cell)]!;
box.damage += dmg;
@@ -5424,8 +5457,8 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
state.squareContents[cellKey(cmd.target.cell)]?.kind === "slime") {
const cell = cmd.target.cell;
if (effect.requiresLos && !(mods.aroundCorner
? bentLos(state, caster, caster.position, cell)
: casterLos(state, caster, caster.position, cell))) {
? bentLos(state, caster, origin, cell)
: casterLos(state, caster, origin, cell))) {
return err("no line of sight to the slime");
}
const wandEvents: GameEvent[] = [];
@@ -5469,7 +5502,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
if (current !== "wall" && current !== "door") {
return err("there is no wall or door on that edge to attack");
}
if (effect.sameSquare && !touchesEdge(caster.position, cell, side)) {
if (effect.sameSquare && !touchesEdge(origin, cell, side)) {
return err("you must stand beside the wall");
}
if (effect.requiresLos && !castSightEdge(state, caster, cmd, view, cell, side)) {
@@ -5495,7 +5528,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
const events2: GameEvent[] = [...wandEvents, {
type: "spellCast", caster: caster.id, card: inHand, cardId: inHand.cardId,
numberCards: mods.numbers, numberValue: mods.magnitude.numberValue,
from: caster.position, target: null, targetCell: cell,
from: origin, target: null, targetCell: cell,
}];
const problem = damageWall(state, events2, caster, cell, side, dmg, inHand.cardId);
if (problem) return err(problem);
@@ -5524,7 +5557,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
const events: GameEvent[] = [{
type: "spellCast", caster: caster.id, card: inHand, cardId: inHand.cardId,
numberCards: mods.numbers, numberValue: mods.magnitude.numberValue,
from: caster.position, target: null, targetCell: null,
from: origin, target: null, targetCell: null,
}];
const queue = turnOrderFrom(state, caster.id).filter((id) =>
id !== caster.id && state.players.find((p) => p.id === id)!.alive);
@@ -5536,16 +5569,17 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
if (cmd.target.playerId === caster.id) return err("you cannot attack yourself");
const target = state.players.find((p) => p.id === (cmd.target as { playerId: PlayerId }).playerId);
if (!target || !target.alive) return err("no such living player");
if (effect.sameSquare && cellKey(target.position) !== cellKey(caster.position)) {
if (effect.sameSquare && cellKey(target.position) !== cellKey(origin)) {
return err("you must be in the same square");
}
const statusBlock = attackBlockedByStatus(state, caster, target);
if (statusBlock) return err(statusBlock);
if (fieldAgainst(state, caster.id, target.position)) return err("their force field turns your spell aside");
const preEvents: GameEvent[] = [];
if (effect.requiresLos) {
const sighted = mods.aroundCorner
? bentLos(state, caster, caster.position, target.position)
: casterLos(state, caster, caster.position, target.position);
? bentLos(state, caster, origin, target.position)
: casterLos(state, caster, origin, target.position);
if (!sighted) return err("no line of sight to the target");
}
// Attacking someone breaks any BUDDY pact you swore to them.
@@ -5579,9 +5613,9 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
// go intended distance" — if the die disagrees with the true direction,
// the spell hits whoever lies that way, or dissipates.
if (isBlinded(state, caster) &&
cellKey(target.position) !== cellKey(caster.position)) {
const dx = target.position.x - caster.position.x;
const dy = target.position.y - caster.position.y;
cellKey(target.position) !== cellKey(origin)) {
const dx = target.position.x - origin.x;
const dy = target.position.y - origin.y;
const intended: Side =
Math.abs(dx) >= Math.abs(dy) && dx !== 0 ? (dx > 0 ? "E" : "W") : dy > 0 ? "S" : "N";
const [roll, rngNext] = rollDie(state.rng);
@@ -5590,11 +5624,11 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
if (rolled !== intended) {
const along = state.players.find((p) => {
if (!p.alive || p.id === caster.id) return false;
const px = p.position.x - caster.position.x;
const py = p.position.y - caster.position.y;
const px = p.position.x - origin.x;
const py = p.position.y - origin.y;
const dirOf: Side | null =
Math.abs(px) >= Math.abs(py) && px !== 0 ? (px > 0 ? "E" : "W") : py !== 0 ? (py > 0 ? "S" : "N") : null;
return dirOf === rolled && casterLos(state, caster, caster.position, p.position);
return dirOf === rolled && casterLos(state, caster, origin, p.position);
});
consumeCast(state, caster, inHand, mods, effect.keepInHand ?? false);
state.turn.attackUsed = true;
@@ -5616,11 +5650,12 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
kind: effect.physical ? "physical" : "spell",
counters: [],
waitingOn: along.id,
...(cmd.via ? { origin } : {}),
};
missEvents.push({
type: "spellCast", caster: caster.id, card: inHand, cardId: inHand.cardId,
numberCards: mods.numbers, numberValue: mods.magnitude.numberValue,
from: caster.position, target: along.id, targetCell: along.position,
from: origin, target: along.id, targetCell: along.position,
});
return { ok: true, state, events: missEvents };
}
@@ -5642,6 +5677,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
counters: [],
waitingOn: target.id,
...(mods.aroundCorner ? { bentCorner: true as const } : {}),
...(cmd.via ? { origin } : {}),
};
state.lastSpellUsed[caster.id] = inHand.cardId;
const events: GameEvent[] = [...wandEvents, ...preEvents];
@@ -5654,7 +5690,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
numberCards: mods.numbers,
numberValue: mods.magnitude.numberValue,
amplifies: mods.amplifies.length,
from: caster.position,
from: origin,
target: target.id,
targetCell: target.position,
});
@@ -5674,7 +5710,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
cardId: inHand.cardId,
numberCards: mods.numbers,
numberValue: mods.magnitude.numberValue,
from: caster.position,
from: origin,
target: cmd.target?.kind === "player" ? cmd.target.playerId : null,
targetCell: cmd.target?.kind === "edge" || cmd.target?.kind === "cell" ? cmd.target.cell : null,
});
@@ -6029,12 +6065,17 @@ function doCounteract(
// any, will be swapped" — the reflector's choice rides the counter.
...(card.cardId === "full-reflection" && params?.cardId ? { cardId: params.cardId } : {}),
});
// Rev 21: FORCE FIELD stands after it stops the spell — "preventing an
// opponent from entering the space you occupy or casting spells on or
// past you. Lasts only until the end of the opponent's turn." The
// field faces every side, not one: this table's simplification.
const events: GameEvent[] = [{ type: "counteractionPlayed", player: playerId, card, cardId: card.cardId, against }];
if (card.cardId === "force-field" && (state.config.deckRev ?? 1) >= 21) {
const facing = state.players.findIndex((q) => q.id === stack.attackerId);
attachSustained(state, events, "force-field", playerId, playerId, PERMANENT_TURNS, { against: facing });
}
stack.waitingOn = stack.attackerId;
return {
ok: true,
state,
events: [{ type: "counteractionPlayed", player: playerId, card, cardId: card.cardId, against }],
};
return { ok: true, state, events };
}
if (playerId === stack.attackerId) {
@@ -6291,6 +6332,7 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
effect.onResolved({
state, events,
attacker: defender, defender: attacker,
origin: defender.position,
damageDealt: 0, fullyStopped: false, reversed: false, duration: 0,
stack: { ...stack, params: { ...stack.params, cardId: chosen } },
});
@@ -6434,6 +6476,7 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
events,
attacker,
defender,
origin: stack.origin ?? attacker.position,
damageDealt,
fullyStopped: pipe.fullyStopped,
reversed: pipe.reversed,
@@ -6472,11 +6515,12 @@ function goAwayRout(
attacker: PlayerState,
defender: PlayerState,
squares: number,
origin: Cell = attacker.position,
): void {
if (isLockedInPlace(state, defender.id)) return;
const from = defender.position;
const dist = (c: Cell) =>
Math.abs(attacker.position.x - c.x) + Math.abs(attacker.position.y - c.y);
Math.abs(origin.x - c.x) + Math.abs(origin.y - c.y);
let heading: Side | null = null;
let moved = 0;
for (let i = 0; i < squares; i++) {
@@ -6527,9 +6571,10 @@ function knockBack(
attacker: PlayerState,
defender: PlayerState,
squares: number,
origin: Cell = attacker.position,
): void {
const dx = defender.position.x - attacker.position.x;
const dy = defender.position.y - attacker.position.y;
const dx = defender.position.x - origin.x;
const dy = defender.position.y - origin.y;
let dir: Side | null = null;
if (Math.abs(dx) >= Math.abs(dy) && dx !== 0) dir = dx > 0 ? "E" : "W";
else if (dy !== 0) dir = dy > 0 ? "S" : "N";
@@ -6790,7 +6835,11 @@ function checkVictory(state: GameState, events: GameEvent[]): void {
const ownerPlayer = owner ? state.players.find((q) => q.id === owner) : null;
return owner !== null && owner !== p.id && ownerPlayer?.alive === true;
});
if (lost) {
// Rev 21: LIFESAVER — "immune to the effects of losing both of your
// treasures to other players' home bases." Older games eliminate its
// holder.
const saved = (state.config.deckRev ?? 1) >= 21 && sustainedOn(state, p.id, "lifesaver").length > 0;
if (lost && !saved) {
p.alive = false;
p.finalHand = [...p.hand];
state.discard.push(...p.hand.splice(0));
@@ -7120,6 +7169,9 @@ function doEndTurn(prev: GameState, draw: number): CommandResult {
}
state.openSafes = [];
// A FORCE FIELD raised against this wizard lasts only until their turn ends.
const seat = state.players.indexOf(p);
state.sustained = state.sustained.filter((f) => !(f.cardId === "force-field" && f.data.against === seat));
events.push({ type: "turnEnded", player: p.id });
if (p.extraTurns > 0) {
+23 -19
View File
@@ -9,6 +9,7 @@ import {
LOS_BLOCKING_CONTENT,
type AmbushState,
type CastStack,
type PushPending,
type CreatureState,
type GameState,
type PlayerId,
@@ -93,7 +94,7 @@ export interface GameView {
chaosPending: { casterId: PlayerId; queue: PlayerId[] } | null;
/** A grab hangs while the treasure's owner decides their Ward. */
wardPending: { ownerId: PlayerId; takerId: PlayerId } | null;
pushPending: GameState["pushPending"];
pushPending: PushPending | null;
slowDeathPending: { playerId: PlayerId; points: number } | null;
/** YOUR armed ambushes. Other players' ambushes are invisible. */
yourAmbushes: AmbushState[];
@@ -220,20 +221,23 @@ export function fearCells(view: GameView): Set<string> {
* reflects what this player knows (illusion walls they believe in block it).
* The basis for the client's "dim the ineligible squares" targeting aid.
*/
export function sightedCellsFor(view: GameView): Set<string> {
export function sightedCellsFor(view: GameView, from?: Cell): Set<string> {
const out = new Set<string>();
const me = view.players.find((p) => p.id === view.you);
if (!me) return out;
// Sight is taken from the viewer's square unless a cast leaves from
// elsewhere — an ALTER EGO's — in which case it is the double's.
const eye = from ?? me.position;
const { board, blockers } = sightBasis(view);
// In a dust cloud a wizard sees only their own square (rev 19).
if (dustAtEnd(view, me.position, me.position, true)) {
out.add(`${me.position.x},${me.position.y}`);
if (inDust(view, eye)) {
out.add(`${eye.x},${eye.y}`);
return out;
}
for (const key of Object.keys(board.cells)) {
const [x, y] = key.split(",").map(Number) as [number, number];
if (dustAtEnd(view, me.position, { x, y })) continue;
if (sightBetween(board, me.position, { x, y }, blockers)) out.add(key);
if (dustAtEnd(view, eye, { x, y })) continue;
if (sightBetween(board, eye, { 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
@@ -248,7 +252,7 @@ export function sightedCellsFor(view: GameView): Set<string> {
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)) {
if (sightBetween(opened, eye, { x, y }, blockers)) {
out.add(unseen[i]!);
unseen.splice(i, 1);
}
@@ -263,8 +267,8 @@ export function sightedCellsFor(view: GameView): Set<string> {
* plus every square visible from one of those — the caster looks to a middle
* cell and the spell turns there, mirroring the engine's bentLos.
*/
export function bentSightedCellsFor(view: GameView): Set<string> {
const out = sightedCellsFor(view);
export function bentSightedCellsFor(view: GameView, from?: Cell): Set<string> {
const out = sightedCellsFor(view, from);
const me = view.players.find((p) => p.id === view.you);
if (!me) return out;
const mids = [...out].map((k) => {
@@ -330,15 +334,15 @@ export function traceSightFor(view: GameView, from: Cell, to: Cell): SightTrace
return traceSight(board, from, to, blockers);
}
/** Rev 19: DUST CLOUD blinds its occupant and hides them from LOS spells;
* a square sees itself still. `standing` asks only whether the viewer's
* own square is dust. */
function dustAtEnd(view: GameView, from: Cell, to: Cell, standing = false): boolean {
if (view.deckRev < 19) return false;
const dust = (c: Cell) => view.squareContents[`${c.x},${c.y}`]?.kind === "dust";
if (standing) return dust(from);
/** Rev 19: DUST CLOUD blinds its occupant. Older games see through it. */
function inDust(view: GameView, cell: Cell): boolean {
return view.deckRev >= 19 && view.squareContents[`${cell.x},${cell.y}`]?.kind === "dust";
}
/** Dust at either end kills a sight line; a square sees itself still. */
function dustAtEnd(view: GameView, from: Cell, to: Cell): boolean {
if (from.x === to.x && from.y === to.y) return false;
return dust(from) || dust(to);
return inDust(view, from) || inDust(view, to);
}
/**
@@ -431,12 +435,12 @@ const SUMMON_CARD_IDS = new Set([
* aid — mirroring the engine's own validation from the viewer's knowledge.
* Null = this card's eligibility is not modeled; light everything.
*/
export function eligibleCellsFor(view: GameView, cardId: string, bentCorner = false): Set<string> | null {
export function eligibleCellsFor(view: GameView, cardId: string, bentCorner = false, from?: Cell): Set<string> | null {
const me = view.players.find((p) => p.id === view.you);
if (!me) return null;
const cells = Object.keys(view.board.cells);
const key = (x: number, y: number) => `${x},${y}`;
const sighted = bentCorner ? bentSightedCellsFor(view) : sightedCellsFor(view);
const sighted = bentCorner ? bentSightedCellsFor(view, from) : sightedCellsFor(view, from);
if (CREATION_CARD_IDS.has(cardId)) {
// emptySquareTarget: on the board, unoccupied by content, home, wizard,
+6 -6
View File
@@ -8,7 +8,7 @@ import {
} from "../src/game";
import { cellKey, edgeKey } from "../src/board";
import { sightedCellsFor, viewFor } from "../src/view";
import { automatonCommand, automatonFallback, drawUnderCurses, pathToward, type AutomatonStyle, type AutomatonTier } from "../src/automaton";
import { automatonCommand, automatonFallback, drawUnderSlowDeath, pathToward, type AutomatonStyle, type AutomatonTier } from "../src/automaton";
import { pushSustained } from "./helpers";
/** Whose input does the maze want right now? */
@@ -1129,14 +1129,14 @@ describe("the clockwork under a curse, and before a pit", () => {
const state = botsTurn();
const bot = state.players.find((p) => p.id === "bot")!;
const plain = viewFor(state, "bot");
expect(drawUnderCurses(plain, 2)).toBe(2);
expect(drawUnderSlowDeath(plain, 2)).toBe(2);
pushSustained(state, { id: "sd", cardId: "slow-death", casterId: "human", targetId: "bot", remainingTurns: 1e9 });
bot.life = 7;
expect(drawUnderCurses(viewFor(state, "bot"), 2)).toBe(2);
expect(drawUnderSlowDeath(viewFor(state, "bot"), 2)).toBe(2);
bot.life = 5;
expect(drawUnderCurses(viewFor(state, "bot"), 2)).toBe(1);
expect(drawUnderSlowDeath(viewFor(state, "bot"), 2)).toBe(1);
bot.life = 4;
expect(drawUnderCurses(viewFor(state, "bot"), 2)).toBe(0);
expect(drawUnderSlowDeath(viewFor(state, "bot"), 2)).toBe(0);
});
it("raises no SHADOW while bleeding, nor with little blood to spare", () => {
@@ -1152,7 +1152,7 @@ describe("the clockwork under a curse, and before a pit", () => {
for (let guard = 0; guard < 30 && actingSeat(state) === "bot"; guard++) {
const view = viewFor(state, "bot");
const cmd = automatonCommand(view, "berserker", "archmage") ?? automatonFallback(view, "archmage");
expect(JSON.stringify(cmd)).not.toContain("shadow#T");
expect(cmd.type === "cast" && cmd.instanceId === "shadow#T").toBe(false);
const r = applyCommand(state, "bot", cmd);
if (!r.ok) break;
state = r.state;
+43 -6
View File
@@ -1,9 +1,10 @@
import { describe, expect, it } from "vitest";
import {
type CreatureState, applyCommand, activePlayer, boardView, creatureAt, type GameState, type PlayerId, createGame } from "../src/game";
type CreatureState, applyCommand, activePlayer, boardView, creatureAt, type GameState, type PlayerId, createGame, gameLos,
} from "../src/game";
import { cellKey, edgeKey, neighbor, opposite, SIDES, stepTarget, type Cell, type Side } from "../src/board";
import type { CardInstance } from "../src/cards";
import { newExpansionGame as newGame, must, drain, giveCard, toRound2, emptyNeighborCell, pushSustained } from "./helpers";
import { newExpansionGame as newGame, must, drain, giveCard, toRound2, emptyNeighborCell, pushSustained, faceOff } from "./helpers";
/** Summon a creature next to its creator (round 2+, consumes the attack). */
function summon(state: GameState, playerId: PlayerId, kind: string, tag = "S") {
@@ -395,7 +396,7 @@ describe("big man", () => {
/** From a giant's square: a step into a square whose exits (all sides but
* the way back) number exactly `want` a corner (1, not straight) or a
* fork (2+). Returns the giant's start, the pushee's square, and the exits. */
function pushSite(state: GameState, want: "corner" | "fork") {
function pushSite(state: GameState, want: "corner" | "fork" | "straight-fork") {
const view = boardView(state);
for (const key of Object.keys(view.cells)) {
const [x, y] = key.split(",").map(Number) as [number, number];
@@ -406,6 +407,7 @@ describe("big man", () => {
const exits = SIDES.filter((d) => d !== opposite(side) && stepTarget(view, t1.to, d).kind === "step");
if (want === "corner" && exits.length === 1 && exits[0] !== side) return { at: { x, y }, side, mid: t1.to, exits };
if (want === "fork" && exits.length >= 2) return { at: { x, y }, side, mid: t1.to, exits };
if (want === "straight-fork" && exits.length >= 2 && exits.includes(side)) return { at: { x, y }, side, mid: t1.to, exits };
}
}
throw new Error(`no ${want} on this board`);
@@ -452,15 +454,15 @@ describe("big man", () => {
expect(state.turn.movementUsed).toBe(used + 1);
});
it("an older game shoves straight ahead when it can, and now rounds a corner when it cannot", () => {
it("a pre-rev-15 game shoves straight ahead when it can and rounds a corner when it cannot", () => {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 14 });
state = toRound2(state);
const giant = activePlayer(state);
state.sustained.push({ id: "fx-big", cardId: "big-man", casterId: giant.id, targetId: giant.id, remainingTurns: 9, data: {} });
const victim = state.players.find((p) => p.id !== giant.id)!;
// A fork where straight ahead is open: no window, straight it is.
const fork = pushSite(state, "fork");
if (fork.exits.includes(fork.side)) {
const fork = pushSite(state, "straight-fork");
{
giant.position = { ...fork.at };
victim.position = { ...fork.mid };
const r = applyCommand(state, giant.id, { type: "move", direction: fork.side });
@@ -961,3 +963,38 @@ describe("the imp's fire is a spell (rev 8)", () => {
expect(after.alive).toBe(true);
});
});
describe("the alter ego casts from its own square", () => {
it("a fireball leaves from the double, by the double's sight, and answers to the caster", () => {
let { state } = newGame(42);
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const a = state.players.find((p) => p.id === attacker)!;
const d = state.players.find((p) => p.id === defender)!;
const ego = giveCard(state, attacker, "alter-ego");
state = must(state, attacker, { type: "cast", instanceId: ego.instanceId });
const double = state.creatures.find((c) => c.kind === "alter-ego" && c.controllerId === attacker)!;
expect(cellKey(double.position)).toBe(cellKey(a.position));
// The caster steps out of the defender's sight; the double keeps it.
const view = boardView(state);
const hidden = Object.keys(view.cells).map((k) => { const [x, y] = k.split(",").map(Number) as [number, number]; return { x, y }; })
.find((c) => !gameLos(state, c, d.position) && !state.squareContents[cellKey(c)] && !view.homes.some((h) => cellKey(h) === cellKey(c)));
expect(hidden).toBeDefined();
const me = state.players.find((p) => p.id === attacker)!;
me.position = { ...hidden! };
const fb = giveCard(state, attacker, "fireball");
const blind = applyCommand(state, attacker, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender } });
expect(blind.ok).toBe(false);
const wrong = applyCommand(state, attacker, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender }, via: "no-such-double" });
expect(wrong.ok).toBe(false);
const viaEgo = applyCommand(state, attacker, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender }, via: double.id });
expect(viaEgo.ok).toBe(true);
if (!viaEgo.ok) return;
expect(viaEgo.state.stack?.attackerId).toBe(attacker);
expect(cellKey(viaEgo.state.stack!.origin!)).toBe(cellKey(double.position));
const cast = viaEgo.events.find((e) => e.type === "spellCast");
expect(cast && cast.type === "spellCast" ? cellKey(cast.from) : null).toBe(cellKey(double.position));
const done = must(viaEgo.state, defender, { type: "pass" });
expect(done.players.find((p) => p.id === defender)!.life).toBe(10);
});
});
+54 -2
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { applyCommand, activePlayer, boardView, createGame, gameLos, sustainedOn, walkingDistance, type GameState } from "../src/game";
import { cellKey, edgeKey, stepTarget, type Cell } from "../src/board";
import { cellKey, edgeKey, opposite, SIDES, stepTarget, type Cell, type Side } from "../src/board";
import type { CardInstance } from "../src/cards";
import { newExpansionGame as newGame, must, drain, giveCard, toRound2, faceOff, castAt } from "./helpers";
@@ -813,7 +813,7 @@ describe("the goat charges on legs, not wings (rev 10)", () => {
});
});
describe("butt-head's charge reaches as far as its legs (K4T3)", () => {
describe("butt-head's charge reaches as far as its legs (rev 16)", () => {
/** Two empty cells seven or eight corridor steps apart on the seed-42 board. */
function longRun(state: GameState): { from: Cell; to: Cell; steps: number } {
const view = boardView(state);
@@ -843,3 +843,55 @@ describe("butt-head's charge reaches as far as its legs (K4T3)", () => {
});
}
});
describe("cards that keep their whole promise (rev 21)", () => {
it("LIFESAVER's holder survives losing both treasures", () => {
for (const [deckRev, alive] of [[21, true], [20, false]] as const) {
let { state } = createGame({ playerIds: ["alice", "bob", "carol"], seed: 42, sets: ["basic", "expansion1"], deckRev });
const me = activePlayer(state);
const others = state.players.filter((p) => p.id !== me.id);
const card = giveCard(state, me.id, "lifesaver");
state = must(state, me.id, { type: "cast", instanceId: card.instanceId });
// Both of the holder's treasures rest on two different enemy homes: nobody wins, the holder is done for.
const mine = state.treasures.filter((t) => t.owner === me.id);
mine[0]!.position = { ...others[0]!.home };
mine[1]!.position = { ...others[1]!.home };
state = must(state, me.id, { type: "endTurn", draw: 0 });
expect(state.players.find((p) => p.id === me.id)!.alive).toBe(alive);
}
});
it("FORCE FIELD stands until the opponent's turn ends: no entering, no casting on its caster", () => {
for (const deckRev of [21, 20]) {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev });
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const a = state.players.find((p) => p.id === attacker)!;
const d = state.players.find((p) => p.id === defender)!;
// Stand the attacker beside the defender, an open edge between.
const view = boardView(state);
const side = SIDES.find((s: Side) => { const t = stepTarget(view, d.position, s); return t.kind === "step" && !state.squareContents[cellKey(t.to)]; })!;
const beside = stepTarget(view, d.position, side);
if (beside.kind === "blocked") throw new Error("no open side");
a.position = { ...beside.to };
const fb = giveCard(state, attacker, "fireball");
d.hand[0] = { instanceId: "force-field#T", cardId: "force-field" };
state = must(state, attacker, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender } });
state = must(state, defender, { type: "counteract", instanceId: "force-field#T" });
state = must(state, attacker, { type: "pass" });
if (state.stack) state = must(state, defender, { type: "pass" });
expect(state.players.find((p) => p.id === defender)!.life).toBe(15);
const step = applyCommand(state, attacker, { type: "move", direction: opposite(side) });
if (deckRev >= 21) {
expect(step.ok).toBe(false);
if (!step.ok) expect(step.error).toMatch(/force field/);
expect(state.sustained.some((f) => f.cardId === "force-field")).toBe(true);
const after = must(state, attacker, { type: "endTurn", draw: 0 });
expect(after.sustained.some((f) => f.cardId === "force-field")).toBe(false);
} else {
expect(step.ok).toBe(true);
expect(state.sustained.some((f) => f.cardId === "force-field")).toBe(false);
}
}
});
});
@@ -561,7 +561,7 @@ describe("the boobytrap keeps its secret", () => {
});
});
describe("a pit on the board's rim (M4Q7)", () => {
describe("a pit on the board's rim (rev 17)", () => {
/** A square on the outer rim with a warp mouth on one side, entered from
* the square opposite the mouth; its other neighbouring squares listed. */
function rimPitSite(state: GameState) {
@@ -637,7 +637,7 @@ describe("a pit on the board's rim (M4Q7)", () => {
});
});
describe("a wave names its victims before it pushes (LK97)", () => {
describe("a wave names its victims before it pushes (rev 20)", () => {
/** A wall between two squares, a wizard on its near side with one open
* square behind them and a wall beyond it, and a caster's square beside. */
function site(state: GameState) {
@@ -692,7 +692,6 @@ describe("spells cast at a slime wait in the gel", () => {
expect(cast.state.slimeTraps[cellKey(spot.cell)]?.length).toBe(1);
// Nobody is hurt yet; the victim walks in on their own turn.
state = must(cast.state, caster.id, { type: "endTurn", draw: 2 });
victim.position = { ...caster.position };
const v = state.players.find((p) => p.id === victim.id)!;
v.position = { ...caster.position };
const walk = applyCommand(state, victim.id, { type: "move", direction: spot.side });
+36
View File
@@ -36,6 +36,7 @@ import { randomBytes } from "node:crypto";
import { readFileSync, existsSync, realpathSync, statSync, createReadStream } from "node:fs";
import { extname, join, normalize, sep } from "node:path";
import { WebSocketServer, WebSocket } from "ws";
import { cardDef } from "@wizwar/engine";
import type { Command, PlayerId } from "@wizwar/engine";
import {
catchUpSteps,
@@ -198,10 +199,45 @@ function safeBase(rawHost: string, rawProto: string): string {
return `${proto}://${escapeHtml(rawHost)}`;
}
/** The turn's story in one line, for the share card: the deed a
* stranger would click to see, chosen by weight a crown, a treasure
* carried home, a blow that landed, a spell cast before the bare fact
* of whose turn it was. */
function headlineOf(data: ShareData): string | null {
// A card retired since the share was written keeps its id as its name.
const name = (id: string | null) => { if (!id) return "a spell"; try { return cardDef(id).name; } catch { return id; } };
const deeds: { rank: number; text: string }[] = [];
const offer = (rank: number, text: string) => { deeds.push({ rank, text }); };
for (const step of data.steps) {
for (const e of step.events) {
switch (e.type) {
case "gameWon": offer(9, e.reason === "treasures" ? `${e.player} wins with two treasures home` : `${e.player} is the last wizard standing`); break;
case "playerEliminated": offer(8, e.reason === "killed" ? `${e.player} falls in the maze` : `${e.player} loses both treasures`); break;
case "treasureDropped": if (e.onHomeOf === e.player) offer(7, `${e.player} carries a stolen treasure home`); break;
case "treasurePickedUp": if (e.owner !== e.player) offer(5, `${e.player} snatches ${e.owner}'s treasure`); break;
case "attackResolved": if (e.damageDealt > 0) offer(6, `${e.attacker} hits ${e.defender} with ${name(e.attackCardId)} for ${e.damageDealt}`); else if (e.fullyStopped) offer(4, `${e.defender} stops ${e.attacker}'s ${name(e.attackCardId)} cold`); break;
case "punched": offer(3, `${e.attacker} punches ${e.target}`); break;
case "spellCast": offer(2, e.target ? `${e.caster} casts ${name(e.cardId)} at ${e.target}` : `${e.caster} casts ${name(e.cardId)}`); break;
case "jumpedPit": offer(3, `${e.player} leaps the pit`); break;
case "fellInPit": offer(3, `${e.player} falls into the pit`); break;
case "wardSprung": offer(4, `${e.owner}'s warded treasure bites ${e.victim}`); break;
case "slimeTrapSprung": offer(4, `the slime springs ${name(e.cardId)} on ${e.victim}`); break;
default: break;
}
}
}
let best: { rank: number; text: string } | null = null;
for (const d of deeds) if (!best || d.rank > best.rank) best = d;
return best?.text ?? null;
}
function shareHtml(id: string, data: ShareData, rawHost: string, rawProto: string): string {
const base = safeBase(rawHost, rawProto);
const headline = data.whole ? null : headlineOf(data);
const title = data.whole
? "The whole tale — a game of Wiz-War, replayed"
: headline
? `${escapeHtml(headline)} — a Wiz-War instant replay`
: `${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.`
+4 -2
View File
@@ -165,7 +165,7 @@ export function evictIdleRooms(hasSockets: (roomId: string) => boolean): number
evicted++;
try {
const stat = ledgerStat(id);
if (stat) writeStubFile(id, stat.bytes, { ...stub, tokens: Object.fromEntries(stub.tokens) });
if (stat) writeStubFile(id, stat.bytes, toStored(stub));
} catch (e) {
console.error(`could not write stub for ${id}:`, e);
}
@@ -185,6 +185,8 @@ interface RoomStub {
const sleepingStubs = new Map<string, RoomStub>();
/** A stub as it rests on disk — the token map spelled as an object. */
type StoredStub = Omit<RoomStub, "tokens"> & { tokens: Record<PlayerId, string> };
const toStored = (stub: RoomStub): StoredStub => ({ ...stub, tokens: Object.fromEntries(stub.tokens) });
const fromStored = (stored: StoredStub): RoomStub => ({ ...stored, tokens: new Map(Object.entries(stored.tokens)) });
function stubOf(room: Room): RoomStub {
const { active, turnHolder, waitKind } = turnFacts(room.state);
@@ -797,7 +799,7 @@ export function loadPersistedRooms(): void {
if (!stat) continue;
const stored = readStubFile<StoredStub>(id);
if (stored && stored.bytes === stat.bytes) {
sleepingStubs.set(id, { ...stored.stub, tokens: new Map(Object.entries(stored.stub.tokens)) });
sleepingStubs.set(id, fromStored(stored.stub));
asleep++;
continue;
}
+2 -2
View File
@@ -5,7 +5,7 @@
import { appendFileSync, existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { randomInt } from "node:crypto";
import { statsDir } from "./store";
import { dataRoot } from "./store";
export interface Share {
id: string;
@@ -20,7 +20,7 @@ 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");
return join(dataRoot(), "shares.jsonl");
}
export function loadShares(): void {
+2 -2
View File
@@ -6,7 +6,7 @@
import { readFileSync, renameSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { statsDir } from "./store";
import { dataRoot } from "./store";
import type { Room } from "./rooms";
export interface EngagementStats {
@@ -35,7 +35,7 @@ interface StatsFile extends Omit<EngagementStats, "wizardsSeated"> {
roomStages: Record<string, "created" | "started" | "finished">;
}
const FILE = () => join(statsDir(), "stats.json");
const FILE = () => join(dataRoot(), "stats.json");
let data: StatsFile = {
gamesCreated: 0, gamesStarted: 0, gamesFinished: 0,
+9 -7
View File
@@ -86,8 +86,10 @@ function fileFor(roomId: string): string {
return join(DATA_DIR, `${roomId}.jsonl`);
}
/** Directory holding stats.json — the parent of the rooms dir. */
export function statsDir(): string {
/** The data root the parent of the rooms dir where everything that
* is not a room ledger lives: stats, feedback, stubs, the graveyard, the
* clip vault. */
export function dataRoot(): string {
return join(DATA_DIR, "..");
}
@@ -149,7 +151,7 @@ export function ledgerStat(roomId: string): { bytes: number; mtimeMs: number } |
// ledger only ever grows, so at boot a stub whose size still matches is
// the room's whole truth and the room stays asleep, unreplayed.
const stubDir = () => join(DATA_DIR, "..", "stubs");
const stubDir = () => join(dataRoot(), "stubs");
function stubFor(roomId: string): string {
if (!safeRoomId(roomId)) throw new Error(`unsafe room id: ${JSON.stringify(roomId)}`);
return join(stubDir(), `${roomId}.json`);
@@ -168,7 +170,7 @@ export function readStubFile<T>(roomId: string): { bytes: number; stub: T } | nu
}
}
export function writeStubFile(roomId: string, bytes: number, stub: unknown): void {
export function writeStubFile<T>(roomId: string, bytes: number, stub: T): void {
const file = stubFor(roomId);
mkdirSync(stubDir(), { recursive: true });
writeFileSync(file + ".tmp", JSON.stringify({ bytes, stub }), "utf8");
@@ -182,7 +184,7 @@ export function writeStubFile(roomId: string, bytes: number, stub: unknown): voi
* timestamp. A report line may carry fields (deckRev, player context)
* that only the operator's raw read uses; readFeedback keeps the
* player-facing subset. */
const feedbackFile = () => join(DATA_DIR, "..", "feedback.jsonl");
const feedbackFile = () => join(dataRoot(), "feedback.jsonl");
export function appendFeedback(entry: Record<string, unknown>): void {
ensureDataDir();
@@ -239,7 +241,7 @@ export function readFeedback(): FeedbackReport[] {
export function archiveRoomFile(roomId: string): void {
const src = fileFor(roomId);
if (!existsSync(src)) return;
const graveyard = join(DATA_DIR, "..", "rooms-abandoned");
const graveyard = join(dataRoot(), "rooms-abandoned");
mkdirSync(graveyard, { recursive: true });
renameSync(src, join(graveyard, `${roomId}.${Date.now()}.jsonl`));
}
@@ -260,7 +262,7 @@ export interface ClipMeta {
height?: number;
}
const clipsDir = () => join(DATA_DIR, "..", "clips");
const clipsDir = () => join(dataRoot(), "clips");
/** A clip's name, which is also its file stem: no separators, no dots,
* so a name can never name a path. */
+174 -66
View File
@@ -1,5 +1,7 @@
<script lang="ts">
import { attentionLabel, net, spellName } from "./net.svelte";
import { motion } from "./motion";
import { attentionLabel, net, SIDE_NAMES, spellName } from "./net.svelte";
import { MediaQuery } from "svelte/reactivity";
import { FISTS } from "./fpv/paintedFx";
import Board from "./Board.svelte";
import LiveFirstPerson from "./LiveFirstPerson.svelte";
@@ -15,7 +17,7 @@
import { prefs, savePrefs } from "./prefs.svelte";
import { CREATURE_ART, objectArt, TERRAIN_ART, tokenArt } from "./art";
import { local } from "./local.svelte";
import { allCardDefs, cardDef, dreadDistance, isNumberCard, isPermanentDuration, SIDES, stepTarget, cellKey, edgeKey, isMovableObject, sightedCellsFor, bentSightedCellsFor, stackSightTrace, type GameView, eligibleCellsFor } from "@wizwar/engine";
import { allCardDefs, cardDef, dreadDistance, isNumberCard, isPermanentDuration, opposite, SIDES, stepTarget, cellKey, edgeKey, isMovableObject, sightedCellsFor, bentSightedCellsFor, stackSightTrace, type GameView, eligibleCellsFor } from "@wizwar/engine";
import type { CardInstance, GameEvent, Side } from "@wizwar/engine";
net.connect();
@@ -205,7 +207,7 @@
}
}
if (view) fpBatch = { n: (fpBatch?.n ?? 0) + 1, events, view };
if (!prefs.flourishes) return;
if (!motion.effects) return;
fxCancels.push(scheduleFx(
events, view,
(fx) => (boardFx = [...boardFx, fx]),
@@ -222,6 +224,12 @@
fxCancels = [];
};
});
$effect(() => {
if (motion.effects) return;
fxCancels.forEach((cancel) => cancel());
fxCancels = [];
boardFx = [];
});
const openingRolls = $derived(net.openingRolls ?? local.openingRolls);
function dismissRolls() {
net.dismissRolls();
@@ -298,7 +306,6 @@
let rotateCW = $state(true);
/** pick-lock / master-key: prop the door for others once it opens. */
let holdDoor = $state(false);
/** mega-monster: which stat the chosen monster doubles. */
/** MEGA-MONSTER aimed at a monster: which of its two numbers to double. */
let megaPrompt = $state<{ instanceId: string; creatureId: string } | null>(null);
function castMega(boost: "life" | "movement") {
@@ -338,8 +345,9 @@
);
const youMustDiscard = $derived(view != null && view.pendingDiscard === view.you);
const youMustShield = $derived(view?.chaosPending?.queue[0] === view?.you && view != null);
/** Picking up an object ends the turn's actions: the hand goes quiet until
* the draw — but never while a counter, shield, or discard is owed. */
/** Picking up an object or sliding into slime ends the turn's actions: the
* hand goes quiet until the draw — but never while a counter, shield, or
* discard is owed. */
const actionsSpent = $derived(
view != null && isYourTurn && view.turn.actionsEnded &&
!youMustRespond && !youMustShield && !youMustDiscard,
@@ -458,9 +466,15 @@
* the spell being built (or answered), controls and all — table talk
* returns the moment the cast resolves. Phones keep the under-board
* strips; a rail below the fold is no place to aim from. */
const wideQuery = typeof matchMedia === "undefined" ? null : matchMedia("(min-width: 901px)");
let wideScreen = $state(wideQuery?.matches ?? true);
wideQuery?.addEventListener("change", (e) => (wideScreen = e.matches));
const wideQuery = new MediaQuery("(min-width: 901px)", true);
const wideScreen = $derived(wideQuery.current);
/** A wide, short screen — a laptop in landscape — stands the hand beside
* the board unless the player has said otherwise: a tall maze under a
* bottom hand leaves a postage stamp, and a first turn deserves better.
* (The narrower "short window" rule in the stylesheet only shrinks the
* cards; this one needs room for a card column, hence the wider floor.) */
const shortWideQuery = new MediaQuery("(min-width: 1180px) and (max-height: 820px)", false);
const handLeftOn = $derived(prefs.handLeftChosen ? prefs.handLeft : shortWideQuery.current);
const castPanelOn = $derived(wideScreen && !local.active &&
(selectedCard != null || (youMustRespond && !!view?.stack)));
@@ -490,7 +504,7 @@
* width, capped so the full hand still fits without a scrollbar. */
let handEl = $state<HTMLDivElement | null>(null);
function fitHandCards(el: HTMLDivElement) {
if (!prefs.handLeft) { el.style.removeProperty("--card-zoom"); return; }
if (!handLeftOn) { el.style.removeProperty("--card-zoom"); return; }
// Card.svelte is the source of truth for the card's box: 8.2rem wide,
// 11.4rem tall. 5rem covers the masthead and the column's padding.
const rem = 16;
@@ -511,7 +525,7 @@
});
$effect(() => {
void view?.yourHand.length;
void prefs.handLeft;
void handLeftOn;
const el = handEl;
if (el) requestAnimationFrame(() => fitHandCards(el));
});
@@ -574,10 +588,17 @@
}
}
/** Attachment plumbing for a cast command: the chosen mods and the
* attached NUMBER ride along (an explicit numberInstanceIds wins). */
/** Your ALTER EGO, when it is the selected token: casts leave from its square. */
const castingDouble = $derived(
selectedCreature && view
? view.creatures.find((c) => c.id === selectedCreature && c.kind === "alter-ego" && c.controllerId === view!.you) ?? null
: null,
);
/** Attachment plumbing for a cast command: the chosen mods, the attached
* NUMBER (an explicit numberInstanceIds wins), and the double it casts through. */
function withMods<T extends Parameters<typeof net.command>[0] & { type: "cast" }>(cmd: T): T {
applyMods(cmd);
if (castingDouble && cmd.target) cmd.via = castingDouble.id;
if (attachedNumber && !cmd.numberInstanceIds) {
cmd.numberInstanceIds = [attachedNumber.instanceId];
}
@@ -648,7 +669,7 @@
discardSelection = next;
return;
}
if (actionsSpent) return; // picking up an object ended the turn's actions
if (actionsSpent) return;
if (!isYourTurn && !yourMoment) {
// Live interruption: Interrupt / Opportunity Fire may be played during
// another player's turn (when no attack is pending).
@@ -924,8 +945,7 @@
// A card in hand is aimed, not walked with: a tap on a bare square
// while holding one is a miss, never a stride into whatever is there.
if (selectedCard) {
net.error = `${cardDef(selectedCard.cardId).name} is aimed by tapping a wizard, a creature, or a slime — put the card down to walk`;
setTimeout(() => { if (net.error?.startsWith(cardDef(selectedCard!.cardId).name)) net.error = null; }, 5000);
net.flash(`${cardDef(selectedCard.cardId).name} is aimed by tapping a wizard, a creature, or a slime — put the card down to walk`);
return;
}
// A cell click is a move if the cell is one legal step away (the server
@@ -960,7 +980,7 @@
y: me.position.y + (side === "S" ? 1 : side === "N" ? -1 : 0) };
if (view.squareContents[cellKey(pit)]?.kind !== "pit") continue;
for (const out of SIDES) {
if (out === (side === "N" ? "S" : side === "S" ? "N" : side === "E" ? "W" : "E")) continue;
if (out === opposite(side)) continue;
const foot = stepTarget(view.board, pit, out);
if (foot.kind !== "blocked" && cellKey(foot.to) === cellKey(cell)) { tryMove(side, undefined, out); return; }
}
@@ -1136,7 +1156,7 @@
function clickPlayer(playerId: string) {
if (!view || !yourMoment) return;
if (selectedCreature) {
if (selectedCreature && !(castingDouble && selectedCard)) {
dispatch({ type: "creatureAttack", creatureId: selectedCreature, targetId: playerId });
selectedCreature = null;
return;
@@ -1485,6 +1505,11 @@
function pass() { dispatch({ type: "pass" }); }
const me = $derived(view?.players.find((p) => p.id === view?.you) ?? null);
/** Enemy treasures resting on your home square: two wins the game. */
const treasuresHome = $derived(
view && me ? view.treasures.filter((t) => t.owner !== me!.id && t.position &&
t.position.x === me!.home.x && t.position.y === me!.home.y).length : 0,
);
const paBurnable = $derived(me ? Array.from({ length: Math.max(0, me.life - 1) }, (_, i) => i + 1) : []);
/** Two treasures can share a square: one dispatches, more ask whose. */
const treasuresHere = $derived(
@@ -1507,10 +1532,10 @@
// answer to "how can he even see me?" when sight ran through a warp mouth.
const sightTrace = $derived(view ? stackSightTrace(view) : null);
/** The step light: with no card in hand, your square and the squares a
* stride reaches are lit and the rest of the maze falls into shadow. */
/** A first game's first turn lights itself; after that only by choice. */
const firstTurnEver = $derived(!!view && prefs.finishedGames === 0 && view.turn.round === 1);
/** The step light: with no card in hand, your square and the squares a
* stride reaches are lit and the rest of the maze falls into shadow. */
const stepLight = $derived(
!!view && !!me && (prefs.stepLightAlways || firstTurnEver) && isYourTurn && !view.turn.actionsEnded && !selectedCard &&
!net.spectating && !youMustRespond && view.turn.movementAllowance - view.turn.movementUsed > 0,
@@ -1521,12 +1546,9 @@
.filter((t) => t.kind !== "blocked")
.map((t) => cellKey(t.to));
});
/** The first stride of a first game gets one line on the board itself. */
const stepCaption = $derived(
stepLight && view && me && view.turn.round === 1 && view.turn.movementUsed === 0
? { cell: me.position, text: "This is you — tap a lit square to walk" }
: null,
);
/** The first stride of a first game gets one line above the board
* off the maze, so the walls around the wizard stay in plain sight. */
const stepCaption = $derived(stepLight && view && me && view.turn.round === 1 && view.turn.movementUsed === 0);
const litCells = $derived.by(() => {
if (view && me && !selectedDef && stepLight) return new Set([cellKey(me.position), ...stepCells]);
if (!view || !selectedDef || !yourMoment) return null;
@@ -1534,10 +1556,10 @@
if (!me) return null;
const bent = attachedMods.some((m) => m.cardId === "around-the-corner");
if (CELL_CARDS.has(selectedCard!.cardId)) {
return eligibleCellsFor(view, selectedCard!.cardId, bent);
return eligibleCellsFor(view, selectedCard!.cardId, bent, castingDouble?.position);
}
if (selectedDef.adjacent === true) {
const { x, y } = me.position;
const { x, y } = castingDouble?.position ?? me.position;
return new Set(
[[x, y], [x + 1, y], [x - 1, y], [x, y + 1], [x, y - 1]]
.map(([cx, cy]) => `${cx},${cy}`)
@@ -1545,7 +1567,7 @@
);
}
if (selectedDef.los !== true) return null;
return bent ? bentSightedCellsFor(view) : sightedCellsFor(view);
return bent ? bentSightedCellsFor(view, castingDouble?.position) : sightedCellsFor(view, castingDouble?.position);
});
/** Creatures awaiting your orders this turn (yours + any democratic monster). */
@@ -1628,7 +1650,7 @@
<span class="mast-sub">{fpvWorkshop ? "the eyes workshop" : tokenWorkshop ? "the token workshop" : "the flourish workshop"}</span>
<a class="mast-leave" href="/">← back to the table</a>
<a class="mast-leave" href="/clips">the clips</a>
<a class="mast-leave mast-help-solo" href="/?tokens">tokens</a>
<a class="mast-leave mast-right" href="/?tokens">tokens</a>
<a class="mast-leave" href="/?fx">flourishes</a>
<a class="mast-leave" href="/?fpv">eyes</a>
</header>
@@ -1653,7 +1675,7 @@
{:else if net.roomId}
<button class="mast-room" title="copy the invite link — friends can join, or watch once it's begun"
onclick={copyInvite}>
{net.spectating ? "watching" : "room"} <b>{net.roomId}</b>{inviteCopied ? " — link copied!" : ""}
{#if net.spectating}watching{:else}<span class="mast-invite">invite friends · </span>room{/if} <b>{net.roomId}</b>{inviteCopied ? " — link copied!" : ""}
</button>
{#if net.audience > 0}
<span class="mast-audience" title="the Peanut Gallery">👁 {net.audience}</span>
@@ -1790,7 +1812,7 @@
<div class="attack-actions">
{#each view.pushPending.exits as d (d)}
<button class="stamp primary" onclick={() => dispatch({ type: "pushChoice", direction: d })}>
{d === "N" ? "North" : d === "S" ? "South" : d === "E" ? "East" : "West"}
{SIDE_NAMES[d]}
</button>
{/each}
</div>
@@ -1877,7 +1899,7 @@
<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>
<span>Spell flourishes (the animated effects — off as well whenever your system asks for reduced motion)</span>
</label>
<label class="pref-row pref-check">
<input type="checkbox" checked={prefs.liveFp}
@@ -1890,9 +1912,9 @@
<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.handLeft}
onchange={(e) => setPref("handLeft", e.currentTarget.checked)} />
<span>Hand at the left: your cards stand in a column beside the board (wide screens)</span>
<input type="checkbox" checked={handLeftOn}
onchange={(e) => { setPref("handLeft", e.currentTarget.checked); setPref("handLeftChosen", true); }} />
<span>Hand at the left: your cards stand in a column beside the board (wide screens; a wide, short window does this on its own until you choose)</span>
</label>
<label class="pref-row pref-check">
<input type="checkbox" checked={prefs.stepLightAlways}
@@ -2225,7 +2247,9 @@
<section class="boxlid">
<div class="boxlid-inner">
<div class="boxlid-title">Wiz-War</div>
<div class="boxlid-tag">A game of magical combat in a stone labyrinth</div>
<div class="boxlid-pitch">Steal treasure. Blast through walls. Outwit your friends.</div>
<div class="boxlid-tag">A turn-based wizard board game for two to six, free in your browser, with
friends or clockwork opponents. Carry home two treasures that aren't yours, or be the last wizard standing.</div>
<button class="about-link" onclick={() => { helpTab = "about"; showHelp = true; }}>
about this game — a labor of love
</button>
@@ -2368,12 +2392,29 @@
{/each}
</div>
{/if}
{#if net.spectating}
{#if net.players.length < 6}
<!-- A friend's link lands here: the seat is the invitation, the gallery the afterthought. -->
<div class="lobby-head">Join {net.hostId ?? "the"}'s game</div>
<div class="boxlid-tag">Choose your wizard's name and take a seat; the boards flip when the host starts.</div>
<div class="gallery-join">
<input bind:value={name} maxlength="20" placeholder="e.g. Mordecai" aria-label="your wizard's name" />
<button class="stamp primary" disabled={!name.trim()}
onclick={() => { setPref("wizardName", name.trim()); net.join(net.roomId!, name.trim()); }}>
Take a seat
</button>
</div>
<p class="waiting">👁 Just watching? You're in the Peanut Gallery until you sit.</p>
{:else}
<p class="waiting">👁 The table is full — you watch from the Peanut Gallery. Waiting for the boards to flip…</p>
{/if}
{/if}
<div class="lobby-head">Fill the table</div>
<div class="boxlid-tag">Two to six wizards enter the maze — friends by code or link,
computer wizards seated below, or a mix of both.</div>
<div class="invite-row">
<code class="invite-link">{inviteLink}</code>
<button class="stamp tiny" onclick={copyInvite}>{inviteCopied ? "copied!" : "copy link"}</button>
<button class="stamp tiny" onclick={copyInvite}>{inviteCopied ? "copied!" : "copy invite link"}</button>
</div>
<ul class="roster">
{#each net.players as p (p)}
@@ -2392,18 +2433,7 @@
</li>
{/each}
</ul>
{#if net.spectating}
<p class="waiting">👁 You watch from the Peanut Gallery. Waiting for the boards to flip…</p>
{#if net.players.length < 6}
<div class="gallery-join">
<input bind:value={name} maxlength="20" placeholder="e.g. Mordecai" aria-label="your wizard's name" />
<button class="stamp" disabled={!name.trim()}
onclick={() => { setPref("wizardName", name.trim()); net.join(net.roomId!, name.trim()); }}>
…or take a seat
</button>
</div>
{/if}
{:else if net.you === net.hostId}
{#if net.you === net.hostId}
{#if net.players.length < 6}
<div class="lobby-head">Seat a computer wizard</div>
<div class="boxlid-tag">An automaton plays a full seat by the rules — a hunter goes for treasure,
@@ -2445,15 +2475,19 @@
</div>
</section>
{:else if view}
<div class="game" class:hand-left={prefs.handLeft}>
<div class="game" class:hand-left={handLeftOn}>
{#snippet tableGuidance()}
{#if youMustRespond || selectedDef || selectedCreature || youMustDiscard || discardMode || discardSelection.size > 0}
<div class="hint-strip">
{#if selectedCreature && !selectedDef}
{@const sc = view.creatures.find((c) => c.id === selectedCreature)}
{#if sc}
{#if sc.kind === "alter-ego"}
<span>Your <strong>double</strong> stands ready — select a card and it casts from the double's square, by the double's sight.</span>
{:else}
<span>Commanding <strong>{cardDef(sc.kind).name}</strong>{creatureStats(sc)}.
Tap a square beside it to march, a target in its square to attack.</span>
{/if}
{#if dmWarnCell}
<span class="hint-alert">⚠ it will claw YOU the moment it enters your square — tap again if you mean it</span>
{/if}
@@ -2638,6 +2672,20 @@
{/if}
{/snippet}
<section class="board-zone">
{#if view.you && !net.spectating}
<div class="view-toggle">
<button class="stamp tiny" class:lit={prefs.liveFp} onclick={() => setPref("liveFp", !prefs.liveFp)}
title="a first-person pane above the board, through your wizard's eyes">
{prefs.liveFp ? "▦ the board only" : "👁 through your eyes"}
</button>
</div>
{/if}
{#if stepCaption && me}
<div class="step-caption">
<span class="step-chip" style:background={playerColor(me.id)}></span>
This is you — tap a lit square beside your wizard to walk
</div>
{/if}
<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)}
@@ -2677,7 +2725,6 @@
onEdgePeek={(tip) => (peekEdge = tip)}
{litCells}
spotCells={stepCells}
spotCaption={stepCaption}
{sightTrace}
povFacing={prefs.liveFp && view.you && !net.spectating ? fpvFacing : null}
povCreatureId={prefs.liveFp && !net.spectating ? fpvBody : null}
@@ -2755,13 +2802,13 @@
{/if}
{#if view.turn.actionsEnded}
{#if me?.carriedTreasureId}
picking up ended your actions for this turn, and the treasure comes with you —
your actions for this turn are over, and the treasure comes with you —
<strong>End turn</strong> now, then carry it home to score.
{:else}
picking up ended your actions for this turn — <strong>End turn</strong> when ready.
your actions for this turn are over <strong>End turn</strong> when ready.
{/if}
{:else}
{view.turn.movementAllowance - view.turn.movementUsed} moves left{view.turn.attackUsed ? " · attack spent" : ""}
{view.turn.movementAllowance - view.turn.movementUsed} moves left{view.turn.attackUsed ? " · attack spent" : ""} · treasures home {treasuresHome} of 2
{/if}
</div>
{#if !prefs.coached && !view.turn.actionsEnded}
@@ -2770,7 +2817,8 @@
<div class="slip coach">
<strong>How a turn works.</strong> Walk up to {view.turn.movementAllowance} squares by tapping
the square beside your wizard, one step at a time; play a NUMBER card first to walk farther.
Tap a card to cast it. When you're done, <strong>End turn</strong>. Stuck? Ask the automatons for hints.
Select a card, then choose its target — a wizard, a square, or a wall, as the hint bar says.
When you're done, <strong>End turn</strong>. Stuck? Ask the automatons for hints.
<button class="stamp tiny" onclick={() => setPref("coached", true)}>got it</button>
</div>
{/if}
@@ -2825,6 +2873,9 @@
<div class="council-head">The council considers your position…
<button class="hint-cancel" onclick={() => (councilHints = null)}>enough</button>
</div>
{#if councilHints.length > 1 && councilHints.every((h) => h.advice === councilHints![0]!.advice)}
<div class="council-row"><span class="council-name">The council agrees:</span> {councilHints[0]!.advice}</div>
{:else}
{#each councilHints as h (h.name)}
<div class="council-row">
<span class="council-name">{h.glyph} {h.name}</span>
@@ -2832,6 +2883,7 @@
{h.advice}
</div>
{/each}
{/if}
<button class="stamp tiny" onclick={askCouncil}>ask again</button>
</div>
{/if}
@@ -3319,7 +3371,7 @@
.workshop-mast { margin: 0 1rem 0; }
.mast-home { text-decoration: none; }
.mast-home:hover { color: #fff; }
.mast-help-solo { margin-left: auto; }
.mast-help-solo, .mast-right { margin-left: auto; }
.hotseat-row { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; justify-content: center; margin-top: 1.3rem; }
.hotseat-exp { margin-top: 0.4rem; margin-bottom: 0; font-size: 0.85rem; }
@@ -3438,6 +3490,39 @@
letter-spacing: 0.02em;
}
.boxlid-title.small { font-size: 1.7rem; }
.boxlid-pitch {
font-family: "Oswald", sans-serif;
font-weight: 700;
font-size: 1.15rem;
letter-spacing: 0.02em;
color: #3a2f1f;
margin: 0.2rem 0 0.1rem;
}
/* The eyes toggle floats over the board's corner so it costs the maze no height. */
.view-toggle { position: absolute; top: 0.25rem; right: 0.25rem; z-index: 3; }
.step-caption {
align-self: flex-start;
max-width: calc(100% - 11rem);
margin: 0 0 0.4rem 0.4rem;
padding: 0.3rem 0.8rem;
border-radius: 999px;
background: rgba(28, 22, 12, 0.92);
border: 1.5px solid #f2cf5b;
color: #f7ecc8;
font-family: "Oswald", sans-serif;
font-size: 0.85rem;
letter-spacing: 0.02em;
display: flex;
align-items: center;
gap: 0.45rem;
}
.step-chip {
display: inline-block;
width: 0.8em;
height: 0.8em;
border-radius: 2px;
border: 1.5px solid #f7ecc8;
}
.boxlid-tag {
font-size: 0.95rem;
color: #6b5a41;
@@ -3729,15 +3814,33 @@
flex: 1;
min-height: 0;
}
.board-zone { display: flex; align-items: flex-start; justify-content: center; min-width: 0; }
.board-zone { position: relative; display: flex; flex-direction: column; align-items: stretch; min-width: 0; }
/* Hand at the left: the cards stand in their own column beside the
* board while the hint strips and action buttons stay beneath it —
* the strip dissolves into the grid so its children place themselves.
* Phones keep the bottom hand; the one-column flow re-orders anyway. */
@media (min-width: 901px) {
/* Bottom hand: the table fills the window — board and rail on top,
* the rail scrolling in place, the hand and its buttons pinned in
* view beneath. The board takes whatever height the row allows. */
.game {
flex: 0 0 auto;
height: calc(100dvh - 5.3rem);
grid-template-rows: minmax(0, 1fr) auto;
}
.paper-rail { overflow-y: auto; min-height: 0; padding-right: 2px; }
.board-zone { min-height: 0; }
.table-stack { flex: 1 1 0; min-height: 0; }
.board-frame { flex: 1 1 0; min-height: 0; display: flex; flex-direction: column; }
.board-viewport { height: auto; flex: 1 1 0; min-height: 0; }
.fpv-primary .board-viewport { flex: 0 0 30dvh; }
.game:not(.hand-left) .table-edge { margin-top: 0.5rem; }
.game.hand-left {
grid-template-columns: minmax(17.4rem, 24rem) minmax(340px, 1fr) minmax(250px, 330px);
grid-template-rows: minmax(0, 1fr) repeat(5, auto);
row-gap: 0.4rem;
}
.game.hand-left .paper-rail { grid-row: 1 / span 6; }
.game.hand-left .hand :global(.card) { zoom: var(--card-zoom, 1); }
.game.hand-left .table-edge { display: contents; }
.game.hand-left .table-edge > * { grid-column: 2; min-width: 0; }
@@ -3751,17 +3854,12 @@
justify-content: center;
align-content: start;
align-self: start;
position: sticky;
top: 0.5rem;
max-height: calc(100dvh - 5rem);
max-height: 100%;
overflow-y: auto;
padding: 0.3rem 0.3rem 0.9rem;
}
.game.hand-left .board-zone { grid-column: 2; grid-row: 1; }
.game.hand-left .paper-rail { grid-column: 3; grid-row: 1; }
/* With the hand out of the bottom strip, the map claims the height. */
.game.hand-left .board-viewport { height: calc(100dvh - 11rem); }
.game.hand-left .fpv-primary .board-viewport { height: 30dvh; }
.game.hand-left .paper-rail { grid-column: 3; }
}
/* 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
@@ -3879,7 +3977,6 @@
padding-left: 0.3rem;
border-radius: 2px;
}
.score-row.active .score-name { font-weight: 700; }
.score-temper {
font-family: "Caveat", cursive;
font-size: 0.85rem;
@@ -4034,6 +4131,12 @@
gap: 0.8rem;
max-width: min(22rem, 92vw);
text-align: center;
/* A notice taller than the screen — preferences on a phone — scrolls
* inside its frame rather than running off the bottom uncatchable. */
max-height: calc(100dvh - 2rem);
overflow-y: auto;
-webkit-overflow-scrolling: touch;
box-sizing: border-box;
}
.attack-headline {
font-family: "Oswald", sans-serif;
@@ -4321,6 +4424,10 @@
align-items: flex-end;
}
/* A short desktop window: smaller cards under the board so the maze keeps its height. */
@media (min-width: 901px) and (max-height: 820px) {
.game:not(.hand-left) .hand :global(.card) { zoom: 0.78; }
}
@media (max-width: 900px) {
/* One-column phone flow: board, then your controls and hand, THEN the
paperwork — cards must never hide below the chronicle. */
@@ -4342,6 +4449,7 @@
.hotseat-row .stamp, .claim-row .stamp { flex: 0 0 auto; }
.mast-title { font-size: 1.05rem; white-space: nowrap; }
.mast-sub { display: none; }
.mast-invite { display: none; }
.mast-leave { font-size: 0.72rem; }
}
</style>
+5 -33
View File
@@ -35,7 +35,6 @@
markedCells = null,
litCells = null,
spotCells = [],
spotCaption = null,
markedSector = null,
ghostSlots = null,
onGhostClick,
@@ -70,8 +69,6 @@
litCells?: Set<string> | null;
/** The step light's squares: a pulsing rim on each. */
spotCells?: string[];
/** One line hung above a square — the first stride's cue. */
spotCaption?: { cell: { x: number; y: number }; text: string } | null;
/** Origin of a picked-up 5x5 sector: the whole sector outlines as taken. */
markedSector?: { x: number; y: number } | null;
/** Empty 5x5 slot origins a sector may land on — drawn as dashed ground
@@ -464,15 +461,11 @@
{@const wx = Number(key.split(":")[1]?.split(",")[0])}
{@const wy = Number(key.split(":")[1]?.split(",")[1])}
{@const frac = Math.min(1, dmg / 20)}
{#if kind === "V"}
<path d={edgeScar((wx+1)*CELL, wy*CELL+3, (wx+1)*CELL, (wy+1)*CELL-3, frac)} class="crack-shadow" style:opacity={0.35 + frac * 0.65} />
<path d={edgeScar((wx+1)*CELL, wy*CELL+3, (wx+1)*CELL, (wy+1)*CELL-3, frac)}
class="crack" style:opacity={0.35 + frac * 0.65}><title>battle-scarred — {dmg} damage taken</title></path>
{:else}
<path d={edgeScar(wx*CELL+3, (wy+1)*CELL, (wx+1)*CELL-3, (wy+1)*CELL, frac)} class="crack-shadow" style:opacity={0.35 + frac * 0.65} />
<path d={edgeScar(wx*CELL+3, (wy+1)*CELL, (wx+1)*CELL-3, (wy+1)*CELL, frac)}
class="crack" style:opacity={0.35 + frac * 0.65}><title>battle-scarred — {dmg} damage taken</title></path>
{/if}
{@const scar = kind === "V"
? edgeScar((wx + 1) * CELL, wy * CELL + 3, (wx + 1) * CELL, (wy + 1) * CELL - 3, frac)
: edgeScar(wx * CELL + 3, (wy + 1) * CELL, (wx + 1) * CELL - 3, (wy + 1) * CELL, frac)}
<path d={scar} class="crack-shadow" style:opacity={0.35 + frac * 0.65} />
<path d={scar} class="crack" style:opacity={0.35 + frac * 0.65}><title>battle-scarred — {dmg} damage taken</title></path>
{/each}
<!-- illusions YOU know are fake: ghostly dashed lines -->
@@ -697,17 +690,6 @@
{@const sy = Number(key.split(",")[1])}
<rect x={sx * CELL + 3} y={sy * CELL + 3} width={CELL - 6} height={CELL - 6} class="spot-cell" rx="5" />
{/each}
{#if spotCaption}
{@const cols = Math.max(...Object.keys(view.board.cells).map((k) => Number(k.split(",")[0]))) + 1}
{@const cw = spotCaption.text.length * 6.6 + 20}
{@const cx = Math.min(Math.max(spotCaption.cell.x * CELL + CELL / 2, cw / 2 + 4), cols * CELL - cw / 2 - 4)}
{@const above = spotCaption.cell.y > 0}
{@const cy = above ? spotCaption.cell.y * CELL - 14 : (spotCaption.cell.y + 1) * CELL + 14}
<g class="spot-caption" transform={`translate(${cx}, ${cy})`}>
<rect x={-cw / 2} y={-12} width={cw} height={24} rx="12" />
<text x="0" y="4">{spotCaption.text}</text>
</g>
{/if}
<!-- FEAR's bubble: the three-space diamond, through walls, wrapping the rim -->
{#each fearAura as key (key)}
{@const bx = Number(key.split(",")[0])}
@@ -865,15 +847,6 @@
0%, 100% { opacity: 0.35; }
50% { opacity: 1; }
}
.spot-caption { pointer-events: none; }
.spot-caption rect { fill: rgba(28, 22, 12, 0.92); stroke: #f2cf5b; stroke-width: 1.5; }
.spot-caption text {
fill: #f7ecc8;
font-size: 12px;
font-family: "Oswald", "Helvetica Neue", Arial, sans-serif;
letter-spacing: 0.02em;
text-anchor: middle;
}
.marked-cell {
fill: rgba(211, 133, 43, 0.18);
stroke: #d3852b;
@@ -933,7 +906,6 @@
stroke: rgba(240, 231, 209, 0.9);
stroke-width: 1.25;
stroke-linecap: round;
pointer-events: none;
}
.warp-dest {
fill: none;
+1 -1
View File
@@ -48,7 +48,7 @@
><title>{tip}</title></rect>
{/if}
<!-- Pigment and hardware sit over the original wall/door hit target. -->
<!-- stone highlights or wood grain and lock hardware; the rect beneath takes the taps -->
<g class="edge-detail" transform={`translate(${kind === "V" ? (x+1)*CELL : x*CELL+CELL/2} ${kind === "V" ? y*CELL+CELL/2 : (y+1)*CELL}) rotate(${kind === "V" ? 90 : 0})`} aria-hidden="true">
{#if state === "wall"}
<path d="M -23 -2.2 L -11 -2 -2 -2.3 11 -2 23 -2.2" class="stone-light" />
+1 -1
View File
@@ -43,7 +43,7 @@
fill="#bd3b1b" class="fw-bar"
role="img" onpointerdown={press} onpointerup={release} onpointerleave={release} />
{/if}
<!-- Drawn flame poses share the original bases and hit area. -->
<!-- tongues of flame, each on its own beat; the bar beneath is the hit target -->
{#each TONGUES as f (f.t)}
<g transform={`translate(${px(f.t)} ${py(f.t)})`} class="fw-art">
<g class="fw-tongue" style={`animation-delay: ${-f.d}s`}>
+2 -2
View File
@@ -223,8 +223,8 @@
is this table's reading. A slime shows how many it holds, and a
peek names them, since every cast into it was seen.</p>
<p>Clockwork wizards — the automatons — play from the same redacted
view a human seat gets and obey every rule; a tier changes what
they draw and know, never how correctly they play.</p>
view a human seat gets and play by the rules the engine enforces on
everyone; a tier changes what they draw and know.</p>
<p>A turn need not be taken at once. Games wait on the lobby ledger
for days; the browser holds your seat; a replay of any game can be
watched from above or through a wizard's eyes and shared by link.</p>
-1
View File
@@ -34,7 +34,6 @@
stroke: #9d8cc1;
stroke-width: 0.4;
stroke-linecap: round;
pointer-events: none;
animation: shimmer-drift 1.4s ease-in-out infinite;
}
.illusion-art { pointer-events: none; }
+24 -6
View File
@@ -7,6 +7,7 @@
// Every live event batch plays through the same fx pipeline the
// instant replay uses, so spells, doors, and conjurations perform
// here first.
import { motion } from "./motion";
import FirstPerson from "./fpv/FirstPerson.svelte";
import type { FpvTarget } from "./fpv/raycast";
import { objectArt, tokenArt } from "./art";
@@ -161,6 +162,8 @@
* still fire — your own magic always turns your head. */
let manualUntil = 0;
let turning = false;
let turnRaf = 0;
onDestroy(() => cancelAnimationFrame(turnRaf));
/** Manual steering holds the director's idle aims off this long. */
const STEER_HOLD_MS = 4000;
@@ -173,14 +176,15 @@
manualUntil = performance.now() + STEER_HOLD_MS;
const from = cam.facing;
const to = from + dir * (Math.PI / 2);
if (motion.reduced) { cam.facing = to; turning = false; return; }
const t0 = performance.now();
const tick = (now: number) => {
const w = Math.min(1, (now - t0) / 180);
cam.facing = from + (to - from) * (smoothstep(w));
if (w < 1) requestAnimationFrame(tick);
if (w < 1) turnRaf = requestAnimationFrame(tick);
else turning = false;
};
requestAnimationFrame(tick);
turnRaf = 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
@@ -225,14 +229,23 @@
let actorPos = $state<Record<string, { x: number; y: number }>>({});
// --- The fx: each batch plays once, on its own beat. -----------------
// Track ONLY the batch: the server sends `events` then `state` back to
// Track the batch and motion policy: the server sends `events` then `state` back to
// back, so a wider dependency set re-runs this within the same frame,
// before a zero-delay timer can fire. No per-run cleanup for the same
// reason — timers die only with the component.
// reason — timers are cleared when effects are disabled or the pane closes.
let playedBatch = 0;
const fxTimers = new Set<ReturnType<typeof setTimeout>>();
$effect(() => {
const enabled = motion.effects;
const b = batch;
if (!enabled) {
fxTimers.forEach(clearTimeout);
fxTimers.clear();
fpFx = [];
// Consume suppressed batches: enabling effects must not replay them.
if (b) playedBatch = b.n;
return;
}
if (!b) return;
untrack(() => {
if (b.n === playedBatch || !me) return;
@@ -240,6 +253,7 @@
for (const { fx, delay } of fpFxForEvents(b.events, b.view, povId)) {
const starter = setTimeout(() => {
fxTimers.delete(starter);
if (!motion.effects) return;
fpFx = [...fpFx, { ...fx, t0: performance.now() }];
const ender = setTimeout(() => {
fxTimers.delete(ender);
@@ -251,7 +265,7 @@
}
});
});
onDestroy(() => fxTimers.forEach(clearTimeout));
onDestroy(() => { fxTimers.forEach(clearTimeout); fxTimers.clear(); });
// --- Other bodies glide between views. -------------------------------
let prevView: GameView | null = null;
@@ -259,6 +273,7 @@
const v = view;
const before = prevView;
prevView = v;
if (motion.reduced) { actorPos = {}; return; }
if (!before || before === v || !me) return;
const moves = gatherGlides(before, v, povId);
if (moves.length === 0) { actorPos = {}; return; }
@@ -283,6 +298,7 @@
// --- The camera: the director's ladder, live. ------------------------
let directedBatch = 0;
$effect(() => {
const reduced = motion.reduced;
const b = batch;
const m = me;
if (!m) { camReady = false; return; }
@@ -385,7 +401,9 @@
// pointed your own head is where it stays, brick or no brick.
targetFacing = deepestFacing(v, tx, ty);
}
if (willCut) {
// Under reduced motion every move is a cut: the director still picks
// where to look, and the eye lands there without the pan or the walk.
if (willCut || reduced) {
cam.x = tx; cam.y = ty; cam.facing = targetFacing;
camReady = true;
return;
+18 -7
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import { motion } from "./motion";
import Board from "./Board.svelte";
import FirstPerson from "./fpv/FirstPerson.svelte";
import { untrack } from "svelte";
@@ -8,7 +9,6 @@
import { fpFxForEvents, smoothstep, type FpFx } from "./fpv/fx3d";
import { castRay, edgeMid } from "./fpv/raycast";
import { cutawayStand, aimOfEvents, gatherGlides, hurledIn, shortestArc, sightline } from "./fpv/director";
import { prefs } from "./prefs.svelte";
import { cardDef, isPermanentDuration, stackSightTrace } from "@wizwar/engine";
import type { GameEvent, GameView } from "@wizwar/engine";
@@ -53,7 +53,7 @@
let playing = $state(true);
let speed = $state(1);
/** Watch the board from above, or relive it through your own eyes. */
let fp = $state(moment);
let fp = $state(untrack(() => 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
@@ -90,12 +90,12 @@
const dieOf = (st: { events: GameEvent[] } | undefined) =>
st?.events.find((e): e is Extract<GameEvent, { type: "dieRolled" }> => e.type === "dieRolled");
/** How long this step's interlude holds the world before it moves. */
const dieMs = $derived(dieOf(step) && prefs.flourishes ? DIE_MS / speed : 0);
const dieMs = $derived(dieOf(step) && motion.effects ? DIE_MS / speed : 0);
let die = $state<{ idx: number; player: string; roll: number; purpose: string; face: number; landed: boolean } | null>(null);
$effect(() => {
const i = Math.min(idx, steps.length - 1);
const d = dieOf(steps[i]);
if (!d || !prefs.flourishes) { die = null; return; }
if (!d || !motion.effects) { die = null; return; }
const t0 = performance.now();
const dur = DIE_MS / speed;
const who = d.player ?? "The maze";
@@ -169,6 +169,7 @@
$effect(() => {
const who = povId;
if (!fp) { lastPov = null; return; }
if (!motion.effects) { lastPov = who; return; }
if (lastPov !== null && lastPov !== who) {
const p = step.view.players.find((x) => x.id === who);
const fx: FpFx = {
@@ -179,7 +180,7 @@
fpFx = [...untrack(() => fpFx), fx];
const t = setTimeout(() => (fpFx = fpFx.filter((f) => f.id !== fx.id)), fx.dur + 80);
lastPov = who;
return () => clearTimeout(t);
return () => { clearTimeout(t); fpFx = fpFx.filter((f) => f.id !== fx.id); };
}
lastPov = who;
});
@@ -210,7 +211,7 @@
let boardFx = $state<BoardFx[]>([]);
$effect(() => {
const step = steps[Math.min(idx, steps.length - 1)];
if (!step || !prefs.flourishes) return;
if (!step || !motion.effects) return;
let cancel = () => {};
const wait = setTimeout(() => {
cancel = scheduleFx(
@@ -237,7 +238,7 @@
$effect(() => {
const i = Math.min(idx, steps.length - 1);
const st = steps[i];
if (!fp || !st || !prefs.flourishes) return;
if (!fp || !st || !motion.effects) return;
const plan = camPlan;
if (plan?.idx !== i) return; // the camera has not set this step's slate yet
const timers: ReturnType<typeof setTimeout>[] = [];
@@ -280,6 +281,7 @@
const v = st.view;
const before = prevView;
prevView = v;
if (motion.reduced) { actorPos = {}; return; }
if (!before || before === v) return;
const moves = gatherGlides(before, v, povId);
if (moves.length === 0) { actorPos = {}; return; }
@@ -316,6 +318,7 @@
"treasurePickedUp", "treasureDropped", "objectDropped"]);
let camReady = false;
$effect(() => {
const reduced = motion.reduced;
if (!fp) { camReady = false; return; }
const v = step.view;
const me = v.players.find((p) => p.id === povId);
@@ -360,6 +363,7 @@
cutawayShot = true;
const i2 = Math.min(idx, steps.length - 1);
const pv = i2 > 0 ? steps[i2 - 1]!.view : null;
if (reduced) { heldView = null; camPlan = { idx: i2, holdMs: 0 }; return; }
heldView = pv;
camPlan = { idx: i2, holdMs: pv ? 420 / speed + dieMs : 0 };
if (pv) {
@@ -483,6 +487,13 @@
}
return () => {};
};
if (reduced) {
cam.x = tx; cam.y = ty; cam.facing = targetFacing + closingArc; cam.pitch = targetPitch;
camReady = true;
heldView = null;
camPlan = { idx: stepIdx, holdMs: 0 };
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
+5 -5
View File
@@ -9,23 +9,23 @@ export const edgeWisps = (x1: number, y1: number, x2: number, y2: number) => {
const p = (t: number, offset = 0) => along(x1, y1, x2, y2, t, offset);
return [0.06, 0.38, 0.71].map((t, i) => {
const sign = i === 1 ? -1 : 1;
return `M ${p(t)} C ${p(t+.07,-3*sign)} ${p(t+.2,-2.5*sign)} ${p(t+.23,.5*sign)} Q ${p(t+.18,-.6*sign)} ${p(t+.15,1.2*sign)} Q ${p(t+.12,-1.6*sign)} ${p(t)}`;
}).join(' ');
return `M ${p(t)} C ${p(t+0.07,-3*sign)} ${p(t+0.2,-2.5*sign)} ${p(t+0.23,0.5*sign)} Q ${p(t+0.18,-0.6*sign)} ${p(t+0.15,1.2*sign)} Q ${p(t+0.12,-1.6*sign)} ${p(t)}`;
}).join(" ");
};
/** An irregular fracture with branches that become longer as damage increases. */
export const edgeScar = (x1: number, y1: number, x2: number, y2: number, strength: number) => {
const width = 1.2 + Math.max(0, Math.min(1, strength)) * 1.4;
const p = (t: number, offset = 0) => along(x1, y1, x2, y2, t, offset);
return `M ${p(.03)} L ${p(.18,-width)} ${p(.34,.6*width)} ${p(.51,-.5*width)} ${p(.7,width)} ${p(.86,-.6*width)} ${p(.98)} M ${p(.34,.6*width)} L ${p(.4,1.25*width)} ${p(.47,1.4*width)} M ${p(.7,width)} L ${p(.64,-1.1*width)} ${p(.57,-1.35*width)}`;
return `M ${p(0.03)} L ${p(0.18,-width)} ${p(0.34,0.6*width)} ${p(0.51,-0.5*width)} ${p(0.7,width)} ${p(0.86,-0.6*width)} ${p(0.98)} M ${p(0.34,0.6*width)} L ${p(0.4,1.25*width)} ${p(0.47,1.4*width)} M ${p(0.7,width)} L ${p(0.64,-1.1*width)} ${p(0.57,-1.35*width)}`;
};
/** Silk spokes and two scalloped cross-threads, with a clear center for the token. */
export const webThreads = (r: number) => {
const p = (x: number, y: number) => `${x*r} ${y*r}`;
return `M ${p(-1,-.6)} L ${p(1,.65)} M ${p(-.75,1)} L ${p(.65,-1)} M ${p(-1,.6)} L ${p(1,-.4)} M ${p(-.15,-1)} L ${p(.18,1)} M ${p(-.75,-.45)} Q ${p(-.15,-.2)} ${p(.4,-.62)} Q ${p(.3,-.15)} ${p(.72,-.28)} M ${p(-.65,.39)} Q ${p(-.25,.1)} ${p(-.47,.63)} Q ${p(0,.4)} ${p(.12,.7)} Q ${p(.35,.35)} ${p(.72,.46)}`;
return `M ${p(-1,-0.6)} L ${p(1,0.65)} M ${p(-0.75,1)} L ${p(0.65,-1)} M ${p(-1,0.6)} L ${p(1,-0.4)} M ${p(-0.15,-1)} L ${p(0.18,1)} M ${p(-0.75,-0.45)} Q ${p(-0.15,-0.2)} ${p(0.4,-0.62)} Q ${p(0.3,-0.15)} ${p(0.72,-0.28)} M ${p(-0.65,0.39)} Q ${p(-0.25,0.1)} ${p(-0.47,0.63)} Q ${p(0,0.4)} ${p(0.12,0.7)} Q ${p(0.35,0.35)} ${p(0.72,0.46)}`;
};
/** Open painted curls around a warp's precise center. */
export const warpCurls = (x: number, y: number, r: number) =>
`M ${x-r} ${y+r*.15} C ${x-r*1.1} ${y-r*.75} ${x+r*.4} ${y-r*1.2} ${x+r*.72} ${y-r*.4} Q ${x+r*.05} ${y-r*.85} ${x-r*.58} ${y-r*.25} M ${x+r} ${y-r*.1} C ${x+r*.95} ${y+r*.8} ${x-r*.3} ${y+r*1.18} ${x-r*.7} ${y+r*.45} Q ${x-r*.1} ${y+r*.85} ${x+r*.6} ${y+r*.25}`;
`M ${x-r} ${y+r*0.15} C ${x-r*1.1} ${y-r*0.75} ${x+r*0.4} ${y-r*1.2} ${x+r*0.72} ${y-r*0.4} Q ${x+r*0.05} ${y-r*0.85} ${x-r*0.58} ${y-r*0.25} M ${x+r} ${y-r*0.1} C ${x+r*0.95} ${y+r*0.8} ${x-r*0.3} ${y+r*1.18} ${x-r*0.7} ${y+r*0.45} Q ${x-r*0.1} ${y+r*0.85} ${x+r*0.6} ${y+r*0.25}`;
+24 -25
View File
@@ -3,8 +3,9 @@
// 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 { motion } from "../motion";
import { billboards, castRay, warpMotion, type Billboard, type FpvTarget } from "./raycast";
import { paintFist, paintPow, punchPose, paintWeb, paintFloorRing } from "./paintedFx";
import { paintFist, paintPow, punchPose, paintWeb, paintFloorRing as floorRing } from "./paintedFx";
import { materialTextures } from "./textures";
import { doorOpenness, fxFallback, growProgress, slateBand, surgeIntensity, type FpFx } from "./fx3d";
import { terrainFallback, TERRAIN3D } from "./terrain3d";
@@ -197,6 +198,10 @@
}
function draw(time: number) {
// With effects off the frame still draws — walls, bodies, hazards —
// but nothing moves: no fx, and the ambient pulses freeze at t=0.
const activeFx = motion.effects ? fx : [];
const ambientTime = motion.effects ? time : 0;
const ctx = canvas?.getContext("2d");
if (!ctx) return;
ctx.imageSmoothingEnabled = false; // crisp texels, as the old masters drew
@@ -207,7 +212,7 @@
// Active shakes wobble the eye a hair off true, fading as they run.
let ex = x, ey = y;
for (const f of fx) {
for (const f of activeFx) {
if (f.kind !== "shake") continue;
const p = (time - f.t0) / f.dur;
if (p < 0 || p >= 1) continue;
@@ -221,7 +226,7 @@
let growing: Record<string, number> | undefined;
const spawn: Record<string, number> = {};
const surging: Record<number, number> = {};
for (const f of fx) {
for (const f of activeFx) {
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);
@@ -321,7 +326,7 @@
// FEAR's aura stains the forbidden ground itself, as the board draws
// its dashed diamond — the same warp-walking yardstick behind both.
const dreadGrid = dreadGridOf(view);
const dreadBlend = dreadGrid ? 0.16 + 0.07 * Math.sin(time / 400) : 0;
const dreadBlend = dreadGrid ? 0.16 + 0.07 * Math.sin(ambientTime / 400) : 0;
for (let row = 0; row < H; row++) {
const below = row > half;
const dz = below ? row - half : half - row;
@@ -440,7 +445,7 @@
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);
const flicker = 0.15 + 0.15 * Math.sin(ambientTime / 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
@@ -448,13 +453,13 @@
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);
const tell = 0.06 + 0.05 * Math.sin(ambientTime / 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);
const seethe = 0.10 + 0.08 * Math.sin(ambientTime / 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);
}
@@ -464,13 +469,13 @@
}
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);
const haze = 0.16 + 0.05 * Math.sin(ambientTime / 300 + col * 0.05);
ctx.fillStyle = `rgba(120,70,220,${haze})`;
ctx.fillRect(col, top, 1, wallH);
}
if (edgeSelect && !hit.frame && (hit.kind === "wall" || hit.kind === "door") && hit.edge) {
// The raised edge-card's invitation: faces breathe gold.
const offer = 0.10 + 0.05 * Math.sin(time / 420 + hit.worldU * 3);
const offer = 0.10 + 0.05 * Math.sin(ambientTime / 420 + hit.worldU * 3);
ctx.fillStyle = `rgba(201,167,42,${Math.max(0, offer)})`;
ctx.fillRect(col, top, 1, wallH);
}
@@ -487,7 +492,7 @@
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) {
for (const f of activeFx) {
if (f.kind !== "projectile" && f.kind !== "impact") continue;
const p = (time - f.t0) / f.dur;
if (p < 0 || p >= 1) continue;
@@ -545,13 +550,13 @@
const w = s.right - s.left;
if (seen && s.dread && s.hit) {
floorRing(ctx, (s.left + s.right) / 2, ringY, Math.max(9, w * 0.5), Math.max(3, w * 0.155),
"160,30,30", 0.45 + 0.25 * Math.sin(time / 300), 0.12);
"160,30,30", 0.45 + 0.25 * Math.sin(ambientTime / 300), 0.12);
}
if (seen && ontarget && aimBeings && litCells && s.hit && s.cell &&
litCells.has(`${s.cell.x},${s.cell.y}`) &&
!(s.hit.kind === "player" && s.hit.id === view.you)) {
floorRing(ctx, (s.left + s.right) / 2, ringY, Math.max(7, w * 0.42), Math.max(3, w * 0.13),
"232,160,60", 0.55 + 0.25 * Math.sin(time / 220), 0.16);
"232,160,60", 0.55 + 0.25 * Math.sin(ambientTime / 220), 0.16);
}
// Painted art composites normally (inks stay true); light glows
// additively. Either way translucency applies.
@@ -607,7 +612,7 @@
const vTop = half - vh / 2;
const wt = textures.warp!;
const s = surging[vl.warp] ?? 0;
const swirl = 0.1 + 0.1 * Math.sin(time / (240 - 170 * s) + vl.u * 9 + vl.col * 0.02);
const swirl = 0.1 + 0.1 * Math.sin(ambientTime / (240 - 170 * s) + vl.u * 9 + vl.col * 0.02);
// The ripple: a ring of light widening from the veil's center over
// the surge, one full sweep to the edges; ~a fifth of the veil wide.
const ring = s > 0 ? Math.max(0, 1 - Math.abs(Math.abs(vl.u - 0.5) * 2 - (1 - s)) / 0.2) : 0;
@@ -636,7 +641,7 @@
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));
(0.7 + 0.3 * Math.sin(ambientTime / 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),
@@ -674,7 +679,7 @@
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);
const ripple = 0.10 + 0.07 * Math.sin(ambientTime / 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),
@@ -695,7 +700,7 @@
ctx.fillRect(0, 0, W, H);
// Being hit is felt: a whole-screen wash that fades as it goes.
for (const f of fx) {
for (const f of activeFx) {
if (f.kind !== "flash") continue;
const p = (time - f.t0) / f.dur;
if (p < 0 || p >= 1) continue;
@@ -709,7 +714,7 @@
// as it goes, and snaps back; one thrown at you swells from the
// middle of the pane until it fills it. Either way the blow lands
// with a comic starburst.
for (const f of fx) {
for (const f of activeFx) {
if (f.kind !== "fist") continue;
const p = (time - f.t0) / f.dur;
if (p < 0 || p >= 1) continue;
@@ -720,7 +725,7 @@
ctx.rotate(pose.rotation);
paintFist(ctx, pose.size, f.swing);
ctx.restore();
if (pose.radius > H * 0.19 * 0.01) {
if (pose.radius > 0.5) {
ctx.save();
ctx.translate(pose.impactX, pose.impactY);
ctx.rotate(-0.14);
@@ -731,7 +736,7 @@
// A change of eyes: the slate wipes across, names the wizard whose
// sight this now is, and wipes away to reveal it.
for (const f of fx) {
for (const f of activeFx) {
if (f.kind !== "slate") continue;
const [l, r] = slateBand((time - f.t0) / f.dur);
if (r <= l) continue;
@@ -772,12 +777,6 @@
if ((ontarget || hover) && mouse) drawHover(ctx, W, H, half);
}
/** An ellipse of light on the floor at a body's feet: `rgb` as "r,g,b",
* the stroke at `alpha`, the fill fainter. */
function floorRing(ctx: CanvasRenderingContext2D, cx: number, cy: number, rx: number, ry: number,
rgb: string, alpha: number, fill: number) {
paintFloorRing(ctx, cx, cy, rx, ry, rgb, alpha, fill);
}
/** Paint the hover cue for whatever stands under the crosshair. */
function drawHover(ctx: CanvasRenderingContext2D, W: number, H: number, half: number) {
+8 -7
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import { motion } from "../motion";
import { smoothstep } from "./fx3d";
// The first-person workshop (/?fpv): a real dealt game, one wizard's
// eyes, free-fly controls. No rules run here — the camera walks where
@@ -145,7 +146,7 @@
const tick = (now: number) => {
if (!anim) startNext(now);
if (anim) {
const w = Math.min(1, (now - anim.t0) / anim.dur);
const w = motion.reduced ? 1 : 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);
@@ -205,17 +206,17 @@
// a refused move reports itself instead of a blank stage.
const scriptName = q.get("script");
const scriptPlay = scriptName ? screenplayByName(scriptName) : null;
let scriptSteps: ScreenplayStep[] = [];
let scriptError: string | null = null;
function loadScript(): { scriptSteps: ScreenplayStep[]; scriptError: string | null } {
if (scriptName && !scriptPlay) {
scriptError = `No screenplay named "${scriptName}". The catalog: ${SCREENPLAYS.map((sp) => sp.name).join(", ")}`;
} else if (scriptPlay) {
return { scriptSteps: [], scriptError: `No screenplay named "${scriptName}". The catalog: ${SCREENPLAYS.map((sp) => sp.name).join(", ")}` };
}
try {
scriptSteps = buildScreenplaySteps(scriptPlay);
return { scriptSteps: scriptPlay ? buildScreenplaySteps(scriptPlay) : [], scriptError: null };
} catch (e) {
scriptError = e instanceof Error ? e.message : String(e);
return { scriptSteps: [], scriptError: e instanceof Error ? e.message : String(e) };
}
}
const { scriptSteps, scriptError } = loadScript();
// Minimap geometry (top-down, one small square per cell).
const MM = 9;
+3 -5
View File
@@ -4,7 +4,8 @@
// camera shakes for the raycaster to draw. What happens ACROSS the room
// is watched; what happens to YOU is felt.
import { fallbackCels, paintCels } from "./paintedFx";
import { fallbackCels, paintCels, smoothstep } from "./paintedFx";
export { smoothstep };
import { fxForEvents } from "../fx";
import { CELL } from "../fx-sprites/geom";
import { castRay, edgeMid, warpMotion } from "./raycast";
@@ -55,9 +56,6 @@ export function slateBand(p: number): [number, number] {
return [smoothstep((p - 0.68) / 0.32), 1];
}
/** The one easing every tween here shares. */
export const smoothstep = (w: number): number => w * w * (3 - 2 * w);
/** Eased growth 0..1 for a conjuration's rise. */
export function growProgress(p: number): number {
if (p <= 0) return 0.01;
@@ -306,7 +304,7 @@ export function fpFxForEvents(
return out;
}
/** Inked stand-ins in each original palette, replaced when the PNG loads. */
/** Core and rim colors for each conjuration's inked stand-in. */
const FX_COLORS: Record<string, [string, string]> = {
fireball: ["#ffe27a", "#e0431a"],
bolt: ["#ffffff", "#7ab0ff"],
+81 -41
View File
@@ -1,10 +1,11 @@
/** Inked canvas cels. Paths are shared by the renderer and artwork previews. */
export const smoothstep = (w: number): number => w * w * (3 - 2 * w);
export type Cel = { d: string; fill?: string; stroke?: string; width?: number };
const INK = "#2b2218";
const ochre = "#dba54b", light = "#f2ce78", shade = "#95602e";
/** Poses modeled on the original right-facing and oncoming fist emoji:
* folded fingers face the viewer; the thumb stays outside their curl. */
/** Two fist poses: a punch thrown (wrist left, knuckles right) and one
* arriving (knuckles to the viewer). The thumb stays outside the curl. */
export const FISTS: Record<"out" | "in", Cel[]> = {
out: [
// Wrist at left, back of hand above, bent index at the right edge.
@@ -33,7 +34,7 @@ export const FISTS: Record<"out" | "in", Cel[]> = {
],
};
/** Deliberately uneven impact silhouette and hand-cut lettering; no font dependency. */
/** The POW starburst and its lettering, as paths: the canvas has no font to wait on. */
export const POW: Cel[] = [
{ d: "M-65-26 L-115-65 -53-53 -59-91 -18-60 8-88 17-57 63-79 53-43 119-43 82-13 126 13 78 23 98 64 45 48 27 86 4 57 -36 82 -39 48 -100 66 -76 28 -126 14 -81-5 -115-28Z", fill: "#f2d24a", width: 5 },
{ d: "M-83-38 L-60-31 M49-50 L59-60 M77 29 L88 37 M-51 46 L-64 55", stroke: "#c03020", width: 3 },
@@ -57,58 +58,97 @@ export function paintCels(ctx: CanvasRenderingContext2D, cels: readonly Cel[]) {
ctx.restore();
}
export function paintFist(ctx: CanvasRenderingContext2D, size: number, swing: "out" | "in") {
ctx.save(); ctx.scale(size / 100, size / 100); paintCels(ctx, FISTS[swing]); ctx.restore();
}
export function paintPow(ctx: CanvasRenderingContext2D, radius: number) {
ctx.save(); ctx.scale(radius / 100, radius / 100); paintCels(ctx, POW); ctx.restore();
ctx.save();
ctx.scale(size / 100, size / 100);
paintCels(ctx, FISTS[swing]);
ctx.restore();
}
/** Same endpoints and landing beats as the original animation. */
export function paintPow(ctx: CanvasRenderingContext2D, radius: number) {
ctx.save();
ctx.scale(radius / 100, radius / 100);
paintCels(ctx, POW);
ctx.restore();
}
/** Where the fist is at progress `p`: thrown, it lunges to 0.42 and
* retracts; arriving, it closes to 0.45, holds, and fades. The POW pops
* at the landing beat and shrinks away. */
export function punchPose(p: number, out: boolean, W: number, H: number) {
const ease = (v: number) => v * v * (3 - 2 * v);
// Clamp before easing: otherwise the incoming fist reverses past p=.45.
const a = out ? (p < .42 ? ease(p / .42) : ease(1 - (p - .42) / .58)) : ease(Math.min(1, p / .45));
const sx = out ? W * .86 : W * .5, sy = out ? H * 1.15 : H * .55;
const tx = W * .56, ty = H * .5;
const land = out ? .36 : .4, q = (p - land) / (1 - land);
const pop = p < land ? 0 : q < .18 ? ease(q / .18) * 1.15 : q < .7 ? 1.15 - .15 * ease((q - .18) / .52) : 1 - ease((q - .7) / .3);
const ease = smoothstep;
// Clamp before easing: past 0.45 the arriving fist would reverse.
const a = out ? (p < 0.42 ? ease(p / 0.42) : ease(1 - (p - 0.42) / 0.58)) : ease(Math.min(1, p / 0.45));
const sx = out ? W * 0.86 : W * 0.5, sy = out ? H * 1.15 : H * 0.55;
const tx = W * 0.56, ty = H * 0.5;
const land = out ? 0.36 : 0.4, q = (p - land) / (1 - land);
const pop = p < land ? 0 : q < 0.18 ? ease(q / 0.18) * 1.15 : q < 0.7 ? 1.15 - 0.15 * ease((q - 0.18) / 0.52) : 1 - ease((q - 0.7) / 0.3);
return { x: sx + (tx - sx) * a, y: sy + (ty - sy) * a,
size: out ? H * (.62 - .30 * a) : H * (.18 + 1.25 * a),
alpha: !out && p > .75 ? 1 - (p - .75) / .25 : 1,
rotation: out ? -.6 : .15, radius: H * .19 * pop,
impactX: out ? tx - W * .02 : W * .5, impactY: H * .34 };
size: out ? H * (0.62 - 0.30 * a) : H * (0.18 + 1.25 * a),
alpha: !out && p > 0.75 ? 1 - (p - 0.75) / 0.25 : 1,
rotation: out ? -0.6 : 0.15, radius: H * 0.19 * pop,
impactX: out ? tx - W * 0.02 : W * 0.5, impactY: H * 0.34 };
}
export function paintWeb(ctx: CanvasRenderingContext2D, left: number, top: number, w: number, h: number) {
const hub = [left + w * .5, top + h * .45];
const rim = [[.02,.06],[.5,0],[.98,.08],[1,.5],[.96,.94],[.5,1],[.04,.92],[0,.5]].map(([u,v]) => [left + u! * w, top + v! * h]);
const at = (i: number, t: number) => { const r = rim[i % 8]!; return [hub[0]! + (r[0]! - hub[0]!) * t, hub[1]! + (r[1]! - hub[1]!) * t]; };
ctx.save(); ctx.lineJoin = "round"; ctx.lineCap = "round";
type Pt = [number, number];
const hub: Pt = [left + w * 0.5, top + h * 0.45];
const rim: Pt[] = ([[0.02, 0.06], [0.5, 0], [0.98, 0.08], [1, 0.5], [0.96, 0.94], [0.5, 1], [0.04, 0.92], [0, 0.5]] as Pt[])
.map(([u, v]) => [left + u * w, top + v * h]);
const at = (i: number, t: number): Pt => {
const r = rim[i % 8] as Pt;
return [hub[0] + (r[0] - hub[0]) * t, hub[1] + (r[1] - hub[1]) * t];
};
ctx.save();
ctx.lineJoin = "round";
ctx.lineCap = "round";
ctx.beginPath();
for (const r of rim) { ctx.moveTo(hub[0]!,hub[1]!); ctx.lineTo(r[0]!,r[1]!); }
for (const t of [.27,.53,.8]) for (let i = 0; i < 8; i++) {
const a = at(i,t), b = at(i+1,t);
ctx.moveTo(a[0]!,a[1]!);
ctx.quadraticCurveTo((a[0]!+b[0]!)*.36+hub[0]!*.28,(a[1]!+b[1]!)*.36+hub[1]!*.28,b[0]!,b[1]!);
for (const r of rim) {
ctx.moveTo(hub[0], hub[1]);
ctx.lineTo(r[0], r[1]);
}
ctx.strokeStyle = "rgba(25,25,30,0.65)"; ctx.lineWidth = 3; ctx.stroke();
ctx.strokeStyle = "rgba(245,245,240,0.9)"; ctx.lineWidth = 1.3; ctx.stroke(); ctx.restore();
// Three sagging rings, each strand bowed toward the hub.
for (const t of [0.27, 0.53, 0.8]) {
for (let i = 0; i < 8; i++) {
const a = at(i, t), b = at(i + 1, t);
ctx.moveTo(a[0], a[1]);
ctx.quadraticCurveTo((a[0] + b[0]) * 0.36 + hub[0] * 0.28, (a[1] + b[1]) * 0.36 + hub[1] * 0.28, b[0], b[1]);
}
}
ctx.strokeStyle = "rgba(25,25,30,0.65)";
ctx.lineWidth = 3;
ctx.stroke();
ctx.strokeStyle = "rgba(245,245,240,0.9)";
ctx.lineWidth = 1.3;
ctx.stroke();
ctx.restore();
}
export function paintFloorRing(ctx: CanvasRenderingContext2D, cx: number, cy: number, rx: number, ry: number, rgb: string, alpha: number, fill: number) {
ctx.save(); ctx.fillStyle = `rgba(${rgb},${fill})`; ctx.strokeStyle = `rgba(${rgb},${alpha})`; ctx.lineWidth = 2;
ctx.beginPath(); ctx.ellipse(cx,cy,rx,ry,0,0,Math.PI*2); ctx.fill();
export function paintFloorRing(
ctx: CanvasRenderingContext2D, cx: number, cy: number, rx: number, ry: number, rgb: string, alpha: number, fill: number,
) {
ctx.save();
ctx.fillStyle = `rgba(${rgb},${fill})`;
ctx.strokeStyle = `rgba(${rgb},${alpha})`;
ctx.lineWidth = 2;
ctx.beginPath();
const segments = [[.06,1.75],[1.87,3.48],[3.62,5.04],[5.18,6.22]];
for (const [start,end] of segments) for (let i=0; i<=20; i++) {
const t = start! + (end!-start!) * i/20, k = 1 - .018 * (1 + Math.sin(t*5));
const x = cx + Math.cos(t)*rx*k, y = cy + Math.sin(t)*ry*k;
if (!i) ctx.moveTo(x,y); else ctx.lineTo(x,y);
ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2);
ctx.fill();
// The rim is inked in four strokes with a slight tremor, not one perfect ellipse.
ctx.beginPath();
const segments: [number, number][] = [[0.06, 1.75], [1.87, 3.48], [3.62, 5.04], [5.18, 6.22]];
for (const [start, end] of segments) {
for (let i = 0; i <= 20; i++) {
const t = start + (end - start) * i / 20;
const k = 1 - 0.018 * (1 + Math.sin(t * 5));
const x = cx + Math.cos(t) * rx * k, y = cy + Math.sin(t) * ry * k;
if (!i) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.stroke(); ctx.restore();
}
ctx.stroke();
ctx.restore();
}
/** Small, legible loading/error sprites using the original fallback palettes. */
/** The inked silhouette drawn in a conjuration's palette until its PNG loads. */
export function fallbackCels(name: string, core: string, rim: string): Cel[] {
const outer: Record<string, string> = {
fireball: "M20 78 Q3 54 28 32 L19 10 Q42 17 48 37 Q68 11 58 3 Q96 24 85 62 Q81 83 60 88 Q33 98 20 78Z",
@@ -135,5 +175,5 @@ export function fallbackCels(name: string, core: string, rim: string): Cel[] {
rubble:"M5 85 L18 79 29 83 24 90 11 91Z M36 72 L48 66 60 73 55 81 40 80Z M69 84 L80 79 89 84 83 91 73 92Z",
};
const kind = outer[name] ? name : "spark";
return [{d:outer[kind]!,fill:rim,width:3},{d:inside[kind]!,fill:core,width:1.5}];
return [{ d: outer[kind]!, fill: rim, width: 3 }, { d: inside[kind]!, fill: core, width: 1.5 }];
}
+6 -6
View File
@@ -19,13 +19,13 @@
</script>
<g style={`--ax: ${away.x}px; --ay: ${away.y}px; --bx: ${-away.x}px; --by: ${away.y}px`}>
<path d={puff(c.x - 6, c.y, (7) * 1)} fill="#b7a786" class="puff" />
<path d={puff(c.x + 5, c.y - 2, (5) * 1)} fill="#b7a786" class="puff back late" />
<path d={puff(c.x, c.y + 3, (6) * 1)} fill="#b7a786" class="puff later" />
<path d={puff(c.x - 6, c.y, 7)} fill="#b7a786" class="puff" />
<path d={puff(c.x + 5, c.y - 2, 5)} fill="#b7a786" class="puff back late" />
<path d={puff(c.x, c.y + 3, 6)} fill="#b7a786" class="puff later" />
<g class="grit">
<path d={puff(c.x - 4, c.y - 5, (1.3) * 1)} />
<path d={puff(c.x + 6, c.y + 2, (1.6) * 1)} />
<path d={puff(c.x + 1, c.y - 2, (0.9) * 1)} />
<path d={puff(c.x - 4, c.y - 5, 1.3)} />
<path d={puff(c.x + 6, c.y + 2, 1.6)} />
<path d={puff(c.x + 1, c.y - 2, 0.9)} />
</g>
</g>
+2 -3
View File
@@ -35,7 +35,7 @@
style={`transform-origin: ${c.x}px ${c.y}px`}
/>
{/each}
<path d={glint(c.x, c.y, (3.5) * 1.6)} class="heart" />
<path d={glint(c.x, c.y, 5.6)} class="heart" />
</g>
<style>
@@ -47,7 +47,7 @@
.spark { animation: spark-out 1.05s ease-out forwards; }
.trail.late, .spark.late { animation-delay: 0.07s; }
.trail.later, .spark.later { animation-delay: 0.14s; }
.heart { fill: #fffdf0; animation: heart-pop 0.4s ease-out forwards; }
.heart { fill: #fffdf0; transform-box: fill-box; transform-origin: center; animation: heart-pop 0.4s ease-out forwards; }
@keyframes trail-out {
0% { opacity: 0; transform: scale(0.15); }
25% { opacity: 1; }
@@ -63,5 +63,4 @@
0% { opacity: 1; transform: scale(0.5); }
100% { opacity: 0; transform: scale(3); }
}
.heart { transform-box: fill-box; transform-origin: center; }
</style>
+4 -5
View File
@@ -8,9 +8,9 @@
<path d={`M ${c.x-14} ${c.y+7} q -6 -6 1 -7 q 4 1 4 5 q 7 -9 12 -5 q -3 4 0 5 q 12 -7 14 -1 q 0 8 -17 9 q -15 1 -14 -6 Z`} fill="#6ea03c" stroke="#46662c" stroke-width="1" class="splat" />
<g class="blobs">
<path d={drop(c.x - 12, c.y + 3, (2.5) * 1)} class="blob" />
<path d={drop(c.x + 11, c.y + 2, (2) * 1)} class="blob late" />
<path d={drop(c.x + 3, c.y - 3, (1.7) * 1)} class="blob later" />
<path d={drop(c.x - 12, c.y + 3, 2.5)} class="blob" />
<path d={drop(c.x + 11, c.y + 2, 2)} class="blob late" />
<path d={drop(c.x + 3, c.y - 3, 1.7)} class="blob later" />
</g>
<style>
@@ -19,7 +19,7 @@
transform-origin: center;
animation: splat-wobble 0.7s ease-out forwards;
}
.blob { fill: #8fbf4d; animation: blob-fly 0.55s ease-out forwards; }
.blob { fill: #8fbf4d; animation: blob-fly 0.55s ease-out both; }
.blob.late { animation-delay: 0.06s; }
.blob.later { animation-delay: 0.12s; }
@keyframes splat-wobble {
@@ -33,5 +33,4 @@
50% { transform: translateY(-7px); }
100% { opacity: 0; transform: translateY(2px); }
}
.blob { animation-fill-mode: both; }
</style>
+5 -6
View File
@@ -11,9 +11,9 @@
<path d={`M ${c.x - 10} ${c.y - 5} a 12 8 0 0 1 20 0`} class="lip" />
<circle cx={c.x} cy={c.y - 2} r="8" class="faller" style={`transform-origin: ${c.x}px ${c.y}px`} />
<g class="rim-dust">
<path d={puff(c.x - 11, c.y - 6, (3) * 1)} class="dust" />
<path d={puff(c.x + 10, c.y - 7, (2.5) * 1)} class="dust late" />
<path d={puff(c.x + 1, c.y - 11, (2) * 1)} class="dust later" />
<path d={puff(c.x - 11, c.y - 6, 3)} class="dust" />
<path d={puff(c.x + 10, c.y - 7, 2.5)} class="dust late" />
<path d={puff(c.x + 1, c.y - 11, 2)} class="dust later" />
</g>
<style>
@@ -32,8 +32,9 @@
stroke-width: 1.6;
stroke-linecap: round;
animation: hole-open 0.75s ease-out forwards;
transform-box: fill-box; transform-origin: center;
}
.dust { fill: rgba(160, 150, 130, 0.75); animation: dust-drift 0.7s ease-out 0.15s forwards; opacity: 0; }
.dust { fill: rgba(160, 150, 130, 0.75); opacity: 0; transform-box: fill-box; transform-origin: center; animation: dust-drift 0.7s ease-out 0.15s forwards; }
.dust.late { animation-delay: 0.24s; }
.dust.later { animation-delay: 0.32s; }
@keyframes hole-open {
@@ -51,6 +52,4 @@
30% { opacity: 0.8; }
100% { opacity: 0; transform: translateY(-10px) scale(1.7); }
}
.lip { transform-box: fill-box; transform-origin: center; }
.dust { transform-box: fill-box; transform-origin: center; }
</style>
@@ -51,7 +51,7 @@
{/if}
<g class="corner-dust">
{#each CORNERS as p, i (i)}
<path d={puff(p.x, p.y, (3.5) * 1)} class={`dust d${i}`} />
<path d={puff(p.x, p.y, 3.5)} class={`dust d${i}`} />
{/each}
</g>
@@ -86,6 +86,7 @@
fill: rgba(160, 150, 130, 0.85);
opacity: 0;
animation: dust-kick 0.6s ease-out 0.85s forwards;
transform-box: fill-box; transform-origin: center;
}
.dust.d1 { animation-delay: 0.9s; }
.dust.d2 { animation-delay: 0.95s; }
@@ -128,5 +129,4 @@
30% { opacity: 0.9; }
100% { opacity: 0; transform: translateY(-9px) scale(1.6); }
}
.dust { transform-box: fill-box; transform-origin: center; }
</style>
+4 -4
View File
@@ -11,11 +11,11 @@
<g class="glint counter" style={`transform-origin: ${c.x}px ${c.y}px`}>
<path d={`M ${c.x} ${c.y - 8} l 2 6 6 2 -6 2 -2 6 -2 -6 -6 -2 6 -2 z`} />
</g>
<path d={glint(c.x, c.y, (3) * 1.7)} class="core" />
<path d={glint(c.x, c.y, 5.1)} class="core" />
<g class="motes">
<path d={glint(c.x - 10, c.y - 9, (1.3) * 1.7)} />
<path d={glint(c.x + 11, c.y - 5, (1.1) * 1.7)} class="late" />
<path d={glint(c.x + 2, c.y + 11, (1.2) * 1.7)} class="later" />
<path d={glint(c.x - 10, c.y - 9, 2.21)} />
<path d={glint(c.x + 11, c.y - 5, 1.87)} class="late" />
<path d={glint(c.x + 2, c.y + 11, 2.04)} class="later" />
</g>
<style>
+1 -2
View File
@@ -32,7 +32,7 @@
stroke-width: 3.2;
animation: ring-out 0.55s ease-out forwards;
}
.drop { fill: #7cc0ee; stroke: #245981; stroke-width: 0.6; animation: drop-arc 0.6s ease-in forwards; }
.drop { fill: #7cc0ee; stroke: #245981; stroke-width: 0.6; animation: drop-arc 0.6s ease-in both; }
.drop.late { animation-delay: 0.07s; }
.drop.later { animation-delay: 0.13s; }
@keyframes pool-spread {
@@ -48,5 +48,4 @@
45% { transform: translateY(-8px); }
100% { opacity: 0; transform: translateY(6px); }
}
.drop { animation-fill-mode: both; }
</style>
+5 -6
View File
@@ -6,16 +6,15 @@ export const center = (c: { x: number; y: number }) => ({
y: c.y * CELL + CELL / 2,
});
/** Small cel silhouettes, expressed in board coordinates so their animation
* origins stay attached to the existing cell and edge anchors. */
export const glint = (x: number, y: number, r: number) =>
`M ${x} ${y-r} Q ${x+r*.13} ${y-r*.13} ${x+r*.84} ${y+.04*r} Q ${x+r*.15} ${y+r*.15} ${x+.06*r} ${y+r} Q ${x-r*.15} ${y+r*.13} ${x-r*.88} ${y} Q ${x-r*.14} ${y-r*.15} ${x} ${y-r} Z`;
`M ${x} ${y-r} Q ${x+r*0.13} ${y-r*0.13} ${x+r*0.84} ${y+0.04*r} Q ${x+r*0.15} ${y+r*0.15} ${x+0.06*r} ${y+r} Q ${x-r*0.15} ${y+r*0.13} ${x-r*0.88} ${y} Q ${x-r*0.14} ${y-r*0.15} ${x} ${y-r} Z`;
export const puff = (x: number, y: number, r: number) =>
`M ${x-r} ${y+r*.3} C ${x-r*1.2} ${y-r*.15} ${x-r*.85} ${y-r*.6} ${x-r*.5} ${y-r*.5} C ${x-r*.65} ${y-r*1.12} ${x+r*.15} ${y-r*1.1} ${x+r*.32} ${y-r*.55} C ${x+r*.95} ${y-r*.75} ${x+r*1.22} ${y+r*.1} ${x+r*.75} ${y+r*.4} C ${x+r*.58} ${y+r*.9} ${x-r*.72} ${y+r*.8} ${x-r} ${y+r*.3} Z`;
`M ${x-r} ${y+r*0.3} C ${x-r*1.2} ${y-r*0.15} ${x-r*0.85} ${y-r*0.6} ${x-r*0.5} ${y-r*0.5} C ${x-r*0.65} ${y-r*1.12} ${x+r*0.15} ${y-r*1.1} ${x+r*0.32} ${y-r*0.55} C ${x+r*0.95} ${y-r*0.75} ${x+r*1.22} ${y+r*0.1} ${x+r*0.75} ${y+r*0.4} C ${x+r*0.58} ${y+r*0.9} ${x-r*0.72} ${y+r*0.8} ${x-r} ${y+r*0.3} Z`;
export const drop = (x: number, y: number, r: number) =>
`M ${x+r*.2} ${y-r*1.7} C ${x+r*.45} ${y-r*.65} ${x+r*1.12} ${y-r*.1} ${x+r*.8} ${y+r*.6} C ${x+r*.45} ${y+r*1.25} ${x-r*.85} ${y+r*.9} ${x-r*.8} ${y+r*.1} C ${x-r*.75} ${y-r*.6} ${x-r*.1} ${y-r} ${x+r*.2} ${y-r*1.7} Z`;
`M ${x+r*0.2} ${y-r*1.7} C ${x+r*0.45} ${y-r*0.65} ${x+r*1.12} ${y-r*0.1} ${x+r*0.8} ${y+r*0.6} C ${x+r*0.45} ${y+r*1.25} ${x-r*0.85} ${y+r*0.9} ${x-r*0.8} ${y+r*0.1} C ${x-r*0.75} ${y-r*0.6} ${x-r*0.1} ${y-r} ${x+r*0.2} ${y-r*1.7} Z`;
export const impact = (x: number, y: number, r: number) =>
`M ${x-r*.06} ${y-r} L ${x+r*.22} ${y-r*.34} ${x+r*.91} ${y-r*.58} ${x+r*.46} ${y-r*.05} ${x+r} ${y+r*.39} ${x+r*.31} ${y+r*.28} ${x+r*.2} ${y+r*.92} ${x-r*.12} ${y+r*.44} ${x-r*.74} ${y+r*.72} ${x-r*.4} ${y+r*.14} ${x-r} ${y-r*.18} ${x-r*.34} ${y-r*.27} Z`;
`M ${x-r*0.06} ${y-r} L ${x+r*0.22} ${y-r*0.34} ${x+r*0.91} ${y-r*0.58} ${x+r*0.46} ${y-r*0.05} ${x+r} ${y+r*0.39} ${x+r*0.31} ${y+r*0.28} ${x+r*0.2} ${y+r*0.92} ${x-r*0.12} ${y+r*0.44} ${x-r*0.74} ${y+r*0.72} ${x-r*0.4} ${y+r*0.14} ${x-r} ${y-r*0.18} ${x-r*0.34} ${y-r*0.27} Z`;
export const ward = (x: number, y: number, r: number) =>
`M ${x} ${y-r} Q ${x+r*.45} ${y-r*.63} ${x+r*.9} ${y-r*.68} L ${x+r*.82} ${y+r*.18} Q ${x+r*.7} ${y+r*.66} ${x} ${y+r} Q ${x-r*.75} ${y+r*.59} ${x-r*.81} ${y+r*.15} L ${x-r*.9} ${y-r*.68} Q ${x-r*.42} ${y-r*.65} ${x} ${y-r} Z`;
`M ${x} ${y-r} Q ${x+r*0.45} ${y-r*0.63} ${x+r*0.9} ${y-r*0.68} L ${x+r*0.82} ${y+r*0.18} Q ${x+r*0.7} ${y+r*0.66} ${x} ${y+r} Q ${x-r*0.75} ${y+r*0.59} ${x-r*0.81} ${y+r*0.15} L ${x-r*0.9} ${y-r*0.68} Q ${x-r*0.42} ${y-r*0.65} ${x} ${y-r} Z`;
+11
View File
@@ -0,0 +1,11 @@
import { prefersReducedMotion } from "svelte/motion";
import { prefs } from "./prefs.svelte";
// Shared by the board, the reel, and the canvas. Svelte's own media query
// tracks the OS setting live and releases its listener when no reactive
// consumer remains.
export const motion = {
get reduced(): boolean { return prefersReducedMotion.current; },
/** Flourishes play only when the player wants them AND the OS allows motion. */
get effects(): boolean { return prefs.flourishes && !prefersReducedMotion.current; },
};
+17 -6
View File
@@ -1,6 +1,6 @@
// Websocket client + reactive session state (Svelte 5 runes).
import type { Command, GameEvent, GameView } from "@wizwar/engine";
import type { Command, GameEvent, GameView, Side } from "@wizwar/engine";
import { cardDef } from "@wizwar/engine";
const SERVER_URL =
@@ -23,6 +23,9 @@ export function spellName(cardId: string): string {
}
}
/** The compass, for captions and buttons. */
export const SIDE_NAMES: Record<Side, string> = { N: "North", E: "East", S: "South", W: "West" };
export function humanize(e: GameEvent): string | null {
switch (e.type) {
case "gameStarted": {
@@ -66,7 +69,7 @@ export function humanize(e: GameEvent): string | null {
case "extraTurnGranted": return `${e.player} speeds up — extra turn banked.`;
case "wardWindow": return `The grab hangs in the air — ${e.owner} clutches something…`;
case "pushWindow": return `${e.giant} bears down on ${e.pushee} at a fork — which way will they go?`;
case "pushChosen": return `${e.pushee} breaks ${e.direction === "N" ? "north" : e.direction === "S" ? "south" : e.direction === "E" ? "east" : "west"}.`;
case "pushChosen": return `${e.pushee} breaks ${SIDE_NAMES[e.direction].toLowerCase()}.`;
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!`;
@@ -246,9 +249,11 @@ const ACTING = new Set([
"climbedFromPit", "spellTrapped",
]);
function actorOf(e: GameEvent, turnOwner: string | null): string | undefined {
const any = e as unknown as Record<string, unknown>;
for (const k of ["caster", "attacker", "by", "owner"]) if (typeof any[k] === "string") return any[k] as string;
if (ACTING.has(e.type) && typeof any.player === "string") return any.player as string;
if ("caster" in e && typeof e.caster === "string") return e.caster;
if ("attacker" in e && typeof e.attacker === "string") return e.attacker;
if ("by" in e && typeof e.by === "string") return e.by;
if ("owner" in e && typeof e.owner === "string") return e.owner;
if (ACTING.has(e.type) && "player" in e && typeof e.player === "string") return e.player;
return turnOwner ?? undefined;
}
@@ -620,12 +625,18 @@ class Net {
setTimeout(() => { if (this.error === msg.message) this.error = null; }, 5000);
break;
}
}
}
private send(message: unknown): void {
if (this.ws?.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(message));
}
/** A toast the table shows for a moment, as it shows the server's refusals. */
flash(message: string): void {
this.error = message;
setTimeout(() => { if (this.error === message) this.error = null; }, 5000);
}
create(name: string): void {
this.you = name;
this.spectating = false;
+5 -3
View File
@@ -25,6 +25,9 @@ export interface Prefs {
/** The hand of cards stands in a column at the board's left instead of
* lying along the bottom (wide screens only). */
handLeft: boolean;
/** The hand's side was picked by hand; until then a wide, short screen
* stands the cards beside the board on its own. */
handLeftChosen: boolean;
/** The first-turn coaching slip has been read and dismissed. */
coached: boolean;
/** The automaton tier was picked by hand (so a finished game leaves it alone). */
@@ -42,7 +45,7 @@ const KEY = "wizwar-prefs";
function load(): Prefs {
const fallback: Prefs = {
art: "photo", autoGrab: false, flourishes: true, wizardName: "", color: null,
cautions: true, botTier: "apprentice", instantReplay: true, liveFp: false, handLeft: false,
cautions: true, botTier: "apprentice", instantReplay: true, liveFp: false, handLeft: false, handLeftChosen: false,
coached: false, tierChosen: false, finishedGames: 0, stepLightAlways: false,
};
try {
@@ -56,12 +59,11 @@ function load(): Prefs {
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,
// A first game is against an apprentice; a browser that has finished
// one is promoted to adept unless the tier was picked by hand.
botTier: p.botTier === "adept" || p.botTier === "archmage" ? p.botTier : "apprentice",
instantReplay: p.instantReplay !== false,
liveFp: p.liveFp === true,
handLeft: p.handLeft === true,
handLeftChosen: p.handLeftChosen === true || p.handLeft === true,
coached: p.coached === true,
tierChosen: p.tierChosen === true,
finishedGames: typeof p.finishedGames === "number" ? p.finishedGames : 0,
+7
View File
@@ -91,6 +91,13 @@ export const RULES_SECTIONS: RulesSection[] = [
];
export const HOW_TO_PLAY: RulesSection[] = [
{
title: "Winning",
body: [
"Carry two treasures that aren't yours home — one at a time, to your home square, and drop them there — or be the last wizard standing. Lose both of your own treasures to enemy homes and you're out.",
"A turn is: walk, then act. Step up to three squares, select a card and choose its target, and End turn. The banner above the board keeps count of your moves and your treasures home.",
],
},
{
title: "Moving",
body: [