From 5abd0ba60ec9f43a5f9dd61d006c6017bb346e94 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Tue, 1 Sep 2026 09:34:20 -0400 Subject: [PATCH] Players see their reports answered in the lobby ledger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reports gain ids; replies live in the same JSONL, folding onto their report when read (legacy reports answer to their timestamp). The lobby asks with the same seat-token proof as the games ledger — wake-free — and lists each report with its reply and status, or 'the wizards are studying the moment'. deploy/feedback-reply.sh answers one from here. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF --- deploy/feedback-reply.sh | 19 +++++++++++++ packages/server/src/index.ts | 23 ++++++++++++++- packages/server/src/store.ts | 52 ++++++++++++++++++++++++++++++++-- packages/web/src/App.svelte | 45 +++++++++++++++++++++++++++++ packages/web/src/net.svelte.ts | 17 +++++++++++ 5 files changed, 153 insertions(+), 3 deletions(-) create mode 100755 deploy/feedback-reply.sh diff --git a/deploy/feedback-reply.sh b/deploy/feedback-reply.sh new file mode 100755 index 0000000..bdb123f --- /dev/null +++ b/deploy/feedback-reply.sh @@ -0,0 +1,19 @@ +#!/bin/bash +# Answer a player's feedback report; the reply appears under it in their +# lobby ledger. Legacy reports without an id answer to their timestamp. +# deploy/feedback-reply.sh +set -eu +HOST="$1"; REPORT_ID="$2"; STATUS="$3"; shift 3 +TEXT="$*" +case "$STATUS" in resolved|by-design|open) ;; *) echo "status must be resolved, by-design, or open" >&2; exit 1;; esac +LINE=$(python3 - "$REPORT_ID" "$STATUS" "$TEXT" <<'EOF' +import json, sys, datetime +print(json.dumps({ + "reportId": sys.argv[1], + "at": datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z"), + "status": sys.argv[2], + "text": sys.argv[3], +})) +EOF +) +printf '%s\n' "$LINE" | ssh "root@$HOST" 'cat >> /var/lib/wizwar/feedback.jsonl && tail -1 /var/lib/wizwar/feedback.jsonl' diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index dcc6c85..34e45a7 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -10,6 +10,7 @@ // {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:"myFeedback", seats} your reports + the wizards' replies // {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 @@ -62,7 +63,8 @@ import { abandonRoom, } from "./rooms"; import { engagementStats, recordHotseat } from "./stats"; -import { appendFeedback } from "./store"; +import { appendFeedback, readFeedback } from "./store"; +import { randomBytes } from "node:crypto"; import { getShare, loadShares, mintShare } from "./shares"; import { renderSharePng } from "./ogimage"; import { BOT_LINES, type BanterTrigger } from "./banter"; @@ -805,6 +807,7 @@ wss.on("connection", (socket) => { const happened = clean(msg.happened); if (!happened) return send(socket, { type: "error", message: "say what happened" }); appendFeedback({ + id: randomBytes(4).toString("hex"), at: new Date().toISOString(), roomId: room.id, player: session.playerId ?? "(gallery)", @@ -817,6 +820,24 @@ wss.on("connection", (socket) => { 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. + const seats = Array.isArray(msg.seats) ? msg.seats.slice(0, MAX_MYGAMES_SEATS) : []; + const proven: { roomId: string; name: string }[] = []; + for (const seat of seats) { + if (typeof seat !== "object" || seat === null) continue; + const roomId = String(seat.roomId ?? "").toUpperCase(); + const name = String(seat.name ?? ""); + const result = peekSummary(roomId, name, typeof seat.token === "string" ? seat.token : null); + 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); + send(socket, { type: "feedbackList", reports }); + 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 cd16af7..1895b5f 100644 --- a/packages/server/src/store.ts +++ b/packages/server/src/store.ts @@ -148,13 +148,61 @@ 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. */ +/** Player surprise reports and the wizards' replies share one JSONL file + * beside the rooms directory — a report line carries roomId/seq pinning + * its moment; a reply line carries reportId/status/text and folds onto + * its report when read. Legacy reports without an id answer to their + * timestamp. */ export function appendFeedback(entry: Record): void { ensureDataDir(); appendFileSync(join(DATA_DIR, "..", "feedback.jsonl"), JSON.stringify(entry) + "\n", "utf8"); } +export interface FeedbackReport { + id: string; + at: string; + roomId: string; + player: string; + seq: number; + round: number | null; + happened: string; + expected: string; + reply?: { at: string; text: string; status: string }; +} + +export function readFeedback(): FeedbackReport[] { + const file = join(DATA_DIR, "..", "feedback.jsonl"); + if (!existsSync(file)) return []; + const reports = new Map(); + for (const raw of readFileSync(file, "utf8").split("\n")) { + if (!raw.trim()) continue; + let line: Record; + try { line = JSON.parse(raw); } catch { continue; } + if (typeof line.reportId === "string") { + const report = reports.get(line.reportId); + if (report) { + report.reply = { + at: String(line.at ?? ""), text: String(line.text ?? ""), status: String(line.status ?? "resolved"), + }; + } + continue; + } + const id = String(line.id ?? line.at ?? ""); + if (!id) continue; + reports.set(id, { + id, + at: String(line.at ?? ""), + roomId: String(line.roomId ?? ""), + player: String(line.player ?? ""), + seq: Number(line.seq ?? 0), + round: line.round == null ? null : Number(line.round), + happened: String(line.happened ?? ""), + expected: String(line.expected ?? ""), + }); + } + return [...reports.values()]; +} + /** 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 1833a5f..ecbf17f 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -2017,6 +2017,24 @@ onclick={() => net.forgetSeat(seat.roomId)}>× {/each} + {#if net.feedbackReports.length > 0} +
your reports to the wizards
+ {#each net.feedbackReports as r (r.id)} +
+ {r.roomId} +
+
“{r.happened}”
+ {#if r.reply} +
+ {r.reply.status} — {r.reply.text} +
+ {:else} +
the wizards are studying the moment…
+ {/if} +
+
+ {/each} + {/if} {/if} @@ -2880,6 +2898,33 @@ .cast-mini.nullified { opacity: 0.45; filter: grayscale(0.8); } .hand-slot { display: flex; cursor: grab; } .hand-slot:active { cursor: grabbing; } + .reports-head { + margin-top: 0.9rem; + font-family: "Caveat", cursive; + font-size: 1.25rem; + color: #6b5a41; + } + .report-row { + display: flex; + gap: 0.6rem; + align-items: baseline; + padding: 0.35rem 0; + border-top: 1px dotted #b7ad92; + text-align: left; + } + .report-body { min-width: 0; } + .report-text { font-size: 0.9rem; color: #43331f; } + .report-reply { font-size: 0.85rem; color: #6b5a41; margin-top: 0.15rem; } + .report-reply.pending { font-style: italic; color: #a49c86; } + .report-status { + text-transform: uppercase; + font-size: 0.72rem; + letter-spacing: 0.06em; + color: #1a9c46; + border: 1px solid #1a9c46; + border-radius: 3px; + padding: 0 0.3rem; + } .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 98477a3..9ab5472 100644 --- a/packages/web/src/net.svelte.ts +++ b/packages/web/src/net.svelte.ts @@ -501,6 +501,12 @@ class Net { this.stats = msg.stats; break; } + case "feedbackList": + this.feedbackReports = msg.reports ?? []; + break; + case "feedbackReceived": + this.refreshFeedback(); + break; case "games": { this.games = msg.games; for (const g of msg.games as GameSummary[]) { @@ -600,6 +606,16 @@ class Net { this.send({ type: "feedback", happened, expected }); } + /** Your reports and the wizards' replies, proven by seat tokens. */ + feedbackReports = $state<{ + id: string; at: string; roomId: string; seq: number; round: number | null; + happened: string; expected: string; + reply?: { at: string; text: string; status: string }; + }[]>([]); + refreshFeedback(): void { + if (this.seats.length > 0) this.send({ type: "myFeedback", seats: $state.snapshot(this.seats) }); + } + requestTransferCode(): void { this.send({ type: "makeTransfer" }); } @@ -661,6 +677,7 @@ class Net { startGamePolling(): void { if (this.pollTimer) return; this.refreshGames(); + this.refreshFeedback(); this.pollTimer = setInterval(() => this.refreshGames(), 45_000); } stopGamePolling(): void {