From f003301541fe1b0b55df07a1133ca5680cc1d2f4 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Fri, 16 Oct 2026 19:04:33 -0400 Subject: [PATCH] The host has a hand on the other seat: once the game begins, Seats makes a transfer phrase for the other player, which gives the seat a new token when claimed, and gives the seat of a player who cannot go on to a housecarl, who plays the move it owes at once; the player'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 housecarl; the server pings every socket and closes one a dead browser left open Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up --- deploy/hnefatafl-reseat.sh | 2 +- deploy/hnefatafl-visitors.sh | 10 +- docs/conventions.md | 12 +++ server/src/index.ts | 30 ++++++ server/src/rooms.test.ts | 55 ++++++++++ server/src/rooms.ts | 92 ++++++++++++++--- server/src/store.ts | 7 +- src/lib/components/Hall.svelte | 2 +- src/lib/components/SeatsSlip.svelte | 154 ++++++++++++++++++++++++++++ src/lib/net/client.ts | 3 + src/lib/net/room.svelte.ts | 19 +++- src/lib/net/view.ts | 3 +- src/routes/guide/+page.svelte | 2 +- src/routes/join/[code]/+page.svelte | 67 +++++++++++- 14 files changed, 429 insertions(+), 29 deletions(-) create mode 100644 src/lib/components/SeatsSlip.svelte diff --git a/deploy/hnefatafl-reseat.sh b/deploy/hnefatafl-reseat.sh index 4f4511b..711fb82 100755 --- a/deploy/hnefatafl-reseat.sh +++ b/deploy/hnefatafl-reseat.sh @@ -19,7 +19,7 @@ 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") + when = "bot" if s["bot"] else ("a housecarl 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)) ' else diff --git a/deploy/hnefatafl-visitors.sh b/deploy/hnefatafl-visitors.sh index b35eed8..a2b5295 100755 --- a/deploy/hnefatafl-visitors.sh +++ b/deploy/hnefatafl-visitors.sh @@ -42,7 +42,7 @@ for f in glob.glob("/var/lib/hnefatafl/rooms/*.jsonl"): continue created = datetime.datetime.fromtimestamp(meta["createdAt"] / 1000, datetime.timezone.utc) humans, bots_seated, started, turns, last, over, talk = [], [], False, 0, created, None, 0 - names = {} + names, seat_changes = {}, [] for x in lines[1:]: if x["t"] == "seat": (bots_seated if x["bot"] else humans).append(x["name"]) @@ -56,12 +56,16 @@ for f in glob.glob("/var/lib/hnefatafl/rooms/*.jsonl"): last = datetime.datetime.fromtimestamp(x["at"] / 1000, datetime.timezone.utc) elif x["t"] == "chat": talk += 1 + elif x["t"] == "standIn": + seat_changes.append("%s %s's seat to a housecarl" % (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": over = "a draw" if x["winner"] is None else "%s won" % names.get(x["winner"], x["winner"]) for h in humans: if h not in first_seen or created < first_seen[h]: 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"]) 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 "", 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 "")) + for change in r["seat_changes"]: + print(" %s" % change) PY diff --git a/docs/conventions.md b/docs/conventions.md index c657250..4ee77d9 100644 --- a/docs/conventions.md +++ b/docs/conventions.md @@ -47,6 +47,18 @@ README. which asks for the key in `/etc//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. +- **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 housecarl, who plays it + from the move the seat owes and does not speak in the person's name. + The person's token opens nothing while one 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 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 diff --git a/server/src/index.ts b/server/src/index.ts index 94910fe..eeec5e3 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -14,6 +14,8 @@ // 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/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/rooms/:id/report {token?, happened, expected} a report to the keeper, pinned to the round // POST /api/reports/:id/image a screenshot for a report just filed @@ -81,6 +83,8 @@ const bells = new RateLimit(3, 60 * 60 * 1000); /** Claims on transfer phrases: a phrase is guessed, not brute-forced. */ const claims = new RateLimit(10, 10 * 60 * 1000); 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; setInterval(() => { const n = rooms.evictIdle(IDLE_ROOM_MS); @@ -307,6 +311,13 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise if (action === 'transfer') { 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 (!desk.allow(clientOf(req))) throw new RoomError(DESK_BUSY, 429); const seat = token ? rooms.seatOf(room, token) : null; @@ -352,11 +363,30 @@ server.on('upgrade', (req, socket, head) => { }; const unsubscribe = seatId === SPECTATOR ? rooms.watch(room, onChange) : rooms.subscribe(room.id, onChange); 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. 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(); +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, () => { console.log(`hnefatafl server listening on http://${HOST}:${PORT}, rooms in ${DATA_DIR}`); }); diff --git a/server/src/rooms.test.ts b/server/src/rooms.test.ts index 7533f2f..38b33c2 100644 --- a/server/src/rooms.test.ts +++ b/server/src/rooms.test.ts @@ -49,3 +49,58 @@ describe('rooms', () => { expect(seats.find((s) => s.id !== mover)!.lastActed).toBeNull(); }); }); + +describe('the host and the other seat', () => { + it("gives a person's seat to a housecarl, who plays it whenever it owes the move, and the person's token opens nothing", () => { + const { rooms, room, ann, bo } = table(); + rooms.standIn(room, ann, 'B'); + expect(() => rooms.seatOf(room, bo)).toThrow(/gave your seat to a housecarl/); + expect(rooms.view(room, 'A').seats[1]).toEqual({ id: 'B', name: 'Bo', bot: true, standIn: true }); + // Whoever moved first, the housecarl has answered, and the move is Ann's. + expect(rooms.waitingOn(room)).toEqual(['A']); + const before = room.resolved; + rooms.submit(room, ann, game.botInput(room.state!, 'A'), room.resolved); + expect(room.resolved).toBe(before + 2); + expect(rooms.waitingOn(room)).toEqual(['A']); + }); + + it("is the host's call alone, in a game under way, for the other person", () => { + 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/); + 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, and only the host makes one", () => { + 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 { 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("voids a phrase for the seat once a housecarl stands 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/); + }); +}); diff --git a/server/src/rooms.ts b/server/src/rooms.ts index 07608f2..63b70b4 100644 --- a/server/src/rooms.ts +++ b/server/src/rooms.ts @@ -17,9 +17,12 @@ export { SPECTATOR }; export interface Seat { id: SeatId; 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; + /** A bot plays this seat: one seated as a bot, or one standing in for a person. */ 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 { @@ -108,8 +111,8 @@ export class Rooms { private listeners = new Map) => void>>(); /** Sockets watching from the gallery, by room. */ private gallery = new Map(); - /** Phrases waiting to be claimed, with the seat and the token they carry. */ - private transfers = new Map(); + /** 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(); private failedClaims = 0; private failWindowStart = 0; @@ -168,7 +171,8 @@ export class Rooms { seatOf(room: Room, token: string | undefined): Seat { if (!token) throw new RoomError('That token opens no seat at this game.', 403); 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 housecarl, who 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); return seat; } @@ -257,9 +261,10 @@ export class Rooms { 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.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 }); - 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 }); return { roomId: next.room.id, seat: next.seat, token: next.token, created: true }; } @@ -267,6 +272,22 @@ export class Rooms { /** Mint a phrase that brings this seat to another device: four words, ten minutes, one use. */ transfer(room: Room, token: string | undefined): { phrase: string; expiresAt: number } { 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 housecarl standing in. Claimed, it gives the seat a new token, so the old one opens nothing after. + */ + hostPhrase(room: Room, 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(); 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); @@ -275,7 +296,7 @@ export class Rooms { phrase = Array.from({ length: 4 }, () => TRANSFER_WORDS[randomBytes(1)[0] % TRANSFER_WORDS.length]).join('-'); } while (this.transfers.has(phrase)); 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 }; } @@ -296,6 +317,10 @@ export class Rooms { } this.transfers.delete(phrase); 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); return { roomId: room.id, seat: seat.id, token: t.token }; } @@ -351,27 +376,49 @@ export class Rooms { } /** - * 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. + * A person's seat gets a new token, for a player whose browser lost the old one, or one taking it back from a + * 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, 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); 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); + this.voidPhrases(room, seat.id); 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, 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 housecarl.', 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 move may have been this seat's. + if (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, 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. */ - seatsForKeeper(room: Room): { id: SeatId; name: string; bot: boolean; lastActed: number | null }[] { + seatsForKeeper(room: Room): { id: SeatId; name: string; bot: boolean; standIn: boolean; lastActed: number | null }[] { const last = new Map(); 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) })); + 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. */ @@ -407,7 +454,8 @@ export class Rooms { private resolve(room: Room): void { const inputs: Record = { ...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); + // A move the person made 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)) inputs[seat.id] = this.game.botInput(room.state!, seat.id); this.commit(room, { t: 'turn', at: Date.now(), inputs }); this.driveBots(room); } @@ -444,7 +492,7 @@ export class Rooms { size: room.size, me: 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), turn: room.state ? this.game.turn(room.state) : null, resolved: room.resolved, @@ -512,7 +560,17 @@ export class Rooms { break; case 'reseat': { 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; } case 'galleryTalk': diff --git a/server/src/store.ts b/server/src/store.ts index 757fca9..2a3f871 100644 --- a/server/src/store.ts +++ b/server/src/store.ts @@ -11,8 +11,13 @@ export type LedgerLine = | { 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. */ | { 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 housecarl was standing in for is the person's again. + */ | { t: 'reseat'; at: number; id: SeatId; tokenHash: string } + /** The host gave a person's seat to a housecarl, who 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. */ | { 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. */ diff --git a/src/lib/components/Hall.svelte b/src/lib/components/Hall.svelte index f7b1941..f4e1f91 100644 --- a/src/lib/components/Hall.svelte +++ b/src/lib/components/Hall.svelte @@ -162,7 +162,7 @@ const g = games[held.roomId]; if (g === undefined) return 'looking'; if (g === null) return 'unreachable'; - const others = g.seats.filter((s) => s.id !== held.seat).map((s) => (s.bot ? `${s.name} (a housecarl)` : s.name)); + const others = g.seats.filter((s) => s.id !== held.seat).map((s) => (s.standIn ? `${s.name} (a housecarl stands in)` : s.bot ? `${s.name} (a housecarl)` : s.name)); if (!g.state) return `waiting to start${g.seats.length ? ` ยท ${g.seats.map((s) => s.name).join(', ')}` : ''}`; if (g.over) { if (g.over.winner === held.seat) return 'you won'; diff --git a/src/lib/components/SeatsSlip.svelte b/src/lib/components/SeatsSlip.svelte new file mode 100644 index 0000000..b2a6dfe --- /dev/null +++ b/src/lib/components/SeatsSlip.svelte @@ -0,0 +1,154 @@ + + + open && e.key === 'Escape' && close()} /> + +{#if open} + +{/if} + + diff --git a/src/lib/net/client.ts b/src/lib/net/client.ts index 5b07e0c..d949edc 100644 --- a/src/lib/net/client.ts +++ b/src/lib/net/client.ts @@ -146,6 +146,9 @@ export function makeApi() { rematch: (roomId: string, token: string) => 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 }), + /** 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('POST', `/api/rooms/${roomId}/standin`, { token, seat }), claim: (phrase: string) => call<{ roomId: string; seat: SeatId; token: string }>('POST', '/api/transfer', { phrase }), report: (roomId: string, token: string, happened: string, expected: string) => call<{ id: string }>('POST', `/api/rooms/${roomId}/report`, { token, happened, expected }), diff --git a/src/lib/net/room.svelte.ts b/src/lib/net/room.svelte.ts index bee6146..6612722 100644 --- a/src/lib/net/room.svelte.ts +++ b/src/lib/net/room.svelte.ts @@ -5,7 +5,7 @@ import { game } from '$lib/game'; import { SPECTATOR, type SeatId, type TableOptions } from '$lib/game/spec'; 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 type { RoomView } from './view'; @@ -38,6 +38,8 @@ export class Room { error = $state(''); /** A move is on its way to the server. */ 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, token: string) { this.view = view; @@ -72,7 +74,10 @@ export class Room { const refresh = () => { fetchView() .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)); } @@ -134,6 +139,16 @@ export class Room { 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 housecarl plays this person's seat for the rest of the game, until a phrase gives it back. */ + standIn(seat: SeatId): Promise { + return this.act(() => api.standIn(this.roomId, this.token, seat), 'The seat could not be given to a housecarl.'); + } + async submit(input: Input): Promise { this.sending = true; try { diff --git a/src/lib/net/view.ts b/src/lib/net/view.ts index 812dc99..24746c4 100644 --- a/src/lib/net/view.ts +++ b/src/lib/net/view.ts @@ -20,7 +20,8 @@ export interface RoomView { me: SeatId; /** The player who opened the table and may begin the game. */ 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. */ waitingOn: SeatId[]; /** Rounds played so far; a move is sent with the count it was made against. */ diff --git a/src/routes/guide/+page.svelte b/src/routes/guide/+page.svelte index cf9b6b7..ee37a7e 100644 --- a/src/routes/guide/+page.svelte +++ b/src/routes/guide/+page.svelte @@ -35,7 +35,7 @@

