Reports are threads, and every turn of one rings the desk's bell

A player answers the desk under its reply from their lobby, proven by
the seat the report was filed from; the lobby shows the exchange in
order and says whose word is pending. A new report, a player's answer,
and a desk reply each raise a Sentry issue on the keeper's phone — the
first two from the server, the last from the reply script itself,
straight to Sentry from the droplet. The Desktop digest lists the
answers, and the desk's rule reads a report as reopened when the last
word is the player's.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
This commit is contained in:
Eric Wagoner
2026-09-21 12:08:55 -04:00
co-authored by Claude Fable 5.1
parent ffbca5bfb9
commit 11e9f950c6
7 changed files with 114 additions and 16 deletions
+40 -2
View File
@@ -1064,9 +1064,48 @@ wss.on("connection", (socket, req) => {
happened,
expected: clean(msg.expected),
});
// The desk's bell: one issue per report, the gist in the title.
if (process.env.SENTRY_DSN) {
Sentry.captureMessage(`Report from ${session.playerId ?? "the gallery"} in ${room.id}: ${happened.slice(0, 100)}`, {
level: "error",
fingerprint: ["report", reportId],
tags: { room: room.id, report: reportId, player: session.playerId ?? "(gallery)" },
extra: { happened, expected: clean(msg.expected), link: `${PUBLIC_URL}/join/${room.id}`, seq: room.log.length },
});
}
send(socket, { type: "feedbackReceived", id: reportId });
break;
}
case "feedbackAnswer": {
// A player's word under the desk's reply, from their lobby: the
// seat is proven as the ledger proves it, and the report must be
// theirs. Each answer rings the desk's bell.
const seat = (msg.seat ?? {}) as Record<string, unknown>;
const roomId = String(seat.roomId ?? "").toUpperCase();
const name = String(seat.name ?? "");
const proof = peekSummary(roomId, name, typeof seat.token === "string" ? seat.token : null);
if (proof === null || proof === "badToken") return send(socket, { type: "error", message: "that seat is not yours to answer from" });
const reportId = String(msg.reportId ?? "");
const report = readFeedback().find((r) => r.id === reportId);
if (!report || report.player !== name || report.roomId !== roomId) return send(socket, { type: "error", message: "no such report of yours" });
const text = String(msg.text ?? "").replace(/[\u0000-\u0009\u000b-\u001f\u007f]/g, " ").trim().slice(0, 2000);
if (!text) return send(socket, { type: "error", message: "say something" });
if (!reportsPerAddress.allow(session.address)) {
return send(socket, { type: "error", message: "the desk has plenty from you for now — more in an hour" });
}
const at = new Date().toISOString();
appendFeedback({ reportId, from: "player", player: name, text, at });
if (process.env.SENTRY_DSN) {
Sentry.captureMessage(`${name} answers on report ${reportId} (${roomId}): ${text.slice(0, 100)}`, {
level: "error",
fingerprint: ["report-answer", reportId, at],
tags: { room: roomId, report: reportId, player: name },
extra: { text, link: `${PUBLIC_URL}/join/${roomId}` },
});
}
send(socket, { type: "feedbackReceived" });
break;
}
case "myFeedback": {
// Reports for every seat this browser can prove — the same token
// check as the games ledger, and just as wake-free.
@@ -1080,8 +1119,7 @@ wss.on("connection", (socket, req) => {
if (result !== null && result !== "badToken") proven.push({ roomId, name });
}
const reports = readFeedback()
.filter((r) => proven.some((s) => s.roomId === r.roomId && s.name === r.player))
.map(({ player: _player, ...r }) => r);
.filter((r) => proven.some((s) => s.roomId === r.roomId && s.name === r.player));
send(socket, { type: "feedbackList", reports });
break;
}
+9 -3
View File
@@ -220,7 +220,10 @@ export interface FeedbackReport {
round: number | null;
happened: string;
expected: string;
/** The latest word from the desk, for the lobby's one-line summary. */
reply?: { at: string; text: string; status: string };
/** The whole exchange in order: the desk's replies and the player's answers. */
thread: { at: string; text: string; from: "desk" | "player"; status?: string }[];
/** A screenshot's file name under feedback-images/, when one was sent. */
image?: string;
}
@@ -242,10 +245,12 @@ export function readFeedback(): FeedbackReport[] {
const report = reports.get(line.reportId);
if (report && typeof line.image === "string") {
report.image = line.image;
} else if (report && line.from === "player") {
report.thread.push({ at: String(line.at ?? ""), text: String(line.text ?? ""), from: "player" });
} else if (report) {
report.reply = {
at: String(line.at ?? ""), text: String(line.text ?? ""), status: String(line.status ?? "resolved"),
};
const reply = { at: String(line.at ?? ""), text: String(line.text ?? ""), status: String(line.status ?? "resolved") };
report.reply = reply;
report.thread.push({ ...reply, from: "desk" });
}
continue;
}
@@ -260,6 +265,7 @@ export function readFeedback(): FeedbackReport[] {
round: line.round == null ? null : Number(line.round),
happened: String(line.happened ?? ""),
expected: String(line.expected ?? ""),
thread: [],
});
}
return [...reports.values()];
+27 -6
View File
@@ -1517,6 +1517,8 @@
let fbHappened = $state("");
let fbExpected = $state("");
let fbSent = $state(false);
/** Drafts of answers under your reports, by report id. */
let reportAnswers = $state<Record<string, string>>({});
/** A screenshot for the desk, chosen with the report. */
let fbImage = $state<File | null>(null);
let fbPreview = $state<string | null>(null);
@@ -2582,13 +2584,28 @@
<div class="report-row">
<span class="ledger-code">{r.roomId}</span>
<div class="report-body">
<div class="report-text">“{r.happened}”</div>
{#if r.reply}
<div class="report-reply">
<span class="report-status">{r.reply.status}</span> — {r.reply.text}
</div>
{:else}
<div class="report-text">“{r.happened}”{#if r.image}<span class="report-pic" title="with your screenshot"> 📷</span>{/if}</div>
{#each r.thread ?? [] as line, j (j)}
{#if line.from === "player"}
<div class="report-reply yours">you — {line.text}</div>
{:else}
<div class="report-reply"><span class="report-status">{line.status}</span> — {line.text}</div>
{/if}
{/each}
{#if !(r.thread ?? []).length}
<div class="report-reply pending">the wizards are studying the moment…</div>
{:else if (r.thread ?? [])[(r.thread ?? []).length - 1]!.from === "player"}
<div class="report-reply pending">your word is with the wizards…</div>
{/if}
{#if (r.thread ?? []).some((l) => l.from === "desk")}
<form class="report-answer" onsubmit={(e) => {
e.preventDefault();
const t = (reportAnswers[r.id] ?? "").trim();
if (t && net.answerReport(r, t)) reportAnswers = { ...reportAnswers, [r.id]: "" };
}}>
<input bind:value={reportAnswers[r.id]} maxlength="2000" placeholder="answer the wizards…" aria-label="answer the wizards" />
<button class="stamp tiny" type="submit" disabled={!(reportAnswers[r.id] ?? "").trim()}>send</button>
</form>
{/if}
</div>
</div>
@@ -3681,6 +3698,10 @@
resize: vertical;
}
.fb-note { font-size: 0.8rem; color: #6b5a41; font-style: italic; }
.report-reply.yours { color: #43331f; font-style: italic; }
.report-pic { color: #8a6d3f; }
.report-answer { display: flex; gap: 0.35rem; margin-top: 0.35rem; }
.report-answer input { flex: 1; min-width: 0; background: #f4eede; border: 1px solid #b3a687; border-radius: 4px; padding: 0.3rem 0.5rem; font-family: inherit; font-size: 0.85rem; color: #43331f; }
.fb-picture input { font-size: 0.85rem; color: #43331f; }
.fb-preview { display: block; max-width: 100%; max-height: 9rem; margin-top: 0.4rem; border: 1px solid #b3a687; border-radius: 3px; }
.fb-thanks { color: #43331f; padding: 0.6rem 0; }
+11
View File
@@ -300,7 +300,11 @@ export interface FeedbackReportView {
round: number | null;
happened: string;
expected: string;
player?: string;
reply?: { at: string; text: string; status: string };
/** The exchange in order: the desk's replies and the player's answers. */
thread?: { at: string; text: string; from: "desk" | "player"; status?: string }[];
image?: string;
}
export interface GameSummary {
roomId: string;
@@ -798,6 +802,13 @@ class Net {
if (this.seats.length > 0) this.send({ type: "myFeedback", seats: $state.snapshot(this.seats) });
}
/** A word back to the desk under one of your reports, proven by the seat it was filed from. */
answerReport(report: { id: string; roomId: string; player?: string }, text: string): boolean {
const seat = this.seats.find((s) => s.roomId === report.roomId && (!report.player || s.name === report.player));
if (!seat) { this.flash("That seat is no longer in this browser, so the desk cannot be sure it is you"); return false; }
return this.send({ type: "feedbackAnswer", reportId: report.id, text, seat: $state.snapshot(seat) });
}
requestTransferCode(): void {
this.send({ type: "makeTransfer" });
}