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:
co-authored by
Claude Fable 5
parent
727b9144d0
commit
4838996cf0
@@ -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, "&").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(/<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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user