Players see their reports answered in the lobby ledger
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
This commit is contained in:
co-authored by
Claude Fable 5
parent
eabc10f18c
commit
5abd0ba60e
Executable
+19
@@ -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 <host> <reportId> <resolved|by-design|open> <text...>
|
||||
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'
|
||||
@@ -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;
|
||||
|
||||
@@ -148,13 +148,61 @@ 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. */
|
||||
/** 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<string, unknown>): 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<string, FeedbackReport>();
|
||||
for (const raw of readFileSync(file, "utf8").split("\n")) {
|
||||
if (!raw.trim()) continue;
|
||||
let line: Record<string, unknown>;
|
||||
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 {
|
||||
|
||||
@@ -2017,6 +2017,24 @@
|
||||
onclick={() => net.forgetSeat(seat.roomId)}>×</button>
|
||||
</div>
|
||||
{/each}
|
||||
{#if net.feedbackReports.length > 0}
|
||||
<div class="reports-head">your reports to the wizards</div>
|
||||
{#each net.feedbackReports as r (r.id)}
|
||||
<div class="report-row" class:answered={!!r.reply}>
|
||||
<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-reply pending">the wizards are studying the moment…</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -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; }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user