The credibility pass on Hnefatafl, ported to the template: the ops scripts count traffic through one parser, the reports digest is one program, the plaintext-token fallback and its migration are gone, and the stylesheet holds each shared rule once

From the hnefatafl repo's pass of the same day, everything that touched a
kit-shared file. The visitors digest and the nightly rollup share
deploy/traffic.py, installed to /usr/local/lib/<slug>; the rollup writes
the finished-games count it computed behind "and False". pull-reports.sh
and the reports skill both use deploy/report-digest.ts. The seat line
requires its token hash and the start line its rules revision; the
migration for ledgers written before hashing goes with them. The route
table in server/src/index.ts lists every route; Report, ReportLine and
Tally are declared once in view.ts for both sides; exports nobody
imported are exports no more.

In the client: .small, the × that dismisses, and the frame of the
reading pages are in app.css once; the preferences panel shares the
report slip's modal shape; the room store gains seatEmpty and
seatUnheld, and the lobby and the join page read those instead of
three spellings of their own. The room's moved and awaiting fields
stay: the demo board reads them, and simultaneous rounds are the
contract.

The deploy README no longer describes a browser-only game; the visitors
skill no longer names a /play route; the reports skill's replay call
carries the room's options. The Slack channel id is passed in the
environment rather than filled in as a placeholder.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GwFKMuQnPAEHJ5yA1q4orh
This commit is contained in:
Eric Wagoner
2026-09-23 20:12:37 -04:00
co-authored by Claude Fable 5.1
parent 6c42e55f59
commit 33ac0ec808
41 changed files with 421 additions and 583 deletions
+56 -1
View File
@@ -3,7 +3,6 @@
:root {
--slate: #1c2830;
--slate-deep: #121a20;
--slate-raised: #24333d;
--bone: #e9e2d2;
--bone-dim: #98a4ab;
--bone-faint: #5d6b73;
@@ -133,6 +132,10 @@ button:disabled {
color: var(--bone-dim);
}
.small {
font-size: 0.85rem;
}
.warning {
color: var(--blood);
}
@@ -150,6 +153,58 @@ button:disabled {
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
/* The × that sends a bot away or forgets a game. */
.dismiss {
background: none;
border: 0;
color: var(--bone-faint);
font-size: 1.1rem;
padding: 0 0.4rem;
line-height: 1;
}
.dismiss:hover {
color: var(--blood);
}
/* A page of reading beside the game: the rules, the guide. */
.reading {
max-width: 46rem;
margin: 0 auto;
padding: 1.5rem var(--gutter) 4rem;
}
.reading a {
color: var(--frost);
}
.reading .back {
font-size: 0.95rem;
}
.reading h1 {
font-size: 2.4rem;
font-weight: 300;
margin: 0.5rem 0 0.3rem;
}
.reading .lede {
color: var(--bone-dim);
max-width: 40em;
}
.reading section {
padding: 1.25rem 0 0.5rem;
border-top: 1px solid var(--rule);
margin-top: 1rem;
}
.reading h2 {
font-size: 1.5rem;
margin-bottom: 0.7rem;
}
@media (prefers-reduced-motion: reduce) {
+12 -35
View File
@@ -6,17 +6,16 @@
import { otherGames, type FamilyGame } from '$lib/family';
import Preferences from './Preferences.svelte';
import { prefs, setPref } from '$lib/prefs.svelte';
import { allSeats, doubtSeat, forgetSeat, rememberSeat, ServerError, trustSeat, type Report, type Tally } from '$lib/net/client';
import { allSeats, doubtSeat, forgetSeat, rememberSeat, ServerError, trustSeat } from '$lib/net/client';
import { api, NAME_MAX, playerName, rememberName, Room } from '$lib/net/room.svelte';
import { unreadTalk } from '$lib/net/talk';
import type { RoomView } from '$lib/net/view';
import type { Report, RoomView, Tally } from '$lib/net/view';
import type { State } from '$lib/game';
import { game } from '$lib/game';
let name = $state(playerName());
let code = $state('');
/** The host's choices for the next table, from the game's declared options. */
// A new table starts from the preferences' table defaults; the hall's selectors set the same keys.
/** The host's choices for the next table: the preferences' table defaults, which the selectors below also set. */
const options = $derived<Record<string, string>>(
Object.fromEntries((game.options ?? []).map((o) => [o.key, o.choices.some((c) => c.value === prefs[`table.${o.key}`]) ? String(prefs[`table.${o.key}`]) : o.default]))
);
@@ -82,7 +81,7 @@
}
}
// The desk is asked once per visit for every seat this browser holds.
// The desk is asked for every seat this browser holds, again whenever that set changes.
$effect(() => {
const held = seats;
if (!held.length) return;
@@ -163,8 +162,8 @@
<h1>__NAME__</h1>
<p class="pitch">One line that says what the game is.</p>
<p class="tag">Two or three sentences for someone who has never heard of it: what you do on a turn, what makes it fun, and that it is free to play here against the bot or with friends.</p>
<p class="links"><a href="/guide">how to play</a> · <a href="/rules">the rules</a> · <a class="about-link" href="#about" onclick={() => (aboutOpen = true)}>about this game — a labor of love</a> · <button type="button" class="link-like" onclick={() => (prefsOpen = true)}>preferences</button></p>
<Preferences bind:open={prefsOpen} />
<p class="links"><a href="/guide">how to play</a> · <a href="/rules">the rules</a> · <a class="about-link" href="#about" onclick={() => (aboutOpen = true)}>about this game — a labor of love</a> · <button type="button" class="link" onclick={() => (prefsOpen = true)}>preferences</button></p>
<Preferences bind:open={prefsOpen} />
</div>
<div class="lid-play">
@@ -176,7 +175,7 @@
<div class="options">
{#each game.options as o (o.key)}
{@const chosen = o.choices.find((c) => c.value === options[o.key])}
<div class="option-block">
<div>
<label class="option">
<span>{o.label}</span>
<select value={options[o.key]} onchange={(e) => setPref(`table.${o.key}`, e.currentTarget.value)}>
@@ -198,7 +197,7 @@
</form>
</div>
<p class="muted small">A code opens the door either way: take a seat if one is free, or watch from the Peanut Gallery.</p>
<form class="joinform claimform" onsubmit={claim}>
<form class="joinform" onsubmit={claim}>
<span class="or">or bring a seat from another device</span>
<input type="text" bind:value={phrase} placeholder="the four-word phrase" autocomplete="off" aria-label="transfer phrase" />
<button type="submit" class="quiet" disabled={claiming || phrase.trim().split(/[\s-]+/).length < 4}>Claim</button>
@@ -220,7 +219,7 @@
<span class="ledger-code">{held.roomId}</span>
<span class="ledger-info">{status(held)}{#if unread(held) > 0} · <span class="talk-new">{unread(held)} new {unread(held) === 1 ? 'line' : 'lines'} of talk</span>{/if}</span>
</a>
<button type="button" class="ledger-forget" title="forget this game" onclick={() => forget(held.roomId)}>×</button>
<button type="button" class="dismiss" title="forget this game" onclick={() => forget(held.roomId)}>×</button>
</div>
{/each}
{#if reports.length}
@@ -382,10 +381,6 @@
text-align: center;
}
.small {
font-size: 0.85rem;
}
.ways {
display: flex;
flex-wrap: wrap;
@@ -489,18 +484,6 @@
color: var(--frost);
}
.ledger-forget {
background: none;
border: 0;
color: var(--bone-faint);
font-size: 1.1rem;
padding: 0 0.4rem;
}
.ledger-forget:hover {
color: var(--blood);
}
.reports-head {
margin-top: 0.9rem;
}
@@ -591,6 +574,7 @@
font-size: 0.85rem;
color: var(--bone-faint);
}
.family {
margin: 0.4rem 0 0.8rem 1.2rem;
padding: 0;
@@ -599,6 +583,7 @@
.family li + li {
margin-top: 0.3rem;
}
.options {
display: flex;
flex-wrap: wrap;
@@ -617,6 +602,7 @@
margin: 0.2rem 0 0;
max-width: 22em;
}
.tally {
display: grid;
grid-template-columns: max-content 1fr;
@@ -632,13 +618,4 @@
.tally dd {
margin: 0;
}
.link-like {
background: none;
border: 0;
padding: 0;
font: inherit;
color: inherit;
text-decoration: underline;
cursor: pointer;
}
</style>
+8 -37
View File
@@ -2,15 +2,11 @@
import TableTalk from './TableTalk.svelte';
import type { Room } from '$lib/net/room.svelte';
let { room, link }: { room: Room; link: string } = $props();
let { room, link, copied, copyLink }: { room: Room; link: string; copied: boolean; copyLink: () => void } = $props();
const v = $derived(room.view);
const hostName = $derived(v.seats.find((s) => s.id === v.host)?.name ?? 'the host');
const seatFree = $derived(v.seats.length < v.size);
/** A seat is free for a newcomer once the held ones are counted. */
const seatOpen = $derived(v.seats.length + v.expected.length < v.size);
const keeperSeated = $derived(v.seats.some((s) => s.name.toLowerCase() === v.keeper.toLowerCase()));
let copied = $state(false);
/** The hour where the keeper lives, in words, so a caller knows whether to wait. */
let minute = $state(0);
@@ -31,22 +27,12 @@
return '';
}
});
async function copyLink() {
try {
await navigator.clipboard.writeText(link);
copied = true;
setTimeout(() => (copied = false), 1600);
} catch {
// The link is on screen to copy by hand.
}
}
</script>
<section class="lobby">
{#if room.spectating}
<h2>At the table</h2>
{#if seatFree}
{#if room.seatEmpty}
<p>{hostName} is gathering players. Sit down above, or watch from the gallery until the game begins.</p>
{:else}
<p>The table is full. You watch from the Peanut Gallery; the game begins when {hostName} says.</p>
@@ -62,7 +48,7 @@
<li>
<span class="seat-name">{s.name}</span>
<span class="muted">{s.id === v.host ? 'opened the table' : s.bot ? 'the bot' : 'seated'}{s.id === v.me ? ', you' : ''}{#if s.bot && room.isHost}
<button type="button" class="unseat" title="Send this bot away" aria-label="Send {s.name} away" onclick={() => room.unseat(s.id)}>×</button>{/if}</span>
<button type="button" class="dismiss" title="Send this bot away" aria-label="Send {s.name} away" onclick={() => room.unseat(s.id)}>×</button>{/if}</span>
</li>
{/each}
{#each v.expected as name (name)}
@@ -78,7 +64,7 @@
{/if}
{#if !room.spectating}
<div class="ways">
{#if seatFree && room.isHost}
{#if room.seatEmpty && room.isHost}
<button type="button" class="quiet" onclick={() => room.addBot()}>Seat a bot</button>
{/if}
{#if room.isHost}
@@ -94,13 +80,13 @@
Let the Peanut Gallery talk at the table
</label>
{/if}
{#if v.keeper && !v.challenge && !keeperSeated && seatOpen}
{#if v.keeper && !v.challenge && !keeperSeated && room.seatUnheld}
<div class="keeper">
<button type="button" class="quiet" title="A seat is held for {v.keeper}, who keeps this site, and their phone rings" onclick={() => room.callKeeper()}>Challenge {v.keeper}, the keeper</button>
<p class="muted small">{v.keeper} keeps this site and answers every call, sometimes within the minute, sometimes after a night's sleep.{#if keeperHour}&nbsp;It is {keeperHour} where {v.keeper} lives.{/if} The seat is held either way.</p>
<p class="muted small note">{v.keeper} keeps this site and answers every call, sometimes within the minute, sometimes after a night's sleep.{#if keeperHour}&nbsp;It is {keeperHour} where {v.keeper} lives.{/if} The seat is held either way.</p>
</div>
{:else if v.challenge}
<p class="muted small">{v.keeper} has been called to the table by {room.nameOf(v.challenge.by)}.{#if keeperHour}&nbsp;It is {keeperHour} there.{/if} A seat is held: if they can come now they will take it; if they are asleep or away they will leave a word below when they can. Meanwhile the table is yours: seat a bot, invite a friend, or open another game in a new tab and play on. This table keeps its place in your hall until you come back.</p>
<p class="muted small note">{v.keeper} has been called to the table by {room.nameOf(v.challenge.by)}.{#if keeperHour}&nbsp;It is {keeperHour} there.{/if} A seat is held: if they can come now they will take it; if they are asleep or away they will leave a word below when they can. Meanwhile the table is yours: seat a bot, invite a friend, or open another game in a new tab and play on. This table keeps its place in your hall until you come back.</p>
{/if}
{/if}
{#if room.error}<p class="warning">{room.error}</p>{/if}
@@ -148,20 +134,6 @@
font-weight: 500;
}
.unseat {
background: none;
border: 0;
color: var(--bone-faint);
font-size: 1.1rem;
padding: 0 0.3rem;
margin-left: 0.3rem;
line-height: 1;
}
.unseat:hover {
color: var(--blood);
}
.ways {
display: flex;
flex-wrap: wrap;
@@ -203,8 +175,7 @@
max-width: 38em;
}
.small {
font-size: 0.85rem;
.note {
margin-top: 0.4rem;
}
</style>
+33 -37
View File
@@ -1,6 +1,7 @@
<script lang="ts">
// The preferences panel: every declared setting, kit and game, as a
// control. Opened from the masthead; closes with its button or Escape.
// control. Opened from the masthead; closes with its button, a click
// outside it, or Escape.
import { PREFERENCES, prefs, setPref } from '$lib/prefs.svelte';
let { open = $bindable(false) }: { open?: boolean } = $props();
@@ -13,29 +14,30 @@
<svelte:window onkeydown={onKey} />
{#if open}
<div class="scrim" role="presentation" onclick={() => (open = false)}></div>
<div class="panel" role="dialog" aria-modal="true" aria-labelledby="prefs-title">
<h2 id="prefs-title">Preferences</h2>
<p class="muted small">Kept in this browser, and nowhere else.</p>
{#each PREFERENCES as p (p.key)}
<div class="pref">
{#if p.kind === 'toggle'}
<label class="row">
<input type="checkbox" checked={prefs[p.key] === true} onchange={(e) => setPref(p.key, e.currentTarget.checked)} />
<span>{p.label}</span>
</label>
{:else}
<label class="row">
<span>{p.label}</span>
<select value={prefs[p.key]} onchange={(e) => setPref(p.key, e.currentTarget.value)}>
{#each p.choices ?? [] as c (c.value)}<option value={c.value}>{c.label}</option>{/each}
</select>
</label>
{/if}
{#if p.note}<p class="muted small note">{p.note}</p>{/if}
</div>
{/each}
<button type="button" class="quiet" onclick={() => (open = false)}>Done</button>
<div class="scrim" role="presentation" onclick={(ev) => ev.target === ev.currentTarget && (open = false)}>
<div class="panel" role="dialog" aria-modal="true" aria-labelledby="prefs-title">
<h2 id="prefs-title">Preferences</h2>
<p class="muted small">Kept in this browser, and nowhere else.</p>
{#each PREFERENCES as p (p.key)}
<div class="pref">
{#if p.kind === 'toggle'}
<label class="row">
<input type="checkbox" checked={prefs[p.key] === true} onchange={(e) => setPref(p.key, e.currentTarget.checked)} />
<span>{p.label}</span>
</label>
{:else}
<label class="row">
<span>{p.label}</span>
<select value={prefs[p.key]} onchange={(e) => setPref(p.key, e.currentTarget.value)}>
{#each p.choices ?? [] as c (c.value)}<option value={c.value}>{c.label}</option>{/each}
</select>
</label>
{/if}
{#if p.note}<p class="muted small note">{p.note}</p>{/if}
</div>
{/each}
<button type="button" class="quiet" onclick={() => (open = false)}>Done</button>
</div>
</div>
{/if}
@@ -43,22 +45,20 @@
.scrim {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
z-index: 20;
background: rgba(0, 0, 0, 0.55);
display: grid;
place-items: center;
padding: 16px;
}
.panel {
position: fixed;
z-index: 21;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: min(92vw, 26rem);
max-height: 90vh;
width: min(100%, 26rem);
max-height: 100%;
overflow: auto;
background: var(--slate);
border: 1px solid var(--rule-strong);
border-radius: 8px;
border-radius: 6px;
padding: 1.2rem 1.4rem 1.4rem;
}
@@ -87,10 +87,6 @@
margin-top: 0.3rem;
}
.small {
font-size: 0.85rem;
}
.quiet {
margin-top: 0.8rem;
}
+3 -17
View File
@@ -38,12 +38,11 @@
async function send(e: SubmitEvent) {
e.preventDefault();
const r = room;
if (!happened.trim() || busy) return;
busy = true;
error = '';
try {
const { id } = await api.report(r.roomId, r.token, happened.trim(), expected.trim());
const { id } = await api.report(room.roomId, room.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);
@@ -75,7 +74,7 @@
<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 round ride along; the keeper can replay the game to this moment.</p>
<p class="muted small note">The room code and the round ride along; the keeper can replay the game to this moment.</p>
{#if error}<p class="warning">{error}</p>{/if}
<div class="actions">
<button type="submit" class="commit small" disabled={!happened.trim() || busy}>{busy ? 'Sending' : 'Send it'}</button>
@@ -119,7 +118,6 @@
margin-top: 0.7rem;
}
.picture input {
font-size: 0.9rem;
}
@@ -132,13 +130,11 @@
margin-top: 0.3rem;
}
.small {
font-size: 0.85rem;
.note {
margin-top: 0.6rem;
}
.warning {
color: var(--blood);
margin-top: 0.4rem;
}
@@ -152,14 +148,4 @@
gap: 1rem;
margin-top: 0.9rem;
}
.link {
background: none;
border: 0;
color: var(--bone-dim);
text-decoration: underline dotted;
padding: 0;
font: inherit;
}
</style>
+2 -3
View File
@@ -57,7 +57,7 @@
<button type="submit" class="quiet" disabled={!draft.trim() || !galleryName.trim() || sending}>Say</button>
</form>
{:else if room.spectating}
<p class="muted small">The gallery listens; the host has not let it talk.</p>
<p class="muted small note">The gallery listens; the host has not let it talk.</p>
{:else}
<form onsubmit={say}>
<input type="text" bind:value={draft} maxlength="300" placeholder="Say something to the table" aria-label="Say something to the table" autocomplete="off" />
@@ -125,8 +125,7 @@
flex: 0 1 8rem;
}
.small {
font-size: 0.85rem;
.note {
margin-top: 0.4rem;
}
</style>
+13 -2
View File
@@ -34,13 +34,24 @@ export interface TableOption {
/** The host's choices for a table, by option key; only keys the game declares get through. */
export type TableOptions = Record<string, string>;
/** A setting the game adds to the preferences panel, kept in the player's browser (see $lib/prefs.svelte). */
export interface Preference {
key: string;
label: string;
/** A toggle holds a boolean; a choice holds one of its choices' values. */
kind: 'toggle' | 'choice';
choices?: { value: string; label: string }[];
default: boolean | string;
note?: string;
}
export interface GameSpec<State, Input> {
/** Seats in table order; the table takes at most this many. */
seatIds: SeatId[];
/** What the host may choose when opening a table; empty for a game with one way to play. */
options?: TableOption[];
/** The game's own settings for the preferences panel, kept in the player's browser (see $lib/prefs.svelte). */
preferences?: { key: string; label: string; kind: 'toggle' | 'choice'; choices?: { value: string; label: string }[]; default: boolean | string; note?: string }[];
/** The game's own settings for the preferences panel. */
preferences?: Preference[];
minSeats: number;
/** Names the server draws for bots, in preference order. */
botNames: string[];
+13 -39
View File
@@ -2,7 +2,7 @@
// browser holds, remembered so a shared link never has to carry a token.
import type { SeatId, TableOptions } from '$lib/game/spec';
import type { RoomView } from './view';
import type { Report, RoomView, Tally } from './view';
export interface HeldSeat {
seat: SeatId;
@@ -41,7 +41,7 @@ export function forgetSeat(roomId: string): void {
/**
* A seat the server refused is doubted, not dropped: a restarting server, a
* stale answer or the wrong backend all answer with ignorance, and ignorance
* is not deletion. Only the SECOND refusal in a row costs the seat.
* is not deletion. Only the second refusal in a row costs the seat.
*/
const DOUBT_KEY = '__SLUG__:doubted';
@@ -103,44 +103,20 @@ export class ServerError extends Error {
}
}
async function call<T>(method: string, path: string, body?: unknown): Promise<T> {
const res = await fetch(path, {
method,
headers: body ? { 'content-type': 'application/json' } : undefined,
body: body ? JSON.stringify(body) : undefined
});
/** The server's answer as JSON, or its refusal as a ServerError with the reason it gave. */
async function fetchJson<T>(path: string, init: RequestInit): Promise<T> {
const res = await fetch(path, init);
const data = (await res.json().catch(() => ({}))) as { error?: string };
if (!res.ok) throw new ServerError(data.error ?? `The server answered ${res.status}.`, res.status);
return data as T;
}
/** A report to the keeper, with the exchange under it. */
export interface Report {
id: string;
at: string;
roomId: string;
player: string;
seat: SeatId | null;
turn: number | null;
happened: string;
expected: string;
thread: { at: string; text: string; from: 'desk' | 'player'; status?: string }[];
image?: string;
}
/** What the ledgers add up to. */
export interface Tally {
tablesOpened: number;
gamesBegun: number;
gamesFinished: number;
namesSeated: number;
turnsPlayed: number;
minutesAtTable: number;
longestGameTurns: number;
gamesWithBot: number;
talkLines: number;
firstGameAt: string | null;
byGame: Record<string, number>;
function call<T>(method: string, path: string, body?: unknown): Promise<T> {
return fetchJson<T>(path, {
method,
headers: body ? { 'content-type': 'application/json' } : undefined,
body: body ? JSON.stringify(body) : undefined
});
}
export function makeApi<State, Input>() {
@@ -167,10 +143,8 @@ export function makeApi<State, Input>() {
claim: (phrase: string) => call<{ roomId: string; seat: SeatId; token: string }>('POST', '/api/transfer', { phrase }),
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);
},
reportImage: (id: string, file: File) =>
fetchJson<void>(`/api/reports/${id}/image`, { method: 'POST', headers: { 'content-type': file.type || 'application/octet-stream' }, body: file }),
tally: () => call<Tally>('GET', '/api/tally'),
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 })
+7 -4
View File
@@ -1,6 +1,6 @@
// The reactive room a page shows: the seat this browser holds (or the
// gallery), the table, the talk, and the game's view. It knows the game only
// through GameSpec; a game's own store wraps or extends this one.
// through GameSpec.
import { game } from '$lib/game';
import { SPECTATOR, type SeatId, type TableOptions } from '$lib/game/spec';
@@ -32,7 +32,7 @@ export function rememberName(raw: string): void {
}
/** "Ysolde", "Ysolde and Grey", "Ysolde, Grey and Ormund". */
export function listText(names: string[]): string {
function listText(names: string[]): string {
if (names.length <= 1) return names[0] ?? '';
return `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`;
}
@@ -57,13 +57,16 @@ export class Room {
started = $derived(this.view.state !== null);
state = $derived(this.view.state);
isHost = $derived(!this.spectating && this.view.host === this.view.me);
others = $derived(this.view.seats.filter((s) => s.id !== this.me));
/** Your move is in and the round waits on someone else. */
moved = $derived(this.started && !this.view.over && !this.spectating && !this.view.waitingOn.includes(this.me));
yourMove = $derived(this.started && !this.view.over && this.view.waitingOn.includes(this.me));
/** Names of the seats the round is waiting on, yours aside. */
awaiting = $derived(this.view.waitingOn.filter((id) => id !== this.me).map((id) => this.nameOf(id)));
awaitingText = $derived(listText(this.awaiting));
others = $derived(this.view.seats.filter((s) => s.id !== this.me));
/** A chair at the table is empty, held for someone or not. */
seatEmpty = $derived(!this.started && this.view.seats.length < this.view.size);
/** An empty chair not held for anyone: a stranger may sit. */
seatUnheld = $derived(this.seatEmpty && this.view.seats.length + this.view.expected.length < this.view.size);
nameOf(id: SeatId): string {
return this.view.seats.find((s) => s.id === id)?.name ?? (this.view.state ? game.nameOf(this.view.state, id) : id);
+45
View File
@@ -46,3 +46,48 @@ export interface RoomView<State> {
keeper: string;
keeperZone: string;
}
/** One line under a report: the keeper's reply, with its status, or the player's answer. */
export interface ReportLine {
at: string;
text: string;
from: 'desk' | 'player';
status?: string;
}
/** A report to the keeper, with the exchange under it. */
export interface Report {
id: string;
at: string;
roomId: string;
/** The reporter's name, or "(gallery)" for a watcher. */
player: string;
seat: SeatId | null;
/** The round being written when the report was filed, and the ledger's length. */
turn: number | null;
seq: number;
happened: string;
expected: string;
/** The whole exchange in order: the keeper's replies and the player's answers. */
thread: ReportLine[];
/** A screenshot's file name under images/, when one was sent. */
image?: string;
}
/** What the ledgers add up to, for the hall's about panel. */
export interface Tally {
tablesOpened: number;
gamesBegun: number;
gamesFinished: number;
/** Distinct names of people who have taken a seat; a bot's name is not counted. */
namesSeated: number;
turnsPlayed: number;
/** Minutes at the table: gaps under ten minutes between one move and the next, summed. */
minutesAtTable: number;
longestGameTurns: number;
gamesWithBot: number;
talkLines: number;
firstGameAt: string | null;
/** The game's own lines, summed over finished games. */
byGame: Record<string, number>;
}
+3 -12
View File
@@ -4,19 +4,10 @@
// panel renders every declaration. Nothing here leaves the browser.
import { game } from '$lib/game';
export interface Preference {
key: string;
label: string;
/** A toggle holds a boolean; a choice holds one of its choices' values. */
kind: 'toggle' | 'choice';
choices?: { value: string; label: string }[];
default: boolean | string;
note?: string;
}
import type { Preference } from '$lib/game/spec';
/** The settings every game in the kit shares. */
export const KIT_PREFERENCES: Preference[] = [
const KIT_PREFERENCES: Preference[] = [
{ key: 'motion', label: 'Motion', kind: 'choice', choices: [{ value: 'full', label: 'pieces move and fade' }, { value: 'reduced', label: 'changes appear at once' }], default: 'full' },
{ key: 'confirmMoves', label: 'Confirm a move before it is sent', kind: 'toggle', default: false }
];
@@ -54,7 +45,7 @@ function load(): Record<string, boolean | string> {
}
/** The live preferences; read them where they matter, set them through setPref. */
export const prefs = $state<Record<string, boolean | string>>(typeof localStorage === 'undefined' ? defaults() : load());
export const prefs = $state<Record<string, boolean | string>>(load());
export function setPref(key: string, value: boolean | string): void {
prefs[key] = value;
+2 -38
View File
@@ -7,14 +7,14 @@
/** Opened from a table, the page offers the way back to it and carries it to its cross-links. */
const room = $derived((page.url.searchParams.get('room') ?? '').toUpperCase());
const withRoom = $derived((path: string) => (room ? `${path}?room=${room}` : path));
const withRoom = (path: string) => (room ? `${path}?room=${room}` : path);
</script>
<svelte:head>
<title>__NAME__: how to play</title>
</svelte:head>
<main class="guide">
<main class="reading">
<header>
{#if room}<a class="back" href={`/join/${room}`}>Back to your game</a> · {/if}<a class="back" href="/">Back to the hall</a> · <a class="back" href={withRoom('/rules')}>The rules</a>
<h1>How to play</h1>
@@ -57,31 +57,6 @@
</main>
<style>
.guide {
max-width: 46rem;
margin: 0 auto;
padding: 1.5rem var(--gutter) 4rem;
}
a {
color: var(--frost);
}
.back {
font-size: 0.95rem;
}
h1 {
font-size: 2.4rem;
font-weight: 300;
margin: 0.5rem 0 0.3rem;
}
.lede {
color: var(--bone-dim);
max-width: 40em;
}
nav {
display: flex;
flex-wrap: wrap;
@@ -90,17 +65,6 @@
font-size: 0.95rem;
}
section {
padding: 1.25rem 0 0.5rem;
border-top: 1px solid var(--rule);
margin-top: 1rem;
}
h2 {
font-size: 1.5rem;
margin-bottom: 0.7rem;
}
p + p {
margin-top: 0.7rem;
}
+6 -8
View File
@@ -22,10 +22,8 @@
let phrase = $state<{ phrase: string; expiresAt: number } | null>(null);
let rematching = $state(false);
const link = $derived(typeof location === 'undefined' ? '' : `${location.origin}/join/${roomId}`);
const link = $derived(`${location.origin}/join/${roomId}`);
const watching = $derived(room?.spectating ?? false);
/** A watcher may still sit while the table is laid and a seat is free. */
const seatFree = $derived(!!room && !room.started && room.view.seats.length < room.view.size);
// Return to a held seat, or take a place in the gallery and offer one.
$effect(() => {
@@ -157,12 +155,12 @@
</p>
{/if}
{#if joining && seatFree}
{#if joining && room?.seatEmpty}
<section class="seat">
<p class="eyebrow">room {roomId}</p>
<h2>Take a seat</h2>
<p>A player has opened this table and is gathering company. Choose a name and sit; the game begins when the host says.</p>
<form class="rename" onsubmit={takeSeat}>
<form class="sit" onsubmit={takeSeat}>
<label class="field">
<span>Your name</span>
<input type="text" bind:value={name} maxlength={NAME_MAX} autocomplete="nickname" required />
@@ -174,7 +172,7 @@
{/if}
{#if room && !room.started}
<Lobby {room} {link} />
<Lobby {room} {link} {copied} {copyLink} />
{:else if room}
<Board {room} />
{:else if !error}
@@ -259,7 +257,7 @@
margin-bottom: 0.4rem;
}
.rename {
.sit {
display: flex;
flex-wrap: wrap;
align-items: center;
@@ -278,7 +276,7 @@
font-style: italic;
}
.rename input {
.sit input {
width: 14em;
}
+3 -39
View File
@@ -4,16 +4,16 @@
// where you can, so the page and the engine follow one source.
import { page } from '$app/state';
/** Opened from a table, the page offers the way back to it. */
/** Opened from a table, the page offers the way back to it and carries it to its cross-links. */
const room = $derived((page.url.searchParams.get('room') ?? '').toUpperCase());
const withRoom = $derived((path: string) => (room ? `${path}?room=${room}` : path));
const withRoom = (path: string) => (room ? `${path}?room=${room}` : path);
</script>
<svelte:head>
<title>__NAME__: the rules</title>
</svelte:head>
<main class="rules">
<main class="reading">
<header>
{#if room}<a class="back" href={`/join/${room}`}>Back to your game</a> · {/if}<a class="back" href="/">Back to the hall</a> · <a class="back" href={withRoom('/guide')}>How to play</a>
<h1>The rules</h1>
@@ -39,42 +39,6 @@
</main>
<style>
.rules {
max-width: 46rem;
margin: 0 auto;
padding: 1.5rem var(--gutter) 4rem;
}
a {
color: var(--frost);
}
.back {
font-size: 0.95rem;
}
h1 {
font-size: 2.4rem;
font-weight: 300;
margin: 0.5rem 0 0.3rem;
}
.lede {
color: var(--bone-dim);
max-width: 40em;
}
section {
padding: 1.25rem 0 0.5rem;
border-top: 1px solid var(--rule);
margin-top: 1rem;
}
h2 {
font-size: 1.5rem;
margin-bottom: 0.7rem;
}
.word {
margin-top: 2rem;
color: var(--bone-faint);