The table

Whoever opened the table is its host, and begins the game once a second player has sat or a housecarl has been seated. A friend who opens the link takes the other seat. The - masthead's Transfer seat gives four words that bring your seat to another device, and when a game is over anyone at the table may call for a rematch. + masthead's Transfer seat gives four words that bring your seat to another device, and when a game is over anyone at the table may call for a rematch. During a game the host's Seats makes the same four words for the other player, if their browser loses the seat, and can hand that seat to a housecarl when they cannot go on. Their four words bring them back.

diff --git a/src/routes/join/[code]/+page.svelte b/src/routes/join/[code]/+page.svelte index fe0143f..92c9c8b 100644 --- a/src/routes/join/[code]/+page.svelte +++ b/src/routes/join/[code]/+page.svelte @@ -4,9 +4,10 @@ import Board from '$lib/components/Board.svelte'; import Lobby from '$lib/components/Lobby.svelte'; import ReportSlip from '$lib/components/ReportSlip.svelte'; + import SeatsSlip from '$lib/components/SeatsSlip.svelte'; import Preferences from '$lib/components/Preferences.svelte'; 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()); @@ -21,6 +22,11 @@ /** A phrase minted to carry this seat to another device, while it lasts. */ let phrase = $state<{ phrase: string; expiresAt: number } | null>(null); 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 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. 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; if (!held) name = playerName(); arrive @@ -62,6 +78,36 @@ return r.connect(); }); + // The seat stopped opening while the page was open (the host gave it to a housecarl): 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) { e.preventDefault(); busy = true; @@ -137,6 +183,9 @@ {#if !watching} {/if} + {#if room.isHost && room.started} + + {/if} {/if} How to play @@ -151,6 +200,7 @@ {#if room} + {#if room.isHost}{/if} {/if} {#if error} @@ -189,8 +239,19 @@ {/if} +{#if room?.started && !seatLost && room.view.seats.some((s) => s.standIn)} +

A housecarl 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.

+{/if} + {#if watching && room?.started && !room.view.over} -

Had a seat at this table on another device? Transfer it from there and claim the phrase in the hall, or write to the keeper for a link that brings it back.

+
+ {#if seatLost}

{seatLost} Claim it here, or write to the keeper.

+ {:else}

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 write to the keeper for a link that brings it back.

{/if} +
+ + +
+
{/if} {#if room && !room.started}