A notice the whole hall shows: hall-announce on the host, served by every game's site, shown at the top of each page
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
294b1d5460
commit
96ec6861d5
@@ -78,6 +78,21 @@ then the round resolves; turn-at-a-time games fit as the case where one
|
|||||||
seat needs input at a time. A game of single commands with out-of-turn
|
seat needs input at a time. A game of single commands with out-of-turn
|
||||||
interruptions, Wiz-War's shape, does not fit and keeps its own server.
|
interruptions, Wiz-War's shape, does not fit and keeps its own server.
|
||||||
|
|
||||||
|
## The hall-wide notice
|
||||||
|
|
||||||
|
Every game on the shared host shows the same notice at the top of its
|
||||||
|
pages when there is one: maintenance, a new game, anything the whole
|
||||||
|
family should hear. The keeper sets it on the host,
|
||||||
|
|
||||||
|
hall-announce "Kestrel's Hall is down for five minutes of maintenance" --at "2026-09-30 11:00 UTC"
|
||||||
|
hall-announce --clear
|
||||||
|
|
||||||
|
which writes /var/lib/hall/announcement.json; each game's site serves it at
|
||||||
|
`/hall/announcement.json` (a `handle_path` in its Caddy site file), and
|
||||||
|
`Announcement.svelte`, in the template's layout, shows it with the time in
|
||||||
|
each reader's own clock. A reader may dismiss one notice; the next still
|
||||||
|
shows. `hall-announce` is installed by `gamehall/deploy.sh`.
|
||||||
|
|
||||||
## The family
|
## The family
|
||||||
|
|
||||||
`family.json` lists every game of Kestrel's Hall: slug, name, address,
|
`family.json` lists every game of Kestrel's Hall: slug, name, address,
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ KIT="$(cd "$(dirname "$0")" && pwd)"
|
|||||||
MAP="$KIT/drift-map/$SLUG.txt"
|
MAP="$KIT/drift-map/$SLUG.txt"
|
||||||
SHARED="server/src/index.ts server/src/rooms.ts server/src/store.ts server/src/reports.ts server/src/ratelimit.ts server/src/tally.ts
|
SHARED="server/src/index.ts server/src/rooms.ts server/src/store.ts server/src/reports.ts server/src/ratelimit.ts server/src/tally.ts
|
||||||
src/lib/game/spec.ts src/lib/net/client.ts src/lib/net/talk.ts src/lib/net/view.ts src/lib/net/room.svelte.ts
|
src/lib/game/spec.ts src/lib/net/client.ts src/lib/net/talk.ts src/lib/net/view.ts src/lib/net/room.svelte.ts
|
||||||
src/lib/components/TableTalk.svelte src/lib/components/ReportSlip.svelte src/lib/components/TabbedDialog.svelte src/lib/components/Tabs.svelte
|
src/lib/components/TableTalk.svelte src/lib/components/ReportSlip.svelte src/lib/components/TabbedDialog.svelte src/lib/components/Tabs.svelte src/lib/components/Announcement.svelte
|
||||||
deploy/deploy.sh deploy/setup-droplet.sh deploy/setup-server.sh deploy/Caddyfile.tmpl deploy/verify-ledgers.sh
|
deploy/deploy.sh deploy/setup-droplet.sh deploy/setup-server.sh deploy/Caddyfile.tmpl deploy/verify-ledgers.sh
|
||||||
deploy/replay-ledgers.ts deploy/pulse.sh deploy/visitors.sh deploy/sentry-slack-alert.sh deploy/pull-reports.sh deploy/report-reply.sh
|
deploy/replay-ledgers.ts deploy/pulse.sh deploy/visitors.sh deploy/sentry-slack-alert.sh deploy/pull-reports.sh deploy/report-reply.sh
|
||||||
deploy/__SLUG__.service deploy/__SLUG__.cron deploy/__SLUG__-backup.sh deploy/__SLUG__-rollup.sh"
|
deploy/__SLUG__.service deploy/__SLUG__.cron deploy/__SLUG__-backup.sh deploy/__SLUG__-rollup.sh"
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ ssh "root@$HOST" '
|
|||||||
install -m 755 /tmp/gamehall-ops/gamehall-rollup.sh /usr/local/bin/gamehall-rollup.sh
|
install -m 755 /tmp/gamehall-ops/gamehall-rollup.sh /usr/local/bin/gamehall-rollup.sh
|
||||||
install -m 644 -D /tmp/gamehall-ops/traffic.py /usr/local/lib/gamehall/traffic.py
|
install -m 644 -D /tmp/gamehall-ops/traffic.py /usr/local/lib/gamehall/traffic.py
|
||||||
install -m 644 /tmp/gamehall-ops/gamehall.cron /etc/cron.d/gamehall
|
install -m 644 /tmp/gamehall-ops/gamehall.cron /etc/cron.d/gamehall
|
||||||
|
# The hall-wide notice every game serves at /hall/announcement.json.
|
||||||
|
install -m 755 /tmp/gamehall-ops/hall-announce.sh /usr/local/bin/hall-announce
|
||||||
|
install -d -m 755 /var/lib/hall
|
||||||
mkdir -p /var/lib/gamehall /var/log/gamehall
|
mkdir -p /var/lib/gamehall /var/log/gamehall
|
||||||
rm -r /tmp/gamehall-ops
|
rm -r /tmp/gamehall-ops
|
||||||
'
|
'
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
# Kestrel's Hall, the front door of the family: a static page, no server.
|
# Kestrel's Hall, the front door of the family: a static page, no server.
|
||||||
gamehall.kestrelsnest.social, gamehall.159.203.115.252.sslip.io {
|
gamehall.kestrelsnest.social, gamehall.159.203.115.252.sslip.io {
|
||||||
|
# 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/gamehall
|
root * /opt/gamehall
|
||||||
encode gzip zstd
|
encode gzip zstd
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# The notice every game of Kestrel's Hall shows at the top of its pages.
|
||||||
|
# Each site serves /var/lib/hall/announcement.json at /hall/announcement.json;
|
||||||
|
# a page checks it on load and every few minutes. Run on the host as root:
|
||||||
|
# hall-announce "Kestrel's Hall is down for five minutes of maintenance" --at "2026-09-30 11:00 UTC" [--until "2026-09-30 11:15 UTC"]
|
||||||
|
# hall-announce --show
|
||||||
|
# hall-announce --clear
|
||||||
|
# --at is when it happens, told in each reader's own time; --until is when the
|
||||||
|
# notice stops showing (an hour after --at when not given).
|
||||||
|
set -euo pipefail
|
||||||
|
FILE=/var/lib/hall/announcement.json
|
||||||
|
|
||||||
|
case "${1:-}" in
|
||||||
|
--clear) rm -f "$FILE"; echo "cleared."; exit 0 ;;
|
||||||
|
--show) cat "$FILE" 2>/dev/null || echo "no announcement."; exit 0 ;;
|
||||||
|
''|-*) sed -n '2,9p' "$0" | sed 's/^# \{0,1\}//'; exit 2 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
TEXT="$1"; shift
|
||||||
|
AT=""; UNTIL=""
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--at) AT="$(date -u -d "$2" +%Y-%m-%dT%H:%M:%SZ)"; shift 2 ;;
|
||||||
|
--until) UNTIL="$(date -u -d "$2" +%Y-%m-%dT%H:%M:%SZ)"; shift 2 ;;
|
||||||
|
*) echo "unknown option: $1" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
[ -n "$AT" ] && [ -z "$UNTIL" ] && UNTIL="$(date -u -d "$AT + 1 hour" +%Y-%m-%dT%H:%M:%SZ)"
|
||||||
|
|
||||||
|
mkdir -p "$(dirname "$FILE")"
|
||||||
|
# A new id each time, so a reader who dismissed the last notice still sees this one.
|
||||||
|
TEXT="$TEXT" AT="$AT" UNTIL="$UNTIL" ID="$(date -u +%Y%m%dT%H%M%S)" python3 -c '
|
||||||
|
import json, os
|
||||||
|
a = {"id": os.environ["ID"], "text": os.environ["TEXT"]}
|
||||||
|
if os.environ["AT"]: a["at"] = os.environ["AT"]
|
||||||
|
if os.environ["UNTIL"]: a["until"] = os.environ["UNTIL"]
|
||||||
|
print(json.dumps(a))' > "$FILE.tmp"
|
||||||
|
chmod 644 "$FILE.tmp"
|
||||||
|
mv "$FILE.tmp" "$FILE"
|
||||||
|
cat "$FILE"
|
||||||
@@ -113,6 +113,9 @@
|
|||||||
|
|
||||||
footer { margin-top: 64px; padding: 28px 0 48px; border-top: 1px solid var(--line); color: var(--bone-faint); font-size: 0.9rem; }
|
footer { margin-top: 64px; padding: 28px 0 48px; border-top: 1px solid var(--line); color: var(--bone-faint); font-size: 0.9rem; }
|
||||||
footer p { margin: 0 0 8px; }
|
footer p { margin: 0 0 8px; }
|
||||||
|
/* The hall-wide notice: its own colors, 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: .8rem; padding: .45rem 2.4rem .45rem 1rem; background: #f2c45a; color: #1d1a12; font: 500 .95rem/1.35 system-ui, -apple-system, 'Segoe UI', sans-serif; text-align: center; box-shadow: 0 1px 4px #0005; }
|
||||||
|
.hall-announcement button { position: absolute; right: .5rem; background: none; border: 0; color: inherit; font-size: 1.3rem; line-height: 1; padding: .2rem .5rem; cursor: pointer; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -214,5 +217,50 @@
|
|||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
<script>
|
||||||
|
// The hall-wide notice every game of Kestrel's Hall shows (hall-announce on the
|
||||||
|
// host): checked on load, every few minutes, and when the page is looked at
|
||||||
|
// again; a reader may dismiss one, and the next still shows.
|
||||||
|
(() => {
|
||||||
|
const KEY = 'hall:dismissed';
|
||||||
|
let bar = null;
|
||||||
|
let current = null;
|
||||||
|
const read = () => { try { return localStorage.getItem(KEY); } catch { return null; } };
|
||||||
|
function when(at) {
|
||||||
|
const t = at ? Date.parse(at) : NaN;
|
||||||
|
if (!Number.isFinite(t)) return '';
|
||||||
|
const minutes = Math.round((t - Date.now()) / 60000);
|
||||||
|
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 show() {
|
||||||
|
const a = current;
|
||||||
|
const live = a && a.id !== read() && !(a.until && Date.parse(a.until) < Date.now());
|
||||||
|
if (!live) { bar?.remove(); bar = null; return; }
|
||||||
|
if (!bar) {
|
||||||
|
bar = document.createElement('div');
|
||||||
|
bar.className = 'hall-announcement';
|
||||||
|
bar.setAttribute('role', 'status');
|
||||||
|
bar.innerHTML = '<span></span><button type="button" aria-label="Dismiss this announcement">×</button>';
|
||||||
|
bar.querySelector('button').onclick = () => { try { localStorage.setItem(KEY, current.id); } catch {} show(); };
|
||||||
|
document.body.prepend(bar);
|
||||||
|
}
|
||||||
|
bar.querySelector('span').textContent = a.text + when(a.at);
|
||||||
|
}
|
||||||
|
async function check() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/hall/announcement.json', { cache: 'no-store' });
|
||||||
|
const a = res.ok ? await res.json() : null;
|
||||||
|
current = a && typeof a.text === 'string' && a.text && typeof a.id === 'string' ? a : null;
|
||||||
|
} catch {}
|
||||||
|
show();
|
||||||
|
}
|
||||||
|
check();
|
||||||
|
setInterval(check, 5 * 60000);
|
||||||
|
setInterval(show, 30000);
|
||||||
|
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') check(); });
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -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/__SLUG__/build
|
root * /opt/__SLUG__/build
|
||||||
encode gzip zstd
|
encode gzip zstd
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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()}
|
||||||
|
|||||||
Reference in New Issue
Block a user