Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c439c15e36 | ||
|
|
7b4080c91f | ||
|
|
4721b0e97b | ||
|
|
ecab471122 |
No files matched your search
@@ -47,6 +47,15 @@ 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. 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
|
||||||
@@ -194,6 +203,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,
|
||||||
|
|||||||
@@ -3,11 +3,12 @@
|
|||||||
The shared infrastructure of Wiz-War and Waving Hands, extracted so the next
|
The shared infrastructure of Wiz-War and Waving Hands, extracted so the next
|
||||||
game starts with all of it in place: the hall, room codes and tables, the
|
game starts with all of it in place: the hall, room codes and tables, the
|
||||||
Peanut Gallery (and the host's leave for it to talk), table talk, rematches,
|
Peanut Gallery (and the host's leave for it to talk), table talk, rematches,
|
||||||
seats that travel between devices by phrase, the keeper's bell, append-only
|
seats that travel between devices by phrase and come back by the keeper's
|
||||||
ledgers replayed through a
|
link when a browser loses them, the keeper's bell, append-only ledgers
|
||||||
deterministic engine, the rules revision, the reports desk, the deploy
|
replayed through a deterministic engine, the rules revision, the reports
|
||||||
script with its determinism gate, nightly rollup and backup, Sentry with
|
desk, the deploy script with its determinism gate, nightly rollup and
|
||||||
Slack alerts, and the visitors, pulse and reports skills for Claude.
|
backup, Sentry with Slack alerts, and the visitors, pulse and reports
|
||||||
|
skills for Claude.
|
||||||
|
|
||||||
The game itself is one file. `src/lib/game/index.ts` implements `GameSpec`
|
The game itself is one file. `src/lib/game/index.ts` implements `GameSpec`
|
||||||
(see `src/lib/game/spec.ts`); the server, the client store, the hall and
|
(see `src/lib/game/spec.ts`); the server, the client store, the hall and
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ src/lib/game/spec.ts src/lib/net/client.ts src/lib/net/talk.ts src/lib/net/view.
|
|||||||
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
|
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 deploy/__SLUG__-reseat.sh"
|
||||||
total=0
|
total=0
|
||||||
for t in $SHARED; do
|
for t in $SHARED; do
|
||||||
g="$t"
|
g="$t"
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ own: on the hall's shared host, `hall-nightly.sh` (in the kit's `gamehall/ops`)
|
|||||||
runs every game's rollup and backup from /etc/cron.d/gamehall.
|
runs every game's rollup and backup from /etc/cron.d/gamehall.
|
||||||
|
|
||||||
- `__SLUG__-visitors.sh [day]`: who is here now and who came that day.
|
- `__SLUG__-visitors.sh [day]`: who is here now and who came that day.
|
||||||
|
- `__SLUG__-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.
|
||||||
- `__SLUG__-pulse.sh`: the weekly health check (service, errors, rollup
|
- `__SLUG__-pulse.sh`: the weekly health check (service, errors, rollup
|
||||||
trend, backup, box).
|
trend, backup, box).
|
||||||
- `__SLUG__-rollup.sh [day]`: one JSON line per day of counts, run
|
- `__SLUG__-rollup.sh [day]`: one JSON line per day of counts, run
|
||||||
|
|||||||
Executable
+33
@@ -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.
|
||||||
|
# __SLUG__-reseat.sh ROOM who sits at the table, and when each person last moved or spoke
|
||||||
|
# __SLUG__-reseat.sh ROOM SEAT re-seat SEAT and print the link
|
||||||
|
set -euo pipefail
|
||||||
|
ROOM="${1:?usage: __SLUG__-reseat.sh ROOM [SEAT]}"
|
||||||
|
DOOR="http://127.0.0.1:__PORT__/keeper/rooms/${ROOM^^}"
|
||||||
|
[ -s /etc/__SLUG__/keeper.key ] || { echo "No keeper's key at /etc/__SLUG__/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/__SLUG__/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
|
||||||
@@ -42,6 +42,13 @@ ssh "root@$HOST" '
|
|||||||
install -m 755 /opt/__SLUG__/app/deploy/__SLUG__-pulse.sh /usr/local/bin/__SLUG__-pulse.sh
|
install -m 755 /opt/__SLUG__/app/deploy/__SLUG__-pulse.sh /usr/local/bin/__SLUG__-pulse.sh
|
||||||
install -m 755 /opt/__SLUG__/app/deploy/__SLUG__-rollup.sh /usr/local/bin/__SLUG__-rollup.sh
|
install -m 755 /opt/__SLUG__/app/deploy/__SLUG__-rollup.sh /usr/local/bin/__SLUG__-rollup.sh
|
||||||
install -m 755 /opt/__SLUG__/app/deploy/__SLUG__-backup.sh /usr/local/bin/__SLUG__-backup.sh
|
install -m 755 /opt/__SLUG__/app/deploy/__SLUG__-backup.sh /usr/local/bin/__SLUG__-backup.sh
|
||||||
|
install -m 755 /opt/__SLUG__/app/deploy/__SLUG__-reseat.sh /usr/local/bin/__SLUG__-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/__SLUG__/keeper.key ]; then
|
||||||
|
install -d -m 750 -o root -g __SLUG__ /etc/__SLUG__
|
||||||
|
(umask 037; openssl rand -hex 32 > /etc/__SLUG__/keeper.key)
|
||||||
|
chown root:__SLUG__ /etc/__SLUG__/keeper.key
|
||||||
|
fi
|
||||||
install -m 644 -D /opt/__SLUG__/app/deploy/traffic.py /usr/local/lib/__SLUG__/traffic.py
|
install -m 644 -D /opt/__SLUG__/app/deploy/traffic.py /usr/local/lib/__SLUG__/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
|
||||||
|
|||||||
@@ -25,10 +25,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/__SLUG__-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';
|
||||||
@@ -45,6 +52,8 @@ const PUBLIC_URL = (process.env.PUBLIC_URL ?? 'https://__DOMAIN__').replace(/\/$
|
|||||||
/** 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/__SLUG__/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.
|
||||||
@@ -186,9 +195,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') {
|
||||||
|
|||||||
@@ -35,6 +35,30 @@ describe('rooms', () => {
|
|||||||
expect(() => rooms.submit(room, ann, { pick: 4 }, 0)).toThrow(/already been played/);
|
expect(() => rooms.submit(room, ann, { pick: 4 }, 0)).toThrow(/already been played/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 } = table();
|
||||||
|
expect(() => rooms.reseat(room, 'Z')).toThrow(/No person sits/);
|
||||||
|
rooms.submit(room, ann, { pick: 5 }, 0);
|
||||||
|
const seats = rooms.seatsForKeeper(room);
|
||||||
|
expect(seats.map((s) => [s.id, s.name])).toEqual([['A', 'Ann'], ['B', 'Bo']]);
|
||||||
|
expect(seats[0].lastActed).toBeGreaterThan(0);
|
||||||
|
expect(seats[1].lastActed).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it('leaves a room with an unreadable ledger unloaded and opens the rest', () => {
|
it('leaves a room with an unreadable ledger unloaded and opens the rest', () => {
|
||||||
const { room } = table();
|
const { room } = table();
|
||||||
appendFileSync(join(dir, 'ZZZZ.jsonl'), '{"t":"room","id":"ZZZZ","seats":2,"createdAt":1,"options":{}}\n{"t":"seat", torn');
|
appendFileSync(join(dir, 'ZZZZ.jsonl'), '{"t":"room","id":"ZZZZ","seats":2,"createdAt":1,"options":{}}\n{"t":"seat", torn');
|
||||||
|
|||||||
@@ -406,6 +406,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);
|
||||||
@@ -609,6 +633,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;
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -143,7 +143,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();
|
||||||
}
|
}
|
||||||
@@ -252,7 +259,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>
|
||||||
|
{#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>
|
<button type="button" class="dismiss" title="forget this game" onclick={() => forget(held.roomId)}>×</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
@@ -520,6 +531,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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in new issue
Block a user