The reports desk: a slip for surprises, replies in the hall

A Report button in a room's masthead files what happened and what was
expected, with a screenshot if there is one, pinned to the room and the
turn and rung through Sentry. Reports and the keeper's replies live in
feedback.jsonl beside the ledgers; the hall shows each seat's reports
with the thread under them and a line to answer. pull-reports.sh mirrors
the desk locally, report-reply.sh answers, and a skill works the desk.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
This commit is contained in:
Eric WagonerandClaude Fable 5.1 committed 2026-09-23 10:12:51 -04:00
1 parent c49703ee4f
commit 8eca078043
11 files changed
+738 -5

No files matched your search

+23 -1
View File
@@ -68,6 +68,20 @@ async function call<T>(method: string, path: string, body?: unknown): Promise<T>
type Seated = { seat: WizardId; token: string; view: RoomView };
/** A report to the keeper, with the exchange under it. */
export interface Report {
id: string;
at: string;
roomId: string;
player: string;
seat: WizardId | null;
turn: number | null;
happened: string;
expected: string;
thread: { at: string; text: string; from: 'desk' | 'player'; status?: string }[];
image?: string;
}
export const api = {
create: (name: string, size: number) => call<Seated>('POST', '/api/rooms', { name, size }),
join: (roomId: string, name: string) => call<Seated>('POST', `/api/rooms/${roomId}/join`, { name }),
@@ -77,7 +91,15 @@ export const api = {
/** The gallery's view: no token, no seat, nothing any wizard could not see. */
watch: (roomId: string) => call<RoomView>('GET', `/api/rooms/${roomId}`),
turn: (roomId: string, token: string, input: TurnInput) => call<RoomView>('POST', `/api/rooms/${roomId}/turn`, { token, input }),
say: (roomId: string, token: string, text: string) => call<RoomView>('POST', `/api/rooms/${roomId}/say`, { token, text })
say: (roomId: string, token: string, text: string) => call<RoomView>('POST', `/api/rooms/${roomId}/say`, { token, text }),
report: (roomId: string, token: string, happened: string, expected: string) =>
call<{ id: string }>('POST', `/api/rooms/${roomId}/report`, { token, happened, expected }),
reportImage: async (id: string, file: File) => {
const res = await fetch(`/api/reports/${id}/image`, { method: 'POST', headers: { 'content-type': file.type || 'application/octet-stream' }, body: file });
if (!res.ok) throw new ServerError(((await res.json().catch(() => ({}))) as { error?: string }).error ?? `The server answered ${res.status}.`, res.status);
},
myReports: (seats: { roomId: string; token: string }[]) => call<{ reports: Report[] }>('POST', '/api/reports/mine', { seats }),
answerReport: (id: string, roomId: string, token: string, text: string) => call<{ ok: true }>('POST', `/api/reports/${id}/answer`, { roomId, token, text })
};
/** A socket that only ever says "fetch the view again"; reconnects on its own. An empty token sits in the gallery. */