From 11e9f950c67d832e61502b0e21f28a12ad2eafe4 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Mon, 21 Sep 2026 12:08:55 -0400 Subject: [PATCH] Reports are threads, and every turn of one rings the desk's bell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG --- .claude/skills/wizwar-reports/SKILL.md | 8 +++-- deploy/feedback-reply.sh | 17 +++++++++++ deploy/pull-feedback.sh | 7 +++-- packages/server/src/index.ts | 42 ++++++++++++++++++++++++-- packages/server/src/store.ts | 12 ++++++-- packages/web/src/App.svelte | 33 ++++++++++++++++---- packages/web/src/net.svelte.ts | 11 +++++++ 7 files changed, 114 insertions(+), 16 deletions(-) diff --git a/.claude/skills/wizwar-reports/SKILL.md b/.claude/skills/wizwar-reports/SKILL.md index 295b009..75a711d 100644 --- a/.claude/skills/wizwar-reports/SKILL.md +++ b/.claude/skills/wizwar-reports/SKILL.md @@ -20,8 +20,12 @@ One JSONL line per entry: — `id` is 8 hex chars; legacy reports (before 2026-09-01) have none and are addressed by their `at` timestamp instead. - Reply: `{reportId, at, status, text}` — folds onto the matching report. - A report is ANSWERED when any reply line names it; never re-answer one - unless Eric asks for a revision. +- Answer: `{reportId, from: "player", player, text, at}` — the player's + word back, sent from their lobby under the desk's reply. + A report is ANSWERED when the LAST line under it is the desk's; a + player's answer reopens it. Never re-answer a settled one unless Eric + asks for a revision. Every report, answer, and desk reply rings a + Sentry issue on Eric's phone (fingerprinted per report or per line). - Picture: `{reportId, image: ".png", at}` — the player attached a screenshot; the file is `/var/lib/wizwar/feedback-images/.`. diff --git a/deploy/feedback-reply.sh b/deploy/feedback-reply.sh index 19f9011..d8d0065 100755 --- a/deploy/feedback-reply.sh +++ b/deploy/feedback-reply.sh @@ -17,3 +17,20 @@ console.log(JSON.stringify({ text: process.env.TEXT, }));') printf '%s\n' "$LINE" | ssh "root@$HOST" 'cat >> /var/lib/wizwar/feedback.jsonl && tail -1 /var/lib/wizwar/feedback.jsonl' +# The desk's bell rings for the reply too, so Eric sees the exchange move: +# an event straight to Sentry from the droplet, with the DSN the service holds. +ssh "root@$HOST" "REPORT_ID='$REPORT_ID' STATUS='$STATUS' TEXT=\"\$(cat)\" python3 - <<'PY' +import json, os, re, uuid, urllib.request, datetime +svc = open('/etc/systemd/system/wizwar.service').read() +m = re.search(r'SENTRY_DSN=https://([0-9a-f]+)@([^/]+)/(\\d+)', svc) +if not m: raise SystemExit(0) +key, host, project = m.groups() +text = os.environ['TEXT']; rid = os.environ['REPORT_ID']; status = os.environ['STATUS'] +event = {'event_id': uuid.uuid4().hex, 'timestamp': datetime.datetime.now(datetime.UTC).isoformat(), 'level': 'error', 'platform': 'other', + 'message': {'formatted': f'Desk replies on report {rid} ({status}): {text[:100]}'}, 'fingerprint': ['report-reply', rid, datetime.datetime.now(datetime.UTC).isoformat()], + 'tags': {'report': rid, 'status': status}, 'extra': {'text': text}} +body = json.dumps({'event_id': event['event_id'], 'sent_at': event['timestamp']}) + '\\n' + json.dumps({'type': 'event'}) + '\\n' + json.dumps(event) +req = urllib.request.Request(f'https://{host}/api/{project}/envelope/', data=body.encode(), headers={'Content-Type': 'application/x-sentry-envelope', 'X-Sentry-Auth': f'Sentry sentry_version=7, sentry_client=wizwar-desk/1, sentry_key={key}'}) +try: urllib.request.urlopen(req, timeout=10); print('bell rung') +except Exception as e: print('bell failed:', e) +PY" <<< "$TEXT" diff --git a/deploy/pull-feedback.sh b/deploy/pull-feedback.sh index 6351bce..76be443 100755 --- a/deploy/pull-feedback.sh +++ b/deploy/pull-feedback.sh @@ -20,7 +20,7 @@ for raw in open(os.path.join(out, "feedback.jsonl"), encoding="utf8"): r = reports.get(line["reportId"]) if not r: continue if "image" in line: r["image"] = line["image"] - else: r.setdefault("replies", []).append(line) + else: r.setdefault("replies", []).append(line) # desk replies and player answers alike, in order continue rid = line.get("id") or line.get("at") reports[rid] = dict(line, replies=[]); order.append(rid) @@ -33,8 +33,9 @@ for rid in reversed(order): if r.get("expected","").strip(): lines.append(f"**What they expected:** {r['expected'].strip()}") if r.get("image"): lines.append(f"**Picture:** ![{r['image']}](images/{r['image']})") for rep in r["replies"]: - lines.append(f"> **{rep.get('status','')}** ({rep.get('at','')[:16].replace('T',' ')}): {rep.get('text','').strip()}") - if not r["replies"]: lines.append("> _unanswered_") + who = f"{r.get('player','the player')} answers" if rep.get("from") == "player" else f"**{rep.get('status','')}**" + lines.append(f"> {who} ({rep.get('at','')[:16].replace('T',' ')}): {rep.get('text','').strip()}") + if not r["replies"] or r["replies"][-1].get("from") == "player": lines.append("> _awaiting the desk_") lines.append("") open(os.path.join(out, "reports.md"), "w", encoding="utf8").write("\n".join(lines)) print(f"{len(order)} reports, {sum(1 for r in reports.values() if r.get('image'))} with pictures -> {out}/reports.md") diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index bda1f6e..9a1564b 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -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; + 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; } diff --git a/packages/server/src/store.ts b/packages/server/src/store.ts index f679153..ef5ddb8 100644 --- a/packages/server/src/store.ts +++ b/packages/server/src/store.ts @@ -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()]; diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index 5f2b2c0..fe186e4 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -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>({}); /** A screenshot for the desk, chosen with the report. */ let fbImage = $state(null); let fbPreview = $state(null); @@ -2582,13 +2584,28 @@
{r.roomId}
-
“{r.happened}”
- {#if r.reply} -
- {r.reply.status} — {r.reply.text} -
- {:else} +
“{r.happened}”{#if r.image} 📷{/if}
+ {#each r.thread ?? [] as line, j (j)} + {#if line.from === "player"} +
you — {line.text}
+ {:else} +
{line.status} — {line.text}
+ {/if} + {/each} + {#if !(r.thread ?? []).length}
the wizards are studying the moment…
+ {:else if (r.thread ?? [])[(r.thread ?? []).length - 1]!.from === "player"} +
your word is with the wizards…
+ {/if} + {#if (r.thread ?? []).some((l) => l.from === "desk")} +
{ + e.preventDefault(); + const t = (reportAnswers[r.id] ?? "").trim(); + if (t && net.answerReport(r, t)) reportAnswers = { ...reportAnswers, [r.id]: "" }; + }}> + + +
{/if}
@@ -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; } diff --git a/packages/web/src/net.svelte.ts b/packages/web/src/net.svelte.ts index ee9f13e..6745f45 100644 --- a/packages/web/src/net.svelte.ts +++ b/packages/web/src/net.svelte.ts @@ -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" }); }