Compare commits

...
3 Commits
Author SHA1 Message Date
Eric WagonerandClaude Opus 5.5 a2d3841cfe A seat link replaces a held seat only when the server refuses the held token, not when the connection drops, so a link cannot take a working seat away on a bad connection
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MbtUPJ24P6vc6tE1x6RS3m
2026-10-13 18:32:02 -04:00
Eric WagonerandClaude Opus 5.5 a2bdf7a825 The keeper's door asks for a key only root and the game's user can read, so another service on the host cannot re-seat anyone; the deploy makes the key once and the reseat script sends it from a file descriptor; and a seat link is taken out of the address bar with the browser's own call, since the page reads it before the router starts
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MbtUPJ24P6vc6tE1x6RS3m
2026-10-13 18:28:28 -04:00
Eric WagonerandClaude Opus 5.5 8bbc41cc87 The keeper can bring back a seat a browser lost: a new token on the ledger, the old one refused, and a link that carries it in the fragment, minted by hnefatafl-reseat.sh through a /keeper door Caddy does not proxy and that turns away anything Caddy forwarded; the join page takes a linked seat only once the server accepts it and never over a seat this browser holds that still opens, a spectator at a game in play is told both ways back, the hall asks before forgetting a game in play, and the server's tests now run with the rest
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MbtUPJ24P6vc6tE1x6RS3m
2026-10-13 18:24:31 -04:00
12 changed files with 227 additions and 8 deletions

No files matched your search

