The host has a hand on the other seats: once the game begins, Seats makes a transfer phrase for another person's seat, which gives it a new token when claimed, and gives the seat of a player who cannot go on to a bot, which plays the move the seat owes at once and speaks nothing in their name; the person's token opens nothing while it stands in, their page goes to the gallery with the reason and a claim box, and a rematch holds their seat without the stand-in; the server pings every socket and closes one a dead browser left open

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
This commit is contained in:
Eric WagonerandClaude Opus 5.5 committed 2026-10-16 19:04:33 -04:00
1 parent c439c15e36
commit 09a8b675ad
15 files changed
+462 -35

No files matched your search

+13 -1
View File
@@ -55,7 +55,19 @@ README.
`/keeper` door, which Caddy does not proxy and which asks for the key in `/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 `/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 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. is told the ways back, and the hall asks before forgetting a game in play.
- **The host has a hand on the other seats**: once the game begins, *Seats*
in the host's masthead makes a transfer phrase for another person's seat
(claimed, it gives the seat a new token, as the keeper's link does), and
gives the seat of a player who cannot go on to a bot, which plays it
from the move the seat owes and does not speak in the person's name.
The person's token opens nothing while it stands in, with the reason;
their page goes to the gallery, where a claim box takes the host's
phrase. A rematch holds the person's seat and leaves the stand-in out.
- **A dead browser's socket is closed**: the server pings every socket on
the protocol's own ping, which browsers answer by themselves, and closes
one silent for a whole round, so a crashed tab does not linger in the
gallery's count.
- **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
+3 -2
View File
@@ -4,7 +4,8 @@ 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 and come back by the keeper's seats that travel between devices by phrase and come back by the keeper's
link when a browser loses them, the keeper's bell, append-only ledgers link or the host's phrase when a browser loses them, a bot to stand in
for a player who cannot go on, the keeper's bell, append-only ledgers
replayed through a deterministic engine, the rules revision, the reports replayed through a deterministic engine, the rules revision, the reports
desk, the deploy script with its determinism gate, nightly rollup and desk, the deploy script with its determinism gate, nightly rollup and
backup, Sentry with Slack alerts, and the visitors, pulse and reports backup, Sentry with Slack alerts, and the visitors, pulse and reports
@@ -46,7 +47,7 @@ Run the demo locally from a new project:
src/lib/game/spec.ts the contract a game implements src/lib/game/spec.ts the contract a game implements
src/lib/game/index.ts the demo game; replace it src/lib/game/index.ts the demo game; replace it
src/lib/net/ client.ts (calls, held seats, socket), room.svelte.ts (the room store), talk.ts, view.ts src/lib/net/ client.ts (calls, held seats, socket), room.svelte.ts (the room store), talk.ts, view.ts
src/lib/components/ Hall, Lobby, TableTalk, ReportSlip, Board (demo; replace it) src/lib/components/ Hall, Lobby, TableTalk, ReportSlip, SeatsSlip, Board (demo; replace it)
src/routes/ the hall, /join/[code], /rules, /guide src/routes/ the hall, /join/[code], /rules, /guide
server/src/ index (routes), rooms (game-agnostic), store (ledger), reports (the desk), tally, ratelimit, and their tests server/src/ index (routes), rooms (game-agnostic), store (ledger), reports (the desk), tally, ratelimit, and their tests
deploy/ deploy, setup, Caddy, systemd, cron, gate + replay, backup, rollup, pulse, visitors, Slack alert, reports tools deploy/ deploy, setup, Caddy, systemd, cron, gate + replay, backup, rollup, pulse, visitors, Slack alert, reports tools
+1 -1
View File
@@ -19,7 +19,7 @@ r = json.load(sys.stdin)
if "error" in r: sys.exit(r["error"]) if "error" in r: sys.exit(r["error"])
print("room %s, %s" % (r["roomId"], "in play" if r["started"] else "not begun")) print("room %s, %s" % (r["roomId"], "in play" if r["started"] else "not begun"))
for s in r["seats"]: 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") when = "bot" if s["bot"] else ("a bot stands in; " if s.get("standIn") 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)) print(" %-3s %-24s %s" % (s["id"], s["name"], when))
' '
else else
+8 -2
View File
@@ -42,7 +42,7 @@ for f in glob.glob("/var/lib/__SLUG__/rooms/*.jsonl"):
continue continue
created = datetime.datetime.fromtimestamp(meta["createdAt"] / 1000, datetime.timezone.utc) created = datetime.datetime.fromtimestamp(meta["createdAt"] / 1000, datetime.timezone.utc)
humans, bots_seated, started, turns, last, over, talk = [], [], False, 0, created, None, 0 humans, bots_seated, started, turns, last, over, talk = [], [], False, 0, created, None, 0
names = {} names, seat_changes = {}, []
for x in lines[1:]: for x in lines[1:]:
if x["t"] == "seat": if x["t"] == "seat":
(bots_seated if x["bot"] else humans).append(x["name"]) (bots_seated if x["bot"] else humans).append(x["name"])
@@ -56,12 +56,16 @@ for f in glob.glob("/var/lib/__SLUG__/rooms/*.jsonl"):
last = datetime.datetime.fromtimestamp(x["at"] / 1000, datetime.timezone.utc) last = datetime.datetime.fromtimestamp(x["at"] / 1000, datetime.timezone.utc)
elif x["t"] == "chat": elif x["t"] == "chat":
talk += 1 talk += 1
elif x["t"] == "standIn":
seat_changes.append("%s %s's seat to a bot" % (datetime.datetime.fromtimestamp(x["at"] / 1000, datetime.timezone.utc).strftime("%H:%M"), names.get(x["id"], x["id"])))
elif x["t"] == "reseat":
seat_changes.append("%s %s re-seated" % (datetime.datetime.fromtimestamp(x["at"] / 1000, datetime.timezone.utc).strftime("%H:%M"), names.get(x["id"], x["id"])))
elif x["t"] == "over": elif x["t"] == "over":
over = "a draw" if x["winner"] is None else "%s won" % names.get(x["winner"], x["winner"]) over = "a draw" if x["winner"] is None else "%s won" % names.get(x["winner"], x["winner"])
for h in humans: for h in humans:
if h not in first_seen or created < first_seen[h]: if h not in first_seen or created < first_seen[h]:
first_seen[h] = created first_seen[h] = created
rooms.append(dict(id=meta["id"], size=meta["seats"], created=created, humans=humans, bots=bots_seated, started=started, turns=turns, last=last, over=over, talk=talk)) rooms.append(dict(id=meta["id"], size=meta["seats"], created=created, humans=humans, bots=bots_seated, started=started, turns=turns, last=last, over=over, talk=talk, seat_changes=seat_changes))
today = sorted([r for r in rooms if r["created"].strftime("%F") == day], key=lambda r: r["created"]) today = sorted([r for r in rooms if r["created"].strftime("%F") == day], key=lambda r: r["created"])
names = sorted({h for r in today for h in r["humans"]}) names = sorted({h for r in today for h in r["humans"]})
@@ -78,4 +82,6 @@ for r in today:
r["created"].strftime("%H:%M"), r["id"], seated, (" (%d seat%s empty)" % (empty, "" if empty == 1 else "s")) if empty else "", r["created"].strftime("%H:%M"), r["id"], seated, (" (%d seat%s empty)" % (empty, "" if empty == 1 else "s")) if empty else "",
state, r["turns"], span, r["last"].strftime("%H:%M") if r["turns"] else "never", state, r["turns"], span, r["last"].strftime("%H:%M") if r["turns"] else "never",
(" %d line%s of talk" % (r["talk"], "" if r["talk"] == 1 else "s")) if r["talk"] else "")) (" %d line%s of talk" % (r["talk"], "" if r["talk"] == 1 else "s")) if r["talk"] else ""))
for change in r["seat_changes"]:
print(" %s" % change)
PY PY
+30
View File
@@ -16,6 +16,8 @@
// POST /api/rooms/:id/challenge {token} call the keeper to a held seat; their phone rings // POST /api/rooms/:id/challenge {token} call the keeper to a held seat; their phone rings
// POST /api/rooms/:id/rematch {token} a finished table opens the next one // POST /api/rooms/:id/rematch {token} a finished table opens the next one
// POST /api/rooms/:id/transfer {token} mint a phrase that carries this seat to another device // POST /api/rooms/:id/transfer {token} mint a phrase that carries this seat to another device
// 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/transfer {phrase} claim one
// POST /api/rooms/:id/report {token?, happened, expected} a report to the keeper, pinned to the round // 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/image <image bytes> a screenshot for a report just filed
@@ -83,6 +85,8 @@ const bells = new RateLimit(3, 60 * 60 * 1000);
/** Claims on transfer phrases: a phrase is guessed, not brute-forced. */ /** Claims on transfer phrases: a phrase is guessed, not brute-forced. */
const claims = new RateLimit(10, 10 * 60 * 1000); const claims = new RateLimit(10, 10 * 60 * 1000);
const MAX_AUDIENCE = 30; const MAX_AUDIENCE = 30;
/** How often each socket is asked to answer; one silent through a whole round is a browser gone, and is closed. */
const PING_MS = Number(process.env.PING_MS ?? 30_000);
const IDLE_ROOM_MS = 7 * 24 * 60 * 60 * 1000; const IDLE_ROOM_MS = 7 * 24 * 60 * 60 * 1000;
setInterval(() => { setInterval(() => {
const n = rooms.evictIdle(IDLE_ROOM_MS); const n = rooms.evictIdle(IDLE_ROOM_MS);
@@ -317,6 +321,13 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise<void>
if (action === 'transfer') { if (action === 'transfer') {
return send(res, 200, rooms.transfer(room, token)); return send(res, 200, rooms.transfer(room, token));
} }
if (action === 'phrase') {
return send(res, 200, rooms.hostPhrase(room, token, body.seat));
}
if (action === 'standin') {
rooms.standIn(room, token, body.seat);
return send(res, 200, rooms.view(room, rooms.seatOf(room, token).id));
}
if (action === 'report') { if (action === 'report') {
if (!desk.allow(clientOf(req))) throw new RoomError(DESK_BUSY, 429); if (!desk.allow(clientOf(req))) throw new RoomError(DESK_BUSY, 429);
const seat = token ? rooms.seatOf(room, token) : null; const seat = token ? rooms.seatOf(room, token) : null;
@@ -362,11 +373,30 @@ server.on('upgrade', (req, socket, head) => {
}; };
const unsubscribe = seatId === SPECTATOR ? rooms.watch(room, onChange) : rooms.subscribe(room.id, onChange); const unsubscribe = seatId === SPECTATOR ? rooms.watch(room, onChange) : rooms.subscribe(room.id, onChange);
ws.on('close', unsubscribe); ws.on('close', unsubscribe);
answered.set(ws, true);
ws.on('pong', () => answered.set(ws, true));
// A socket's own error (a message over the limit, a malformed frame) closes that socket, never the server. // A socket's own error (a message over the limit, a malformed frame) closes that socket, never the server.
ws.on('error', () => ws.terminate()); ws.on('error', () => ws.terminate());
}); });
}); });
/**
* A browser that died without closing (a crashed tab, a sleeping laptop, a dropped network) leaves its socket
* open until TCP gives up, counted in the gallery all the while. The protocol's own ping, which every browser
* answers by itself, finds it within two rounds.
*/
const answered = new WeakMap<WebSocket, boolean>();
setInterval(() => {
for (const ws of wss.clients) {
if (!answered.get(ws)) {
ws.terminate();
continue;
}
answered.set(ws, false);
ws.ping();
}
}, PING_MS).unref();
server.listen(PORT, HOST, () => { server.listen(PORT, HOST, () => {
console.log(`__SLUG__ server listening on http://${HOST}:${PORT}, rooms in ${DATA_DIR}`); console.log(`__SLUG__ server listening on http://${HOST}:${PORT}, rooms in ${DATA_DIR}`);
}); });
+75
View File
@@ -231,3 +231,78 @@ describe('rooms', () => {
}); });
}); });
}); });
describe('the host and the other seats', () => {
it("gives a person's seat to a bot, which plays it at once, and the person's token opens nothing", () => {
const { rooms, room, ann, bo } = table();
rooms.submit(room, ann, { pick: 5 }, 0);
rooms.standIn(room, ann, 'B');
// The round waited on Bo alone; the bot played it.
expect(room.resolved).toBe(1);
expect(rooms.waitingOn(room)).toEqual(['A']);
expect(() => rooms.seatOf(room, bo)).toThrow(/gave your seat to a bot/);
expect(rooms.view(room, 'A').seats[1]).toEqual({ id: 'B', name: 'Bo', bot: true, standIn: true });
rooms.submit(room, ann, { pick: 3 }, 1);
expect(room.resolved).toBe(2);
});
it('keeps a move the person made before the bot stood in', () => {
const { rooms, room, ann, bo } = table();
rooms.submit(room, bo, { pick: 2 }, 0);
rooms.standIn(room, ann, 'B');
rooms.submit(room, ann, { pick: 5 }, 0);
expect(room.state!.rounds[0].picks).toEqual({ A: 5, B: 2 });
});
it('is the host\'s call alone, in a game under way, for a person other than the host', () => {
const { rooms, room, ann, bo } = table();
expect(() => rooms.standIn(room, bo, 'A')).toThrow(/Only the player who opened/);
expect(() => rooms.standIn(room, ann, 'A')).toThrow(/keeps their own seat/);
expect(() => rooms.standIn(room, ann, 'Z')).toThrow(/No person sits/);
const lobby = rooms.create('Ann', 2);
rooms.join(lobby.room, 'Bo');
expect(() => rooms.standIn(lobby.room, lobby.token, 'B')).toThrow(/under way/);
});
it('gives the seat back by the host\'s phrase, with a new token, and a restart keeps both changes', () => {
const { rooms, room, ann, bo } = table();
rooms.standIn(room, ann, 'B');
expect(new Rooms(game, new Store(dir)).get(room.id).seats[1]).toMatchObject({ bot: true, standIn: true });
const claimed = rooms.claim(rooms.hostPhrase(room, ann, 'B').phrase);
expect(claimed.seat).toBe('B');
expect(rooms.seatOf(room, claimed.token).name).toBe('Bo');
expect(() => rooms.seatOf(room, bo)).toThrow(/opens no seat/);
expect(rooms.waitingOn(room)).toContain('B');
const reopened = new Rooms(game, new Store(dir)).get(room.id);
expect(reopened.seats[1]).toEqual({ id: 'B', name: 'Bo', tokenHash: room.seats[1].tokenHash, bot: false });
});
it("re-seats a person whose browser lost the seat, by the host's phrase, once", () => {
const { rooms, room, ann, bo } = table();
const { phrase } = rooms.hostPhrase(room, ann, 'B');
const claimed = rooms.claim(phrase);
expect(claimed.token).not.toBe(bo);
expect(() => rooms.seatOf(room, bo)).toThrow(/opens no seat/);
expect(() => rooms.claim(phrase)).toThrow(/unknown or has expired/);
});
it("makes a phrase only for the host, and only for another person's seat", () => {
const { rooms, room, ann, bo } = table();
expect(() => rooms.hostPhrase(room, bo, 'A')).toThrow(/Only the player who opened/);
expect(() => rooms.hostPhrase(room, ann, 'A')).toThrow(/No other person/);
const withBot = rooms.create('Ann', 2);
const bot = rooms.addBot(withBot.room, withBot.token);
expect(() => rooms.hostPhrase(withBot.room, withBot.token, bot.id)).toThrow(/No other person/);
});
it('voids a phrase for the seat once a bot stands in, and holds the person\'s seat at a rematch without the stand-in', () => {
const { rooms, room, ann } = table();
const { phrase } = rooms.hostPhrase(room, ann, 'B');
rooms.standIn(room, ann, 'B');
expect(() => rooms.claim(phrase)).toThrow(/unknown or has expired/);
while (!game.over(room.state!)) rooms.submit(room, ann, { pick: 5 }, room.resolved);
const next = rooms.get(rooms.rematch(room, ann).roomId);
expect(next.expected).toEqual(['Bo']);
expect(next.seats.map((s) => s.name)).toEqual(['Ann']);
});
});
+84 -20
View File
@@ -24,9 +24,12 @@ export { SPECTATOR };
export interface Seat { export interface Seat {
id: SeatId; id: SeatId;
name: string; name: string;
/** SHA-256 of the seat's token, hex; empty for a bot. */ /** SHA-256 of the seat's token, hex; empty for a bot. A seat a bot stands in for keeps the person's, which opens nothing while it does. */
tokenHash: string; tokenHash: string;
/** A bot plays this seat: one seated as a bot, or one standing in for a person. */
bot: boolean; 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;
} }
function hashToken(token: string): string { function hashToken(token: string): string {
@@ -119,8 +122,8 @@ export class Rooms<State, Input> {
private listeners = new Map<string, Set<(room: Room<State>) => void>>(); private listeners = new Map<string, Set<(room: Room<State>) => void>>();
/** Sockets watching from the gallery, by room. */ /** Sockets watching from the gallery, by room. */
private gallery = new Map<string, number>(); private gallery = new Map<string, number>();
/** Phrases waiting to be claimed, with the seat and the token they carry. */ /** Phrases waiting to be claimed, with the seat and the token they carry; the host's carry none, and re-seat on claim. */
private transfers = new Map<string, { roomId: string; seatId: SeatId; token: string; expiresAt: number }>(); private transfers = new Map<string, { roomId: string; seatId: SeatId; token: string | null; expiresAt: number }>();
private failedClaims = 0; private failedClaims = 0;
private failWindowStart = 0; private failWindowStart = 0;
/** Tables whose bots are playing on, paced: the next round's timer. */ /** Tables whose bots are playing on, paced: the next round's timer. */
@@ -190,7 +193,8 @@ export class Rooms<State, Input> {
seatOf(room: Room<State>, token: string | undefined): Seat { seatOf(room: Room<State>, token: string | undefined): Seat {
if (!token) throw new RoomError('That token opens no seat at this game.', 403); if (!token) throw new RoomError('That token opens no seat at this game.', 403);
const given = Buffer.from(hashToken(token), 'hex'); const given = Buffer.from(hashToken(token), 'hex');
const seat = room.seats.find((s) => !s.bot && s.tokenHash.length === given.length * 2 && timingSafeEqual(Buffer.from(s.tokenHash, 'hex'), given)); const seat = room.seats.find((s) => s.tokenHash.length === given.length * 2 && timingSafeEqual(Buffer.from(s.tokenHash, 'hex'), given));
if (seat?.standIn) throw new RoomError('The host gave your seat to a bot, which plays on in your place. Ask them for a transfer phrase to take it back.', 403);
if (!seat) throw new RoomError('That token opens no seat at this game.', 403); if (!seat) throw new RoomError('That token opens no seat at this game.', 403);
return seat; return seat;
} }
@@ -270,9 +274,10 @@ export class Rooms<State, Input> {
const caller = this.seatOf(room, token); const caller = this.seatOf(room, token);
if (!room.state || !this.game.over(room.state)) throw new RoomError('The game is not over yet.', 409); if (!room.state || !this.game.over(room.state)) throw new RoomError('The game is not over yet.', 409);
if (room.rematch) return { roomId: room.rematch.to, seat: null, token: null, created: false }; if (room.rematch) return { roomId: room.rematch.to, seat: null, token: null, created: false };
const others = room.seats.filter((s) => !s.bot && s.id !== caller.id).map((s) => s.name); // A person a bot stood in for is still one of the company; the stand-in does not follow.
const others = room.seats.filter((s) => (!s.bot || s.standIn) && s.id !== caller.id).map((s) => s.name);
const next = this.open(caller.name, room.size, room.options, { rematchOf: room.id, expected: others }); const next = this.open(caller.name, room.size, room.options, { rematchOf: room.id, expected: others });
for (const bot of room.seats.filter((s) => s.bot)) this.sit(next.room, bot.name, true); for (const bot of room.seats.filter((s) => s.bot && !s.standIn)) this.sit(next.room, bot.name, true);
this.commit(room, { t: 'rematch', at: Date.now(), to: next.room.id, by: caller.id }); this.commit(room, { t: 'rematch', at: Date.now(), to: next.room.id, by: caller.id });
return { roomId: next.room.id, seat: next.seat, token: next.token, created: true }; return { roomId: next.room.id, seat: next.seat, token: next.token, created: true };
} }
@@ -280,6 +285,22 @@ export class Rooms<State, Input> {
/** Mint a phrase that brings this seat to another device: four words, ten minutes, one use. */ /** Mint a phrase that brings this seat to another device: four words, ten minutes, one use. */
transfer(room: Room<State>, token: string | undefined): { phrase: string; expiresAt: number } { transfer(room: Room<State>, token: string | undefined): { phrase: string; expiresAt: number } {
const seat = this.seatOf(room, token); const seat = this.seatOf(room, token);
return this.mint(room.id, seat.id, token!);
}
/**
* The host mints a phrase for another person's seat: for a player whose browser lost it, or to take it back
* from a bot standing in. Claimed, it gives the seat a new token, so the old one opens nothing after.
*/
hostPhrase(room: Room<State>, token: string | undefined, seatId: unknown): { phrase: string; expiresAt: number } {
const host = this.seatOf(room, token);
if (host.id !== room.seats[0]?.id) throw new RoomError('Only the player who opened the table may make a phrase for another seat.', 403);
const seat = room.seats.find((s) => s.id === seatId && s.id !== host.id && (!s.bot || s.standIn));
if (!seat) throw new RoomError('No other person sits in that seat.', 404);
return this.mint(room.id, seat.id, null);
}
private mint(roomId: string, seatId: SeatId, token: string | null): { phrase: string; expiresAt: number } {
const now = Date.now(); const now = Date.now();
for (const [phrase, t] of this.transfers) if (t.expiresAt < now) this.transfers.delete(phrase); for (const [phrase, t] of this.transfers) if (t.expiresAt < now) this.transfers.delete(phrase);
if (this.transfers.size >= 200) throw new RoomError('Too many transfers are pending; try again in a few minutes.', 429); if (this.transfers.size >= 200) throw new RoomError('Too many transfers are pending; try again in a few minutes.', 429);
@@ -288,7 +309,7 @@ export class Rooms<State, Input> {
phrase = Array.from({ length: 4 }, () => TRANSFER_WORDS[randomBytes(1)[0] % TRANSFER_WORDS.length]).join('-'); phrase = Array.from({ length: 4 }, () => TRANSFER_WORDS[randomBytes(1)[0] % TRANSFER_WORDS.length]).join('-');
} while (this.transfers.has(phrase)); } while (this.transfers.has(phrase));
const expiresAt = now + TRANSFER_TTL_MS; const expiresAt = now + TRANSFER_TTL_MS;
this.transfers.set(phrase, { roomId: room.id, seatId: seat.id, token: token!, expiresAt }); this.transfers.set(phrase, { roomId, seatId, token, expiresAt });
return { phrase, expiresAt }; return { phrase, expiresAt };
} }
@@ -309,6 +330,10 @@ export class Rooms<State, Input> {
} }
this.transfers.delete(phrase); this.transfers.delete(phrase);
const room = this.get(t.roomId); const room = this.get(t.roomId);
if (t.token === null) {
const { seat, token } = this.reseat(room, t.seatId);
return { roomId: room.id, seat: seat.id, token };
}
const seat = this.seatOf(room, t.token); const seat = this.seatOf(room, t.token);
return { roomId: room.id, seat: seat.id, token: t.token }; return { roomId: room.id, seat: seat.id, token: t.token };
} }
@@ -407,27 +432,49 @@ export class Rooms<State, Input> {
} }
/** /**
* The keeper gives a person's seat a new token, for a player whose browser lost the old one. Whether the one * A person's seat gets a new token, for a player whose browser lost the old one, or one taking it back from a
* asking holds the seat is the keeper's judgment; this carries it out. The old token opens nothing after. * bot standing in. Whether the one asking holds the seat is the keeper's judgment, or the host's in the phrase
* they made; this carries it out. The old token opens nothing after.
*/ */
reseat(room: Room<State>, seatId: unknown): { seat: Seat; token: string } { reseat(room: Room<State>, seatId: unknown): { seat: Seat; token: string } {
const seat = room.seats.find((s) => s.id === seatId && !s.bot); const seat = room.seats.find((s) => s.id === seatId && (!s.bot || s.standIn));
if (!seat) throw new RoomError('No person sits in that seat.', 404); if (!seat) throw new RoomError('No person sits in that seat.', 404);
const token = newId(18); const token = newId(18);
this.commit(room, { t: 'reseat', at: Date.now(), id: seat.id, tokenHash: hashToken(token) }); 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. this.voidPhrases(room, seat.id);
for (const [phrase, t] of this.transfers) if (t.roomId === room.id && t.seatId === seat.id) this.transfers.delete(phrase);
return { seat, token }; return { seat, token };
} }
/**
* The host gives a person's seat to a bot, for a player who cannot go on: the bot plays it from the next move
* the seat owes, and the person's token opens nothing until a phrase from the host gives it back.
*/
standIn(room: Room<State>, token: string | undefined, seatId: unknown): void {
const host = this.seatOf(room, token);
if (host.id !== room.seats[0]?.id) throw new RoomError('Only the player who opened the table may give a seat to a bot.', 403);
if (!room.state || this.game.over(room.state)) throw new RoomError('Only a game under way needs a stand-in.', 409);
if (seatId === host.id) throw new RoomError('The host keeps their own seat.', 409);
const seat = room.seats.find((s) => s.id === seatId && !s.bot);
if (!seat) throw new RoomError('No person sits in that seat.', 404);
this.commit(room, { t: 'standIn', at: Date.now(), id: seat.id, by: host.id });
this.voidPhrases(room, seat.id);
// The round may have been waiting on this seat alone. Bots already playing on take the new one in their stride.
if (!this.pacing.has(room.id) && this.waitingOn(room).length === 0) this.resolve(room);
}
/** A phrase minted for a seat whose token has changed hands would carry a seat that no longer answers to it. */
private voidPhrases(room: Room<State>, seatId: SeatId): void {
for (const [phrase, t] of this.transfers) if (t.roomId === room.id && t.seatId === seatId) this.transfers.delete(phrase);
}
/** The table as the keeper sees it before re-seating: who sits where, and when each person last moved or spoke. */ /** 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 }[] { seatsForKeeper(room: Room<State>): { id: SeatId; name: string; bot: boolean; standIn: boolean; lastActed: number | null }[] {
const last = new Map<SeatId, number>(); const last = new Map<SeatId, number>();
for (const line of this.store.read(room.id)) { for (const line of this.store.read(room.id)) {
if (line.t === 'input' || line.t === 'chat') last.set(line.id, line.at); 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); 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) })); return room.seats.map((s) => ({ id: s.id, name: s.name, bot: s.bot && !s.standIn, standIn: !!s.standIn, lastActed: s.bot && !s.standIn ? 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. */
@@ -479,7 +526,12 @@ export class Rooms<State, Input> {
private resolve(room: Room<State>): void { private resolve(room: Room<State>): void {
const inputs: Record<SeatId, unknown> = { ...room.pending }; const inputs: Record<SeatId, unknown> = { ...room.pending };
for (const seat of room.seats) if (seat.bot && this.game.needsInput(room.state!, seat.id)) inputs[seat.id] = this.game.botInput(room.state!, seat.id, this.heardBy(room, seat.id)); // A move a person settled before a bot stood in for them stands.
for (const seat of room.seats) {
if (seat.bot && this.game.needsInput(room.state!, seat.id) && !(seat.id in inputs && this.settled(inputs[seat.id]))) {
inputs[seat.id] = this.game.botInput(room.state!, seat.id, this.heardBy(room, seat.id));
}
}
this.commit(room, { t: 'turn', at: Date.now(), inputs }); this.commit(room, { t: 'turn', at: Date.now(), inputs });
this.driveBots(room); this.driveBots(room);
this.botsSpeak(room, 'phase'); this.botsSpeak(room, 'phase');
@@ -494,7 +546,8 @@ export class Rooms<State, Input> {
if (!this.game.botPress || !room.state) return; if (!this.game.botPress || !room.state) return;
const people = room.seats.filter((s) => !s.bot).map((s) => s.id); const people = room.seats.filter((s) => !s.bot).map((s) => s.id);
for (const seat of room.seats) { for (const seat of room.seats) {
if (!seat.bot || (only && !only.includes(seat.id))) continue; // A stand-in plays a person's seat; it does not speak in their name.
if (!seat.bot || seat.standIn || (only && !only.includes(seat.id))) continue;
for (const line of this.game.botPress(room.state, seat.id, this.heardBy(room, seat.id), people, trigger)) { for (const line of this.game.botPress(room.state, seat.id, this.heardBy(room, seat.id), people, trigger)) {
const to = line.to.filter((id) => people.includes(id)); const to = line.to.filter((id) => people.includes(id));
if (!to.length || !line.text.trim()) continue; if (!to.length || !line.text.trim()) continue;
@@ -524,13 +577,14 @@ export class Rooms<State, Input> {
? [...new Set(rawTo.filter((id): id is SeatId => typeof id === 'string' && id !== seat.id && room.seats.some((s) => s.id === id)))] ? [...new Set(rawTo.filter((id): id is SeatId => typeof id === 'string' && id !== seat.id && room.seats.some((s) => s.id === id)))]
: []; : [];
if (Array.isArray(rawTo) && rawTo.length && !to.length) throw new RoomError('Nobody at this table answers to that.'); if (Array.isArray(rawTo) && rawTo.length && !to.length) throw new RoomError('Nobody at this table answers to that.');
const botsTo = to.filter((id) => room.seats.find((s) => s.id === id)?.bot); // A private line to a seat a bot stands in for waits for the person; the stand-in reads none.
const botsTo = to.filter((id) => room.seats.find((s) => s.id === id && s.bot && !s.standIn));
if (botsTo.length && !this.game.botPress) throw new RoomError('The bots at this table read no private talk.'); if (botsTo.length && !this.game.botPress) throw new RoomError('The bots at this table read no private talk.');
// A person's first word of the game, said to everyone, is heard by the bots too: the game chooses who answers. // A person's first word of the game, said to everyone, is heard by the bots too: the game chooses who answers.
const first = !to.length && !room.chat.some((l) => l.id === seat.id); const first = !to.length && !room.chat.some((l) => l.id === seat.id);
this.commit(room, { t: 'chat', at: Date.now(), id: seat.id, text: this.cleanTalk(raw), ...(to.length ? { to } : {}) }); this.commit(room, { t: 'chat', at: Date.now(), id: seat.id, text: this.cleanTalk(raw), ...(to.length ? { to } : {}) });
if (botsTo.length) this.botsSpeak(room, 'spoken', botsTo); if (botsTo.length) this.botsSpeak(room, 'spoken', botsTo);
else if (first) this.botsSpeak(room, 'spoken', room.seats.filter((s) => s.bot).map((s) => s.id)); else if (first) this.botsSpeak(room, 'spoken', room.seats.filter((s) => s.bot && !s.standIn).map((s) => s.id));
} }
private cleanTalk(raw: unknown): string { private cleanTalk(raw: unknown): string {
@@ -560,7 +614,7 @@ export class Rooms<State, Input> {
size: room.size, size: room.size,
me: seatId, me: seatId,
host: room.seats[0]?.id ?? seatId, host: room.seats[0]?.id ?? seatId,
seats: room.seats.map((s) => ({ id: s.id, name: s.name, bot: s.bot })), seats: room.seats.map((s) => ({ id: s.id, name: s.name, bot: s.bot, ...(s.standIn ? { standIn: true as const } : {}) })),
waitingOn: this.waitingOn(room), waitingOn: this.waitingOn(room),
turn: room.state ? this.game.turn(room.state) : null, turn: room.state ? this.game.turn(room.state) : null,
resolved: room.resolved, resolved: room.resolved,
@@ -635,7 +689,17 @@ export class Rooms<State, Input> {
break; break;
case 'reseat': { case 'reseat': {
const seat = room.seats.find((s) => s.id === line.id); const seat = room.seats.find((s) => s.id === line.id);
if (seat) seat.tokenHash = line.tokenHash; if (!seat) break;
seat.tokenHash = line.tokenHash;
seat.bot = false;
delete seat.standIn;
break;
}
case 'standIn': {
const seat = room.seats.find((s) => s.id === line.id);
if (!seat) break;
seat.bot = true;
seat.standIn = true;
break; break;
} }
case 'galleryTalk': case 'galleryTalk':
+6 -1
View File
@@ -11,8 +11,13 @@ 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. */ /**
* A person's seat got a new token, from the keeper or a phrase the host made, for a player whose browser lost the
* old one; the old one opens nothing after. A seat a bot was standing in for is the person's again.
*/
| { t: 'reseat'; at: number; id: SeatId; tokenHash: string } | { t: 'reseat'; at: number; id: SeatId; tokenHash: string }
/** The host gave a person's seat to a bot, which plays it from here on; a reseat gives it back. */
| { t: 'standIn'; at: number; id: SeatId; by: SeatId }
/** 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. */
+1 -1
View File
@@ -163,7 +163,7 @@
const g = games[held.roomId]; const g = games[held.roomId];
if (g === undefined) return 'looking'; if (g === undefined) return 'looking';
if (g === null) return 'unreachable'; if (g === null) return 'unreachable';
const others = g.seats.filter((s) => s.id !== held.seat).map((s) => (s.bot ? `${s.name} (the bot)` : s.name)); const others = g.seats.filter((s) => s.id !== held.seat).map((s) => (s.standIn ? `${s.name} (a bot stands in)` : s.bot ? `${s.name} (the bot)` : s.name));
if (!g.state) return `waiting to start${g.seats.length ? ` · ${g.seats.map((s) => s.name).join(', ')}` : ''}`; if (!g.state) return `waiting to start${g.seats.length ? ` · ${g.seats.map((s) => s.name).join(', ')}` : ''}`;
if (g.over) { if (g.over) {
if (g.over.winner === held.seat) return 'you won'; if (g.over.winner === held.seat) return 'you won';
@@ -0,0 +1,154 @@
<script lang="ts">
// The host's hand on the other seats during a game: a transfer phrase for a
// player whose browser lost their seat, and a bot to play the seat of one
// who cannot go on, until a phrase gives it back.
import type { SeatId } from '$lib/game/spec';
import type { Room } from '$lib/net/room.svelte';
let { room, open = $bindable(false) }: { room: Room; open?: boolean } = $props();
const people = $derived(room.view.seats.filter((s) => s.id !== room.me && (!s.bot || s.standIn)));
let asked = $state<SeatId | null>(null);
let phrase = $state<{ name: string; phrase: string; expiresAt: number } | null>(null);
let error = $state('');
function close() {
open = false;
asked = null;
error = '';
}
async function makePhrase(seat: SeatId, name: string) {
error = '';
try {
const made = await room.hostPhrase(seat);
phrase = { name, ...made };
setTimeout(() => {
if (phrase?.phrase === made.phrase) phrase = null;
}, Math.max(0, made.expiresAt - Date.now()));
} catch (e) {
error = e instanceof Error ? e.message : 'The phrase could not be made.';
}
}
async function standIn(seat: SeatId) {
asked = null;
if (!(await room.standIn(seat))) error = room.error;
}
function phraseTitle(name: string, standIn: boolean): string {
const why = standIn ? `Brings ${name} back from the bot.` : `For when ${name}'s browser lost the seat, or they need to play from another device.`;
return `${why} You get four words to pass to them. Within ten minutes, they enter them in the hall, or in the claim box while watching this table, and sit back down as ${name}. The phrase works once, and the browser they used before stops opening the seat.`;
}
</script>
<svelte:window onkeydown={(e) => open && e.key === 'Escape' && close()} />
{#if open}
<div class="scrim" role="presentation" onclick={(ev) => ev.target === ev.currentTarget && close()}>
<div class="slip" role="dialog" aria-labelledby="seats-heading">
<h2 id="seats-heading">The other seats</h2>
<p class="muted small">As the host, you can bring a player back to a seat their browser lost, or give the seat of one who cannot go on to a bot, which plays it at once.</p>
{#if people.length === 0}
<p class="muted">Nobody else at this table is a person.</p>
{/if}
<ul>
{#each people as s (s.id)}
<li>
<span class="name">{s.name}{#if s.standIn}<span class="muted note">a bot plays for them</span>{/if}</span>
<span class="ways">
<button type="button" class="quiet" title={phraseTitle(s.name, !!s.standIn)} onclick={() => makePhrase(s.id, s.name)}>{s.standIn ? 'Transfer phrase to take it back' : 'Make a transfer phrase'}</button>
{#if !s.standIn && !room.view.over}
{#if asked === s.id}
<button type="button" class="commit small" onclick={() => standIn(s.id)}>Yes, a bot plays for {s.name}</button>
<button type="button" class="link" onclick={() => (asked = null)}>no</button>
{:else}
<button type="button" class="quiet" title="A bot plays {s.name}'s seat from their next move, and their browser stops opening it. A transfer phrase gives it back." onclick={() => (asked = s.id)}>Give to a bot</button>
{/if}
{/if}
</span>
</li>
{/each}
</ul>
{#if phrase}
<p class="made">Give {phrase.name} this phrase to claim within ten minutes, in the hall or while watching this table: <b class="phrase">{phrase.phrase}</b>. It works once, and their old link stops opening the seat.</p>
{/if}
{#if error}<p class="warning">{error}</p>{/if}
<div class="actions"><button type="button" class="link" onclick={close}>close</button></div>
</div>
</div>
{/if}
<style>
.scrim {
position: fixed;
inset: 0;
z-index: 20;
background: rgba(0, 0, 0, 0.55);
display: grid;
grid-template-rows: minmax(0, 1fr);
place-items: center;
padding: 16px;
}
.slip {
width: min(100%, 34rem);
max-height: 100%;
overflow-y: auto;
background: var(--slate);
border: 1px solid var(--rule-strong);
border-radius: 6px;
padding: 1rem 1.2rem 1.2rem;
}
h2 {
font-size: 1.3rem;
margin-bottom: 0.4rem;
}
ul {
list-style: none;
padding: 0;
margin: 0.8rem 0 0;
}
li {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.4rem 1rem;
padding: 0.5rem 0;
border-bottom: 1px solid var(--rule);
}
.note {
margin-left: 0.5em;
font-style: italic;
font-size: 0.9em;
}
.ways {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
.made {
margin-top: 0.8rem;
}
.phrase {
letter-spacing: 0.06em;
user-select: all;
}
.warning {
margin-top: 0.5rem;
}
.actions {
margin-top: 0.9rem;
}
</style>
+3
View File
@@ -148,6 +148,9 @@ export function makeApi<State, Input>() {
rematch: (roomId: string, token: string) => rematch: (roomId: string, token: string) =>
call<{ roomId: string; seat: SeatId | null; token: string | null; created: boolean }>('POST', `/api/rooms/${roomId}/rematch`, { token }), call<{ roomId: string; seat: SeatId | null; token: string | null; created: boolean }>('POST', `/api/rooms/${roomId}/rematch`, { token }),
transfer: (roomId: string, token: string) => call<{ phrase: string; expiresAt: number }>('POST', `/api/rooms/${roomId}/transfer`, { token }), transfer: (roomId: string, token: string) => call<{ phrase: string; expiresAt: number }>('POST', `/api/rooms/${roomId}/transfer`, { token }),
/** The host's phrase for another person's seat; claimed, it re-seats them. */
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 }), claim: (phrase: string) => call<{ roomId: string; seat: SeatId; token: string }>('POST', '/api/transfer', { phrase }),
report: (roomId: string, token: string, happened: string, expected: string) => report: (roomId: string, token: string, happened: string, expected: string) =>
call<{ id: string }>('POST', `/api/rooms/${roomId}/report`, { token, happened, expected }), call<{ id: string }>('POST', `/api/rooms/${roomId}/report`, { token, happened, expected }),
+17 -2
View File
@@ -5,7 +5,7 @@
import { game } from '$lib/game'; import { game } from '$lib/game';
import { SPECTATOR, type SeatId, type TableOptions } from '$lib/game/spec'; import { SPECTATOR, type SeatId, type TableOptions } from '$lib/game/spec';
import type { Input, State } from '$lib/game'; import type { Input, State } from '$lib/game';
import { makeApi, rememberSeat, watchRoom, type HeldSeat } from './client'; import { makeApi, rememberSeat, ServerError, watchRoom, type HeldSeat } from './client';
import { markTalkSeen } from './talk'; import { markTalkSeen } from './talk';
import type { RoomView } from './view'; import type { RoomView } from './view';
@@ -44,6 +44,8 @@ export class Room {
error = $state(''); error = $state('');
/** A move is on its way to the server. */ /** A move is on its way to the server. */
sending = $state(false); sending = $state(false);
/** The server stopped taking this seat's token while the page was open, and why; the page goes to the gallery. */
lost = $state('');
constructor(view: RoomView<State>, token: string) { constructor(view: RoomView<State>, token: string) {
this.view = view; this.view = view;
@@ -83,7 +85,10 @@ export class Room {
const refresh = () => { const refresh = () => {
fetchView() fetchView()
.then((v) => this.apply(v)) .then((v) => this.apply(v))
.catch((e: Error) => (this.error = e.message)); .catch((e: Error) => {
if (e instanceof ServerError && e.status === 403 && !this.spectating) this.lost = e.message;
else this.error = e.message;
});
}; };
return watchRoom(this.roomId, this.token, refresh, (open) => (this.connected = open)); return watchRoom(this.roomId, this.token, refresh, (open) => (this.connected = open));
} }
@@ -156,6 +161,16 @@ export class Room {
return api.transfer(this.roomId, this.token); return api.transfer(this.roomId, this.token);
} }
/** Host only: a phrase for another person's seat, which gives it a new token when claimed. */
hostPhrase(seat: SeatId): Promise<{ phrase: string; expiresAt: number }> {
return api.hostPhrase(this.roomId, this.token, seat);
}
/** Host only: a bot plays this person's seat for the rest of the game, until a phrase gives it back. */
standIn(seat: SeatId): Promise<boolean> {
return this.act(() => api.standIn(this.roomId, this.token, seat), 'The seat could not be given to a bot.');
}
async submit(input: Input): Promise<boolean> { async submit(input: Input): Promise<boolean> {
this.sending = true; this.sending = true;
try { try {
+2 -1
View File
@@ -26,7 +26,8 @@ export interface RoomView<State> {
me: SeatId; me: SeatId;
/** The player who opened the table and may begin the game. */ /** The player who opened the table and may begin the game. */
host: SeatId; host: SeatId;
seats: { id: SeatId; name: string; bot: boolean }[]; /** Who sits where; `standIn` marks a person's seat the host gave to a bot, which plays it until they take it back. */
seats: { id: SeatId; name: string; bot: boolean; standIn?: true }[];
/** Seats that still have to move before the round resolves. */ /** Seats that still have to move before the round resolves. */
waitingOn: SeatId[]; waitingOn: SeatId[];
/** Rounds played so far; a move is sent with the count it was made against. */ /** Rounds played so far; a move is sent with the count it was made against. */
+1 -1
View File
@@ -46,7 +46,7 @@
<section id="gallery"> <section id="gallery">
<h2>The Peanut Gallery and table talk</h2> <h2>The Peanut Gallery and table talk</h2>
<p>A room's link is an invitation to sit while the table is laid and to watch once the game has begun. Anyone who opens it without a seat takes a place in the Peanut Gallery, shown only what every player at the table could see. <em>Table talk</em> is for the players seated, unless the host lets the gallery talk. Then a watcher gives a name, and their lines are marked as the gallery's. A line goes to everyone at the table and everyone in the gallery, and is kept with the game.</p> <p>A room's link is an invitation to sit while the table is laid and to watch once the game has begun. Anyone who opens it without a seat takes a place in the Peanut Gallery, shown only what every player at the table could see. <em>Table talk</em> is for the players seated, unless the host lets the gallery talk. Then a watcher gives a name, and their lines are marked as the gallery's. A line goes to everyone at the table and everyone in the gallery, and is kept with the game.</p>
<p>A seat can follow you to another device. <em>Transfer seat</em> in the masthead gives four words, good for ten minutes, and the hall on the other device claims them. When a game ends, anyone at the table may call for a rematch, which opens a new table for the same company and holds their seats until they are back. While a table is laid, you may challenge the keeper of this site to a seat; the note beside the button gives the hour where they live.</p> <p>A seat can follow you to another device. <em>Transfer seat</em> in the masthead gives four words, good for ten minutes, and the hall on the other device claims them. If your browser loses the seat, the host's <em>Seats</em> makes those words for you. If a player cannot go on, the host can give their seat to a bot there, and the same words bring them back to it. When a game ends, anyone at the table may call for a rematch, which opens a new table for the same company and holds their seats until they are back. While a table is laid, you may challenge the keeper of this site to a seat; the note beside the button gives the hour where they live.</p>
</section> </section>
<section id="first"> <section id="first">
+64 -3
View File
@@ -4,9 +4,10 @@
import Board from '$lib/components/Board.svelte'; import Board from '$lib/components/Board.svelte';
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 SeatsSlip from '$lib/components/SeatsSlip.svelte';
import Preferences from '$lib/components/Preferences.svelte'; import Preferences from '$lib/components/Preferences.svelte';
import { rememberSeat, seatFor, seatInLink, ServerError, trustSeat, type HeldSeat } 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 { api, NAME_MAX, playerName, rememberName, Room } from '$lib/net/room.svelte';
const roomId = $derived((page.params.code ?? '').toUpperCase()); const roomId = $derived((page.params.code ?? '').toUpperCase());
@@ -21,6 +22,11 @@
/** A phrase minted to carry this seat to another device, while it lasts. */ /** A phrase minted to carry this seat to another device, while it lasts. */
let phrase = $state<{ phrase: string; expiresAt: number } | null>(null); let phrase = $state<{ phrase: string; expiresAt: number } | null>(null);
let rematching = $state(false); let rematching = $state(false);
let seatsOpen = $state(false);
/** Why this browser's seat no longer opens, when the server said so; shown while watching. */
let seatLost = $state('');
let claimPhrase = $state('');
let claiming = $state(false);
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);
@@ -44,7 +50,17 @@
}); });
// Only the server's refusal of the held token lets a link in: a dropped connection is not a lost seat. // 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 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); // A held seat the server refuses, with no link to replace it, is watched from the gallery, with the reason.
const watchRefused = (e: Error) => {
seatLost = e.message;
return Room.watch(id);
};
seatLost = '';
const arrive = held
? Room.resume(id, held).catch((e: Error) => (sent && refused(e) ? take(sent) : refused(e) ? watchRefused(e) : Promise.reject(e)))
: sent
? take(sent).catch(() => Room.watch(id))
: Room.watch(id);
joining = !held && !sent; joining = !held && !sent;
if (!held) name = playerName(); if (!held) name = playerName();
arrive arrive
@@ -62,6 +78,36 @@
return r.connect(); return r.connect();
}); });
// The seat stopped opening while the page was open (the host gave it to a bot): watch on from the gallery.
$effect(() => {
const r = room;
if (!r?.lost) return;
seatLost = r.lost;
Room.watch(r.roomId)
.then((w) => (room = w))
.catch((e: Error) => (error = e.message));
});
/** Sit back down by a phrase: the host's, or one this seat minted on another device. */
async function claim(e: SubmitEvent) {
e.preventDefault();
claiming = true;
error = '';
try {
const got = await api.claim(claimPhrase.trim());
rememberSeat(got.roomId, { seat: got.seat, token: got.token });
trustSeat(got.roomId);
claimPhrase = '';
seatLost = '';
if (got.roomId === roomId) room = await Room.resume(roomId, { seat: got.seat, token: got.token });
else await goto(`/join/${got.roomId}`);
} catch (err) {
error = err instanceof Error ? err.message : 'The phrase could not be claimed.';
} finally {
claiming = false;
}
}
async function takeSeat(e: SubmitEvent) { async function takeSeat(e: SubmitEvent) {
e.preventDefault(); e.preventDefault();
busy = true; busy = true;
@@ -137,6 +183,9 @@
{#if !watching} {#if !watching}
<button type="button" class="quiet" title="A phrase that brings this seat to another device" onclick={transfer}>Transfer seat</button> <button type="button" class="quiet" title="A phrase that brings this seat to another device" onclick={transfer}>Transfer seat</button>
{/if} {/if}
{#if room.isHost && room.started}
<button type="button" class="quiet" title="Bring a player back to a seat their browser lost, or give a seat to a bot" onclick={() => (seatsOpen = true)}>Seats</button>
{/if}
{/if} {/if}
<button type="button" class="quiet" onclick={() => (prefsOpen = true)}>Preferences</button> <button type="button" class="quiet" onclick={() => (prefsOpen = true)}>Preferences</button>
<a class="quiet" href={room ? `/guide?room=${roomId}` : '/guide'}>How to play</a> <a class="quiet" href={room ? `/guide?room=${roomId}` : '/guide'}>How to play</a>
@@ -151,6 +200,7 @@
<Preferences bind:open={prefsOpen} /> <Preferences bind:open={prefsOpen} />
{#if room} {#if room}
<ReportSlip {room} bind:open={reporting} /> <ReportSlip {room} bind:open={reporting} />
{#if room.isHost}<SeatsSlip {room} bind:open={seatsOpen} />{/if}
{/if} {/if}
{#if error} {#if error}
@@ -189,8 +239,19 @@
</section> </section>
{/if} {/if}
{#if room?.started && !seatLost && room.view.seats.some((s) => s.standIn)}
<p class="notice muted">A bot plays for {room.view.seats.filter((s) => s.standIn).map((s) => s.name).join(', ')}, at the host's word, until a transfer phrase from the host brings them back.</p>
{/if}
{#if watching && room?.started && !room.view.over} {#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> <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}<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}
<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>
</form>
</div>
{/if} {/if}
{#if room && !room.started} {#if room && !room.started}