diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 29d59c2..aaba267 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -147,19 +147,26 @@ function shareData(id: string): ShareData | null { 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, rawHost: string, rawProto: string): string { - // Host and proto arrive from request headers — attacker-writable text - // that must never reach an HTML attribute raw. - const proto = /^https?$/.test(rawProto) ? rawProto : "https"; - const host = escapeHtml(rawHost); - // 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") +/** index.html with a bespoke OpenGraph card folded into its head — + * crawlers never run the app, so the unfurl must arrive pre-baked. The + * stock page carries its own generic card; strip it, or crawlers (which + * take the FIRST tag they meet) never see this page's. */ +function ogPage(metas: string[]): string { + return readFileSync(join(staticRoot!, "index.html"), "utf8") .replace(/]*>\s*/g, "") - .replace(/
link: an invitation while the
+ * room waits to start, a summons to the gallery once it has. */
+function inviteHtml(room: Room, rawHost: string, rawProto: string): string {
+ const base = safeBase(rawHost, rawProto);
+ const seats = room.players.length;
+ const wizards = `${seats} wizard${seats === 1 ? "" : "s"}`;
+ const title = `You're summoned — Wiz-War room ${room.id}`;
+ const desc = room.state
+ ? `The duel is underway, ${wizards} in the labyrinth. Follow the link to watch it live from the Peanut Gallery.`
+ : `${wizards} at the table, waiting to flip the boards. Follow the link, pick a name, and take a seat.`;
+ const metas = [
+ `${title} `,
+ ``,
+ ``,
+ ``,
+ ``,
+ ``,
+ ``,
+ ``,
+ ``,
+ ``,
+ ``,
+ ];
+ return ogPage(metas);
}
const httpServer = createServer((req, res) => {
try {
@@ -233,6 +266,19 @@ const httpServer = createServer((req, res) => {
res.end(shareHtml(watch[1]!, data, hostname, proto));
return;
}
+ // Room invitations: a living room gets its recruiting card; a dead
+ // code falls through to the app, which reports it in the lobby.
+ const invite = url.match(/^\/join\/([A-Za-z0-9]{4})$/);
+ if (invite) {
+ const room = getRoom(invite[1]!);
+ if (room) {
+ 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(inviteHtml(room, hostname, proto));
+ return;
+ }
+ }
let file = normalize(join(staticRoot, url === "/" ? "index.html" : url));
if (file !== staticRoot && !file.startsWith(staticRoot + sep)) {
res.writeHead(403).end();
diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte
index 1e9c89c..5211342 100644
--- a/packages/web/src/App.svelte
+++ b/packages/web/src/App.svelte
@@ -23,6 +23,33 @@
let name = $state(prefs.wizardName);
let joinCode = $state("");
let claimPhrase = $state("");
+ /** A /join/ recruiting link. The visitor lands looking at the
+ * room: a held seat walks them back to it, a waiting room offers a
+ * seat or the gallery, a started game is watched from the gallery. */
+ const inviteCode = location.pathname.match(/^\/join\/([A-Za-z0-9]{4})$/)?.[1]?.toUpperCase() ?? null;
+ if (inviteCode) joinCode = inviteCode;
+ let inviteFollowed = false;
+ $effect(() => {
+ if (!inviteCode || inviteFollowed || net.status !== "connected" || net.roomId) return;
+ inviteFollowed = true;
+ const seat = net.seats.find((s) => s.roomId === inviteCode);
+ if (seat) net.resume(seat);
+ else net.watch(inviteCode);
+ });
+ /** The address bar holds the table's invite link while you sit at one,
+ * so recruiting is a copy away. */
+ $effect(() => {
+ const path = net.roomId ? `/join/${net.roomId}` : "/";
+ if (location.pathname !== path) history.replaceState(null, "", path + location.search);
+ });
+ const inviteLink = $derived(net.roomId ? `${location.origin}/join/${net.roomId}` : "");
+ let inviteCopied = $state(false);
+ function copyInvite() {
+ navigator.clipboard?.writeText(inviteLink).then(() => {
+ inviteCopied = true;
+ setTimeout(() => (inviteCopied = false), 1600);
+ });
+ }
let showHelp = $state(false);
/** The finished game whose victory fanfare has been dismissed. */
let victorySeen = $state(false);
@@ -1827,7 +1854,11 @@
Room {net.roomId}
- Share the code. Two to six wizards enter the maze.
+ Share the code — or the link. Two to six wizards enter the maze.
+
+ {inviteLink}
+
+
{#each net.players as p (p)}
{@const chosen = net.roomColors[p]}
@@ -1847,6 +1878,15 @@
{#if net.spectating}
👁 You watch from the Peanut Gallery. Waiting for the boards to flip…
+ {#if net.players.length < 6}
+
+
+
+
+ {/if}
{:else}
{#each [0, 1, 2, 3, 4, 5] as c (c)}
@@ -2716,6 +2756,31 @@
.roster-standee { width: 1.9rem; height: 1.9rem; border-radius: 4px; object-fit: cover; }
.check { display: flex; gap: 0.5rem; align-items: center; justify-content: center; font-size: 0.92rem; margin-bottom: 1rem; }
.waiting { color: #6b5a41; font-style: italic; }
+ .invite-row {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.5rem;
+ margin-bottom: 0.5rem;
+ }
+ .invite-link {
+ font-size: 0.8rem;
+ color: #6b5a41;
+ background: rgba(0, 0, 0, 0.06);
+ border-radius: 4px;
+ padding: 0.15rem 0.45rem;
+ user-select: all;
+ }
+ .gallery-join {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.5rem;
+ margin-top: 0.4rem;
+ }
+ .gallery-join input {
+ width: 11rem;
+ }
.transfer-slip {
max-width: 30rem;