diff --git a/.claude/skills/wizwar-pulse/SKILL.md b/.claude/skills/wizwar-pulse/SKILL.md index 968b332..6cefdf6 100644 --- a/.claude/skills/wizwar-pulse/SKILL.md +++ b/.claude/skills/wizwar-pulse/SKILL.md @@ -69,7 +69,7 @@ action, or "nothing needs you." - Unattended-upgrades reboots the box at 09:30 UTC when a kernel patch requires it; a reboot there is maintenance, not an outage. - Caddy access logs live at /var/lib/caddy/access.log (self-rotating, - 10MiB × 30 since 2026-09-03); the systemd sandbox denies /var/log/caddy. + 10MiB × 30); the systemd sandbox denies /var/log/caddy. - Per-address limits (2026-09-03): 12 new rooms and 6 reports per address per hour, in packages/server/src/ratelimit.ts. A player who hits one sees "try again in an hour"; a pulse showing many refused diff --git a/deploy/clips-prep.mjs b/deploy/clips-prep.mjs index e8d3a60..56936e9 100755 --- a/deploy/clips-prep.mjs +++ b/deploy/clips-prep.mjs @@ -14,9 +14,10 @@ // Needs ffmpeg + ffprobe and the Playwright chromium cache (see // verify-scenes.mjs). Idempotent: run it again after re-recording. import { execFileSync } from "node:child_process"; -import { existsSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs"; +import { existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { chromium } from "playwright-core"; +import { chromiumExe, ff } from "./lib/harness.mjs"; const dir = process.argv[2]; if (!dir || !existsSync(join(dir, "clips.json"))) { @@ -29,22 +30,6 @@ if (!dir || !existsSync(join(dir, "clips.json"))) { const VIEWPORT = { x: 18, y: 51, w: 700, h: 393 }; const CARD = { width: 1200, height: 630 }; -function chromiumExe() { - if (process.env.WIZWAR_CHROME) return process.env.WIZWAR_CHROME; - const cache = join(process.env.HOME ?? "", "Library", "Caches", "ms-playwright"); - const builds = existsSync(cache) - ? readdirSync(cache).filter((d) => /^chromium-\d+$/.test(d)).sort() - : []; - for (const build of builds.reverse()) { - const exe = join(cache, build, "chrome-mac-arm64", - "Google Chrome for Testing.app", "Contents", "MacOS", "Google Chrome for Testing"); - if (existsSync(exe)) return exe; - } - throw new Error("no Playwright chromium found — run: npx playwright-core install chromium (or set WIZWAR_CHROME)"); -} - -const ff = (...args) => execFileSync("ffmpeg", ["-loglevel", "error", "-y", ...args]); - function faststart(file) { const tmp = `${file}.tmp.mp4`; ff("-i", file, "-c", "copy", "-movflags", "+faststart", tmp); @@ -61,10 +46,11 @@ const esc = (s) => s.replace(/&/g, "&").replace(/
diff --git a/deploy/deploy.sh b/deploy/deploy.sh index 2aa0d2a..4314617 100755 --- a/deploy/deploy.sh +++ b/deploy/deploy.sh @@ -11,18 +11,11 @@ ssh "root@$HOST" ' cd /opt/wizwar && npm install --no-audit --no-fund chown -R wizwar:wizwar /opt/wizwar cp /opt/wizwar/deploy/wizwar.service /etc/systemd/system/wizwar.service - # The backup script is cron-run from /usr/local/bin — install it every - # deploy, or the repo copy and the live copy drift apart (they did: - # the Sentry check-ins shipped in the repo and never reached the box). + # Cron runs the backup and the rollup from /usr/local/bin: install + # them on every deploy so the live copies are always the repo copies. install -m 755 /opt/wizwar/deploy/wizwar-backup.sh /usr/local/bin/wizwar-backup.sh - # The nightly rollup and its cron entry ride along the same way; its - # Sentry check-in URL is the backup monitor'"'"'s with its own slug. install -m 755 /opt/wizwar/deploy/wizwar-rollup.sh /usr/local/bin/wizwar-rollup.sh install -m 644 /opt/wizwar/deploy/wizwar-rollup.cron /etc/cron.d/wizwar-rollup - if [ -f /root/.wizwar-sentry-cron ] && [ ! -f /root/.wizwar-sentry-cron-rollup ]; then - sed "s#/cron/new-monitor/#/cron/wizwar-rollup/#" /root/.wizwar-sentry-cron > /root/.wizwar-sentry-cron-rollup - chmod 600 /root/.wizwar-sentry-cron-rollup - fi systemctl daemon-reload systemctl enable --now wizwar systemctl restart wizwar diff --git a/deploy/film-clips.mjs b/deploy/film-clips.mjs index 20aee59..0acc4be 100644 --- a/deploy/film-clips.mjs +++ b/deploy/film-clips.mjs @@ -10,50 +10,25 @@ // // Run from the repo root AFTER `npx vite build` in packages/web. Needs // ffmpeg and the Playwright chromium cache (see verify-scenes.mjs). -import { execFileSync, spawn } from "node:child_process"; -import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { chromium } from "playwright-core"; +import { chromiumExe, ff, rewindReel, startServer } from "./lib/harness.mjs"; import { SCREENPLAYS } from "../packages/web/src/fpv/screenplays.ts"; const [outDir, ...only] = process.argv.slice(2); if (!outDir) { console.error("usage: film-clips.mjs [scene ...]"); process.exit(1); } const PORT = 8809; const FPS = 25; -const TAIL_MS = 2500; // hold on the last move +const TAIL_MS = 2500; // hold on the last move +const POSTER_SETTLE_MS = 1200; // the poster beat's tween and fx, done const EPOCH = new Date("2026-01-01T00:00:00Z").getTime(); -function chromiumExe() { - if (process.env.WIZWAR_CHROME) return process.env.WIZWAR_CHROME; - const cache = join(process.env.HOME ?? "", "Library", "Caches", "ms-playwright"); - const builds = existsSync(cache) - ? readdirSync(cache).filter((d) => /^chromium-\d+$/.test(d)).sort() - : []; - for (const build of builds.reverse()) { - const exe = join(cache, build, "chrome-mac-arm64", - "Google Chrome for Testing.app", "Contents", "MacOS", "Google Chrome for Testing"); - if (existsSync(exe)) return exe; - } - throw new Error("no Playwright chromium found — run: npx playwright-core install chromium (or set WIZWAR_CHROME)"); -} - -const dataDir = mkdtempSync(join(tmpdir(), "wizwar-film-")); -const server = spawn("npx", ["tsx", "src/index.ts"], { - cwd: join(import.meta.dirname, "..", "packages", "server"), - env: { ...process.env, PORT: String(PORT), WIZWAR_DATA_DIR: join(dataDir, "rooms") }, - stdio: "ignore", -}); -const up = async () => { - for (let i = 0; i < 60; i++) { - try { if ((await fetch(`http://127.0.0.1:${PORT}/`)).ok) return; } catch { /* not yet */ } - await new Promise((r) => setTimeout(r, 500)); - } - throw new Error("server never came up"); -}; +const server = startServer(PORT, "wizwar-film"); try { - await up(); + await server.up(); mkdirSync(outDir, { recursive: true }); const browser = await chromium.launch({ executablePath: chromiumExe(), @@ -71,9 +46,7 @@ try { const page = await ctx.newPage(); await page.clock.install({ time: EPOCH }); await page.goto(`http://127.0.0.1:${PORT}/?fpv&script=${sp.name}`, { waitUntil: "networkidle" }); - await page.locator("button", { hasText: "❚❚" }).click(); - const count = Number((await page.locator(".replay-count").textContent()).match(/of (\d+)/)[1]); - for (let i = 0; i < count; i++) await page.locator("button", { hasText: "◀" }).click(); + const count = await rewindReel(page); if (view === "fpv") await page.getByText("your eyes").click(); await page.clock.pauseAt(EPOCH + 60_000); await page.clock.runFor(400); // let the first frame settle before rolling @@ -82,36 +55,34 @@ try { // Roll: the reel's play button is the ▶ before the step button. await page.locator("button", { hasText: "▶" }).first().click(); // Roll until the reel reaches its last move (steps a die decides - // run longer than the beat), then hold the tail. The poster is - // the first pinned beat, settled. + // run longer than the beat), then hold the tail. const posterBeat = sp.beats?.[0] ?? 1; - let f = 0, tail = -1, posterIn = -1, total = 0; - const MAX_FRAMES = FPS * 240; // a reel that never ends is a bug, not a feature + const MAX_FRAMES = FPS * 240; // four minutes: a reel that never reaches its last move + let f = 0, tail = -1, posterIn = -1; while (tail !== 0 && f < MAX_FRAMES) { const png = await page.screenshot({ clip }); writeFileSync(join(frames, `${String(f).padStart(5, "0")}.png`), png); const at = Number((await page.locator(".replay-count").textContent()).match(/move (\d+)/)[1]); - if (view === "fpv" && posterIn < 0 && at === posterBeat + 1) posterIn = Math.round(1200 / (1000 / FPS)); + if (view === "fpv" && posterIn < 0 && at === posterBeat + 1) posterIn = Math.round(POSTER_SETTLE_MS / (1000 / FPS)); if (posterIn === 0) writeFileSync(join(frames, "poster.png"), png); if (posterIn >= 0) posterIn--; if (tail < 0 && at === count) tail = Math.round(TAIL_MS / (1000 / FPS)); if (tail > 0) tail--; await page.clock.runFor(1000 / FPS); - f++; total += 1000 / FPS; + f++; } - const nFrames = f; await ctx.close(); - execFileSync("ffmpeg", ["-loglevel", "error", "-y", "-framerate", String(FPS), - "-i", join(frames, "%05d.png"), "-vf", "pad=ceil(iw/2)*2:ceil(ih/2)*2", // h264 wants even sides + ff("-framerate", String(FPS), "-i", join(frames, "%05d.png"), + "-vf", "pad=ceil(iw/2)*2:ceil(ih/2)*2", // h264 wants even sides "-c:v", "libx264", "-pix_fmt", "yuv420p", "-crf", "22", - "-movflags", "+faststart", join(outDir, `${sp.name}-${view}.mp4`)]); + "-movflags", "+faststart", join(outDir, `${sp.name}-${view}.mp4`)); if (view === "fpv") { const poster = existsSync(join(frames, "poster.png")) ? join(frames, "poster.png") : join(frames, "00000.png"); - execFileSync("ffmpeg", ["-loglevel", "error", "-y", "-i", poster, "-q:v", "3", join(outDir, `${sp.name}.jpg`)]); - seconds = Math.round(total / 1000); + ff("-i", poster, "-q:v", "3", join(outDir, `${sp.name}.jpg`)); + seconds = Math.round(f / FPS); } rmSync(frames, { recursive: true, force: true }); - console.log(`${sp.name} ${view}: ${nFrames} frames`); + console.log(`${sp.name} ${view}: ${f} frames`); } catalog.push({ name: sp.name, title: sp.title, blurb: sp.blurb, seconds }); } @@ -124,6 +95,5 @@ try { writeFileSync(file, JSON.stringify(merged, null, 2) + "\n"); console.log(`filmed ${catalog.length} scenes into ${outDir}`); } finally { - server.kill(); - rmSync(dataDir, { recursive: true, force: true }); + server.stop(); } diff --git a/deploy/lib/harness.mjs b/deploy/lib/harness.mjs new file mode 100644 index 0000000..45e83c3 --- /dev/null +++ b/deploy/lib/harness.mjs @@ -0,0 +1,56 @@ +// Shared by the scene gate, the clip recorder, and the card cutter: the +// Playwright chromium they all drive, a private game server on a +// throwaway data dir, ffmpeg with the flags they all want, and the +// reel's rewind. Run from the repo root after `npx vite build` in +// packages/web. +import { execFileSync, spawn } from "node:child_process"; +import { existsSync, mkdtempSync, readdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +/** The Playwright chromium executable: $WIZWAR_CHROME, else the newest + * build in the ms-playwright cache. */ +export function chromiumExe() { + if (process.env.WIZWAR_CHROME) return process.env.WIZWAR_CHROME; + const cache = join(process.env.HOME ?? "", "Library", "Caches", "ms-playwright"); + const builds = existsSync(cache) + ? readdirSync(cache).filter((d) => /^chromium-\d+$/.test(d)).sort() + : []; + for (const build of builds.reverse()) { + const exe = join(cache, build, "chrome-mac-arm64", + "Google Chrome for Testing.app", "Contents", "MacOS", "Google Chrome for Testing"); + if (existsSync(exe)) return exe; + } + throw new Error("no Playwright chromium found — run: npx playwright-core install chromium (or set WIZWAR_CHROME)"); +} + +/** A game server on `port` with its own empty data dir. `up()` resolves + * once it answers; `stop()` kills it and removes the dir. */ +export function startServer(port, label = "wizwar-harness") { + const dataDir = mkdtempSync(join(tmpdir(), `${label}-`)); + const server = spawn("npx", ["tsx", "src/index.ts"], { + cwd: join(import.meta.dirname, "..", "..", "packages", "server"), + env: { ...process.env, PORT: String(port), WIZWAR_DATA_DIR: join(dataDir, "rooms") }, + stdio: "ignore", + }); + const up = async () => { + for (let i = 0; i < 60; i++) { + try { if ((await fetch(`http://127.0.0.1:${port}/`)).ok) return; } catch { /* not yet */ } + await new Promise((r) => setTimeout(r, 500)); + } + throw new Error("server never came up"); + }; + const stop = () => { server.kill(); rmSync(dataDir, { recursive: true, force: true }); }; + return { up, stop }; +} + +/** ffmpeg, quiet and overwriting. */ +export const ff = (...args) => execFileSync("ffmpeg", ["-loglevel", "error", "-y", ...args]); + +/** Pause the reel and step it back to its first move. */ +export async function rewindReel(page) { + await page.locator("button", { hasText: "❚❚" }).click(); + const count = Number((await page.locator(".replay-count").textContent()).match(/of (\d+)/)[1]); + for (let i = 0; i < count; i++) await page.locator("button", { hasText: "◀" }).click(); + return count; +} diff --git a/deploy/setup-droplet.sh b/deploy/setup-droplet.sh index e70907a..c2621d6 100755 --- a/deploy/setup-droplet.sh +++ b/deploy/setup-droplet.sh @@ -25,8 +25,8 @@ mkdir -p /opt/wizwar /var/lib/wizwar/rooms chown -R wizwar:wizwar /opt/wizwar /var/lib/wizwar # Caddy vhost: auto-TLS, security headers, proxy to the game. -# Access log kept ~30 days (10MiB x 30): the nightly rollup keeps the -# counts forever, the raw lines back it for a month. +# Access log kept to 30 rolls of 10MiB: the nightly rollup keeps the +# counts; the raw lines back it for a while. printf '%s\n\nheader {\n\tStrict-Transport-Security "max-age=31536000"\n\tX-Content-Type-Options "nosniff"\n\tX-Frame-Options "DENY"\n\tReferrer-Policy "no-referrer"\n}\nlog {\n\toutput file /var/lib/caddy/access.log {\n\t\troll_size 10MiB\n\t\troll_keep 30\n\t}\n}\nreverse_proxy localhost:8787\n' "$HOST" > /etc/caddy/Caddyfile systemctl reload caddy diff --git a/deploy/verify-scenes.mjs b/deploy/verify-scenes.mjs index 483eef0..58a004a 100644 --- a/deploy/verify-scenes.mjs +++ b/deploy/verify-scenes.mjs @@ -11,11 +11,12 @@ // Run from the repo root AFTER `npx vite build` in packages/web (the // wrapper deploy/verify-scenes.sh does both). Needs the Playwright // chromium cache (`npx playwright-core install chromium` if missing). -import { execFileSync, spawn } from "node:child_process"; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { execFileSync } from "node:child_process"; +import { existsSync, mkdirSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { chromium } from "playwright-core"; +import { chromiumExe, rewindReel, startServer } from "./lib/harness.mjs"; import { SCREENPLAYS } from "../packages/web/src/fpv/screenplays.ts"; const args = process.argv.slice(2); @@ -31,20 +32,6 @@ const CHANNEL_TOL = 6; const FAIL_FRAC = 0.002; const EPOCH = new Date("2026-01-01T00:00:00Z").getTime(); -function chromiumExe() { - if (process.env.WIZWAR_CHROME) return process.env.WIZWAR_CHROME; - const cache = join(process.env.HOME ?? "", "Library", "Caches", "ms-playwright"); - const builds = existsSync(cache) - ? readdirSync(cache).filter((d) => /^chromium-\d+$/.test(d)).sort() - : []; - for (const build of builds.reverse()) { - const exe = join(cache, build, "chrome-mac-arm64", - "Google Chrome for Testing.app", "Contents", "MacOS", "Google Chrome for Testing"); - if (existsSync(exe)) return exe; - } - throw new Error("no Playwright chromium found — run: npx playwright-core install chromium (or set WIZWAR_CHROME)"); -} - /** Decode a PNG to raw RGBA via ffmpeg — the harness's only image dep. */ function rgba(png) { return execFileSync("ffmpeg", ["-loglevel", "error", "-i", png, @@ -64,26 +51,11 @@ function compare(goldenPng, actualPng) { } // --- A private server on a throwaway data dir. --------------------------- -const dataDir = mkdtempSync(join(tmpdir(), "wizwar-scenes-")); -const server = spawn("npx", ["tsx", "src/index.ts"], { - cwd: join(import.meta.dirname, "..", "packages", "server"), - env: { ...process.env, PORT: String(PORT), WIZWAR_DATA_DIR: join(dataDir, "rooms") }, - stdio: "ignore", -}); -const up = async () => { - for (let i = 0; i < 60; i++) { - try { - const r = await fetch(`http://127.0.0.1:${PORT}/`); - if (r.ok) return; - } catch { /* not yet */ } - await new Promise((r) => setTimeout(r, 500)); - } - throw new Error("server never came up"); -}; +const server = startServer(PORT, "wizwar-scenes"); let failures = 0; try { - await up(); + await server.up(); mkdirSync(GOLDENS, { recursive: true }); rmSync(DIFFS, { recursive: true, force: true }); mkdirSync(DIFFS, { recursive: true }); @@ -95,10 +67,8 @@ try { if (!scenes.length) throw new Error(`no scenes match: ${only.join(", ")}`); for (const sp of scenes) { - // One page per view: the reel steps forward beat to beat under the - // paused clock, so a scene's later beats cost only their marginal - // steps — and every beat's simulated capture time is still exactly - // (steps so far) × 2.6s, identical every run. + // One page per view: the reel steps forward beat to beat, so a + // scene's later beats cost only their marginal steps. const takes = [ { view: "fpv", beats: [...(sp.beats ?? [])].sort((a, b) => a - b) }, { view: "board", beats: sp.boardBeat === undefined ? [] : [sp.boardBeat] }, @@ -109,13 +79,11 @@ try { const page = await ctx.newPage(); await page.clock.install({ time: EPOCH }); await page.goto(`http://127.0.0.1:${PORT}/?fpv&script=${sp.name}`, { waitUntil: "networkidle" }); - await page.locator("button", { hasText: "❚❚" }).click(); - for (let i = 0; i < 4; i++) await page.locator("button", { hasText: "◀" }).click(); + await rewindReel(page); if (view === "fpv") await page.getByText("your eyes").click(); - // Freeze the world at a fixed instant, then advance it by hand. - // Steps on the way to a beat get 0.6 simulated seconds; the beat - // itself gets 2.6 to settle its tween, hold, and fx. The schedule - // is fixed, so every run renders the same bytes. + // Freeze the world at a fixed instant, then advance it by hand: + // steps on the way to a beat get 0.6 simulated seconds, the beat + // itself 2.6 to settle its tween, hold, and fx. await page.clock.pauseAt(EPOCH + 60_000); let at = 0; for (const beat of beats) { @@ -154,8 +122,7 @@ try { } await browser.close(); } finally { - server.kill(); - rmSync(dataDir, { recursive: true, force: true }); + server.stop(); } if (!update) console.log(`--- ${failures ? `${failures} beat(s) drifted` : "every pinned beat matches its golden"}`); process.exit(failures ? 1 : 0); diff --git a/deploy/wizwar-rollup.sh b/deploy/wizwar-rollup.sh index 48e004c..6d481e4 100755 --- a/deploy/wizwar-rollup.sh +++ b/deploy/wizwar-rollup.sh @@ -6,7 +6,7 @@ # self-rotating access log cannot keep. # Cron: 10 0 * * * (UTC), see /etc/cron.d/wizwar-rollup (deploy.sh installs both). # Sentry Crons check-in: /root/.wizwar-sentry-cron-rollup holds the URL -# (derived from the backup monitor's on first deploy; absent = no check-ins). +# (absent = no check-ins). set -u OUT="/var/lib/wizwar/rollup.jsonl" LOG="/var/log/wizwar/rollup.log" @@ -89,8 +89,9 @@ def sh(cmd): mem = sh("systemctl show wizwar -p MemoryCurrent --value"); peak = sh("systemctl show wizwar -p MemoryPeak --value") disk = sh("df --output=pcent / | tail -1").strip().rstrip("%") load = sh("cut -d' ' -f1 /proc/loadavg") -errs = sh("journalctl -u wizwar --since '%s' --until '%s' --no-pager 2>/dev/null | grep -ci 'unhandled protocol error'" % (day, (d0 + datetime.timedelta(days=1)).date())) -starts = sh("journalctl -u wizwar --since '%s' --until '%s' --no-pager 2>/dev/null | grep -c 'Started wizwar'" % (day, (d0 + datetime.timedelta(days=1)).date())) +journal = sh("journalctl -u wizwar --since '%s' --until '%s' --no-pager 2>/dev/null" % (day, (d0 + datetime.timedelta(days=1)).date())) +errs = str(len(re.findall(r"(?i)unhandled protocol error", journal))) +starts = str(journal.count("Started wizwar")) ledger = sum(os.path.getsize(f) for f in glob.glob("/var/lib/wizwar/rooms/*.jsonl")) row = { "day": day, "requests": req, "human": human, "bot": bot, diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index 30139b0..1d95b97 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -5743,6 +5743,8 @@ function doCounteract( ): CommandResult { const state = clone(prev); const stack = state.stack!; + /** What the counter is played against, as the chronicle names it. */ + const against = stack.attackCard?.cardId ?? stack.sourceName ?? "punch"; const player = state.players.find((p) => p.id === playerId)!; const card = player.hand.find((c) => c.instanceId === instanceId); if (!card) return err("card not in hand"); @@ -5779,7 +5781,7 @@ function doCounteract( } player.hand.push(attackCard); const events: GameEvent[] = [ - { type: "counteractionPlayed", player: playerId, card, cardId: card.cardId, against: stack.attackCard?.cardId ?? stack.sourceName ?? "punch" }, + { type: "counteractionPlayed", player: playerId, card, cardId: card.cardId, against }, { type: "attackAbsorbedIntoHand", player: playerId, attackCard }, { type: "attackResolved", attacker: stack.attackerId, defender: stack.defenderId, attackCardId: attackCard.cardId, damageDealt: 0, reflectedDamage: 0, fullyStopped: true, redirected: false }, ]; @@ -5809,7 +5811,7 @@ function doCounteract( return { ok: true, state, - events: [{ type: "counteractionPlayed", player: playerId, card, cardId: card.cardId, against: stack.attackCard?.cardId ?? stack.sourceName ?? "punch" }], + events: [{ type: "counteractionPlayed", player: playerId, card, cardId: card.cardId, against }], }; } @@ -5821,7 +5823,7 @@ function doCounteract( takeFromHand(player, instanceId); state.discard.push(card); const events: GameEvent[] = [ - { type: "counteractionPlayed", player: playerId, card, cardId: card.cardId, against: stack.attackCard?.cardId ?? stack.sourceName ?? "punch" }, + { type: "counteractionPlayed", player: playerId, card, cardId: card.cardId, against }, ]; attachSustained(state, events, "invisible", playerId, playerId, duration); stack.counters.push({ player: playerId, card, nullified: false }); @@ -5838,7 +5840,7 @@ function doCounteract( takeFromHand(player, instanceId); state.discard.push(card); const events: GameEvent[] = [ - { type: "counteractionPlayed", player: playerId, card, cardId: card.cardId, against: stack.attackCard?.cardId ?? stack.sourceName ?? "punch" }, + { type: "counteractionPlayed", player: playerId, card, cardId: card.cardId, against }, ]; attachSustained(state, events, "empathy", playerId, playerId, duration); stack.counters.push({ player: playerId, card, nullified: false }); @@ -5877,7 +5879,7 @@ function doCounteract( return { ok: true, state, - events: [{ type: "counteractionPlayed", player: playerId, card, cardId: card.cardId, against: stack.attackCard?.cardId ?? stack.sourceName ?? "punch" }], + events: [{ type: "counteractionPlayed", player: playerId, card, cardId: card.cardId, against }], }; } @@ -5897,7 +5899,7 @@ function doCounteract( return { ok: true, state, - events: [{ type: "counteractionPlayed", player: playerId, card, cardId: card.cardId, against: stack.attackCard?.cardId ?? stack.sourceName ?? "punch" }], + events: [{ type: "counteractionPlayed", player: playerId, card, cardId: card.cardId, against }], }; } diff --git a/packages/engine/src/view.ts b/packages/engine/src/view.ts index bd2f636..51e66a2 100644 --- a/packages/engine/src/view.ts +++ b/packages/engine/src/view.ts @@ -191,12 +191,6 @@ export function viewFor(state: GameState, playerId: PlayerId): GameView { }; } -/** - * Every cell the viewing player can see from where they stand, by the same - * line-of-sight rules the engine enforces — computed from the VIEW, so it - * reflects what this player knows (illusion walls they believe in block it). - * The basis for the client's "dim the ineligible squares" targeting aid. - */ /** Every square inside a living FEAR-bearer's aura — dreadDistance walks * through warps, the engine's own yardstick, so the painted aura and the * movement refusal can never disagree. */ @@ -213,6 +207,12 @@ export function fearCells(view: GameView): Set { return out; } +/** + * Every cell the viewing player can see from where they stand, by the same + * line-of-sight rules the engine enforces — computed from the VIEW, so it + * reflects what this player knows (illusion walls they believe in block it). + * The basis for the client's "dim the ineligible squares" targeting aid. + */ export function sightedCellsFor(view: GameView): Set { const out = new Set(); const me = view.players.find((p) => p.id === view.you); diff --git a/packages/server/src/clips.ts b/packages/server/src/clips.ts index dece34a..540c26c 100644 --- a/packages/server/src/clips.ts +++ b/packages/server/src/clips.ts @@ -17,6 +17,7 @@ const PAGE_CSS = ` .mast a { font-family: "Oswald", sans-serif; text-transform: uppercase; letter-spacing: 0.18em; font-size: 0.95rem; color: #e9e1cb; text-decoration: none; } .mast .crumb { color: #8d8672; font-size: 0.85rem; letter-spacing: 0.06em; } + .mast .crumb a { font: inherit; letter-spacing: inherit; text-transform: none; color: inherit; } .mast .deal { margin-left: auto; font-size: 0.8rem; letter-spacing: 0.12em; color: #e0b34a; } h1 { font-family: "Oswald", sans-serif; font-weight: 500; font-size: 1.7rem; letter-spacing: 0.06em; text-transform: uppercase; margin: 0 0 0.3rem; color: #e9e1cb; } @@ -137,7 +138,7 @@ export function clipPageHtml(clip: ClipMeta, base: string): string { ``, ]; const body = ` - +

${esc(clip.title)}

${esc(clip.blurb)}

diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 8e0c0dd..6b29495 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -31,7 +31,7 @@ // {type:"error", message} import * as Sentry from "@sentry/node"; -import { createServer } from "node:http"; +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; import { randomBytes } from "node:crypto"; import { readFileSync, existsSync, realpathSync, statSync, createReadStream } from "node:fs"; import { extname, join, normalize, sep } from "node:path"; @@ -65,7 +65,7 @@ import { abandonRoom, } from "./rooms"; import { engagementStats, recordHotseat } from "./stats"; -import { appendFeedback, readFeedback, readClips, clipAssetPath } from "./store"; +import { appendFeedback, readFeedback, readClips, clipAssetPath, CLIP_SLUG } from "./store"; import { clipsIndexHtml, clipPageHtml } from "./clips"; import { SlidingLimit, clientAddress } from "./ratelimit"; import { getShare, loadShares, mintShare } from "./shares"; @@ -184,6 +184,15 @@ function ogPage(metas: string[]): string { /** Host and proto arrive from request headers — attacker-writable text * that must never reach an HTML attribute raw. */ +const noSuchClip = (res: ServerResponse) => + res.writeHead(404, { "content-type": "text/plain" }).end("no such clip — see /clips"); + +/** The absolute origin a request came in on, as the proxy saw it. */ +function requestBase(req: IncomingMessage): string { + const proto = String(req.headers["x-forwarded-proto"] ?? "http").split(",")[0]!.trim(); + return safeBase(String(req.headers.host ?? `localhost:${port}`), proto); +} + function safeBase(rawHost: string, rawProto: string): string { const proto = /^https?$/.test(rawProto) ? rawProto : "https"; return `${proto}://${escapeHtml(rawHost)}`; @@ -312,16 +321,15 @@ const httpServer = createServer((req, res) => { // with Range support — Safari refuses an mp4 whose server can't // serve bytes 0-1 on demand. if (url === "/clips" || url === "/clips/") { - const proto = String(req.headers["x-forwarded-proto"] ?? "http").split(",")[0]!.trim(); - const base = safeBase(String(req.headers.host ?? `localhost:${port}`), proto); + const base = requestBase(req); res.writeHead(200, { "content-type": "text/html", "cache-control": "no-cache" }); res.end(clipsIndexHtml(readClips(), base)); return; } - const clipAsset = url.match(/^\/clips\/([a-z0-9-]{1,70}\.(?:mp4|jpg))$/); + const clipAsset = url.match(/^\/clips\/([^/]+\.(?:mp4|jpg))$/); if (clipAsset) { const path = clipAssetPath(clipAsset[1]!); - if (!path) { res.writeHead(404).end("no such clip"); return; } + if (!path) { noSuchClip(res); return; } const size = statSync(path).size; const type = path.endsWith(".mp4") ? "video/mp4" : "image/jpeg"; const range = /^bytes=(\d*)-(\d*)$/.exec(String(req.headers.range ?? "")); @@ -349,17 +357,16 @@ const httpServer = createServer((req, res) => { createReadStream(path).pipe(res); return; } - const clipPage = url.match(/^\/clips\/([a-z0-9-]{1,60})$/); - if (clipPage) { + const clipPage = url.match(/^\/clips\/([^/]+)$/); + if (clipPage && CLIP_SLUG.test(clipPage[1]!)) { const clip = readClips().find((c) => c.name === clipPage[1]); if (clip) { - const proto = String(req.headers["x-forwarded-proto"] ?? "http").split(",")[0]!.trim(); - const base = safeBase(String(req.headers.host ?? `localhost:${port}`), proto); + const base = requestBase(req); res.writeHead(200, { "content-type": "text/html", "cache-control": "no-cache" }); res.end(clipPageHtml(clip, base)); return; } - res.writeHead(404, { "content-type": "text/html" }).end("no such clip — see /clips"); + noSuchClip(res); return; } // Room invitations: a living room gets its recruiting card; a dead diff --git a/packages/server/src/ratelimit.ts b/packages/server/src/ratelimit.ts index 3e38e87..e04d773 100644 --- a/packages/server/src/ratelimit.ts +++ b/packages/server/src/ratelimit.ts @@ -31,6 +31,8 @@ export class SlidingLimit { } } +const LOOPBACK = new Set(["127.0.0.1", "::1", "::ffff:127.0.0.1"]); + /** The client's address as Caddy reports it. The proxy APPENDS the true * peer to X-Forwarded-For, so the last entry is the trustworthy one; a * client can write anything into the first. And the header is believed @@ -43,5 +45,3 @@ export function clientAddress(headers: Record s.trim()).filter(Boolean); return parts[parts.length - 1] || peer; } - -const LOOPBACK = new Set(["127.0.0.1", "::1", "::ffff:127.0.0.1"]); diff --git a/packages/server/src/store.ts b/packages/server/src/store.ts index 5011007..0411e34 100644 --- a/packages/server/src/store.ts +++ b/packages/server/src/store.ts @@ -235,24 +235,28 @@ export interface ClipMeta { const clipsDir = () => join(DATA_DIR, "..", "clips"); +/** A clip's name, which is also its file stem: no separators, no dots, + * so a name can never name a path. */ +const SLUG = "[a-z0-9-]{1,60}"; +export const CLIP_SLUG = new RegExp(`^${SLUG}$`); +const CLIP_FILE = new RegExp(`^${SLUG}(?:(?:-fpv|-board)\\.mp4|(?:-card)?\\.jpg)$`); + export function readClips(): ClipMeta[] { const file = join(clipsDir(), "clips.json"); if (!existsSync(file)) return []; try { const parsed = JSON.parse(readFileSync(file, "utf8")) as ClipMeta[]; - return parsed.filter((c) => /^[a-z0-9-]{1,60}$/.test(c.name)); + return parsed.filter((c) => CLIP_SLUG.test(c.name)); } catch { return []; } } /** Resolve a clip asset request to its path — or null for any name that - * is not exactly a published clip file shape. The gate IS the security: - * nothing outside (-fpv|-board).mp4 / (-card)?.jpg can be named. */ + * is not exactly a published clip file: -fpv.mp4, -board.mp4, + * .jpg, -card.jpg. The pattern is the only path guard. */ export function clipAssetPath(file: string): string | null { - if (!/^[a-z0-9-]{1,60}(-fpv|-board)\.mp4$/.test(file) && !/^[a-z0-9-]{1,60}(-card)?\.jpg$/.test(file)) { - return null; - } + if (!CLIP_FILE.test(file)) return null; const path = join(clipsDir(), file); return existsSync(path) ? path : null; } diff --git a/packages/web/src/LiveFirstPerson.svelte b/packages/web/src/LiveFirstPerson.svelte index 2472c9f..a40fa7d 100644 --- a/packages/web/src/LiveFirstPerson.svelte +++ b/packages/web/src/LiveFirstPerson.svelte @@ -10,7 +10,7 @@ import FirstPerson from "./fpv/FirstPerson.svelte"; import type { FpvTarget } from "./fpv/raycast"; import { objectArt, tokenArt } from "./art"; - import { fpFxForEvents, type FpFx } from "./fpv/fx3d"; + import { fpFxForEvents, smoothstep, type FpFx } from "./fpv/fx3d"; import { castRay, SIDE_ANGLE, OPPOSITE } from "./fpv/raycast"; import { cutawayStand, deepestFacing } from "./fpv/director"; import { aimOfEvents, gatherGlides, hurledIn, shortestArc } from "./fpv/director"; @@ -176,7 +176,7 @@ const t0 = performance.now(); const tick = (now: number) => { const w = Math.min(1, (now - t0) / 180); - cam.facing = from + (to - from) * (w * w * (3 - 2 * w)); + cam.facing = from + (to - from) * (smoothstep(w)); if (w < 1) requestAnimationFrame(tick); else turning = false; }; @@ -267,7 +267,7 @@ let raf = 0; const tick = (now: number) => { const w = Math.min(1, Math.max(0, (now - t0) / dur)); - const ease = w * w * (3 - 2 * w); + const ease = smoothstep(w); const next: Record = {}; for (const m of moves) { next[m.id] = { x: m.fx + (m.tx - m.fx) * ease, y: m.fy + (m.ty - m.fy) * ease }; @@ -403,7 +403,7 @@ } else if (walkMs > 0 && t < turnMs + walkMs) { cam.facing = fromF + arc; const w = (t - turnMs) / walkMs; - const ease = w * w * (3 - 2 * w); + const ease = smoothstep(w); cam.x = fromX + (tx - fromX) * ease; cam.y = fromY + (ty - fromY) * ease; } else { diff --git a/packages/web/src/Replay.svelte b/packages/web/src/Replay.svelte index 1955b55..a013495 100644 --- a/packages/web/src/Replay.svelte +++ b/packages/web/src/Replay.svelte @@ -5,9 +5,9 @@ import { humanize, spellName } from "./net.svelte"; import { tokenArt } from "./art"; import { scheduleFx, type BoardFx } from "./fx"; - import { fpFxForEvents, type FpFx } from "./fpv/fx3d"; + import { fpFxForEvents, smoothstep, type FpFx } from "./fpv/fx3d"; import { castRay, edgeMid } from "./fpv/raycast"; - import { cutawayStand, deepestFacing, aimOfEvents, gatherGlides, hurledIn, shortestArc, sightline } from "./fpv/director"; + import { cutawayStand, aimOfEvents, gatherGlides, hurledIn, shortestArc, sightline } from "./fpv/director"; import { prefs } from "./prefs.svelte"; import { cardDef, isPermanentDuration, stackSightTrace } from "@wizwar/engine"; import type { GameEvent, GameView } from "@wizwar/engine"; @@ -99,7 +99,9 @@ const t0 = performance.now(); const dur = DIE_MS / speed; const who = d.player ?? "The maze"; - die = { idx: i, player: who, roll: d.roll, purpose: d.purpose, face: 1, landed: false }; + const show = (face: number, landed: boolean) => + (die = { idx: i, player: who, roll: d.roll, purpose: d.purpose, face, landed }); + show(1, false); let raf = 0; const tick = (now: number) => { const p = Math.max(0, now - t0) / dur; @@ -108,9 +110,9 @@ // The tumble: faces flick past, slowing as the die settles. A // fixed sequence, so the harness films the same roll every time. const n = Math.floor(p * 26 - p * p * 18); - die = { idx: i, player: who, roll: d.roll, purpose: d.purpose, face: ((n * 3 + 1) % 4) + 1, landed: false }; + show(((n * 3 + 1) % 4) + 1, false); } else if (!die?.landed) { - die = { idx: i, player: who, roll: d.roll, purpose: d.purpose, face: d.roll, landed: true }; + show(d.roll, true); } raf = requestAnimationFrame(tick); }; @@ -122,15 +124,14 @@ /** What you drew belongs in the chronicle, not on a reel that may be * recorded and passed around — captions keep the count, not the cards. */ const CAPTION_SILENT = new Set(["cardsDrawnPrivate", "cardsDealtPrivate", "cardsStolenPrivate"]); - const lines = $derived( - step.events - .filter((e) => !CAPTION_SILENT.has(e.type)) - .map(humanize) - .filter((l): l is string => l !== null), - ); + const captionLines = (events: GameEvent[], silent: Set) => + events.filter((e) => !silent.has(e.type)).map(humanize).filter((l): l is string => l !== null); + const lines = $derived(captionLines(step.events, CAPTION_SILENT)); const atEnd = $derived(idx >= steps.length - 1); - /** What the roll brought about, in the caption's own words. */ - const dieVerdict = $derived(lines.filter((l) => !l.startsWith("\u{1F3B2}")).slice(0, 2).join(" ")); + /** What the roll brought about, in the caption's own words — the roll + * itself is on the die card, so its line is left out. */ + const VERDICT_SILENT = new Set([...CAPTION_SILENT, "dieRolled"]); + const dieVerdict = $derived(captionLines(step.events, VERDICT_SILENT).slice(0, 2).join(" ")); /** The acting wizard's standee, shown beside their words. */ const actorArt = $derived.by(() => { @@ -147,7 +148,7 @@ const p = v.players.find((x) => x.id === step.actor); if (!p) return null; const acting = v.activePlayerId === p.id; - const steps = acting ? Math.max(0, v.turn.movementAllowance - v.turn.movementUsed) : null; + const strides = acting ? Math.max(0, v.turn.movementAllowance - v.turn.movementUsed) : null; const chips: string[] = [`♥ ${p.life}`]; if (p.carriedTreasureId) chips.push("carrying a treasure"); for (const c of p.displayed) chips.push(cardDef(c.cardId).name.toLowerCase()); @@ -158,7 +159,7 @@ if (p.lostTurns > 0) chips.push(`loses ${p.lostTurns} turn${p.lostTurns === 1 ? "" : "s"}`); if (p.passWallCharges > 0) chips.push(`pass wall × ${p.passWallCharges}`); if (acting && v.turn.attackUsed) chips.push("attack spent"); - return { steps, chips }; + return { strides, chips }; }); /** A change of eyes wipes a slate across the pane: the cut to another @@ -257,8 +258,8 @@ $effect(() => { if (!playing) return; // Each step gets the reel's beat, plus the die's interlude when - // one decided it. Read the step here so every advance re-arms the - // timer — a derived that stays 0 across steps would not. + // one decided it. Reading idx here is what re-arms the timer on + // every advance. const from = idx; const t = setTimeout(() => { if (idx !== from) return; @@ -287,7 +288,7 @@ let raf = 0; const tick = (now: number) => { const w = Math.min(1, Math.max(0, (now - t0) / dur)); - const ease = w * w * (3 - 2 * w); + const ease = smoothstep(w); const next: Record = {}; for (const m of moves) { next[m.id] = { x: m.fx + (m.tx - m.fx) * ease, y: m.fy + (m.ty - m.fy) * ease }; @@ -308,6 +309,11 @@ /** How far the eyes drop to watch their own feet: the horizon rises * to the pane's top third and the ground underfoot fills the rest. */ const LOOK_DOWN = 0.36; + /** Deeds on the ground of your own square, watched by looking down. + * A punch traded with a square-mate is not one: they stand in front + * of the eyes wherever those point. */ + const GROUNDWORK = new Set(["spellCast", "squareFilled", "creatureCreated", + "treasurePickedUp", "treasureDropped", "objectDropped"]); let camReady = false; $effect(() => { if (!fp) { camReady = false; return; } @@ -364,18 +370,11 @@ }; let targetFacing = camF; let aimed = false; - // Something at your own feet — a conjuration on your square, a - // treasure taken up, the pit you just fell down — is watched by - // looking down; the eyes come back level with the next step. + // The pit you fell down, or climbed from, is at your feet too; the + // eyes come back level with the next step. const underfoot = step.events.some((e) => - (e.type === "fellInPit" || e.type === "climbedFromPit") && "player" in e && (e as { player: string }).player === povId); + (e.type === "fellInPit" || e.type === "climbedFromPit") && e.player === povId); let targetPitch = 0; - // Only GROUND deeds earn the look-down: a conjuration on this - // square, a treasure taken up or set down. A punch traded with - // someone sharing the square aims at nobody's feet — they stand - // right in front of the eyes, wherever those point. - const GROUNDWORK = new Set(["spellCast", "squareFilled", "creatureCreated", - "treasurePickedUp", "treasureDropped", "objectDropped"]); const groundwork = aim?.self && step.events.some((e) => GROUNDWORK.has(e.type)); /** A stride that ends with something to watch — an ambush that * fired as the step landed — turns once more after the walk. */ @@ -392,10 +391,7 @@ targetPitch = LOOK_DOWN; aimed = true; } - else if (aim?.self) { - // Nothing to turn toward: keep the view, unless it is a wall. - } - else if (aim) { + else if (aim && !aim.self) { const ax = aim.x + 0.5, ay = aim.y + 0.5; // The eyes watch their own magic land wherever they can see it — // straight down a corridor or through a warp mouth. Only a target @@ -523,20 +519,20 @@ const t = Math.max(0, now - t0); if (tiltMs > 0) { const w = Math.min(1, t / tiltMs); - cam.pitch = fromP + (targetPitch - fromP) * (w * w * (3 - 2 * w)); + cam.pitch = fromP + (targetPitch - fromP) * (smoothstep(w)); } if (turnMs > 0 && t < turnMs) { cam.facing = fromF + arc * (t / turnMs); } else if (walkMs > 0 && t < turnMs + walkMs) { cam.facing = fromF + arc; const w = (t - turnMs) / walkMs; - const ease = w * w * (3 - 2 * w); + const ease = smoothstep(w); cam.x = fromX + (tx - fromX) * ease; cam.y = fromY + (ty - fromY) * ease; } else if (closingArc !== 0 && t < turnMs + walkMs + closingMs) { cam.x = tx; cam.y = ty; const w = (t - turnMs - walkMs) / closingMs; - cam.facing = fromF + arc + closingArc * w * w * (3 - 2 * w); + cam.facing = fromF + arc + closingArc * smoothstep(w); } else if (tiltMs > 0 && t < tiltMs) { cam.facing = fromF + arc + closingArc; cam.x = tx; cam.y = ty; @@ -606,13 +602,13 @@ said.forEach((line, i) => g.fillText(line, textX, 720 - 32 + i * 26, 760)); if (status) { g.textAlign = "right"; - if (status.steps !== null) { + if (status.strides !== null) { g.fillStyle = "#efe8d4"; g.font = "600 34px Oswald, sans-serif"; - g.fillText(String(status.steps), 1280 - 28, 720 - 52); + g.fillText(String(status.strides), 1280 - 28, 720 - 52); g.fillStyle = "#a49c86"; g.font = "600 14px Oswald, sans-serif"; - g.fillText(status.steps === 1 ? "STEP LEFT" : "STEPS LEFT", 1280 - 28, 720 - 34); + g.fillText(status.strides === 1 ? "STEP LEFT" : "STEPS LEFT", 1280 - 28, 720 - 34); } g.fillStyle = "#d8d2c0"; g.font = "16px 'Courier Prime', monospace"; @@ -720,8 +716,8 @@
{#if status}
- {#if status.steps !== null} -
{status.steps}{status.steps === 1 ? "step" : "steps"} left
+ {#if status.strides !== null} +
{status.strides}{status.strides === 1 ? "step" : "steps"} left
{/if}
{#each status.chips as chip, i (i)}{chip}{/each} diff --git a/packages/web/src/fpv/FirstPerson.svelte b/packages/web/src/fpv/FirstPerson.svelte index 2cfe7e6..e6b7c48 100644 --- a/packages/web/src/fpv/FirstPerson.svelte +++ b/packages/web/src/fpv/FirstPerson.svelte @@ -3,9 +3,9 @@ // GameView. Columns of wall shaded by distance and facing; token art // billboarded for whatever stands in the corridors, occluded per column // by the same depth buffer the walls wrote. - import { billboards, castRay, warpMotion, type FpvTarget } from "./raycast"; + import { billboards, castRay, warpMotion, type Billboard, type FpvTarget } from "./raycast"; import { materialTextures } from "./textures"; - import { doorOpenness, fxFallback, growProgress, slateBand, surgeIntensity, type FpFx } from "./fx3d"; + import { doorOpenness, fxFallback, growProgress, slateBand, smoothstep, surgeIntensity, type FpFx } from "./fx3d"; import { terrainFallback, TERRAIN3D } from "./terrain3d"; import { tokenArt } from "../art"; import { PLAYER_COLORS } from "../colors"; @@ -25,6 +25,7 @@ posOverride, rubble = [], ontarget, + hover = false, litCells = null, aimBeings = false, edgeSelect = false, @@ -51,6 +52,8 @@ rubble?: { x: number; y: number }[]; /** Present = the pane is an instrument: clicks resolve to targets. */ ontarget?: (t: FpvTarget) => void; + /** Show the crosshair's hover cue without a click handler (the workshop's poses). */ + hover?: boolean; /** Squares a selected cell-target card may aim at: the pane dims the * ineligible ground exactly as the board dims its squares. */ litCells?: Set | null; @@ -137,8 +140,8 @@ if (cells.size > 0) { grid = new Uint8Array(v.board.width * v.board.height); for (const k of cells) { - const [fx, fy] = k.split(",").map(Number) as [number, number]; - if (fx >= 0 && fy >= 0 && fx < v.board.width && fy < v.board.height) grid[fy * v.board.width + fx] = 1; + const [px, py] = k.split(",").map(Number) as [number, number]; + if (px >= 0 && py >= 0 && px < v.board.width && py < v.board.height) grid[py * v.board.width + px] = 1; } } dreadCache.set(v, grid); @@ -233,7 +236,6 @@ } } - // Walls, one ray per column; remember each column's depth for // sprites, and whether its ray bent through a warp — a sprite's warp // side must MATCH its column's, or bodies near a far mouth would @@ -539,34 +541,16 @@ if (spriteVisibleInCol(s, col, zbuf, warpIdCol, warpDistCol)) { seen = true; break; } } const ringY = Math.min(H - 2, s.bottom); + const w = s.right - s.left; if (seen && s.dread && s.hit) { - const w = s.right - s.left; - const pulse = 0.45 + 0.25 * Math.sin(time / 300); - ctx.save(); - ctx.strokeStyle = `rgba(160,30,30,${pulse})`; - ctx.fillStyle = "rgba(160,30,30,0.12)"; - ctx.lineWidth = 2; - ctx.beginPath(); - ctx.ellipse((s.left + s.right) / 2, ringY, Math.max(9, w * 0.5), Math.max(3, w * 0.155), 0, 0, Math.PI * 2); - ctx.fill(); - ctx.stroke(); - ctx.restore(); + floorRing(ctx, (s.left + s.right) / 2, ringY, Math.max(9, w * 0.5), Math.max(3, w * 0.155), + "160,30,30", 0.45 + 0.25 * Math.sin(time / 300), 0.12); } if (seen && ontarget && aimBeings && litCells && s.hit && s.cell && litCells.has(`${s.cell.x},${s.cell.y}`) && !(s.hit.kind === "player" && s.hit.id === view.you)) { - const w = s.right - s.left; - const cx = (s.left + s.right) / 2; - const pulse = 0.55 + 0.25 * Math.sin(time / 220); - ctx.save(); - ctx.strokeStyle = `rgba(232,160,60,${pulse})`; - ctx.fillStyle = "rgba(232,160,60,0.16)"; - ctx.lineWidth = 2; - ctx.beginPath(); - ctx.ellipse(cx, ringY, Math.max(7, w * 0.42), Math.max(3, w * 0.13), 0, 0, Math.PI * 2); - ctx.fill(); - ctx.stroke(); - ctx.restore(); + floorRing(ctx, (s.left + s.right) / 2, ringY, Math.max(7, w * 0.42), Math.max(3, w * 0.13), + "232,160,60", 0.55 + 0.25 * Math.sin(time / 220), 0.16); } // Painted art composites normally (inks stay true); light glows // additively. Either way translucency applies. @@ -610,10 +594,11 @@ if (s.webbed && s.hit && seen) { const w = s.right - s.left, h = s.bottom - s.top; const hubX = s.left + w * 0.5, hubY = s.top + h * 0.45; - const rim: [number, number][] = [ + const rimU: [number, number][] = [ [0.02, 0.06], [0.5, 0.0], [0.98, 0.08], [1.0, 0.5], [0.96, 0.94], [0.5, 1.0], [0.04, 0.92], [0.0, 0.5], - ].map(([fx, fy]) => [s.left + fx * w, s.top + fy * h]); + ]; + const rim: [number, number][] = rimU.map(([u, v]) => [s.left + u * w, s.top + v * h]); const web = () => { ctx.beginPath(); for (const [rx, ry] of rim) { @@ -757,7 +742,7 @@ if (f.kind !== "fist") continue; const p = (time - f.t0) / f.dur; if (p < 0 || p >= 1) continue; - const ease = (w: number) => w * w * (3 - 2 * w); + const ease = smoothstep; const out = f.swing === "out"; // Travel: out 0→0.42 lunge, 0.42→1 retract; in 0→0.45 approach, then hold and fade. const a = out ? (p < 0.42 ? ease(p / 0.42) : ease(1 - (p - 0.42) / 0.58)) : Math.min(1, ease(p / 0.45)); @@ -854,7 +839,22 @@ // The crosshair's answer, before the click commits: what the pane // would target here, outlined in the table's gold with its name. - if (ontarget && mouse) drawHover(ctx, W, H, half); + 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) { + ctx.save(); + ctx.strokeStyle = `rgba(${rgb},${alpha})`; + ctx.fillStyle = `rgba(${rgb},${fill})`; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + ctx.restore(); } /** Paint the hover cue for whatever stands under the crosshair. */ @@ -956,13 +956,7 @@ sort: number; } function project( - b: { x: number; y: number; src: string; scale: number; rise: number; - aspect?: number; alpha?: number; glow?: boolean; bias?: number; - fallback?: string; warped?: boolean; warpId?: number; - clip?: { x: number; y: number }; - hit?: { kind: "player" | "creature"; id: string }; - cell?: { x: number; y: number }; - webbed?: boolean; gaze?: boolean; dread?: boolean; sink?: boolean }, + b: Pick & Partial, ex: number, ey: number, ): Projected | null { const relX = b.x - ex, relY = b.y - ey; diff --git a/packages/web/src/fpv/FpvWorkshop.svelte b/packages/web/src/fpv/FpvWorkshop.svelte index e212480..6791c4a 100644 --- a/packages/web/src/fpv/FpvWorkshop.svelte +++ b/packages/web/src/fpv/FpvWorkshop.svelte @@ -1,4 +1,5 @@