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
+36 -3
View File
@@ -28,6 +28,7 @@
let punchWallMode = $state(false);
/** Leafing through the face-up discard pile. */
let showDiscards = $state(false);
let chatDraft = $state("");
let helpTab = $state<"play" | "rules" | "cards" | "about" | "tally">("play");
let hotseatCount = $state(2);
let setupName = $state("");
@@ -961,9 +962,9 @@
{:else if !g.started}
waiting to start · {g.players.join(", ")}
{:else if g.yourTurn}
{attentionLabel(g.attention)} · round {g.round} · {timeAgo(g.lastMoveAt)}
{attentionLabel(g.attention)} · round {g.round} · {timeAgo(g.lastMoveAt)}{#if net.unreadChat(g.roomId, g.chatCount) > 0}&nbsp;· 💬{net.unreadChat(g.roomId, g.chatCount)}{/if}
{:else}
{g.activePlayerId}'s turn · round {g.round} · {timeAgo(g.lastMoveAt)}
{g.activePlayerId}'s turn · round {g.round} · {timeAgo(g.lastMoveAt)}{#if net.unreadChat(g.roomId, g.chatCount) > 0}&nbsp;· 💬{net.unreadChat(g.roomId, g.chatCount)}{/if}
{/if}
</span>
</button>
@@ -1150,9 +1151,21 @@
<div class="chronicle" aria-label="game log" bind:this={chronicleEl}>
{#each (local.active ? local.log : net.log).slice(-60) as line, i (i)}
<div>{line}</div>
<div class:table-talk={line.startsWith("\u{1F4AC}")}>{line}</div>
{/each}
</div>
{#if !local.active && net.roomId}
<form class="say-box" onsubmit={(e) => {
e.preventDefault();
const t = chatDraft.trim();
if (t) net.sendChat(t);
chatDraft = "";
}}>
<input class="say-input" bind:value={chatDraft} maxlength="300"
placeholder="say something to the table…" aria-label="table talk" />
<button class="stamp tiny" type="submit" disabled={!chatDraft.trim()}>say</button>
</form>
{/if}
</aside>
</div>
@@ -1894,6 +1907,26 @@
.attack-power { font-family: "Courier Prime", monospace; color: #6b5a41; font-size: 0.9rem; }
.attack-fist { font-size: 1.1rem; color: #43331f; }
.table-talk {
background: #efe8d4;
color: #43331f;
border-radius: 3px;
padding: 0.1rem 0.4rem;
margin: 0.15rem 0;
font-style: italic;
}
.say-box { display: flex; gap: 0.35rem; margin-top: 0.4rem; }
.say-input {
flex: 1;
min-width: 0;
background: #f6f0df;
border: 1px solid #b3a687;
border-radius: 4px;
padding: 0.35rem 0.55rem;
font-family: "Courier Prime", monospace;
font-size: 0.8rem;
color: #43331f;
}
.discard-link {
background: none;
border: none;