Table talk: chat woven into the chronicle

A say-box under the chronicle sends table talk to the room; messages
land in the event log itself, styled as parchment asides — banter
between the battle lines, the way it happens at a real table. Talk is
seat-authenticated, stripped and capped at 300 chars, rate-limited by
the existing bucket, persisted as chat lines in the room file (the
game replay ignores them; the room keeps its last 500), and replayed
to anyone joining. Async games get unread badges in the lobby ledger
(💬3), cleared by watching the table, and catch-up replays speak the
lines at the step where they were said. Public to the whole room, no
whispers — and no chat in hotseat, where the table talks for itself.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-16 14:24:22 -04:00
co-authored by Claude Fable 5
parent ed9585aa59
commit df52e9b53a
7 changed files with 242 additions and 7 deletions
+15
View File
@@ -8,6 +8,7 @@
// {type:"makeTransfer"} mint a seat-transfer phrase
// {type:"claimTransfer", code} claim a seat on a new device
// {type:"catchUp", sinceSeq} replay of moves missed while away
// {type:"chat", text} table talk to the room
// {type:"myGames", seats} summaries for held seats
// {type:"stats"} the engagement tally
// {type:"hotseatReport", ...} anonymous hotseat game counts
@@ -17,6 +18,8 @@
// {type:"room", roomId, players, hostId, started, colors}
// {type:"events", events} redacted for this recipient
// {type:"state", view, seq} redacted full view (after every change)
// {type:"chat", player, text, at} one line of table talk
// {type:"chatHistory", messages} the room's talk so far, on join
// {type:"transferCode"|"transferClaimed"|"catchUp"|"games"|"stats"}
// {type:"error", message}
@@ -33,6 +36,7 @@ import {
getRoom,
joinRoom,
loadPersistedRooms,
addChat,
makeTransferCode,
redactFor,
roomCount,
@@ -246,6 +250,9 @@ wss.on("connection", (socket) => {
if (room.state) {
send(socket, { type: "events", events: redactFor(room.events, name) });
}
if (room.chat.length > 0) {
send(socket, { type: "chatHistory", messages: room.chat.slice(-100) });
}
broadcastRoomState(room);
break;
}
@@ -271,6 +278,14 @@ wss.on("connection", (socket) => {
broadcast(room, (playerId) => ({ type: "state", view: viewForPlayer(room, playerId), seq: room.log.length }));
break;
}
case "chat": {
const room = session.roomId ? getRoom(session.roomId) : undefined;
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
const result = addChat(room, session.playerId, String(msg.text ?? ""));
if ("error" in result) return send(socket, { type: "error", message: result.error });
broadcast(room, () => ({ type: "chat", player: session.playerId, text: result.text, at: result.at }));
break;
}
case "makeTransfer": {
const room = session.roomId ? getRoom(session.roomId) : undefined;
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });