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:
co-authored by
Claude Fable 5.1
parent
6c42e55f59
commit
33ac0ec808
+56
-1
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} 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} 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} 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} 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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user