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:
1 parent
c439c15e36
commit
09a8b675ad
15 files changed
+462
-35
No files matched your search
@@ -16,6 +16,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 <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. */
|
||||
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);
|
||||
@@ -317,6 +321,13 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise<void>
|
||||
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;
|
||||
@@ -362,11 +373,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<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, () => {
|
||||
console.log(`__SLUG__ server listening on http://${HOST}:${PORT}, rooms in ${DATA_DIR}`);
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -24,9 +24,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 {
|
||||
@@ -119,8 +122,8 @@ export class Rooms<State, Input> {
|
||||
private listeners = new Map<string, Set<(room: Room<State>) => void>>();
|
||||
/** Sockets watching from the gallery, by room. */
|
||||
private gallery = new Map<string, number>();
|
||||
/** Phrases waiting to be claimed, with the seat and the token they carry. */
|
||||
private transfers = new Map<string, { roomId: string; seatId: SeatId; token: string; expiresAt: number }>();
|
||||
/** 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 | null; expiresAt: number }>();
|
||||
private failedClaims = 0;
|
||||
private failWindowStart = 0;
|
||||
/** 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 {
|
||||
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 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);
|
||||
return seat;
|
||||
}
|
||||
@@ -270,9 +274,10 @@ export class Rooms<State, Input> {
|
||||
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 };
|
||||
}
|
||||
@@ -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. */
|
||||
transfer(room: Room<State>, 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 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();
|
||||
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);
|
||||
@@ -288,7 +309,7 @@ export class Rooms<State, Input> {
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -309,6 +330,10 @@ export class Rooms<State, Input> {
|
||||
}
|
||||
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 };
|
||||
}
|
||||
@@ -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
|
||||
* 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<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);
|
||||
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<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. */
|
||||
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>();
|
||||
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. */
|
||||
@@ -479,7 +526,12 @@ export class Rooms<State, Input> {
|
||||
|
||||
private resolve(room: Room<State>): void {
|
||||
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.driveBots(room);
|
||||
this.botsSpeak(room, 'phase');
|
||||
@@ -494,7 +546,8 @@ export class Rooms<State, Input> {
|
||||
if (!this.game.botPress || !room.state) return;
|
||||
const people = room.seats.filter((s) => !s.bot).map((s) => s.id);
|
||||
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)) {
|
||||
const to = line.to.filter((id) => people.includes(id));
|
||||
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)))]
|
||||
: [];
|
||||
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.');
|
||||
// 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);
|
||||
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);
|
||||
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 {
|
||||
@@ -560,7 +614,7 @@ export class Rooms<State, Input> {
|
||||
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,
|
||||
@@ -635,7 +689,17 @@ export class Rooms<State, Input> {
|
||||
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':
|
||||
|
||||
@@ -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 bot 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 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. */
|
||||
| { 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. */
|
||||
|
||||
Reference in new issue
Block a user