feat(client): AI badge and bot-moving turn indicator

Track aiOpponent in game store; show a pill badge in the topbar for AI
games, update turn label to "<Brain> is moving…" on the bot's turn,
and suppress the disconnected-opponent banner when the opponent is a bot.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
claude (blind_chess)
2026-04-28 14:26:25 -04:00
parent 31f68db654
commit 06bd144f7c
2 changed files with 41 additions and 1 deletions
+37 -1
View File
@@ -61,6 +61,17 @@
setTimeout(() => copied = false, 1500); setTimeout(() => copied = false, 1500);
} }
const isBotTurn = $derived(
!!game.state.aiOpponent
&& game.state.gameStatus === 'active'
&& game.state.view?.toMove === game.state.aiOpponent.color,
);
const aiBadgeText = $derived.by(() => {
if (!game.state.aiOpponent) return null;
return game.state.aiOpponent.brain === 'casual' ? 'Casual bot' : 'gemma4 recon';
});
const turnLabel = $derived.by(() => { const turnLabel = $derived.by(() => {
if (game.state.gameStatus === 'finished') { if (game.state.gameStatus === 'finished') {
const reason = game.state.endReason; const reason = game.state.endReason;
@@ -74,6 +85,10 @@
if (game.state.gameStatus === 'waiting') return 'Waiting for opponent…'; if (game.state.gameStatus === 'waiting') return 'Waiting for opponent…';
if (!game.state.you) return '…'; if (!game.state.you) return '…';
if (game.state.view?.toMove === game.state.you) return 'Your turn'; if (game.state.view?.toMove === game.state.you) return 'Your turn';
if (game.state.aiOpponent) {
const name = game.state.aiOpponent.brain === 'casual' ? 'Casual bot' : 'gemma4 recon';
return `${name} is moving…`;
}
return 'Opponent thinking'; return 'Opponent thinking';
}); });
</script> </script>
@@ -89,6 +104,11 @@
· You: {game.state.you === 'w' ? 'White' : 'Black'} · You: {game.state.you === 'w' ? 'White' : 'Black'}
{/if} {/if}
</span> </span>
{#if aiBadgeText}
<span class="ai-badge" class:thinking={isBotTurn}>
{aiBadgeText}
</span>
{/if}
</div> </div>
{#if game.state.gameStatus === 'waiting'} {#if game.state.gameStatus === 'waiting'}
@@ -141,7 +161,7 @@
<div class="banner err">{game.state.lastError.code}: {game.state.lastError.message}</div> <div class="banner err">{game.state.lastError.code}: {game.state.lastError.message}</div>
{/if} {/if}
{#if !game.state.opponentConnected && game.state.gameStatus === 'active'} {#if !game.state.opponentConnected && game.state.gameStatus === 'active' && !game.state.aiOpponent}
<div class="banner muted">Opponent disconnected — 5 minute grace window.</div> <div class="banner muted">Opponent disconnected — 5 minute grace window.</div>
{/if} {/if}
</aside> </aside>
@@ -221,6 +241,22 @@
.banner .row { display: flex; gap: 8px; margin-top: 8px; } .banner .row { display: flex; gap: 8px; margin-top: 8px; }
.banner .row button { flex: 1; } .banner .row button { flex: 1; }
.ai-badge {
font-size: 12px;
font-weight: 600;
padding: 4px 10px;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--panel);
color: var(--text-dim);
white-space: nowrap;
}
.ai-badge.thinking {
color: var(--accent);
border-color: var(--accent);
background: rgba(211, 84, 0, 0.07);
}
.waiting-card { .waiting-card {
background: var(--panel); background: var(--panel);
border: 1px solid var(--border); border: 1px solid var(--border);
@@ -28,6 +28,7 @@ interface GameStateValue {
winner: Color | null; winner: Color | null;
opponentConnected: boolean; opponentConnected: boolean;
lastError: { code: ErrorCode; message: string; at: number } | null; lastError: { code: ErrorCode; message: string; at: number } | null;
aiOpponent: { color: Color; brain: 'casual' | 'recon' } | null;
} }
function makeStore() { function makeStore() {
@@ -47,6 +48,7 @@ function makeStore() {
winner: null, winner: null,
opponentConnected: false, opponentConnected: false,
lastError: null, lastError: null,
aiOpponent: null,
}); });
function tokenKey(gameId: string) { return `bc:${gameId}`; } function tokenKey(gameId: string) { return `bc:${gameId}`; }
@@ -91,6 +93,7 @@ function makeStore() {
state.mode = m.mode; state.mode = m.mode;
state.highlightingEnabled = m.highlightingEnabled; state.highlightingEnabled = m.highlightingEnabled;
state.opponentConnected = m.opponentConnected; state.opponentConnected = m.opponentConnected;
state.aiOpponent = m.aiOpponent ?? null;
if (state.gameId) localStorage.setItem(tokenKey(state.gameId), m.token); if (state.gameId) localStorage.setItem(tokenKey(state.gameId), m.token);
break; break;
case 'update': case 'update':
@@ -103,6 +106,7 @@ function makeStore() {
if (m.newAnnouncements.length) { if (m.newAnnouncements.length) {
state.announcements = [...state.announcements, ...m.newAnnouncements]; state.announcements = [...state.announcements, ...m.newAnnouncements];
} }
if (m.aiOpponent !== undefined) state.aiOpponent = m.aiOpponent;
break; break;
case 'peer-status': case 'peer-status':
if (state.you && m.color !== state.you) { if (state.you && m.color !== state.you) {