Odds, ends, and every missing effect: doorways dressed, victory earns its fireworks

The audit Eric called for, delivered whole. Open doors are DOORWAYS
now: stone jamb posts at both ends and a lintel hung across the top
(paintable as textures/doorframe.png), the way through open to the eye.
Battle damage shows — the engine's per-edge wallDamage wears painted
cracks into any wall, heavier as harm mounts. The wall safe stands as
a strongbox volume instead of a flat token. Hedges take root: an
underbrush floor decal covers the whole square beneath them (Eric's
report — a billboard at center depth can't reach the floor trapezoid's
near edge), and the hedge itself widens past the cell. Rubble
stretches across the fallen wall's full gap, corner piles landing at
the posts, exactly as its art was painted.

The first-person effect gaps close: victory now throws SIX fireworks
climbing over the winner's home with gold washing the screen; sector
rotations flash violet and heave the whole world; the Thumb of God
lands with a burst and a shudder; walls conjured from nothing rise in
stone dust; and every hazard pratfall plays — pit falls (with a dark
drop and jolt for the faller's own eyes), ooze slips, tack yelps,
thorn snaps, slime, dust. Dying in first person fades long and dark.

And the whole tale travels: a finished game's full replay carries the
share button too — turn -1 mints a link to every turn of the game,
each through its wizard's own eyes, titled for the winner's triumph.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-23 19:32:42 -04:00
co-authored by Claude Fable 5
parent a94ab5c6f5
commit 5fc5805354
17 changed files with 269 additions and 39 deletions
+31 -10
View File
@@ -108,6 +108,8 @@ interface ShareData {
steps: { actor: PlayerId; events: unknown[]; view: unknown }[];
actor: string;
round: number;
/** A whole finished game rather than one turn. */
whole?: boolean;
}
const shareCache = new Map<string, { at: number; data: ShareData | null }>();
function shareData(id: string): ShareData | null {
@@ -117,9 +119,21 @@ function shareData(id: string): ShareData | null {
const share = getShare(id);
const room = share ? getRoom(share.roomId) : undefined;
if (share && room?.state) {
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 };
if (share.turn < 0) {
// The whole tale: a finished game from the deal to the crown.
const steps = catchUpSteps(room, SPECTATOR, 0, true);
if (!("error" in steps) && steps.length > 0) {
let winner = "";
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 };
}
} 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 };
}
}
}
if (shareCache.size > 50) shareCache.clear();
@@ -143,9 +157,13 @@ function shareHtml(id: string, data: ShareData, rawHost: string, rawProto: strin
.replace(/<meta (?:property="og:|name="twitter:)[^>]*>\s*/g, "")
.replace(/<title>[^<]*<\/title>\s*/, "");
const base = `${proto}://${host}`;
const title = `${escapeHtml(data.actor)}'s turn — a Wiz-War instant replay`;
const desc = `Round ${data.round || "?"} of a game of Wiz-War, magical combat in a stone labyrinth. ` +
`Watch the turn through ${escapeHtml(data.actor)}'s own eyes, then deal yourself in.`;
const title = data.whole
? "The whole tale — a game of Wiz-War, replayed"
: `${escapeHtml(data.actor)}'s turn — a Wiz-War instant replay`;
const desc = data.whole
? `A full game of Wiz-War, magical combat in a stone labyrinth — every turn through its wizard's own eyes${data.actor ? `, to ${escapeHtml(data.actor)}'s triumph` : ""}. Watch it all, then deal yourself in.`
: `Round ${data.round || "?"} of a game of Wiz-War, magical combat in a stone labyrinth. ` +
`Watch the turn through ${escapeHtml(data.actor)}'s own eyes, then deal yourself in.`;
const metas = [
`<title>${title}</title>`,
`<meta property="og:type" content="website"/>`,
@@ -193,7 +211,7 @@ const httpServer = createServer((req, res) => {
"cache-control": "public, max-age=60",
"access-control-allow-origin": "*",
});
res.end(JSON.stringify({ steps: data.steps, actor: data.actor, round: data.round }));
res.end(JSON.stringify({ steps: data.steps, actor: data.actor, round: data.round, whole: data.whole === true }));
return;
}
const watch = url.match(/^\/watch\/([a-z0-9]{4,20})(\/og\.png)?$/);
@@ -607,14 +625,17 @@ wss.on("connection", (socket) => {
const room = session.roomId ? getRoom(session.roomId) : undefined;
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
const turn = Number(msg.turn);
if (!Number.isInteger(turn) || turn < 0) return send(socket, { type: "error", message: "no such turn" });
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" });
}
session.lastCatchUpAt = now;
const reel = momentSteps(room, session.playerId, turn);
if ("error" in reel) return send(socket, { type: "error", message: reel.error });
// turn -1 shares the whole finished game; anything else, one turn.
const check = turn === -1
? catchUpSteps(room, session.playerId, 0, true)
: momentSteps(room, session.playerId, turn);
if ("error" in check) return send(socket, { type: "error", message: check.error });
const share = mintShare(room.id, turn);
send(socket, { type: "share", id: share.id, turn });
break;