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:
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user