The template banks a browser's seats in a cookie the server sets, and gives a lost seat back by name to the address it was last used from
A seat lived in the browser's storage alone, and Safari clears that after a week away, or at a word from the person on another device. Now every seat taken is banked as well in an HttpOnly cookie the server writes, which such clearing leaves alone, and the hall takes its seats back from the bank on arrival. For a browser that lost even the cookie, the ledger notes the address each seat was last used from, as a salted hash, and a name given in the hall or at the table gets its seats back from the same address, with a new token; where the address differs, or two people at the table share it, the table is named so the host can mint a phrase. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CCLa2PaEeCj8njQZSWGqHx
This commit is contained in:
1 parent
6a9dc5a496
commit
dcb18b2b7e
7 files changed
+318
-8
No files matched your search
@@ -19,6 +19,11 @@
|
||||
// POST /api/rooms/:id/phrase {token, seat} the host mints one for another person's seat, which re-seats it when claimed
|
||||
// POST /api/rooms/:id/standin {token, seat} the host gives a person's seat to a bot for the rest of the game
|
||||
// POST /api/transfer {phrase} claim one
|
||||
// POST /api/seats {seats: [{roomId, token}], forget?: [roomId]} bank this browser's seats in a cookie the server
|
||||
// sets, and get back every seat the cookie holds; a browser whose storage was
|
||||
// cleared gets its seats from the cookie, which clearing storage leaves alone
|
||||
// POST /api/seats/find {name, roomId?} the seats a name holds at tables not yet over: those last used from this
|
||||
// address are re-seated and returned; the rest named, to ask the host for a phrase
|
||||
// POST /api/rooms/:id/report {token?, happened, expected} a report to the keeper, pinned to the round
|
||||
// POST /api/reports/:id/image <image bytes> a screenshot for a report just filed
|
||||
// POST /api/reports/:id/answer {roomId, token, text} your word under the keeper's reply
|
||||
@@ -37,9 +42,9 @@
|
||||
|
||||
import * as Sentry from '@sentry/node';
|
||||
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 { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { WebSocketServer, WebSocket } from 'ws';
|
||||
import { game } from '../../src/lib/game';
|
||||
import { RateLimit } from './ratelimit';
|
||||
@@ -58,6 +63,12 @@ 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;
|
||||
/** The seats a browser holds, banked in a cookie: the browser keeps it for the server alone, and sends it to the API only. */
|
||||
const SEATS_COOKIE = 'seats';
|
||||
/** As long as a browser will keep a cookie. */
|
||||
const SEATS_COOKIE_MAX_AGE = 400 * 24 * 60 * 60;
|
||||
/** The newest seats a cookie carries; the rest fall off the end. */
|
||||
const SEATS_COOKIE_KEEP = 60;
|
||||
|
||||
// Errors go to Sentry when a DSN is set; the SDK drops them otherwise.
|
||||
if (process.env.SENTRY_DSN) {
|
||||
@@ -107,6 +118,70 @@ function clientOf(req: IncomingMessage): string {
|
||||
return first || req.socket.remoteAddress || '?';
|
||||
}
|
||||
|
||||
/** Addresses go on the ledgers as salted hashes. The salt is made once, beside the ledgers, and never leaves the host. */
|
||||
const SALT_FILE = join(dirname(DATA_DIR), 'addr.salt');
|
||||
function addrSalt(): string {
|
||||
try {
|
||||
const salt = readFileSync(SALT_FILE, 'utf8').trim();
|
||||
if (salt) return salt;
|
||||
} catch {
|
||||
// No salt yet: this is the first start.
|
||||
}
|
||||
const salt = randomBytes(32).toString('base64url');
|
||||
writeFileSync(SALT_FILE, salt + '\n', { mode: 0o600 });
|
||||
return salt;
|
||||
}
|
||||
const salt = addrSalt();
|
||||
/** The visitor's address as the ledgers note it: a hash a reader of the ledger cannot turn back into the address. */
|
||||
function addrOf(req: IncomingMessage): string {
|
||||
return createHmac('sha256', salt).update(clientOf(req)).digest('hex').slice(0, 16);
|
||||
}
|
||||
|
||||
/** The seats the browser's cookie banks, as room and token; a cookie this server never wrote reads as none. */
|
||||
function bankedSeats(req: IncomingMessage): { roomId: string; token: string }[] {
|
||||
const header = req.headers.cookie ?? '';
|
||||
const pair = header.split(';').map((c) => c.trim()).find((c) => c.startsWith(`${SEATS_COOKIE}=`));
|
||||
if (!pair) return [];
|
||||
return pair
|
||||
.slice(SEATS_COOKIE.length + 1)
|
||||
.split(':')
|
||||
.map((entry) => entry.split('.'))
|
||||
.filter((e): e is [string, string] => e.length === 2 && /^[A-Z0-9]{4}$/.test(e[0]) && /^[A-Za-z0-9_-]{1,64}$/.test(e[1]))
|
||||
.map(([roomId, token]) => ({ roomId, token }));
|
||||
}
|
||||
|
||||
/** The cookie written for the seats given, newest last; sent only to the API, only over https where there is any, never to a script. */
|
||||
function bankSeats(req: IncomingMessage, res: ServerResponse, seats: { roomId: string; token: string }[]): void {
|
||||
const value = seats.slice(-SEATS_COOKIE_KEEP).map((s) => `${s.roomId}.${s.token}`).join(':');
|
||||
const secure = req.headers['x-forwarded-proto'] === 'https' ? '; Secure' : '';
|
||||
res.setHeader('set-cookie', `${SEATS_COOKIE}=${value}; Path=/api; Max-Age=${SEATS_COOKIE_MAX_AGE}; HttpOnly; SameSite=Lax${secure}`);
|
||||
}
|
||||
|
||||
/** The seats a browser claims, from its cookie and its own storage, kept to the ones their tokens still open, each room once. */
|
||||
function provenBank(req: IncomingMessage, claimed: unknown, forget: unknown): { roomId: string; seat: string; token: string }[] {
|
||||
const dropped = new Set(Array.isArray(forget) ? forget.slice(0, 50).map((r) => String(r).toUpperCase()) : []);
|
||||
const own = Array.isArray(claimed) ? claimed.slice(0, SEATS_COOKIE_KEEP) : [];
|
||||
const byRoom = new Map<string, string>();
|
||||
// The browser's own copy is the fresher where the two differ, so it is taken last.
|
||||
for (const c of [...bankedSeats(req), ...own]) {
|
||||
if (typeof c !== 'object' || c === null) continue;
|
||||
const { roomId, token } = c as Record<string, unknown>;
|
||||
if (typeof roomId !== 'string' || typeof token !== 'string' || !token) continue;
|
||||
const id = roomId.toUpperCase();
|
||||
if (!dropped.has(id)) byRoom.set(id, token);
|
||||
}
|
||||
const proven: { roomId: string; seat: string; token: string }[] = [];
|
||||
for (const [roomId, token] of byRoom) {
|
||||
try {
|
||||
const room = rooms.get(roomId);
|
||||
proven.push({ roomId: room.id, seat: rooms.seatOf(room, token).id, token });
|
||||
} catch {
|
||||
// A seat whose token no longer opens is not banked again.
|
||||
}
|
||||
}
|
||||
return proven;
|
||||
}
|
||||
|
||||
function send(res: ServerResponse, status: number, body: unknown): void {
|
||||
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
|
||||
res.end(status === 204 ? undefined : JSON.stringify(body));
|
||||
@@ -245,6 +320,22 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise<void>
|
||||
const body = await readBody(req);
|
||||
return send(res, 200, rooms.claim(body.phrase));
|
||||
}
|
||||
if (parts[0] === 'api' && parts[1] === 'seats' && req.method === 'POST') {
|
||||
const body = await readBody(req);
|
||||
if (parts.length === 2) {
|
||||
const seats = provenBank(req, body.seats, body.forget);
|
||||
bankSeats(req, res, seats);
|
||||
return send(res, 200, { seats });
|
||||
}
|
||||
if (parts[2] === 'find' && parts.length === 3) {
|
||||
if (!claims.allow(clientOf(req))) throw new RoomError('Too many searches from here just now; try again later.', 429);
|
||||
const found = rooms.findSeats(String(body.name ?? ''), addrOf(req), typeof body.roomId === 'string' ? body.roomId : undefined);
|
||||
// The seats given back are banked at once, so the cookie and the browser agree.
|
||||
bankSeats(req, res, provenBank(req, found.seated, []));
|
||||
return send(res, 200, found);
|
||||
}
|
||||
throw new RoomError('Not here.', 404);
|
||||
}
|
||||
if (parts[0] !== 'api' || parts[1] !== 'rooms') throw new RoomError('Not here.', 404);
|
||||
|
||||
if (parts.length === 2 && req.method === 'POST') {
|
||||
@@ -259,11 +350,13 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise<void>
|
||||
if (!action && req.method === 'GET') {
|
||||
const token = url.searchParams.get('token');
|
||||
if (!token) return send(res, 200, rooms.view(room, SPECTATOR));
|
||||
rooms.seen(room, token, addrOf(req));
|
||||
return send(res, 200, rooms.view(room, rooms.seatOf(room, token).id));
|
||||
}
|
||||
if (req.method !== 'POST') throw new RoomError('Not here.', 404);
|
||||
const body = await readBody(req);
|
||||
const token = typeof body.token === 'string' && body.token ? body.token : undefined;
|
||||
if (token) rooms.seen(room, token, addrOf(req));
|
||||
if (action === 'join') {
|
||||
if (!doors.allow(clientOf(req))) throw new RoomError('Too many seats taken from here just now; try again later.', 429);
|
||||
const { seat, token: minted } = rooms.join(room, String(body.name ?? ''));
|
||||
|
||||
@@ -327,3 +327,51 @@ describe('the host and the other seats', () => {
|
||||
expect(next.seats.map((s) => s.name)).toEqual(['Ann']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a seat a browser lost', () => {
|
||||
it('notes the address a seat is used from, once per change, without waking the table, and a restart keeps it', () => {
|
||||
const { rooms, room, bo } = table();
|
||||
const seq = room.seq;
|
||||
rooms.seen(room, bo, 'home');
|
||||
rooms.seen(room, bo, 'home');
|
||||
expect(room.seats.find((s) => s.id === 'B')?.addr).toBe('home');
|
||||
expect(room.seq).toBe(seq);
|
||||
rooms.seen(room, 'no-such-token', 'elsewhere');
|
||||
expect(new Store(dir).read(room.id).filter((l) => l.t === 'seen')).toHaveLength(1);
|
||||
expect(new Rooms(game, new Store(dir)).get(room.id).seats.find((s) => s.id === 'B')?.addr).toBe('home');
|
||||
});
|
||||
|
||||
it('gives the seat back, under its name, to the address it was last used from, with a new token', () => {
|
||||
const { rooms, room, bo } = table();
|
||||
rooms.seen(room, bo, 'home');
|
||||
const found = rooms.findSeats('bo', 'home');
|
||||
expect(found.elsewhere).toEqual([]);
|
||||
expect(found.seated).toHaveLength(1);
|
||||
const { roomId, seat, token } = found.seated[0];
|
||||
expect([roomId, seat]).toEqual([room.id, 'B']);
|
||||
expect(rooms.seatOf(room, token).name).toBe('Bo');
|
||||
expect(() => rooms.seatOf(room, bo)).toThrow(/opens no seat/);
|
||||
});
|
||||
|
||||
it('only names the table when the address differs, or another person at it shares the address, or the name is nobody\'s', () => {
|
||||
const { rooms, room, ann, bo } = table();
|
||||
rooms.seen(room, bo, 'home');
|
||||
expect(rooms.findSeats('Bo', 'away')).toEqual({ seated: [], elsewhere: [{ roomId: room.id, seat: 'B', players: ['Ann'] }] });
|
||||
// Ann and Bo play from one address: a name alone cannot tell them apart, so neither is re-seated by it.
|
||||
rooms.seen(room, ann, 'home');
|
||||
expect(rooms.findSeats('Bo', 'home').seated).toEqual([]);
|
||||
expect(rooms.findSeats('Dee', 'home')).toEqual({ seated: [], elsewhere: [] });
|
||||
expect(rooms.seatOf(room, bo).id).toBe('B');
|
||||
});
|
||||
|
||||
it('looks only at the table named when one is, and passes over a table that is over', () => {
|
||||
const { rooms, room, bo } = table();
|
||||
rooms.seen(room, bo, 'home');
|
||||
const other = rooms.create('Bo', 3);
|
||||
rooms.seen(other.room, other.token, 'home');
|
||||
expect(rooms.findSeats('Bo', 'home', other.room.id).seated.map((s) => s.roomId)).toEqual([other.room.id]);
|
||||
expect(rooms.findSeats('Bo', 'home').seated.map((s) => s.roomId).sort()).toEqual([other.room.id, room.id].sort());
|
||||
room.over = true;
|
||||
expect(rooms.findSeats('Bo', 'home').seated.map((s) => s.roomId)).toEqual([other.room.id]);
|
||||
});
|
||||
});
|
||||
@@ -30,8 +30,13 @@ export interface Seat {
|
||||
bot: boolean;
|
||||
/** The host gave this person's seat to a bot; a phrase from the host, or the keeper, gives it back. */
|
||||
standIn?: true;
|
||||
/** The address this person's seat was last used from, as a salted hash; a seat their browser lost goes back to the same address. */
|
||||
addr?: string;
|
||||
}
|
||||
|
||||
/** A table whose ledger has not been written in this long is not looked through for a lost seat. */
|
||||
const LOST_SEAT_LOOKBACK_MS = 90 * 24 * 60 * 60 * 1000;
|
||||
|
||||
function hashToken(token: string): string {
|
||||
return createHash('sha256').update(token).digest('hex');
|
||||
}
|
||||
@@ -318,6 +323,51 @@ export class Rooms<State, Input> {
|
||||
return { phrase, expiresAt };
|
||||
}
|
||||
|
||||
/** The address a seat is used from, noted when it changes. Not a change the table need hear of: nothing wakes for it. */
|
||||
seen(room: Room<State>, token: string | undefined, addr: string): void {
|
||||
let seat: Seat;
|
||||
try {
|
||||
seat = this.seatOf(room, token);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (seat.addr === addr) return;
|
||||
const line: LedgerLine = { t: 'seen', at: Date.now(), id: seat.id, addr };
|
||||
this.store.append(room.id, line);
|
||||
this.apply(room, line);
|
||||
}
|
||||
|
||||
/**
|
||||
* The seats a name holds at tables not yet over, for a browser that lost them. A seat last used from the
|
||||
* asker's own address, at a table where no other person shares that address, is theirs again with a new
|
||||
* token, the old one dead; the rest are named, so the asker can get a phrase from the host there.
|
||||
*/
|
||||
findSeats(rawName: string, addr: string, only?: string): { seated: { roomId: string; seat: SeatId; token: string }[]; elsewhere: { roomId: string; seat: SeatId; players: string[] }[] } {
|
||||
const name = cleanName(rawName).toLowerCase();
|
||||
const seated: { roomId: string; seat: SeatId; token: string }[] = [];
|
||||
const elsewhere: { roomId: string; seat: SeatId; players: string[] }[] = [];
|
||||
const since = Date.now() - LOST_SEAT_LOOKBACK_MS;
|
||||
const ids = only ? [normalizeCode(String(only))] : this.store.roomIds().filter((id) => this.store.updatedAt(id) > since);
|
||||
for (const id of ids) {
|
||||
let room: Room<State>;
|
||||
try {
|
||||
room = this.get(id);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (room.over) continue;
|
||||
const person = (s: Seat) => !s.bot || s.standIn;
|
||||
const mine = room.seats.find((s) => person(s) && s.name.toLowerCase() === name);
|
||||
if (!mine) continue;
|
||||
const shared = room.seats.some((s) => s !== mine && person(s) && s.addr === addr);
|
||||
if (mine.addr === addr && !shared) {
|
||||
const { token } = this.reseat(room, mine.id);
|
||||
seated.push({ roomId: room.id, seat: mine.id, token });
|
||||
} else elsewhere.push({ roomId: room.id, seat: mine.id, players: room.seats.filter((s) => s !== mine).map((s) => s.name) });
|
||||
}
|
||||
return { seated, elsewhere };
|
||||
}
|
||||
|
||||
/** Claim a phrase: the seat and its token, once. Too many misses void every pending phrase. */
|
||||
claim(raw: unknown): { roomId: string; seat: SeatId; token: string } {
|
||||
const now = Date.now();
|
||||
@@ -714,6 +764,11 @@ export class Rooms<State, Input> {
|
||||
case 'seat':
|
||||
room.seats.push({ id: line.id, name: line.name, tokenHash: line.tokenHash, bot: line.bot });
|
||||
break;
|
||||
case 'seen': {
|
||||
const seat = room.seats.find((s) => s.id === line.id);
|
||||
if (seat) seat.addr = line.addr;
|
||||
break;
|
||||
}
|
||||
case 'reseat': {
|
||||
const seat = room.seats.find((s) => s.id === line.id);
|
||||
if (!seat) break;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// top: the engine is deterministic given the seed and the recorded inputs, so
|
||||
// nothing else needs saving.
|
||||
|
||||
import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync } from 'node:fs';
|
||||
import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import type { SeatId, TableOptions } from '../../src/lib/game/spec';
|
||||
|
||||
@@ -43,7 +43,9 @@ export type LedgerLine =
|
||||
/** A seat is held for the keeper of the site, called to the table. */
|
||||
| { t: 'challenge'; at: number; by: SeatId; keeper: string }
|
||||
/** A finished table called for a rematch: where it went, and who called. */
|
||||
| { t: 'rematch'; at: number; to: string; by: SeatId };
|
||||
| { t: 'rematch'; at: number; to: string; by: SeatId }
|
||||
/** The address a person's seat was last used from, as a salted hash, so a seat a browser lost can go back to the same address. */
|
||||
| { t: 'seen'; at: number; id: SeatId; addr: string };
|
||||
|
||||
export class Store {
|
||||
constructor(private dir: string) {
|
||||
@@ -72,6 +74,15 @@ export class Store {
|
||||
return existsSync(this.path(roomId));
|
||||
}
|
||||
|
||||
/** When the ledger was last written, for passing over tables long finished. */
|
||||
updatedAt(roomId: string): number {
|
||||
try {
|
||||
return statSync(this.path(roomId)).mtimeMs;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
roomIds(): string[] {
|
||||
return readdirSync(this.dir)
|
||||
.filter((f) => f.endsWith('.jsonl'))
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
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 { allSeats, doubtSeat, forgetSeat, rememberSeat, restoreSeats, 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';
|
||||
@@ -26,6 +26,14 @@
|
||||
let busy = $state(false);
|
||||
let error = $state('');
|
||||
let seats = $state(allSeats());
|
||||
// The bank is asked once on arrival: a browser whose storage was cleared has its seats again before it misses them.
|
||||
$effect(() => {
|
||||
void restoreSeats()
|
||||
.then((found) => {
|
||||
if (found) seats = allSeats();
|
||||
})
|
||||
.catch(() => {});
|
||||
});
|
||||
let games = $state<Record<string, RoomView<State> | null>>({});
|
||||
let aboutOpen = $state(false);
|
||||
/** The ledgers' tally, fetched when the about panel opens. */
|
||||
@@ -112,6 +120,30 @@
|
||||
}
|
||||
}
|
||||
|
||||
let finding = $state(false);
|
||||
/** What a search for lost seats turned up, in words. */
|
||||
let found = $state('');
|
||||
/** Seats under this name at tables still in play: those last used from here are taken back; the rest are named, to ask the host. */
|
||||
async function findSeats() {
|
||||
finding = true;
|
||||
error = '';
|
||||
found = '';
|
||||
try {
|
||||
rememberName(name);
|
||||
const r = await api.findSeats(name.trim());
|
||||
for (const s of r.seated) rememberSeat(s.roomId, { seat: s.seat, token: s.token });
|
||||
if (r.seated.length) seats = allSeats();
|
||||
const back = r.seated.length ? `Seated again at ${r.seated.map((s) => s.roomId).join(', ')}.` : '';
|
||||
const others = r.elsewhere.map((s) => `${s.roomId} with ${s.players.join(', ') || 'nobody yet'}`);
|
||||
const ask = others.length ? ` ${r.seated.length ? 'Also at' : 'At'} ${others.join('; ')}: that seat was last used from another address, so ask the host there for a transfer phrase, or write to the keeper.` : '';
|
||||
found = back + ask || 'No table still in play has a seat under that name.';
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'The search did not go through.';
|
||||
} finally {
|
||||
finding = false;
|
||||
}
|
||||
}
|
||||
|
||||
// The desk is asked for every seat this browser holds, again whenever that set changes.
|
||||
$effect(() => {
|
||||
const held = seats;
|
||||
@@ -244,6 +276,8 @@
|
||||
<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>
|
||||
<p class="muted small lost">Lost your games? If this browser forgot them, <button type="button" class="link" disabled={!ready || finding} onclick={findSeats}>find the seats under your name</button>.</p>
|
||||
{#if found}<p class="muted small">{found}</p>{/if}
|
||||
{#if error}<p class="warning">{error}</p>{/if}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -42,6 +42,29 @@ export function forgetSeat(roomId: string): void {
|
||||
} catch {
|
||||
// Nothing to forget without storage.
|
||||
}
|
||||
void bankSeats([roomId]).catch(() => {});
|
||||
}
|
||||
|
||||
/**
|
||||
* The seats this browser holds are banked with the server as well, in a cookie it sets and the browser keeps for
|
||||
* it alone. A browser whose storage is cleared, or that Safari clears for it after a week away, still has the
|
||||
* cookie, and gets its seats back from it. The answer is every seat the bank holds that still opens.
|
||||
*/
|
||||
export async function bankSeats(forget: string[] = []): Promise<{ roomId: string; seat: SeatId; token: string }[]> {
|
||||
const seats = allSeats().map(({ roomId, token }) => ({ roomId, token }));
|
||||
const { seats: banked } = await call<{ seats: { roomId: string; seat: SeatId; token: string }[] }>('POST', '/api/seats', { seats, forget });
|
||||
return banked;
|
||||
}
|
||||
|
||||
/** The seats the bank holds that this browser does not, taken into storage; true when there were any. */
|
||||
export async function restoreSeats(): Promise<boolean> {
|
||||
let found = false;
|
||||
for (const s of await bankSeats()) {
|
||||
if (seatFor(s.roomId)) continue;
|
||||
store(s.roomId, { seat: s.seat, token: s.token });
|
||||
found = true;
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -92,7 +115,7 @@ export function trustSeat(roomId: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
export function rememberSeat(roomId: string, held: HeldSeat): void {
|
||||
function store(roomId: string, held: HeldSeat): void {
|
||||
try {
|
||||
localStorage.setItem(SEATS_KEY, JSON.stringify({ ...heldSeats(), [roomId]: held }));
|
||||
} catch {
|
||||
@@ -100,6 +123,12 @@ export function rememberSeat(roomId: string, held: HeldSeat): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** A seat taken is kept here and banked with the server, each time, so the two agree. */
|
||||
export function rememberSeat(roomId: string, held: HeldSeat): void {
|
||||
store(roomId, held);
|
||||
void bankSeats().catch(() => {});
|
||||
}
|
||||
|
||||
export class ServerError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
@@ -152,6 +181,9 @@ export function makeApi<State, Input>() {
|
||||
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 }),
|
||||
/** The seats a name holds at tables not over: those last used from this address come back seated; the rest are named. */
|
||||
findSeats: (name: string, roomId?: string) =>
|
||||
call<{ seated: { roomId: string; seat: SeatId; token: string }[]; elsewhere: { roomId: string; seat: SeatId; players: string[] }[] }>('POST', '/api/seats/find', { name, ...(roomId ? { roomId } : {}) }),
|
||||
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) =>
|
||||
|
||||
@@ -117,12 +117,45 @@
|
||||
room = await Room.join(roomId, name.trim());
|
||||
joining = false;
|
||||
} catch (err) {
|
||||
// A seat already under this name may be this person's own, lost with the browser's storage: it is looked for first.
|
||||
if (err instanceof ServerError && err.status === 409 && (await sitBackDown())) return;
|
||||
error = err instanceof Error ? err.message : 'The seat could not be taken.';
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** The seat under this name at this table, back again if it was last used from this address; true when it was. */
|
||||
async function sitBackDown(): Promise<boolean> {
|
||||
const r = await api.findSeats(name.trim(), roomId).catch(() => null);
|
||||
const mine = r?.seated.find((s) => s.roomId === roomId);
|
||||
if (!mine) {
|
||||
if (r?.elsewhere.length) error = `A seat here is under that name, last used from another address. Ask the host for a transfer phrase, or write to the keeper.`;
|
||||
return false;
|
||||
}
|
||||
rememberSeat(roomId, { seat: mine.seat, token: mine.token });
|
||||
trustSeat(roomId);
|
||||
seatLost = '';
|
||||
room = await Room.resume(roomId, { seat: mine.seat, token: mine.token });
|
||||
joining = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** From the gallery of a game begun: the seat under this name, if it was last used from here. */
|
||||
async function findMySeat(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
busy = true;
|
||||
error = '';
|
||||
try {
|
||||
rememberName(name);
|
||||
if (!(await sitBackDown()) && !error) error = 'No seat here is under that name.';
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'The seat could not be found.';
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function copyLink() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(link);
|
||||
@@ -247,7 +280,11 @@
|
||||
<div class="notice muted">
|
||||
{#if seatLost}<p>{seatLost} Claim it here, or <a href="mailto:eric@ericwagoner.com?subject={encodeURIComponent(`My seat in room ${roomId}`)}">write to the keeper</a>.</p>
|
||||
{:else}{#if room.view.expected.length > 0}<p>{room.nameOf(room.view.host)} began the game before {room.view.expected.join(' and ')} arrived, and the seat held for {room.view.expected.length === 1 ? 'them' : 'each'} went with it.</p>{/if}
|
||||
<p>Had a seat at this table? Ask the host for a transfer phrase, or make one on the device that still holds the seat, and claim it here. 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}
|
||||
<p>Had a seat at this table? Give your name, and if the seat was last used from here it is yours again. Otherwise ask the host for a transfer phrase, or make one on the device that still holds the seat, and claim it here. 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}
|
||||
<form class="sit" onsubmit={findMySeat}>
|
||||
<input type="text" bind:value={name} maxlength={NAME_MAX} placeholder="your name at this table" autocomplete="nickname" aria-label="your name at this table" />
|
||||
<button type="submit" class="quiet" disabled={busy || !name.trim()}>Sit back down</button>
|
||||
</form>
|
||||
<form class="sit" onsubmit={claim}>
|
||||
<input type="text" bind:value={claimPhrase} placeholder="the four-word phrase" autocomplete="off" aria-label="transfer phrase" />
|
||||
<button type="submit" class="quiet" disabled={claiming || claimPhrase.trim().split(/[\s-]+/).length < 4}>Claim the seat</button>
|
||||
|
||||
Reference in new issue
Block a user