The Peanut Gallery and table talk
A room's link without a seat is now a place in the gallery: a tokenless view that withholds any gesture hidden from anyone at the table, counted for the table and the lobby. Seated wizards can talk; each line is a ledger line, shown in the rail and in the lobby, and the hall counts what was said while you were away. The hall's pitch no longer says two wizards. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
75c2dc9241
commit
8e7376d59a
+28
-11
@@ -5,9 +5,11 @@
|
||||
// POST /api/rooms/:id/join {name} take the next seat
|
||||
// POST /api/rooms/:id/bot {token} seat a bot (any seated player may, before the duel begins)
|
||||
// POST /api/rooms/:id/begin {token} the host begins the duel with the wizards seated so far
|
||||
// GET /api/rooms/:id?token= the view for that seat
|
||||
// GET /api/rooms/:id?token= the view for that seat; without a token, the gallery's view
|
||||
// POST /api/rooms/:id/turn {token, input} this seat's move
|
||||
// WS /ws?room=:id&token= {type:"update", seq} whenever the room changes
|
||||
// POST /api/rooms/:id/say {token, text} table talk, from a seat to the whole room
|
||||
// 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
|
||||
//
|
||||
// Caddy serves the static site and proxies /api and /ws here.
|
||||
|
||||
@@ -15,7 +17,7 @@ import * as Sentry from '@sentry/node';
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
||||
import { WebSocketServer, WebSocket } from 'ws';
|
||||
import { RateLimit } from './ratelimit';
|
||||
import { RoomError, Rooms } from './rooms';
|
||||
import { RoomError, Rooms, SPECTATOR } from './rooms';
|
||||
import { Store } from './store';
|
||||
|
||||
const PORT = Number(process.env.PORT ?? 8788);
|
||||
@@ -35,10 +37,14 @@ process.on('unhandledRejection', (reason) => {
|
||||
const rooms = new Rooms(new Store(DATA_DIR));
|
||||
/** Opening rooms and taking seats are open to anyone; a script gets a few dozen an hour, not thousands. */
|
||||
const doors = new RateLimit(40, 60 * 60 * 1000);
|
||||
/** Table talk: a lively table says a few lines a minute, not hundreds. */
|
||||
const voices = new RateLimit(240, 60 * 60 * 1000);
|
||||
const MAX_AUDIENCE = 30;
|
||||
const IDLE_ROOM_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
setInterval(() => {
|
||||
const n = rooms.evictIdle(IDLE_ROOM_MS);
|
||||
doors.prune();
|
||||
voices.prune();
|
||||
if (n) console.log(`evicted ${n} idle room${n === 1 ? '' : 's'} from memory; ${rooms.loaded} loaded`);
|
||||
}, 60 * 60 * 1000).unref();
|
||||
|
||||
@@ -95,8 +101,9 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise<void>
|
||||
const room = rooms.get(parts[2] ?? '');
|
||||
const action = parts[3];
|
||||
if (!action && req.method === 'GET') {
|
||||
const seat = rooms.seatOf(room, url.searchParams.get('token') ?? undefined);
|
||||
return send(res, 200, rooms.view(room, seat.id));
|
||||
const token = url.searchParams.get('token');
|
||||
if (!token) return send(res, 200, rooms.view(room, SPECTATOR));
|
||||
return send(res, 200, rooms.view(room, rooms.seatOf(room, token).id));
|
||||
}
|
||||
if (req.method !== 'POST') throw new RoomError('Not here.', 404);
|
||||
const body = await readBody(req);
|
||||
@@ -122,6 +129,13 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise<void>
|
||||
const seat = rooms.seatOf(room, token);
|
||||
return send(res, 200, rooms.view(room, seat.id));
|
||||
}
|
||||
if (action === 'say') {
|
||||
if (!voices.allow(clientOf(req))) throw new RoomError('The table has heard enough from here for now.', 429);
|
||||
const token = typeof body.token === 'string' ? body.token : undefined;
|
||||
rooms.say(room, token, body.text);
|
||||
const seat = rooms.seatOf(room, token);
|
||||
return send(res, 200, rooms.view(room, seat.id));
|
||||
}
|
||||
throw new RoomError('Not here.', 404);
|
||||
}
|
||||
|
||||
@@ -139,17 +153,20 @@ server.on('upgrade', (req, socket, head) => {
|
||||
const url = new URL(req.url ?? '/', 'http://localhost');
|
||||
if (url.pathname !== '/ws') return socket.destroy();
|
||||
let seatId: string;
|
||||
let roomId: string;
|
||||
let room: ReturnType<Rooms['get']>;
|
||||
try {
|
||||
roomId = url.searchParams.get('room') ?? '';
|
||||
seatId = rooms.seatOf(rooms.get(roomId), url.searchParams.get('token') ?? undefined).id;
|
||||
room = rooms.get(url.searchParams.get('room') ?? '');
|
||||
const token = url.searchParams.get('token');
|
||||
seatId = token ? rooms.seatOf(room, token).id : SPECTATOR;
|
||||
if (seatId === SPECTATOR && rooms.audience(room) >= MAX_AUDIENCE) throw new RoomError('The gallery is full.', 429);
|
||||
} catch {
|
||||
return socket.destroy();
|
||||
}
|
||||
wss.handleUpgrade(req, socket, head, (ws: WebSocket) => {
|
||||
const unsubscribe = rooms.subscribe(roomId, (room) => {
|
||||
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'update', seq: room.seq }));
|
||||
});
|
||||
const onChange = (changed: ReturnType<Rooms['get']>) => {
|
||||
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'update', seq: changed.seq }));
|
||||
};
|
||||
const unsubscribe = seatId === SPECTATOR ? rooms.watch(room, onChange) : rooms.subscribe(room.id, onChange);
|
||||
ws.on('close', unsubscribe);
|
||||
ws.send(JSON.stringify({ type: 'hello', seat: seatId }));
|
||||
});
|
||||
|
||||
+49
-5
@@ -7,14 +7,18 @@ import { randomBytes } from 'node:crypto';
|
||||
import { chooseBotTurn } from '../../src/lib/game/bot';
|
||||
import { resolveTurn } from '../../src/lib/game/resolve';
|
||||
import { GESTURES, type Gesture } from '../../src/lib/game/spells';
|
||||
import { createGame, inDuel, type GameState, type TurnInput, type WizardId } from '../../src/lib/game/state';
|
||||
import { viewFor, type RoomView } from '../../src/lib/game/view';
|
||||
import { createGame, inDuel, SPECTATOR, type GameState, type TurnInput, type WizardId } from '../../src/lib/game/state';
|
||||
import { viewFor, type ChatLine, type RoomView } from '../../src/lib/game/view';
|
||||
import type { LedgerLine, Store } from './store';
|
||||
|
||||
/** A dozen seats at most: a turn resolves only when every seat has moved, and the slowest sets the pace. */
|
||||
const SEAT_IDS: WizardId[] = 'ABCDEFGHJKLM'.split('');
|
||||
const BOT_NAMES = ['Aldric', 'Morwenna', 'Thessaly', 'Gandric', 'Ysolde', 'Ormund', 'Corwin', 'Isaura', 'Bellamy', 'Hesper', 'Lucan', 'Wynne'];
|
||||
export const MAX_SEATS = SEAT_IDS.length;
|
||||
export { SPECTATOR };
|
||||
const CHAT_MAX_LENGTH = 300;
|
||||
/** Lines of talk kept and sent; the ledger keeps them all. */
|
||||
const CHAT_KEEP = 200;
|
||||
|
||||
export interface Seat {
|
||||
id: WizardId;
|
||||
@@ -31,6 +35,7 @@ export interface Room {
|
||||
state: GameState | null;
|
||||
/** Inputs received for the turn in progress, by seat. */
|
||||
pending: Record<WizardId, TurnInput>;
|
||||
chat: ChatLine[];
|
||||
/** Bumped on every change a client might want to see. */
|
||||
seq: number;
|
||||
updatedAt: number;
|
||||
@@ -67,6 +72,8 @@ export function normalizeCode(raw: string): string {
|
||||
export class Rooms {
|
||||
private rooms = new Map<string, Room>();
|
||||
private listeners = new Map<string, Set<(room: Room) => void>>();
|
||||
/** Sockets watching from the gallery, by room. */
|
||||
private gallery = new Map<string, number>();
|
||||
|
||||
constructor(private store: Store) {
|
||||
for (const id of store.roomIds()) {
|
||||
@@ -117,7 +124,7 @@ export class Rooms {
|
||||
if (!Number.isInteger(size) || size < 2 || size > MAX_SEATS) throw new RoomError(`A table seats 2 to ${MAX_SEATS} wizards.`);
|
||||
let id = newCode();
|
||||
while (this.rooms.has(id)) id = newCode();
|
||||
const room: Room = { id, size, createdAt: Date.now(), seats: [], state: null, pending: {}, seq: 0, updatedAt: Date.now() };
|
||||
const room: Room = { id, size, createdAt: Date.now(), seats: [], state: null, pending: {}, chat: [], seq: 0, updatedAt: Date.now() };
|
||||
this.rooms.set(id, room);
|
||||
this.commit(room, { t: 'room', id, seats: size, createdAt: room.createdAt });
|
||||
const seat = this.sit(room, name, false);
|
||||
@@ -185,6 +192,19 @@ export class Rooms {
|
||||
if (room.state?.over) this.commit(room, { t: 'over', at: Date.now(), winner: room.state.over.winner, reason: room.state.over.reason });
|
||||
}
|
||||
|
||||
/** Table talk, from a seat to everyone at the table and in the gallery. */
|
||||
say(room: Room, token: string | undefined, raw: unknown): void {
|
||||
const seat = this.seatOf(room, token);
|
||||
const text = String(raw ?? '')
|
||||
.replace(/[\u0000-\u001f\u007f]/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, CHAT_MAX_LENGTH);
|
||||
if (!text) throw new RoomError('Nothing to say.');
|
||||
this.commit(room, { t: 'chat', at: Date.now(), id: seat.id, text });
|
||||
}
|
||||
|
||||
/** The view for a seat, or for the gallery when the seat is SPECTATOR. */
|
||||
view(room: Room, seatId: WizardId): RoomView {
|
||||
return {
|
||||
roomId: room.id,
|
||||
@@ -194,7 +214,9 @@ export class Rooms {
|
||||
host: room.seats[0]?.id ?? seatId,
|
||||
seats: room.seats.map((s) => ({ id: s.id, name: s.name, bot: s.bot })),
|
||||
waitingOn: waitingOn(room),
|
||||
state: room.state ? viewFor(room.state, seatId) : null
|
||||
state: room.state ? viewFor(room.state, seatId) : null,
|
||||
chat: room.chat,
|
||||
audience: this.gallery.get(room.id) ?? 0
|
||||
};
|
||||
}
|
||||
|
||||
@@ -204,6 +226,24 @@ export class Rooms {
|
||||
return () => this.listeners.get(roomId)?.delete(fn);
|
||||
}
|
||||
|
||||
/** Subscribe from the gallery; the table is told when its audience changes. */
|
||||
watch(room: Room, fn: (room: Room) => void): () => void {
|
||||
const unsubscribe = this.subscribe(room.id, fn);
|
||||
this.gallery.set(room.id, (this.gallery.get(room.id) ?? 0) + 1);
|
||||
room.seq += 1;
|
||||
this.notify(room);
|
||||
return () => {
|
||||
unsubscribe();
|
||||
this.gallery.set(room.id, Math.max(0, (this.gallery.get(room.id) ?? 1) - 1));
|
||||
room.seq += 1;
|
||||
this.notify(room);
|
||||
};
|
||||
}
|
||||
|
||||
audience(room: Room): number {
|
||||
return this.gallery.get(room.id) ?? 0;
|
||||
}
|
||||
|
||||
/** Write to the ledger, then apply; the file is the truth a restart returns to. */
|
||||
private commit(room: Room, line: LedgerLine): void {
|
||||
this.store.append(room.id, line);
|
||||
@@ -248,13 +288,17 @@ function apply(room: Room, line: LedgerLine): void {
|
||||
break;
|
||||
case 'over':
|
||||
break;
|
||||
case 'chat':
|
||||
room.chat.push({ id: line.id, text: line.text, at: line.at });
|
||||
if (room.chat.length > CHAT_KEEP) room.chat.splice(0, room.chat.length - CHAT_KEEP);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function replay(id: string, lines: LedgerLine[]): Room | null {
|
||||
const first = lines[0];
|
||||
if (!first || first.t !== 'room') return null;
|
||||
const room: Room = { id, size: first.seats, createdAt: first.createdAt, seats: [], state: null, pending: {}, seq: lines.length, updatedAt: first.createdAt };
|
||||
const room: Room = { id, size: first.seats, createdAt: first.createdAt, seats: [], state: null, pending: {}, chat: [], seq: lines.length, updatedAt: first.createdAt };
|
||||
for (const line of lines) {
|
||||
apply(room, line);
|
||||
if (line.t === 'turn') room.updatedAt = line.at;
|
||||
|
||||
+3
-1
@@ -12,7 +12,9 @@ export type LedgerLine =
|
||||
| { t: 'start'; seed: number }
|
||||
| { t: 'turn'; at: number; inputs: Record<WizardId, TurnInput> }
|
||||
/** Written after the turn that ends the duel, for anything that reads ledgers without the engine. */
|
||||
| { t: 'over'; at: number; winner: WizardId | null; reason: string };
|
||||
| { t: 'over'; at: number; winner: WizardId | null; reason: string }
|
||||
/** A line of table talk from a seated wizard. */
|
||||
| { t: 'chat'; at: number; id: WizardId; text: string };
|
||||
|
||||
export class Store {
|
||||
constructor(private dir: string) {
|
||||
|
||||
Reference in New Issue
Block a user