Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f66a8ed15 | ||
|
|
f4da7674b6 |
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 LobbyPage from './pages/LobbyPage';
|
||||||
import RoomPage from './pages/RoomPage';
|
import RoomPage from './pages/RoomPage';
|
||||||
import GamePage from './pages/GamePage';
|
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() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
|
<Nav />
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<LobbyPage />} />
|
<Route path="/" element={<LobbyPage />} />
|
||||||
<Route path="/room/:roomId" element={<RoomPage />} />
|
<Route path="/room/:roomId" element={<RoomPage />} />
|
||||||
<Route path="/game/:gameId" element={<GamePage />} />
|
<Route path="/game/:gameId" element={<GamePage />} />
|
||||||
|
<Route path="/simulator" element={<SimulatorPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</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)
|
_fail_sim(sim_id, e)
|
||||||
traceback.print_exc()
|
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
|
# Simulation API Endpoints
|
||||||
# ══════════════════════════════════════════════════════════════
|
# ══════════════════════════════════════════════════════════════
|
||||||
@@ -446,6 +546,43 @@ async def sim_run_deepseek(request: FastAPIRequest):
|
|||||||
'total': num_games, 'deckName': deck_name,
|
'total': num_games, 'deckName': deck_name,
|
||||||
'opponentType': 'deepseek'})
|
'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
|
# DeepSeek AI Brain Endpoints
|
||||||
# ══════════════════════════════════════════════════════════════
|
# ══════════════════════════════════════════════════════════════
|
||||||
|
|||||||
Reference in new issue
Block a user