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:
co-authored by
Claude Fable 5.1
parent
c49703ee4f
commit
8eca078043
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user