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
+46 -1
View File
@@ -169,6 +169,12 @@ export function humanize(e: GameEvent): string | null {
const SEAT_KEY = "wizwar-seat";
const SEATS_KEY = "wizwar-seats";
const CHAT_SEEN_KEY = "wizwar-chat-seen";
function loadChatSeen(): Record<string, number> {
try { return JSON.parse(localStorage.getItem(CHAT_SEEN_KEY) ?? "{}"); }
catch { return {}; }
}
const SEEN_KEY = "wizwar-seen";
function loadSeen(): Record<string, number> {
@@ -189,6 +195,7 @@ export interface GameSummary {
attention: "turn" | "counteract" | "discard" | "interrupt" | null;
round: number | null;
lastMoveAt: string | null;
chatCount: number;
}
export function attentionLabel(a: GameSummary["attention"]): string {
@@ -224,6 +231,9 @@ class Net {
seats = $state<Seat[]>(loadSeats());
/** Lobby ledger: one summary per live seat. */
stats = $state<Record<string, number | string | null> | null>(null);
chatSeen = $state<Record<string, number>>(loadChatSeen());
/** Messages in the current room this session (history + live). */
chatCount = 0;
games = $state<GameSummary[]>([]);
notificationsEnabled = $state(
typeof Notification !== "undefined" && Notification.permission === "granted",
@@ -233,7 +243,7 @@ class Net {
/** Moves you haven't watched yet in the current room. */
missedMoves = $state(0);
/** A catch-up reel delivered by the server. */
catchUp = $state<{ seq: number; actor: string; events: GameEvent[]; view: GameView }[] | null>(null);
catchUp = $state<{ seq: number; actor: string; events: GameEvent[]; view: GameView; chat?: { player: string; text: string }[] }[] | null>(null);
private seen: Record<string, number> = loadSeen();
private currentSeq = 0;
private lastYourTurn = new Map<string, boolean>();
@@ -325,6 +335,23 @@ class Net {
this.resume({ name: seat.name, roomId: seat.roomId, token: seat.token });
break;
}
case "chat": {
this.log = [...this.log, `\u{1F4AC} ${msg.player}: ${msg.text}`];
this.chatCount += 1;
if (this.roomId) this.markChatSeen();
break;
}
case "chatHistory": {
this.log = [
...this.log,
...(msg.messages as { player: string; text: string }[]).map(
(m) => `\u{1F4AC} ${m.player}: ${m.text}`,
),
];
this.chatCount = msg.messages.length;
if (this.roomId) this.markChatSeen();
break;
}
case "stats": {
this.stats = msg.stats;
break;
@@ -399,6 +426,24 @@ class Net {
}
/** Ask the server how all our games are doing. */
sendChat(text: string): void {
this.send({ type: "chat", text });
}
/** Watching the table counts as reading the talk. */
markChatSeen(): void {
if (!this.roomId) return;
const games = this.games.find((g) => g.roomId === this.roomId);
const count = Math.max(this.chatCount, games?.chatCount ?? 0);
if ((this.chatSeen[this.roomId] ?? 0) >= count) return;
this.chatSeen = { ...this.chatSeen, [this.roomId]: count };
localStorage.setItem(CHAT_SEEN_KEY, JSON.stringify(this.chatSeen));
}
unreadChat(roomId: string, chatCount: number): number {
return Math.max(0, chatCount - (this.chatSeen[roomId] ?? 0));
}
requestStats(): void {
this.send({ type: "stats" });
}