diff --git a/.claude/skills/wizwar-audits/SKILL.md b/.claude/skills/wizwar-audits/SKILL.md
index 60a999c..680b868 100644
--- a/.claude/skills/wizwar-audits/SKILL.md
+++ b/.claude/skills/wizwar-audits/SKILL.md
@@ -43,7 +43,7 @@ Replay with `createGame({playerIds, seed, sets, colors, deckRev})` +
any seq to inspect full state. To ask why a bot did something, rebuild
the state at its turn and call `automatonCommand(viewFor(state, id),
style, tier)` — and if its choice differs from the ledger, the engine
-refused it and the fallback burned the turn (the X2XN pattern).
+refused it and the fallback burned the turn.
For "which games are open/stalled" sweeps: fetch all `*.jsonl`, replay
each, and report phase / round / humans vs bots / last command's `at`.
@@ -55,5 +55,5 @@ each, and report phase / round / humans vs bots / last command's `at`.
Strict-replays every production ledger against the local engine; one
refused command fails. A room whose ledger no longer replays becomes
unreachable after restart. Rules changes while games are live need a
-`deckRev` bump plus an engine gate (the convention survives the 2026-08
-reset to rev 1).
+`deckRev` bump plus an engine gate. Production deckRev is currently 1,
+so low numbers are not stale.
diff --git a/packages/engine/src/automaton.ts b/packages/engine/src/automaton.ts
index d6271ea..5dc5de8 100644
--- a/packages/engine/src/automaton.ts
+++ b/packages/engine/src/automaton.ts
@@ -1124,7 +1124,6 @@ export function automatonCommand(
const cursedIdiot = view.sustained.some((e) => e.cardId === "idiot" && e.targetId === view.you);
const thief = thiefOfMine(view);
if (!cursedIdiot) {
-
// Deliver or grab treasure underfoot.
if (self.carriedTreasureId && here === cellKey(self.home)) return { type: "dropTreasure" };
if (!self.carriedTreasureId) {
@@ -1187,8 +1186,6 @@ export function automatonCommand(
if ((!view.turn.attackUsed || (adrenalized && !view.turn.secondAttackUsed)) &&
view.turn.round > 1) {
const sighted = sightedCellsFor(view);
- // A wizard under the clockwork's own BUDDY pact is off the target list:
- // attacking them would tear up the pact it just paid a card for.
// Whoever cast BUDDY on you is safe from your first strike — the
// engine refuses the attack outright. Your OWN pacts are merely
// precious: striking someone you pacted tears up your protection.
diff --git a/packages/engine/test/automaton.test.ts b/packages/engine/test/automaton.test.ts
index 1debece..938381b 100644
--- a/packages/engine/test/automaton.test.ts
+++ b/packages/engine/test/automaton.test.ts
@@ -348,9 +348,8 @@ describe("the clockwork respects the bush's shelter", () => {
it("never attacks out of its own bush", () => {
const { state, bot, foe } = faceOffWithFireball();
- foe.position = { x: bot.position.x, y: bot.position.y };
- state.squareContents[cellKey(bot.position)] = { kind: "thornbush", damage: 0, createdBy: "foe" };
foe.position = { ...bot.position };
+ state.squareContents[cellKey(bot.position)] = { kind: "thornbush", damage: 0, createdBy: "foe" };
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage")
?? automatonFallback(viewFor(state, "bot"), "archmage");
expect(cmd).not.toMatchObject({ type: "cast", instanceId: "fireball#T" });
diff --git a/packages/engine/test/expansion-combat.test.ts b/packages/engine/test/expansion-combat.test.ts
index f498815..1398422 100644
--- a/packages/engine/test/expansion-combat.test.ts
+++ b/packages/engine/test/expansion-combat.test.ts
@@ -434,7 +434,6 @@ describe("strength tears treasures from wizards' arms", () => {
// One attack per turn: a second wrench is refused.
expect(applyCommand(r.state, attacker, { type: "tearTreasure", targetId: defender }).ok).toBe(false);
}
- expect(torn + kept).toBe(12);
expect(torn).toBeGreaterThan(0);
expect(kept).toBeGreaterThan(0);
});
diff --git a/packages/engine/test/ui-coverage.test.ts b/packages/engine/test/ui-coverage.test.ts
index 219f7d2..988d9ef 100644
--- a/packages/engine/test/ui-coverage.test.ts
+++ b/packages/engine/test/ui-coverage.test.ts
@@ -63,9 +63,9 @@ describe("every targeted cast is aimable on the board", () => {
if (!id) continue;
// Only demands stated as refusals bind: `target.kind !== "cell"` etc.
// (an optional `target?.kind === ...` branch is not a requirement).
- if (/target \|\| cmd\.target\.kind !== "cell"|!cmd\.target \|\| cmd\.target\.kind !== "cell"/.test(entry) &&
+ if (/target \|\| cmd\.target\.kind !== "cell"/.test(entry) &&
!cellCards.has(id)) missing.push(`${id} (cell)`);
- if (/target \|\| cmd\.target\.kind !== "edge"|!cmd\.target \|\| cmd\.target\.kind !== "edge"/.test(entry) &&
+ if (/target \|\| cmd\.target\.kind !== "edge"/.test(entry) &&
!edgeCards.has(id)) missing.push(`${id} (edge)`);
}
expect(missing, `casts the board cannot aim: ${missing.join(", ")}`).toEqual([]);
diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts
index 4910b8f..784bae5 100644
--- a/packages/server/src/index.ts
+++ b/packages/server/src/index.ts
@@ -52,6 +52,7 @@ import {
runCommand,
seatTokenValid,
SPECTATOR,
+ type CatchUpStep,
startGame,
summarize,
viewForPlayer,
@@ -105,7 +106,7 @@ const staticRoot = existsSync(STATIC_DIR) ? realpathSync(normalize(STATIC_DIR))
// Every lookup is a full-game replay, so results rest briefly in memory.
interface ShareData {
- steps: { actor: PlayerId; events: unknown[]; view: unknown }[];
+ steps: CatchUpStep[];
actor: string;
round: number;
/** A whole finished game rather than one turn. */
@@ -127,12 +128,12 @@ function shareData(id: string): ShareData | null {
for (const st of steps) {
for (const e of st.events) if (e.type === "gameWon" && "player" in e) winner = e.player;
}
- data = { steps: steps as unknown as ShareData["steps"], actor: winner, round: 0, whole: true };
+ data = { steps, actor: winner, round: 0, whole: true };
}
} else {
const reel = momentSteps(room, SPECTATOR, share.turn);
if (!("error" in reel) && reel.steps.length > 0) {
- data = { steps: reel.steps as unknown as ShareData["steps"], actor: reel.owner, round: reel.round };
+ data = { steps: reel.steps, actor: reel.owner, round: reel.round };
}
}
}
@@ -219,8 +220,7 @@ const httpServer = createServer((req, res) => {
const data = shareData(watch[1]!);
if (!data) { res.writeHead(404).end("no such replay"); return; }
if (watch[2]) {
- const last = data.steps[data.steps.length - 1]!;
- const png = renderSharePng(last.view as Parameters[0]);
+ const png = renderSharePng(data.steps[data.steps.length - 1]!.view);
res.writeHead(200, { "content-type": "image/png", "cache-control": "public, max-age=300" });
res.end(png);
return;
@@ -628,7 +628,7 @@ wss.on("connection", (socket) => {
if (!Number.isInteger(turn) || turn < -1) return send(socket, { type: "error", message: "no such turn" });
const now = Date.now();
if (now - session.lastCatchUpAt < CATCHUP_COOLDOWN_MS) {
- return send(socket, { type: "error", message: "one moment" });
+ return send(socket, { type: "error", message: "catching up already — one moment" });
}
session.lastCatchUpAt = now;
// turn -1 shares the whole finished game; anything else, one turn.
diff --git a/packages/server/src/rooms.ts b/packages/server/src/rooms.ts
index b284009..76974a5 100644
--- a/packages/server/src/rooms.ts
+++ b/packages/server/src/rooms.ts
@@ -447,8 +447,8 @@ export function momentSteps(room: Room, playerId: PlayerId, turnIndex: number):
const MAX_STEPS = 80;
const { state: fresh, events: dealt } = createGame(room.state.config);
let current = fresh;
- // The deal's own events open the first turn — count them, or every
- // turn number would sit one behind the chronicle's.
+ // The deal's own events open the first turn — count them so turn
+ // numbers match the client chronicle's.
let counter = -1;
let owner: PlayerId | null = null;
let round = 0;
diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte
index a0f4ab8..75b78a7 100644
--- a/packages/web/src/App.svelte
+++ b/packages/web/src/App.svelte
@@ -161,7 +161,7 @@
/** Leafing through the face-up discard pile. */
let showDiscards = $state(false);
let chatDraft = $state("");
- let botTier = $state(prefs.botTier);
+ let botTier = $state(prefs.botTier);
/** Card whose official FAQ rulings are open. */
let faqCardId = $state(null);
/** A discard-pile card enlarged above the pile. */
@@ -1395,9 +1395,9 @@
Automatons default to
- {#each ["apprentice", "adept", "archmage"] as t (t)}
+ {#each ["apprentice", "adept", "archmage"] as const as t (t)}
+ onclick={() => { setPref("botTier", t); botTier = t; }}>{t}
{/each}
- {#each ["wall", "rim", "stone", "door", "firewall", "warp", "doorframe", "cracks", "floor", "ceiling", "home", "pit", "slime", "tacks", "dimwarp", "underbrush"] as name (name)}
+ {#each MATERIALS as name (name)}
{name}
diff --git a/packages/web/src/fpv/FirstPerson.svelte b/packages/web/src/fpv/FirstPerson.svelte
index 0a07436..af01bd5 100644
--- a/packages/web/src/fpv/FirstPerson.svelte
+++ b/packages/web/src/fpv/FirstPerson.svelte
@@ -55,16 +55,6 @@
return img.complete && img.naturalWidth > 0 ? img : null;
}
- /** Base colors per face (fallback + tinting basis). */
- const FACE: Record = {
- wall: [126, 118, 100],
- stone: [96, 96, 104],
- door: [130, 92, 48],
- rim: [82, 76, 66],
- firewall: [214, 92, 28],
- warp: [96, 60, 160],
- };
-
// Materials come from /textures/*.png when those files exist — the
// independently paintable set — with the baked procedurals underneath
// so a missing or still-loading file never leaves a wall naked.
@@ -253,6 +243,9 @@
// VIRTUAL one only through its OWN warp's columns, beyond the mouth.
const warpIdCol = new Int32Array(W).fill(-1);
const warpDistCol = new Float64Array(W).fill(Infinity);
+ // Each mouth a ray passed through hangs a translucent veil of the
+ // warp texture at its own depth, drawn with the other overlays.
+ const veils: { col: number; depth: number; u: number }[] = [];
// Known illusions: the ray passes, but a translucent ghost of a wall
// stands at the crossing — drawn after the sprites so bodies show
// through it, shimmering so nobody mistakes it for stone.
@@ -269,6 +262,7 @@
if (hit.warpId !== undefined) {
warpIdCol[col] = hit.warpId;
warpDistCol[col] = (hit.warpDist ?? 0) * Math.cos(rayAngle - facing);
+ veils.push({ col, depth: warpDistCol[col]!, u: hit.warpU ?? 0 });
}
if (hit.ghost) {
ghosts.push({
@@ -289,9 +283,9 @@
const top = half - wallH / 2;
const tex = hit.frame ? textures.doorframe! : textures[hit.kind] ?? textures.wall!;
// Sample by the texture's own size: painted files may be any scale.
- // Fire and warps shift their slice per world cell, so a blaze
- // spanning edges reads as one long fire, not a repeated flame.
- const texU = hit.kind === "firewall" || hit.kind === "warp"
+ // Fire shifts its slice per world cell, so a blaze spanning edges
+ // reads as one long fire, not a repeated flame.
+ const texU = hit.kind === "firewall"
? (hit.u + Math.floor(hit.worldU) * 0.37) % 1
: hit.u;
ctx.drawImage(tex, Math.min(tex.width - 1, texU * tex.width), 0,
@@ -316,11 +310,6 @@
ctx.fillRect(col, top, 1, wallH);
dark *= 0.5; // the fire lights itself
}
- if (hit.kind === "warp") {
- const swirl = 0.12 + 0.12 * Math.sin(time / 240 + hit.worldU * 9);
- ctx.fillStyle = `rgba(190,150,255,${Math.max(0, swirl)})`;
- ctx.fillRect(col, top, 1, wallH);
- }
if (hit.kind === "wall" && hit.edge && view.illusionEdges[hit.edge] === "untested") {
// The same tell the board gives: an untested illusion's face
// shimmers faintly — maybe stone, maybe not.
@@ -389,7 +378,7 @@
for (let col = Math.max(0, s.left | 0); col < Math.min(W, s.right); col++) {
if (s.depth >= zbuf[col]!) continue;
if (s.warped) {
- if (warpIdCol[col] !== (s.warpId ?? -2) || s.depth <= warpDistCol[col]!) continue;
+ if (warpIdCol[col] !== s.warpId || s.depth <= warpDistCol[col]!) continue;
} else if (s.depth >= warpDistCol[col]!) continue;
if (s.clampL !== undefined && (col < s.clampL || col > s.clampR!)) continue;
const texX = ((col - s.left) / (s.right - s.left));
@@ -413,6 +402,22 @@
if (s.alpha !== undefined || s.glow) ctx.globalAlpha = 1;
}
+ // Warp mouths wear their veil: the warp texture at the opening,
+ // translucent, swirling — the doorway between rooms that are not
+ // neighbors announces itself.
+ for (const vl of veils) {
+ const vh = Math.min(H * 2.5, H / Math.max(vl.depth, 0.05));
+ const vTop = half - vh / 2;
+ const wt = textures.warp!;
+ ctx.globalAlpha = 0.3;
+ ctx.drawImage(wt, Math.min(wt.width - 1, vl.u * wt.width), 0,
+ Math.max(1, wt.width / 96), wt.height, vl.col, vTop, 1, vh);
+ ctx.globalAlpha = 1;
+ const swirl = 0.1 + 0.1 * Math.sin(time / 240 + vl.u * 9 + vl.col * 0.02);
+ ctx.fillStyle = `rgba(190,150,255,${Math.max(0, swirl)})`;
+ ctx.fillRect(vl.col, vTop, 1, vh);
+ }
+
// Conjurations rising: the wall (or stone) grows bottom-up where it
// will stand, glowing swirls running over the young face.
for (const ri of risings) {
@@ -521,7 +526,7 @@
const bottom = half + wallH / 2 - b.rise * wallH;
// A cell-bound volume is windowed to its own square: the billboard's
// camera-facing plane is intersected with the cell, and only that
- // lateral segment may paint — an obliquely-viewed hedge can no longer
+ // lateral segment may paint, so an obliquely-viewed hedge cannot
// poke its ends through the neighboring walls. (Virtual warp copies
// skip this — their clip cell lives in another frame.)
let clampL: number | undefined;
diff --git a/packages/web/src/fpv/FpvWorkshop.svelte b/packages/web/src/fpv/FpvWorkshop.svelte
index 74d9d61..56e7c0a 100644
--- a/packages/web/src/fpv/FpvWorkshop.svelte
+++ b/packages/web/src/fpv/FpvWorkshop.svelte
@@ -56,10 +56,9 @@
const start = view.players.find((p) => p.id === povId)!.position;
// ?x=&y=&dir= override the spawn — for standing the camera anywhere.
- const DIR_ANGLE: Record = { E: 0, S: Math.PI / 2, W: Math.PI, N: -Math.PI / 2 };
let x = $state((q.has("x") ? Number(q.get("x")) : start.x) + 0.5);
let y = $state((q.has("y") ? Number(q.get("y")) : start.y) + 0.5);
- let facing = $state(DIR_ANGLE[q.get("dir") ?? ""] ?? 0);
+ let facing = $state(SIDE_ANGLE[q.get("dir") as keyof typeof SIDE_ANGLE] ?? 0);
// Dungeon-crawler locomotion: the maze is walked cell by cell, so the
// camera moves the same way — a tap glides one square, a turn swings a
diff --git a/packages/web/src/fpv/raycast.ts b/packages/web/src/fpv/raycast.ts
index f6b8155..39656f4 100644
--- a/packages/web/src/fpv/raycast.ts
+++ b/packages/web/src/fpv/raycast.ts
@@ -13,7 +13,7 @@ export interface Hit {
/** Distance along the ray (perpendicular-corrected by the caller). */
dist: number;
/** What the ray struck. */
- kind: "wall" | "door" | "firewall" | "stone" | "rim" | "warp";
+ kind: "wall" | "door" | "firewall" | "stone" | "rim";
/** 0..1 across the struck face (texture coordinate). */
u: number;
/** Vertical faces get a different shade than horizontal ones. */
@@ -26,11 +26,13 @@ export interface Hit {
edge?: string;
/** The eye reached this through a warp: haze it other-worldly. */
warped?: boolean;
- /** Which warp (index in board.warps) the ray first bent through, and
- * how far along the ray that mouth stood — the gate a sprite must be
- * beyond (its own warp) or in front of (any) to paint this column. */
+ /** Which warp (index in board.warps) the ray first bent through, how
+ * far along the ray that mouth stood, and where across it — the gate a
+ * sprite must be beyond (its own warp) or in front of (any) to paint
+ * this column, and the veil the renderer hangs in the opening. */
warpId?: number;
warpDist?: number;
+ warpU?: number;
/** The first known-illusion edge the ray crossed before its solid hit:
* the eye passes, but a translucent ghost of a wall stands there. */
ghost?: { dist: number; u: number; worldU: number; axis: "x" | "y" };
@@ -62,8 +64,8 @@ export function warpLaneMirrored(from: Side, to: Side): boolean {
const [x, y] = LANE_AXIS[from];
const rx = x * Math.cos(d) - y * Math.sin(d);
const ry = x * Math.sin(d) + y * Math.cos(d);
- const [ex2, ey] = LANE_AXIS[to];
- return rx * ex2 + ry * ey < 0;
+ const [ux, uy] = LANE_AXIS[to];
+ return rx * ux + ry * uy < 0;
}
/** Is this edge passable to the EYE (rays), and if not, what is it?
@@ -77,11 +79,6 @@ function edgeObstacle(view: GameView, key: string): Hit["kind"] | null {
return "wall";
}
-/**
- * March one ray from (ox, oy) at `angle` and return the first thing that
- * stops the eye. Off-board is the maze's rim: a wall, unless the crossing
- * is a warp mouth (then a shimmering opening).
- */
/** Half-thickness per material: walls are masonry, doors joinery, fire a
* sheet. The slab gives every wall END a visible cap face, so corners and
* doorways read as three-dimensional stone rather than paper. */
@@ -105,6 +102,14 @@ function slabHit(
return { t: Math.max(tNear, 0), axis: tx1 > ty1 ? "x" : "y" };
}
+/** What a slab test found: the strike, and how the door slid under it. */
+type SlabStrike = { t: number; axis: "x" | "y"; kind: Hit["kind"]; slide: number; edge: string; frame?: boolean };
+
+/**
+ * March one ray from (ox, oy) at `angle` and return the first thing that
+ * stops the eye. Off-board is the maze's rim: a wall, unless the crossing
+ * is a warp mouth — the ray passes through those, remembering the first.
+ */
export function castRay(
view: GameView, ox: number, oy: number, angle: number,
/** Transient door openness (0 shut - 1 wide), keyed by edge; doors the
@@ -121,10 +126,12 @@ export function castRay(
let warped = false;
let warpId: number | undefined;
let warpDist: number | undefined;
+ let warpU: number | undefined;
let ghost: Hit["ghost"];
let doorway: Hit["doorway"];
let rising: Hit["rising"];
for (let traversal = 0; traversal < 3; traversal++) {
+ let traversed = false;
const dx = Math.cos(angle);
const dy = Math.sin(angle);
let cx = Math.floor(ox);
@@ -140,7 +147,7 @@ export function castRay(
// Every blocked edge of the current cell stands as a slab; the
// nearest strike inside this cell's span wins.
const exit = Math.min(sideDistX, sideDistY);
- let best: { t: number; axis: "x" | "y"; kind: Hit["kind"]; slide: number; edge: string; frame?: boolean } | null = null;
+ let best: SlabStrike | null = null;
const trySide = (side: Side, minX: number, maxX: number, minY: number, maxY: number) => {
const key = edgeKey({ x: cx, y: cy }, side);
const kind = edgeObstacle(view, key);
@@ -188,13 +195,13 @@ export function castRay(
trySide("S", cx, cx + 1, cy + 1 - hw("S"), cy + 1 + hw("S"));
trySide("N", cx, cx + 1, cy - hw("N"), cy + hw("N"));
if (best !== null) {
- const b = best as { t: number; axis: "x" | "y"; kind: Hit["kind"]; slide: number; edge: string; frame?: boolean };
+ const b = best as SlabStrike;
const along = b.axis === "x" ? oy + b.t * dy : ox + b.t * dx;
const u = along - Math.floor(along);
// A sliding door carries its texture with it: sample past the gap.
return {
dist: baseDist + b.t, kind: b.kind, u: Math.max(0, u - b.slide),
- axis: b.axis, worldU: along, edge: b.edge, warped, warpId, warpDist, ghost, doorway, rising,
+ axis: b.axis, worldU: along, edge: b.edge, warped, warpId, warpDist, warpU, ghost, doorway, rising,
...(b.frame ? { frame: true } : {}),
};
}
@@ -221,8 +228,8 @@ export function castRay(
(w) => cellKey(w.from.cell) === cellKey({ x: cx, y: cy }) && w.from.side === side,
);
const warp = wIdx >= 0 ? view.board.warps[wIdx]! : undefined;
- if (!warp) return { dist: baseDist + dist, kind: "rim", u: texU, axis, worldU: along, warped, warpId, warpDist, ghost, doorway, rising };
- if (warpId === undefined) { warpId = wIdx; warpDist = baseDist + dist; }
+ if (!warp) return { dist: baseDist + dist, kind: "rim", u: texU, axis, worldU: along, warped, warpId, warpDist, warpU, ghost, doorway, rising };
+ if (warpId === undefined) { warpId = wIdx; warpDist = baseDist + dist; warpU = texU; }
// Step through: re-enter at the paired mouth, heading inward, the
// lane carried by the same proper rotation the heading turns by —
// mirrored for the pairings whose axes land head-to-head.
@@ -236,23 +243,23 @@ export function castRay(
else { ox = c.x + lane; oy = c.y + 1e-4; }
baseDist += dist;
warped = true;
- i = 64; // restart the DDA from the far mouth
- break;
+ traversed = true;
+ break; // restart the DDA from the far mouth
}
if (view.squareContents[cellKey({ x: nx, y: ny })]?.kind === "stone") {
const sg = growing?.[cellKey({ x: nx, y: ny })];
if (sg !== undefined && sg < 1) {
if (!rising) rising = { dist: baseDist + dist, u: texU, worldU: along, kind: "stone", g: sg };
} else {
- return { dist: baseDist + dist, kind: "stone", u: texU, axis, worldU: along, warped, warpId, warpDist, ghost, doorway, rising };
+ return { dist: baseDist + dist, kind: "stone", u: texU, axis, worldU: along, warped, warpId, warpDist, warpU, ghost, doorway, rising };
}
}
cx = nx;
cy = ny;
}
- if (!warped || traversal === 2) break;
+ if (!traversed) break;
}
- return { dist: baseDist + 64, kind: "rim", u: 0, axis: "x", worldU: 0, warped, warpId, warpDist, ghost, doorway, rising };
+ return { dist: baseDist + 64, kind: "rim", u: 0, axis: "x", worldU: 0, warped, warpId, warpDist, warpU, ghost, doorway, rising };
}
/** Can a wizard's body (not just their eye) cross this edge? Workshop
@@ -436,8 +443,8 @@ export function billboards(
} else if (content.kind === "safe") {
out.push({
...at, src: "/terrain3d/safe.png", fallback: "safe",
- scale: 0.55, aspect: 1, rise: 0, bias: 0.18, label: "safe",
- clip: { x: Math.floor(at.x), y: Math.floor(at.y) },
+ scale: 0.55, aspect: 1, rise: 0, bias: 0.18, label: "safe", key: k,
+ clip: { x: tx, y: ty },
});
}
}
diff --git a/packages/web/src/fpv/textures.ts b/packages/web/src/fpv/textures.ts
index 5c8b7ac..9b8573f 100644
--- a/packages/web/src/fpv/textures.ts
+++ b/packages/web/src/fpv/textures.ts
@@ -8,13 +8,11 @@ const FACE: Record = {
stone: [96, 96, 104],
door: [130, 92, 48],
rim: [82, 76, 66],
- firewall: [214, 92, 28],
- warp: [96, 60, 160],
};
// Textures are baked once at 64x64 and sampled one column at a time —
// perspective-correct verticals for the price of a single drawImage.
-export const TEX = 64;
+const TEX = 64;
function bake(paint: (c: CanvasRenderingContext2D) => void): HTMLCanvasElement {
const t = document.createElement("canvas");
t.width = TEX; t.height = TEX;
@@ -42,10 +40,10 @@ function brickTexture(base: [number, number, number], rows: number, mortar: stri
for (let r = 0; r <= rows; r++) c.fillRect(0, r * rh - 1, TEX, 2);
});
}
-export const MATERIALS = ["wall", "rim", "stone", "door", "firewall", "warp", "floor", "ceiling", "home", "pit", "slime", "tacks", "dimwarp", "doorframe", "cracks", "underbrush"] as const;
+export const MATERIALS = ["wall", "rim", "stone", "door", "firewall", "warp", "doorframe", "cracks", "floor", "ceiling", "home", "pit", "slime", "tacks", "dimwarp", "underbrush"] as const;
/** The built-in bake: what ships when no painted file overrides it. */
-export function proceduralTextures(): Record {
+function proceduralTextures(): Record {
return {
wall: brickTexture(FACE.wall!, 4, "rgba(28,24,18,0.9)"),
rim: brickTexture(FACE.rim!, 3, "rgba(16,14,12,0.95)"),
diff --git a/packages/web/src/net.svelte.ts b/packages/web/src/net.svelte.ts
index 57f8666..a214ccf 100644
--- a/packages/web/src/net.svelte.ts
+++ b/packages/web/src/net.svelte.ts
@@ -325,6 +325,7 @@ class Net {
/** The turn whose moment reel is open (share links point at it). */
private momentTurn: number | null = null;
private shareResolve: ((url: string) => void) | null = null;
+ private shareReject: ((e: Error) => void) | null = null;
private seen: Record = loadSeen();
/** Room whose live stream this connection has already shown once: states
* after the first mark themselves seen while the tab is visible. */
@@ -430,6 +431,7 @@ class Net {
case "share":
this.shareResolve?.(`${location.origin}/watch/${msg.id}`);
this.shareResolve = null;
+ this.shareReject = null;
break;
case "events": {
let talk = 0;
@@ -527,9 +529,7 @@ class Net {
/** Take a seat in the Peanut Gallery: watch a game with no name and no voice. */
watch(roomId: string): void {
this.you = null;
- this.log = [];
- this.turnCounter = -1;
- this.eyeTurn = -1;
+ this.resetChronicle();
this.send({ type: "watch", roomId: roomId.toUpperCase() });
}
@@ -549,9 +549,7 @@ class Net {
this.spectating = false;
this.token = seat.token;
this.roomIdPending = seat.roomId;
- this.log = [];
- this.turnCounter = -1;
- this.eyeTurn = -1;
+ this.resetChronicle();
this.send({ type: "join", roomId: seat.roomId, name: seat.name, token: seat.token });
}
@@ -658,9 +656,7 @@ class Net {
this.view = null;
this.started = false;
this.players = [];
- this.log = [];
- this.turnCounter = -1;
- this.eyeTurn = -1;
+ this.resetChronicle();
this.token = null;
this.spectating = false;
this.audience = 0;
@@ -698,6 +694,13 @@ class Net {
this.markSeen();
}
+ /** The chronicle and its turn count reset together, always. */
+ private resetChronicle(): void {
+ this.log = [];
+ this.turnCounter = -1;
+ this.eyeTurn = -1;
+ }
+
/** Summon one turn's reel by its chronicle turn number. */
requestMoment(turn: number): void {
this.momentTurn = turn;
@@ -705,15 +708,19 @@ class Net {
}
/** Mint a public link: the open moment's turn, or -1 for the whole
- * finished game. */
+ * finished game. One mint in flight at a time — a newcomer supersedes
+ * a stranded predecessor rather than leaving it pending forever. */
requestShare(turn: number | null = this.momentTurn): Promise {
return new Promise((resolve, reject) => {
if (turn === null) return reject(new Error("no turn open"));
+ this.shareReject?.(new Error("superseded"));
this.shareResolve = resolve;
+ this.shareReject = reject;
this.send({ type: "share", turn });
setTimeout(() => {
if (this.shareResolve === resolve) {
this.shareResolve = null;
+ this.shareReject = null;
reject(new Error("share timed out"));
}
}, 10_000);
@@ -722,6 +729,7 @@ class Net {
closeMoment(): void {
this.moment = null;
+ this.momentTurn = null;
}
command(command: Command): void {
diff --git a/research/fx3d-sprite-spec.md b/research/fx3d-sprite-spec.md
index 233b6a4..9ba24f0 100644
--- a/research/fx3d-sprite-spec.md
+++ b/research/fx3d-sprite-spec.md
@@ -2,11 +2,12 @@
The first-person view (`/?fpv`, and the replay's "your eyes" mode) throws
spell moments through the air as **billboard sprites**: flat images that
-always face the camera, drawn glowing in mid-air, occluded by walls, and
-scaled by distance. Nine sprites cover every spell moment in the game.
-Each lives at `packages/web/public/textures/../fx3d/.png` — replace
-the file, refresh, done. The current files are placeholder radial glows;
-all nine are shown side by side at `/?tokens` under "The conjurations."
+always face the camera, drawn in mid-air, occluded by walls, and scaled
+by distance. The sprites in `FX_ART` (fpv/fx3d.ts) cover every spell
+moment in the game. Each lives at `packages/web/public/fx3d/.png`
+— replace the file, refresh, done. Missing files fall back to built-in
+procedural glows; the whole set is shown side by side at `/?tokens`
+under "The conjurations."
## Technical requirements
@@ -15,10 +16,9 @@ all nine are shown side by side at `/?tokens` under "The conjurations."
- **Size:** square, any resolution; 128×128 or 256×256 recommended. The
renderer scales freely and pixelates on magnification (the whole view
is deliberately chunky, like the wall textures).
-- **Drawn additively:** sprites are composited in "lighter" (additive)
- mode over a dark dungeon, so dark pixels vanish and bright pixels glow.
- Paint light-on-transparent; pure black will be invisible. Midtones read
- as translucent light.
+- **Compositing:** painted sprites draw normally — inks stay true, dark
+ outlines hold. (Only `warpglow` in terrain3d is drawn additively, as
+ light.) Impacts fade out by alpha as they swell.
- **No animation frames.** Each sprite is a single still. Motion comes
from the renderer: projectiles streak across the room, impacts swell
from small to large while fading out. Radially symmetric (or nearly so)
@@ -39,11 +39,11 @@ all nine are shown side by side at `/?tokens` under "The conjurations."
| `hit.png` | impact | damage landing on someone you can see; punches, claws |
| `shimmer.png` | impact | teleports, warps, illusions, minds touched — the general "magic happened here" glimmer, violet by tradition |
| `shield.png` | impact | an attack fully stopped; absorbed spells |
-| `rubble.png` | prop | a destroyed wall's remains: bursts on the kill, then stands as a mound at the fallen edge for the rest of the reel. Unlike the others this is drawn OPAQUE (normal compositing) — paint it as solid debris, not light |
+| `rubble.png` | prop | a destroyed wall's remains: bursts on the kill, then stands stretched across the fallen edge's whole gap for the rest of the reel — piles painted at the canvas's lower corners land at the two posts |
Projectiles hold their size in flight (about a third of a wizard's
-height). Impacts start small and swell to roughly double while fading —
-so an impact painted as a ring or burst reads especially well.
+height). Impacts start small and swell past triple while fading — so an
+impact painted as a ring or burst reads especially well.
What the sprites do NOT need to carry: screen-wide flashes when YOU are
hit (red), healed (green), or teleported (violet), the camera shake, the
@@ -81,10 +81,6 @@ hedge — leaf litter to the square's edges, a floor decal like the pit.
`public/terrain3d/safe.png` is the wall safe, standing as an opaque
strongbox (square, transparent ground).
-Note on `rubble.png`: it renders stretched ACROSS the fallen wall's
-whole gap — one cell wide, half a cell tall — so piles painted at the
-canvas's lower corners land at the two posts.
-
## One more paintable surface: the home tile
`public/textures/home.png` is a floor overlay for home-base cells,