Compare commits

...
4 Commits
Author SHA1 Message Date
Eric WagonerandClaude Opus 5.5 8a87d3527d Pages show the hall-wide notice, when there is one, at the top
The keeper's hall-announce writes one file on the shared host; the site
serves it at /hall/announcement.json, and the banner shows it with the time
in the reader's own clock, dismissable one notice at a time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-29 13:03:36 -04:00
Eric WagonerandClaude Opus 5.5 804be953c2 The tally's comment describes the tally, not where its look came from
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-28 14:47:01 -04:00
Eric WagonerandClaude Opus 5.5 43a5a2023b dev:all stops both sides when the server's port is taken
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-28 13:43:15 -04:00
Eric WagonerandClaude Opus 5.5 ab999c1c43 npm run dev:all starts the server and the site together
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-28 12:37:56 -04:00
7 changed files with 172 additions and 4 deletions

No files matched your search

+1 -1
View File
@@ -14,7 +14,7 @@ The full rules, as transcribed from the *Duel Purpose* fanzine, are in
```sh ```sh
npm install npm install
npm run dev npm run dev:all # the duel server and the site together; Ctrl-C stops both
``` ```
The hall at `/` offers two ways in: play the bot at `/play`, or duel a person. A duel between The hall at `/` offers two ways in: play the bot at `/play`, or duel a person. A duel between
+7
View File
@@ -1,4 +1,11 @@
__HOST__ { __HOST__ {
# The hall-wide notice (hall-announce on the host): one file, served by every game's site.
handle_path /hall/* {
root * /var/lib/hall
header Cache-Control "no-store"
file_server
}
root * /opt/waving-hands/build root * /opt/waving-hands/build
encode gzip zstd encode gzip zstd
+1
View File
@@ -5,6 +5,7 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
"dev:all": "node scripts/dev-all.mjs",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"prepare": "svelte-kit sync || echo ''", "prepare": "svelte-kit sync || echo ''",
+39
View File
@@ -0,0 +1,39 @@
// The game server and the site together, for playing locally: one terminal,
// each line marked with its side, and Ctrl-C (or either one stopping or
// failing to get its port) stops both.
import { spawn } from 'node:child_process';
const sides = [
['server', ['run', 'server']],
['site', ['run', 'dev', '--', ...process.argv.slice(2)]]
];
const children = sides.map(([name, args]) => {
const child = spawn('npm', args, { stdio: ['inherit', 'pipe', 'pipe'] });
for (const stream of [child.stdout, child.stderr]) {
let rest = '';
stream.on('data', (chunk) => {
const lines = (rest + chunk).split('\n');
rest = lines.pop();
for (const line of lines) {
console.log(`[${name}] ${line}`);
// A watcher outlives the crash it watches, so a port already taken would leave one side running alone.
if (line.includes('EADDRINUSE')) {
console.log(`[${name}] its port is taken: is another copy already running?`);
stop(1);
}
}
});
}
child.on('exit', (code) => stop(code ?? 0));
return child;
});
let stopping = false;
function stop(code) {
if (stopping) return;
stopping = true;
for (const c of children) if (c.exitCode === null) c.kill('SIGTERM');
process.exitCode = code;
}
process.on('SIGINT', () => stop(0));
process.on('SIGTERM', () => stop(0));
+119
View File
@@ -0,0 +1,119 @@
<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;
}
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 = a && typeof a === 'object' && typeof (a as Announcement).text === 'string' && (a as Announcement).text && typeof (a as Announcement).id === 'string' ? (a as Announcement) : 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() {
// Taken first: 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>
+3 -3
View File
@@ -7,9 +7,9 @@
</script> </script>
<script lang="ts"> <script lang="ts">
// What the ledgers add up to, told as Wiz-War tells it: a few counts that // What the ledgers add up to: a few counts that are the game's own story,
// are the game's own story, large, on cards; then the rest in a column of // large, on cards; then the rest in a column of figures; then the fine
// figures; then the fine print. Each game chooses its counts and words. // print. Each game chooses its counts and words.
let { headline, details, note, title = 'How much love the table is getting' }: { headline: TallyLine[]; details: TallyLine[]; note?: string; title?: string } = let { headline, details, note, title = 'How much love the table is getting' }: { headline: TallyLine[]; details: TallyLine[]; note?: string; title?: string } =
$props(); $props();
+2
View File
@@ -1,7 +1,9 @@
<script lang="ts"> <script lang="ts">
import '../app.css'; import '../app.css';
import Announcement from '$lib/components/Announcement.svelte';
let { children } = $props(); let { children } = $props();
</script> </script>
<Announcement />
{@render children()} {@render children()}