Credibility pass: the session seams sanded from the clips-and-camera batch

Two blind reviews of everything since the last pass (afa0e17), every
finding checked against the code, no behavior changed: all thirty
scene goldens match without a re-bless and the engine suite is
untouched.

Reel and renderer: the camera's look-down rule is stated once, beside
LOOK_DOWN, instead of twice in the effect; the empty aim branch that
stood where a cutaway used to be is gone (the guard it implied is now
explicit); the pit events and the punch are handled by their own
types, not through "in" casts; smoothstep is one export used by every
tween instead of eleven inline copies; the two floor rings share one
painter; project() takes a Billboard instead of a third hand-typed
copy of its fields; the strides-left figure and the web rim no longer
shadow the reel's steps and the pane's fx; the die card's verdict is
built from events, not by matching an emoji; the workshop asks for the
hover cue by name instead of passing an empty click handler.

Server and engine: one requestBase() for the origin, one slug pattern
in store.ts gating both the clip page and its files, one 404 for both;
LOOPBACK sits above its only caller; doCounteract names what a counter
is played against once; fearCells sits beside its own docblock rather
than between sightedCellsFor and its.

Deploy: chromiumExe, the private server, ffmpeg, and the reel rewind
live in deploy/lib/harness.mjs, shared by the gate, the recorder, and
the card cutter instead of pasted three times; the recorder drops its
duplicate frame counters and names its poster settle; the card uses the
gallery's exact gold; the one-time Sentry URL bootstrap leaves
deploy.sh; the backup comment states the rule rather than the incident.

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-03 11:55:57 -04:00
co-authored by Claude Fable 5.1
parent ddd2e347bd
commit e1a740119c
20 changed files with 238 additions and 263 deletions
+1 -1
View File
@@ -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
+7 -21
View File
@@ -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, "&amp;").replace(/</g, "&lt;");
/** The share card: the viewport scaled to the card's width (a 16:9 crop
* runs 674 tall at 1200 wide, so 22px shave top and bottom), a shade
* rising from the bottom, the title in the gallery's gold over it. */
* rising from the bottom, the title over it in the gallery's gold — set
* in Georgia, since the card is rendered offline with no web fonts. */
function cardHtml(title, viewportPngDataUrl) {
return `<!doctype html><html><head><meta charset="utf-8"><style>
html, body { margin: 0; width: ${CARD.width}px; height: ${CARD.height}px; overflow: hidden; background: #171a21; }
html, body { margin: 0; width: ${CARD.width}px; height: ${CARD.height}px; overflow: hidden; background: #171a20; }
.card { position: relative; width: ${CARD.width}px; height: ${CARD.height}px;
font-family: Georgia, "Times New Roman", serif; color: #d6d2c4; }
.card img { position: absolute; left: 0; top: -22px; width: ${CARD.width}px; height: 674px;
@@ -72,11 +58,11 @@ function cardHtml(title, viewportPngDataUrl) {
.shade { position: absolute; inset: 0;
background: linear-gradient(to bottom, rgba(23,26,33,0) 45%, rgba(23,26,33,0.92) 100%); }
.title { position: absolute; left: 56px; right: 56px; bottom: 94px;
font-size: 66px; line-height: 1.1; color: #e8c87a; letter-spacing: 0.02em;
font-size: 66px; line-height: 1.1; color: #e0b34a; letter-spacing: 0.02em;
text-shadow: 0 2px 12px rgba(0,0,0,0.6); }
.mark { position: absolute; left: 58px; bottom: 52px; font-size: 26px; letter-spacing: 0.08em;
color: #d6d2c4; opacity: 0.9; text-shadow: 0 1px 8px rgba(0,0,0,0.6); }
.mark b { font-weight: normal; color: #e8c87a; }
.mark b { font-weight: normal; color: #e0b34a; }
</style></head><body><div class="card">
<img src="${viewportPngDataUrl}">
<div class="shade"></div>
+2 -9
View File
@@ -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
+19 -49
View File
@@ -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 <out-dir> [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();
}
+56
View File
@@ -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;
}
+2 -2
View File
@@ -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
+12 -45
View File
@@ -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);
+4 -3
View File
@@ -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,
+8 -6
View File
@@ -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 }],
};
}
+6 -6
View File
@@ -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<string> {
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<string> {
const out = new Set<string>();
const me = view.players.find((p) => p.id === view.you);
+2 -1
View File
@@ -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 {
`<meta name="twitter:card" content="summary_large_image"/>`,
];
const body = `
<nav class="mast"><a href="/">Wiz-War</a><span class="crumb"><a href="/clips" style="font:inherit;letter-spacing:inherit;text-transform:none;color:inherit">clips</a> / ${esc(clip.title)}</span><a class="deal" href="/">deal yourself in →</a></nav>
<nav class="mast"><a href="/">Wiz-War</a><span class="crumb"><a href="/clips">clips</a> / ${esc(clip.title)}</span><a class="deal" href="/">deal yourself in →</a></nav>
<h1><a href="/clips">${esc(clip.title)}</a></h1>
<p class="sub">${esc(clip.blurb)}</p>
<button class="share" type="button" data-title="${esc(clip.title)}" data-text="${esc(clip.blurb)}">Share this clip</button>
+18 -11
View File
@@ -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
+2 -2
View File
@@ -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<string, string | string[] | undefi
const parts = (Array.isArray(fwd) ? fwd.join(",") : fwd ?? "").split(",").map((s) => s.trim()).filter(Boolean);
return parts[parts.length - 1] || peer;
}
const LOOPBACK = new Set(["127.0.0.1", "::1", "::ffff:127.0.0.1"]);
+10 -6
View File
@@ -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 <slug>(-fpv|-board).mp4 / <slug>(-card)?.jpg can be named. */
* is not exactly a published clip file: <slug>-fpv.mp4, <slug>-board.mp4,
* <slug>.jpg, <slug>-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;
}
+4 -4
View File
@@ -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<string, { x: number; y: number }> = {};
for (const m of moves) {
next[m.id] = { x: m.fx + (m.tx - m.fx) * ease, y: m.fy + (m.ty - m.fy) * ease };
@@ -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 {
+36 -40
View File
@@ -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<string>) =>
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<string, { x: number; y: number }> = {};
for (const m of moves) {
next[m.id] = { x: m.fx + (m.tx - m.fx) * ease, y: m.fy + (m.ty - m.fy) * ease };
@@ -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 @@
</div>
{#if status}
<div class="caption-status">
{#if status.steps !== null}
<div class="caption-steps"><b>{status.steps}</b><span>{status.steps === 1 ? "step" : "steps"} left</span></div>
{#if status.strides !== null}
<div class="caption-steps"><b>{status.strides}</b><span>{status.strides === 1 ? "step" : "steps"} left</span></div>
{/if}
<div class="caption-chips">
{#each status.chips as chip, i (i)}<span class="chip">{chip}</span>{/each}
+33 -39
View File
@@ -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<string> | 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<Billboard, "x" | "y" | "src" | "scale" | "rise"> & Partial<Billboard>,
ex: number, ey: number,
): Projected | null {
const relX = b.x - ex, relY = b.y - ey;
+3 -2
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import { smoothstep } from "./fx3d";
// The first-person workshop (/?fpv): a real dealt game, one wizard's
// eyes, free-fly controls. No rules run here — the camera walks where
// walls allow and the maze is exactly what viewFor would send that
@@ -97,7 +98,7 @@
turned: boolean; t0: number; dur: number }
| null;
let anim: Anim = null;
const ease = (w: number) => w * w * (3 - 2 * w);
const ease = (w: number) => smoothstep(w);
function startNext(now: number) {
const turn = (held.has("arrowleft") || held.has("a") ? -1 : 0) +
(held.has("arrowright") || held.has("d") ? 1 : 0);
@@ -256,7 +257,7 @@
</p>
<div class="stage">
<FirstPerson {view} {povId} {x} {y} {facing} litCells={aimCells} aimBeings={!!aimCells}
ontarget={aimCells ? () => {} : undefined} />
hover={!!aimCells} />
<svg class="minimap" viewBox="0 0 {view.board.width * MM} {view.board.height * MM}">
{#each cells as c (cellKey({ x: c.cx, y: c.cy }))}
<rect x={c.cx * MM} y={c.cy * MM} width={MM} height={MM} class="mm-cell" />
+1 -2
View File
@@ -3,10 +3,9 @@
// between one view and the next. One ladder, so the replay and the
// table never learn different instincts.
import { edgeMid, warpMotion } from "./raycast";
import { castRay, edgeMid, warpMotion } from "./raycast";
import { fpFxForEvents } from "./fx3d";
import type { GameEvent, GameView } from "@wizwar/engine";
import { castRay } from "./raycast";
export function shortestArc(from: number, to: number): number {
let d = (to - from) % (2 * Math.PI);
+12 -14
View File
@@ -48,18 +48,20 @@ export function surgeIntensity(p: number): number {
* p, as [left, right] fractions sweeping in from the left, holding,
* then sweeping off to the right. */
export function slateBand(p: number): [number, number] {
const ease = (w: number) => w * w * (3 - 2 * w);
if (p < 0 || p >= 1) return [0, 0];
if (p < 0.28) return [0, ease(p / 0.28)];
if (p < 0.28) return [0, smoothstep(p / 0.28)];
if (p < 0.68) return [0, 1];
return [ease((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. */
export function growProgress(p: number): number {
if (p <= 0) return 0.01;
if (p >= 1) return 1;
return Math.max(0.01, p * p * (3 - 2 * p));
return Math.max(0.01, smoothstep(p));
}
/** How far open an animated door stands at progress p: swings open,
@@ -143,11 +145,6 @@ export function fpFxForEvents(
delay,
});
}
} else if (fx.kind === "portal") {
// The board ripples a mouth on every crossing. In here a warp is
// a doorway like any other — the veil already marks it, and a
// body walks through as it would a door. A mouth just OPENED is
// staged from the raw event below.
} else if ("at" in fx && IMPACT_ART[fx.kind]) {
out.push({
fx: { id: nextId++, kind: "impact", art: IMPACT_ART[fx.kind]!, at: { x: fx.at.x + 0.5, y: fx.at.y + 0.5 }, t0: 0, dur: 550 },
@@ -157,14 +154,15 @@ export function fpFxForEvents(
}
// The maze itself performs: doors swing for whoever steps through,
// walls die into rubble, walking THROUGH stone shimmers, and a
// wormhole just cast surges through both its new mouths.
// wormhole just cast surges through both its new mouths. A crossing
// through an existing mouth is silent: a warp is a doorway here, and
// the veil already marks it (the board's "portal" ripple is not staged).
const warpIndex = (m: { cell: { x: number; y: number }; side: string }) =>
view.board.warps.findIndex((w) => w.from.cell.x === m.cell.x && w.from.cell.y === m.cell.y && w.from.side === m.side);
for (const e of events) {
if (e.type === "punched" && "attacker" in e) {
const pe = e as { attacker: string; target: string };
if (pe.attacker === povId) out.push({ fx: { id: nextId++, kind: "fist", swing: "out", t0: 0, dur: 620 }, delay: 0 });
else if (pe.target === povId) out.push({ fx: { id: nextId++, kind: "fist", swing: "in", t0: 0, dur: 620 }, delay: 0 });
if (e.type === "punched") {
if (e.attacker === povId) out.push({ fx: { id: nextId++, kind: "fist", swing: "out", t0: 0, dur: 620 }, delay: 0 });
else if (e.target === povId) out.push({ fx: { id: nextId++, kind: "fist", swing: "in", t0: 0, dur: 620 }, delay: 0 });
}
if (e.type === "warpOpened") {
for (const [m, delay] of [[e.a, 0], [e.b, 200]] as const) {