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
@@ -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} · 💬{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} · 💬{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;
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
steps,
|
||||
onclose,
|
||||
}: {
|
||||
steps: { seq: number; actor: string; events: GameEvent[]; view: GameView }[];
|
||||
steps: { seq: number; actor: string; events: GameEvent[]; view: GameView; chat?: { player: string; text: string }[] }[];
|
||||
onclose: () => void;
|
||||
} = $props();
|
||||
|
||||
@@ -52,7 +52,10 @@
|
||||
<div class="replay-caption">
|
||||
<strong>{step.actor}</strong>
|
||||
{#each lines as line, i (i)}<div>{line}</div>{/each}
|
||||
{#if lines.length === 0}<div>…considers the maze.</div>{/if}
|
||||
{#if lines.length === 0 && !step.chat?.length}<div>…considers the maze.</div>{/if}
|
||||
{#each step.chat ?? [] as c, i (i)}
|
||||
<div class="reel-talk">💬 {c.player}: {c.text}</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="replay-controls">
|
||||
<button onclick={() => { playing = false; idx = Math.max(0, idx - 1); }} aria-label="previous move">◀</button>
|
||||
@@ -122,6 +125,7 @@
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
.reel-talk { font-style: italic; opacity: 0.85; }
|
||||
.replay-caption {
|
||||
background: #efe8d4;
|
||||
color: #3a2f1f;
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user