The host has a hand on the other seats: once the duel begins, Seats makes a transfer phrase for another wizard's seat, which gives it a new token when claimed, and gives the seat of a wizard who cannot go on to a bot, which duels the turn it owes at once and keeps gestures already sealed; the wizard's token opens nothing while it stands in, their page goes to the gallery with the reason and a claim box, the roster says a bot duels for them, 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 a673ea9766
commit f98bfba107
14 files changed
+438 -31

No files matched your search

+8 -2
View File
@@ -44,7 +44,7 @@ for f in glob.glob("/var/lib/waving-hands/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,6 +56,10 @@ for f in glob.glob("/var/lib/waving-hands/rooms/*.jsonl"):
elif x["t"] == "turn": elif x["t"] == "turn":
turns += 1 turns += 1
last = datetime.datetime.fromtimestamp(x["at"] / 1000, datetime.timezone.utc) last = datetime.datetime.fromtimestamp(x["at"] / 1000, datetime.timezone.utc)
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"] == "chat": elif x["t"] == "chat":
talk += 1 talk += 1
elif x["t"] == "over": elif x["t"] == "over":
@@ -63,7 +67,7 @@ for f in glob.glob("/var/lib/waving-hands/rooms/*.jsonl"):
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"]})
@@ -80,4 +84,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
+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
+31
View File
@@ -14,6 +14,8 @@
// POST /api/rooms/:id/challenge {token} call the keeper of the site to a held seat; their phone rings // POST /api/rooms/:id/challenge {token} call the keeper of the site to a held seat; their phone rings
// POST /api/rooms/:id/rematch {token} a finished table opens another with the same company // POST /api/rooms/:id/rematch {token} a finished table opens another with the same company
// POST /api/rooms/:id/transfer {token} a four-word phrase that carries this seat to another device // POST /api/rooms/:id/transfer {token} a four-word phrase that carries this seat to another device
// POST /api/rooms/:id/phrase {token, seat} the host mints one for another wizard's seat, which re-seats it when claimed
// POST /api/rooms/:id/standin {token, seat} the host gives a wizard's seat to a bot for the rest of the duel
// POST /api/transfer {phrase} claim a phrase: the seat and its token, once // POST /api/transfer {phrase} claim a phrase: the seat and its token, once
// WS /ws?room=:id&token= {type:"update", seq} whenever the room changes; without a token, // WS /ws?room=:id&token= {type:"update", seq} whenever the room changes; without a token,
// a seat in the Peanut Gallery, counted for the table // a seat in the Peanut Gallery, counted for the table
@@ -79,6 +81,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);
@@ -339,6 +343,14 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise<void>
if (action === 'transfer') { if (action === 'transfer') {
return send(res, 200, rooms.transfer(room, typeof body.token === 'string' ? body.token : undefined)); return send(res, 200, rooms.transfer(room, typeof body.token === 'string' ? body.token : undefined));
} }
if (action === 'phrase') {
return send(res, 200, rooms.hostPhrase(room, typeof body.token === 'string' ? body.token : undefined, body.seat));
}
if (action === 'standin') {
const token = typeof body.token === 'string' ? body.token : undefined;
rooms.standIn(room, token, body.seat);
return send(res, 200, rooms.view(room, rooms.seatOf(room, token).id));
}
throw new RoomError('Not here.', 404); throw new RoomError('Not here.', 404);
} }
@@ -372,12 +384,31 @@ 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());
ws.send(JSON.stringify({ type: 'hello', seat: seatId })); ws.send(JSON.stringify({ type: 'hello', seat: seatId }));
}); });
}); });
/**
* 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(`waving-hands server listening on http://${HOST}:${PORT}, rooms in ${DATA_DIR}`); console.log(`waving-hands server listening on http://${HOST}:${PORT}, rooms in ${DATA_DIR}`);
}); });
+64
View File
@@ -3,6 +3,7 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path'; import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { Rooms } from './rooms'; import { Rooms } from './rooms';
import { chooseBotTurn } from '../../src/lib/game/bot';
import { Store } from './store'; import { Store } from './store';
let dir: string; let dir: string;
@@ -47,3 +48,66 @@ describe('rooms', () => {
expect(seats[1].lastActed).toBeNull(); expect(seats[1].lastActed).toBeNull();
}); });
}); });
describe('the host and the other seats', () => {
it("gives a wizard's seat to a bot, which duels the turn it was waiting on, and the wizard's token opens nothing", () => {
const { rooms, room, ann, bo } = table();
rooms.submit(room, ann, chooseBotTurn(room.state!, 'A'), 0);
rooms.standIn(room, ann, 'B');
expect(room.resolved).toBe(1);
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 });
});
it('keeps the gestures a wizard made before the bot stood in', () => {
const { rooms, room, ann, bo } = table();
const mine = { left: 'P', right: 'W', casts: [] } as const;
rooms.submit(room, bo, mine, 0);
rooms.standIn(room, ann, 'B');
rooms.submit(room, ann, chooseBotTurn(room.state!, 'A'), 0);
expect(room.state!.wizards.B.history.at(-1)).toMatchObject({ left: 'P', right: 'W' });
});
it("is the host's call alone, in a duel under way, for a wizard other than the host", () => {
const { rooms, room, ann, bo } = table();
expect(() => rooms.standIn(room, bo, 'A')).toThrow(/Only the wizard who opened/);
expect(() => rooms.standIn(room, ann, 'A')).toThrow(/keeps their own seat/);
expect(() => rooms.standIn(room, ann, 'Z')).toThrow(/No wizard 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(new Store(dir)).get(room.id).seats[1]).toMatchObject({ bot: true, standIn: true });
const claimed = rooms.claim(rooms.hostPhrase(room, ann, 'B').phrase);
expect(rooms.seatOf(room, claimed.token).name).toBe('Bo');
expect(() => rooms.seatOf(room, bo)).toThrow(/opens no seat/);
const reopened = new Rooms(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 wizard 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 wizard who opened/);
expect(() => rooms.hostPhrase(room, ann, 'A')).toThrow(/No other wizard/);
const { phrase } = rooms.hostPhrase(room, ann, 'B');
expect(rooms.claim(phrase).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 bot stands in, and holds the wizard\'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/);
room.state = { ...room.state!, over: { winner: 'A', reason: 'test' } } as typeof room.state;
const next = rooms.get(rooms.rematch(room, ann).roomId);
expect(next.expected).toEqual(['Bo']);
expect(next.seats.map((s) => s.name)).toEqual(['Ann']);
});
});
+75 -17
View File
@@ -23,9 +23,12 @@ const CHAT_KEEP = 200;
export interface Seat { export interface Seat {
id: WizardId; id: WizardId;
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 wizard'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 wizard. */
bot: boolean; bot: boolean;
/** The host gave this wizard's seat to a bot; a phrase from the host, or the keeper, gives it back. */
standIn?: true;
} }
export function hashToken(token: string): string { export function hashToken(token: string): string {
@@ -153,7 +156,8 @@ export class Rooms {
seatOf(room: Room, token: string | undefined): Seat { seatOf(room: Room, token: string | undefined): Seat {
if (!token) throw new RoomError('That token opens no seat at this duel.', 403); if (!token) throw new RoomError('That token opens no seat at this duel.', 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 duels 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 duel.', 403); if (!seat) throw new RoomError('That token opens no seat at this duel.', 403);
return seat; return seat;
} }
@@ -223,21 +227,38 @@ export class Rooms {
const caller = this.seatOf(room, token); const caller = this.seatOf(room, token);
if (!room.state?.over) throw new RoomError('The duel is not over yet.', 409); if (!room.state?.over) throw new RoomError('The duel 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 wizard 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, { rematchOf: room.id, expected: others }); const next = this.open(caller.name, room.size, { 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 };
} }
/** 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: WizardId; token: string; expiresAt: number }>(); private transfers = new Map<string, { roomId: string; seatId: WizardId; token: string | null; expiresAt: number }>();
private failedClaims = 0; private failedClaims = 0;
private failWindowStart = 0; private failWindowStart = 0;
/** 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, token: string | undefined): { phrase: string; expiresAt: number } { transfer(room: Room, 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 wizard'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, 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 wizard 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 wizard sits in that seat.', 404);
return this.mint(room.id, seat.id, null);
}
private mint(roomId: string, seatId: WizardId, 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);
@@ -246,7 +267,7 @@ export class Rooms {
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 };
} }
@@ -267,6 +288,10 @@ export class Rooms {
} }
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 };
} }
@@ -308,27 +333,49 @@ export class Rooms {
} }
/** /**
* The keeper gives a wizard's seat a new token, for a player whose browser lost the old one. Whether the one * A wizard'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, seatId: unknown): { seat: Seat; token: string } { 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 wizard sits in that seat.', 404); if (!seat) throw new RoomError('No wizard 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 wizard's seat to a bot, for a player who cannot go on: the bot plays it from the gestures the
* seat owes, and the wizard'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 wizard who opened the table may give a seat to a bot.', 403);
if (!room.state || room.state.over) throw new RoomError('Only a duel 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 wizard 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 turn may have been waiting on this seat alone.
if (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: WizardId): 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 wizard last moved or spoke. */ /** The table as the keeper sees it before re-seating: who sits where, and when each wizard last moved or spoke. */
seatsForKeeper(room: Room): { id: WizardId; name: string; bot: boolean; lastActed: number | null }[] { seatsForKeeper(room: Room): { id: WizardId; name: string; bot: boolean; standIn: boolean; lastActed: number | null }[] {
const last = new Map<WizardId, number>(); const last = new Map<WizardId, 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 duel begins. People leave by not coming back. */ /** The host sends a bot away before the duel begins. People leave by not coming back. */
@@ -361,8 +408,9 @@ export class Rooms {
private resolve(room: Room): void { private resolve(room: Room): void {
const state = room.state!; const state = room.state!;
const inputs: Record<WizardId, TurnInput> = { ...room.pending }; const inputs: Record<WizardId, TurnInput> = { ...room.pending };
// Gestures a wizard made before a bot stood in for them stand.
for (const seat of room.seats) { for (const seat of room.seats) {
if (seat.bot && needsInput(state, seat.id)) inputs[seat.id] = chooseBotTurn(state, seat.id); if (seat.bot && needsInput(state, seat.id) && !(seat.id in inputs)) inputs[seat.id] = chooseBotTurn(state, seat.id);
} }
this.commit(room, { t: 'turn', at: Date.now(), inputs }); this.commit(room, { t: 'turn', at: Date.now(), inputs });
// A time stop owed to a bot resolves at once; a human's waits for them. // A time stop owed to a bot resolves at once; a human's waits for them.
@@ -387,7 +435,7 @@ export class Rooms {
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: waitingOn(room), waitingOn: waitingOn(room),
resolved: room.resolved, resolved: room.resolved,
state: room.state ? viewFor(room.state, seatId) : null, state: room.state ? viewFor(room.state, seatId) : null,
@@ -463,7 +511,17 @@ function apply(room: Room, line: LedgerLine): void {
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 'input': case 'input':
+6 -1
View File
@@ -11,8 +11,13 @@ export type LedgerLine =
| { t: 'room'; id: string; seats: number; createdAt: number; rematchOf?: string; expected?: string[] } | { t: 'room'; id: string; seats: number; createdAt: number; rematchOf?: string; expected?: string[] }
/** A seat holds only the hash of its token; the token itself goes once to the browser that earned it. Ledgers written before hashing carry `token` and are read once more. */ /** A seat holds only the hash of its token; the token itself goes once to the browser that earned it. Ledgers written before hashing carry `token` and are read once more. */
| { t: 'seat'; id: WizardId; name: string; tokenHash?: string; token?: string; bot: boolean } | { t: 'seat'; id: WizardId; name: string; tokenHash?: string; token?: string; bot: boolean }
/** The keeper gave a wizard's seat a new token, for a player whose browser lost the old one; the old one opens nothing after. */ /**
* A wizard'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 wizard's again.
*/
| { t: 'reseat'; at: number; id: WizardId; tokenHash: string } | { t: 'reseat'; at: number; id: WizardId; tokenHash: string }
/** The host gave a wizard's seat to a bot, which duels in it from here on; a reseat gives it back. */
| { t: 'standIn'; at: number; id: WizardId; by: WizardId }
/** One seat's move for the turn in progress, so a restart keeps it. The turn line that follows carries every input again. */ /** One seat's move for the turn in progress, so a restart keeps it. The turn line that follows carries every input again. */
| { t: 'input'; at: number; id: WizardId; input: TurnInput } | { t: 'input'; at: number; id: WizardId; input: TurnInput }
/** A bot sent away by the host before the duel began; its seat id is free again. */ /** A bot sent away by the host before the duel began; its seat id is free again. */
+1 -1
View File
@@ -183,7 +183,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.state.over) { if (g.state.over) {
if (g.state.over.winner === held.seat) return 'you won'; if (g.state.over.winner === held.seat) return 'you won';
+3 -2
View File
@@ -20,8 +20,9 @@
if (!duel.remote?.started || duel.state.over) return ''; if (!duel.remote?.started || duel.state.over) return '';
const w = duel.state.wizards[id]; const w = duel.state.wizards[id];
if (!w.alive || w.surrendered) return 'out'; if (!w.alive || w.surrendered) return 'out';
const bot = duel.remote.seats.find((s) => s.id === id)?.bot; const seat = duel.remote.seats.find((s) => s.id === id);
if (bot) return ''; if (seat?.standIn) return 'a bot duels for them';
if (seat?.bot) return '';
return waitingOn.includes(id) ? 'writing' : 'moved'; return waitingOn.includes(id) ? 'writing' : 'moved';
} }
</script> </script>
+154
View File
@@ -0,0 +1,154 @@
<script lang="ts">
// The host's hand on the other seats during a duel: a transfer phrase for a
// wizard whose browser lost their seat, and a bot to duel in the seat of one
// who cannot go on, until a phrase gives it back.
import type { Duel } from '$lib/game/duel.svelte';
import type { WizardId } from '$lib/game/state';
let { duel, open = $bindable(false) }: { duel: Duel; open?: boolean } = $props();
const people = $derived((duel.remote?.seats ?? []).filter((s) => s.id !== duel.me && (!s.bot || s.standIn)));
let asked = $state<WizardId | 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: WizardId, name: string) {
error = '';
try {
const made = await duel.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: WizardId) {
asked = null;
if (!(await duel.standIn(seat))) error = duel.remote?.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 duel, 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 wizard back to a seat their browser lost, or give the seat of one who cannot go on to a bot, which duels in it from this turn.</p>
{#if people.length === 0}
<p class="muted">Every other wizard at this table is a bot.</p>
{/if}
<ul>
{#each people as s (s.id)}
<li>
<span class="name">{s.name}{#if s.standIn}<span class="muted note">a bot duels 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 && !duel.state.over}
{#if asked === s.id}
<button type="button" class="reveal 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 duels in {s.name}'s seat from this turn, 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 duel: <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
@@ -169,6 +169,9 @@ export const api = {
rematch: (roomId: string, token: string) => rematch: (roomId: string, token: string) =>
call<{ roomId: string; seat: WizardId | null; token: string | null; created: boolean }>('POST', `/api/rooms/${roomId}/rematch`, { token }), call<{ roomId: string; seat: WizardId | 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 wizard's seat; claimed, it re-seats them. */
hostPhrase: (roomId: string, token: string, seat: WizardId) => call<{ phrase: string; expiresAt: number }>('POST', `/api/rooms/${roomId}/phrase`, { token, seat }),
standIn: (roomId: string, token: string, seat: WizardId) => call<RoomView>('POST', `/api/rooms/${roomId}/standin`, { token, seat }),
claim: (phrase: string) => call<{ roomId: string; seat: WizardId; token: string }>('POST', '/api/transfer', { phrase }), claim: (phrase: string) => call<{ roomId: string; seat: WizardId; 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 }),
+28 -2
View File
@@ -4,7 +4,7 @@
import { tick } from 'svelte'; import { tick } from 'svelte';
import { chooseBotTurn } from './bot'; import { chooseBotTurn } from './bot';
import { api, rememberSeat, watchRoom, type HeldSeat } from './client'; import { api, rememberSeat, ServerError, watchRoom, type HeldSeat } from './client';
import { markTalkSeen } from './talk'; import { markTalkSeen } from './talk';
import { import {
cellsOverlap, cellsOverlap,
@@ -219,6 +219,8 @@ export interface Remote extends HeldSeat {
started: boolean; started: boolean;
connected: boolean; connected: boolean;
error: string; error: string;
/** The server stopped taking this seat's token while the page was open, and why; the page goes to the gallery. */
lost: string;
chat: ChatLine[]; chat: ChatLine[];
/** How many watch from the Peanut Gallery. */ /** How many watch from the Peanut Gallery. */
audience: number; audience: number;
@@ -867,6 +869,26 @@ export class Duel {
return api.transfer(r.roomId, r.token); return api.transfer(r.roomId, r.token);
} }
/** Host only: a phrase for another wizard's seat, which gives it a new token when claimed. */
hostPhrase(seat: WizardId): Promise<{ phrase: string; expiresAt: number }> {
const r = this.remote!;
return api.hostPhrase(r.roomId, r.token, seat);
}
/** Host only: a bot duels in this wizard's seat for the rest of the duel, until a phrase gives it back. */
async standIn(seat: WizardId): Promise<boolean> {
const r = this.remote;
if (!r) return false;
r.error = '';
try {
this.apply(await api.standIn(r.roomId, r.token, seat));
return true;
} catch (e) {
r.error = e instanceof Error ? e.message : 'The seat could not be given to a bot.';
return false;
}
}
/** Say something to the whole table. Resolves false when the words did not arrive. */ /** Say something to the whole table. Resolves false when the words did not arrive. */
async say(text: string): Promise<boolean> { async say(text: string): Promise<boolean> {
const r = this.remote; const r = this.remote;
@@ -913,7 +935,10 @@ export class Duel {
const refresh = () => { const refresh = () => {
fetchView() fetchView()
.then((v) => this.apply(v)) .then((v) => this.apply(v))
.catch((e: Error) => (r.error = e.message)); .catch((e: Error) => {
if (e instanceof ServerError && e.status === 403 && !r.spectating) r.lost = e.message;
else r.error = e.message;
});
}; };
return watchRoom(r.roomId, r.token, refresh, (open) => (r.connected = open)); return watchRoom(r.roomId, r.token, refresh, (open) => (r.connected = open));
} }
@@ -1045,6 +1070,7 @@ function remoteFrom(view: RoomView, token: string): Remote {
started: view.state !== null, started: view.state !== null,
connected: false, connected: false,
error: '', error: '',
lost: '',
chat: view.chat, chat: view.chat,
audience: view.audience, audience: view.audience,
spectating: view.me === SPECTATOR, spectating: view.me === SPECTATOR,
+2 -1
View File
@@ -33,7 +33,8 @@ export interface RoomView {
me: WizardId; me: WizardId;
/** The wizard who opened the table and may begin the duel. */ /** The wizard who opened the table and may begin the duel. */
host: WizardId; host: WizardId;
seats: { id: WizardId; name: string; bot: boolean }[]; /** Who sits where; `standIn` marks a wizard's seat the host gave to a bot, which duels in it until they take it back. */
seats: { id: WizardId; name: string; bot: boolean; standIn?: true }[];
/** Seats that still have to move before the turn resolves. */ /** Seats that still have to move before the turn resolves. */
waitingOn: WizardId[]; waitingOn: WizardId[];
/** Turns played so far, time stops included; gestures are sent with the count they were made against. */ /** Turns played so far, time stops included; gestures are sent with the count they were made against. */
+1 -1
View File
@@ -104,7 +104,7 @@
<p>A room's link is an invitation to sit while the table is laid and to watch once the duel has begun. Anyone who opens it without a seat takes a place in the Peanut Gallery. They see the ledger, the result of each turn and the chronicle, and the table can see how many are watching. The gallery is shown only what every wizard at the table could see. A gesture hidden from any one wizard is a question mark to the whole gallery, and the turn being written shows nothing at all.</p> <p>A room's link is an invitation to sit while the table is laid and to watch once the duel has begun. Anyone who opens it without a seat takes a place in the Peanut Gallery. They see the ledger, the result of each turn and the chronicle, and the table can see how many are watching. The gallery is shown only what every wizard at the table could see. A gesture hidden from any one wizard is a question mark to the whole gallery, and the turn being written shows nothing at all.</p>
<p><em>Table talk</em>, in the rail, is for the wizards seated. A line goes to everyone at the table and in the gallery, and it is kept with the duel. When you come back, the hall says how many lines were said while you were away. The gallery only listens unless the host lets it talk. Then a watcher signs a name, and their lines read <em>Name (gallery)</em>.</p> <p><em>Table talk</em>, in the rail, is for the wizards seated. A line goes to everyone at the table and in the gallery, and it is kept with the duel. When you come back, the hall says how many lines were said while you were away. The gallery only listens unless the host lets it talk. Then a watcher signs a name, and their lines read <em>Name (gallery)</em>.</p>
<p>Before a duel begins, any seated wizard may <strong>challenge the keeper</strong>, who looks after this site. A seat is held under the keeper's name and the keeper's phone rings. The note beside the button gives the hour where the keeper lives, so you know whether to wait or come back later.</p> <p>Before a duel begins, any seated wizard may <strong>challenge the keeper</strong>, who looks after this site. A seat is held under the keeper's name and the keeper's phone rings. The note beside the button gives the hour where the keeper lives, so you know whether to wait or come back later.</p>
<p>When a duel is over, anyone at the table may call for <strong>a rematch</strong>. That opens a new table of the same size with the same bots, and holds a seat for everyone who played. <strong>Transfer seat</strong> at the top of the page gives you four words that move your seat to another device. They work for ten minutes, and you enter them in the hall on the other device. If your browser has lost your seat altogether, write to the keeper from the room's page, and the keeper can send you a link that brings it back.</p> <p>When a duel is over, anyone at the table may call for <strong>a rematch</strong>. That opens a new table of the same size with the same bots, and holds a seat for everyone who played. <strong>Transfer seat</strong> at the top of the page gives you four words that move your seat to another device. They work for ten minutes, and you enter them in the hall on the other device. If your browser has lost your seat altogether, ask the host: <strong>Seats</strong> at the top of their page makes the four words for you. Failing that, write to the keeper from the room's page for a link that brings the seat back. A wizard who cannot go on can have their seat given to a bot by the host, and four words from the host bring them back to it.</p>
</section> </section>
<section id="first"> <section id="first">
+61 -3
View File
@@ -3,8 +3,9 @@
import { page } from '$app/state'; import { page } from '$app/state';
import Board from '$lib/components/Board.svelte'; import Board from '$lib/components/Board.svelte';
import ReportSlip from '$lib/components/ReportSlip.svelte'; import ReportSlip from '$lib/components/ReportSlip.svelte';
import SeatsSlip from '$lib/components/SeatsSlip.svelte';
import TableTalk from '$lib/components/TableTalk.svelte'; import TableTalk from '$lib/components/TableTalk.svelte';
import { rememberSeat, seatFor, seatInLink, ServerError, trustSeat, type HeldSeat } from '$lib/game/client'; import { api, rememberSeat, seatFor, seatInLink, ServerError, trustSeat, type HeldSeat } from '$lib/game/client';
import { COMPACT_QUERY, Duel, NAME_MAX, playerName } from '$lib/game/duel.svelte'; import { COMPACT_QUERY, Duel, NAME_MAX, playerName } from '$lib/game/duel.svelte';
const roomId = $derived((page.params.code ?? '').toUpperCase()); const roomId = $derived((page.params.code ?? '').toUpperCase());
@@ -19,6 +20,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);
/** The hour where the keeper lives, in words, so a caller knows whether to wait. */ /** The hour where the keeper lives, in words, so a caller knows whether to wait. */
let minute = $state(0); let minute = $state(0);
@@ -90,8 +96,14 @@
}); });
// 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;
// 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 Duel.watchRoom(id);
};
seatLost = '';
const arrive = held const arrive = held
? Duel.resumeRoom(id, held).catch((e: Error) => (sent && refused(e) ? take(sent) : Promise.reject(e))) ? Duel.resumeRoom(id, held).catch((e: Error) => (sent && refused(e) ? take(sent) : refused(e) ? watchRefused(e) : Promise.reject(e)))
: sent : sent
? take(sent).catch(() => Duel.watchRoom(id)) ? take(sent).catch(() => Duel.watchRoom(id))
: Duel.watchRoom(id); : Duel.watchRoom(id);
@@ -113,6 +125,37 @@
return stop; return stop;
}); });
// 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;
const lost = r?.remote?.lost;
if (!r || !lost) return;
seatLost = lost;
Duel.watchRoom(r.remote!.roomId)
.then((remote) => (room = new Duel(remote)))
.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 = new Duel(await Duel.resumeRoom(roomId, { seat: got.seat, token: got.token }));
else await goto(`/join/${got.roomId}`);
} catch (err) {
error = err instanceof Error ? err.message : 'The phrase opened no seat.';
} finally {
claiming = false;
}
}
// The store's idea of a narrow screen must agree with the stylesheets' breakpoint. // The store's idea of a narrow screen must agree with the stylesheets' breakpoint.
$effect(() => { $effect(() => {
const r = room; const r = room;
@@ -183,6 +226,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.remote?.started}
<button type="button" class="quiet" title="Bring a wizard back to a seat their browser lost, or give a seat to a bot" onclick={() => (seatsOpen = true)}>Seats</button>
{/if}
{/if} {/if}
<a class="quiet" href="/guide">How to play</a> <a class="quiet" href="/guide">How to play</a>
<a class="quiet" href="/rules">Rules</a> <a class="quiet" href="/rules">Rules</a>
@@ -195,6 +241,7 @@
{#if room} {#if room}
<ReportSlip duel={room} bind:open={reporting} /> <ReportSlip duel={room} bind:open={reporting} />
{#if room.isHost}<SeatsSlip duel={room} bind:open={seatsOpen} />{/if}
{/if} {/if}
{#if error} {#if error}
@@ -205,8 +252,19 @@
<p class="notice muted">On the other device, open the hall and claim this phrase within ten minutes: <b class="phrase">{phrase.phrase}</b></p> <p class="notice muted">On the other device, open the hall and claim this phrase within ten minutes: <b class="phrase">{phrase.phrase}</b></p>
{/if} {/if}
{#if room?.remote?.started && !seatLost && room.remote.seats.some((s) => s.standIn)}
<p class="notice muted">A bot duels for {room.remote.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?.remote?.started && !room.state.over} {#if watching && room?.remote?.started && !room.state.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 duel? 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="rename" 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?.remote?.started && room.state.over && !watching} {#if room?.remote?.started && room.state.over && !watching}