Files
wizwar6e/packages/server/src/ogimage.ts
T
Eric WagonerandClaude Fable 5 4838996cf0 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>
2026-08-23 17:34:00 -04:00

192 lines
7.3 KiB
TypeScript

// 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);
}