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
+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) {