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
This commit is contained in:
Eric Wagoner
2026-09-16 00:21:34 -04:00
co-authored by Claude Fable 5.1
parent 85690f0e23
commit 6e49fc9020
40 changed files with 331 additions and 293 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 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, addresses): the last 7 rows give requests, human vs bot addresses,
socket connects, path mix (/, /clips, /watch, /ws), external socket connects, path mix (/, /clips, /watch, /ws), external
referrers (reddit.com, boardgamegeek.com after the 2026-09-03 referrers, new rooms, human seats and names, reports/replies,
announcement), new rooms, human seats and names, reports/replies,
and vitals (memMB, memPeakMB, diskPct, load1, protocolErrors, and vitals (memMB, memPeakMB, diskPct, load1, protocolErrors,
serviceStarts, ledgerMB). Report day-over-day direction, not raw serviceStarts, ledgerMB). Report day-over-day direction, not raw
rows. Its Sentry cron monitor is slug `wizwar-rollup` (a missed 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 check-in there means the rollup itself is broken). Re-roll a day by
hand: `wizwar-rollup.sh YYYY-MM-DD`. `campaigns` counts visitors hand: `wizwar-rollup.sh YYYY-MM-DD`. `campaigns` counts visitors
(one per address per day) whose link carried `?ref=<name>` — the tags (one per address per day) whose link carried a tag the rollup header
on Eric's posts (reddit, bgg, …) — or a Facebook fbclid; referrers names (`?ref=<name>`, a utm_source, Facebook's fbclid); referrers
alone can't attribute Reddit or BGG, which send none. alone can't attribute Reddit or BGG, which send none.
**Right now** (on request, or when the announcement is fresh): live **Right now** (on request): `wizwar-visitors.sh` prints it — live
sockets `ss -tn state established '( sport = :8787 )' | tail -n +2 | wc -l`, sockets, rooms touched in the last hour, and today's partial row —
rooms touched in the last hour `find /var/lib/wizwar/rooms -name '*.jsonl' -mmin -60 | wc -l`, see /wizwar-visitors.
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).
5. **The table itself**: 5. **The table itself**:
- Room count and growth: `ls /var/lib/wizwar/rooms/*.jsonl | wc -l` - Room count and growth: `ls /var/lib/wizwar/rooms/*.jsonl | wc -l`
- New human names this week: room ledgers' join lines minus bots - New human names this week: the rollup rows' `humanNames` (host and
(Automaton*) and known names, on rooms created in the last 7 days joiners alike, bots excluded) minus the regulars the visitors skill
(meta line `createdAt`) lists
- Unanswered feedback count from /var/lib/wizwar/feedback.jsonl — - Unanswered feedback count from /var/lib/wizwar/feedback.jsonl —
if any, offer to run the reports desk (`/wizwar-reports`) if any, offer to run the reports desk (`/wizwar-reports`)
- Access-log pulse: `wc -l /var/lib/caddy/access.log` and a count of - 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 - The service restarts cleanly (ledgers replay); restarts in the journal
during deploys are normal, not incidents. during deploys are normal, not incidents.
- Room memory is evicted after idle (finished 30 min, others 24 h); - Room memory is evicted after idle (finished 30 min, others 24 h), each
boot-restores load everything then shed, so post-deploy memory spikes sleeper leaving a stub on disk; a boot replays only rooms whose stub is
settle within the hour. 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 - Unattended-upgrades reboots the box at 09:30 UTC when a kernel patch
requires it; a reboot there is maintenance, not an outage. requires it; a reboot there is maintenance, not an outage.
- Caddy access logs live at /var/lib/caddy/access.log (self-rotating, - 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. "right now" line. Two sockets at a quiet hour is Eric plus one.
- **traffic** — today's row of `/var/lib/wizwar/rollup.jsonl`: - **traffic** — today's row of `/var/lib/wizwar/rollup.jsonl`:
addresses of people (bots split out), front-page vs gallery vs addresses of people (bots split out), front-page vs gallery vs
replay views, campaigns (`?ref=` tags on posted links, Facebook's replay views, campaigns (the tags a link carries: `?ref=`, a
fbclid) and referrers. Reddit and BGG send no referrer. utm_source, Facebook's fbclid) and referrers. Reddit and BGG send no
referrer.
- **players today / NEW today** — names seated in rooms created today; - **players today / NEW today** — names seated in rooms created today;
NEW means the name's first room ever is today. Known regulars: NEW means the name's first room ever is today. Known regulars:
Kestrel, Reuben, Jason, Yohon, and "Claude" (the duel seat). 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 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 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 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 with many moves is a real session, resumable from their lobby. Quote
human chat verbatim. Then the traffic line as a single sentence with human chat verbatim. Then the traffic line as a single sentence with
the day-over-day direction if a previous row exists. 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. # 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 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. # 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 # Counts only: no addresses are kept. The pulse reads it for trends the
# self-rotating access log cannot keep. Referrers are what browsers send # self-rotating access log cannot keep. Referrers are what browsers send
# (Reddit and BGG send none); campaigns are the ?ref=<name> tags on links # (Reddit and BGG send none); campaigns are the tags a link itself carries
# Eric posts, one count per address per day, plus Facebook's fbclid. # — ?ref=<name> on a link Eric posts, a utm_source, or Facebook's fbclid
# Cron: 10 0 * * * (UTC), see /etc/cron.d/wizwar-rollup (deploy.sh installs both). # 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 # Sentry Crons check-in: /root/.wizwar-sentry-cron-rollup holds the URL
# (absent = no check-ins). # (absent = no check-ins).
set -u set -u
OUT="/var/lib/wizwar/rollup.jsonl" OUT="/var/lib/wizwar/rollup.jsonl"
LOG="/var/log/wizwar/rollup.log" LOG="/var/log/wizwar/rollup.log"
DAY="${1:-$(date -u -d 'yesterday' +%Y-%m-%d)}" 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) 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 # 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 # been told the shape of, and a bare ping to an unknown slug is accepted
# and dropped without a word. # and dropped without a word.
checkin() { checkin() {
[ -n "$CRON_URL" ] && curl -sf -o /dev/null -X POST -H "Content-Type: application/json" \ [ -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 "$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() 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") 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() req = 0; human = 0; bot = 0; hips = set(); bips = set()
paths = collections.Counter(); status = collections.Counter(); refs = collections.Counter(); ws = 0 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 # 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") 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 "(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)") 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 status[str(r.get("status", 0))[0] + "xx"] += 1
ref = " ".join(h.get("Referer", [""])) ref = " ".join(h.get("Referer", [""]))
m = re.match(r"https?://([^/]+)", ref) m = re.match(r"https?://([^/]+)", ref)
host = m.group(1) if m else "" host = m.group(1) if m else ""
ours = host.endswith("kestrelsnest.social") or "104.236.96.198" in host 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), # Scanners forge referrers on paths like /.env, and the app answers
# not a reader — and the app answers unknown paths with the page, # unknown paths with the page itself, so the status cannot tell a
# so the status cannot tell them apart. Only the pages count. # probe from a reader. Only the pages count.
if host and not ours and not isbot and key in ("/", "/clips", "/watch"): refs[host] += 1 if host and not ours and not isbot and key in ("/", "/clips", "/watch"): refs[host] += 1
# --- the table --- # --- the table ---
@@ -107,7 +113,7 @@ except OSError: pass
# --- vitals (now, at rollup time) --- # --- vitals (now, at rollup time) ---
def sh(cmd): def sh(cmd):
try: return subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=20).stdout.strip() 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") 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("%") disk = sh("df --output=pcent / | tail -1").strip().rstrip("%")
load = sh("cut -d' ' -f1 /proc/loadavg") load = sh("cut -d' ' -f1 /proc/loadavg")
+1 -1
View File
@@ -7,7 +7,7 @@ set -u
DAY="${1:-$(date -u +%F)}" DAY="${1:-$(date -u +%F)}"
/usr/local/bin/wizwar-rollup.sh "$DAY" >/dev/null 2>&1 /usr/local/bin/wizwar-rollup.sh "$DAY" >/dev/null 2>&1
python3 - "$DAY" <<'PY' python3 - "$DAY" <<'PY'
import json, sys, glob, os, datetime, subprocess import json, sys, glob, datetime, subprocess
day = sys.argv[1] day = sys.argv[1]
now = datetime.datetime.now(datetime.timezone.utc) now = datetime.datetime.now(datetime.timezone.utc)
print("as of %s UTC, day %s" % (now.strftime("%H:%M"), day)) 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. // BERSERKER for blood, the WORRIER for the shadows between the two.
import { cardDef, type CardInstance } from "./cards"; 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 { bentSightFor, sightedCellsFor, type GameView } from "./view";
import { wallIgnoringDistance } from "./game"; import { wallIgnoringDistance } from "./game";
import type { AmbushTrigger, Command, PlayerId } from "./game"; import type { AmbushTrigger, Command, PlayerId } from "./game";
@@ -584,31 +584,19 @@ function wallBlastTarget(
return { cell: best.cell, side: best.side }; 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 * 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 * when the clockwork can unlock them; the first such door is reported so the
* key gets used before the boot. * 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( export function pathToward(
view: GameView, view: GameView,
@@ -634,12 +622,12 @@ export function pathToward(
const { to, viaDoor } = step; const { to, viaDoor } = step;
const k = cellKey(to); const k = cellKey(to);
if (seen.has(k)) continue; if (seen.has(k)) continue;
if (view.squareContents[k]?.kind === "stone") continue;
const hazard = view.squareContents[k]?.kind; const hazard = view.squareContents[k]?.kind;
if (hazard === "stone") continue;
// A pit is entered by leaping to the square beyond it in the same // 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 // direction; with nothing to land on, the maze bounces the leaper
// back and charges the stride. Goal or waypoint, that is no road. // 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. // Out of a pit only by one of its exits.
if (view.squareContents[cellKey(c)]?.kind === "pit") { if (view.squareContents[cellKey(c)]?.kind === "pit") {
const entry = cameBy.get(cellKey(c))?.dir; 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 * clogged hand never refreshes; only cards the brain has no play for are
* shed (good counters and attacks are hoarded, as a human would). */ * shed (good counters and attacks are hoarded, as a human would). */
function endTurnDrawing(view: GameView, tier: TierTraits, style?: AutomatonStyle): Command { 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); const deficit = draw - (handLimitOf(view) - view.yourHand.length);
if (tier.sheds && deficit > 0) { if (tier.sheds && deficit > 0) {
const shed = [...view.yourHand] 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 /** 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 * it can afford, keeping four life in hand, and nothing at all when it
* cannot — a bare hand beats a bare grave. */ * 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); const slow = view.sustained.some((e) => e.cardId === "slow-death" && e.targetId === view.you && !e.data?.reversed);
if (!slow) return draw; if (!slow) return draw;
return Math.max(0, Math.min(draw, me(view).life - 4)); 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" }; 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 * 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` * wall/door/firewall edges the segment crosses and by any `blockedCells`
+38 -48
View File
@@ -27,7 +27,7 @@ import {
bentSightThroughGap, bentSightThroughGap,
neighbor, neighbor,
stepTarget, stepTarget,
opposite, opposite, pitRimExits,
} from "./board"; } from "./board";
import { import {
buildDeck, buildDeck,
@@ -80,6 +80,12 @@ export interface PlayerState {
} }
/** A duration spell in play. Expires at the START of the caster's turns. */ /** A duration spell in play. Expires at the START of the caster's turns. */
/** 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 { export interface SustainedEffect {
id: string; id: string;
cardId: string; cardId: string;
@@ -298,11 +304,7 @@ export interface GameState {
/** A treasure was just grabbed and its owner holds WARD: /** A treasure was just grabbed and its owner holds WARD:
* the table waits while they choose to play it "at that time" or not. */ * the table waits while they choose to play it "at that time" or not. */
wardPending: { ownerId: PlayerId; takerId: PlayerId } | null; wardPending: { ownerId: PlayerId; takerId: PlayerId } | null;
/** BIG MAN has pushed a wizard to a fork: the giant's stride hangs while pushPending: PushPending | null;
* the pushed one picks which way to go (rev 15). */
pushPending: {
giantId: PlayerId; pusheeId: PlayerId; direction: Side; over: boolean; exit?: Side; exits: Side[];
} | null;
/** SLOW DEATH's bites hang while the victim weighs counteractions: /** SLOW DEATH's bites hang while the victim weighs counteractions:
* the draw's total is one blow ABSORB soaks up to three of it, * the draw's total is one blow ABSORB soaks up to three of it,
* BLUNT halves it rounding up (table ruling). */ * BLUNT halves it rounding up (table ruling). */
@@ -2401,11 +2403,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
if ((state.config.deckRev ?? 1) < 10 || !target) return null; if ((state.config.deckRev ?? 1) < 10 || !target) return null;
const caster = activePlayer(state); const caster = activePlayer(state);
const legs = state.turn.movementAllowance - state.turn.movementUsed; const legs = state.turn.movementAllowance - state.turn.movementUsed;
// Rev 16: the charge is measured as far as the legs reach. Before it, const d = walkingDistance(state, caster.position, target.position, chargeReach(state, legs));
// 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);
if (d > legs) return "the goat charges on legs, not wings — you cannot walk that far this turn"; if (d > legs) return "the goat charges on legs, not wings — you cannot walk that far this turn";
return null; return null;
}, },
@@ -2413,8 +2411,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
if (ctx.fullyStopped || !ctx.defender.alive || !ctx.attacker.alive) return; if (ctx.fullyStopped || !ctx.defender.alive || !ctx.attacker.alive) return;
if ((ctx.state.config.deckRev ?? 1) >= 10) { if ((ctx.state.config.deckRev ?? 1) >= 10) {
const legs = ctx.state.turn.movementAllowance - ctx.state.turn.movementUsed; 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, chargeReach(ctx.state, legs));
const d = walkingDistance(ctx.state, ctx.attacker.position, ctx.defender.position, reach);
if (d === 0) return; if (d === 0) return;
if (d > legs) { if (d > legs) {
// The target slipped beyond the charge (a counter-teleport, say): // The target slipped beyond the charge (a counter-teleport, say):
@@ -2903,10 +2900,8 @@ function waveForce(range: number, dist: number): number {
/** One line of a wave's sweep: the squares it reaches, nearest first, with /** 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 * 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 * (rev 20); older games sweep square by square, so a wizard shoved along
* victim it had just shoved and shove them again with the force left: a * the sweep's own line is caught and shoved again. */
* wizard washed one square into a wall was crushed twice. Older games
* replay that sweep. */
function sweepWave( function sweepWave(
state: GameState, events: GameEvent[], line: { cell: Cell; force: number }[], dir: Side, reason: string, state: GameState, events: GameEvent[], line: { cell: Cell; force: number }[], dir: Side, reason: string,
): void { ): void {
@@ -3724,9 +3719,16 @@ function attachSustained(
}); });
} }
/** BFS steps between cells respecting walls (MENTAL FORCE's 3 moved spaces). */ /** How far a BUTT-HEAD charge is measured: as far as the goat's legs
/** Steps along real corridors from one cell to another, searched out to * reach (rev 16); older games search six steps and call the rest
* `limit` steps; beyond that the answer is Infinity. */ * 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 { export function walkingDistance(state: GameState, from: Cell, to: Cell, limit = 6): number {
if (cellKey(from) === cellKey(to)) return 0; if (cellKey(from) === cellKey(to)) return 0;
const view = boardView(state); const view = boardView(state);
@@ -4254,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 // BLIND (or a DUST CLOUD): "must roll direction on D4 if attempting to
// move ... bumping into a wall counts as one space of movement." // 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) { if (isBlinded(state, p) && shove === undefined) {
direction = SIDES[rollD4(state, events, p.id, "a blind stagger picks the direction") - 1]!; direction = SIDES[rollD4(state, events, p.id, "a blind stagger picks the direction") - 1]!;
} }
@@ -4397,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 // the other player gets to decide which direction to go." The
// pushed one leaves by any open side but the way the giant came; // 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 // one open side is taken, several are theirs to choose — the stride
// hangs until they do. Older games shoved straight ahead only. // hangs until they do. Older games (deckRev < 15) shove straight
// A game dealt before rev 15 shoves straight ahead when it can, as it // ahead when that way is open; rounding a corner is allowed in every
// always did; the corner and the fork were refusals before, so every // game.
// game may round a corner now.
const fork = (state.config.deckRev ?? 1) >= 15; const fork = (state.config.deckRev ?? 1) >= 15;
let landing: Cell | null = null; let landing: Cell | null = null;
const exits = SIDES.filter((d) => d !== opposite(direction) && canShoveTo(d) !== null); const exits = SIDES.filter((d) => d !== opposite(direction) && canShoveTo(d) !== null);
@@ -4461,19 +4463,12 @@ function doMove(prev: GameState, direction: Side, over = false, exit?: Side, sho
let landingWarp = false; let landingWarp = false;
if (ledge) { if (ledge) {
const pitCell = p.position; const pitCell = p.position;
const footing = (d: Side): { kind: "step" | "warp"; to: Cell } | null => { const { floor, mouths } = pitRimExits(view, pitCell, direction,
const t = stepTarget(view, pitCell, d); (c) => state.squareContents[cellKey(c)]?.kind === "stone");
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");
// A warp mouth on the rim is a way off it like any square, and may // 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 // 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; // 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 // before it a bare step takes the square.
// recorded games replay as they were played.
const exits = [...floor, ...mouths]; const exits = [...floor, ...mouths];
const bare = (state.config.deckRev ?? 1) >= 17 || floor.length === 0 ? exits : floor; const bare = (state.config.deckRev ?? 1) >= 17 || floor.length === 0 ? exits : floor;
if (exits.length === 0) { if (exits.length === 0) {
@@ -4489,7 +4484,7 @@ function doMove(prev: GameState, direction: Side, over = false, exit?: Side, sho
p.position = from; p.position = from;
return err(`the rim leads more than one way — click the square to land on: ${exits.join(" or ")}`); 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; landingCell = foot.to;
landingWarp = foot.kind === "warp"; landingWarp = foot.kind === "warp";
} }
@@ -6074,16 +6069,13 @@ function doCounteract(
// opponent from entering the space you occupy or casting spells on or // 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 // past you. Lasts only until the end of the opponent's turn." The
// field faces every side, not one: this table's simplification. // 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) { if (card.cardId === "force-field" && (state.config.deckRev ?? 1) >= 21) {
const against = state.players.findIndex((q) => q.id === stack.attackerId); const facing = state.players.findIndex((q) => q.id === stack.attackerId);
attachSustained(state, [], "force-field", playerId, playerId, PERMANENT_TURNS, { against }); attachSustained(state, events, "force-field", playerId, playerId, PERMANENT_TURNS, { against: facing });
} }
stack.waitingOn = stack.attackerId; stack.waitingOn = stack.attackerId;
return { return { ok: true, state, events };
ok: true,
state,
events: [{ type: "counteractionPlayed", player: playerId, card, cardId: card.cardId, against }],
};
} }
if (playerId === stack.attackerId) { if (playerId === stack.attackerId) {
@@ -6844,8 +6836,8 @@ function checkVictory(state: GameState, events: GameEvent[]): void {
return owner !== null && owner !== p.id && ownerPlayer?.alive === true; return owner !== null && owner !== p.id && ownerPlayer?.alive === true;
}); });
// Rev 21: LIFESAVER — "immune to the effects of losing both of your // Rev 21: LIFESAVER — "immune to the effects of losing both of your
// treasures to other players' home bases." The card was cast and // treasures to other players' home bases." Older games eliminate its
// remembered but never asked here; older games eliminated its holder. // holder.
const saved = (state.config.deckRev ?? 1) >= 21 && sustainedOn(state, p.id, "lifesaver").length > 0; const saved = (state.config.deckRev ?? 1) >= 21 && sustainedOn(state, p.id, "lifesaver").length > 0;
if (lost && !saved) { if (lost && !saved) {
p.alive = false; p.alive = false;
@@ -7178,10 +7170,8 @@ function doEndTurn(prev: GameState, draw: number): CommandResult {
state.openSafes = []; state.openSafes = [];
// A FORCE FIELD raised against this wizard lasts only until their turn ends. // A FORCE FIELD raised against this wizard lasts only until their turn ends.
{ const seat = state.players.indexOf(p);
const idx = state.players.findIndex((q) => q.id === p.id); state.sustained = state.sustained.filter((f) => !(f.cardId === "force-field" && f.data.against === seat));
state.sustained = state.sustained.filter((f) => !(f.cardId === "force-field" && f.data.against === idx));
}
events.push({ type: "turnEnded", player: p.id }); events.push({ type: "turnEnded", player: p.id });
if (p.extraTurns > 0) { if (p.extraTurns > 0) {
+11 -10
View File
@@ -9,6 +9,7 @@ import {
LOS_BLOCKING_CONTENT, LOS_BLOCKING_CONTENT,
type AmbushState, type AmbushState,
type CastStack, type CastStack,
type PushPending,
type CreatureState, type CreatureState,
type GameState, type GameState,
type PlayerId, type PlayerId,
@@ -93,7 +94,7 @@ export interface GameView {
chaosPending: { casterId: PlayerId; queue: PlayerId[] } | null; chaosPending: { casterId: PlayerId; queue: PlayerId[] } | null;
/** A grab hangs while the treasure's owner decides their Ward. */ /** A grab hangs while the treasure's owner decides their Ward. */
wardPending: { ownerId: PlayerId; takerId: PlayerId } | null; wardPending: { ownerId: PlayerId; takerId: PlayerId } | null;
pushPending: GameState["pushPending"]; pushPending: PushPending | null;
slowDeathPending: { playerId: PlayerId; points: number } | null; slowDeathPending: { playerId: PlayerId; points: number } | null;
/** YOUR armed ambushes. Other players' ambushes are invisible. */ /** YOUR armed ambushes. Other players' ambushes are invisible. */
yourAmbushes: AmbushState[]; yourAmbushes: AmbushState[];
@@ -229,7 +230,7 @@ export function sightedCellsFor(view: GameView, from?: Cell): Set<string> {
const eye = from ?? me.position; const eye = from ?? me.position;
const { board, blockers } = sightBasis(view); const { board, blockers } = sightBasis(view);
// In a dust cloud a wizard sees only their own square (rev 19). // In a dust cloud a wizard sees only their own square (rev 19).
if (dustAtEnd(view, eye, eye, true)) { if (inDust(view, eye)) {
out.add(`${eye.x},${eye.y}`); out.add(`${eye.x},${eye.y}`);
return out; return out;
} }
@@ -333,15 +334,15 @@ export function traceSightFor(view: GameView, from: Cell, to: Cell): SightTrace
return traceSight(board, from, to, blockers); return traceSight(board, from, to, blockers);
} }
/** Rev 19: DUST CLOUD blinds its occupant and hides them from LOS spells; /** Rev 19: DUST CLOUD blinds its occupant. Older games see through it. */
* a square sees itself still. `standing` asks only whether the viewer's function inDust(view: GameView, cell: Cell): boolean {
* own square is dust. */ return view.deckRev >= 19 && view.squareContents[`${cell.x},${cell.y}`]?.kind === "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"; /** Dust at either end kills a sight line; a square sees itself still. */
if (standing) return dust(from); function dustAtEnd(view: GameView, from: Cell, to: Cell): boolean {
if (from.x === to.x && from.y === to.y) return false; if (from.x === to.x && from.y === to.y) return false;
return dust(from) || dust(to); return inDust(view, from) || inDust(view, to);
} }
/** /**
+6 -6
View File
@@ -8,7 +8,7 @@ import {
} from "../src/game"; } from "../src/game";
import { cellKey, edgeKey } from "../src/board"; import { cellKey, edgeKey } from "../src/board";
import { sightedCellsFor, viewFor } from "../src/view"; 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"; import { pushSustained } from "./helpers";
/** Whose input does the maze want right now? */ /** 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 state = botsTurn();
const bot = state.players.find((p) => p.id === "bot")!; const bot = state.players.find((p) => p.id === "bot")!;
const plain = viewFor(state, "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 }); pushSustained(state, { id: "sd", cardId: "slow-death", casterId: "human", targetId: "bot", remainingTurns: 1e9 });
bot.life = 7; bot.life = 7;
expect(drawUnderCurses(viewFor(state, "bot"), 2)).toBe(2); expect(drawUnderSlowDeath(viewFor(state, "bot"), 2)).toBe(2);
bot.life = 5; bot.life = 5;
expect(drawUnderCurses(viewFor(state, "bot"), 2)).toBe(1); expect(drawUnderSlowDeath(viewFor(state, "bot"), 2)).toBe(1);
bot.life = 4; 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", () => { 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++) { for (let guard = 0; guard < 30 && actingSeat(state) === "bot"; guard++) {
const view = viewFor(state, "bot"); const view = viewFor(state, "bot");
const cmd = automatonCommand(view, "berserker", "archmage") ?? automatonFallback(view, "archmage"); 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); const r = applyCommand(state, "bot", cmd);
if (!r.ok) break; if (!r.ok) break;
state = r.state; state = r.state;
+5 -4
View File
@@ -396,7 +396,7 @@ describe("big man", () => {
/** From a giant's square: a step into a square whose exits (all sides but /** 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 * 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. */ * 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); const view = boardView(state);
for (const key of Object.keys(view.cells)) { for (const key of Object.keys(view.cells)) {
const [x, y] = key.split(",").map(Number) as [number, number]; const [x, y] = key.split(",").map(Number) as [number, number];
@@ -407,6 +407,7 @@ describe("big man", () => {
const exits = SIDES.filter((d) => d !== opposite(side) && stepTarget(view, t1.to, d).kind === "step"); 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 === "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 === "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`); throw new Error(`no ${want} on this board`);
@@ -453,15 +454,15 @@ describe("big man", () => {
expect(state.turn.movementUsed).toBe(used + 1); 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 }); let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 14 });
state = toRound2(state); state = toRound2(state);
const giant = activePlayer(state); const giant = activePlayer(state);
state.sustained.push({ id: "fx-big", cardId: "big-man", casterId: giant.id, targetId: giant.id, remainingTurns: 9, data: {} }); 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)!; const victim = state.players.find((p) => p.id !== giant.id)!;
// A fork where straight ahead is open: no window, straight it is. // A fork where straight ahead is open: no window, straight it is.
const fork = pushSite(state, "fork"); const fork = pushSite(state, "straight-fork");
if (fork.exits.includes(fork.side)) { {
giant.position = { ...fork.at }; giant.position = { ...fork.at };
victim.position = { ...fork.mid }; victim.position = { ...fork.mid };
const r = applyCommand(state, giant.id, { type: "move", direction: fork.side }); const r = applyCommand(state, giant.id, { type: "move", direction: fork.side });
@@ -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. */ /** Two empty cells seven or eight corridor steps apart on the seed-42 board. */
function longRun(state: GameState): { from: Cell; to: Cell; steps: number } { function longRun(state: GameState): { from: Cell; to: Cell; steps: number } {
const view = boardView(state); const view = boardView(state);
@@ -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 /** 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. */ * the square opposite the mouth; its other neighbouring squares listed. */
function rimPitSite(state: GameState) { 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 /** 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. */ * square behind them and a wall beyond it, and a caster's square beside. */
function site(state: GameState) { 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); expect(cast.state.slimeTraps[cellKey(spot.cell)]?.length).toBe(1);
// Nobody is hurt yet; the victim walks in on their own turn. // Nobody is hurt yet; the victim walks in on their own turn.
state = must(cast.state, caster.id, { type: "endTurn", draw: 2 }); state = must(cast.state, caster.id, { type: "endTurn", draw: 2 });
victim.position = { ...caster.position };
const v = state.players.find((p) => p.id === victim.id)!; const v = state.players.find((p) => p.id === victim.id)!;
v.position = { ...caster.position }; v.position = { ...caster.position };
const walk = applyCommand(state, victim.id, { type: "move", direction: spot.side }); const walk = applyCommand(state, victim.id, { type: "move", direction: spot.side });
+6 -3
View File
@@ -204,9 +204,10 @@ function safeBase(rawHost: string, rawProto: string): string {
* carried home, a blow that landed, a spell cast before the bare fact * carried home, a blow that landed, a spell cast before the bare fact
* of whose turn it was. */ * of whose turn it was. */
function headlineOf(data: ShareData): string | null { 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 name = (id: string | null) => { if (!id) return "a spell"; try { return cardDef(id).name; } catch { return id; } };
let best: { rank: number; text: string } | null = null; const deeds: { rank: number; text: string }[] = [];
const offer = (rank: number, text: string) => { if (!best || rank > best.rank) best = { rank, text }; }; const offer = (rank: number, text: string) => { deeds.push({ rank, text }); };
for (const step of data.steps) { for (const step of data.steps) {
for (const e of step.events) { for (const e of step.events) {
switch (e.type) { switch (e.type) {
@@ -225,7 +226,9 @@ function headlineOf(data: ShareData): string | null {
} }
} }
} }
return best ? (best as { text: string }).text : null; 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 { function shareHtml(id: string, data: ShareData, rawHost: string, rawProto: string): string {
+4 -2
View File
@@ -165,7 +165,7 @@ export function evictIdleRooms(hasSockets: (roomId: string) => boolean): number
evicted++; evicted++;
try { try {
const stat = ledgerStat(id); 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) { } catch (e) {
console.error(`could not write stub for ${id}:`, e); console.error(`could not write stub for ${id}:`, e);
} }
@@ -185,6 +185,8 @@ interface RoomStub {
const sleepingStubs = new Map<string, RoomStub>(); const sleepingStubs = new Map<string, RoomStub>();
/** A stub as it rests on disk — the token map spelled as an object. */ /** A stub as it rests on disk — the token map spelled as an object. */
type StoredStub = Omit<RoomStub, "tokens"> & { tokens: Record<PlayerId, string> }; 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 { function stubOf(room: Room): RoomStub {
const { active, turnHolder, waitKind } = turnFacts(room.state); const { active, turnHolder, waitKind } = turnFacts(room.state);
@@ -797,7 +799,7 @@ export function loadPersistedRooms(): void {
if (!stat) continue; if (!stat) continue;
const stored = readStubFile<StoredStub>(id); const stored = readStubFile<StoredStub>(id);
if (stored && stored.bytes === stat.bytes) { 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++; asleep++;
continue; continue;
} }
+2 -2
View File
@@ -5,7 +5,7 @@
import { appendFileSync, existsSync, readFileSync } from "node:fs"; import { appendFileSync, existsSync, readFileSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
import { randomInt } from "node:crypto"; import { randomInt } from "node:crypto";
import { statsDir } from "./store"; import { dataRoot } from "./store";
export interface Share { export interface Share {
id: string; id: string;
@@ -20,7 +20,7 @@ const SHARE_ID_LENGTH = 10; // 31^10 ≈ 8×10^14 — unguessable, typeable
const shares = new Map<string, Share>(); const shares = new Map<string, Share>();
function sharesFile(): string { function sharesFile(): string {
return join(statsDir(), "shares.jsonl"); return join(dataRoot(), "shares.jsonl");
} }
export function loadShares(): void { export function loadShares(): void {
+2 -2
View File
@@ -6,7 +6,7 @@
import { readFileSync, renameSync, writeFileSync } from "node:fs"; import { readFileSync, renameSync, writeFileSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
import { statsDir } from "./store"; import { dataRoot } from "./store";
import type { Room } from "./rooms"; import type { Room } from "./rooms";
export interface EngagementStats { export interface EngagementStats {
@@ -35,7 +35,7 @@ interface StatsFile extends Omit<EngagementStats, "wizardsSeated"> {
roomStages: Record<string, "created" | "started" | "finished">; roomStages: Record<string, "created" | "started" | "finished">;
} }
const FILE = () => join(statsDir(), "stats.json"); const FILE = () => join(dataRoot(), "stats.json");
let data: StatsFile = { let data: StatsFile = {
gamesCreated: 0, gamesStarted: 0, gamesFinished: 0, 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`); return join(DATA_DIR, `${roomId}.jsonl`);
} }
/** Directory holding stats.json — the parent of the rooms dir. */ /** The data root the parent of the rooms dir where everything that
export function statsDir(): string { * is not a room ledger lives: stats, feedback, stubs, the graveyard, the
* clip vault. */
export function dataRoot(): string {
return join(DATA_DIR, ".."); 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 // 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. // 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 { function stubFor(roomId: string): string {
if (!safeRoomId(roomId)) throw new Error(`unsafe room id: ${JSON.stringify(roomId)}`); if (!safeRoomId(roomId)) throw new Error(`unsafe room id: ${JSON.stringify(roomId)}`);
return join(stubDir(), `${roomId}.json`); 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); const file = stubFor(roomId);
mkdirSync(stubDir(), { recursive: true }); mkdirSync(stubDir(), { recursive: true });
writeFileSync(file + ".tmp", JSON.stringify({ bytes, stub }), "utf8"); 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) * timestamp. A report line may carry fields (deckRev, player context)
* that only the operator's raw read uses; readFeedback keeps the * that only the operator's raw read uses; readFeedback keeps the
* player-facing subset. */ * player-facing subset. */
const feedbackFile = () => join(DATA_DIR, "..", "feedback.jsonl"); const feedbackFile = () => join(dataRoot(), "feedback.jsonl");
export function appendFeedback(entry: Record<string, unknown>): void { export function appendFeedback(entry: Record<string, unknown>): void {
ensureDataDir(); ensureDataDir();
@@ -239,7 +241,7 @@ export function readFeedback(): FeedbackReport[] {
export function archiveRoomFile(roomId: string): void { export function archiveRoomFile(roomId: string): void {
const src = fileFor(roomId); const src = fileFor(roomId);
if (!existsSync(src)) return; if (!existsSync(src)) return;
const graveyard = join(DATA_DIR, "..", "rooms-abandoned"); const graveyard = join(dataRoot(), "rooms-abandoned");
mkdirSync(graveyard, { recursive: true }); mkdirSync(graveyard, { recursive: true });
renameSync(src, join(graveyard, `${roomId}.${Date.now()}.jsonl`)); renameSync(src, join(graveyard, `${roomId}.${Date.now()}.jsonl`));
} }
@@ -260,7 +262,7 @@ export interface ClipMeta {
height?: number; 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, /** A clip's name, which is also its file stem: no separators, no dots,
* so a name can never name a path. */ * so a name can never name a path. */
+27 -30
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { motion } from "./motion"; import { motion } from "./motion";
import { attentionLabel, net, spellName } from "./net.svelte"; import { attentionLabel, net, SIDE_NAMES, spellName } from "./net.svelte";
import { MediaQuery } from "svelte/reactivity";
import { FISTS } from "./fpv/paintedFx"; import { FISTS } from "./fpv/paintedFx";
import Board from "./Board.svelte"; import Board from "./Board.svelte";
import LiveFirstPerson from "./LiveFirstPerson.svelte"; import LiveFirstPerson from "./LiveFirstPerson.svelte";
@@ -16,7 +17,7 @@
import { prefs, savePrefs } from "./prefs.svelte"; import { prefs, savePrefs } from "./prefs.svelte";
import { CREATURE_ART, objectArt, TERRAIN_ART, tokenArt } from "./art"; import { CREATURE_ART, objectArt, TERRAIN_ART, tokenArt } from "./art";
import { local } from "./local.svelte"; 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"; import type { CardInstance, GameEvent, Side } from "@wizwar/engine";
net.connect(); net.connect();
@@ -305,7 +306,6 @@
let rotateCW = $state(true); let rotateCW = $state(true);
/** pick-lock / master-key: prop the door for others once it opens. */ /** pick-lock / master-key: prop the door for others once it opens. */
let holdDoor = $state(false); 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. */ /** MEGA-MONSTER aimed at a monster: which of its two numbers to double. */
let megaPrompt = $state<{ instanceId: string; creatureId: string } | null>(null); let megaPrompt = $state<{ instanceId: string; creatureId: string } | null>(null);
function castMega(boost: "life" | "movement") { function castMega(boost: "life" | "movement") {
@@ -345,8 +345,9 @@
); );
const youMustDiscard = $derived(view != null && view.pendingDiscard === view.you); const youMustDiscard = $derived(view != null && view.pendingDiscard === view.you);
const youMustShield = $derived(view?.chaosPending?.queue[0] === view?.you && view != null); 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 /** Picking up an object or sliding into slime ends the turn's actions: the
* the draw — but never while a counter, shield, or discard is owed. */ * hand goes quiet until the draw — but never while a counter, shield, or
* discard is owed. */
const actionsSpent = $derived( const actionsSpent = $derived(
view != null && isYourTurn && view.turn.actionsEnded && view != null && isYourTurn && view.turn.actionsEnded &&
!youMustRespond && !youMustShield && !youMustDiscard, !youMustRespond && !youMustShield && !youMustDiscard,
@@ -465,16 +466,15 @@
* the spell being built (or answered), controls and all — table talk * the spell being built (or answered), controls and all — table talk
* returns the moment the cast resolves. Phones keep the under-board * returns the moment the cast resolves. Phones keep the under-board
* strips; a rail below the fold is no place to aim from. */ * strips; a rail below the fold is no place to aim from. */
const wideQuery = typeof matchMedia === "undefined" ? null : matchMedia("(min-width: 901px)"); const wideQuery = new MediaQuery("(min-width: 901px)", true);
let wideScreen = $state(wideQuery?.matches ?? true); const wideScreen = $derived(wideQuery.current);
wideQuery?.addEventListener("change", (e) => (wideScreen = e.matches));
/** A wide, short screen — a laptop in landscape — stands the hand beside /** 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 * 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. */ * bottom hand leaves a postage stamp, and a first turn deserves better.
const shortWideQuery = typeof matchMedia === "undefined" ? null : matchMedia("(min-width: 1180px) and (max-height: 820px)"); * (The narrower "short window" rule in the stylesheet only shrinks the
let shortWide = $state(shortWideQuery?.matches ?? false); * cards; this one needs room for a card column, hence the wider floor.) */
shortWideQuery?.addEventListener("change", (e) => (shortWide = e.matches)); const shortWideQuery = new MediaQuery("(min-width: 1180px) and (max-height: 820px)", false);
const handLeftOn = $derived(prefs.handLeftChosen ? prefs.handLeft : shortWide); const handLeftOn = $derived(prefs.handLeftChosen ? prefs.handLeft : shortWideQuery.current);
const castPanelOn = $derived(wideScreen && !local.active && const castPanelOn = $derived(wideScreen && !local.active &&
(selectedCard != null || (youMustRespond && !!view?.stack))); (selectedCard != null || (youMustRespond && !!view?.stack)));
@@ -588,14 +588,14 @@
} }
} }
/** 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. */ /** Your ALTER EGO, when it is the selected token: casts leave from its square. */
const castingDouble = $derived( const castingDouble = $derived(
selectedCreature && view selectedCreature && view
? view.creatures.find((c) => c.id === selectedCreature && c.kind === "alter-ego" && c.controllerId === view!.you) ?? null ? view.creatures.find((c) => c.id === selectedCreature && c.kind === "alter-ego" && c.controllerId === view!.you) ?? null
: 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 { function withMods<T extends Parameters<typeof net.command>[0] & { type: "cast" }>(cmd: T): T {
applyMods(cmd); applyMods(cmd);
if (castingDouble && cmd.target) cmd.via = castingDouble.id; if (castingDouble && cmd.target) cmd.via = castingDouble.id;
@@ -669,7 +669,7 @@
discardSelection = next; discardSelection = next;
return; return;
} }
if (actionsSpent) return; // picking up an object ended the turn's actions if (actionsSpent) return;
if (!isYourTurn && !yourMoment) { if (!isYourTurn && !yourMoment) {
// Live interruption: Interrupt / Opportunity Fire may be played during // Live interruption: Interrupt / Opportunity Fire may be played during
// another player's turn (when no attack is pending). // another player's turn (when no attack is pending).
@@ -945,8 +945,7 @@
// A card in hand is aimed, not walked with: a tap on a bare square // 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. // while holding one is a miss, never a stride into whatever is there.
if (selectedCard) { 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`; net.flash(`${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);
return; return;
} }
// A cell click is a move if the cell is one legal step away (the server // A cell click is a move if the cell is one legal step away (the server
@@ -981,7 +980,7 @@
y: me.position.y + (side === "S" ? 1 : side === "N" ? -1 : 0) }; y: me.position.y + (side === "S" ? 1 : side === "N" ? -1 : 0) };
if (view.squareContents[cellKey(pit)]?.kind !== "pit") continue; if (view.squareContents[cellKey(pit)]?.kind !== "pit") continue;
for (const out of SIDES) { 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); const foot = stepTarget(view.board, pit, out);
if (foot.kind !== "blocked" && cellKey(foot.to) === cellKey(cell)) { tryMove(side, undefined, out); return; } if (foot.kind !== "blocked" && cellKey(foot.to) === cellKey(cell)) { tryMove(side, undefined, out); return; }
} }
@@ -1533,10 +1532,10 @@
// answer to "how can he even see me?" when sight ran through a warp mouth. // answer to "how can he even see me?" when sight ran through a warp mouth.
const sightTrace = $derived(view ? stackSightTrace(view) : null); 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. */ /** A first game's first turn lights itself; after that only by choice. */
const firstTurnEver = $derived(!!view && prefs.finishedGames === 0 && view.turn.round === 1); 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( const stepLight = $derived(
!!view && !!me && (prefs.stepLightAlways || firstTurnEver) && isYourTurn && !view.turn.actionsEnded && !selectedCard && !!view && !!me && (prefs.stepLightAlways || firstTurnEver) && isYourTurn && !view.turn.actionsEnded && !selectedCard &&
!net.spectating && !youMustRespond && view.turn.movementAllowance - view.turn.movementUsed > 0, !net.spectating && !youMustRespond && view.turn.movementAllowance - view.turn.movementUsed > 0,
@@ -1651,7 +1650,7 @@
<span class="mast-sub">{fpvWorkshop ? "the eyes workshop" : tokenWorkshop ? "the token workshop" : "the flourish workshop"}</span> <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="/">← back to the table</a>
<a class="mast-leave" href="/clips">the clips</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="/?fx">flourishes</a>
<a class="mast-leave" href="/?fpv">eyes</a> <a class="mast-leave" href="/?fpv">eyes</a>
</header> </header>
@@ -1813,7 +1812,7 @@
<div class="attack-actions"> <div class="attack-actions">
{#each view.pushPending.exits as d (d)} {#each view.pushPending.exits as d (d)}
<button class="stamp primary" onclick={() => dispatch({ type: "pushChoice", direction: 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> </button>
{/each} {/each}
</div> </div>
@@ -2803,10 +2802,10 @@
{/if} {/if}
{#if view.turn.actionsEnded} {#if view.turn.actionsEnded}
{#if me?.carriedTreasureId} {#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. <strong>End turn</strong> now, then carry it home to score.
{:else} {: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} {/if}
{:else} {:else}
{view.turn.movementAllowance - view.turn.movementUsed} moves left{view.turn.attackUsed ? " · attack spent" : ""} · treasures home {treasuresHome} of 2 {view.turn.movementAllowance - view.turn.movementUsed} moves left{view.turn.attackUsed ? " · attack spent" : ""} · treasures home {treasuresHome} of 2
@@ -3372,7 +3371,7 @@
.workshop-mast { margin: 0 1rem 0; } .workshop-mast { margin: 0 1rem 0; }
.mast-home { text-decoration: none; } .mast-home { text-decoration: none; }
.mast-home:hover { color: #fff; } .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-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; } .hotseat-exp { margin-top: 0.4rem; margin-bottom: 0; font-size: 0.85rem; }
@@ -3500,7 +3499,6 @@
margin: 0.2rem 0 0.1rem; margin: 0.2rem 0 0.1rem;
} }
/* The eyes toggle floats over the board's corner so it costs the maze no height. */ /* The eyes toggle floats over the board's corner so it costs the maze no height. */
.board-zone { position: relative; flex-direction: column; align-items: stretch; }
.view-toggle { position: absolute; top: 0.25rem; right: 0.25rem; z-index: 3; } .view-toggle { position: absolute; top: 0.25rem; right: 0.25rem; z-index: 3; }
.step-caption { .step-caption {
align-self: flex-start; align-self: flex-start;
@@ -3816,7 +3814,7 @@
flex: 1; flex: 1;
min-height: 0; 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 /* Hand at the left: the cards stand in their own column beside the
* board while the hint strips and action buttons stay beneath it — * board while the hint strips and action buttons stay beneath it —
* the strip dissolves into the grid so its children place themselves. * the strip dissolves into the grid so its children place themselves.
@@ -3831,7 +3829,7 @@
grid-template-rows: minmax(0, 1fr) auto; grid-template-rows: minmax(0, 1fr) auto;
} }
.paper-rail { overflow-y: auto; min-height: 0; padding-right: 2px; } .paper-rail { overflow-y: auto; min-height: 0; padding-right: 2px; }
.board-zone { flex-direction: column; align-items: stretch; min-height: 0; } .board-zone { min-height: 0; }
.table-stack { flex: 1 1 0; 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-frame { flex: 1 1 0; min-height: 0; display: flex; flex-direction: column; }
.board-viewport { height: auto; flex: 1 1 0; min-height: 0; } .board-viewport { height: auto; flex: 1 1 0; min-height: 0; }
@@ -3979,7 +3977,6 @@
padding-left: 0.3rem; padding-left: 0.3rem;
border-radius: 2px; border-radius: 2px;
} }
.score-row.active .score-name { font-weight: 700; }
.score-temper { .score-temper {
font-family: "Caveat", cursive; font-family: "Caveat", cursive;
font-size: 0.85rem; font-size: 0.85rem;
+5 -10
View File
@@ -461,15 +461,11 @@
{@const wx = Number(key.split(":")[1]?.split(",")[0])} {@const wx = Number(key.split(":")[1]?.split(",")[0])}
{@const wy = Number(key.split(":")[1]?.split(",")[1])} {@const wy = Number(key.split(":")[1]?.split(",")[1])}
{@const frac = Math.min(1, dmg / 20)} {@const frac = Math.min(1, dmg / 20)}
{#if kind === "V"} {@const scar = 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} /> ? edgeScar((wx + 1) * CELL, wy * CELL + 3, (wx + 1) * CELL, (wy + 1) * CELL - 3, frac)
<path d={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)}
class="crack" style:opacity={0.35 + frac * 0.65}><title>battle-scarred — {dmg} damage taken</title></path> <path d={scar} class="crack-shadow" style:opacity={0.35 + frac * 0.65} />
{:else} <path d={scar} class="crack" style:opacity={0.35 + frac * 0.65}><title>battle-scarred — {dmg} damage taken</title></path>
<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}
{/each} {/each}
<!-- illusions YOU know are fake: ghostly dashed lines --> <!-- illusions YOU know are fake: ghostly dashed lines -->
@@ -910,7 +906,6 @@
stroke: rgba(240, 231, 209, 0.9); stroke: rgba(240, 231, 209, 0.9);
stroke-width: 1.25; stroke-width: 1.25;
stroke-linecap: round; stroke-linecap: round;
pointer-events: none;
} }
.warp-dest { .warp-dest {
fill: none; fill: none;
+1 -1
View File
@@ -48,7 +48,7 @@
><title>{tip}</title></rect> ><title>{tip}</title></rect>
{/if} {/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"> <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"} {#if state === "wall"}
<path d="M -23 -2.2 L -11 -2 -2 -2.3 11 -2 23 -2.2" class="stone-light" /> <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" fill="#bd3b1b" class="fw-bar"
role="img" onpointerdown={press} onpointerup={release} onpointerleave={release} /> role="img" onpointerdown={press} onpointerup={release} onpointerleave={release} />
{/if} {/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)} {#each TONGUES as f (f.t)}
<g transform={`translate(${px(f.t)} ${py(f.t)})`} class="fw-art"> <g transform={`translate(${px(f.t)} ${py(f.t)})`} class="fw-art">
<g class="fw-tongue" style={`animation-delay: ${-f.d}s`}> <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 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> peek names them, since every cast into it was seen.</p>
<p>Clockwork wizards — the automatons — play from the same redacted <p>Clockwork wizards — the automatons — play from the same redacted
view a human seat gets and obey every rule; a tier changes what view a human seat gets and play by the rules the engine enforces on
they draw and know, never how correctly they play.</p> 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 <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 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> watched from above or through a wizard's eyes and shared by link.</p>
-1
View File
@@ -34,7 +34,6 @@
stroke: #9d8cc1; stroke: #9d8cc1;
stroke-width: 0.4; stroke-width: 0.4;
stroke-linecap: round; stroke-linecap: round;
pointer-events: none;
animation: shimmer-drift 1.4s ease-in-out infinite; animation: shimmer-drift 1.4s ease-in-out infinite;
} }
.illusion-art { pointer-events: none; } .illusion-art { pointer-events: none; }
+1 -1
View File
@@ -179,7 +179,7 @@
if (motion.reduced) { cam.facing = to; turning = false; return; } if (motion.reduced) { cam.facing = to; turning = false; return; }
const t0 = performance.now(); const t0 = performance.now();
const tick = (now: number) => { const tick = (now: number) => {
const w = motion.reduced ? 1 : Math.min(1, (now - t0) / 180); const w = Math.min(1, (now - t0) / 180);
cam.facing = from + (to - from) * (smoothstep(w)); cam.facing = from + (to - from) * (smoothstep(w));
if (w < 1) turnRaf = requestAnimationFrame(tick); if (w < 1) turnRaf = requestAnimationFrame(tick);
else turning = false; else turning = false;
+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); const p = (t: number, offset = 0) => along(x1, y1, x2, y2, t, offset);
return [0.06, 0.38, 0.71].map((t, i) => { return [0.06, 0.38, 0.71].map((t, i) => {
const sign = i === 1 ? -1 : 1; 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)}`; 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(' '); }).join(" ");
}; };
/** An irregular fracture with branches that become longer as damage increases. */ /** An irregular fracture with branches that become longer as damage increases. */
export const edgeScar = (x1: number, y1: number, x2: number, y2: number, strength: number) => { 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 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); 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. */ /** Silk spokes and two scalloped cross-threads, with a clear center for the token. */
export const webThreads = (r: number) => { export const webThreads = (r: number) => {
const p = (x: number, y: number) => `${x*r} ${y*r}`; 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. */ /** Open painted curls around a warp's precise center. */
export const warpCurls = (x: number, y: number, r: number) => 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}`;
+4 -9
View File
@@ -5,7 +5,7 @@
// by the same depth buffer the walls wrote. // by the same depth buffer the walls wrote.
import { motion } from "../motion"; import { motion } from "../motion";
import { billboards, castRay, warpMotion, type Billboard, type FpvTarget } from "./raycast"; 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 { materialTextures } from "./textures";
import { doorOpenness, fxFallback, growProgress, slateBand, surgeIntensity, type FpFx } from "./fx3d"; import { doorOpenness, fxFallback, growProgress, slateBand, surgeIntensity, type FpFx } from "./fx3d";
import { terrainFallback, TERRAIN3D } from "./terrain3d"; import { terrainFallback, TERRAIN3D } from "./terrain3d";
@@ -198,7 +198,8 @@
} }
function draw(time: number) { function draw(time: number) {
// Keep the final game state and static hazard/target cues visible. // 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 activeFx = motion.effects ? fx : [];
const ambientTime = motion.effects ? time : 0; const ambientTime = motion.effects ? time : 0;
const ctx = canvas?.getContext("2d"); const ctx = canvas?.getContext("2d");
@@ -724,7 +725,7 @@
ctx.rotate(pose.rotation); ctx.rotate(pose.rotation);
paintFist(ctx, pose.size, f.swing); paintFist(ctx, pose.size, f.swing);
ctx.restore(); ctx.restore();
if (pose.radius > H * 0.19 * 0.01) { if (pose.radius > 0.5) {
ctx.save(); ctx.save();
ctx.translate(pose.impactX, pose.impactY); ctx.translate(pose.impactX, pose.impactY);
ctx.rotate(-0.14); ctx.rotate(-0.14);
@@ -776,12 +777,6 @@
if ((ontarget || hover) && mouse) drawHover(ctx, W, H, half); 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. */ /** Paint the hover cue for whatever stands under the crosshair. */
function drawHover(ctx: CanvasRenderingContext2D, W: number, H: number, half: number) { function drawHover(ctx: CanvasRenderingContext2D, W: number, H: number, half: number) {
+4 -3
View File
@@ -10,7 +10,7 @@
import FirstPerson from "./FirstPerson.svelte"; import FirstPerson from "./FirstPerson.svelte";
import Replay from "../Replay.svelte"; import Replay from "../Replay.svelte";
import { canWalk, edgeMid, SIDE_ANGLE, OPPOSITE } from "./raycast"; import { canWalk, edgeMid, SIDE_ANGLE, OPPOSITE } from "./raycast";
import { buildScreenplaySteps, screenplayByName, SCREENPLAYS } from "./screenplays"; import { buildScreenplaySteps, screenplayByName, SCREENPLAYS, type ScreenplayStep } from "./screenplays";
const q = new URLSearchParams(location.search); const q = new URLSearchParams(location.search);
const seed = Number(q.get("seed") ?? 42); const seed = Number(q.get("seed") ?? 42);
@@ -206,7 +206,7 @@
// a refused move reports itself instead of a blank stage. // a refused move reports itself instead of a blank stage.
const scriptName = q.get("script"); const scriptName = q.get("script");
const scriptPlay = scriptName ? screenplayByName(scriptName) : null; const scriptPlay = scriptName ? screenplayByName(scriptName) : null;
const { scriptSteps, scriptError } = (() => { function loadScript(): { scriptSteps: ScreenplayStep[]; scriptError: string | null } {
if (scriptName && !scriptPlay) { if (scriptName && !scriptPlay) {
return { scriptSteps: [], scriptError: `No screenplay named "${scriptName}". The catalog: ${SCREENPLAYS.map((sp) => sp.name).join(", ")}` }; return { scriptSteps: [], scriptError: `No screenplay named "${scriptName}". The catalog: ${SCREENPLAYS.map((sp) => sp.name).join(", ")}` };
} }
@@ -215,7 +215,8 @@
} catch (e) { } catch (e) {
return { scriptSteps: [], 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). // Minimap geometry (top-down, one small square per cell).
const MM = 9; const MM = 9;
+3 -5
View File
@@ -4,7 +4,8 @@
// camera shakes for the raycaster to draw. What happens ACROSS the room // camera shakes for the raycaster to draw. What happens ACROSS the room
// is watched; what happens to YOU is felt. // 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 { fxForEvents } from "../fx";
import { CELL } from "../fx-sprites/geom"; import { CELL } from "../fx-sprites/geom";
import { castRay, edgeMid, warpMotion } from "./raycast"; 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]; 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. */ /** Eased growth 0..1 for a conjuration's rise. */
export function growProgress(p: number): number { export function growProgress(p: number): number {
if (p <= 0) return 0.01; if (p <= 0) return 0.01;
@@ -306,7 +304,7 @@ export function fpFxForEvents(
return out; 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]> = { const FX_COLORS: Record<string, [string, string]> = {
fireball: ["#ffe27a", "#e0431a"], fireball: ["#ffe27a", "#e0431a"],
bolt: ["#ffffff", "#7ab0ff"], bolt: ["#ffffff", "#7ab0ff"],
+81 -41
View File
@@ -1,10 +1,11 @@
/** Inked canvas cels. Paths are shared by the renderer and artwork previews. */ /** 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 }; export type Cel = { d: string; fill?: string; stroke?: string; width?: number };
const INK = "#2b2218"; const INK = "#2b2218";
const ochre = "#dba54b", light = "#f2ce78", shade = "#95602e"; const ochre = "#dba54b", light = "#f2ce78", shade = "#95602e";
/** Poses modeled on the original right-facing and oncoming fist emoji: /** Two fist poses: a punch thrown (wrist left, knuckles right) and one
* folded fingers face the viewer; the thumb stays outside their curl. */ * arriving (knuckles to the viewer). The thumb stays outside the curl. */
export const FISTS: Record<"out" | "in", Cel[]> = { export const FISTS: Record<"out" | "in", Cel[]> = {
out: [ out: [
// Wrist at left, back of hand above, bent index at the right edge. // 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[] = [ 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-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 }, { 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(); ctx.restore();
} }
export function paintFist(ctx: CanvasRenderingContext2D, size: number, swing: "out" | "in") { export function paintFist(ctx: CanvasRenderingContext2D, size: number, swing: "out" | "in") {
ctx.save(); ctx.scale(size / 100, size / 100); paintCels(ctx, FISTS[swing]); ctx.restore(); ctx.save();
} ctx.scale(size / 100, size / 100);
export function paintPow(ctx: CanvasRenderingContext2D, radius: number) { paintCels(ctx, FISTS[swing]);
ctx.save(); ctx.scale(radius / 100, radius / 100); paintCels(ctx, POW); ctx.restore(); 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) { export function punchPose(p: number, out: boolean, W: number, H: number) {
const ease = (v: number) => v * v * (3 - 2 * v); const ease = smoothstep;
// Clamp before easing: otherwise the incoming fist reverses past p=.45. // Clamp before easing: past 0.45 the arriving fist would reverse.
const a = out ? (p < .42 ? ease(p / .42) : ease(1 - (p - .42) / .58)) : ease(Math.min(1, p / .45)); 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 * .86 : W * .5, sy = out ? H * 1.15 : H * .55; const sx = out ? W * 0.86 : W * 0.5, sy = out ? H * 1.15 : H * 0.55;
const tx = W * .56, ty = H * .5; const tx = W * 0.56, ty = H * 0.5;
const land = out ? .36 : .4, q = (p - land) / (1 - land); const land = out ? 0.36 : 0.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 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, return { x: sx + (tx - sx) * a, y: sy + (ty - sy) * a,
size: out ? H * (.62 - .30 * a) : H * (.18 + 1.25 * a), size: out ? H * (0.62 - 0.30 * a) : H * (0.18 + 1.25 * a),
alpha: !out && p > .75 ? 1 - (p - .75) / .25 : 1, alpha: !out && p > 0.75 ? 1 - (p - 0.75) / 0.25 : 1,
rotation: out ? -.6 : .15, radius: H * .19 * pop, rotation: out ? -0.6 : 0.15, radius: H * 0.19 * pop,
impactX: out ? tx - W * .02 : W * .5, impactY: H * .34 }; 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) { export function paintWeb(ctx: CanvasRenderingContext2D, left: number, top: number, w: number, h: number) {
const hub = [left + w * .5, top + h * .45]; type Pt = [number, number];
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 hub: Pt = [left + w * 0.5, top + h * 0.45];
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]; }; 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[])
ctx.save(); ctx.lineJoin = "round"; ctx.lineCap = "round"; .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(); ctx.beginPath();
for (const r of rim) { ctx.moveTo(hub[0]!,hub[1]!); ctx.lineTo(r[0]!,r[1]!); } for (const r of rim) {
for (const t of [.27,.53,.8]) for (let i = 0; i < 8; i++) { ctx.moveTo(hub[0], hub[1]);
const a = at(i,t), b = at(i+1,t); ctx.lineTo(r[0], r[1]);
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]!);
} }
ctx.strokeStyle = "rgba(25,25,30,0.65)"; ctx.lineWidth = 3; ctx.stroke(); // Three sagging rings, each strand bowed toward the hub.
ctx.strokeStyle = "rgba(245,245,240,0.9)"; ctx.lineWidth = 1.3; ctx.stroke(); ctx.restore(); 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) { export function paintFloorRing(
ctx.save(); ctx.fillStyle = `rgba(${rgb},${fill})`; ctx.strokeStyle = `rgba(${rgb},${alpha})`; ctx.lineWidth = 2; ctx: CanvasRenderingContext2D, cx: number, cy: number, rx: number, ry: number, rgb: string, alpha: number, fill: number,
ctx.beginPath(); ctx.ellipse(cx,cy,rx,ry,0,0,Math.PI*2); ctx.fill(); ) {
ctx.save();
ctx.fillStyle = `rgba(${rgb},${fill})`;
ctx.strokeStyle = `rgba(${rgb},${alpha})`;
ctx.lineWidth = 2;
ctx.beginPath(); ctx.beginPath();
const segments = [[.06,1.75],[1.87,3.48],[3.62,5.04],[5.18,6.22]]; ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2);
for (const [start,end] of segments) for (let i=0; i<=20; i++) { ctx.fill();
const t = start! + (end!-start!) * i/20, k = 1 - .018 * (1 + Math.sin(t*5)); // The rim is inked in four strokes with a slight tremor, not one perfect ellipse.
const x = cx + Math.cos(t)*rx*k, y = cy + Math.sin(t)*ry*k; ctx.beginPath();
if (!i) ctx.moveTo(x,y); else ctx.lineTo(x,y); 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[] { export function fallbackCels(name: string, core: string, rim: string): Cel[] {
const outer: Record<string, string> = { 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", 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", 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"; 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> </script>
<g style={`--ax: ${away.x}px; --ay: ${away.y}px; --bx: ${-away.x}px; --by: ${away.y}px`}> <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 - 6, c.y, 7)} 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 + 5, c.y - 2, 5)} 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, c.y + 3, 6)} fill="#b7a786" class="puff later" />
<g class="grit"> <g class="grit">
<path d={puff(c.x - 4, c.y - 5, (1.3) * 1)} /> <path d={puff(c.x - 4, c.y - 5, 1.3)} />
<path d={puff(c.x + 6, c.y + 2, (1.6) * 1)} /> <path d={puff(c.x + 6, c.y + 2, 1.6)} />
<path d={puff(c.x + 1, c.y - 2, (0.9) * 1)} /> <path d={puff(c.x + 1, c.y - 2, 0.9)} />
</g> </g>
</g> </g>
+2 -3
View File
@@ -35,7 +35,7 @@
style={`transform-origin: ${c.x}px ${c.y}px`} style={`transform-origin: ${c.x}px ${c.y}px`}
/> />
{/each} {/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> </g>
<style> <style>
@@ -47,7 +47,7 @@
.spark { animation: spark-out 1.05s ease-out forwards; } .spark { animation: spark-out 1.05s ease-out forwards; }
.trail.late, .spark.late { animation-delay: 0.07s; } .trail.late, .spark.late { animation-delay: 0.07s; }
.trail.later, .spark.later { animation-delay: 0.14s; } .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 { @keyframes trail-out {
0% { opacity: 0; transform: scale(0.15); } 0% { opacity: 0; transform: scale(0.15); }
25% { opacity: 1; } 25% { opacity: 1; }
@@ -63,5 +63,4 @@
0% { opacity: 1; transform: scale(0.5); } 0% { opacity: 1; transform: scale(0.5); }
100% { opacity: 0; transform: scale(3); } 100% { opacity: 0; transform: scale(3); }
} }
.heart { transform-box: fill-box; transform-origin: center; }
</style> </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" /> <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"> <g class="blobs">
<path d={drop(c.x - 12, c.y + 3, (2.5) * 1)} class="blob" /> <path d={drop(c.x - 12, c.y + 3, 2.5)} class="blob" />
<path d={drop(c.x + 11, c.y + 2, (2) * 1)} class="blob late" /> <path d={drop(c.x + 11, c.y + 2, 2)} class="blob late" />
<path d={drop(c.x + 3, c.y - 3, (1.7) * 1)} class="blob later" /> <path d={drop(c.x + 3, c.y - 3, 1.7)} class="blob later" />
</g> </g>
<style> <style>
@@ -19,7 +19,7 @@
transform-origin: center; transform-origin: center;
animation: splat-wobble 0.7s ease-out forwards; 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.late { animation-delay: 0.06s; }
.blob.later { animation-delay: 0.12s; } .blob.later { animation-delay: 0.12s; }
@keyframes splat-wobble { @keyframes splat-wobble {
@@ -33,5 +33,4 @@
50% { transform: translateY(-7px); } 50% { transform: translateY(-7px); }
100% { opacity: 0; transform: translateY(2px); } 100% { opacity: 0; transform: translateY(2px); }
} }
.blob { animation-fill-mode: both; }
</style> </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" /> <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`} /> <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"> <g class="rim-dust">
<path d={puff(c.x - 11, c.y - 6, (3) * 1)} class="dust" /> <path d={puff(c.x - 11, c.y - 6, 3)} class="dust" />
<path d={puff(c.x + 10, c.y - 7, (2.5) * 1)} class="dust late" /> <path d={puff(c.x + 10, c.y - 7, 2.5)} class="dust late" />
<path d={puff(c.x + 1, c.y - 11, (2) * 1)} class="dust later" /> <path d={puff(c.x + 1, c.y - 11, 2)} class="dust later" />
</g> </g>
<style> <style>
@@ -32,8 +32,9 @@
stroke-width: 1.6; stroke-width: 1.6;
stroke-linecap: round; stroke-linecap: round;
animation: hole-open 0.75s ease-out forwards; 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.late { animation-delay: 0.24s; }
.dust.later { animation-delay: 0.32s; } .dust.later { animation-delay: 0.32s; }
@keyframes hole-open { @keyframes hole-open {
@@ -51,6 +52,4 @@
30% { opacity: 0.8; } 30% { opacity: 0.8; }
100% { opacity: 0; transform: translateY(-10px) scale(1.7); } 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> </style>
@@ -51,7 +51,7 @@
{/if} {/if}
<g class="corner-dust"> <g class="corner-dust">
{#each CORNERS as p, i (i)} {#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} {/each}
</g> </g>
@@ -86,6 +86,7 @@
fill: rgba(160, 150, 130, 0.85); fill: rgba(160, 150, 130, 0.85);
opacity: 0; opacity: 0;
animation: dust-kick 0.6s ease-out 0.85s forwards; animation: dust-kick 0.6s ease-out 0.85s forwards;
transform-box: fill-box; transform-origin: center;
} }
.dust.d1 { animation-delay: 0.9s; } .dust.d1 { animation-delay: 0.9s; }
.dust.d2 { animation-delay: 0.95s; } .dust.d2 { animation-delay: 0.95s; }
@@ -128,5 +129,4 @@
30% { opacity: 0.9; } 30% { opacity: 0.9; }
100% { opacity: 0; transform: translateY(-9px) scale(1.6); } 100% { opacity: 0; transform: translateY(-9px) scale(1.6); }
} }
.dust { transform-box: fill-box; transform-origin: center; }
</style> </style>
+4 -4
View File
@@ -11,11 +11,11 @@
<g class="glint counter" style={`transform-origin: ${c.x}px ${c.y}px`}> <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`} /> <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> </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"> <g class="motes">
<path d={glint(c.x - 10, c.y - 9, (1.3) * 1.7)} /> <path d={glint(c.x - 10, c.y - 9, 2.21)} />
<path d={glint(c.x + 11, c.y - 5, (1.1) * 1.7)} class="late" /> <path d={glint(c.x + 11, c.y - 5, 1.87)} class="late" />
<path d={glint(c.x + 2, c.y + 11, (1.2) * 1.7)} class="later" /> <path d={glint(c.x + 2, c.y + 11, 2.04)} class="later" />
</g> </g>
<style> <style>
+1 -2
View File
@@ -32,7 +32,7 @@
stroke-width: 3.2; stroke-width: 3.2;
animation: ring-out 0.55s ease-out forwards; 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.late { animation-delay: 0.07s; }
.drop.later { animation-delay: 0.13s; } .drop.later { animation-delay: 0.13s; }
@keyframes pool-spread { @keyframes pool-spread {
@@ -48,5 +48,4 @@
45% { transform: translateY(-8px); } 45% { transform: translateY(-8px); }
100% { opacity: 0; transform: translateY(6px); } 100% { opacity: 0; transform: translateY(6px); }
} }
.drop { animation-fill-mode: both; }
</style> </style>
+5 -6
View File
@@ -6,16 +6,15 @@ export const center = (c: { x: number; y: number }) => ({
y: c.y * CELL + CELL / 2, y: c.y * CELL + CELL / 2,
}); });
/** Small cel silhouettes, expressed in board coordinates so their animation /** Small cel silhouettes, expressed in board coordinates so their animation
* origins stay attached to the existing cell and edge anchors. */ * origins stay attached to the existing cell and edge anchors. */
export const glint = (x: number, y: number, r: number) => 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) => 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) => 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) => 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) => 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`;
+17 -6
View File
@@ -1,6 +1,6 @@
// Websocket client + reactive session state (Svelte 5 runes). // 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"; import { cardDef } from "@wizwar/engine";
const SERVER_URL = 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 { export function humanize(e: GameEvent): string | null {
switch (e.type) { switch (e.type) {
case "gameStarted": { case "gameStarted": {
@@ -66,7 +69,7 @@ export function humanize(e: GameEvent): string | null {
case "extraTurnGranted": return `${e.player} speeds up — extra turn banked.`; case "extraTurnGranted": return `${e.player} speeds up — extra turn banked.`;
case "wardWindow": return `The grab hangs in the air — ${e.owner} clutches something…`; 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 "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 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 — it tumbles to the floor!`
: `${e.attacker} TEARS the treasure from ${e.defender}'s arms!`; : `${e.attacker} TEARS the treasure from ${e.defender}'s arms!`;
@@ -246,9 +249,11 @@ const ACTING = new Set([
"climbedFromPit", "spellTrapped", "climbedFromPit", "spellTrapped",
]); ]);
function actorOf(e: GameEvent, turnOwner: string | null): string | undefined { function actorOf(e: GameEvent, turnOwner: string | null): string | undefined {
const any = e as unknown as Record<string, unknown>; if ("caster" in e && typeof e.caster === "string") return e.caster;
for (const k of ["caster", "attacker", "by", "owner"]) if (typeof any[k] === "string") return any[k] as string; if ("attacker" in e && typeof e.attacker === "string") return e.attacker;
if (ACTING.has(e.type) && typeof any.player === "string") return any.player as string; 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; return turnOwner ?? undefined;
} }
@@ -620,12 +625,18 @@ class Net {
setTimeout(() => { if (this.error === msg.message) this.error = null; }, 5000); setTimeout(() => { if (this.error === msg.message) this.error = null; }, 5000);
break; break;
} }
} }
private send(message: unknown): void { private send(message: unknown): void {
if (this.ws?.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(message)); 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 { create(name: string): void {
this.you = name; this.you = name;
this.spectating = false; this.spectating = false;
-2
View File
@@ -59,8 +59,6 @@ function load(): Prefs {
wizardName: typeof p.wizardName === "string" ? p.wizardName.slice(0, 20) : "", wizardName: typeof p.wizardName === "string" ? p.wizardName.slice(0, 20) : "",
color: typeof p.color === "number" && p.color >= 0 && p.color <= 5 ? p.color : null, color: typeof p.color === "number" && p.color >= 0 && p.color <= 5 ? p.color : null,
cautions: p.cautions !== false, 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", botTier: p.botTier === "adept" || p.botTier === "archmage" ? p.botTier : "apprentice",
instantReplay: p.instantReplay !== false, instantReplay: p.instantReplay !== false,
liveFp: p.liveFp === true, liveFp: p.liveFp === true,