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:
co-authored by
Claude Fable 5
parent
ed9585aa59
commit
df52e9b53a
@@ -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" });
|
||||
|
||||
@@ -39,6 +39,8 @@ export interface Room {
|
||||
state: GameState | null; // null until started
|
||||
log: LoggedCommand[];
|
||||
events: GameEvent[]; // full history (unredacted — redact per recipient)
|
||||
/** Table talk, persisted with the room (public to all seats). */
|
||||
chat: { player: PlayerId; text: string; at: string }[];
|
||||
}
|
||||
|
||||
const rooms = new Map<string, Room>();
|
||||
@@ -95,6 +97,7 @@ export function createRoom(hostId: PlayerId): { room: Room; token: string } {
|
||||
state: null,
|
||||
log: [],
|
||||
events: [],
|
||||
chat: [],
|
||||
};
|
||||
rooms.set(room.id, room);
|
||||
recordRoom(room);
|
||||
@@ -210,6 +213,20 @@ export function runCommand(
|
||||
return { events: result.events };
|
||||
}
|
||||
|
||||
const CHAT_MAX_LENGTH = 300;
|
||||
const CHAT_KEEP = 500;
|
||||
|
||||
export function addChat(room: Room, playerId: PlayerId, rawText: string): { text: string; at: string } | { error: string } {
|
||||
if (!room.players.includes(playerId)) return { error: "you hold no seat in this room" };
|
||||
const text = rawText.replace(/[\u0000-\u001f\u007f]/g, " ").trim().slice(0, CHAT_MAX_LENGTH);
|
||||
if (!text) return { error: "say something" };
|
||||
const at = new Date().toISOString();
|
||||
room.chat.push({ player: playerId, text, at });
|
||||
if (room.chat.length > CHAT_KEEP) room.chat.splice(0, room.chat.length - CHAT_KEEP);
|
||||
appendLine(room.id, { kind: "chat", player: playerId, text, at });
|
||||
return { text, at };
|
||||
}
|
||||
|
||||
export interface GameSummary {
|
||||
roomId: string;
|
||||
name: PlayerId;
|
||||
@@ -223,6 +240,8 @@ export interface GameSummary {
|
||||
attention: "turn" | "counteract" | "discard" | "interrupt" | null;
|
||||
round: number | null;
|
||||
lastMoveAt: string | null;
|
||||
/** Total table-talk messages; the client tracks which it has seen. */
|
||||
chatCount: number;
|
||||
}
|
||||
|
||||
/** A seat-holder's one-line view of a room, for the lobby ledger. */
|
||||
@@ -252,6 +271,7 @@ export function summarize(room: Room, playerId: PlayerId): GameSummary {
|
||||
attention,
|
||||
round: s?.turn.round ?? null,
|
||||
lastMoveAt: room.log.length > 0 ? room.log[room.log.length - 1]!.at : null,
|
||||
chatCount: room.chat.length,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -264,6 +284,8 @@ export interface CatchUpStep {
|
||||
actor: PlayerId;
|
||||
events: GameEvent[];
|
||||
view: GameView;
|
||||
/** Table talk uttered between the previous command and this one. */
|
||||
chat?: { player: PlayerId; text: string }[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -284,11 +306,16 @@ export function catchUpSteps(room: Room, playerId: PlayerId, sinceSeq: number, f
|
||||
if (!result.ok) return { error: `replay diverged at seq ${entry.seq}` };
|
||||
current = result.state;
|
||||
if (entry.seq >= from) {
|
||||
const prevAt = entry.seq > 0 ? room.log[entry.seq - 1]!.at : "";
|
||||
const said = room.chat
|
||||
.filter((c) => c.at > prevAt && c.at <= entry.at)
|
||||
.map((c) => ({ player: c.player, text: c.text }));
|
||||
steps.push({
|
||||
seq: entry.seq,
|
||||
actor: entry.playerId,
|
||||
events: redactFor(result.events, playerId),
|
||||
view: viewFor(current, playerId),
|
||||
...(said.length > 0 ? { chat: said } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -396,6 +423,7 @@ export function loadPersistedRooms(): void {
|
||||
state: null,
|
||||
log: [],
|
||||
events: [],
|
||||
chat: [],
|
||||
};
|
||||
for (const line of lines.slice(1)) {
|
||||
if (line.kind === "join") {
|
||||
@@ -406,6 +434,8 @@ export function loadPersistedRooms(): void {
|
||||
} else if (line.kind === "start") {
|
||||
const r = startInMemory(room, line.expansion, line.colors, line.deckRev);
|
||||
if ("error" in r) throw new Error(`replay start failed: ${r.error}`);
|
||||
} else if (line.kind === "chat") {
|
||||
room.chat.push({ player: line.player, text: line.text, at: line.at });
|
||||
} else if (line.kind === "command") {
|
||||
if (!room.state) throw new Error("command before start in log");
|
||||
const result = applyCommand(room.state, line.playerId, line.command as Command);
|
||||
|
||||
@@ -43,7 +43,14 @@ export interface CommandLine {
|
||||
at: string;
|
||||
}
|
||||
|
||||
export type RoomLine = RoomMetaLine | JoinLine | StartLine | CommandLine;
|
||||
export interface ChatLine {
|
||||
kind: "chat";
|
||||
player: string;
|
||||
text: string;
|
||||
at: string;
|
||||
}
|
||||
|
||||
export type RoomLine = RoomMetaLine | JoinLine | StartLine | CommandLine | ChatLine;
|
||||
|
||||
const DATA_DIR = process.env.WIZWAR_DATA_DIR ?? join(process.cwd(), "data", "rooms");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user