From 553754a596730a17d4e0b88d23eb61a70cece61f Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Sun, 30 Aug 2026 14:08:39 -0400 Subject: [PATCH] Mid-game invite links and a surprise-report slip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The masthead's room code is now a button: one click copies the /join link, which seats newcomers in a waiting room and the gallery once the boards flip. And a 🐞 report opens a two-question slip — what happened, what was expected — that lands in feedback.jsonl beside the rooms with the room code, move number, round, and rules rev attached, so any surprise can be replayed to its exact moment. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF --- packages/server/src/index.ts | 24 +++++++++- packages/server/src/store.ts | 7 +++ packages/web/src/App.svelte | 80 +++++++++++++++++++++++++++++++++- packages/web/src/net.svelte.ts | 5 +++ 4 files changed, 113 insertions(+), 3 deletions(-) diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 42443ea..f30f2cc 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -9,6 +9,7 @@ // {type:"claimTransfer", code} claim a seat on a new device // {type:"catchUp", sinceSeq} replay of moves missed while away // {type:"chat", text} table talk to the room +// {type:"feedback", happened, expected} a surprise report, pinned to room+seq // {type:"rollDie"} the tabletop D4, published as talk // {type:"addBot", style?, tier?} host seats an automaton // {type:"watch", roomId} join the Peanut Gallery: nameless, read-only @@ -25,7 +26,7 @@ // {type:"chat", player, text, at} one line of table talk // {type:"watching", roomId} you are seated in the gallery // {type:"audience", count} how many watch from the gallery -// {type:"transferCode"|"transferClaimed"|"catchUp"|"games"|"stats"} +// {type:"transferCode"|"transferClaimed"|"catchUp"|"games"|"stats"|"feedbackReceived"} // {type:"error", message} import { createServer } from "node:http"; @@ -62,6 +63,7 @@ import { abandonRoom, } from "./rooms"; import { engagementStats, recordHotseat } from "./stats"; +import { appendFeedback } from "./store"; import { getShare, loadShares, mintShare } from "./shares"; import { renderSharePng } from "./ogimage"; import { BOT_LINES, type BanterTrigger } from "./banter"; @@ -796,6 +798,26 @@ wss.on("connection", (socket) => { broadcastRoomState(room); break; } + case "feedback": { + const room = session.roomId ? getRoom(session.roomId) : undefined; + if (!room) return send(socket, { type: "error", message: "join a room first — a report rides its ledger" }); + const clean = (raw: unknown) => + String(raw ?? "").replace(/[\u0000-\u0009\u000b-\u001f\u007f]/g, " ").trim().slice(0, 2000); + const happened = clean(msg.happened); + if (!happened) return send(socket, { type: "error", message: "say what happened" }); + appendFeedback({ + at: new Date().toISOString(), + roomId: room.id, + player: session.playerId ?? "(gallery)", + seq: room.log.length, + round: room.state?.turn.round ?? null, + deckRev: room.state?.config.deckRev ?? null, + happened, + expected: clean(msg.expected), + }); + send(socket, { type: "feedbackReceived" }); + break; + } case "hotseatReport": { // A device finishes a handful of games at most; a firehose is abuse. if (++session.hotseatReports > 20) return; diff --git a/packages/server/src/store.ts b/packages/server/src/store.ts index d0ca44f..cd16af7 100644 --- a/packages/server/src/store.ts +++ b/packages/server/src/store.ts @@ -148,6 +148,13 @@ export function readAllRooms(): Map { return rooms; } +/** Player surprise reports, one JSONL line each, beside the rooms + * directory — the room id and seq pin the exact moment to replay. */ +export function appendFeedback(entry: Record): void { + ensureDataDir(); + appendFileSync(join(DATA_DIR, "..", "feedback.jsonl"), JSON.stringify(entry) + "\n", "utf8"); +} + /** Retire an abandoned room's ledger to the graveyard — never deleted, * only moved out of the living rooms directory. */ export function archiveRoomFile(roomId: string): void { diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index 5b0c69d..a75f36c 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -1179,6 +1179,23 @@ /** The preferences slip (client-side taste, saved on this device). */ let prefsOpen = $state(false); + /** The bug-report slip: what happened vs. what was expected. The room + * code and move number ride along server-side, pinning the moment in + * the ledger for replay. */ + let feedbackOpen = $state(false); + let fbHappened = $state(""); + let fbExpected = $state(""); + let fbSent = $state(false); + function submitFeedback() { + net.sendFeedback(fbHappened.trim(), fbExpected.trim()); + fbSent = true; + setTimeout(() => { + feedbackOpen = false; + fbSent = false; + fbHappened = ""; + fbExpected = ""; + }, 1600); + } /** First tap of marching the Democratic Monster onto YOURSELF: warn once. */ let dmWarnCell = $state(null); @@ -1360,7 +1377,10 @@ {:else if net.roomId} - {net.spectating ? "watching" : "room"} {net.roomId} + {#if net.audience > 0} 👁 {net.audience} {/if} @@ -1368,6 +1388,8 @@ {/if} + {/if} + + + {/if} + + + {/if} + {#if prefsOpen}
(prefsOpen = false)} onkeydown={() => {}}> @@ -2619,8 +2670,33 @@ letter-spacing: 0.06em; color: #6b6454; } - .mast-room { margin-left: auto; font-size: 0.85rem; color: #a49c86; } + .mast-room { + margin-left: auto; + font-size: 0.85rem; + color: #a49c86; + background: none; + border: none; + font-family: inherit; + padding: 0; + cursor: pointer; + } .mast-room b { color: #e9e1cb; letter-spacing: 0.12em; } + .fb-field { display: block; margin: 0.5rem 0; } + .fb-field span { display: block; margin-bottom: 0.2rem; color: #6b5a41; } + .fb-field textarea { + width: 100%; + box-sizing: border-box; + font-family: inherit; + font-size: 0.9rem; + padding: 0.35rem 0.5rem; + border: 1px solid #8a7a5e; + border-radius: 4px; + background: #f3ecd8; + color: #43331f; + resize: vertical; + } + .fb-note { font-size: 0.8rem; color: #6b5a41; font-style: italic; } + .fb-thanks { color: #43331f; padding: 0.6rem 0; } .mast-audience { font-size: 0.85rem; color: #a49c86; white-space: nowrap; } .mind-read { max-width: min(92vw, 46rem); } .caution-reason { padding: 0.15rem 0; } diff --git a/packages/web/src/net.svelte.ts b/packages/web/src/net.svelte.ts index 6aa93da..5453cc4 100644 --- a/packages/web/src/net.svelte.ts +++ b/packages/web/src/net.svelte.ts @@ -594,6 +594,11 @@ class Net { this.games = this.games.filter((g) => g.roomId !== roomId); } + /** A surprise report: the server pins it to the room and move number. */ + sendFeedback(happened: string, expected: string): void { + this.send({ type: "feedback", happened, expected }); + } + requestTransferCode(): void { this.send({ type: "makeTransfer" }); }