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;
+184
View File
@@ -0,0 +1,184 @@
<script lang="ts">
import type { Duel } from '$lib/game/duel.svelte';
import { api } from '$lib/game/client';
let { duel, open = $bindable(false) }: { duel: Duel; open?: boolean } = $props();
let happened = $state('');
let expected = $state('');
let image = $state<File | null>(null);
let preview = $state<string | null>(null);
let busy = $state(false);
let sent = $state(false);
let error = $state('');
function pickImage(e: Event) {
const file = (e.currentTarget as HTMLInputElement).files?.[0] ?? null;
if (preview) URL.revokeObjectURL(preview);
if (file && file.size > 2_500_000) {
error = 'That picture is over 2.5 MB; a smaller one, please.';
image = null;
preview = null;
return;
}
error = '';
image = file;
preview = file ? URL.createObjectURL(file) : null;
}
function close() {
open = false;
if (preview) URL.revokeObjectURL(preview);
happened = '';
expected = '';
image = null;
preview = null;
sent = false;
error = '';
}
async function send(e: SubmitEvent) {
e.preventDefault();
const r = duel.remote;
if (!r || !happened.trim() || busy) return;
busy = true;
error = '';
try {
const { id } = await api.report(r.roomId, r.token, happened.trim(), expected.trim());
if (image) await api.reportImage(id, image).catch(() => (error = 'The report went; the picture did not.'));
sent = true;
setTimeout(close, 2200);
} catch (err) {
error = err instanceof Error ? err.message : 'The report did not reach the keeper.';
} finally {
busy = false;
}
}
</script>
{#if open && duel.remote}
<div class="scrim" role="presentation" onclick={(ev) => ev.target === ev.currentTarget && close()}>
<form class="slip" aria-labelledby="report-heading" onsubmit={send}>
<h2 id="report-heading">Something amiss?</h2>
{#if sent}
<p class="thanks">Recorded beside the room's ledger, with the turn. Thank you; the keeper will study the moment.{#if error} {error}{/if}</p>
{:else}
<label class="field">
<span>What happened?</span>
<textarea bind:value={happened} rows="3" maxlength="2000" placeholder="e.g. my fireball landed but Wynne took no damage"></textarea>
</label>
<label class="field">
<span>What did you expect?</span>
<textarea bind:value={expected} rows="2" maxlength="2000" placeholder="e.g. five points of damage"></textarea>
</label>
<label class="field picture">
<span>A screenshot, if you have one</span>
<input type="file" accept="image/png,image/jpeg,image/webp" onchange={pickImage} />
{#if preview}<img class="preview" src={preview} alt="The screenshot you chose" />{/if}
</label>
<p class="muted small">The room code and the turn ride along; the keeper can replay the duel to this moment.</p>
{#if error}<p class="warning">{error}</p>{/if}
<div class="actions">
<button type="submit" class="reveal small" disabled={!happened.trim() || busy}>{busy ? 'Sending' : 'Send it'}</button>
<button type="button" class="link" onclick={close}>never mind</button>
</div>
{/if}
</form>
</div>
{/if}
<style>
.scrim {
position: fixed;
inset: 0;
z-index: 20;
background: rgba(0, 0, 0, 0.55);
display: grid;
place-items: center;
padding: 16px;
}
.slip {
width: min(100%, 30rem);
max-height: 100%;
overflow-y: auto;
background: var(--slate);
border: 1px solid var(--rule-strong);
border-radius: 6px;
padding: 1rem 1.2rem 1.2rem;
}
h2 {
font-size: 1.3rem;
margin-bottom: 0.6rem;
}
.field {
display: flex;
flex-direction: column;
gap: 0.25rem;
margin-top: 0.7rem;
}
textarea {
font: inherit;
color: var(--bone);
background: var(--slate-deep);
border: 1px solid var(--rule-strong);
border-radius: 4px;
padding: 0.4rem 0.6rem;
resize: vertical;
}
textarea:focus-visible {
outline: 2px solid var(--frost);
outline-offset: 1px;
}
.picture input {
font-size: 0.9rem;
}
.preview {
max-height: 8rem;
max-width: 100%;
border: 1px solid var(--rule);
border-radius: 3px;
margin-top: 0.3rem;
}
.small {
font-size: 0.85rem;
margin-top: 0.6rem;
}
.warning {
color: var(--blood);
margin-top: 0.4rem;
}
.thanks {
color: var(--bone);
}
.actions {
display: flex;
align-items: center;
gap: 1rem;
margin-top: 0.9rem;
}
.reveal.small {
padding: 0.4rem 0.9rem;
font-size: 1rem;
}
.link {
background: none;
border: 0;
color: var(--bone-dim);
text-decoration: underline dotted;
padding: 0;
font: inherit;
}
</style>