Share a turn with the world: /watch links, cards and chrome included

The share button lands on the instant replay. One click mints a slug —
persistent, unguessable, one per (room, turn) — and copies a /watch
link anyone can open: the server rebuilds exactly that turn for the
nameless SPECTATOR, public knowledge only, none of the rest of the
game visible. The page arrives with its OpenGraph card pre-baked into
the head (crawlers never run the app): the turn's title and round in
the text, and an og.png of the maze itself — cells, walls, doors, warp
mouths, homes, standees — painted server-side and PNG-encoded with
nothing but node's own zlib. The viewer page wears its chrome: the
WIZ-WAR masthead linking home, the turn's title, the reel opening
straight into the turn-owner's eyes with a "watch it again" loop, and
a footer that says what this table is and deals the visitor in.

The share page loads as its own entry so a shared link never drags the
full app's socket appetite along; the reel's modal scrim learns to
stand in a page instead of over one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-23 17:34:00 -04:00
co-authored by Claude Fable 5
parent 727b9144d0
commit 4838996cf0
10 changed files with 569 additions and 8 deletions
+118
View File
@@ -58,6 +58,8 @@ import {
type Room,
} from "./rooms";
import { engagementStats, recordHotseat } from "./stats";
import { getShare, loadShares, mintShare } from "./shares";
import { renderSharePng } from "./ogimage";
import { BOT_LINES, type BanterTrigger } from "./banter";
// --- Abuse limits: this is a public server on a small box. -----------------
@@ -80,6 +82,7 @@ const port = Number(process.env.PORT ?? 8787);
// is not reachable from the internet. Dev default stays LAN-friendly.
const host = process.env.HOST ?? "0.0.0.0";
loadPersistedRooms();
loadShares();
// A restart can land mid-bot-turn: without a kick, a restored room whose
// current actor is an automaton waits forever for a human to poke it.
setTimeout(() => {
@@ -97,6 +100,74 @@ const MIME: Record<string, string> = {
// The client build may be absent in development (vite serves it instead);
// serve a 404 for static requests in that case rather than dying on boot.
const staticRoot = existsSync(STATIC_DIR) ? realpathSync(normalize(STATIC_DIR)) : null;
// --- Share pages: one turn, rebuilt for the nameless viewer. ---------------
// Every lookup is a full-game replay, so results rest briefly in memory.
interface ShareData {
steps: { actor: PlayerId; events: unknown[]; view: unknown }[];
actor: string;
round: number;
}
const shareCache = new Map<string, { at: number; data: ShareData | null }>();
function shareData(id: string): ShareData | null {
const hit = shareCache.get(id);
if (hit && Date.now() - hit.at < 30_000) return hit.data;
let data: ShareData | null = null;
const share = getShare(id);
const room = share ? getRoom(share.roomId) : undefined;
if (share && room?.state) {
const steps = momentSteps(room, SPECTATOR, share.turn);
if (!("error" in steps) && steps.length > 0) {
let actor: string = steps[steps.length - 1]!.actor;
let round = 0;
outer: for (const st of steps) {
for (const e of st.events) {
if (e.type === "turnStarted" || e.type === "extraTurnStarted") {
actor = e.player;
if (e.type === "turnStarted") round = e.round;
break outer;
}
}
}
data = { steps: steps as unknown as ShareData["steps"], actor, round };
}
}
if (shareCache.size > 50) shareCache.clear();
shareCache.set(id, { at: Date.now(), data });
return data;
}
const escapeHtml = (t: string) =>
t.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
/** index.html with this share's OpenGraph card folded into its head —
* crawlers never run the app, so the unfurl must arrive pre-baked. */
function shareHtml(id: string, data: ShareData, host: string, proto: string): string {
// The stock page carries its own generic card; strip it, or crawlers
// (which take the FIRST tag they meet) never see this turn's.
const html = readFileSync(join(staticRoot!, "index.html"), "utf8")
.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 metas = [
`<title>${title}</title>`,
`<meta property="og:type" content="website"/>`,
`<meta property="og:site_name" content="Wiz-War"/>`,
`<meta property="og:title" content="${title}"/>`,
`<meta property="og:description" content="${desc}"/>`,
`<meta property="og:url" content="${base}/watch/${id}"/>`,
`<meta property="og:image" content="${base}/watch/${id}/og.png"/>`,
`<meta property="og:image:width" content="1200"/>`,
`<meta property="og:image:height" content="630"/>`,
`<meta name="twitter:card" content="summary_large_image"/>`,
`<meta name="twitter:image" content="${base}/watch/${id}/og.png"/>`,
].join("\n ");
return html.replace("</head>", ` ${metas}\n </head>`);
}
const httpServer = createServer((req, res) => {
try {
if (req.method !== "GET" && req.method !== "HEAD") {
@@ -119,6 +190,36 @@ const httpServer = createServer((req, res) => {
res.writeHead(400).end();
return;
}
// Share pages: the turn's data, its card image, and its chrome.
const api = url.match(/^\/api\/share\/([a-z0-9]{4,20})$/);
if (api) {
const data = shareData(api[1]!);
if (!data) { res.writeHead(404, { "content-type": "application/json" }).end('{"error":"no such replay"}'); return; }
res.writeHead(200, {
"content-type": "application/json",
"cache-control": "public, max-age=60",
"access-control-allow-origin": "*",
});
res.end(JSON.stringify({ steps: data.steps, actor: data.actor, round: data.round }));
return;
}
const watch = url.match(/^\/watch\/([a-z0-9]{4,20})(\/og\.png)?$/);
if (watch) {
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<typeof renderSharePng>[0]);
res.writeHead(200, { "content-type": "image/png", "cache-control": "public, max-age=300" });
res.end(png);
return;
}
const proto = String(req.headers["x-forwarded-proto"] ?? "http").split(",")[0]!.trim();
const hostname = String(req.headers.host ?? `localhost:${port}`);
res.writeHead(200, { "content-type": "text/html", "cache-control": "no-cache" });
res.end(shareHtml(watch[1]!, data, hostname, proto));
return;
}
let file = normalize(join(staticRoot, url === "/" ? "index.html" : url));
if (file !== staticRoot && !file.startsWith(staticRoot + sep)) {
res.writeHead(403).end();
@@ -508,6 +609,23 @@ wss.on("connection", (socket) => {
send(socket, { type: "catchUp", steps });
break;
}
case "share": {
// Mint (or re-find) the public link for one turn's replay.
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" });
const now = Date.now();
if (now - session.lastCatchUpAt < CATCHUP_COOLDOWN_MS) {
return send(socket, { type: "error", message: "one moment" });
}
session.lastCatchUpAt = now;
const steps = momentSteps(room, session.playerId, turn);
if ("error" in steps) return send(socket, { type: "error", message: steps.error });
const share = mintShare(room.id, turn);
send(socket, { type: "share", id: share.id, turn });
break;
}
case "moment": {
// A chronicle line's instant-replay eye: one turn's reel.
const room = session.roomId ? getRoom(session.roomId) : undefined;
+191
View File
@@ -0,0 +1,191 @@
// The share card: a 1200x630 OpenGraph image of the shared turn's maze,
// painted from a spectator view and encoded as PNG with nothing but
// node's own zlib — no image libraries on this small box. The unfurl
// text carries the words; this carries the board.
import { deflateSync } from "node:zlib";
import type { GameView } from "@wizwar/engine";
// --- Minimal PNG encoding (truecolor, filter 0) ----------------------------
const CRC_TABLE = new Int32Array(256);
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
CRC_TABLE[n] = c;
}
function crc32(buf: Uint8Array): number {
let c = ~0;
for (const b of buf) c = CRC_TABLE[(c ^ b) & 0xff]! ^ (c >>> 8);
return ~c >>> 0;
}
function chunk(type: string, data: Uint8Array): Uint8Array {
const out = new Uint8Array(12 + data.length);
const dv = new DataView(out.buffer);
dv.setUint32(0, data.length);
for (let i = 0; i < 4; i++) out[4 + i] = type.charCodeAt(i);
out.set(data, 8);
dv.setUint32(8 + data.length, crc32(out.subarray(4, 8 + data.length)));
return out;
}
function encodePng(width: number, height: number, rgb: Uint8Array): Buffer {
const ihdr = new Uint8Array(13);
const dv = new DataView(ihdr.buffer);
dv.setUint32(0, width);
dv.setUint32(4, height);
ihdr[8] = 8; // bit depth
ihdr[9] = 2; // truecolor
const raw = new Uint8Array(height * (1 + width * 3));
for (let y = 0; y < height; y++) {
raw.set(rgb.subarray(y * width * 3, (y + 1) * width * 3), y * (1 + width * 3) + 1);
}
return Buffer.concat([
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
chunk("IHDR", ihdr),
chunk("IDAT", deflateSync(raw)),
chunk("IEND", new Uint8Array(0)),
]);
}
// --- The painting ----------------------------------------------------------
type Rgb = [number, number, number];
const hex = (s: string): Rgb => [
parseInt(s.slice(1, 3), 16), parseInt(s.slice(3, 5), 16), parseInt(s.slice(5, 7), 16),
];
const BG = hex("#171a20");
const CELL = hex("#e9e1cb");
const CELL_LINE = hex("#cfc4a8");
const WALL = hex("#43331f");
const DOOR = hex("#a2703a");
const FIRE = hex("#d65c1c");
const STONE = hex("#8a8a94");
const WARP = hex("#7a4ccc");
const CREATURE = hex("#4a4438");
const TREASURE = hex("#c9a72a");
const HAZARD = hex("#7c8a4a");
/** Standee colors, mirroring the client's PLAYER_COLORS. */
const PLAYER: Rgb[] = [
hex("#1a9c46"), hex("#d3352b"), hex("#c9308f"),
hex("#3a3ac0"), hex("#2ab0c9"), hex("#c9a72a"),
];
export function renderSharePng(view: GameView): Buffer {
const W = 1200, H = 630;
const img = new Uint8Array(W * H * 3);
const rect = (x: number, y: number, w: number, h: number, c: Rgb) => {
const x0 = Math.max(0, Math.round(x)), y0 = Math.max(0, Math.round(y));
const x1 = Math.min(W, Math.round(x + w)), y1 = Math.min(H, Math.round(y + h));
for (let yy = y0; yy < y1; yy++) {
let o = (yy * W + x0) * 3;
for (let xx = x0; xx < x1; xx++) {
img[o] = c[0]; img[o + 1] = c[1]; img[o + 2] = c[2];
o += 3;
}
}
};
const disc = (cx: number, cy: number, r: number, c: Rgb) => {
for (let yy = Math.round(cy - r); yy <= cy + r; yy++) {
for (let xx = Math.round(cx - r); xx <= cx + r; xx++) {
if (xx < 0 || yy < 0 || xx >= W || yy >= H) continue;
if ((xx - cx) ** 2 + (yy - cy) ** 2 > r * r) continue;
const o = (yy * W + xx) * 3;
img[o] = c[0]; img[o + 1] = c[1]; img[o + 2] = c[2];
}
}
};
rect(0, 0, W, H, BG);
const bw = view.board.width, bh = view.board.height;
const cell = Math.floor(Math.min((W - 120) / bw, (H - 80) / bh));
const ox = Math.round((W - bw * cell) / 2);
const oy = Math.round((H - bh * cell) / 2);
const px = (cx: number, cy: number) => [ox + cx * cell, oy + cy * cell] as const;
const line = Math.max(3, Math.round(cell * 0.1));
// The rooms of the maze.
const has = (cx: number, cy: number) => !!view.board.cells[`${cx},${cy}`];
for (const k of Object.keys(view.board.cells)) {
const [cx, cy] = k.split(",").map(Number) as [number, number];
const [x, y] = px(cx, cy);
rect(x, y, cell, cell, CELL);
rect(x, y, cell, 1, CELL_LINE);
rect(x, y, 1, cell, CELL_LINE);
}
// Home bases wear their owner's color as a floor border.
for (const p of view.players) {
if (!p.home) continue;
const [x, y] = px(p.home.x, p.home.y);
const c = PLAYER[p.colorIndex] ?? WALL;
const t = Math.max(2, Math.round(cell * 0.08));
rect(x + 2, y + 2, cell - 4, t, c);
rect(x + 2, y + cell - 2 - t, cell - 4, t, c);
rect(x + 2, y + 2, t, cell - 4, c);
rect(x + cell - 2 - t, y + 2, t, cell - 4, c);
}
// The floor's furniture: filled stone reads solid; other hazards dot.
for (const [k, content] of Object.entries(view.squareContents)) {
const [cx, cy] = k.split(",").map(Number) as [number, number];
const [x, y] = px(cx, cy);
if (content.kind === "stone") rect(x + 1, y + 1, cell - 2, cell - 2, STONE);
else disc(x + cell / 2, y + cell / 2, cell * 0.16, HAZARD);
}
// Interior walls, doors, and fire, on their edges.
for (const [k, e] of Object.entries(view.board.edges)) {
if (e === "open") continue;
const [kind, coords] = k.split(":") as [string, string];
const [ex, ey] = coords.split(",").map(Number) as [number, number];
const c = e === "door" ? DOOR : e === "firewall" ? FIRE : WALL;
if (kind === "V") {
const [x, y] = px(ex + 1, ey);
rect(x - line / 2, y, line, cell, c);
} else {
const [x, y] = px(ex, ey + 1);
rect(x, y - line / 2, cell, line, c);
}
}
// The rim: every open side facing nothing is a wall, except warp mouths.
const mouth = new Set(view.board.warps.map((w) => `${w.from.cell.x},${w.from.cell.y}:${w.from.side}`));
for (const k of Object.keys(view.board.cells)) {
const [cx, cy] = k.split(",").map(Number) as [number, number];
const [x, y] = px(cx, cy);
const side = (missing: boolean, sx: number, sy: number, w: number, h: number, s: string) => {
if (!missing) return;
rect(sx, sy, w, h, mouth.has(`${cx},${cy}:${s}`) ? WARP : WALL);
};
side(!has(cx + 1, cy), x + cell - line / 2, y, line, cell, "E");
side(!has(cx - 1, cy), x - line / 2, y, line, cell, "W");
side(!has(cx, cy + 1), x, y + cell - line / 2, cell, line, "S");
side(!has(cx, cy - 1), x, y - line / 2, cell, line, "N");
}
// Loose treasures glint gold.
for (const t of view.treasures) {
if (!t.position || t.carriedBy) continue;
const [x, y] = px(t.position.x, t.position.y);
rect(x + cell * 0.6, y + cell * 0.6, cell * 0.25, cell * 0.25, TREASURE);
}
// Creatures crouch dark; wizards stand in their colors.
for (const c of view.creatures) {
const [x, y] = px(c.position.x, c.position.y);
disc(x + cell * 0.3, y + cell * 0.68, cell * 0.18, CREATURE);
}
const standing = new Map<string, number>();
for (const p of view.players) {
if (!p.alive) continue;
const key = `${p.position.x},${p.position.y}`;
const n = standing.get(key) ?? 0;
standing.set(key, n + 1);
const [x, y] = px(p.position.x, p.position.y);
const cx = x + cell / 2 + (n - 0.5) * cell * 0.16;
disc(cx, y + cell * 0.42, cell * 0.26, PLAYER[p.colorIndex] ?? WALL);
disc(cx, y + cell * 0.42, cell * 0.26 - 2, PLAYER[p.colorIndex] ?? WALL);
}
return encodePng(W, H, img);
}
+4 -1
View File
@@ -428,7 +428,10 @@ const TURN_BOUNDARY = new Set(["turnStarted", "extraTurnStarted", "turnSkipped"]
*/
export function momentSteps(room: Room, playerId: PlayerId, turnIndex: number): CatchUpStep[] | { error: string } {
if (!room.state) return { error: "game not started" };
if (!room.players.includes(playerId)) return { error: "you hold no seat in this room" };
// The SPECTATOR builds share pages: public knowledge only, no seat.
if (playerId !== SPECTATOR && !room.players.includes(playerId)) {
return { error: "you hold no seat in this room" };
}
const MAX_STEPS = 80;
const { state: fresh, events: dealt } = createGame(room.state.config);
let current = fresh;
+58
View File
@@ -0,0 +1,58 @@
// Share links: a minted slug names one turn of one game, and anyone
// holding it may watch that turn — spectator-redacted, nothing else of
// the game visible. Shares persist beside the rooms and survive restarts.
import { appendFileSync, existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { randomInt } from "node:crypto";
import { statsDir } from "./store";
export interface Share {
id: string;
roomId: string;
turn: number;
createdAt: string;
}
const SHARE_ALPHABET = "abcdefghjkmnpqrstuvwxyz23456789";
const SHARE_ID_LENGTH = 10; // 31^10 ≈ 8×10^14 — unguessable, typeable
const shares = new Map<string, Share>();
function sharesFile(): string {
return join(statsDir(), "shares.jsonl");
}
export function loadShares(): void {
const file = sharesFile();
if (!existsSync(file)) return;
for (const line of readFileSync(file, "utf8").trim().split("\n")) {
if (!line) continue;
try {
const s = JSON.parse(line) as Share;
if (s.id) shares.set(s.id, s);
} catch {
// A torn tail line loses one share, never the server.
}
}
}
export function mintShare(roomId: string, turn: number): Share {
// One link per (room, turn): sharing the same turn twice hands back
// the same slug, so a re-share never splits an audience.
for (const s of shares.values()) {
if (s.roomId === roomId && s.turn === turn) return s;
}
let id = "";
for (let i = 0; i < SHARE_ID_LENGTH; i++) {
id += SHARE_ALPHABET[randomInt(SHARE_ALPHABET.length)];
}
const share: Share = { id, roomId, turn, createdAt: new Date().toISOString() };
shares.set(id, share);
appendFileSync(sharesFile(), JSON.stringify(share) + "\n", "utf8");
return share;
}
export function getShare(id: string): Share | undefined {
return shares.get(id);
}
+1 -1
View File
@@ -1612,7 +1612,7 @@
{/if}
{#if net.moment && net.moment.length > 0}
<Replay steps={net.moment} moment onclose={() => net.closeMoment()} />
<Replay steps={net.moment} moment onshare={() => net.requestShare()} onclose={() => net.closeMoment()} />
{:else if net.catchUp && net.catchUp.length > 0}
<Replay steps={net.catchUp} onclose={() => net.closeCatchUp()} />
{:else if local.replaySteps && local.replaySteps.length > 0}
+28 -1
View File
@@ -14,14 +14,35 @@
steps,
onclose,
moment = false,
onshare = null,
endLabel = null,
}: {
steps: { seq: number; actor: string; events: GameEvent[]; view: GameView; chat?: { player: string; text: string }[] }[];
onclose: () => void;
/** An instant replay of one turn: open straight into first person,
* through the eyes of the wizard whose turn it is. */
moment?: boolean;
/** Mint a public link to this turn; resolves to the URL. */
onshare?: (() => Promise<string>) | null;
/** What the leave button says once the reel has run out. */
endLabel?: string | null;
} = $props();
/** The share button's little life: offer, mint, report. */
let shareState = $state<"idle" | "minting" | "copied" | "failed">("idle");
async function doShare() {
if (!onshare || shareState === "minting") return;
shareState = "minting";
try {
const url = await onshare();
await navigator.clipboard.writeText(url);
shareState = "copied";
} catch {
shareState = "failed";
}
setTimeout(() => (shareState = "idle"), 4000);
}
let idx = $state(0);
let playing = $state(true);
let speed = $state(1);
@@ -378,7 +399,13 @@
<button class="replay-eyes" class:lit={recorder !== null} onclick={toggleRecord}>
{recorder ? "⏹ stop & save" : "⏺ save video"}</button>
{/if}
<button class="replay-skip" onclick={onclose}>{atEnd ? "back to the game" : "skip to now"}</button>
{#if moment && onshare}
<button class="replay-eyes" class:lit={shareState === "copied"} onclick={doShare}>
{shareState === "idle" ? "🔗 share link"
: shareState === "minting" ? "…"
: shareState === "copied" ? "✓ link copied" : "share failed"}</button>
{/if}
<button class="replay-skip" onclick={onclose}>{atEnd ? (endLabel ?? "back to the game") : "skip to now"}</button>
</header>
<div class="replay-board" bind:this={stageEl}>
{#if fp}
+137
View File
@@ -0,0 +1,137 @@
<script lang="ts">
// The public share page (/watch/<id>): one turn of one game, watchable
// by anyone, spectator-redacted — the rest of the game stays private.
// The reel opens in first person through the turn-owner's eyes, framed
// by enough chrome to explain itself and point the way home.
import Replay from "./Replay.svelte";
import type { GameEvent, GameView } from "@wizwar/engine";
const id = location.pathname.split("/")[2] ?? "";
// Under the vite dev server the API lives on the game server's port.
const API = location.port === "5173" ? `http://${location.hostname}:8787` : "";
interface ShareSteps {
steps: { seq: number; actor: string; events: GameEvent[]; view: GameView }[];
actor: string;
round: number;
}
let data = $state<ShareSteps | null>(null);
let failed = $state(false);
let reelKey = $state(0);
$effect(() => {
fetch(`${API}/api/share/${id}`)
.then((r) => (r.ok ? r.json() : Promise.reject(new Error("gone"))))
.then((d) => (data = d))
.catch(() => (failed = true));
});
</script>
<div class="share-page">
<header class="share-head">
<a class="share-brand" href="/">WIZ-WAR</a>
{#if data}
<div class="share-title">Instant replay — {data.actor}'s turn{data.round ? ` (round ${data.round})` : ""}</div>
{/if}
</header>
{#if failed}
<div class="share-note">
<p>This replay has wandered off — the link may be old, or the game gone.</p>
<a class="share-cta" href="/">▶ deal yourself into a fresh game</a>
</div>
{:else if !data}
<div class="share-note"><p>Rebuilding the turn…</p></div>
{:else}
<div class="share-stage">
{#key reelKey}
<Replay steps={data.steps} moment endLabel="⟲ watch it again"
onclose={() => (reelKey += 1)} />
{/key}
</div>
{/if}
<footer class="share-foot">
<p>
<strong>Wiz-War</strong> is Tom Jolly's 1983 game of magical combat in
a stone labyrinth. This digital table is an unofficial, non-commercial
fan re-creation of the sixth edition (Chessex, 1993) — every card
transcribed, every wall verified, replays rebuilt move by move from
the game's own ledger.
</p>
<a class="share-cta" href="/">▶ play free — two to six wizards enter the maze</a>
</footer>
</div>
<style>
.share-page {
min-height: 100vh;
background: #171a20;
color: #d8d2c0;
font-family: "Archivo Narrow", system-ui, sans-serif;
display: flex;
flex-direction: column;
align-items: center;
padding: 1rem;
}
.share-head {
display: flex;
align-items: baseline;
gap: 1rem;
width: min(46rem, 100%);
margin-bottom: 0.6rem;
}
.share-brand {
font-family: "Oswald", sans-serif;
letter-spacing: 0.3em;
font-size: 1.1rem;
color: #e0b34a;
text-decoration: none;
}
.share-title {
font-family: "Oswald", sans-serif;
text-transform: uppercase;
letter-spacing: 0.12em;
font-size: 0.85rem;
color: #e9e1cb;
}
/* The reel is a modal everywhere else; here it stands in the page. */
.share-stage { width: min(46rem, 100%); }
.share-stage :global(.replay-scrim) {
position: relative;
inset: auto;
background: none;
padding: 0;
display: block;
z-index: 1;
}
.share-stage :global(.replay) { width: 100%; max-height: none; }
.share-note {
background: #efe8d4;
color: #3a2f1f;
border-radius: 4px;
padding: 1.2rem 1.5rem;
margin: 2rem 0;
font-family: "Courier Prime", monospace;
text-align: center;
}
.share-foot {
width: min(46rem, 100%);
margin-top: 1rem;
color: #8d8672;
font-size: 0.9rem;
line-height: 1.5;
}
.share-foot strong { color: #c9a72a; }
.share-cta {
display: inline-block;
margin-top: 0.4rem;
background: #e9e1cb;
color: #43331f;
border: 1.5px solid #43331f;
border-radius: 3px;
padding: 0.45rem 0.9rem;
text-decoration: none;
font-family: "Oswald", sans-serif;
letter-spacing: 0.06em;
}
</style>
+1 -1
View File
@@ -290,7 +290,7 @@
.map((b) => project(b, ex, ey))
.filter((s): s is Projected => s !== null);
for (const spot of rubble) {
const s = project({ x: spot.x, y: spot.y, src: "/fx3d/rubble.png", scale: 0.4, rise: 0 }, ex, ey);
const s = project({ x: spot.x, y: spot.y, src: "/fx3d/rubble.png", scale: 0.55, rise: 0 }, ex, ey);
if (s) sprites.push({ ...s, fallback: "rubble" });
}
for (const f of fx) {
+8 -4
View File
@@ -1,6 +1,10 @@
import { mount } from "svelte";
import App from "./App.svelte";
const app = mount(App, { target: document.getElementById("app")! });
export default app;
// /watch/<id> is the public share page — one turn, anyone may look. It
// loads its own component so the full app (and its live socket appetite)
// stays out of a shared link's way.
const target = document.getElementById("app")!;
const page = location.pathname.startsWith("/watch/")
? import("./SharePage.svelte")
: import("./App.svelte");
page.then(({ default: Root }) => mount(Root, { target }));
+23
View File
@@ -321,6 +321,9 @@ class Net {
private turnCounter = -1;
/** The turn that already carries an instant-replay eye (one per turn). */
private eyeTurn = -1;
/** 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 seen: Record<string, number> = loadSeen();
/** Room whose live stream this connection has already shown once: states
* after the first mark themselves seen while the tab is visible. */
@@ -423,6 +426,10 @@ class Net {
case "moment":
this.moment = msg.steps;
break;
case "share":
this.shareResolve?.(`${location.origin}/watch/${msg.id}`);
this.shareResolve = null;
break;
case "events": {
let talk = 0;
if (!msg.replayed) this.onFx?.(msg.events as GameEvent[]);
@@ -688,9 +695,25 @@ class Net {
/** Summon one turn's reel by its chronicle turn number. */
requestMoment(turn: number): void {
this.momentTurn = turn;
this.send({ type: "moment", turn });
}
/** Mint a public link to the open moment's turn. */
requestShare(): Promise<string> {
return new Promise((resolve, reject) => {
if (this.momentTurn === null) return reject(new Error("no turn open"));
this.shareResolve = resolve;
this.send({ type: "share", turn: this.momentTurn });
setTimeout(() => {
if (this.shareResolve === resolve) {
this.shareResolve = null;
reject(new Error("share timed out"));
}
}, 10_000);
});
}
closeMoment(): void {
this.moment = null;
}