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 {