+2
View File
@@ -53,6 +53,8 @@ A fresh host, when one is needed: `doctl compute droplet create hall --region ny
deploy, so the live copies are the repo copies: deploy, so the live copies are the repo copies:
- `hnefatafl-visitors.sh [day]`: who is here now and who came that day. - `hnefatafl-visitors.sh [day]`: who is here now and who came that day.
- `hnefatafl-reseat.sh ROOM [SEAT]`: who sits at a table; with a seat, a new
token for a player locked out of it, as a link to send them.
- `hnefatafl-pulse.sh`: the weekly health check (service, errors, rollup - `hnefatafl-pulse.sh`: the weekly health check (service, errors, rollup
trend, backup, box). trend, backup, box).
- `hnefatafl-rollup.sh [day]`: one JSON line per day of counts, run - `hnefatafl-rollup.sh [day]`: one JSON line per day of counts, run
+7
View File
@@ -42,6 +42,13 @@ ssh "root@$HOST" '
install -m 755 /opt/hnefatafl/app/deploy/hnefatafl-pulse.sh /usr/local/bin/hnefatafl-pulse.sh install -m 755 /opt/hnefatafl/app/deploy/hnefatafl-pulse.sh /usr/local/bin/hnefatafl-pulse.sh
install -m 755 /opt/hnefatafl/app/deploy/hnefatafl-rollup.sh /usr/local/bin/hnefatafl-rollup.sh install -m 755 /opt/hnefatafl/app/deploy/hnefatafl-rollup.sh /usr/local/bin/hnefatafl-rollup.sh
install -m 755 /opt/hnefatafl/app/deploy/hnefatafl-backup.sh /usr/local/bin/hnefatafl-backup.sh install -m 755 /opt/hnefatafl/app/deploy/hnefatafl-backup.sh /usr/local/bin/hnefatafl-backup.sh
install -m 755 /opt/hnefatafl/app/deploy/hnefatafl-reseat.sh /usr/local/bin/hnefatafl-reseat.sh
# The key to the keeper door: made once, readable by root (the reseat script) and the game user (the server) alone.
if [ ! -s /etc/hnefatafl/keeper.key ]; then
install -d -m 750 -o root -g hnefatafl /etc/hnefatafl
(umask 037; openssl rand -hex 32 > /etc/hnefatafl/keeper.key)
chown root:hnefatafl /etc/hnefatafl/keeper.key
fi
install -m 644 -D /opt/hnefatafl/app/deploy/traffic.py /usr/local/lib/hnefatafl/traffic.py install -m 644 -D /opt/hnefatafl/app/deploy/traffic.py /usr/local/lib/hnefatafl/traffic.py
# On a host the hall shares, its hall-nightly.sh runs the rollup and backup # On a host the hall shares, its hall-nightly.sh runs the rollup and backup
# of every game and checks in with Sentry once for all; a game on a box of # of every game and checks in with Sentry once for all; a game on a box of
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env bash
# A way back for a player whose browser lost their seat: the seat gets a new
# token, the old one stops working, and the link printed carries the new one.
# Whether the one asking holds that seat is the keeper's call; send the link
# only to them. Runs ON the droplet, through the game server's keeper door.
# hnefatafl-reseat.sh ROOM who sits at the table, and when each person last moved or spoke
# hnefatafl-reseat.sh ROOM SEAT re-seat SEAT and print the link
set -euo pipefail
ROOM="${1:?usage: hnefatafl-reseat.sh ROOM [SEAT]}"
DOOR="http://127.0.0.1:8789/keeper/rooms/${ROOM^^}"
[ -s /etc/hnefatafl/keeper.key ] || { echo "No keeper's key at /etc/hnefatafl/keeper.key; a deploy makes it." >&2; exit 1; }
# The key goes as a header read from a file descriptor, never on a command line another user could see.
KEY="@/dev/fd/3"
exec 3< <(printf 'x-keeper-key: %s\n' "$(cat /etc/hnefatafl/keeper.key)")
if [ $# -lt 2 ]; then
curl -s -H "$KEY" "$DOOR" | python3 -c '
import datetime, json, sys
r = json.load(sys.stdin)
if "error" in r: sys.exit(r["error"])
print("room %s, %s" % (r["roomId"], "in play" if r["started"] else "not begun"))
for s in r["seats"]:
when = "bot" if s["bot"] else "never moved or spoke" if s["lastActed"] is None else "last acted " + datetime.datetime.fromtimestamp(s["lastActed"] / 1000, datetime.timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
print(" %-3s %-24s %s" % (s["id"], s["name"], when))
'
else
curl -s -X POST -H "$KEY" -H 'content-type: application/json' -d "{\"seat\":\"$2\"}" "$DOOR/reseat" | python3 -c '
import json, sys
r = json.load(sys.stdin)
if "error" in r: sys.exit(r["error"])
print("%s has a new token; their old one opens nothing now. Send them this link:" % r["name"])
print(r["link"])
'
fi
+12
View File
@@ -37,6 +37,16 @@ README.
- **A seat travels by phrase**: *Transfer seat* in the masthead mints four - **A seat travels by phrase**: *Transfer seat* in the masthead mints four
words good for ten minutes and one use; the hall's *bring a seat from words good for ten minutes and one use; the hall's *bring a seat from
another device* claims them. The browser that claims holds the seat too. another device* claims them. The browser that claims holds the seat too.
- **The keeper can bring a seat back**: a player whose browser lost the
seat (Safari's seven days, an app's own browser, cleared data) writes to
the keeper, and `<slug>-reseat.sh ROOM SEAT` on the host gives the seat
a new token and prints a link carrying it in the fragment. The old token
opens nothing after. A link is kept only once the server takes it, and
never over a seat the browser holds that still opens. The script answers
through the game server's `/keeper` door, which Caddy does not proxy and
which asks for the key in `/etc/<slug>/keeper.key`, made by the deploy and
readable by root and the game's user alone. A spectator at a game in play
is told both ways back, and the hall asks before forgetting a game in play.
- **Held seats are doubted before they are dropped**: the hall forgets a - **Held seats are doubted before they are dropped**: the hall forgets a
seat only on the second refusal in a row from the server. A restart, a seat only on the second refusal in a row from the server. A restart, a
stale answer or the wrong backend answer with ignorance, and ignorance stale answer or the wrong backend answer with ignorance, and ignorance
@@ -123,6 +133,8 @@ Every game gets the same tools, installed by `deploy.sh` on every deploy:
- `<slug>-visitors.sh` (skill `<slug>-visitors`): who is here now, who came - `<slug>-visitors.sh` (skill `<slug>-visitors`): who is here now, who came
today, every room opened and how far it got. Names seated for the first today, every room opened and how far it got. Names seated for the first
time are marked NEW. time are marked NEW.
- `<slug>-reseat.sh ROOM [SEAT]`: who sits at a table and when each last
acted; with a seat, a new token for a player locked out, as a link.
- `<slug>-pulse.sh` (skill `<slug>-pulse`): service health, journal errors, - `<slug>-pulse.sh` (skill `<slug>-pulse`): service health, journal errors,
the rollup's last week, the backup's last word, the box's vitals. the rollup's last week, the backup's last word, the box's vitals.
- `<slug>-rollup.sh` nightly at 00:12 UTC: one JSON line a day of counts, - `<slug>-rollup.sh` nightly at 00:12 UTC: one JSON line a day of counts,
+40 -1
View File
@@ -23,10 +23,17 @@
// WS /ws?room=:id&token= {type:"update", seq} whenever the room changes; without a token, // WS /ws?room=:id&token= {type:"update", seq} whenever the room changes; without a token,
// a seat in the Peanut Gallery, counted for the table // a seat in the Peanut Gallery, counted for the table
// //
// Caddy serves the static site and proxies /api and /ws here. // Caddy serves the static site and proxies /api and /ws here. The keeper's
// door answers only on the host itself, and only to the key in KEEPER_KEY_FILE
// (deploy/hnefatafl-reseat.sh sends it):
//
// GET /keeper/rooms/:id who sits at the table, and when each person last moved or spoke
// POST /keeper/rooms/:id/reseat {seat} a new token for a player locked out, as a link to send them
import * as Sentry from '@sentry/node'; import * as Sentry from '@sentry/node';
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
import { timingSafeEqual } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { dirname } from 'node:path'; import { dirname } from 'node:path';
import { WebSocketServer, WebSocket } from 'ws'; import { WebSocketServer, WebSocket } from 'ws';
import { game } from '../../src/lib/game'; import { game } from '../../src/lib/game';
@@ -43,6 +50,8 @@ const PUBLIC_URL = (process.env.PUBLIC_URL ?? 'https://tafl.kestrelsnest.social'
/** The keeper of the site, whom a table may call to a seat, and where they sleep. */ /** The keeper of the site, whom a table may call to a seat, and where they sleep. */
const KEEPER = process.env.KEEPER ?? 'the keeper'; const KEEPER = process.env.KEEPER ?? 'the keeper';
const KEEPER_TZ = process.env.KEEPER_TZ ?? 'America/New_York'; const KEEPER_TZ = process.env.KEEPER_TZ ?? 'America/New_York';
/** The keeper's key, readable by root and this game's user alone, so another service on the host cannot use the keeper's door. Absent, the door stays shut. */
const KEEPER_KEY_FILE = process.env.KEEPER_KEY_FILE ?? '/etc/hnefatafl/keeper.key';
const BODY_LIMIT = 16 * 1024; const BODY_LIMIT = 16 * 1024;
// Errors go to Sentry when a DSN is set; the SDK drops them otherwise. // Errors go to Sentry when a DSN is set; the SDK drops them otherwise.
@@ -184,9 +193,39 @@ async function handleReports(req: IncomingMessage, res: ServerResponse, parts: s
throw new RoomError('Not here.', 404); throw new RoomError('Not here.', 404);
} }
/** The key the keeper's door asks for, read on each knock so a key written after the server started is honoured. */
function keeperKeyGiven(req: IncomingMessage): boolean {
let key: Buffer;
try {
key = Buffer.from(readFileSync(KEEPER_KEY_FILE, 'utf8').trim());
} catch {
return false;
}
const given = Buffer.from(String(req.headers['x-keeper-key'] ?? ''));
return key.length > 0 && given.length === key.length && timingSafeEqual(given, key);
}
/**
* The keeper's door. Caddy proxies only /api and /ws, so it is reached from the host alone; a request Caddy
* forwarded is turned away besides, and so is one without the keeper's key.
*/
async function handleKeeper(req: IncomingMessage, res: ServerResponse, parts: string[]): Promise<void> {
if (req.headers['x-forwarded-for'] || parts[1] !== 'rooms' || !keeperKeyGiven(req)) throw new RoomError('Not here.', 404);
const room = rooms.get(parts[2] ?? '');
if (parts.length === 3 && req.method === 'GET') return send(res, 200, { roomId: room.id, started: !!room.state, seats: rooms.seatsForKeeper(room) });
if (parts[3] === 'reseat' && parts.length === 4 && req.method === 'POST') {
const body = await readBody(req);
const { seat, token } = rooms.reseat(room, body.seat);
// The token rides the fragment, which the browser keeps to itself: it reaches neither Caddy's log nor this server.
return send(res, 200, { name: seat.name, link: `${PUBLIC_URL}/join/${room.id}#seat=${seat.id}.${token}` });
}
throw new RoomError('Not here.', 404);
}
async function handle(req: IncomingMessage, res: ServerResponse): Promise<void> { async function handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
const url = new URL(req.url ?? '/', 'http://localhost'); const url = new URL(req.url ?? '/', 'http://localhost');
const parts = url.pathname.split('/').filter(Boolean); const parts = url.pathname.split('/').filter(Boolean);
if (parts[0] === 'keeper') return handleKeeper(req, res, parts);
if (parts[0] === 'api' && parts[1] === 'reports') return handleReports(req, res, parts); if (parts[0] === 'api' && parts[1] === 'reports') return handleReports(req, res, parts);
if (parts[0] === 'api' && parts[1] === 'tally' && parts.length === 2 && req.method === 'GET') return send(res, 200, tallies.get()); if (parts[0] === 'api' && parts[1] === 'tally' && parts.length === 2 && req.method === 'GET') return send(res, 200, tallies.get());
if (parts[0] === 'api' && parts[1] === 'transfer' && parts.length === 2 && req.method === 'POST') { if (parts[0] === 'api' && parts[1] === 'transfer' && parts.length === 2 && req.method === 'POST') {
+51
View File
@@ -0,0 +1,51 @@
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { game } from '../../src/lib/game';
import { Rooms } from './rooms';
import { Store } from './store';
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'rooms-'));
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
/** A table of two: Ann, who opened it, and Bo, the game begun. */
function table() {
const rooms = new Rooms(game, new Store(dir));
const host = rooms.create('Ann', 2);
const guest = rooms.join(host.room, 'Bo');
rooms.begin(host.room, host.token);
return { rooms, room: host.room, ann: host.token, bo: guest.token };
}
describe('rooms', () => {
it("gives a seat a new token at the keeper's word: the old one opens nothing, and a restart keeps the change", () => {
const { rooms, room, bo } = table();
const phrase = rooms.transfer(room, bo).phrase;
const { seat, token } = rooms.reseat(room, 'B');
expect(seat.name).toBe('Bo');
expect(rooms.seatOf(room, token).id).toBe('B');
expect(() => rooms.seatOf(room, bo)).toThrow(/opens no seat/);
// A phrase minted with the old token carries nothing now.
expect(() => rooms.claim(phrase)).toThrow(/unknown or has expired/);
const reopened = new Rooms(game, new Store(dir)).get(room.id);
expect(rooms.seatOf(reopened, token).id).toBe('B');
expect(() => rooms.seatOf(reopened, bo)).toThrow(/opens no seat/);
});
it('re-seats only a person, and says when each person last acted', () => {
const { rooms, room, ann, bo } = table();
expect(() => rooms.reseat(room, 'Z')).toThrow(/No person sits/);
const mover = rooms.waitingOn(room)[0];
rooms.submit(room, mover === 'A' ? ann : bo, game.botInput(room.state!, mover), 0);
const seats = rooms.seatsForKeeper(room);
expect(seats.map((s) => [s.id, s.name])).toEqual([['A', 'Ann'], ['B', 'Bo']]);
expect(seats.find((s) => s.id === mover)!.lastActed).toBeGreaterThan(0);
expect(seats.find((s) => s.id !== mover)!.lastActed).toBeNull();
});
});
+29
View File
@@ -350,6 +350,30 @@ export class Rooms<State, Input> {
return { seat, token }; return { seat, token };
} }
/**
* The keeper gives a person's seat a new token, for a player whose browser lost the old one. Whether the one
* asking holds the seat is the keeper's judgment; this carries it out. The old token opens nothing after.
*/
reseat(room: Room<State>, seatId: unknown): { seat: Seat; token: string } {
const seat = room.seats.find((s) => s.id === seatId && !s.bot);
if (!seat) throw new RoomError('No person sits in that seat.', 404);
const token = newId(18);
this.commit(room, { t: 'reseat', at: Date.now(), id: seat.id, tokenHash: hashToken(token) });
// A phrase minted with the old token would carry a seat that no longer answers to it.
for (const [phrase, t] of this.transfers) if (t.roomId === room.id && t.seatId === seat.id) this.transfers.delete(phrase);
return { seat, token };
}
/** The table as the keeper sees it before re-seating: who sits where, and when each person last moved or spoke. */
seatsForKeeper(room: Room<State>): { id: SeatId; name: string; bot: boolean; lastActed: number | null }[] {
const last = new Map<SeatId, number>();
for (const line of this.store.read(room.id)) {
if (line.t === 'input' || line.t === 'chat') last.set(line.id, line.at);
else if (line.t === 'turn') for (const id of Object.keys(line.inputs)) last.set(id, line.at);
}
return room.seats.map((s) => ({ id: s.id, name: s.name, bot: s.bot, lastActed: s.bot ? null : (last.get(s.id) ?? null) }));
}
/** The host sends a bot away before the game begins. People leave by not coming back. */ /** The host sends a bot away before the game begins. People leave by not coming back. */
unseat(room: Room<State>, token: string | undefined, seatId: unknown): void { unseat(room: Room<State>, token: string | undefined, seatId: unknown): void {
const host = this.seatOf(room, token); const host = this.seatOf(room, token);
@@ -486,6 +510,11 @@ export class Rooms<State, Input> {
case 'seat': case 'seat':
room.seats.push({ id: line.id, name: line.name, tokenHash: line.tokenHash, bot: line.bot }); room.seats.push({ id: line.id, name: line.name, tokenHash: line.tokenHash, bot: line.bot });
break; break;
case 'reseat': {
const seat = room.seats.find((s) => s.id === line.id);
if (seat) seat.tokenHash = line.tokenHash;
break;
}
case 'galleryTalk': case 'galleryTalk':
room.galleryTalk = line.on; room.galleryTalk = line.on;
break; break;
+2
View File
@@ -11,6 +11,8 @@ export type LedgerLine =
| { t: 'room'; id: string; seats: number; createdAt: number; rematchOf?: string; expected?: string[]; options: TableOptions } | { t: 'room'; id: string; seats: number; createdAt: number; rematchOf?: string; expected?: string[]; options: TableOptions }
/** A seat holds only the hash of its token; the token itself goes once to the browser that earned it. A bot's hash is empty. */ /** A seat holds only the hash of its token; the token itself goes once to the browser that earned it. A bot's hash is empty. */
| { t: 'seat'; id: SeatId; name: string; tokenHash: string; bot: boolean } | { t: 'seat'; id: SeatId; name: string; tokenHash: string; bot: boolean }
/** The keeper gave a person's seat a new token, for a player whose browser lost the old one; the old one opens nothing after. */
| { t: 'reseat'; at: number; id: SeatId; tokenHash: string }
/** One seat's move for the round in progress, so a restart keeps it. The turn line that follows carries every input again. */ /** One seat's move for the round in progress, so a restart keeps it. The turn line that follows carries every input again. */
| { t: 'input'; at: number; id: SeatId; input: unknown } | { t: 'input'; at: number; id: SeatId; input: unknown }
/** A bot sent away by the host before the game began; its seat id is free again. */ /** A bot sent away by the host before the game began; its seat id is free again. */
+17 -1
View File
@@ -146,7 +146,14 @@
if (c) void goto(`/join/${c}`); 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) { function forget(roomId: string) {
if (!games[roomId]?.over && forgetting !== roomId) {
forgetting = roomId;
return;
}
forgetting = null;
forgetSeat(roomId); forgetSeat(roomId);
seats = allSeats(); seats = allSeats();
} }
@@ -253,7 +260,11 @@
<span class="ledger-code">{held.roomId}</span> <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> <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> </a>
<button type="button" class="dismiss" title="forget this game" onclick={() => forget(held.roomId)}>×</button> {#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> </div>
{/each} {/each}
{#if reports.length} {#if reports.length}
@@ -478,6 +489,11 @@
background: rgba(240, 165, 58, 0.08); background: rgba(240, 165, 58, 0.08);
} }
.forget-ask {
font-size: 0.85em;
flex: none;
}
.ledger-row.your-move .ledger-info { .ledger-row.your-move .ledger-info {
color: var(--ember); color: var(--ember);
} }
+6
View File
@@ -19,6 +19,12 @@ function heldSeats(): Record<string, HeldSeat> {
} }
} }
/** 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 { export function seatFor(roomId: string): HeldSeat | null {
return heldSeats()[roomId] ?? null; return heldSeats()[roomId] ?? null;
} }
+27 -5
View File
@@ -5,7 +5,7 @@
import Lobby from '$lib/components/Lobby.svelte'; import Lobby from '$lib/components/Lobby.svelte';
import ReportSlip from '$lib/components/ReportSlip.svelte'; import ReportSlip from '$lib/components/ReportSlip.svelte';
import Preferences from '$lib/components/Preferences.svelte'; import Preferences from '$lib/components/Preferences.svelte';
import { seatFor } from '$lib/net/client'; import { rememberSeat, seatFor, seatInLink, ServerError, trustSeat, type HeldSeat } from '$lib/net/client';
import { NAME_MAX, playerName, rememberName, Room } from '$lib/net/room.svelte'; import { NAME_MAX, playerName, rememberName, Room } from '$lib/net/room.svelte';
const roomId = $derived((page.params.code ?? '').toUpperCase()); const roomId = $derived((page.params.code ?? '').toUpperCase());
@@ -25,16 +25,34 @@
const link = $derived(`${location.origin}/join/${roomId}`); const link = $derived(`${location.origin}/join/${roomId}`);
const watching = $derived(room?.spectating ?? false); const watching = $derived(room?.spectating ?? false);
// Return to a held seat, or take a place in the gallery and offer one. // Return to a held seat, or take a place in the gallery and offer one. A link from the keeper carries a seat:
// it leaves the address bar at once, is kept only once the server takes it, and never replaces a seat that
// still opens, so a link someone else sends cannot take a working seat away.
$effect(() => { $effect(() => {
const id = roomId; const id = roomId;
room = null; room = null;
error = ''; error = '';
const sent = seatInLink(location.hash);
// The browser's own call: this runs as the page first loads, before the router could take one.
if (sent) history.replaceState(history.state, '', location.pathname + location.search);
const held = seatFor(id); const held = seatFor(id);
const arrive = held ? Room.resume(id, held) : Room.watch(id); const take = (s: HeldSeat) =>
joining = !held; Room.resume(id, s).then((r) => {
rememberSeat(id, s);
trustSeat(id);
return r;
});
// Only the server's refusal of the held token lets a link in: a dropped connection is not a lost seat.
const refused = (e: unknown) => e instanceof ServerError && e.status === 403;
const arrive = held ? Room.resume(id, held).catch((e: Error) => (sent && refused(e) ? take(sent) : Promise.reject(e))) : sent ? take(sent).catch(() => Room.watch(id)) : Room.watch(id);
joining = !held && !sent;
if (!held) name = playerName(); if (!held) name = playerName();
arrive.then((r) => (room = r)).catch((e: Error) => (error = e.message)); arrive
.then((r) => {
room = r;
if (r.spectating) joining = true;
})
.catch((e: Error) => (error = e.message));
}); });
// Keep the view fresh for as long as the room is on screen. // Keep the view fresh for as long as the room is on screen.
@@ -171,6 +189,10 @@
</section> </section>
{/if} {/if}
{#if watching && room?.started && !room.view.over}
<p class="notice muted">Had a seat at this table on another device? Transfer it from there and claim the phrase in the hall, or <a href="mailto:eric@ericwagoner.com?subject={encodeURIComponent(`My seat in room ${roomId}`)}">write to the keeper</a> for a link that brings it back.</p>
{/if}
{#if room && !room.started} {#if room && !room.started}
<Lobby {room} {link} {copied} {copyLink} /> <Lobby {room} {link} {copied} {copyLink} />
{:else if room} {:else if room}
+1 -1
View File
@@ -21,6 +21,6 @@ export default defineConfig({
} }
}, },
test: { test: {
include: ['src/**/*.test.ts'] include: ['src/**/*.test.ts', 'server/src/**/*.test.ts']
} }
}); });