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 Wagoner
2026-09-23 10:12:51 -04:00
co-authored by Claude Fable 5.1
parent c49703ee4f
commit 8eca078043
11 changed files with 738 additions and 5 deletions
+111 -1
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { allSeats, api, forgetSeat } from '$lib/game/client';
import { allSeats, api, forgetSeat, type Report } from '$lib/game/client';
import { Duel, duel, NAME_MAX, playerName, rememberName } from '$lib/game/duel.svelte';
import { unreadTalk } from '$lib/game/talk';
import type { RoomView } from '$lib/game/view';
@@ -12,6 +12,33 @@
let seats = $state(allSeats());
let games = $state<Record<string, RoomView | null>>({});
let aboutOpen = $state(false);
/** Reports this browser's seats have filed, with the keeper's replies. */
let reports = $state<Report[]>([]);
let answers = $state<Record<string, string>>({});
let answerError = $state('');
// The desk is asked once per visit for every seat this browser holds.
$effect(() => {
const held = seats;
if (!held.length) return;
api.myReports(held.map((h) => ({ roomId: h.roomId, token: h.token })))
.then((r) => (reports = r.reports.sort((a, b) => (a.at < b.at ? 1 : -1))))
.catch(() => (reports = []));
});
async function answer(r: Report) {
const text = (answers[r.id] ?? '').trim();
const held = seats.find((h) => h.roomId === r.roomId);
if (!text || !held) return;
answerError = '';
try {
await api.answerReport(r.id, r.roomId, held.token, text);
r.thread.push({ at: new Date().toISOString(), text, from: 'player' });
answers[r.id] = '';
} catch (e) {
answerError = e instanceof Error ? e.message : 'The answer did not reach the keeper.';
}
}
/** The single-player duel saved in this browser, if one is under way. */
const local = $derived(duel.inProgress ? duel : null);
@@ -187,6 +214,37 @@
<button type="button" class="ledger-forget" title="forget this duel" onclick={() => forget(held.roomId)}>×</button>
</div>
{/each}
{#if reports.length}
<div class="ledger-head reports-head">your reports to the keeper</div>
{#each reports as r (r.id)}
{@const last = r.thread[r.thread.length - 1]}
<div class="report-row">
<span class="ledger-code">{r.roomId}</span>
<div class="report-body">
<p class="report-text">“{r.happened}”{#if r.image}<span class="muted" title="with your screenshot"> (with a picture)</span>{/if}</p>
{#each r.thread as line, j (j)}
{#if line.from === 'player'}
<p class="report-reply yours">you: {line.text}</p>
{:else}
<p class="report-reply"><span class="report-status">{line.status}</span> {line.text}</p>
{/if}
{/each}
{#if !last}
<p class="report-reply pending">the keeper is studying the moment</p>
{:else if last.from === 'player'}
<p class="report-reply pending">your word is with the keeper</p>
{/if}
{#if r.thread.some((l) => l.from === 'desk')}
<form class="report-answer" onsubmit={(e) => { e.preventDefault(); void answer(r); }}>
<input type="text" bind:value={answers[r.id]} maxlength="2000" placeholder="answer the keeper" aria-label="answer the keeper" />
<button type="submit" class="quiet" disabled={!(answers[r.id] ?? '').trim()}>Send</button>
</form>
{/if}
</div>
</div>
{/each}
{#if answerError}<p class="warning">{answerError}</p>{/if}
{/if}
</div>
{/if}
@@ -443,6 +501,58 @@
color: var(--frost);
}
.reports-head {
margin-top: 0.9rem;
}
.report-row {
display: flex;
gap: 0.75rem;
align-items: flex-start;
padding: 0.45rem 0;
border-top: 1px solid var(--rule);
}
.report-body {
min-width: 0;
flex: 1;
}
.report-text {
font-size: 0.95rem;
}
.report-reply {
font-size: 0.85rem;
color: var(--bone-dim);
margin-top: 0.2rem;
}
.report-reply.yours {
font-style: italic;
}
.report-reply.pending {
font-style: italic;
color: var(--bone-faint);
}
.report-status {
color: var(--ember);
font-style: italic;
}
.report-answer {
display: flex;
gap: 0.4rem;
margin-top: 0.4rem;
}
.report-answer input {
flex: 1;
min-width: 0;
}
.ledger-code {
font-weight: 600;
letter-spacing: 0.12em;