From 4838996cf004324b23263deedc40ec9a47b85969 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Sun, 23 Aug 2026 17:34:00 -0400 Subject: [PATCH] Share a turn with the world: /watch links, cards and chrome included MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/server/src/index.ts | 118 +++++++++++++++ packages/server/src/ogimage.ts | 191 ++++++++++++++++++++++++ packages/server/src/rooms.ts | 5 +- packages/server/src/shares.ts | 58 +++++++ packages/web/src/App.svelte | 2 +- packages/web/src/Replay.svelte | 29 +++- packages/web/src/SharePage.svelte | 137 +++++++++++++++++ packages/web/src/fpv/FirstPerson.svelte | 2 +- packages/web/src/main.ts | 12 +- packages/web/src/net.svelte.ts | 23 +++ 10 files changed, 569 insertions(+), 8 deletions(-) create mode 100644 packages/server/src/ogimage.ts create mode 100644 packages/server/src/shares.ts create mode 100644 packages/web/src/SharePage.svelte diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index bbb3bac..5956b6c 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -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 = { // 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(); +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, "&").replace(//g, ">").replace(/"/g, """); + +/** 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(/]*>\s*/g, "") + .replace(/[^<]*<\/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}`, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ].join("\n "); + return html.replace("", ` ${metas}\n `); +} 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[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; diff --git a/packages/server/src/ogimage.ts b/packages/server/src/ogimage.ts new file mode 100644 index 0000000..9ea5b02 --- /dev/null +++ b/packages/server/src/ogimage.ts @@ -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(); + 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); +} diff --git a/packages/server/src/rooms.ts b/packages/server/src/rooms.ts index 25f376b..fb6be45 100644 --- a/packages/server/src/rooms.ts +++ b/packages/server/src/rooms.ts @@ -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; diff --git a/packages/server/src/shares.ts b/packages/server/src/shares.ts new file mode 100644 index 0000000..4afbfb1 --- /dev/null +++ b/packages/server/src/shares.ts @@ -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(); + +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); +} diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index 618a42e..7d055f3 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -1612,7 +1612,7 @@ {/if} {#if net.moment && net.moment.length > 0} - net.closeMoment()} /> + net.requestShare()} onclose={() => net.closeMoment()} /> {:else if net.catchUp && net.catchUp.length > 0} net.closeCatchUp()} /> {:else if local.replaySteps && local.replaySteps.length > 0} diff --git a/packages/web/src/Replay.svelte b/packages/web/src/Replay.svelte index 02e2eb3..df90e1a 100644 --- a/packages/web/src/Replay.svelte +++ b/packages/web/src/Replay.svelte @@ -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) | 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 @@ {/if} - + {#if moment && onshare} + + {/if} +
{#if fp} diff --git a/packages/web/src/SharePage.svelte b/packages/web/src/SharePage.svelte new file mode 100644 index 0000000..b5308bd --- /dev/null +++ b/packages/web/src/SharePage.svelte @@ -0,0 +1,137 @@ + + + + + diff --git a/packages/web/src/fpv/FirstPerson.svelte b/packages/web/src/fpv/FirstPerson.svelte index f3f8e62..e4ed672 100644 --- a/packages/web/src/fpv/FirstPerson.svelte +++ b/packages/web/src/fpv/FirstPerson.svelte @@ -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) { diff --git a/packages/web/src/main.ts b/packages/web/src/main.ts index 5409f56..ff9c772 100644 --- a/packages/web/src/main.ts +++ b/packages/web/src/main.ts @@ -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/ 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 })); diff --git a/packages/web/src/net.svelte.ts b/packages/web/src/net.svelte.ts index fe9c634..17256c5 100644 --- a/packages/web/src/net.svelte.ts +++ b/packages/web/src/net.svelte.ts @@ -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 = 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 { + 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; }