Mid-game invite links and a surprise-report slip

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
This commit is contained in:
Eric Wagoner
2026-08-30 14:08:39 -04:00
co-authored by Claude Fable 5
parent a1fdb37c54
commit 553754a596
4 changed files with 113 additions and 3 deletions
+23 -1
View File
@@ -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;
+7
View File
@@ -148,6 +148,13 @@ export function readAllRooms(): Map<string, RoomLine[]> {
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<string, unknown>): 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 {
+78 -2
View File
@@ -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<string | null>(null);
@@ -1360,7 +1377,10 @@
<button class="mast-leave" onclick={() => local.leave()}>set the game aside</button>
<button class="mast-leave" onclick={() => local.abandon()}>abandon game</button>
{:else if net.roomId}
<span class="mast-room">{net.spectating ? "watching" : "room"} <b>{net.roomId}</b></span>
<button class="mast-room" title="copy the invite link — friends can join, or watch once it's begun"
onclick={copyInvite}>
{net.spectating ? "watching" : "room"} <b>{net.roomId}</b>{inviteCopied ? " — link copied!" : ""}
</button>
{#if net.audience > 0}
<span class="mast-audience" title="the Peanut Gallery">👁 {net.audience}</span>
{/if}
@@ -1368,6 +1388,8 @@
<button class="mast-leave" onclick={() => net.requestTransferCode()}>transfer seat</button>
{/if}
<button class="mast-leave" onclick={() => net.leave()}>{net.spectating ? "leave the gallery" : "leave table"}</button>
<button class="mast-leave" title="something behaved unexpectedly? tell the wizards"
onclick={() => (feedbackOpen = true)}>🐞 report</button>
{/if}
<button class="mast-leave" class:mast-help-solo={!net.roomId} onclick={() => { helpTab = "play"; showHelp = true; }}>
help &amp; rules
@@ -1462,6 +1484,35 @@
{/if}
{/if}
{#if feedbackOpen && net.roomId}
<div class="scrim attack-scrim" role="button" tabindex="-1"
onclick={() => (feedbackOpen = false)} onkeydown={() => {}}>
<div class="attack-notice prefs-slip" role="dialog" aria-label="report a surprise" tabindex="-1"
onclick={(ev) => ev.stopPropagation()} onkeydown={() => {}}>
<div class="attack-title">Something unexpected?</div>
{#if fbSent}
<p class="fb-thanks">Recorded beside the room's ledger — thank you. The wizards will study the moment.</p>
{:else}
<label class="fb-field">
<span>What happened?</span>
<textarea bind:value={fbHappened} rows="3" maxlength="2000"
placeholder="e.g. my fireball hit but no damage was taken"></textarea>
</label>
<label class="fb-field">
<span>What did you expect?</span>
<textarea bind:value={fbExpected} rows="3" maxlength="2000"
placeholder="e.g. 5 points of damage"></textarea>
</label>
<p class="fb-note">The room code and move number ride along automatically.</p>
<div class="attack-actions">
<button class="stamp primary" disabled={!fbHappened.trim()} onclick={submitFeedback}>Send it</button>
<button class="hint-cancel" onclick={() => (feedbackOpen = false)}>never mind</button>
</div>
{/if}
</div>
</div>
{/if}
{#if prefsOpen}
<div class="scrim attack-scrim" role="button" tabindex="-1"
onclick={() => (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; }
+5
View File
@@ -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" });
}