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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
This commit is contained in:
1 parent
a2d3841cfe
commit
f003301541
14 files changed
+429
-29
No files matched your search
+75
-17
@@ -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<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;
|
||||
|
||||
@@ -168,7 +171,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 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<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 };
|
||||
}
|
||||
@@ -267,6 +272,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 housecarl 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);
|
||||
@@ -275,7 +296,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 };
|
||||
}
|
||||
|
||||
@@ -296,6 +317,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 };
|
||||
}
|
||||
@@ -351,27 +376,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 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<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. */
|
||||
@@ -407,7 +454,8 @@ 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);
|
||||
// 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<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,
|
||||
@@ -512,7 +560,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':
|
||||
|
||||
Reference in new issue
Block a user