Compare commits

...
Author SHA1 Message Date
Commander AI LabandClaude Opus 4.6 3f66a8ed15 feat: add 4-player batch simulator React page with N-player API endpoint
- Add POST /api/sim/run-n-player endpoint accepting 2-4 deck IDs for
  N-player batch simulations using the existing engine.run_n() method
- Add _run_sim_thread_n_player tracking per-seat wins and stats
- Create SimulatorPage.tsx with 4 deck selection slots, config row,
  progress bar, results table, CSS bar chart, and per-game logs
- Add simulator types in frontend/src/types/simulator.ts
- Add /simulator route and nav bar to App.tsx

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 03:39:03 +00:00
KoalaTrapLord f4da7674b6 feat: React + TypeScript frontend migration (all 12 phases)
Complete migration of the Commander AI Lab GUI from vanilla JS to React + TypeScript + Vite + Tailwind.

Phases implemented:
- Phase 1: Vite + React-TS scaffold with proxy config
- Phase 2: TypeScript type definitions (game.ts, constants.ts)
- Phase 3: useGameSocket hook + gameReducer
- Phase 4: Core components (PhaseBar, PlayerZone, CardChip, Board)
- Phase 5: NarrationPanel with auto-scroll
- Phase 6: MoveBar + HandCard
- Phase 7: GameOverOverlay
- Phase 8: App routing + GamePage assembly
- Phase 9: Lobby UI (LobbyPage, RoomPage, useLobbySock, spectator mode)
- Phase 10: Tailwind colour tokens
- Phase 11: Build integration + CI + README
- Phase 12: Test suite (26 tests, all passing)
2026-03-17 22:58:54 -04:00
4 changed files with 588 additions and 1 deletions

No files matched your search

