Gallery talk, the keeper's bell, rematches, seats that travel by phrase, and seats doubted before dropped
Brought over from the game kit, where they were ported from Wiz-War. The host may let the Peanut Gallery talk; a watcher signs a name no seat holds. A seated wizard may challenge Kestrel, the keeper: a seat is held, the call rings Sentry once per table, and the lobby says what hour it is where the keeper lives. A finished table may call for a rematch with the same company and bots, seats held. Transfer seat mints four words good for ten minutes and one use; the hall claims them. The hall forgets a held seat only on the second refusal in a row. 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
5b169d7131
commit
e5c240d970
+56
-3
@@ -8,7 +8,13 @@
|
||||
// POST /api/rooms/:id/unseat {token, seat} the host sends a bot away before the duel begins
|
||||
// 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
|
||||
// POST /api/rooms/:id/say {token, text} table talk, from a seat to the whole room
|
||||
// POST /api/rooms/:id/say {token, text} table talk, from a seat to the whole room;
|
||||
// {name, text} from the gallery, when the host has let it talk
|
||||
// POST /api/rooms/:id/gallery {token, on} the host lets the gallery talk, or hushes it
|
||||
// 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/transfer {token} a four-word phrase that carries this seat to another device
|
||||
// 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,
|
||||
// a seat in the Peanut Gallery, counted for the table
|
||||
// POST /api/rooms/:id/report {token?, happened, expected} a report to the keeper, pinned to the turn
|
||||
@@ -30,6 +36,9 @@ import { dirname } from 'node:path';
|
||||
const PORT = Number(process.env.PORT ?? 8788);
|
||||
const HOST = process.env.HOST ?? '127.0.0.1';
|
||||
const DATA_DIR = process.env.WH_DATA_DIR ?? '../data/rooms';
|
||||
/** The keeper of the site, whom a table may call to a seat, and where they sleep. */
|
||||
const KEEPER = process.env.KEEPER ?? 'the keeper';
|
||||
const KEEPER_TZ = process.env.KEEPER_TZ ?? 'America/New_York';
|
||||
const BODY_LIMIT = 16 * 1024;
|
||||
|
||||
// Errors go to Sentry when a DSN is set; the SDK drops them otherwise.
|
||||
@@ -41,7 +50,7 @@ process.on('unhandledRejection', (reason) => {
|
||||
Sentry.captureException(reason);
|
||||
});
|
||||
|
||||
const rooms = new Rooms(new Store(DATA_DIR));
|
||||
const rooms = new Rooms(new Store(DATA_DIR), { name: KEEPER, zone: KEEPER_TZ });
|
||||
/** Reports live beside the room ledgers, not among them. */
|
||||
const reports = new Reports(dirname(DATA_DIR));
|
||||
const PUBLIC_URL = (process.env.WH_PUBLIC_URL ?? 'https://hands.kestrelsnest.social').replace(/\/$/, '');
|
||||
@@ -51,6 +60,12 @@ const doors = new RateLimit(40, 60 * 60 * 1000);
|
||||
const voices = new RateLimit(240, 60 * 60 * 1000);
|
||||
/** Reports, answers and pictures: a few an hour from one address; each rings the keeper's phone. */
|
||||
const desk = new RateLimit(6, 60 * 60 * 1000);
|
||||
/** The gallery talks under a tighter rein than the table: no seat, no token, one address. */
|
||||
const galleryVoices = new RateLimit(30, 10 * 60 * 1000);
|
||||
/** Calls to the keeper: a real person's phone rings for each. */
|
||||
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;
|
||||
const IDLE_ROOM_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
setInterval(() => {
|
||||
@@ -58,6 +73,9 @@ setInterval(() => {
|
||||
doors.prune();
|
||||
voices.prune();
|
||||
desk.prune();
|
||||
galleryVoices.prune();
|
||||
bells.prune();
|
||||
claims.prune();
|
||||
if (n) console.log(`evicted ${n} idle room${n === 1 ? '' : 's'} from memory; ${rooms.loaded} loaded`);
|
||||
}, 60 * 60 * 1000).unref();
|
||||
|
||||
@@ -178,6 +196,11 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise<void>
|
||||
const url = new URL(req.url ?? '/', 'http://localhost');
|
||||
const parts = url.pathname.split('/').filter(Boolean);
|
||||
if (parts[0] === 'api' && parts[1] === 'reports') return handleReports(req, res, parts);
|
||||
if (parts[0] === 'api' && parts[1] === 'transfer' && parts.length === 2 && req.method === 'POST') {
|
||||
if (!claims.allow(clientOf(req))) throw new RoomError('Too many claims from here just now; try again later.', 429);
|
||||
const body = await readBody(req);
|
||||
return send(res, 200, rooms.claim(body.phrase));
|
||||
}
|
||||
if (parts[0] !== 'api' || parts[1] !== 'rooms') throw new RoomError('Not here.', 404);
|
||||
|
||||
if (parts.length === 2 && req.method === 'POST') {
|
||||
@@ -237,12 +260,42 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise<void>
|
||||
return send(res, 201, { id: report.id });
|
||||
}
|
||||
if (action === 'say') {
|
||||
const token = typeof body.token === 'string' && body.token ? body.token : undefined;
|
||||
if (!token) {
|
||||
if (!galleryVoices.allow(clientOf(req))) throw new RoomError('The gallery has said plenty from here for now.', 429);
|
||||
rooms.sayFromGallery(room, body.name, body.text);
|
||||
return send(res, 200, rooms.view(room, SPECTATOR));
|
||||
}
|
||||
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);
|
||||
return send(res, 200, rooms.view(room, rooms.seatOf(room, token).id));
|
||||
}
|
||||
if (action === 'gallery') {
|
||||
const token = typeof body.token === 'string' ? body.token : undefined;
|
||||
rooms.setGalleryTalk(room, token, body.on === true);
|
||||
return send(res, 200, rooms.view(room, rooms.seatOf(room, token).id));
|
||||
}
|
||||
if (action === 'challenge') {
|
||||
if (!bells.allow(clientOf(req))) throw new RoomError('The keeper has been called enough from here for one hour.', 429);
|
||||
const token = typeof body.token === 'string' ? body.token : undefined;
|
||||
const seat = rooms.seatOf(room, token);
|
||||
rooms.callKeeper(room, token, KEEPER);
|
||||
const link = `${PUBLIC_URL}/join/${room.id}`;
|
||||
// One issue per table, so each call rings once, with the door in the message.
|
||||
ringBell(`${seat.name} challenges ${KEEPER} to a duel — ${link}`, {
|
||||
fingerprint: ['challenge', room.id],
|
||||
tags: { room: room.id, challenger: seat.name },
|
||||
extra: { link, wizards: room.seats.map((s) => s.name).join(', ') }
|
||||
});
|
||||
return send(res, 200, rooms.view(room, seat.id));
|
||||
}
|
||||
if (action === 'rematch') {
|
||||
const next = rooms.rematch(room, typeof body.token === 'string' ? body.token : undefined);
|
||||
return send(res, next.created ? 201 : 200, { roomId: next.roomId, seat: next.seat?.id ?? null, token: next.token, created: next.created });
|
||||
}
|
||||
if (action === 'transfer') {
|
||||
return send(res, 200, rooms.transfer(room, typeof body.token === 'string' ? body.token : undefined));
|
||||
}
|
||||
throw new RoomError('Not here.', 404);
|
||||
}
|
||||
|
||||
|
||||
+165
-13
@@ -37,6 +37,16 @@ export interface Room {
|
||||
size: number;
|
||||
createdAt: number;
|
||||
seats: Seat[];
|
||||
/** The host lets the Peanut Gallery talk at this table. */
|
||||
galleryTalk: boolean;
|
||||
/** The keeper of the site was called to a seat, and by whom. */
|
||||
challenge: { by: WizardId; at: number } | null;
|
||||
/** Names with a seat held for them: the wizards of the last table, or the keeper. */
|
||||
expected: string[];
|
||||
/** A finished table that called for a rematch: where it went, and who called. */
|
||||
rematch: { to: string; by: WizardId } | null;
|
||||
/** The table this one is the rematch of. */
|
||||
rematchOf: string | null;
|
||||
state: GameState | null;
|
||||
/** Inputs received for the turn in progress, by seat. */
|
||||
pending: Record<WizardId, TurnInput>;
|
||||
@@ -70,6 +80,21 @@ function newSeed(): number {
|
||||
return (Date.now() ^ (Math.random() * 0xffffffff)) >>> 0;
|
||||
}
|
||||
|
||||
/** Phrases that carry a seat between devices: four plain words, ten minutes, one use. */
|
||||
const TRANSFER_WORDS = [
|
||||
'ember', 'raven', 'flagon', 'wand', 'rune', 'moss', 'torch', 'frost', 'amber', 'wisp', 'cellar', 'gable',
|
||||
'onyx', 'briar', 'tome', 'cinder', 'gloom', 'spiral', 'mirror', 'lantern', 'thistle', 'harbor', 'quill', 'saddle',
|
||||
'willow', 'copper', 'meadow', 'anvil', 'beacon', 'orchard', 'pebble', 'cloister', 'walnut', 'tallow', 'heron', 'marble',
|
||||
'ivy', 'kestrel', 'fennel', 'velvet', 'canyon', 'lattice', 'ledger', 'bramble', 'sable', 'tundra', 'juniper', 'compass'
|
||||
];
|
||||
const TRANSFER_TTL_MS = 10 * 60 * 1000;
|
||||
const TRANSFER_FAIL_LIMIT = 20;
|
||||
|
||||
/** A table with nobody seated yet. */
|
||||
function blank(id: string, size: number, createdAt: number, rematchOf: string | null, expected: string[], seq = 0): Room {
|
||||
return { id, size, createdAt, seats: [], galleryTalk: false, challenge: null, expected, rematch: null, rematchOf, state: null, pending: {}, chat: [], seq, updatedAt: createdAt };
|
||||
}
|
||||
|
||||
export function normalizeCode(raw: string): string {
|
||||
return raw.trim().toUpperCase();
|
||||
}
|
||||
@@ -80,7 +105,11 @@ export class Rooms {
|
||||
/** Sockets watching from the gallery, by room. */
|
||||
private gallery = new Map<string, number>();
|
||||
|
||||
constructor(private store: Store) {
|
||||
constructor(
|
||||
private store: Store,
|
||||
/** The keeper of the site: the name a table may call to a seat, and where they live. */
|
||||
private keeper: { name: string; zone: string } = { name: 'the keeper', zone: 'UTC' }
|
||||
) {
|
||||
for (const id of store.roomIds()) {
|
||||
const room = replay(id, store.read(id));
|
||||
if (room) this.rooms.set(id, room);
|
||||
@@ -129,11 +158,16 @@ export class Rooms {
|
||||
|
||||
create(name: string, size: number): { room: Room; seat: Seat; token: string } {
|
||||
if (!Number.isInteger(size) || size < 2 || size > MAX_SEATS) throw new RoomError(`A table seats 2 to ${MAX_SEATS} wizards.`);
|
||||
return this.open(name, size);
|
||||
}
|
||||
|
||||
/** Open a table with its first seat taken; a rematch names the table it follows and holds seats for its wizards. */
|
||||
private open(name: string, size: number, follows?: { rematchOf: string; expected: string[] }): { room: Room; seat: Seat; token: string } {
|
||||
let id = newCode();
|
||||
while (this.rooms.has(id) || this.store.exists(id)) id = newCode();
|
||||
const room: Room = { id, size, createdAt: Date.now(), seats: [], state: null, pending: {}, chat: [], seq: 0, updatedAt: Date.now() };
|
||||
const room = blank(id, size, Date.now(), follows?.rematchOf ?? null, follows?.expected ?? []);
|
||||
this.rooms.set(id, room);
|
||||
this.commit(room, { t: 'room', id, seats: size, createdAt: room.createdAt });
|
||||
this.commit(room, { t: 'room', id, seats: size, createdAt: room.createdAt, ...(follows ? { rematchOf: follows.rematchOf, expected: follows.expected } : {}) });
|
||||
return { room, ...this.sit(room, name, false) };
|
||||
}
|
||||
|
||||
@@ -142,6 +176,99 @@ export class Rooms {
|
||||
return this.sit(room, name, false);
|
||||
}
|
||||
|
||||
/** Names with a seat held who have not yet sat. */
|
||||
private awaited(room: Room): string[] {
|
||||
return room.expected.filter((n) => !room.seats.some((s) => s.name.toLowerCase() === n.toLowerCase()));
|
||||
}
|
||||
|
||||
/** Host only: let the Peanut Gallery talk at this table, or hush it. Written to the ledger. */
|
||||
setGalleryTalk(room: Room, token: string | undefined, on: boolean): void {
|
||||
const seat = this.seatOf(room, token);
|
||||
if (seat.id !== room.seats[0]?.id) throw new RoomError('Only the wizard who opened the table decides whether the gallery may talk.', 403);
|
||||
if (room.galleryTalk === on) return;
|
||||
this.commit(room, { t: 'galleryTalk', at: Date.now(), on, by: seat.id });
|
||||
}
|
||||
|
||||
/** A word from the Peanut Gallery, when the host allows it: signed with a name no seat holds. */
|
||||
sayFromGallery(room: Room, rawName: unknown, raw: unknown): void {
|
||||
if (!room.galleryTalk) throw new RoomError('The gallery listens at this table; the host has not let it talk.', 403);
|
||||
const name = cleanName(rawName);
|
||||
if (room.seats.some((s) => s.name.toLowerCase() === name.toLowerCase())) throw new RoomError('A wizard at the table has that name.', 409);
|
||||
this.commit(room, { t: 'chat', at: Date.now(), id: SPECTATOR, name, text: cleanTalk(raw) });
|
||||
}
|
||||
|
||||
/**
|
||||
* A seated wizard calls the keeper of the site to the table: a seat is
|
||||
* held under the keeper's name and the call goes on the ledger. Once per
|
||||
* table, before the duel begins. The caller rings the keeper's bell.
|
||||
*/
|
||||
callKeeper(room: Room, token: string | undefined, keeper: string): void {
|
||||
const seat = this.seatOf(room, token);
|
||||
if (room.state) throw new RoomError('The duel has begun.', 409);
|
||||
if (room.challenge) throw new RoomError(`${keeper} has already been called to this table.`, 409);
|
||||
if (room.seats.some((s) => s.name.toLowerCase() === keeper.toLowerCase())) throw new RoomError(`${keeper} is already here.`, 409);
|
||||
if (room.seats.length + this.awaited(room).length >= room.size) throw new RoomError('Every seat at this table is taken or held.', 409);
|
||||
this.commit(room, { t: 'challenge', at: Date.now(), by: seat.id, keeper });
|
||||
}
|
||||
|
||||
/**
|
||||
* A finished table calls for a rematch: a new table of the same size with
|
||||
* the same bots, seats held for the same people, the caller seated as its
|
||||
* host. Anyone at the old table may call; a second call finds the table
|
||||
* already open.
|
||||
*/
|
||||
rematch(room: Room, token: string | undefined): { roomId: string; seat: Seat | null; token: string | null; created: boolean } {
|
||||
const caller = this.seatOf(room, token);
|
||||
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 };
|
||||
const others = room.seats.filter((s) => !s.bot && s.id !== caller.id).map((s) => s.name);
|
||||
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);
|
||||
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 };
|
||||
}
|
||||
|
||||
/** Phrases waiting to be claimed, with the seat and the token they carry. */
|
||||
private transfers = new Map<string, { roomId: string; seatId: WizardId; token: string; expiresAt: number }>();
|
||||
private failedClaims = 0;
|
||||
private failWindowStart = 0;
|
||||
|
||||
/** Mint a phrase that brings this seat to another device: four words, ten minutes, one use. */
|
||||
transfer(room: Room, token: string | undefined): { phrase: string; expiresAt: number } {
|
||||
const seat = this.seatOf(room, token);
|
||||
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);
|
||||
let phrase: string;
|
||||
do {
|
||||
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 });
|
||||
return { phrase, expiresAt };
|
||||
}
|
||||
|
||||
/** Claim a phrase: the seat and its token, once. Too many misses void every pending phrase. */
|
||||
claim(raw: unknown): { roomId: string; seat: WizardId; token: string } {
|
||||
const now = Date.now();
|
||||
if (now - this.failWindowStart > TRANSFER_TTL_MS) {
|
||||
this.failWindowStart = now;
|
||||
this.failedClaims = 0;
|
||||
}
|
||||
if (this.failedClaims >= TRANSFER_FAIL_LIMIT) throw new RoomError('Too many failed claims; transfers are cooling off. Mint a fresh phrase.', 429);
|
||||
const phrase = String(raw ?? '').trim().toLowerCase().replace(/\s+/g, '-');
|
||||
const t = this.transfers.get(phrase);
|
||||
if (!t || t.expiresAt < now) {
|
||||
this.failedClaims += 1;
|
||||
if (this.failedClaims >= TRANSFER_FAIL_LIMIT) this.transfers.clear();
|
||||
throw new RoomError('That phrase is unknown or has expired.', 404);
|
||||
}
|
||||
this.transfers.delete(phrase);
|
||||
const room = this.get(t.roomId);
|
||||
const seat = this.seatOf(room, t.token);
|
||||
return { roomId: room.id, seat: seat.id, token: t.token };
|
||||
}
|
||||
|
||||
/** The host, who opened the table, begins the duel once at least two are seated, however full the table. */
|
||||
begin(room: Room, token: string | undefined): void {
|
||||
const seat = this.seatOf(room, token);
|
||||
@@ -166,6 +293,10 @@ export class Rooms {
|
||||
private sit(room: Room, rawName: string, bot: boolean): { seat: Seat; token: string } {
|
||||
if (room.seats.length >= room.size) throw new RoomError('Every seat at this duel is taken.', 409);
|
||||
const name = cleanName(rawName);
|
||||
const awaited = this.awaited(room);
|
||||
if (!awaited.some((n) => n.toLowerCase() === name.toLowerCase()) && room.seats.length + awaited.length >= room.size) {
|
||||
throw new RoomError('The seats left are held for wizards expected at this table.', 409);
|
||||
}
|
||||
if (room.seats.some((s) => s.name.toLowerCase() === name.toLowerCase())) throw new RoomError('Another wizard here already has that name.', 409);
|
||||
const id = SEAT_IDS.find((s) => !room.seats.some((taken) => taken.id === s))!;
|
||||
const token = bot ? '' : newId(18);
|
||||
@@ -212,13 +343,7 @@ export class Rooms {
|
||||
/** 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 });
|
||||
this.commit(room, { t: 'chat', at: Date.now(), id: seat.id, text: cleanTalk(raw) });
|
||||
}
|
||||
|
||||
/** The view for a seat, or for the gallery when the seat is SPECTATOR. */
|
||||
@@ -233,7 +358,14 @@ export class Rooms {
|
||||
waitingOn: waitingOn(room),
|
||||
state: room.state ? viewFor(room.state, seatId) : null,
|
||||
chat: room.chat,
|
||||
audience: this.gallery.get(room.id) ?? 0
|
||||
audience: this.gallery.get(room.id) ?? 0,
|
||||
galleryTalk: room.galleryTalk,
|
||||
challenge: room.challenge,
|
||||
expected: this.awaited(room),
|
||||
rematch: room.rematch,
|
||||
rematchOf: room.rematchOf,
|
||||
keeper: this.keeper.name,
|
||||
keeperZone: this.keeper.zone
|
||||
};
|
||||
}
|
||||
|
||||
@@ -298,6 +430,16 @@ function apply(room: Room, line: LedgerLine): void {
|
||||
case 'input':
|
||||
room.pending[line.id] = line.input;
|
||||
break;
|
||||
case 'galleryTalk':
|
||||
room.galleryTalk = line.on;
|
||||
break;
|
||||
case 'challenge':
|
||||
room.challenge = { by: line.by, at: line.at };
|
||||
if (!room.expected.some((n) => n.toLowerCase() === line.keeper.toLowerCase())) room.expected = [...room.expected, line.keeper];
|
||||
break;
|
||||
case 'rematch':
|
||||
room.rematch = { to: line.to, by: line.by };
|
||||
break;
|
||||
case 'unseat':
|
||||
room.seats = room.seats.filter((s) => s.id !== line.id);
|
||||
break;
|
||||
@@ -312,7 +454,7 @@ function apply(room: Room, line: LedgerLine): void {
|
||||
case 'over':
|
||||
break;
|
||||
case 'chat':
|
||||
room.chat.push({ id: line.id, text: line.text, at: line.at });
|
||||
room.chat.push({ id: line.id, text: line.text, at: line.at, ...(line.name ? { name: line.name } : {}) });
|
||||
if (room.chat.length > CHAT_KEEP) room.chat.splice(0, room.chat.length - CHAT_KEEP);
|
||||
break;
|
||||
}
|
||||
@@ -321,7 +463,7 @@ function apply(room: Room, line: LedgerLine): void {
|
||||
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: {}, chat: [], seq: lines.length, updatedAt: first.createdAt };
|
||||
const room = blank(id, first.seats, first.createdAt, first.rematchOf ?? null, first.expected ?? [], lines.length);
|
||||
for (const line of lines) {
|
||||
apply(room, line);
|
||||
if (line.t === 'turn' || line.t === 'input') room.updatedAt = line.at;
|
||||
@@ -329,6 +471,16 @@ function replay(id: string, lines: LedgerLine[]): Room | null {
|
||||
return room;
|
||||
}
|
||||
|
||||
function cleanTalk(raw: unknown): string {
|
||||
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.');
|
||||
return text;
|
||||
}
|
||||
|
||||
export function cleanName(raw: unknown): string {
|
||||
const name = String(raw ?? '')
|
||||
.replace(/\s+/g, ' ')
|
||||
|
||||
+10
-3
@@ -7,7 +7,8 @@ import { join } from 'node:path';
|
||||
import type { TurnInput, WizardId } from '../../src/lib/game/state';
|
||||
|
||||
export type LedgerLine =
|
||||
| { t: 'room'; id: string; seats: number; createdAt: number }
|
||||
/** `rematchOf` and `expected` mark a table opened for a rematch: whose it follows, and whose seats are held. */
|
||||
| { 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. */
|
||||
| { t: 'seat'; id: WizardId; name: string; tokenHash?: string; token?: string; bot: boolean }
|
||||
/** One seat's move for the turn in progress, so a restart keeps it. The turn line that follows carries every input again. */
|
||||
@@ -19,8 +20,14 @@ export type LedgerLine =
|
||||
| { 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 }
|
||||
/** A line of table talk from a seated wizard. */
|
||||
| { t: 'chat'; at: number; id: WizardId; text: string };
|
||||
/** A line of table talk: from a seat, or, with `name`, from the Peanut Gallery (id SPECTATOR). */
|
||||
| { t: 'chat'; at: number; id: WizardId; text: string; name?: string }
|
||||
/** The host let the gallery talk, or hushed it. */
|
||||
| { t: 'galleryTalk'; at: number; on: boolean; by: WizardId }
|
||||
/** A seat is held for the keeper of the site, called to the table. */
|
||||
| { t: 'challenge'; at: number; by: WizardId; keeper: string }
|
||||
/** A finished table called for a rematch: where it went, and who called. */
|
||||
| { t: 'rematch'; at: number; to: string; by: WizardId };
|
||||
|
||||
export class Store {
|
||||
constructor(private dir: string) {
|
||||
|
||||
Reference in New Issue
Block a user