feat: add 4-player batch simulator React page with N-player API endpoint
Adds SimulatorPage at /simulator with 4 deck slot selection, config panel, progress polling, per-seat win rate charts, and stats table. Backend: POST /api/sim/run-n-player endpoint using engine.run_n() for 2-4 player batch sims.
This commit is contained in:
4 files changed
+588
-1
No files matched your search
+32
-1
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
Reference in new issue
Block a user