+32 -1
View File
@@ -1,15 +1,46 @@
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { BrowserRouter, Routes, Route, Link, useLocation } from 'react-router-dom';
import LobbyPage from './pages/LobbyPage';
import RoomPage from './pages/RoomPage';
import GamePage from './pages/GamePage';
import SimulatorPage from './pages/SimulatorPage';
function Nav() {
const location = useLocation();
// Hide nav on active game pages
if (location.pathname.startsWith('/game/')) return null;
const links = [
{ to: '/', label: 'Lobby' },
{ to: '/simulator', label: 'Simulator' },
];
return (
<nav className="bg-bg-bar border-b border-border-zone px-4 py-2 flex items-center gap-4">
<span className="text-sm font-bold text-white mr-4">Commander AI Lab</span>
{links.map((l) => (
<Link
key={l.to}
to={l.to}
className={`text-sm px-2 py-1 rounded transition-colors ${
location.pathname === l.to
? 'text-white bg-accent-blue/20'
: 'text-text-muted hover:text-white'
}`}
>
{l.label}
</Link>
))}
</nav>
);
}
function App() {
return (
<BrowserRouter>
<Nav />
<Routes>
<Route path="/" element={<LobbyPage />} />
<Route path="/room/:roomId" element={<RoomPage />} />
<Route path="/game/:gameId" element={<GamePage />} />
<Route path="/simulator" element={<SimulatorPage />} />
</Routes>
</BrowserRouter>
);
+391
View File
@@ -0,0 +1,391 @@
import { useEffect, useRef, useState } from 'react';
import { SEAT_COLOURS } from '../constants';
import type { SimDeck, SimResult } from '../types/simulator';
const SEAT_LABELS = ['Seat 1 (You)', 'Seat 2', 'Seat 3', 'Seat 4'];
type SimStatus = 'idle' | 'running' | 'complete' | 'error';
export default function SimulatorPage() {
// Deck list from API
const [decks, setDecks] = useState<SimDeck[]>([]);
const [loadingDecks, setLoadingDecks] = useState(true);
// Slot selections (4 slots, null = empty)
const [slots, setSlots] = useState<(number | null)[]>([null, null, null, null]);
// Config
const [numGames, setNumGames] = useState(10);
const [engineType, setEngineType] = useState('heuristic');
const [recordLogs, setRecordLogs] = useState(true);
// Sim state
const [simId, setSimId] = useState<string | null>(null);
const [status, setStatus] = useState<SimStatus>('idle');
const [completed, setCompleted] = useState(0);
const [total, setTotal] = useState(0);
const [result, setResult] = useState<SimResult | null>(null);
const [games, setGames] = useState<unknown[]>([]);
const [error, setError] = useState<string | null>(null);
const [showLogs, setShowLogs] = useState(false);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Fetch decks on mount
useEffect(() => {
fetch('/api/decks')
.then((res) => res.json())
.then((data) => {
const list = (data.decks || []).map((d: Record<string, unknown>) => ({
id: d.id as number,
name: d.name as string,
commander_name: (d.commander_name || '') as string,
color_identity: (d.color_identity || []) as string[],
}));
setDecks(list);
})
.catch(() => setDecks([]))
.finally(() => setLoadingDecks(false));
}, []);
// Polling
useEffect(() => {
if (status !== 'running' || !simId) return;
pollRef.current = setInterval(async () => {
try {
const res = await fetch(`/api/sim/status?simId=${simId}`);
const data = await res.json();
setCompleted(data.completed ?? 0);
setTotal(data.total ?? 0);
if (data.status === 'complete') {
setStatus('complete');
// Fetch results
const rres = await fetch(`/api/sim/result?simId=${simId}`);
const rdata = await rres.json();
setResult(rdata.summary ?? null);
setGames(rdata.games ?? []);
} else if (data.status === 'error') {
setStatus('error');
setError(data.error || 'Simulation failed');
}
} catch {
// keep polling
}
}, 1000);
return () => {
if (pollRef.current) clearInterval(pollRef.current);
};
}, [status, simId]);
const selectedCount = slots.filter((s) => s !== null).length;
const canRun = selectedCount >= 2 && status !== 'running';
const handleRun = async () => {
const deckIds = slots.filter((s): s is number => s !== null);
setStatus('running');
setCompleted(0);
setTotal(numGames);
setResult(null);
setGames([]);
setError(null);
try {
const res = await fetch('/api/sim/run-n-player', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
deckIds,
numGames,
recordLogs,
engineType,
}),
});
const data = await res.json();
if (!res.ok) {
setStatus('error');
setError(data.error || 'Failed to start simulation');
return;
}
setSimId(data.simId);
setTotal(data.total);
} catch {
setStatus('error');
setError('Network error');
}
};
const setSlot = (idx: number, value: number | null) => {
setSlots((prev) => {
const next = [...prev];
next[idx] = value;
return next;
});
};
const getDeckById = (id: number) => decks.find((d) => d.id === id);
return (
<div className="min-h-screen bg-bg-base text-white p-6">
<div className="max-w-5xl mx-auto">
<h1 className="text-3xl font-bold mb-2">Batch Simulator</h1>
<p className="text-text-muted text-sm mb-6">
Run N-player Monte Carlo simulations with your decks.
</p>
{/* Deck Selection */}
<div className="mb-6">
<h2 className="text-lg font-semibold mb-3">Deck Slots</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{SEAT_LABELS.map((label, idx) => {
const selected = slots[idx];
const deck = selected !== null ? getDeckById(selected) : null;
return (
<div
key={idx}
className="bg-bg-zone rounded-md p-4"
style={{
border: `2px solid ${selected !== null ? SEAT_COLOURS[idx] : '#3a4060'}`,
boxShadow: selected !== null ? `0 0 8px ${SEAT_COLOURS[idx]}40` : 'none',
}}
>
<div
className="text-sm font-medium mb-2"
style={{ color: SEAT_COLOURS[idx] }}
>
{label}
</div>
<select
value={selected ?? ''}
onChange={(e) => setSlot(idx, e.target.value ? Number(e.target.value) : null)}
className="w-full bg-bg-panel border border-border-zone rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-accent-blue"
>
<option value="">— Empty —</option>
{decks.map((d) => (
<option key={d.id} value={d.id}>
{d.name}
</option>
))}
</select>
{deck && (
<div className="mt-2 text-xs text-text-muted">
{deck.commander_name && (
<div className="truncate">{deck.commander_name}</div>
)}
{deck.color_identity.length > 0 && (
<div>{deck.color_identity.join(' ')}</div>
)}
</div>
)}
</div>
);
})}
</div>
{loadingDecks && (
<p className="text-text-muted text-sm mt-2">Loading decks...</p>
)}
</div>
{/* Config Row */}
<div className="flex flex-wrap items-end gap-4 mb-6 bg-bg-zone rounded-md border border-border-zone p-4">
<div>
<label className="block text-sm text-text-muted mb-1">Games</label>
<input
type="number"
min={1}
max={1000}
value={numGames}
onChange={(e) => setNumGames(Math.max(1, Math.min(1000, Number(e.target.value))))}
className="w-24 bg-bg-panel border border-border-zone rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-accent-blue"
/>
</div>
<div>
<label className="block text-sm text-text-muted mb-1">Engine</label>
<select
value={engineType}
onChange={(e) => setEngineType(e.target.value)}
className="bg-bg-panel border border-border-zone rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-accent-blue"
>
<option value="heuristic">Heuristic AI</option>
<option value="deepseek">DeepSeek AI</option>
</select>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="recordLogs"
checked={recordLogs}
onChange={(e) => setRecordLogs(e.target.checked)}
className="rounded"
/>
<label htmlFor="recordLogs" className="text-sm text-text-muted">
Record Logs
</label>
</div>
<button
onClick={handleRun}
disabled={!canRun}
className="bg-accent-blue text-white rounded px-4 py-2 text-sm font-medium hover:brightness-110 disabled:opacity-50 disabled:cursor-not-allowed"
>
{status === 'running' ? (
<span className="flex items-center gap-2">
<span className="inline-block w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
Running...
</span>
) : (
'Run Simulation'
)}
</button>
</div>
{/* Progress */}
{status === 'running' && (
<div className="mb-6">
<div className="flex justify-between text-sm text-text-muted mb-1">
<span>Progress</span>
<span>
{completed} / {total} games
</span>
</div>
<div className="w-full bg-bg-panel rounded-full h-3 overflow-hidden">
<div
className="h-full bg-accent-blue rounded-full transition-all duration-300"
style={{ width: `${total > 0 ? (completed / total) * 100 : 0}%` }}
/>
</div>
</div>
)}
{/* Error */}
{status === 'error' && error && (
<div className="mb-6 bg-red-900/30 border border-red-700 rounded-md p-3 text-sm text-red-300">
{error}
</div>
)}
{/* Results */}
{status === 'complete' && result && (
<div className="space-y-6">
<div className="flex items-center gap-4 text-sm text-text-muted">
<span>{result.totalGames} games</span>
<span>{result.playerCount} players</span>
<span>{result.elapsedSeconds}s elapsed</span>
</div>
{/* Win Rate Bar Chart */}
<div className="bg-bg-zone rounded-md border border-border-zone p-4">
<h3 className="text-sm font-semibold mb-3">Win Rates</h3>
<div className="space-y-2">
{result.players.map((p) => (
<div key={p.seat} className="flex items-center gap-3">
<div className="w-28 text-sm truncate" style={{ color: SEAT_COLOURS[p.seat] }}>
{p.deckName}
</div>
<div className="flex-1 bg-bg-panel rounded-full h-5 overflow-hidden">
<div
className="h-full rounded-full transition-all duration-500"
style={{
width: `${p.winRate}%`,
backgroundColor: SEAT_COLOURS[p.seat],
minWidth: p.winRate > 0 ? '2px' : '0',
}}
/>
</div>
<div className="w-16 text-right text-sm font-mono">
{p.winRate}%
</div>
</div>
))}
</div>
</div>
{/* Stats Table */}
<div className="bg-bg-zone rounded-md border border-border-zone overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border-zone text-text-muted">
<th className="text-left px-3 py-2">Seat</th>
<th className="text-left px-3 py-2">Deck</th>
<th className="text-right px-3 py-2">Wins</th>
<th className="text-right px-3 py-2">Win%</th>
<th className="text-right px-3 py-2">Avg Turns</th>
<th className="text-right px-3 py-2">Avg Dmg</th>
<th className="text-right px-3 py-2">Avg Spells</th>
<th className="text-right px-3 py-2">Avg Creatures</th>
<th className="text-right px-3 py-2">Avg Removal</th>
</tr>
</thead>
<tbody>
{result.players.map((p) => {
const isWinner = result.players.every((o) => o.wins <= p.wins);
return (
<tr
key={p.seat}
className={`border-b border-border-zone ${isWinner ? 'font-semibold' : ''}`}
style={{
backgroundColor: `${SEAT_COLOURS[p.seat]}${isWinner ? '20' : '0a'}`,
}}
>
<td className="px-3 py-2" style={{ color: SEAT_COLOURS[p.seat] }}>
P{p.seat + 1}
</td>
<td className="px-3 py-2">{p.deckName}</td>
<td className="text-right px-3 py-2">{p.wins}</td>
<td className="text-right px-3 py-2">{p.winRate}%</td>
<td className="text-right px-3 py-2">{p.avgTurns}</td>
<td className="text-right px-3 py-2">{p.avgDamageDealt}</td>
<td className="text-right px-3 py-2">{p.avgSpellsCast}</td>
<td className="text-right px-3 py-2">{p.avgCreaturesPlayed}</td>
<td className="text-right px-3 py-2">{p.avgRemovalUsed}</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* Per-Game Logs (collapsible) */}
{games.length > 0 && (
<div className="bg-bg-zone rounded-md border border-border-zone">
<button
onClick={() => setShowLogs(!showLogs)}
className="w-full text-left px-4 py-3 text-sm font-medium flex justify-between items-center hover:bg-bg-panel transition-colors"
>
<span>Per-Game Results ({games.length} games)</span>
<span className="text-text-muted">{showLogs ? '▲' : '▼'}</span>
</button>
{showLogs && (
<div className="px-4 pb-3 max-h-96 overflow-y-auto">
<div className="space-y-1">
{games.map((g: unknown, i: number) => {
const game = g as Record<string, unknown>;
return (
<div
key={i}
className="flex items-center gap-3 text-xs text-text-muted py-1 border-b border-border-zone"
>
<span className="w-12 font-mono">#{game.gameNumber as number}</span>
<span>
Winner:{' '}
<span
style={{
color: SEAT_COLOURS[(game.winner ?? game.winningSeat ?? 0) as number],
}}
>
P{((game.winner ?? game.winningSeat ?? 0) as number) + 1}
</span>
</span>
<span>{game.turns as number} turns</span>
</div>
);
})}
</div>
</div>
)}
</div>
)}
</div>
)}
</div>
</div>
);
}
+28
View File
@@ -0,0 +1,28 @@
export interface SimDeck {
id: number;
name: string;
commander_name: string;
color_identity: string[];
}
export interface SimPlayerResult {
seat: number;
deckName: string;
wins: number;
winRate: number;
avgTurns: number;
avgDamageDealt: number;
avgSpellsCast: number;
avgCreaturesPlayed: number;
avgRemovalUsed: number;
avgRampPlayed: number;
avgCardsDrawn: number;
avgMaxBoardSize: number;
}
export interface SimResult {
playerCount: number;
totalGames: number;
players: SimPlayerResult[];
elapsedSeconds: number;
}
+137
View File
@@ -295,6 +295,106 @@ def _run_sim_thread_deepseek(sim_id: str, card_data: list[dict],
_fail_sim(sim_id, e)
traceback.print_exc()
# ══════════════════════════════════════════════════════════════
# N-Player Sim Thread
# ══════════════════════════════════════════════════════════════
def _run_sim_thread_n_player(sim_id: str, decks_data: list[list[dict]],
deck_names: list[str], num_games: int,
record_logs: bool, engine_type: str):
"""Background thread: N-player (2-4) batch simulation."""
try:
_ensure_src_path()
from commander_ai_lab.sim.engine import GameEngine
from commander_ai_lab.sim.deepseek_engine import DeepSeekGameEngine
with _sim_lock:
_sim_runs[sim_id]['status'] = 'running'
n_players = len(decks_data)
built_decks = [_build_deck_from_card_data(cd) for cd in decks_data]
if engine_type == 'deepseek':
brain = _get_deepseek_brain()
if brain and not brain._connected:
brain.check_connection()
engine = DeepSeekGameEngine(
brain=brain, ai_player_index=1,
max_turns=25, record_log=record_logs)
else:
engine = GameEngine(max_turns=25, record_log=record_logs)
# Per-seat accumulators
seat_wins = [0] * n_players
seat_turns = [0] * n_players
seat_damage_dealt = [0] * n_players
seat_spells_cast = [0] * n_players
seat_creatures_played = [0] * n_players
seat_removal_used = [0] * n_players
seat_ramp_played = [0] * n_players
seat_cards_drawn = [0] * n_players
seat_max_board = [0] * n_players
game_results = []
start = time.time()
for i in range(num_games):
result = engine.run_n(decks=built_decks, names=deck_names)
game_data = result.to_dict()
game_data['gameNumber'] = i + 1
game_results.append(game_data)
if 0 <= result.winner_seat < n_players:
seat_wins[result.winner_seat] += 1
for pr in result.players:
si = pr.seat_index
seat_turns[si] += result.turns
if pr.stats:
seat_damage_dealt[si] += pr.stats.damage_dealt
seat_spells_cast[si] += pr.stats.spells_cast
seat_creatures_played[si] += pr.stats.creatures_played
seat_removal_used[si] += pr.stats.removal_used
seat_ramp_played[si] += pr.stats.ramp_played
seat_cards_drawn[si] += pr.stats.cards_drawn
seat_max_board[si] += pr.stats.max_board_size
with _sim_lock:
_sim_runs[sim_id]['completed'] = i + 1
elapsed = time.time() - start
n = num_games if num_games > 0 else 1
players_summary = []
for si in range(n_players):
players_summary.append({
'seat': si,
'deckName': deck_names[si],
'wins': seat_wins[si],
'winRate': round(seat_wins[si] / n * 100, 1),
'avgTurns': round(seat_turns[si] / n, 1),
'avgDamageDealt': round(seat_damage_dealt[si] / n, 1),
'avgSpellsCast': round(seat_spells_cast[si] / n, 1),
'avgCreaturesPlayed': round(seat_creatures_played[si] / n, 1),
'avgRemovalUsed': round(seat_removal_used[si] / n, 1),
'avgRampPlayed': round(seat_ramp_played[si] / n, 1),
'avgCardsDrawn': round(seat_cards_drawn[si] / n, 1),
'avgMaxBoardSize': round(seat_max_board[si] / n, 1),
})
summary = {
'playerCount': n_players,
'totalGames': num_games,
'players': players_summary,
'elapsedSeconds': round(elapsed, 3),
}
_finish_sim(sim_id, summary, game_results)
except Exception as e:
_fail_sim(sim_id, e)
traceback.print_exc()
# ══════════════════════════════════════════════════════════════
# Simulation API Endpoints
# ══════════════════════════════════════════════════════════════
@@ -446,6 +546,43 @@ async def sim_run_deepseek(request: FastAPIRequest):
'total': num_games, 'deckName': deck_name,
'opponentType': 'deepseek'})
@router.post('/api/sim/run-n-player')
async def sim_run_n_player(request: FastAPIRequest):
"""Start an N-player (2-4) batch simulation."""
body = await request.json()
deck_ids = body.get('deckIds', [])
num_games = body.get('numGames', 10)
record_logs = body.get('recordLogs', True)
engine_type = body.get('engineType', 'heuristic')
if len(deck_ids) < 2 or len(deck_ids) > 4:
return JSONResponse({'error': '2-4 decks required'}, status_code=400)
if num_games < 1 or num_games > 1000:
return JSONResponse({'error': 'numGames must be 1-1000'}, status_code=400)
decks_data = []
deck_names = []
for did in deck_ids:
name, cards = _fetch_deck_card_data(did)
if name is None:
return JSONResponse({'error': f'Deck {did} not found'}, status_code=404)
if not cards:
return JSONResponse({'error': f'Deck {did} has no cards'}, status_code=400)
deck_names.append(name)
decks_data.append(cards)
sim_id = _create_sim_run(num_games, ' vs '.join(deck_names))
_threading.Thread(target=_run_sim_thread_n_player,
args=(sim_id, decks_data, deck_names, num_games,
record_logs, engine_type),
daemon=True).start()
return JSONResponse({
'simId': sim_id, 'status': 'queued', 'total': num_games,
'deckNames': deck_names, 'playerCount': len(deck_ids),
})
# ══════════════════════════════════════════════════════════════
# DeepSeek AI Brain Endpoints
# ══════════════════════════════════════════════════════════════