Begin Wyrm from the game kit

This commit is contained in:
Eric Wagoner committed 2026-10-26 16:08:50 -04:00
commit d91b95cfab
73 files changed
+10509

No files matched your search

+126
View File
@@ -0,0 +1,126 @@
<script lang="ts">
// The host's announcement, shown by every game of Kestrel's Hall: the keeper
// writes /var/lib/hall/announcement.json on the host (hall-announce), and each
// game's site serves it at /hall/announcement.json. It is checked when the page
// loads, every few minutes, and whenever the page is looked at again. A reader
// may dismiss one announcement; the next one still shows.
import { onMount } from 'svelte';
interface Announcement {
/** Names this announcement, so dismissing it does not hide the next. */
id: string;
text: string;
/** When what it announces happens, told in each reader's own time. */
at?: string;
/** After this it is no longer shown. */
until?: string;
}
/** An empty file, `{}`, is how the keeper takes an announcement down. */
function isAnnouncement(a: unknown): a is Announcement {
if (!a || typeof a !== 'object') return false;
const { id, text } = a as Partial<Announcement>;
return typeof id === 'string' && typeof text === 'string' && text !== '';
}
const SOURCE = '/hall/announcement.json';
const CHECK_MS = 5 * 60_000;
const DISMISSED = 'hall:dismissed';
let current = $state<Announcement | null>(null);
let dismissed = $state<string | null>(null);
let now = $state(Date.now());
async function check() {
try {
const res = await fetch(SOURCE, { cache: 'no-store' });
const a: unknown = res.ok ? await res.json() : null;
current = isAnnouncement(a) ? a : null;
} catch {
// No announcement reachable: none shown.
}
now = Date.now();
}
onMount(() => {
try {
dismissed = localStorage.getItem(DISMISSED);
} catch {
// Without storage a dismissal lasts this visit.
}
void check();
const poll = setInterval(check, CHECK_MS);
const tick = setInterval(() => (now = Date.now()), 30_000);
const looked = () => {
if (document.visibilityState === 'visible') void check();
};
document.addEventListener('visibilitychange', looked);
return () => {
clearInterval(poll);
clearInterval(tick);
document.removeEventListener('visibilitychange', looked);
};
});
const shown = $derived(current && current.id !== dismissed && !(current.until && Date.parse(current.until) < now) ? current : null);
/** When it happens, in the reader's own clock: "in 25 minutes, at 7:00 AM", or the day and time further off. */
const when = $derived.by(() => {
const t = shown?.at ? Date.parse(shown.at) : NaN;
if (!Number.isFinite(t)) return '';
const minutes = Math.round((t - now) / 60_000);
if (minutes > 90) return ` (${new Date(t).toLocaleString([], { weekday: 'short', hour: 'numeric', minute: '2-digit' })}, your time)`;
if (minutes > 1) return ` (in ${minutes} minutes, at ${new Date(t).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })})`;
return minutes >= -15 ? ' (now)' : '';
});
function dismiss() {
// Read before dismissing: once dismissed, nothing is shown.
const id = shown?.id;
if (!id) return;
dismissed = id;
try {
localStorage.setItem(DISMISSED, id);
} catch {
// The dismissal still holds for this visit.
}
}
</script>
{#if shown}
<div class="hall-announcement" role="status">
<span>{shown.text}{when}</span>
<button type="button" aria-label="Dismiss this announcement" onclick={dismiss}>×</button>
</div>
{/if}
<style>
/* Its own colors, not the game's: the same notice reads the same in every game of the hall. */
.hall-announcement {
position: sticky;
top: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
gap: 0.8rem;
padding: 0.45rem 2.4rem 0.45rem 1rem;
background: #f2c45a;
color: #1d1a12;
font: 500 0.95rem/1.35 system-ui, -apple-system, 'Segoe UI', sans-serif;
text-align: center;
box-shadow: 0 1px 4px #0005;
}
button {
position: absolute;
right: 0.5rem;
background: none;
border: 0;
color: inherit;
font-size: 1.3rem;
line-height: 1;
padding: 0.2rem 0.5rem;
cursor: pointer;
}
</style>
+209
View File
@@ -0,0 +1,209 @@
<script lang="ts">
// The demo game's board. A real game replaces this file entirely; the
// masthead, lobby, talk, gallery and reports around it stay.
import TableTalk from './TableTalk.svelte';
import { HIGHEST, TARGET } from '$lib/game';
import type { Room } from '$lib/net/room.svelte';
let { room }: { room: Room } = $props();
const g = $derived(room.state!);
let pick = $state(0);
async function play() {
if (!pick) return;
if (await room.submit({ pick })) pick = 0;
}
</script>
<main class="board">
<section class="play">
<table class="rounds">
<thead>
<tr>
<th class="turn"><span class="visually-hidden">Round</span></th>
{#each g.seats as id (id)}
<th class:you={id === room.me}>{g.players[id].name}{#if id === room.me}&nbsp;(you){/if}</th>
{/each}
</tr>
</thead>
<tbody>
{#each g.rounds as round, i (i)}
<tr>
<td class="turn">{i + 1}</td>
{#each g.seats as id (id)}
<td class:scored={round.scorer === id}>{round.picks[id]}</td>
{/each}
</tr>
{/each}
{#if !g.over}
<tr class="draft">
<td class="turn">{g.rounds.length + 1}</td>
{#each g.seats as id (id)}
<td class="muted">{id === room.me && pick ? pick : '?'}</td>
{/each}
</tr>
{/if}
</tbody>
</table>
{#if g.over}
<div class="verdict">
<h2>{g.over.winner === room.me ? 'You win.' : g.over.winner ? `${g.players[g.over.winner].name} wins.` : 'A draw.'}</h2>
<p>{g.over.reason}</p>
{#each room.view.parting as last, i (i)}<p><em>{room.nameOf(last.id)}</em> {last.text}</p>{/each}
<a class="commit" href="/">Back to the hall</a>
</div>
{:else if room.view.playingOn}
<section class="move">
<h2>The bots play on</h2>
<p class="muted">Nobody else owes a move, so the bots play a round every few seconds for you to watch.</p>
{#if !room.spectating}<button type="button" class="quiet" onclick={() => room.hurry()}>Play the rest at once</button>{/if}
</section>
{:else if room.spectating}
<section class="move">
<h2>You watch from the Peanut Gallery</h2>
<p class="muted">{room.awaitingText ? `${room.awaitingText} ${room.awaiting.length === 1 ? 'turns a card over and over, still choosing.' : 'turn their cards over and over, still choosing.'}` : 'Cards are being chosen, face down.'}</p>
</section>
{:else}
<section class="move">
<h2>Name a number for round {g.rounds.length + 1}</h2>
<p class="muted">Every player lays a card face down, and the highest card nobody else laid takes the point. The first to {TARGET} points wins.</p>
<div class="picks">
{#each Array.from({ length: HIGHEST }, (_, i) => i + 1) as n (n)}
<button type="button" class="pick" class:chosen={pick === n} disabled={room.moved} onclick={() => (pick = n)}>{n}</button>
{/each}
</div>
<button type="button" class="commit" disabled={!pick || room.moved || room.sending} onclick={play}>
{room.moved ? `Waiting on ${room.awaitingText}` : room.sending ? 'Sending' : 'Commit'}
</button>
{#if room.error}<p class="warning">{room.error}</p>{/if}
</section>
{/if}
</section>
<aside class="rail">
<section class="standings">
<h2>Standings</h2>
<ul>
{#each g.seats as id (id)}
<li><span>{g.players[id].name}</span><span>{g.players[id].points} / {TARGET}</span></li>
{/each}
</ul>
</section>
<TableTalk {room} />
</aside>
</main>
<style>
.board {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(280px, 360px);
gap: 0 clamp(1.5rem, 4vw, 3.5rem);
padding: 0 var(--gutter);
max-width: 1280px;
margin: 0 auto;
}
@media (max-width: 860px) {
.board {
grid-template-columns: minmax(0, 1fr);
}
}
.rounds {
width: 100%;
border-collapse: collapse;
font-size: 1.3rem;
text-align: center;
}
.rounds th {
font-weight: 500;
font-style: italic;
font-size: 1rem;
padding: 0.3rem 0;
border-bottom: 1px solid var(--rule-strong);
}
.rounds th.you {
color: var(--frost);
}
.rounds td {
padding: 0.35rem 0;
border-bottom: 1px solid var(--rule);
}
.rounds .turn {
width: 2.5rem;
font-size: 0.8rem;
color: var(--bone-faint);
}
.rounds td.scored {
color: var(--ember);
font-weight: 600;
}
.rounds tr.draft td {
background: var(--slate);
}
.move,
.verdict {
margin-top: 1.2rem;
}
.move h2,
.verdict h2 {
font-size: 1.3rem;
margin-bottom: 0.4rem;
}
.picks {
display: flex;
gap: 0.5rem;
margin: 0.8rem 0;
}
.pick {
width: 3rem;
height: 3rem;
font-size: 1.3rem;
background: var(--slate);
border: 1px solid var(--rule-strong);
border-radius: 6px;
color: var(--bone);
}
.pick.chosen {
border-color: var(--ember);
color: var(--ember);
}
.verdict .commit {
display: inline-block;
margin-top: 0.6rem;
text-decoration: none;
}
.standings h2 {
font-size: 1.1rem;
font-style: italic;
margin: 0.75rem 0 0.4rem;
}
.standings ul {
list-style: none;
margin: 0;
padding: 0;
}
.standings li {
display: flex;
justify-content: space-between;
padding: 0.3rem 0;
border-bottom: 1px solid var(--rule);
}
</style>
+669
View File
@@ -0,0 +1,669 @@
<script lang="ts">
// The hall: the landing page every game in the kit shares. Name, play the
// bot, open a table, join by code, the ledger of your tables, and the
// about panel with the rules, the guide and a way to reach the keeper.
import { goto } from '$app/navigation';
import { loadFamily, type FamilyGame } from '$lib/family';
import TabbedDialog from './TabbedDialog.svelte';
import TallyBoard from './Tally.svelte';
import Preferences from './Preferences.svelte';
import { prefs, setPref } from '$lib/prefs.svelte';
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 { Report, RoomView, Tally } from '$lib/net/view';
import type { State } from '$lib/game';
import { game } from '$lib/game';
import { tableSize } from '$lib/game/spec';
let name = $state(playerName());
let code = $state('');
/** 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]))
);
let prefsOpen = $state(false);
let busy = $state(false);
let error = $state('');
let seats = $state(allSeats());
let games = $state<Record<string, RoomView<State> | null>>({});
let aboutOpen = $state(false);
/** The ledgers' tally, fetched when the about panel opens. */
let tally = $state<Tally | null>(null);
$effect(() => {
if (aboutOpen && !tally) api.tally().then((t) => (tally = t)).catch(() => (tally = null));
});
const hours = (minutes: number) => (minutes < 90 ? `${minutes} minutes` : `${Math.round(minutes / 60)} hours`);
/** The tally as the about panel tells it: this game's own story on cards, the rest in detail. */
const told = $derived.by(() => {
const t = tally;
if (!t) return null;
const headline = [
{ n: t.gamesFinished, label: t.gamesFinished === 1 ? 'game played to a finish' : 'games played to a finish' },
{ n: t.namesSeated, label: t.namesSeated === 1 ? 'name at the table' : 'names at the table' },
{ n: t.turnsPlayed, label: 'turns recorded' },
{ n: hours(t.minutesAtTable), label: 'at the table' }
];
const onCards = new Set<string>([]);
const details = [
{ n: t.gamesBegun, label: `games begun, at ${t.tablesOpened} ${t.tablesOpened === 1 ? 'table' : 'tables'}` },
{ n: t.namesSeated, label: t.namesSeated === 1 ? 'name has taken a seat' : 'names have taken a seat' },
{ n: t.turnsPlayed, label: `turns recorded; ${t.longestGameTurns} in the longest game` },
{ n: t.gamesWithBot, label: t.gamesWithBot === 1 ? 'game with a bot seated' : 'games with a bot seated' },
...(t.talkLines ? [{ n: t.talkLines, label: t.talkLines === 1 ? 'line of table talk' : 'lines of table talk' }] : []),
...Object.entries(t.byGame).filter(([label]) => !onCards.has(label)).map(([label, n]) => ({ n, label }))
];
const first = t.firstGameAt ? `The first game began ${new Date(t.firstGameAt).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' })}. ` : '';
return { headline, details, note: `${first}Names are counted, not people: one player under two names is two here. Table time counts only the minutes between moves, so a game left open overnight adds nothing for the night.` };
});
/** The other games of Kestrel's Hall, for the about panel. */
let family = $state<FamilyGame[]>([]);
let hall = $state('');
$effect(() => {
void loadFamily('wyrm').then((f) => {
family = f.games;
hall = f.hall;
});
});
/** 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('');
const ready = $derived(name.trim().length > 0);
// Each held seat is looked up once, so the ledger can say whose move it
// is; until the answer comes, its game is absent and reads "looking". A
// seat the server refuses outright is doubted; a second refusal in a row
// forgets it. Silence (a restart, a stale answer) forgets nothing.
const asked = new Set<string>();
$effect(() => {
for (const held of seats) {
if (asked.has(held.roomId)) continue;
asked.add(held.roomId);
api.view(held.roomId, held.token)
.then((v) => {
trustSeat(held.roomId);
games[held.roomId] = v;
})
.catch((e: unknown) => {
games[held.roomId] = null;
if (e instanceof ServerError && (e.status === 403 || e.status === 404) && doubtSeat(held.roomId)) seats = allSeats();
});
}
});
let phrase = $state('');
let claiming = $state(false);
/** Bring a seat from another device by the phrase it minted. */
async function claim(e: SubmitEvent) {
e.preventDefault();
claiming = true;
error = '';
try {
const got = await api.claim(phrase.trim());
rememberSeat(got.roomId, { seat: got.seat, token: got.token });
await goto(`/join/${got.roomId}`);
} catch (err) {
error = err instanceof Error ? err.message : 'The phrase opened no seat.';
} finally {
claiming = false;
}
}
// The desk is asked for every seat this browser holds, again whenever that set changes.
$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 open(make: () => Promise<Room>) {
busy = true;
error = '';
try {
rememberName(name);
const room = await make();
await goto(`/join/${room.roomId}`);
} catch (e) {
error = e instanceof Error ? e.message : 'The table could not be opened.';
} finally {
busy = false;
}
}
function join(e: SubmitEvent) {
e.preventDefault();
rememberName(name);
const c = code.trim().toUpperCase();
if (c) void goto(`/join/${c}`);
}
/** A game in play asks before it is forgotten: this browser may be the only thing that holds the seat. */
let forgetting = $state<string | null>(null);
function forget(roomId: string) {
if (!games[roomId]?.over && forgetting !== roomId) {
forgetting = roomId;
return;
}
forgetting = null;
forgetSeat(roomId);
seats = allSeats();
}
/** A game counts as finished once its table says so; until then, even while loading, it is in play. */
const finished = $derived(seats.filter((h) => !!games[h.roomId]?.over));
const active = $derived(seats.filter((h) => !games[h.roomId]?.over));
function status(held: { roomId: string; seat: string }): string {
const g = games[held.roomId];
if (g === undefined) return 'looking';
if (g === null) return 'unreachable';
const others = g.seats.filter((s) => s.id !== held.seat).map((s) => (s.standIn ? `${s.name} (a bot stands in)` : s.bot ? `${s.name} (the bot)` : s.name));
if (!g.state) return `waiting to start${g.seats.length ? ` · ${g.seats.map((s) => s.name).join(', ')}` : ''}`;
if (g.over) {
if (g.over.winner === held.seat) return 'you won';
if (g.over.winner === null) return 'a draw';
return `${game.nameOf(g.state, g.over.winner)} won`;
}
const round = `round ${g.turn}`;
if (g.waitingOn.includes(held.seat)) return `your move · ${round} · against ${others.join(', ')}`;
return `waiting on ${g.waitingOn.map((id) => game.nameOf(g.state!, id)).join(', ')} · ${round}`;
}
function unread(held: { roomId: string }): number {
const g = games[held.roomId];
return g ? unreadTalk(held.roomId, g.chat.length) : 0;
}
function yourMove(held: { roomId: string; seat: string }): boolean {
const g = games[held.roomId];
return !!g?.state && !g.over && g.waitingOn.includes(held.seat);
}
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.';
}
}
</script>
<section class="lid" class:returning={active.length > 0}>
<div class="lid-head">
<h1>Wyrm</h1>
<p class="pitch">One sentence that says what the game is, in its own world.</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">about this game</a> · <button type="button" class="link" onclick={() => (prefsOpen = true)}>preferences</button></p>
<Preferences bind:open={prefsOpen} />
</div>
<div class="lid-play">
<label class="name">
<span>Your name</span>
<input type="text" bind:value={name} maxlength={NAME_MAX} placeholder="e.g. Morwenna" autocomplete="nickname" />
</label>
{#if game.options?.length}
<div class="options">
{#each game.options as o (o.key)}
{@const chosen = o.choices.find((c) => c.value === options[o.key])}
<div>
<label class="option">
<span>{o.label}</span>
<select value={options[o.key]} onchange={(e) => setPref(`table.${o.key}`, e.currentTarget.value)}>
{#each o.choices as c (c.value)}<option value={c.value}>{c.label}</option>{/each}
</select>
</label>
{#if chosen?.note}<p class="option-note muted small">{chosen.note}</p>{/if}
</div>
{/each}
</div>
{/if}
<button type="button" class="commit primary" disabled={!ready || busy} onclick={() => open(() => Room.createWithBot(name.trim(), options))}>Play now against the bot</button>
<div class="ways">
<button type="button" class="quiet" disabled={!ready || busy} onclick={() => open(() => Room.create(name.trim(), tableSize(game, options), options))}>Open a table</button>
<form class="joinform" onsubmit={join}>
<span class="or">or join one</span>
<input type="text" class="code" bind:value={code} maxlength="4" placeholder="CODE" autocapitalize="characters" autocomplete="off" aria-label="room code" />
<button type="submit" class="quiet" disabled={!ready || code.trim().length < 4}>Join</button>
</form>
</div>
<p class="muted small">With a code you can take a seat if one is free, or watch from the Peanut Gallery.</p>
<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>
</form>
{#if error}<p class="warning">{error}</p>{/if}
</div>
<div class="lid-look" aria-hidden="true">
<!-- A still of the game at its most characteristic: a few rounds of the ledger, a board mid-play. -->
<p class="caption">A picture of the game goes here.</p>
</div>
{#snippet gameRow(held: (typeof seats)[number])}
<div class="ledger-row" class:your-move={yourMove(held)}>
<a class="ledger-resume" href={`/join/${held.roomId}`}>
<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>
{#if forgetting === held.roomId}
<span class="forget-ask">This browser may be all that holds your seat. <button type="button" class="quiet" onclick={() => forget(held.roomId)}>Forget it</button> <button type="button" class="quiet" onclick={() => (forgetting = null)}>Keep it</button></span>
{:else}
<button type="button" class="dismiss" title="forget this game" onclick={() => forget(held.roomId)}>×</button>
{/if}
</div>
{/snippet}
<!-- The games still in play, first thing for someone coming back; finished ones wait further down. -->
{#if active.length}
<div class="ledger">
<div class="ledger-head">your games</div>
{#each active as held (held.roomId)}{@render gameRow(held)}{/each}
</div>
{/if}
{#if finished.length || reports.length}
<div class="ledger past">
{#if finished.length}
<div class="ledger-head">finished games</div>
{#each finished as held (held.roomId)}{@render gameRow(held)}{/each}
{/if}
{#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}
<!-- The about panel, a tab at a time: its sections are written here, in the game's own words. -->
{#snippet aboutGame()}
<div class="about-body">
<p>Where the game comes from, who made the original and when, and how the keeper of this page first met it.</p>
<p>What this version keeps and what it changes. Where the rules text it follows lives (the <a href="/rules">rules page</a>), and any borrowed art or text with its notice.</p>
<p>It is free and keeps no accounts. A game between people lives on a small server as an append-only ledger of moves, so it can be replayed from its first move. Each player is shown only what the rules let them see (drop this sentence if nothing in the game is hidden).</p>
</div>
{/snippet}
{#snippet aboutTally()}
{#if told}
<TallyBoard title="How much love the table is getting" headline={told.headline} details={told.details} note={told.note} />
{:else}
<p class="muted">Counting…</p>
{/if}
{/snippet}
{#snippet aboutFamily()}
<div class="about-body">{#if family.length}
<h3>More games in {#if hall}<a href={hall}>Kestrel's Hall</a>{:else}Kestrel's Hall{/if}</h3>
<ul class="family">
{#each family as g (g.slug)}
<li><a href={g.url}>{g.name}</a>: {g.pitch} <span class="muted">{g.players} players; {g.origin}.</span></li>
{/each}
</ul>
{/if}
<p class="hosting">
The games of Kestrel's Hall are free and will stay free. They run on small servers that cost a few dollars a month, and anyone who wants to chip in toward the hosting can do so at
<a href="https://ko-fi.com/kestrelsnest" target="_blank" rel="noreferrer">ko-fi.com/kestrelsnest</a>. Chipping in unlocks nothing, and nothing is held back without it.
</p>
<p class="made">Wyrm is its designer's.</p>
</div>
{/snippet}
{#snippet aboutWord()}
<div class="about-body">
<p>If a rule reads wrong, something breaks, or you have a game to tell of, write to <a href="mailto:eric@ericwagoner.com">eric@ericwagoner.com</a>, <a href="https://bsky.app/profile/kestrelsnest.social" target="_blank" rel="noreferrer">@kestrelsnest.social</a> on Bluesky, or <a href="https://toots.kestrelsnest.social/@eric" target="_blank" rel="noreferrer">@eric@toots.kestrelsnest.social</a> on Mastodon. The keeper of this hall roosts at <a href="https://kestrelsnest.social" target="_blank" rel="noreferrer">kestrelsnest.social</a>.</p>
</div>
{/snippet}
<TabbedDialog
address="about"
bind:open={aboutOpen}
title="About Wyrm"
label="About this game"
tabs={[
{ id: 'game', label: 'Origins', body: aboutGame },
{ id: 'tally', label: 'Games so far', body: aboutTally },
{ id: 'family', label: 'More games', body: aboutFamily },
{ id: 'word', label: 'Send word', body: aboutWord }
]}
/>
</section>
<style>
.lid {
width: 100%;
max-width: 54rem;
background: var(--slate);
border: 1px solid var(--rule-strong);
border-radius: 8px;
padding: clamp(1.4rem, 3vw, 2.2rem) clamp(1.2rem, 3vw, 2.6rem) 1.8rem;
display: grid;
grid-template-columns: minmax(0, 1fr);
grid-template-areas: 'head' 'play' 'look' 'ledger' 'past';
gap: 1.2rem 2.2rem;
}
/* Someone coming back sees their games first. */
.lid.returning {
grid-template-areas: 'head' 'ledger' 'play' 'look' 'past';
}
@media (min-width: 901px) {
.lid {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
grid-template-areas: 'head head' 'play look' 'ledger ledger' 'past past';
}
.lid.returning {
grid-template-areas: 'head head' 'ledger ledger' 'play look' 'past past';
}
}
.lid-head {
grid-area: head;
text-align: center;
}
h1 {
font-size: clamp(2.6rem, 6vw, 3.6rem);
font-weight: 300;
letter-spacing: -0.01em;
}
.pitch {
font-style: italic;
font-size: 1.2rem;
margin-top: 0.3rem;
}
.tag {
color: var(--bone-dim);
max-width: 40em;
margin: 0.6rem auto 0.4rem;
font-size: 0.95rem;
}
.links {
font-size: 0.95rem;
color: var(--bone-faint);
}
.links a {
color: var(--frost);
}
.about-link {
font-style: italic;
text-decoration: underline dotted;
}
.lid-play {
grid-area: play;
display: flex;
flex-direction: column;
gap: 0.8rem;
}
.name {
display: block;
}
.name span {
display: block;
font-size: 0.8rem;
letter-spacing: 0.12em;
color: var(--bone-dim);
margin-bottom: 0.3rem;
}
.name input {
width: 100%;
}
.commit.primary {
width: 100%;
text-align: center;
}
.ways {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.6rem 1rem;
}
.joinform {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.or {
color: var(--bone-dim);
font-style: italic;
font-size: 0.95rem;
}
.code {
width: 6.5em;
text-align: center;
text-transform: uppercase;
letter-spacing: 0.15em;
font-weight: 600;
}
.lid-look {
grid-area: look;
display: grid;
place-items: center;
min-height: 8rem;
background: var(--slate-deep);
border: 1px solid var(--rule-strong);
border-radius: 6px;
}
.caption {
font-size: 0.85rem;
color: var(--bone-dim);
text-align: center;
}
/* the games ledger */
.ledger {
grid-area: ledger;
border-top: 1px solid var(--rule-strong);
padding-top: 0.5rem;
}
.ledger.past {
grid-area: past;
}
.ledger-head {
font-style: italic;
color: var(--bone-dim);
margin-bottom: 0.3rem;
}
.ledger-row {
display: flex;
align-items: center;
gap: 0.4rem;
border-radius: 4px;
padding: 0.15rem 0.3rem;
}
.ledger-row.your-move {
background: rgba(240, 165, 58, 0.08);
}
.forget-ask {
font-size: 0.85em;
flex: none;
}
.ledger-row.your-move .ledger-info {
color: var(--ember);
}
.ledger-resume {
flex: 1;
display: flex;
align-items: baseline;
gap: 0.9rem;
min-width: 0;
color: inherit;
text-decoration: none;
padding: 0.25rem 0;
}
.ledger-resume:hover .ledger-code {
color: var(--frost);
}
.ledger-code {
font-weight: 600;
letter-spacing: 0.12em;
flex-shrink: 0;
}
.ledger-info {
color: var(--bone-dim);
font-size: 0.95rem;
overflow-wrap: anywhere;
}
.talk-new {
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;
}
/* about: the panel's own words; TabbedDialog.svelte is the dialog around them */
.about-body {
max-width: 44em;
color: var(--bone-dim);
padding: 0.6rem 0 0.2rem;
}
.about-body p + p {
margin-top: 0.6rem;
}
.about-body .made {
font-size: 0.85rem;
color: var(--bone-faint);
}
.family {
margin: 0.4rem 0 0.8rem 1.2rem;
padding: 0;
}
.family li + li {
margin-top: 0.3rem;
}
.options {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 1.2rem;
margin: 0.2rem 0 0.6rem;
}
.option {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9rem;
}
.option-note {
margin: 0.2rem 0 0;
max-width: 22em;
}
</style>
+272
View File
@@ -0,0 +1,272 @@
<script lang="ts">
import { untrack } from 'svelte';
import TableTalk from './TableTalk.svelte';
import { game } from '$lib/game';
import type { Room } from '$lib/net/room.svelte';
let { room, link, copied, copyLink }: { room: Room; link: string; copied: boolean; copyLink: () => void } = $props();
const v = $derived(room.view);
/** The host asked to begin while a seat is still held for someone on their way. */
let beginAsked = $state(false);
const hostName = $derived(v.seats.find((s) => s.id === v.host)?.name ?? 'the host');
const keeperSeated = $derived(v.seats.some((s) => s.name.toLowerCase() === v.keeper.toLowerCase()));
// A table that asks for rankings: this seat's, best first, kept on the server as it changes.
const choices = $derived(game.rankable?.(v.options) ?? null);
const people = $derived(v.seats.filter((s) => !s.bot).length);
const labelOf = (value: string) => choices?.find((c) => c.value === value)?.label ?? value;
let order = $state<string[]>([]);
// Follows the server's copy; the local order is read untracked, so a click is not undone before its reply.
$effect(() => {
if (!choices) return;
const values = choices.map((c) => c.value);
const mine = v.yourRanking;
if (mine && mine.length === values.length) order = [...mine];
else if (untrack(() => order.length) !== values.length) order = values;
});
/** Move the choice at `i` to place `j`, the others closing up behind it. */
async function move(i: number, j: number) {
if (j < 0 || j >= order.length || j === i) return;
const next = [...order];
next.splice(j, 0, ...next.splice(i, 1));
order = next;
await room.rank(next);
}
/** The hour where the keeper lives, in words, so a caller knows whether to wait. */
let minute = $state(0);
$effect(() => {
const id = setInterval(() => (minute += 1), 60_000);
return () => clearInterval(id);
});
const keeperHour = $derived.by(() => {
void minute;
try {
const hour = Number(new Intl.DateTimeFormat('en-US', { hour: 'numeric', hour12: false, timeZone: v.keeperZone }).format(new Date())) % 24;
if (hour === 0) return 'midnight';
if (hour === 12) return 'noon';
const word = ['twelve', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', 'ten', 'eleven'][hour % 12];
const part = hour < 5 ? 'in the small hours' : hour < 12 ? 'in the morning' : hour < 18 ? 'in the afternoon' : 'in the evening';
return `${word} ${part}`;
} catch {
return '';
}
});
</script>
<section class="lobby">
{#if room.spectating}
<h2>At the table</h2>
{#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, so you watch from the Peanut Gallery. The game begins when {hostName} says.</p>
{/if}
{:else}
<p class="eyebrow">room {v.roomId}</p>
<h2>The table is laid</h2>
<p>Send this link, or the code. Whoever opens it takes a seat, and the game begins when you say. Anyone who arrives once it has begun watches from the Peanut Gallery.</p>
<p class="link-row"><code>{link}</code><button type="button" class="quiet" onclick={copyLink}>{copied ? 'Copied' : 'Copy link'}</button></p>
{/if}
<ul class="roster">
{#each v.seats as s (s.id)}
<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="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)}
<li>
<span class="seat-name">{name}</span>
<span class="muted">{v.challenge && name.toLowerCase() === v.keeper.toLowerCase() ? 'called to the table; a seat is held' : 'from the last table; a seat is held'}</span>
</li>
{/each}
<li class="empty muted">{v.size - v.seats.length - v.expected.length} of {v.size} seats empty{#if v.audience > 0}; {v.audience} in the gallery{v.galleryTalk ? ', allowed to talk' : ''}{/if}</li>
</ul>
{#if v.rematchOf}
<p class="muted">This is a rematch of table {v.rematchOf}, with seats held for those not yet back.</p>
{/if}
{#if choices && !room.spectating}
<section class="ranking" aria-label="Your ranking">
<h3>Your ranking</h3>
<p class="muted small">
Best first. When the game begins, the deal reads everyone's ranking, and nobody sees another player's.
{#if !v.yourRanking}Until you move one, you have no preference and take what is left.{:else}Your ranking is kept.{/if}
</p>
<ol>
{#each order as value, i (value)}
<li>
<span class="place">{i + 1}.</span>
<span class="choice">{labelOf(value)}</span>
<button type="button" class="quiet" aria-label="Move {labelOf(value)} to the top" title="To the top" disabled={i === 0} onclick={() => move(i, 0)}>⤒</button>
<button type="button" class="quiet" aria-label="Move {labelOf(value)} up" disabled={i === 0} onclick={() => move(i, i - 1)}>↑</button>
<button type="button" class="quiet" aria-label="Move {labelOf(value)} down" disabled={i === order.length - 1} onclick={() => move(i, i + 1)}>↓</button>
</li>
{/each}
</ol>
{#if !v.yourRanking}<button type="button" class="quiet" onclick={() => room.rank(order)}>Keep this order</button>{/if}
{#if people > 1}<p class="muted small">{v.ranked} of the {people} players {v.ranked === 1 ? 'has' : 'have'} ranked.</p>{/if}
</section>
{/if}
{#if !room.spectating}
<div class="ways">
{#if room.seatEmpty && room.isHost}
<button type="button" class="quiet" onclick={() => room.addBot()}>Seat a bot</button>
{/if}
{#if room.isHost && beginAsked && v.expected.length > 0}
<span>A seat is held for {v.expected.join(' and ')}, who {v.expected.length === 1 ? 'has' : 'have'} not arrived. The seat goes once the game begins.</span>
<button type="button" class="commit small" onclick={() => room.begin()}>Begin without them</button>
<button type="button" class="link" onclick={() => (beginAsked = false)}>wait</button>
{:else if room.isHost}
<button type="button" class="commit small" disabled={v.seats.length < 2} onclick={() => (v.expected.length > 0 ? (beginAsked = true) : room.begin())}>Begin</button>
<span class="muted">{v.seats.length < 2 ? 'once a second player is seated' : `with ${v.seats.length} players`}</span>
{:else}
<span class="muted">{hostName} will begin when the table is ready.</span>
{/if}
</div>
{#if room.isHost}
<label class="switch muted">
<input type="checkbox" checked={v.galleryTalk} onchange={(e) => room.setGalleryTalk(e.currentTarget.checked)} />
Let the Peanut Gallery talk at the table
</label>
{/if}
{#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 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 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 for them. If they can come now, they will take it. If they are asleep or away, they will leave a word below when they can. In the meantime you can seat a bot, invite a friend, or play another game in a new tab. 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}
<div class="lobby-talk"><TableTalk {room} /></div>
</section>
<style>
.lobby {
max-width: 1280px;
margin: 0 auto;
padding: 1rem var(--gutter) 2rem;
}
.lobby h2 {
font-size: 1.4rem;
margin-bottom: 0.4rem;
}
.lobby p + p {
margin-top: 0.6rem;
}
.roster {
list-style: none;
margin: 0.9rem 0 0.6rem;
padding: 0;
max-width: 28em;
}
.roster li {
display: flex;
justify-content: space-between;
gap: 1rem;
padding: 0.3rem 0;
border-bottom: 1px solid var(--rule);
}
.roster li.empty {
border-bottom: 0;
justify-content: flex-start;
font-style: italic;
}
.seat-name {
font-weight: 500;
}
.ways {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem 0.9rem;
margin-top: 0.4rem;
}
.link-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem 0.75rem;
}
code {
font-family: var(--font);
background: var(--slate);
padding: 0.3rem 0.6rem;
border-radius: 4px;
word-break: break-all;
}
.lobby-talk {
max-width: 28em;
margin-top: 1rem;
}
.switch {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.6rem;
font-size: 0.9rem;
}
.ranking {
max-width: 26rem;
margin: 1rem 0;
}
.ranking h3 {
margin: 0 0 0.2rem;
font-size: 1.05rem;
}
.ranking ol {
list-style: none;
margin: 0.4rem 0;
padding: 0;
}
.ranking li {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.15rem 0;
}
.ranking .place {
width: 1.6rem;
color: var(--bone-dim);
font-variant-numeric: tabular-nums;
}
.ranking .choice {
flex: 1;
}
.ranking .quiet {
min-width: 2.6rem;
min-height: 2.4rem;
padding: 0.2rem 0.5rem;
}
.keeper {
margin-top: 0.8rem;
max-width: 38em;
}
.note {
margin-top: 0.4rem;
}
</style>
+95
View File
@@ -0,0 +1,95 @@
<script lang="ts">
// The preferences panel: every declared setting, kit and game, as a
// 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();
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') open = false;
}
</script>
<svelte:window onkeydown={onKey} />
{#if open}
<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}
<style>
.scrim {
position: fixed;
inset: 0;
z-index: 20;
background: rgba(0, 0, 0, 0.55);
display: grid;
/* One row the window's height, so the dialog's max-height: 100% is a real limit and it scrolls within. */
grid-template-rows: minmax(0, 1fr);
place-items: center;
padding: 16px;
}
.panel {
width: min(100%, 26rem);
max-height: 100%;
overflow: auto;
background: var(--slate);
border: 1px solid var(--rule-strong);
border-radius: 6px;
padding: 1.2rem 1.4rem 1.4rem;
}
h2 {
font-size: 1.3rem;
margin: 0 0 0.2rem;
}
.pref {
padding: 0.55rem 0;
border-top: 1px solid var(--rule);
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.8rem;
}
.row input[type='checkbox'] {
order: 2;
}
.note {
margin-top: 0.3rem;
}
.quiet {
margin-top: 0.8rem;
}
</style>
+155
View File
@@ -0,0 +1,155 @@
<script lang="ts">
import { api, type Room } from '$lib/net/room.svelte';
let { room, open = $bindable(false) }: { room: Room; 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();
if (!happened.trim() || busy) return;
busy = true;
error = '';
try {
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);
} catch (err) {
error = err instanceof Error ? err.message : 'The report did not reach the keeper.';
} finally {
busy = false;
}
}
</script>
<svelte:window onkeydown={(e) => open && e.key === 'Escape' && close()} />
{#if open}
<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 look into it.{#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 move went in but nothing changed"></textarea>
</label>
<label class="field">
<span>What did you expect?</span>
<textarea bind:value={expected} rows="2" maxlength="2000" placeholder="e.g. a point for me"></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 note">The room code and the turn are sent with it, so 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>
<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;
/* One row the window's height, so the dialog's max-height: 100% is a real limit and it scrolls within. */
grid-template-rows: minmax(0, 1fr);
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;
}
.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;
}
.note {
margin-top: 0.6rem;
}
.warning {
margin-top: 0.4rem;
}
.thanks {
color: var(--bone);
}
.actions {
display: flex;
align-items: center;
gap: 1rem;
margin-top: 0.9rem;
}
</style>
+154
View File
@@ -0,0 +1,154 @@
<script lang="ts">
// The host's hand on the other seats during a game: a transfer phrase for a
// player whose browser lost their seat, and a bot to play the seat of one
// who cannot go on, until a phrase gives it back.
import type { SeatId } from '$lib/game/spec';
import type { Room } from '$lib/net/room.svelte';
let { room, open = $bindable(false) }: { room: Room; open?: boolean } = $props();
const people = $derived(room.view.seats.filter((s) => s.id !== room.me && (!s.bot || s.standIn)));
let asked = $state<SeatId | null>(null);
let phrase = $state<{ name: string; phrase: string; expiresAt: number } | null>(null);
let error = $state('');
function close() {
open = false;
asked = null;
error = '';
}
async function makePhrase(seat: SeatId, name: string) {
error = '';
try {
const made = await room.hostPhrase(seat);
phrase = { name, ...made };
setTimeout(() => {
if (phrase?.phrase === made.phrase) phrase = null;
}, Math.max(0, made.expiresAt - Date.now()));
} catch (e) {
error = e instanceof Error ? e.message : 'The phrase could not be made.';
}
}
async function standIn(seat: SeatId) {
asked = null;
if (!(await room.standIn(seat))) error = room.error;
}
function phraseTitle(name: string, standIn: boolean): string {
const why = standIn ? `Brings ${name} back from the bot.` : `For when ${name}'s browser lost the seat, or they need to play from another device.`;
return `${why} You get four words to pass to them. Within ten minutes, they enter them in the hall, or in the claim box while watching this table, and sit back down as ${name}. The phrase works once, and the browser they used before stops opening the seat.`;
}
</script>
<svelte:window onkeydown={(e) => open && e.key === 'Escape' && close()} />
{#if open}
<div class="scrim" role="presentation" onclick={(ev) => ev.target === ev.currentTarget && close()}>
<div class="slip" role="dialog" aria-labelledby="seats-heading">
<h2 id="seats-heading">The other seats</h2>
<p class="muted small">As the host, you can bring a player back to a seat their browser lost, or give the seat of one who cannot go on to a bot, which plays it at once.</p>
{#if people.length === 0}
<p class="muted">Nobody else at this table is a person.</p>
{/if}
<ul>
{#each people as s (s.id)}
<li>
<span class="name">{s.name}{#if s.standIn}<span class="muted note">a bot plays for them</span>{/if}</span>
<span class="ways">
<button type="button" class="quiet" title={phraseTitle(s.name, !!s.standIn)} onclick={() => makePhrase(s.id, s.name)}>{s.standIn ? 'Transfer phrase to take it back' : 'Make a transfer phrase'}</button>
{#if !s.standIn && !room.view.over}
{#if asked === s.id}
<button type="button" class="commit small" onclick={() => standIn(s.id)}>Yes, a bot plays for {s.name}</button>
<button type="button" class="link" onclick={() => (asked = null)}>no</button>
{:else}
<button type="button" class="quiet" title="A bot plays {s.name}'s seat from their next move, and their browser stops opening it. A transfer phrase gives it back." onclick={() => (asked = s.id)}>Give to a bot</button>
{/if}
{/if}
</span>
</li>
{/each}
</ul>
{#if phrase}
<p class="made">Give {phrase.name} this phrase to claim within ten minutes, in the hall or while watching this table: <b class="phrase">{phrase.phrase}</b>. It works once, and their old link stops opening the seat.</p>
{/if}
{#if error}<p class="warning">{error}</p>{/if}
<div class="actions"><button type="button" class="link" onclick={close}>close</button></div>
</div>
</div>
{/if}
<style>
.scrim {
position: fixed;
inset: 0;
z-index: 20;
background: rgba(0, 0, 0, 0.55);
display: grid;
grid-template-rows: minmax(0, 1fr);
place-items: center;
padding: 16px;
}
.slip {
width: min(100%, 34rem);
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.4rem;
}
ul {
list-style: none;
padding: 0;
margin: 0.8rem 0 0;
}
li {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.4rem 1rem;
padding: 0.5rem 0;
border-bottom: 1px solid var(--rule);
}
.note {
margin-left: 0.5em;
font-style: italic;
font-size: 0.9em;
}
.ways {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
.made {
margin-top: 0.8rem;
}
.phrase {
letter-spacing: 0.06em;
user-select: all;
}
.warning {
margin-top: 0.5rem;
}
.actions {
margin-top: 0.9rem;
}
</style>
+147
View File
@@ -0,0 +1,147 @@
<script lang="ts">
// A dialog of tabs: the about panel, how to play, the rules. The address
// names it, so a plain link opens it: #rules opens the rules, #guide/notation
// how to play at its notation. It closes with its button, a click outside
// it, or Escape. The tab last read is the one it reopens on. `open` tells
// the page whether it is showing.
import { untrack, type Snippet } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import Tabs, { type Tab } from './Tabs.svelte';
let {
address,
title,
label = title,
tabs,
intro,
open = $bindable(false)
}: { address: string; title: string; label?: string; tabs: Tab[]; intro?: Snippet; open?: boolean } = $props();
let current = $state('');
// The dialog is open exactly while the router's address names it. Opening, choosing a tab and
// closing all navigate, so the address and the dialog cannot disagree.
const [named, tab] = $derived.by(() => {
const [name, t] = page.url.hash.slice(1).split('/');
return [name === address, t] as const;
});
$effect(() => {
open = named;
});
$effect(() => {
if (named && tabs.some((t) => t.id === tab)) untrack(() => (current = tab));
});
// The address follows the chosen tab, so what is being read can be linked to.
$effect(() => {
if (named && current) untrack(() => current !== tab && show(current));
});
const bare = () => `${page.url.pathname}${page.url.search}`;
const quietly = { replaceState: true, noScroll: true, keepFocus: true };
function show(id: string) {
goto(`${bare()}#${address}/${id}`, quietly);
}
function close() {
goto(bare(), quietly);
}
function onKey(e: KeyboardEvent) {
if (named && e.key === 'Escape') close();
}
</script>
<svelte:window onkeydown={onKey} />
{#if named}
<div class="scrim anchored" role="presentation" onclick={(ev) => ev.target === ev.currentTarget && close()}>
<div class="panel" role="dialog" aria-modal="true" aria-labelledby="{address}-title">
<header>
<h2 id="{address}-title">{title}</h2>
<button type="button" class="close" aria-label="Close" onclick={close}>×</button>
</header>
<div class="body">
{#if intro}<div class="intro">{@render intro()}</div>{/if}
<Tabs {tabs} {label} bind:current />
</div>
</div>
</div>
{/if}
<style>
/* Its own backdrop: not every game has another dialog to share one with. */
.scrim {
position: fixed;
inset: 0;
z-index: 20;
background: rgba(0, 0, 0, 0.55);
display: grid;
/* One row the window's height, so the dialog's max-height: 100% is a real limit and it scrolls within. */
grid-template-rows: minmax(0, 1fr);
place-items: center;
padding: 16px;
}
/* Held near the top, not centered: a tab of another length must not move the tabs under the pointer. */
.anchored {
place-items: start center;
padding-top: max(16px, 6vh);
}
/* min-width 0 all the way down, so wide content scrolls in place instead of widening the dialog past a phone's screen. */
.panel {
width: min(100%, 44rem);
min-width: 0;
max-height: 100%;
display: flex;
flex-direction: column;
background: var(--slate);
border: 1px solid var(--rule-strong);
border-radius: 6px;
padding: 1rem 1.4rem 1.2rem;
}
header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 1rem;
}
h2 {
font-size: 1.3rem;
margin: 0 0 0.6rem;
}
.close {
background: none;
border: none;
color: inherit;
font-size: 1.5rem;
line-height: 1;
cursor: pointer;
padding: 0.2rem 0.4rem;
}
.body {
overflow: auto;
min-height: 12rem;
min-width: 0;
}
.intro {
color: var(--bone-dim);
}
.body :global(a) {
color: var(--frost);
}
.body :global(h3) {
font-size: 1rem;
font-style: italic;
margin: 0.9rem 0 0.2rem;
color: var(--bone);
}
.body :global(h3:first-child) {
margin-top: 0;
}
/* The tab strip stays in view while a long tab scrolls beneath it. */
.body :global([role='tablist']) {
position: sticky;
top: 0;
z-index: 1;
background: var(--slate);
}
</style>
+138
View File
@@ -0,0 +1,138 @@
<script lang="ts">
import { tick } from 'svelte';
import { NAME_MAX, playerName, rememberName, type Room } from '$lib/net/room.svelte';
let { room }: { room: Room } = $props();
const lines = $derived(room.view.chat);
let draft = $state('');
/** The gallery signs its lines; the name is the one the hall remembers. */
let galleryName = $state(playerName());
let sending = $state(false);
let list = $state<HTMLElement | null>(null);
// New talk scrolls into view, as at any table.
$effect(() => {
void lines.length;
void tick().then(() => {
if (list) list.scrollTop = list.scrollHeight;
});
});
async function say(e: SubmitEvent) {
e.preventDefault();
const text = draft.trim();
if (!text || sending) return;
sending = true;
if (await room.say(text)) draft = '';
sending = false;
}
async function sayFromGallery(e: SubmitEvent) {
e.preventDefault();
const text = draft.trim();
const name = galleryName.trim();
if (!text || !name || sending) return;
sending = true;
rememberName(name);
if (await room.sayFromGallery(name, text)) draft = '';
sending = false;
}
</script>
<section class="talk" aria-label="Table talk">
<h2>Table talk{#if room.view.audience > 0}<span class="audience">{room.view.audience} in the gallery</span>{/if}</h2>
<ul bind:this={list}>
{#if lines.length === 0}
<li class="muted">Nobody has said a word.</li>
{/if}
{#each lines as line, i (line.at + ':' + i)}
<li class:you={!line.name && line.id === room.me} class:private={!!line.to}>
<em>{line.keeper ? `${line.name}, the keeper` : line.name ? `${line.name} (gallery)` : line.id === room.me ? 'You' : room.nameOf(line.id)}{#if line.to}<span class="to">, to {line.to.map((id) => (id === room.me ? 'you' : room.nameOf(id))).join(', ')} alone</span>{/if}</em>
{line.text}
</li>
{/each}
</ul>
{#if room.spectating && room.view.galleryTalk}
<form onsubmit={sayFromGallery}>
<input type="text" class="name" bind:value={galleryName} maxlength={NAME_MAX} placeholder="Your name" aria-label="Your name in the gallery" autocomplete="nickname" />
<input type="text" bind:value={draft} maxlength="300" placeholder="Say something from the gallery" aria-label="Say something from the gallery" autocomplete="off" />
<button type="submit" class="quiet" disabled={!draft.trim() || !galleryName.trim() || sending}>Say</button>
</form>
{:else if room.spectating}
<p class="muted small note">The host has not let the gallery talk at this table.</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" />
<button type="submit" class="quiet" disabled={!draft.trim() || sending}>Say</button>
</form>
{/if}
</section>
<style>
.talk {
padding: 0.75rem 0;
border-top: 1px solid var(--rule);
}
h2 {
font-size: 1.1rem;
font-style: italic;
margin-bottom: 0.4rem;
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 0.75rem;
}
.audience {
font-size: 0.8rem;
font-style: normal;
color: var(--bone-faint);
}
ul {
list-style: none;
margin: 0;
padding: 0;
max-height: 12rem;
overflow-y: auto;
font-size: 0.9rem;
line-height: 1.4;
}
li + li {
margin-top: 0.2rem;
}
li em {
color: var(--bone-dim);
}
li.you em {
color: var(--frost);
}
li.private {
font-style: italic;
}
form {
display: flex;
gap: 0.4rem;
margin-top: 0.5rem;
}
input {
flex: 1;
min-width: 0;
}
input.name {
flex: 0 1 8rem;
}
.note {
margin-top: 0.4rem;
}
</style>
+87
View File
@@ -0,0 +1,87 @@
<script lang="ts" module>
import type { Snippet } from 'svelte';
/** One tab and what it shows. */
export interface Tab {
id: string;
label: string;
body: Snippet;
}
</script>
<script lang="ts">
// A strip of tabs over one panel, for a dialog that would otherwise be one
// long read. Arrow keys walk the tabs, Home and End go to the ends.
import { tick } from 'svelte';
let { tabs, label, current = $bindable('') }: { tabs: Tab[]; label: string; current?: string } = $props();
const uid = $props.id();
const shown = $derived(tabs.find((t) => t.id === current) ?? tabs[0]);
let tabButtons: HTMLButtonElement[] = $state([]);
async function onKey(e: KeyboardEvent, i: number) {
const to = e.key === 'ArrowRight' ? i + 1 : e.key === 'ArrowLeft' ? i - 1 : e.key === 'Home' ? 0 : e.key === 'End' ? tabs.length - 1 : null;
if (to === null) return;
e.preventDefault();
const next = (to + tabs.length) % tabs.length;
current = tabs[next].id;
await tick();
tabButtons[next]?.focus();
}
</script>
<div class="tabs" role="tablist" aria-label={label}>
{#each tabs as t, i (t.id)}
<button
type="button"
role="tab"
id="{uid}-{t.id}"
aria-controls="{uid}-panel"
aria-selected={shown.id === t.id}
tabindex={shown.id === t.id ? 0 : -1}
class:on={shown.id === t.id}
bind:this={tabButtons[i]}
onclick={() => (current = t.id)}
onkeydown={(e) => onKey(e, i)}>{t.label}</button
>
{/each}
</div>
<div class="panel" id="{uid}-panel" role="tabpanel" aria-labelledby="{uid}-{shown.id}" tabindex="0">
{@render shown.body()}
</div>
<style>
/* Small capitals in a row that may wrap, the open tab boxed: a second row reads as more buttons, not a broken line. */
.tabs {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--rule-strong);
margin: 0.6rem 0 0.8rem;
}
.tabs button {
font-family: var(--font-ui);
font-size: 0.74rem;
letter-spacing: 0.1em;
text-transform: uppercase;
background: none;
border: 1.5px solid transparent;
border-radius: 3px;
color: var(--bone-dim);
padding: 0.3rem 0.6rem;
cursor: pointer;
}
.tabs button:hover {
color: var(--bone);
}
.tabs button.on {
border-color: var(--bone);
color: var(--bone);
background: var(--slate-deep);
}
.panel :global(p + p) {
margin-top: 0.7rem;
}
</style>
+94
View File
@@ -0,0 +1,94 @@
<script lang="ts" module>
/** One count told: the number, and what it counts. */
export interface TallyLine {
n: number | string;
label: string;
}
</script>
<script lang="ts">
// What the ledgers add up to: a few counts that are the game's own story,
// large, on cards; then the rest in a column of figures; then the fine
// print. Each game chooses its counts and words.
let { headline, details, note, title }: { headline: TallyLine[]; details: TallyLine[]; note?: string; title: string } =
$props();
const shown = (n: number | string) => (typeof n === 'number' ? n.toLocaleString() : n);
</script>
<h3>{title}</h3>
<div class="stories">
{#each headline as s (s.label)}
<div class="story"><span class="big">{shown(s.n)}</span><span>{s.label}</span></div>
{/each}
</div>
{#if details.length}
<h3>The ledgers in detail</h3>
<dl class="ledger">
{#each details as d (d.label)}
<dt>{shown(d.n)}</dt><dd>{d.label}</dd>
{/each}
</dl>
{/if}
{#if note}<p class="note">{note}</p>{/if}
<style>
h3 {
font-size: 0.8rem;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--bone);
border-bottom: 1px solid var(--rule-strong);
padding-bottom: 0.25rem;
margin: 0.2rem 0 0.7rem;
}
.stories {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(8.5rem, 1fr));
gap: 0.7rem;
margin: 0 0 1.2rem;
}
.story {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 0.2rem;
background: var(--slate-deep);
border: 1px solid var(--rule-strong);
border-radius: 4px;
padding: 0.7rem 0.5rem 0.6rem;
font-size: 0.9rem;
color: var(--bone-dim);
}
.big {
font-size: 1.9rem;
font-weight: 700;
line-height: 1.05;
color: var(--ember);
font-variant-numeric: tabular-nums;
}
.ledger {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.35rem 0.8rem;
margin: 0 0 1rem;
}
.ledger dt {
font-weight: 700;
color: var(--ember);
text-align: right;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.ledger dd {
margin: 0;
color: var(--bone-dim);
}
.note {
font-size: 0.85rem;
font-style: italic;
color: var(--bone-faint);
}
</style>
+32
View File
@@ -0,0 +1,32 @@
// The other games of Kestrel's Hall, from the family list the kit keeps
// (family.json, one copy shipped with every game): the hall's front door, and
// the next table for a player who liked this one. This game is left out of its
// own list.
export interface FamilyGame {
slug: string;
name: string;
url: string;
pitch: string;
players: string;
origin: string;
}
/** The family list: the hall's front door and every game but this one. */
export interface Family {
hall: string;
games: FamilyGame[];
}
const NONE: Family = { hall: '', games: [] };
export async function loadFamily(self: string): Promise<Family> {
try {
const res = await fetch('/family.json', { cache: 'no-cache' });
if (!res.ok) return NONE;
const data = (await res.json()) as { hall?: string; games?: FamilyGame[] };
return { hall: data.hall ?? '', games: (data.games ?? []).filter((g) => g.slug !== self) };
} catch {
return NONE;
}
}
+36
View File
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest';
import { CURRENT_RULES, createGame, game, resolveRound } from './index';
describe('High Card', () => {
it('scores the highest number named by exactly one seat', () => {
let s = createGame({ A: 'Ann', B: 'Bo', C: 'Cy' }, 1);
s = resolveRound(s, { A: { pick: 5 }, B: { pick: 5 }, C: { pick: 2 } });
expect(s.rounds[0].scorer).toBe('C');
expect(s.players.C.points).toBe(1);
});
it('ends when a seat reaches the target', () => {
let s = createGame({ A: 'Ann', B: 'Bo' }, 1);
for (let i = 0; i < 3; i++) s = resolveRound(s, { A: { pick: 3 }, B: { pick: 1 } });
expect(s.over?.winner).toBe('A');
expect(game.needsInput(s, 'A')).toBe(false);
});
it('draws the same bot picks from the same seed, and different ones as rounds pass', () => {
const a = createGame({ A: 'Ann', B: 'Bo' }, 42);
const b = createGame({ A: 'Ann', B: 'Bo' }, 42);
expect(game.botInput(a, 'B')).toEqual(game.botInput(b, 'B'));
const picks = new Set<number>();
let s = a;
for (let i = 0; i < 12; i++) {
picks.add(game.botInput(s, 'B').pick);
s = resolveRound(s, { A: { pick: 1 }, B: game.botInput(s, 'B') });
}
expect(picks.size).toBeGreaterThan(1);
});
it('stamps the rules revision', () => {
expect(createGame({ A: 'Ann', B: 'Bo' }).rules).toBe(CURRENT_RULES);
expect(createGame({ A: 'Ann', B: 'Bo' }, 1, 1).rules).toBe(1);
});
});
+94
View File
@@ -0,0 +1,94 @@
// The demo game, "High Card": every round each seat names a number from one
// to five; the highest number named by exactly one seat scores it a point,
// and the first to three points wins. It exists to show the shape of a game
// this kit can run: simultaneous hidden moves, a seeded bot, a view that
// withholds the round in progress, and a rules revision. Replace this file
// with your own game and keep the GameSpec shape.
import { SPECTATOR, type GameSpec, type Outcome, type SeatId, type TableOptions } from './spec';
export const CURRENT_RULES = 1;
export const TARGET = 3;
export const HIGHEST = 5;
export interface Player {
id: SeatId;
name: string;
points: number;
}
export interface Round {
picks: Record<SeatId, number>;
scorer: SeatId | null;
}
export interface State {
rules: number;
seats: SeatId[];
players: Record<SeatId, Player>;
rounds: Round[];
over: Outcome | null;
rng: number;
}
export type Input = { pick: number };
/** Mulberry32 on a seed: a small generator, so a game replays to the same bot picks. */
function random(seed: number): number {
let t = (seed + 0x6d2b79f5) >>> 0;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
}
export function createGame(names: Record<SeatId, string>, seed = Date.now(), rules = CURRENT_RULES, _options?: TableOptions): State {
const seats = Object.keys(names);
return {
rules,
seats,
players: Object.fromEntries(seats.map((id) => [id, { id, name: names[id], points: 0 }])),
rounds: [],
over: null,
rng: seed >>> 0 || 1
};
}
export function resolveRound(previous: State, inputs: Record<SeatId, Input>): State {
const state = structuredClone(previous);
if (state.over) return state;
const picks: Record<SeatId, number> = {};
for (const id of state.seats) picks[id] = inputs[id]?.pick ?? 1;
const counts = new Map<number, SeatId[]>();
for (const id of state.seats) counts.set(picks[id], [...(counts.get(picks[id]) ?? []), id]);
let scorer: SeatId | null = null;
for (let n = HIGHEST; n >= 1 && !scorer; n--) {
const who = counts.get(n);
if (who?.length === 1) scorer = who[0];
}
if (scorer) state.players[scorer].points += 1;
state.rounds.push({ picks, scorer });
if (scorer && state.players[scorer].points >= TARGET) {
state.over = { winner: scorer, reason: `${state.players[scorer].name} lays down the last card and sweeps the table: ${TARGET} points, and the game.` };
}
return state;
}
export const game: GameSpec<State, Input> = {
seatIds: 'ABCDEFGH'.split(''),
minSeats: 2,
botNames: ['Aldric', 'Morwenna', 'Thessaly', 'Gandric', 'Ysolde', 'Ormund', 'Corwin', 'Isaura'],
currentRules: CURRENT_RULES,
create: createGame,
resolve: resolveRound,
needsInput: (state) => !state.over,
over: (state) => state.over,
turn: (state) => state.rounds.length + 1,
// Every finished round is public; nothing is hidden, so the gallery sees what a seat sees.
view: (state, viewer) => (viewer === SPECTATOR ? structuredClone(state) : structuredClone(state)),
// A function of the state alone: the seed, the round and the seat, so a replay draws the same pick.
botInput: (state, seat) => ({ pick: 1 + Math.floor(random(state.rng + state.rounds.length * 7919 + state.seats.indexOf(seat)) * HIGHEST) }),
cleanInput: (raw) => ({ pick: Number((raw as { pick?: unknown })?.pick) }),
validate: (_state, _seat, input) =>
Number.isInteger(input.pick) && input.pick >= 1 && input.pick <= HIGHEST ? null : `Name a number from 1 to ${HIGHEST}.`,
nameOf: (state, seat) => state.players[seat]?.name ?? seat
};
+22
View File
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest';
import { cleanOptions, tableSize, type TableOption } from './spec';
const options: TableOption[] = [
{ key: 'length', label: 'Length', choices: [{ value: 'short', label: 'Short' }, { value: 'long', label: 'Long' }], default: 'short' }
];
describe('table options', () => {
it('keeps only declared options and offered choices, the rest at their defaults', () => {
expect(cleanOptions({ options }, { length: 'long', colour: 'red' })).toEqual({ length: 'long' });
expect(cleanOptions({ options }, { length: 'endless' })).toEqual({ length: 'short' });
expect(cleanOptions({ options }, null)).toEqual({ length: 'short' });
});
it('seats as many as the options allow, never more than the seats there are', () => {
const seatIds = ['A', 'B', 'C', 'D'];
expect(tableSize({ seatIds, options })).toBe(4);
const seatsFor = (o: Record<string, string>) => (o.length === 'short' ? 2 : 9);
expect(tableSize({ seatIds, options, seatsFor }, { length: 'short' })).toBe(2);
expect(tableSize({ seatIds, options, seatsFor }, { length: 'long' })).toBe(4);
});
});
+165
View File
@@ -0,0 +1,165 @@
// The contract between a game and everything else in this kit. The server,
// the client store and the hall know nothing about the game beyond this
// file: they create a state from names and a seed, feed it one round of
// inputs at a time, ask who still has to move, and hand each viewer the
// view they are allowed to see. A new game implements GameSpec once, in
// src/lib/game/index.ts, and the rest of the kit works unchanged.
//
// The shape is simultaneous rounds: every seat that needs input submits, then
// the round resolves. Turn-at-a-time games fit too (only one seat needs input
// at once). A game of single commands with out-of-turn interruptions, like
// Wiz-War, does not fit this contract and needs its own server.
/** A seat at the table: a single letter from GameSpec.seatIds. */
export type SeatId = string;
/**
* A line of table talk as a game reads it: who, what, when, the round it was
* said in, to whom (absent: everyone), and a bot's note on its own line.
*/
export interface PressLine {
id: SeatId;
text: string;
at: number;
/** The seats a private line was said to. */
to?: SeatId[];
/** The round the line was said in, from one; absent before the game begins. */
turn?: number;
/** What a bot noted about its own line; kept in the ledger, never shown. */
note?: unknown;
}
/** A bot's line of talk: to the people it names, with a note for its own memory. */
export interface BotLine {
to: SeatId[];
text: string;
note?: unknown;
}
/** The viewer with no seat: the Peanut Gallery. Views built for it show only what every seat could see. */
export const SPECTATOR: SeatId = '';
export interface Outcome {
winner: SeatId | null;
reason: string;
}
/** A choice the host makes when opening a table: a variant, a side, a length. */
export interface TableOption {
key: string;
label: string;
/** A choice's note is shown under the selector while it is chosen: the one line a player needs to pick. */
choices: { value: string; label: string; note?: string }[];
/** The value a table gets when the host chooses nothing. */
default: string;
}
/** 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[];
/** The most seats a table with these options takes, when an option makes it fewer than `seatIds`. */
seatsFor?(options: TableOptions): number;
/** 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. */
preferences?: Preference[];
minSeats: number;
/** Names the server draws for bots, in preference order. */
botNames: string[];
/**
* The rules revision new games begin under. A game keeps the revision it
* started with, recorded on its ledger, so a later fix can keep the old
* path for old ledgers behind `state.rules < N`. Bump only when the deploy
* gate shows a fix changes how an already-played turn resolves.
*/
currentRules: number;
/**
* Begin the game. `rankings` are the seats' preferences, best first, at a
* table that asks for them (`rankable`); the ledger's start line keeps them.
*/
create(names: Record<SeatId, string>, seed: number, rules: number, options?: TableOptions, rankings?: Record<SeatId, string[]>): State;
/**
* Whether a table with these options asks each seat to rank something
* before the deal (sides, roles, colours): the choices to rank, or null.
* Each seat ranks in secret; `create` reads every ranking at once.
*/
rankable?(options: TableOptions): { value: string; label: string }[] | null;
/** Resolve one round. Must be a pure function of its arguments: the ledger is replayed through it. */
resolve(state: State, inputs: Record<SeatId, Input>): State;
/** Whether this seat's input is needed before the next resolution. */
needsInput(state: State, seat: SeatId): boolean;
/**
* Whether a submitted input is final. A game that lets a player save a
* draft and change it answers false until the player says it is done;
* the round waits for every seat's settled input. Absent, every input is final.
*/
settled?(input: Input): boolean;
over(state: State): Outcome | null;
/** The round being written, counted from one, for the ledger and the report pin. */
turn(state: State): number;
/** What one viewer may see. The server sends nothing else. */
view(state: State, viewer: SeatId): State;
/**
* A bot's move. `press` is the talk its seat has heard (the table's, and
* private lines to it or from it, notes included), for a game whose bots
* keep what was agreed; a function of the state and the press, which the
* turn line records, so a replay never asks again.
*/
botInput(state: State, seat: SeatId, press?: PressLine[]): Input;
/**
* What a bot says: when a round opens ('phase'), and when a person has
* just written to it, or said a first word to the table ('spoken').
* `press` is what its seat has heard; `people` the seats played by people.
* Each line goes on the ledger, to the people it names and no one else.
* Absent, bots say nothing and a private line to one is refused.
*/
botPress?(state: State, seat: SeatId, press: PressLine[], people: SeatId[], trigger: 'phase' | 'spoken'): BotLine[];
/** The bots' last words to `viewer` once the game is over, read from the whole of the talk. */
parting?(press: PressLine[], viewer: SeatId): { id: SeatId; text: string }[];
/** Only the shapes the engine understands get through; the engine validates the rest. */
cleanInput(raw: unknown): Input;
/** Why this seat may not make this move now, or null when it may. The server answers with the reason. */
validate?(state: State, seat: SeatId, input: Input): string | null;
/** A seat's name from the state, for the hall and the chronicle. */
nameOf(state: State, seat: SeatId): string;
/** The game's own lines for the hall's tally, counted from a finished game: a label and how many it adds. */
tally?(state: State): Record<string, number>;
/** The game's own lines counted from every game begun, finished or not, by its table's options (games begun on each variant). */
tallyBegun?(options: TableOptions): Record<string, number>;
}
/**
* A table's options as the game will play them: only the options it
* declares, only the choices it offers, the rest at their defaults. The
* server, the tally and the ledger replay all read a room line through this,
* so each creates the same game from the same ledger.
*/
export function cleanOptions(game: Pick<GameSpec<unknown, unknown>, 'options'>, raw: unknown): TableOptions {
const given = (typeof raw === 'object' && raw !== null ? raw : {}) as Record<string, unknown>;
const options: TableOptions = {};
for (const o of game.options ?? []) {
const v = given[o.key];
options[o.key] = typeof v === 'string' && o.choices.some((c) => c.value === v) ? v : o.default;
}
return options;
}
/** The most seats a table with these options takes. */
export function tableSize(game: Pick<GameSpec<unknown, unknown>, 'options' | 'seatIds' | 'seatsFor'>, raw?: unknown): number {
return Math.min(game.seatIds.length, game.seatsFor?.(cleanOptions(game, raw)) ?? game.seatIds.length);
}
+198
View File
@@ -0,0 +1,198 @@
// The browser's side of the game server: a few JSON calls, and the seats this
// browser holds, remembered so a shared link never has to carry a token.
import type { SeatId, TableOptions } from '$lib/game/spec';
import type { Report, RoomView, Tally } from './view';
export interface HeldSeat {
seat: SeatId;
token: string;
}
const SEATS_KEY = 'wyrm:seats';
function heldSeats(): Record<string, HeldSeat> {
try {
return JSON.parse(localStorage.getItem(SEATS_KEY) ?? '{}') as Record<string, HeldSeat>;
} catch {
return {};
}
}
/** A seat the keeper sent in a link, `#seat=<seat>.<token>`: a fragment stays in the browser, out of every log. */
export function seatInLink(hash: string): HeldSeat | null {
const m = /^#seat=([^.]+)\.([A-Za-z0-9_-]+)$/.exec(hash);
return m ? { seat: m[1], token: m[2] } : null;
}
export function seatFor(roomId: string): HeldSeat | null {
return heldSeats()[roomId] ?? null;
}
/** Every seat this browser holds, newest last. */
export function allSeats(): { roomId: string; seat: SeatId; token: string }[] {
return Object.entries(heldSeats()).map(([roomId, h]) => ({ roomId, ...h }));
}
export function forgetSeat(roomId: string): void {
try {
const seats = heldSeats();
delete seats[roomId];
localStorage.setItem(SEATS_KEY, JSON.stringify(seats));
} catch {
// Nothing to forget without storage.
}
}
/**
* 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.
*/
const DOUBT_KEY = 'wyrm:doubted';
function doubted(): Record<string, true> {
try {
return JSON.parse(localStorage.getItem(DOUBT_KEY) ?? '{}') as Record<string, true>;
} catch {
return {};
}
}
/** Note a refusal; true when it is the second in a row and the seat is forgotten. */
export function doubtSeat(roomId: string): boolean {
const all = doubted();
if (all[roomId]) {
forgetSeat(roomId);
delete all[roomId];
try {
localStorage.setItem(DOUBT_KEY, JSON.stringify(all));
} catch {
// Nothing to note without storage.
}
return true;
}
try {
localStorage.setItem(DOUBT_KEY, JSON.stringify({ ...all, [roomId]: true }));
} catch {
// Nothing to note without storage.
}
return false;
}
/** The server answered for this seat: any doubt is cleared. */
export function trustSeat(roomId: string): void {
const all = doubted();
if (!all[roomId]) return;
delete all[roomId];
try {
localStorage.setItem(DOUBT_KEY, JSON.stringify(all));
} catch {
// Nothing to clear without storage.
}
}
export function rememberSeat(roomId: string, held: HeldSeat): void {
try {
localStorage.setItem(SEATS_KEY, JSON.stringify({ ...heldSeats(), [roomId]: held }));
} catch {
// Without storage the seat lasts for this page only.
}
}
export class ServerError extends Error {
constructor(
message: string,
public status: number
) {
super(message);
}
}
/** 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;
}
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>() {
type Seated = { seat: SeatId; token: string; view: RoomView<State> };
type View = RoomView<State>;
return {
create: (name: string, size: number, options?: TableOptions) => call<Seated>('POST', '/api/rooms', { name, size, options }),
join: (roomId: string, name: string) => call<Seated>('POST', `/api/rooms/${roomId}/join`, { name }),
addBot: (roomId: string, token: string) => call<View>('POST', `/api/rooms/${roomId}/bot`, { token }),
begin: (roomId: string, token: string) => call<View>('POST', `/api/rooms/${roomId}/begin`, { token }),
unseat: (roomId: string, token: string, seat: SeatId) => call<View>('POST', `/api/rooms/${roomId}/unseat`, { token, seat }),
view: (roomId: string, token: string) => call<View>('GET', `/api/rooms/${roomId}?token=${encodeURIComponent(token)}`),
/** The gallery's view: no token, no seat, nothing any player could not see. */
watch: (roomId: string) => call<View>('GET', `/api/rooms/${roomId}`),
turn: (roomId: string, token: string, input: Input, resolved: number) => call<View>('POST', `/api/rooms/${roomId}/turn`, { token, input, resolved }),
hurry: (roomId: string, token: string) => call<View>('POST', `/api/rooms/${roomId}/hurry`, { token }),
rank: (roomId: string, token: string, ranking: string[]) => call<View>('POST', `/api/rooms/${roomId}/rank`, { token, ranking }),
say: (roomId: string, token: string, text: string, to?: SeatId[]) => call<View>('POST', `/api/rooms/${roomId}/say`, { token, text, ...(to?.length ? { to } : {}) }),
/** A word from the gallery, signed with a name, when the host allows it. */
sayFromGallery: (roomId: string, name: string, text: string) => call<View>('POST', `/api/rooms/${roomId}/say`, { name, text }),
setGalleryTalk: (roomId: string, token: string, on: boolean) => call<View>('POST', `/api/rooms/${roomId}/gallery`, { token, on }),
challenge: (roomId: string, token: string) => call<View>('POST', `/api/rooms/${roomId}/challenge`, { token }),
rematch: (roomId: string, token: string) =>
call<{ roomId: string; seat: SeatId | null; token: string | null; created: boolean }>('POST', `/api/rooms/${roomId}/rematch`, { token }),
transfer: (roomId: string, token: string) => call<{ phrase: string; expiresAt: number }>('POST', `/api/rooms/${roomId}/transfer`, { token }),
/** The host's phrase for another person's seat; claimed, it re-seats them. */
hostPhrase: (roomId: string, token: string, seat: SeatId) => call<{ phrase: string; expiresAt: number }>('POST', `/api/rooms/${roomId}/phrase`, { token, seat }),
standIn: (roomId: string, token: string, seat: SeatId) => call<View>('POST', `/api/rooms/${roomId}/standin`, { token, seat }),
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: (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 })
};
}
/** A socket that only ever says "fetch the view again"; reconnects on its own. An empty token sits in the gallery. */
export function watchRoom(roomId: string, token: string, onUpdate: () => void, onStatus: (open: boolean) => void): () => void {
let socket: WebSocket | null = null;
let closed = false;
let delay = 1000;
const open = () => {
if (closed) return;
const protocol = location.protocol === 'https:' ? 'wss' : 'ws';
const seat = token ? `&token=${encodeURIComponent(token)}` : '';
socket = new WebSocket(`${protocol}://${location.host}/ws?room=${encodeURIComponent(roomId)}${seat}`);
socket.onopen = () => {
delay = 1000;
onStatus(true);
onUpdate();
};
socket.onmessage = (e) => {
try {
if (JSON.parse(String(e.data)).type === 'update') onUpdate();
} catch {
// Not a message this client understands.
}
};
socket.onclose = () => {
onStatus(false);
if (!closed) setTimeout(open, delay);
delay = Math.min(delay * 2, 30000);
};
};
open();
return () => {
closed = true;
socket?.close();
};
}
+215
View File
@@ -0,0 +1,215 @@
// 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.
import { game } from '$lib/game';
import { SPECTATOR, type SeatId, type TableOptions } from '$lib/game/spec';
import type { Input, State } from '$lib/game';
import { makeApi, rememberSeat, ServerError, watchRoom, type HeldSeat } from './client';
import { markTalkSeen } from './talk';
import type { RoomView } from './view';
export const api = makeApi<State, Input>();
export const NAME_MAX = 24;
const NAME_KEY = 'wyrm:name';
export function playerName(): string {
try {
return localStorage.getItem(NAME_KEY) ?? '';
} catch {
return '';
}
}
export function rememberName(raw: string): void {
const name = raw.trim().slice(0, NAME_MAX);
try {
if (name) localStorage.setItem(NAME_KEY, name);
else localStorage.removeItem(NAME_KEY);
} catch {
// The name still applies to this visit.
}
}
/** "Ysolde", "Ysolde and Grey", "Ysolde, Grey and Ormund". */
function listText(names: string[]): string {
if (names.length <= 1) return names[0] ?? '';
return `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`;
}
export class Room {
view = $state<RoomView<State>>(null!);
token: string;
connected = $state(false);
error = $state('');
/** A move is on its way to the server. */
sending = $state(false);
/** The server stopped taking this seat's token while the page was open, and why; the page goes to the gallery. */
lost = $state('');
constructor(view: RoomView<State>, token: string) {
this.view = view;
this.token = token;
}
me = $derived(this.view.me);
roomId = $derived(this.view.roomId);
/** Watching from the gallery, with no seat at the table. */
spectating = $derived(this.view.me === SPECTATOR);
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));
/** 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));
/** 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);
}
apply(view: RoomView<State>): void {
this.view = view;
markTalkSeen(view.roomId, view.chat.length);
}
/** Fetch the view now, and keep fetching whenever the server says something changed. */
connect(): () => void {
const fetchView = () => (this.spectating ? api.watch(this.roomId) : api.view(this.roomId, this.token));
const refresh = () => {
fetchView()
.then((v) => this.apply(v))
.catch((e: Error) => {
if (e instanceof ServerError && e.status === 403 && !this.spectating) this.lost = e.message;
else this.error = e.message;
});
};
return watchRoom(this.roomId, this.token, refresh, (open) => (this.connected = open));
}
private async act(fn: () => Promise<RoomView<State>>, failure: string): Promise<boolean> {
this.error = '';
try {
this.apply(await fn());
return true;
} catch (e) {
this.error = e instanceof Error ? e.message : failure;
return false;
}
}
begin(): Promise<boolean> {
return this.act(() => api.begin(this.roomId, this.token), 'The game could not begin.');
}
addBot(): Promise<boolean> {
return this.act(() => api.addBot(this.roomId, this.token), 'The bot could not be seated.');
}
/** Send a bot away before the game begins; only the host may. */
unseat(seat: SeatId): Promise<boolean> {
return this.act(() => api.unseat(this.roomId, this.token, seat), 'The bot could not be sent away.');
}
/** Out of the game, with the bots playing on: play the rest at once. */
hurry(): Promise<boolean> {
return this.act(() => api.hurry(this.roomId, this.token), 'The table did not hear you.');
}
/** This seat's ranking of the table's choices, best first, before the game begins. */
rank(ranking: string[]): Promise<boolean> {
return this.act(() => api.rank(this.roomId, this.token, ranking), 'Your ranking was not kept.');
}
/** Talk to the whole table, or, naming seats, to them alone. */
say(text: string, to?: SeatId[]): Promise<boolean> {
if (this.spectating) return Promise.resolve(false);
return this.act(() => api.say(this.roomId, this.token, text, to), 'The table did not hear you.');
}
/** A word from the gallery, signed; only when the host has let the gallery talk. */
sayFromGallery(name: string, text: string): Promise<boolean> {
if (!this.spectating) return Promise.resolve(false);
return this.act(() => api.sayFromGallery(this.roomId, name, text), 'The table did not hear you.');
}
/** Host only: let the Peanut Gallery talk at this table, or hush it. */
setGalleryTalk(on: boolean): Promise<boolean> {
return this.act(() => api.setGalleryTalk(this.roomId, this.token, on), 'The gallery could not be told.');
}
/** Call the keeper of the site to a held seat; their phone rings. */
callKeeper(): Promise<boolean> {
return this.act(() => api.challenge(this.roomId, this.token), 'The keeper could not be called.');
}
/** A finished table opens another with the same company; the caller is seated as its host. */
async rematch(): Promise<string> {
const next = await api.rematch(this.roomId, this.token);
if (next.created && next.seat && next.token) rememberSeat(next.roomId, { seat: next.seat, token: next.token });
return next.roomId;
}
/** A phrase that brings this seat to another device, good for ten minutes. */
transfer(): Promise<{ phrase: string; expiresAt: number }> {
return api.transfer(this.roomId, this.token);
}
/** Host only: a phrase for another person's seat, which gives it a new token when claimed. */
hostPhrase(seat: SeatId): Promise<{ phrase: string; expiresAt: number }> {
return api.hostPhrase(this.roomId, this.token, seat);
}
/** Host only: a bot plays this person's seat for the rest of the game, until a phrase gives it back. */
standIn(seat: SeatId): Promise<boolean> {
return this.act(() => api.standIn(this.roomId, this.token, seat), 'The seat could not be given to a bot.');
}
async submit(input: Input): Promise<boolean> {
this.sending = true;
try {
return await this.act(() => api.turn(this.roomId, this.token, input, this.view.resolved), 'The move did not reach the server.');
} finally {
this.sending = false;
}
}
/** Open a table on the server, seated as its host; it begins when the host says. */
static async create(name: string, size: number, options?: TableOptions): Promise<Room> {
const seated = await api.create(name, size, options);
rememberSeat(seated.view.roomId, { seat: seated.seat, token: seated.token });
return new Room(seated.view, seated.token);
}
/** A room with a bot seated and the game begun, so a solo game is a ledger like any other. */
static async createWithBot(name: string, options?: TableOptions): Promise<Room> {
const seated = await api.create(name, 2, options);
rememberSeat(seated.view.roomId, { seat: seated.seat, token: seated.token });
const view = await api.addBot(seated.view.roomId, seated.token);
// A table that asks for rankings waits in the lobby for the player's; the rest begin at once.
if (game.rankable?.(view.options)) return new Room(view, seated.token);
return new Room(await api.begin(seated.view.roomId, seated.token), seated.token);
}
static async join(roomId: string, name: string): Promise<Room> {
const seated = await api.join(roomId, name);
rememberSeat(roomId, { seat: seated.seat, token: seated.token });
return new Room(seated.view, seated.token);
}
/** Return to a seat this browser already holds. */
static async resume(roomId: string, held: HeldSeat): Promise<Room> {
return new Room(await api.view(roomId, held.token), held.token);
}
/** Take a place in the Peanut Gallery: watch a table with no seat and no voice. */
static async watch(roomId: string): Promise<Room> {
return new Room(await api.watch(roomId), '');
}
}
+25
View File
@@ -0,0 +1,25 @@
// How much of each table's talk this browser has read, so the hall can say what is new.
const SEEN_KEY = 'wyrm:talk-seen';
function seen(): Record<string, number> {
try {
return JSON.parse(localStorage.getItem(SEEN_KEY) ?? '{}') as Record<string, number>;
} catch {
return {};
}
}
export function markTalkSeen(roomId: string, count: number): void {
try {
const all = seen();
if ((all[roomId] ?? 0) >= count) return;
localStorage.setItem(SEEN_KEY, JSON.stringify({ ...all, [roomId]: count }));
} catch {
// Without storage every line reads as new, which is the safe side.
}
}
export function unreadTalk(roomId: string, count: number): number {
return Math.max(0, count - (seen()[roomId] ?? 0));
}
+116
View File
@@ -0,0 +1,116 @@
// What the server tells a viewer about a room. The game's own state is
// whatever GameSpec.view returned for that viewer; nothing else leaves.
import type { Outcome, SeatId, TableOptions } from '../game/spec';
/** One line of table talk: from a seat, or from the Peanut Gallery when the host allows it (id SPECTATOR, signed with `name`). */
export interface ChatLine {
id: SeatId;
text: string;
at: number;
name?: string;
/** Said by the keeper of the site, through the keeper's door. */
keeper?: true;
/** A private line: the seats it was said to. Absent, the whole table and the gallery heard it. */
to?: SeatId[];
/** The round the line was said in, counted from one; absent before the game begins. */
turn?: number;
/** A bot's memory of the line (an offer made, an answer heard). Kept in the ledger; never sent. */
note?: unknown;
}
export interface RoomView<State> {
roomId: string;
seq: number;
/** The most seats the table will take. */
size: number;
/** The viewer's seat, or SPECTATOR for the gallery. */
me: SeatId;
/** The player who opened the table and may begin the game. */
host: SeatId;
/** Who sits where; `standIn` marks a person's seat the host gave to a bot, which plays it until they take it back. */
seats: { id: SeatId; name: string; bot: boolean; standIn?: true }[];
/** Seats that still have to move before the round resolves. */
waitingOn: SeatId[];
/** Rounds played so far; a move is sent with the count it was made against. */
resolved: number;
/** The round being written, from one; null before the game begins. */
turn: number | null;
over: Outcome | null;
state: State | null;
/** This seat's ranking of the table's choices, at a table that asks for one; nobody sees another's. */
yourRanking: string[] | null;
/** How many seats have ranked, and nothing more about them. */
ranked: number;
/** This seat's input for the round in progress, as the server holds it (a draft, or a settled move); null for none, and for the gallery. */
yourInput: unknown;
/** This seat's input in the last round it played, for telling it what became of its own choice; null for none, and for the gallery. */
yourLastInput: unknown;
chat: ChatLine[];
/** The bots' last words to this seat once the game is over, for a game that gives them some. */
parting: { id: SeatId; text: string }[];
/** The bots are playing on by themselves, a round at a time, with no person owing a move. */
playingOn: boolean;
/** How many watch from the Peanut Gallery. */
audience: number;
/** The host lets the gallery talk. */
galleryTalk: boolean;
/** The keeper of the site was called to a seat, and by whom. */
challenge: { by: SeatId; at: number } | null;
/** Names with a seat held who have not yet sat: the last table's players, or the keeper. */
expected: string[];
/** A finished table's rematch: where it went, and who called. */
rematch: { to: string; by: SeatId } | null;
/** The table this one is the rematch of. */
rematchOf: string | null;
/** The host's choices for this table, every declared option filled. */
options: TableOptions;
/** The keeper of the site, and where they live, for a table that calls them. */
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>;
}
+63
View File
@@ -0,0 +1,63 @@
// Preferences: what this browser remembers about how its player likes to
// play. The kit's own settings are motion and whether a move is confirmed
// before it is sent; a game declares more in GameSpec.preferences, and the
// panel renders every declaration. Nothing here leaves the browser.
import { game } from '$lib/game';
import type { Preference } from '$lib/game/spec';
/** The settings every game in the kit shares. */
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 }
];
/** Every table option is also a preference: the choice a new table starts with. The hall reads and writes the same keys. */
const TABLE_DEFAULTS: Preference[] = (game.options ?? []).map((o) => ({
key: `table.${o.key}`,
label: `${o.label}, for a new table`,
kind: 'choice',
choices: o.choices.map((c) => ({ value: c.value, label: c.label })),
default: o.default
}));
export const PREFERENCES: Preference[] = [...KIT_PREFERENCES, ...TABLE_DEFAULTS, ...(game.preferences ?? [])];
const KEY = 'wyrm:prefs';
function defaults(): Record<string, boolean | string> {
return Object.fromEntries(PREFERENCES.map((p) => [p.key, p.default]));
}
function load(): Record<string, boolean | string> {
const base = defaults();
try {
const saved = JSON.parse(localStorage.getItem(KEY) ?? '{}') as Record<string, unknown>;
for (const p of PREFERENCES) {
const v = saved[p.key];
if (p.kind === 'toggle' && typeof v === 'boolean') base[p.key] = v;
if (p.kind === 'choice' && typeof v === 'string' && p.choices?.some((c) => c.value === v)) base[p.key] = v;
}
} catch {
// Without storage the defaults apply for this visit.
}
return base;
}
/** The live preferences; read them where they matter, set them through setPref. */
export const prefs = $state<Record<string, boolean | string>>(load());
export function setPref(key: string, value: boolean | string): void {
prefs[key] = value;
try {
localStorage.setItem(KEY, JSON.stringify(prefs));
} catch {
// The change still applies for this visit.
}
}
/** The reduced-motion preference, or the system's, whichever asks for calm. */
export function reducedMotion(): boolean {
if (prefs.motion === 'reduced') return true;
return typeof matchMedia !== 'undefined' && matchMedia('(prefers-reduced-motion: reduce)').matches;
}