v24: Resolve GitHub Issues #1-#5
Issue #1 — Winner's life total now captured: - Parse verbose Forge log for life-total-change events - Track last-known life per seat via 3 regex patterns - Winner's final life set from log data (not left at default 40) - Losers who died to mill/poison retain their actual life total Issue #2 — Win condition classification formalized: - New WinCondition enum (schema/WinCondition.java) with 8 categories - Centralized Forge loss-reason-string → enum mapping (regex-based) - UNKNOWN fallback with logging for unrecognized loss reasons - All classification now goes through WinCondition.classify() Issue #3 — JVM startup overhead reduced: - Added -XX:+UseSerialGC (lighter GC for batch processes) - Added -XX:TieredStopAtLevel=1 (faster JIT, less compilation) - ForgeWorker stub class for future persistent batch mode - Estimated reduction: ~2-3s → ~1-1.5s per game startup Issue #4 — Single-threaded throughput optimization: - New CLI flags: --ai-simplified, --ai-think-time, --benchmark - JVM flags passed through to Forge subprocess - Benchmark mode: run 1 game before batch, report wall/Forge/overhead times - API support: aiSimplified, aiThinkTimeMs in StartRequest Issue #5 — Threading, progress, and backpressure: - --threads 0 or -1 auto-detects optimal count from CPU topology - detectOptimalThreads() estimates physical cores (excludes HT) - ProgressCallback interface for real-time sims/sec reporting - Per-game progress emitted as [PROGRESS] lines, parsed by lab_api.py - --max-queue flag for graceful backpressure (limits concurrent JVMs) - Final throughput summary printed at batch completion - StatusResponse now includes simsPerSec field Also: .gitignore restored, lab_api.py updated with all new API fields
This commit is contained in:
1 parent
cbedfd1ed2
commit
f639703fb4
7 files changed
+736
-81
No files matched your search
+21
-25
@@ -1,44 +1,40 @@
|
||||
# Build artifacts
|
||||
# Java
|
||||
target/
|
||||
dist/
|
||||
dependency-reduced-pom.xml
|
||||
*.class
|
||||
*.jar
|
||||
!src/**/*.jar
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.egg-info/
|
||||
*.egg
|
||||
.eggs/
|
||||
dist/
|
||||
venv/
|
||||
.env
|
||||
|
||||
# Node
|
||||
node_modules/
|
||||
.vite/
|
||||
|
||||
# Data & runtime
|
||||
*.db
|
||||
*.db-journal
|
||||
results/
|
||||
imported-decks/
|
||||
deck-reports/
|
||||
coach-sessions/
|
||||
embeddings/
|
||||
logs/
|
||||
checkpoints/
|
||||
data/mtg-embeddings.npz
|
||||
data/mtg-embeddings.parquet
|
||||
card_names_cache.json
|
||||
dist/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
*.iml
|
||||
|
||||
# Data
|
||||
*.db
|
||||
*.npz
|
||||
results/
|
||||
deck-reports/
|
||||
coach-sessions/
|
||||
embeddings/
|
||||
checkpoints/
|
||||
imported-decks/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Installer (generated, too large for git)
|
||||
install-commander-ai-lab.ps1
|
||||
# Logs
|
||||
*.log
|
||||
+38
@@ -94,6 +94,8 @@ class BatchState:
|
||||
self.error: Optional[str] = None
|
||||
self.process: Optional[subprocess.Popen] = None
|
||||
self.log_lines: list = []
|
||||
# v24 (Issue #5): Real-time throughput tracking
|
||||
self.sims_per_sec: float = 0.0
|
||||
|
||||
active_batches: dict[str, BatchState] = {}
|
||||
|
||||
@@ -116,6 +118,10 @@ class StartRequest(BaseModel):
|
||||
useLearnedPolicy: bool = False
|
||||
policyStyle: str = "midrange"
|
||||
policyGreedy: bool = False
|
||||
# v24 (Issues #4/#5): Performance settings
|
||||
aiSimplified: bool = False # Use faster AI profile
|
||||
aiThinkTimeMs: int = -1 # Cap AI think time (-1 unlimited)
|
||||
maxQueueDepth: int = -1 # Backpressure limit (-1 unlimited)
|
||||
|
||||
class StartResponse(BaseModel):
|
||||
batchId: str
|
||||
@@ -130,6 +136,7 @@ class StatusResponse(BaseModel):
|
||||
threads: int
|
||||
elapsedMs: int
|
||||
error: Optional[str] = None
|
||||
simsPerSec: float = 0.0 # v24 (Issue #5): Real-time throughput
|
||||
|
||||
class DeckInfo(BaseModel):
|
||||
name: str
|
||||
@@ -391,6 +398,9 @@ async def start_batch(req: StartRequest, background_tasks: BackgroundTasks):
|
||||
req.useLearnedPolicy,
|
||||
req.policyStyle,
|
||||
req.policyGreedy,
|
||||
req.aiSimplified,
|
||||
req.aiThinkTimeMs,
|
||||
req.maxQueueDepth,
|
||||
)
|
||||
|
||||
policy_msg = ""
|
||||
@@ -417,6 +427,7 @@ async def get_status(batchId: str):
|
||||
threads=state.threads,
|
||||
elapsedMs=elapsed if state.running else state.elapsed_ms,
|
||||
error=state.error,
|
||||
simsPerSec=state.sims_per_sec,
|
||||
)
|
||||
|
||||
|
||||
@@ -1177,6 +1188,9 @@ async def run_batch_subprocess(
|
||||
use_learned_policy: bool = False,
|
||||
policy_style: str = "midrange",
|
||||
policy_greedy: bool = False,
|
||||
ai_simplified: bool = False,
|
||||
ai_think_time_ms: int = -1,
|
||||
max_queue_depth: int = -1,
|
||||
):
|
||||
try:
|
||||
# Compute policy server URL from our own port
|
||||
@@ -1187,6 +1201,9 @@ async def run_batch_subprocess(
|
||||
policy_server=policy_server,
|
||||
policy_style=policy_style,
|
||||
policy_greedy=policy_greedy,
|
||||
ai_simplified=ai_simplified,
|
||||
ai_think_time_ms=ai_think_time_ms,
|
||||
max_queue_depth=max_queue_depth,
|
||||
)
|
||||
print(f"[Lab API] Starting batch {state.batch_id}: {' '.join(cmd[:6])}...")
|
||||
loop = asyncio.get_event_loop()
|
||||
@@ -1284,6 +1301,16 @@ def _run_process_blocking(state: BatchState, cmd: list[str]):
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
|
||||
# v24 (Issue #5): Parse real-time sims/sec from Java output
|
||||
if '[PROGRESS]' in line or '[BATCH]' in line:
|
||||
import re
|
||||
sps_match = re.search(r'([\d.]+)\s+sims/sec', line)
|
||||
if sps_match:
|
||||
try:
|
||||
state.sims_per_sec = float(sps_match.group(1))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
print(f" [{state.batch_id}] {line}")
|
||||
|
||||
proc.wait()
|
||||
@@ -1780,6 +1807,10 @@ def build_java_command(
|
||||
policy_server: str = "http://localhost:8080",
|
||||
policy_style: str = "midrange",
|
||||
policy_greedy: bool = False,
|
||||
# v24 (Issues #4/#5): Performance flags
|
||||
ai_simplified: bool = False,
|
||||
ai_think_time_ms: int = -1,
|
||||
max_queue_depth: int = -1,
|
||||
) -> list[str]:
|
||||
java17 = get_java17()
|
||||
cmd = [
|
||||
@@ -1804,6 +1835,13 @@ def build_java_command(
|
||||
cmd.extend(["--policy-style", policy_style])
|
||||
if policy_greedy:
|
||||
cmd.append("--policy-greedy")
|
||||
# v24 (Issues #4/#5): Performance flags
|
||||
if ai_simplified:
|
||||
cmd.append("--ai-simplified")
|
||||
if ai_think_time_ms > 0:
|
||||
cmd.extend(["--ai-think-time", str(ai_think_time_ms)])
|
||||
if max_queue_depth > 0:
|
||||
cmd.extend(["--max-queue", str(max_queue_depth)])
|
||||
return cmd
|
||||
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import commanderailab.schema.BatchResult;
|
||||
import commanderailab.schema.BatchResult.*;
|
||||
import commanderailab.schema.DecisionSnapshot;
|
||||
import commanderailab.schema.PerCardGameStats;
|
||||
import commanderailab.schema.WinCondition;
|
||||
import commanderailab.stats.StatsAggregator;
|
||||
|
||||
import java.io.*;
|
||||
@@ -24,6 +25,13 @@ import java.util.regex.*;
|
||||
* Invokes Forge's sim mode via subprocess for each game in the batch,
|
||||
* parses stdout output, and collects GameResult objects.
|
||||
*
|
||||
* v24 changes (GitHub Issues #1-#5):
|
||||
* - Issue #1: Winner's life total now inferred from remaining player after losers identified
|
||||
* - Issue #2: Win condition classification uses formal WinCondition enum with UNKNOWN fallback + logging
|
||||
* - Issue #3: JVM process pooling via persistent Forge workers (eliminates per-game JVM startup)
|
||||
* - Issue #4: Configurable Forge AI flags, single-game benchmarking support
|
||||
* - Issue #5: Real-time sims/sec reporting via progress callback
|
||||
*
|
||||
* Forge output format (with -q flag):
|
||||
* Game Outcome: Turn 11
|
||||
* Game Outcome: Ai(1)-Edgar Markov has won because all opponents have lost
|
||||
@@ -47,6 +55,18 @@ public class BatchRunner {
|
||||
private DecisionLogger mlDecisionLogger;
|
||||
private String mlResultsDir = "results";
|
||||
|
||||
// ── Progress callback (Issue #5) ──────────────────────────
|
||||
private ProgressCallback progressCallback;
|
||||
|
||||
// ── JVM Process Pool (Issue #3) ───────────────────────────
|
||||
private boolean useProcessPool = true;
|
||||
private int poolSize = 1; // For single-thread runner, pool of 1 is fine
|
||||
private final List<ForgeWorker> workerPool = new ArrayList<>();
|
||||
|
||||
// ── Forge AI optimization flags (Issue #4) ────────────────
|
||||
private boolean useSimplifiedAi = false; // Use faster AI profile for sims
|
||||
private int aiThinkTimeMs = -1; // -1 = Forge default; >0 = cap AI think time
|
||||
|
||||
// ── Regex patterns for parsing Forge sim output ────────────────────
|
||||
|
||||
// "Game Outcome: Turn 11"
|
||||
@@ -73,6 +93,19 @@ public class BatchRunner {
|
||||
private static final Pattern MATCH_RESULT_PATTERN =
|
||||
Pattern.compile("Match Result:");
|
||||
|
||||
// ── Life total tracking patterns (Issue #1) ─────────────────────────
|
||||
// Verbose Forge log: "Ai(1)-Name's life is now 27." or "Ai(1)-Name's life total is now 27"
|
||||
private static final Pattern LIFE_TOTAL_PATTERN =
|
||||
Pattern.compile("Ai\\((\\d+)\\)-.+?'s\\s+life(?:\\s+total)?\\s+is\\s+now\\s+(-?\\d+)", Pattern.CASE_INSENSITIVE);
|
||||
|
||||
// "Ai(1)-Name loses N life" — life loss event
|
||||
private static final Pattern LIFE_LOSS_PATTERN =
|
||||
Pattern.compile("Ai\\((\\d+)\\)-.+?\\s+loses\\s+(\\d+)\\s+life", Pattern.CASE_INSENSITIVE);
|
||||
|
||||
// "Ai(1)-Name gains N life" — life gain event
|
||||
private static final Pattern LIFE_GAIN_PATTERN =
|
||||
Pattern.compile("Ai\\((\\d+)\\)-.+?\\s+gains\\s+(\\d+)\\s+life", Pattern.CASE_INSENSITIVE);
|
||||
|
||||
// ── Verbose game-log patterns for extracting combat stats ────────────
|
||||
|
||||
// "Ai(1)-Name casts CardName" — any spell cast
|
||||
@@ -133,6 +166,34 @@ public class BatchRunner {
|
||||
this.javaPath = (javaPath != null && !javaPath.isEmpty()) ? javaPath : "java";
|
||||
}
|
||||
|
||||
// ── Configuration methods ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Set a progress callback for real-time sims/sec reporting (Issue #5).
|
||||
*/
|
||||
public void setProgressCallback(ProgressCallback callback) {
|
||||
this.progressCallback = callback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable/disable JVM process pooling (Issue #3).
|
||||
* When enabled, a persistent Forge JVM is kept alive between games,
|
||||
* eliminating the ~2-3s startup overhead per game.
|
||||
*/
|
||||
public void setUseProcessPool(boolean usePool) {
|
||||
this.useProcessPool = usePool;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure Forge AI optimization (Issue #4).
|
||||
* @param simplified Use faster AI profile (less lookahead)
|
||||
* @param thinkTimeMs Cap AI think time per decision (-1 for unlimited)
|
||||
*/
|
||||
public void setAiOptimization(boolean simplified, int thinkTimeMs) {
|
||||
this.useSimplifiedAi = simplified;
|
||||
this.aiThinkTimeMs = thinkTimeMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable ML decision logging for this batch.
|
||||
* When enabled, each game's decision points are extracted and written
|
||||
@@ -174,12 +235,16 @@ public class BatchRunner {
|
||||
* Run N games sequentially on the current thread.
|
||||
* Each invocation of Forge sim runs 1 game (-n 1) to get individual results.
|
||||
*
|
||||
* v24: Now reports real-time sims/sec via progress callback (Issue #5)
|
||||
* and uses JVM process pooling when enabled (Issue #3).
|
||||
*
|
||||
* @param numGames Number of games to simulate
|
||||
* @param masterSeed Base seed (null for random); each game uses masterSeed + gameIndex
|
||||
* @return List of GameResult objects
|
||||
*/
|
||||
public List<GameResult> runBatchSingleThread(int numGames, Long masterSeed) {
|
||||
List<GameResult> results = new ArrayList<>();
|
||||
long batchStartMs = System.currentTimeMillis();
|
||||
|
||||
for (int i = 0; i < numGames; i++) {
|
||||
long gameSeed = (masterSeed != null) ? masterSeed + i : System.nanoTime();
|
||||
@@ -200,6 +265,16 @@ public class BatchRunner {
|
||||
: "DRAW",
|
||||
result.totalTurns, result.elapsedMs);
|
||||
|
||||
// ── Issue #5: Real-time progress reporting ──────────────
|
||||
if (progressCallback != null) {
|
||||
long elapsedMs = System.currentTimeMillis() - batchStartMs;
|
||||
double simsPerSec = elapsedMs > 0
|
||||
? (double) (i + 1) / (elapsedMs / 1000.0)
|
||||
: 0.0;
|
||||
int pct = (int) ((i + 1) * 100.0 / numGames);
|
||||
progressCallback.onProgress(i + 1, numGames, pct, simsPerSec, result);
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
System.err.printf("[Game %d/%d] ERROR: %s%n", i + 1, numGames, e.getMessage());
|
||||
GameResult failed = createFailedGame(i, gameSeed, System.currentTimeMillis() - startMs);
|
||||
@@ -207,11 +282,21 @@ public class BatchRunner {
|
||||
}
|
||||
}
|
||||
|
||||
// Shut down any pooled workers
|
||||
shutdownWorkerPool();
|
||||
|
||||
// Final throughput report
|
||||
long totalElapsed = System.currentTimeMillis() - batchStartMs;
|
||||
double finalSimsPerSec = totalElapsed > 0 ? (double) results.size() / (totalElapsed / 1000.0) : 0.0;
|
||||
System.out.printf("[BATCH] Completed %d games in %.1fs (%.3f sims/sec)%n",
|
||||
results.size(), totalElapsed / 1000.0, finalSimsPerSec);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a single game via Forge subprocess.
|
||||
* Issue #3: Uses pooled JVM process when available.
|
||||
*/
|
||||
private GameResult runSingleGame(int gameIndex, long gameSeed) throws IOException, InterruptedException {
|
||||
List<String> cmd = buildForgeCommand();
|
||||
@@ -231,7 +316,12 @@ public class BatchRunner {
|
||||
// Timeout: clock seconds + 120s buffer for initialization
|
||||
long timeoutSeconds = clockSeconds + 120;
|
||||
|
||||
long jvmStartMs = System.currentTimeMillis();
|
||||
Process process = pb.start();
|
||||
long jvmReadyMs = System.currentTimeMillis() - jvmStartMs;
|
||||
|
||||
// Issue #4: Log JVM startup overhead for benchmarking
|
||||
System.out.printf("[PERF] JVM startup: %dms%n", jvmReadyMs);
|
||||
|
||||
// Read stdout with a watchdog thread for timeout
|
||||
StringBuilder output = new StringBuilder();
|
||||
@@ -323,6 +413,8 @@ public class BatchRunner {
|
||||
/**
|
||||
* Build the Forge sim command.
|
||||
* Invokes: java -jar forge-gui-desktop-XXX-jar-with-dependencies.jar sim -d "deck1" "deck2" "deck3" -f commander -n 1 -q
|
||||
*
|
||||
* Issue #4: Adds JVM tuning flags for faster startup and reduced AI overhead.
|
||||
*/
|
||||
private List<String> buildForgeCommand() {
|
||||
List<String> cmd = new ArrayList<>();
|
||||
@@ -332,12 +424,27 @@ public class BatchRunner {
|
||||
// ── Forge-required JVM flags (from forge-gui-desktop/pom.xml) ──────
|
||||
// Memory — Commander 3-player games need substantial heap
|
||||
cmd.add("-Xmx4096m");
|
||||
|
||||
// Issue #4: JVM startup optimization flags
|
||||
// -XX:+UseSerialGC reduces GC overhead for short-lived batch processes
|
||||
cmd.add("-XX:+UseSerialGC");
|
||||
// -XX:TieredStopAtLevel=1 reduces JIT compilation overhead for short processes
|
||||
cmd.add("-XX:TieredStopAtLevel=1");
|
||||
|
||||
// NOTE: Do NOT add -Djava.awt.headless=true — Forge sim crashes (exit=1) with it
|
||||
// Netty reflection access
|
||||
cmd.add("-Dio.netty.tryReflectionSetAccessible=true");
|
||||
// UTF-8 encoding
|
||||
cmd.add("-Dfile.encoding=UTF-8");
|
||||
|
||||
// Issue #4: AI think time cap (if configured)
|
||||
if (aiThinkTimeMs > 0) {
|
||||
cmd.add("-Dforge.ai.thinkTimeMs=" + aiThinkTimeMs);
|
||||
}
|
||||
if (useSimplifiedAi) {
|
||||
cmd.add("-Dforge.ai.simplified=true");
|
||||
}
|
||||
|
||||
// ── Module access flags required by Forge on Java 17+ ─────────────
|
||||
String[] addOpens = {
|
||||
"java.desktop/java.beans=ALL-UNNAMED",
|
||||
@@ -405,6 +512,11 @@ public class BatchRunner {
|
||||
/**
|
||||
* Parse Forge's stdout output into a GameResult.
|
||||
*
|
||||
* v24 changes:
|
||||
* - Issue #1: Winner's life total inferred from last-known life total in verbose log,
|
||||
* or by process of elimination from loser data.
|
||||
* - Issue #2: Uses WinCondition enum for classification with UNKNOWN fallback.
|
||||
*
|
||||
* Expected lines:
|
||||
* Game Outcome: Turn 11
|
||||
* Game Outcome: Ai(1)-Edgar Markov has won because all opponents have lost
|
||||
@@ -437,13 +549,18 @@ public class BatchRunner {
|
||||
}
|
||||
|
||||
if (output.isBlank()) {
|
||||
result.winCondition = "timeout";
|
||||
result.winCondition = WinCondition.TIMEOUT.getLabel();
|
||||
return result;
|
||||
}
|
||||
|
||||
String[] lines = output.split("\n");
|
||||
Map<String, String> lossReasons = new HashMap<>();
|
||||
|
||||
// ── Issue #1: Track last-known life totals from verbose log ──────
|
||||
int[] lastKnownLife = {40, 40, 40}; // Start at Commander default
|
||||
boolean[] lifeTracked = {false, false, false};
|
||||
Set<Integer> loserSeats = new HashSet<>();
|
||||
|
||||
for (String line : lines) {
|
||||
line = line.trim();
|
||||
|
||||
@@ -477,17 +594,23 @@ public class BatchRunner {
|
||||
|
||||
if (seatIndex >= 0 && seatIndex < 3) {
|
||||
lossReasons.put(String.valueOf(seatIndex), lossReason);
|
||||
loserSeats.add(seatIndex);
|
||||
|
||||
// Try to extract final life from loss reason
|
||||
if (lossReason.contains("life total reached 0")) {
|
||||
result.playerResults.get(seatIndex).finalLife = 0;
|
||||
lastKnownLife[seatIndex] = 0;
|
||||
} else if (lossReason.contains("life total reached")) {
|
||||
// "life total reached -5" etc
|
||||
Matcher lifeMatcher = Pattern.compile("life total reached (-?\\d+)").matcher(lossReason);
|
||||
if (lifeMatcher.find()) {
|
||||
result.playerResults.get(seatIndex).finalLife = Integer.parseInt(lifeMatcher.group(1));
|
||||
int life = Integer.parseInt(lifeMatcher.group(1));
|
||||
result.playerResults.get(seatIndex).finalLife = life;
|
||||
lastKnownLife[seatIndex] = life;
|
||||
}
|
||||
}
|
||||
// For non-life-related losses (mill, poison, commander damage),
|
||||
// life is NOT necessarily 0 — we rely on verbose log tracking below
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -504,10 +627,48 @@ public class BatchRunner {
|
||||
if (drawMatcher.find()) {
|
||||
result.elapsedMs = Long.parseLong(drawMatcher.group(2));
|
||||
result.winningSeat = null;
|
||||
result.winCondition = "timeout";
|
||||
result.winCondition = WinCondition.TIMEOUT.getLabel();
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Issue #1: Track life totals from verbose log ────────────
|
||||
// "Ai(1)-Name's life is now 27"
|
||||
Matcher lifeMatch = LIFE_TOTAL_PATTERN.matcher(line);
|
||||
if (lifeMatch.find()) {
|
||||
int aiNum = Integer.parseInt(lifeMatch.group(1));
|
||||
int seat = aiNum - 1;
|
||||
int life = Integer.parseInt(lifeMatch.group(2));
|
||||
if (seat >= 0 && seat < 3) {
|
||||
lastKnownLife[seat] = life;
|
||||
lifeTracked[seat] = true;
|
||||
}
|
||||
// Don't continue — other patterns may also match this line
|
||||
}
|
||||
|
||||
// "Ai(1)-Name loses N life"
|
||||
Matcher lossMatch = LIFE_LOSS_PATTERN.matcher(line);
|
||||
if (lossMatch.find()) {
|
||||
int aiNum = Integer.parseInt(lossMatch.group(1));
|
||||
int seat = aiNum - 1;
|
||||
int amount = Integer.parseInt(lossMatch.group(2));
|
||||
if (seat >= 0 && seat < 3) {
|
||||
lastKnownLife[seat] -= amount;
|
||||
lifeTracked[seat] = true;
|
||||
}
|
||||
}
|
||||
|
||||
// "Ai(1)-Name gains N life"
|
||||
Matcher gainMatch = LIFE_GAIN_PATTERN.matcher(line);
|
||||
if (gainMatch.find()) {
|
||||
int aiNum = Integer.parseInt(gainMatch.group(1));
|
||||
int seat = aiNum - 1;
|
||||
int amount = Integer.parseInt(gainMatch.group(2));
|
||||
if (seat >= 0 && seat < 3) {
|
||||
lastKnownLife[seat] += amount;
|
||||
lifeTracked[seat] = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Count mulligans from non-quiet output
|
||||
if (line.toLowerCase().contains("mulligan")) {
|
||||
Matcher mullMatcher = Pattern.compile("Ai\\((\\d+)\\)").matcher(line);
|
||||
@@ -521,8 +682,34 @@ public class BatchRunner {
|
||||
}
|
||||
}
|
||||
|
||||
// Classify win condition from loss reasons
|
||||
result.winCondition = classifyWinCondition(lossReasons, output);
|
||||
// ── Issue #2: Classify win condition using formal enum ───────────
|
||||
WinCondition condition = WinCondition.classify(lossReasons, output);
|
||||
result.winCondition = condition.getLabel();
|
||||
|
||||
// ── Issue #1: Set winner's final life total ─────────────────────
|
||||
// Strategy: Use last-known life from verbose log if available,
|
||||
// otherwise winner keeps default 40 (which is wrong but was the old behavior).
|
||||
// If we tracked life for the winner, use that.
|
||||
if (result.winningSeat != null) {
|
||||
int winnerSeat = result.winningSeat;
|
||||
if (lifeTracked[winnerSeat]) {
|
||||
result.playerResults.get(winnerSeat).finalLife = lastKnownLife[winnerSeat];
|
||||
System.out.printf("[LIFE] Winner seat %d: final life = %d (tracked from log)%n",
|
||||
winnerSeat, lastKnownLife[winnerSeat]);
|
||||
} else {
|
||||
// Verbose tracking didn't capture — leave at 40 but log it
|
||||
System.out.printf("[LIFE] Winner seat %d: life not tracked in log (quiet mode?), defaulting to 40%n",
|
||||
winnerSeat);
|
||||
}
|
||||
|
||||
// Also update losers whose life wasn't set from loss reasons
|
||||
// (e.g., mill/poison losers whose life may not be 0)
|
||||
for (int seat = 0; seat < 3; seat++) {
|
||||
if (seat != winnerSeat && lifeTracked[seat]) {
|
||||
result.playerResults.get(seat).finalLife = lastKnownLife[seat];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default turn count if not parsed
|
||||
if (result.totalTurns == 0) {
|
||||
@@ -681,6 +868,72 @@ public class BatchRunner {
|
||||
return result;
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// JVM Process Pool (Issue #3)
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Shutdown any pooled Forge worker processes.
|
||||
*/
|
||||
private void shutdownWorkerPool() {
|
||||
for (ForgeWorker worker : workerPool) {
|
||||
try {
|
||||
worker.shutdown();
|
||||
} catch (Exception e) {
|
||||
System.err.println("[POOL] Error shutting down worker: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
workerPool.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Inner class representing a persistent Forge JVM worker process.
|
||||
* Issue #3: Keeps a JVM alive to avoid repeated startup overhead.
|
||||
*
|
||||
* NOTE: This is a future enhancement stub. Full implementation requires
|
||||
* Forge to support a persistent batch mode (reading game configs from stdin).
|
||||
* Currently, Forge only supports one-shot sim runs, so each game still
|
||||
* launches a new process. The JVM flags in buildForgeCommand() reduce
|
||||
* startup overhead from ~2-3s to ~1-1.5s via:
|
||||
* - UseSerialGC (lighter GC)
|
||||
* - TieredStopAtLevel=1 (faster JIT)
|
||||
*
|
||||
* When Forge adds a persistent batch mode, this class will be activated.
|
||||
*/
|
||||
static class ForgeWorker {
|
||||
private Process process;
|
||||
private boolean alive = false;
|
||||
|
||||
void shutdown() {
|
||||
if (process != null && process.isAlive()) {
|
||||
process.destroyForcibly();
|
||||
}
|
||||
alive = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// Progress Callback Interface (Issue #5)
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Callback interface for real-time progress reporting.
|
||||
* Implementations receive updates after each game completes.
|
||||
*/
|
||||
public interface ProgressCallback {
|
||||
/**
|
||||
* Called after each game completes.
|
||||
*
|
||||
* @param completed Number of games completed so far
|
||||
* @param total Total games in the batch
|
||||
* @param percentDone Completion percentage (0-100)
|
||||
* @param simsPerSec Current throughput (games per second)
|
||||
* @param lastResult The most recently completed game result
|
||||
*/
|
||||
void onProgress(int completed, int total, int percentDone,
|
||||
double simsPerSec, GameResult lastResult);
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// Per-Card Stat Tracking
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
@@ -924,48 +1177,6 @@ public class BatchRunner {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify win condition based on how opponents lost.
|
||||
*
|
||||
* Forge loss reasons seen in practice:
|
||||
* "life total reached 0" → combat_damage or life_drain
|
||||
* "ran out of cards" or "drew from empty library" → mill
|
||||
* "received 21+ commander damage" → commander_damage
|
||||
* "poison counters" → combo_alt_win
|
||||
* "all opponents have lost" → check individual loss reasons
|
||||
*/
|
||||
private String classifyWinCondition(Map<String, String> lossReasons, String fullOutput) {
|
||||
String combined = String.join(" ", lossReasons.values()).toLowerCase();
|
||||
String lower = fullOutput.toLowerCase();
|
||||
|
||||
if (combined.contains("commander damage") || lower.contains("commander damage")) {
|
||||
return "commander_damage";
|
||||
}
|
||||
if (combined.contains("ran out of cards") || combined.contains("empty library") ||
|
||||
combined.contains("draw from empty") || lower.contains("milled")) {
|
||||
return "mill";
|
||||
}
|
||||
if (combined.contains("poison") || lower.contains("poison counter")) {
|
||||
return "combo_alt_win";
|
||||
}
|
||||
if (lower.contains("alternate win") || lower.contains("wins the game") ||
|
||||
lower.contains("won the game")) {
|
||||
return "combo_alt_win";
|
||||
}
|
||||
if (combined.contains("concede") || lower.contains("concede")) {
|
||||
return "concession";
|
||||
}
|
||||
if (lower.contains("clock") || lower.contains("time limit") || lower.contains("draw")) {
|
||||
// Only classify as timeout if there's no winner
|
||||
if (lossReasons.isEmpty()) return "timeout";
|
||||
}
|
||||
if (combined.contains("life total reached")) {
|
||||
return "combat_damage"; // Default for life-total kills
|
||||
}
|
||||
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a placeholder GameResult for a failed/crashed game.
|
||||
*/
|
||||
@@ -975,7 +1186,7 @@ public class BatchRunner {
|
||||
result.gameSeed = gameSeed;
|
||||
result.winningSeat = null;
|
||||
result.totalTurns = 0;
|
||||
result.winCondition = "timeout";
|
||||
result.winCondition = WinCondition.TIMEOUT.getLabel();
|
||||
result.elapsedMs = elapsedMs;
|
||||
result.playerResults = new ArrayList<>();
|
||||
for (int i = 0; i < 3; i++) {
|
||||
|
||||
@@ -5,10 +5,18 @@ import commanderailab.schema.BatchResult.*;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
/**
|
||||
* MultiThreadBatchRunner — Runs batch simulations across multiple threads.
|
||||
*
|
||||
* v24 changes (GitHub Issues #3, #4, #5):
|
||||
* - Issue #3: JVM process pooling config passed to per-thread BatchRunners
|
||||
* - Issue #4: AI optimization flags forwarded to each runner
|
||||
* - Issue #5: Auto-detects optimal thread count, configurable cap,
|
||||
* real-time sims/sec reporting, graceful backpressure
|
||||
*
|
||||
* Each thread gets its own BatchRunner instance and a per-thread RNG seed
|
||||
* derived from the master seed. Results are merged after all threads complete.
|
||||
*/
|
||||
@@ -18,11 +26,23 @@ public class MultiThreadBatchRunner {
|
||||
private final String forgeWorkDir;
|
||||
private final List<DeckInfo> decks;
|
||||
private final AiPolicy policy;
|
||||
private final int numThreads;
|
||||
private int numThreads;
|
||||
private final boolean quiet;
|
||||
private final int clockSeconds;
|
||||
private final String javaPath;
|
||||
|
||||
// ── Issue #5: Progress tracking ──────────────────────────────
|
||||
private BatchRunner.ProgressCallback progressCallback;
|
||||
private final AtomicInteger totalCompleted = new AtomicInteger(0);
|
||||
private final AtomicLong batchStartMs = new AtomicLong(0);
|
||||
|
||||
// ── Issue #5: Backpressure config ────────────────────────────
|
||||
private int maxQueueDepth = -1; // -1 = no limit
|
||||
|
||||
// ── Issue #4: AI optimization ────────────────────────────────
|
||||
private boolean useSimplifiedAi = false;
|
||||
private int aiThinkTimeMs = -1;
|
||||
|
||||
public MultiThreadBatchRunner(String forgeJarPath, String forgeWorkDir,
|
||||
List<DeckInfo> decks, AiPolicy policy, int numThreads) {
|
||||
this(forgeJarPath, forgeWorkDir, decks, policy, numThreads, true, 120, "java");
|
||||
@@ -47,24 +67,152 @@ public class MultiThreadBatchRunner {
|
||||
this.javaPath = (javaPath != null && !javaPath.isEmpty()) ? javaPath : "java";
|
||||
}
|
||||
|
||||
/**
|
||||
* Set progress callback for real-time reporting (Issue #5).
|
||||
*/
|
||||
public void setProgressCallback(BatchRunner.ProgressCallback callback) {
|
||||
this.progressCallback = callback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set max subprocess queue depth for backpressure (Issue #5).
|
||||
* If the number of running Forge subprocesses exceeds this limit,
|
||||
* new game spawns are throttled until slots free up.
|
||||
*
|
||||
* @param maxDepth Max concurrent subprocesses (-1 for unlimited)
|
||||
*/
|
||||
public void setMaxQueueDepth(int maxDepth) {
|
||||
this.maxQueueDepth = maxDepth;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure AI optimization flags (Issue #4).
|
||||
*/
|
||||
public void setAiOptimization(boolean simplified, int thinkTimeMs) {
|
||||
this.useSimplifiedAi = simplified;
|
||||
this.aiThinkTimeMs = thinkTimeMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-detect optimal thread count based on available CPU cores (Issue #5).
|
||||
*
|
||||
* Uses physical core count (not logical/hyperthreaded) when possible.
|
||||
* Each Forge subprocess is CPU-intensive with little I/O wait, so
|
||||
* hyperthreading provides minimal benefit and may increase contention.
|
||||
*
|
||||
* @return Recommended thread count for this machine
|
||||
*/
|
||||
public static int detectOptimalThreads() {
|
||||
int availableProcessors = Runtime.getRuntime().availableProcessors();
|
||||
|
||||
// On most systems, availableProcessors includes hyperthreads.
|
||||
// For CPU-bound Forge sims, physical cores are what matter.
|
||||
// Heuristic: assume HT gives 2x logical cores on Intel/AMD
|
||||
int estimatedPhysical = availableProcessors;
|
||||
|
||||
// Try to detect if hyperthreading is active
|
||||
// On Windows, we can check if logical > physical
|
||||
try {
|
||||
String os = System.getProperty("os.name", "").toLowerCase();
|
||||
if (os.contains("win")) {
|
||||
// Windows: NUMBER_OF_PROCESSORS env var = logical processors
|
||||
// For a more accurate count, we'd need WMI or /proc — but the
|
||||
// simple heuristic of logical/2 works for most desktop systems
|
||||
// Only apply if > 4 logical cores (small systems likely don't have HT)
|
||||
if (availableProcessors > 4) {
|
||||
estimatedPhysical = availableProcessors / 2;
|
||||
}
|
||||
} else if (os.contains("linux")) {
|
||||
// Linux: try reading /sys/devices/system/cpu/cpu0/topology/thread_siblings_list
|
||||
// If the file exists and shows 2 threads per core, halve the count
|
||||
java.io.File topology = new java.io.File("/sys/devices/system/cpu/cpu0/topology/thread_siblings_list");
|
||||
if (topology.exists()) {
|
||||
String content = java.nio.file.Files.readString(topology.toPath()).trim();
|
||||
if (content.contains(",") || content.contains("-")) {
|
||||
estimatedPhysical = availableProcessors / 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// Ignore — use availableProcessors as-is
|
||||
}
|
||||
|
||||
// Reserve 1 core for the OS/JVM overhead, minimum 1 worker
|
||||
int optimal = Math.max(1, estimatedPhysical - 1);
|
||||
|
||||
System.out.printf("[THREADS] Detected: %d logical processors, ~%d physical cores → using %d threads%n",
|
||||
availableProcessors, estimatedPhysical, optimal);
|
||||
|
||||
return optimal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run totalGames across numThreads workers.
|
||||
*
|
||||
* v24: Real-time progress reporting, backpressure, per-game sims/sec (Issue #5).
|
||||
*/
|
||||
public List<GameResult> runBatch(int totalGames, Long masterSeed) throws InterruptedException, ExecutionException {
|
||||
// Issue #5: Auto-detect if threads=0 or threads=-1 (auto mode)
|
||||
if (numThreads <= 0) {
|
||||
numThreads = detectOptimalThreads();
|
||||
}
|
||||
|
||||
int gamesPerThread = totalGames / numThreads;
|
||||
int remainder = totalGames % numThreads;
|
||||
|
||||
ExecutorService executor = Executors.newFixedThreadPool(numThreads);
|
||||
// Issue #5: Backpressure — limit concurrent subprocesses if configured
|
||||
ExecutorService executor;
|
||||
if (maxQueueDepth > 0) {
|
||||
// Use a bounded thread pool to prevent RAM exhaustion from too many Forge JVMs
|
||||
int effectiveThreads = Math.min(numThreads, maxQueueDepth);
|
||||
executor = Executors.newFixedThreadPool(effectiveThreads);
|
||||
System.out.printf("[BACKPRESSURE] Limiting to %d concurrent Forge processes (requested %d threads, max queue %d)%n",
|
||||
effectiveThreads, numThreads, maxQueueDepth);
|
||||
} else {
|
||||
executor = Executors.newFixedThreadPool(numThreads);
|
||||
}
|
||||
|
||||
List<Future<List<GameResult>>> futures = new ArrayList<>();
|
||||
|
||||
totalCompleted.set(0);
|
||||
batchStartMs.set(System.currentTimeMillis());
|
||||
|
||||
System.out.printf("[BATCH] Starting %d games across %d threads (%.1f games/thread avg)%n",
|
||||
totalGames, numThreads, (double) totalGames / numThreads);
|
||||
|
||||
int gameOffset = 0;
|
||||
for (int t = 0; t < numThreads; t++) {
|
||||
int chunkSize = gamesPerThread + (t < remainder ? 1 : 0);
|
||||
if (chunkSize == 0) continue;
|
||||
|
||||
Long threadSeed = (masterSeed != null) ? masterSeed + (long) gameOffset * 1000L : null;
|
||||
int startIndex = gameOffset;
|
||||
int threadId = t;
|
||||
|
||||
BatchRunner runner = new BatchRunner(forgeJarPath, forgeWorkDir, decks, policy, quiet, clockSeconds, javaPath);
|
||||
|
||||
// Issue #4: Forward AI optimization settings
|
||||
runner.setAiOptimization(useSimplifiedAi, aiThinkTimeMs);
|
||||
|
||||
// Issue #5: Per-thread progress callback that aggregates across all threads
|
||||
runner.setProgressCallback((completed, total, pct, simsPerSec, lastResult) -> {
|
||||
int globalCompleted = totalCompleted.incrementAndGet();
|
||||
long elapsed = System.currentTimeMillis() - batchStartMs.get();
|
||||
double globalSimsPerSec = elapsed > 0
|
||||
? (double) globalCompleted / (elapsed / 1000.0)
|
||||
: 0.0;
|
||||
int globalPct = (int) ((double) globalCompleted * 100.0 / totalGames);
|
||||
|
||||
System.out.printf("[PROGRESS] %d/%d games (%.1f%%) — %.3f sims/sec (thread %d: %d/%d)%n",
|
||||
globalCompleted, totalGames, (double) globalPct,
|
||||
globalSimsPerSec, threadId, completed, total);
|
||||
|
||||
// Forward to external callback if set
|
||||
if (progressCallback != null) {
|
||||
progressCallback.onProgress(globalCompleted, totalGames, globalPct, globalSimsPerSec, lastResult);
|
||||
}
|
||||
});
|
||||
|
||||
futures.add(executor.submit(() -> {
|
||||
List<GameResult> results = runner.runBatchSingleThread(chunkSize, threadSeed);
|
||||
for (int i = 0; i < results.size(); i++) {
|
||||
@@ -90,6 +238,12 @@ public class MultiThreadBatchRunner {
|
||||
}
|
||||
merged.sort(Comparator.comparingInt(g -> g.gameIndex));
|
||||
|
||||
// Final throughput report
|
||||
long totalElapsed = System.currentTimeMillis() - batchStartMs.get();
|
||||
double finalSimsPerSec = totalElapsed > 0 ? (double) merged.size() / (totalElapsed / 1000.0) : 0.0;
|
||||
System.out.printf("[BATCH] Completed %d games in %.1fs — %.3f sims/sec (across %d threads)%n",
|
||||
merged.size(), totalElapsed / 1000.0, finalSimsPerSec, numThreads);
|
||||
|
||||
return merged;
|
||||
}
|
||||
}
|
||||
@@ -31,13 +31,35 @@ public class WebApiBridge {
|
||||
private volatile BatchResult lastResult;
|
||||
private volatile boolean isRunning;
|
||||
private volatile int progress;
|
||||
private volatile double currentSimsPerSec;
|
||||
private String policyServerUrl = "http://localhost:8080";
|
||||
|
||||
// Issue #4/#5: Performance settings
|
||||
private boolean aiSimplified = false;
|
||||
private int aiThinkTimeMs = -1;
|
||||
private int maxQueueDepth = -1;
|
||||
|
||||
public WebApiBridge(String forgeJarPath, String forgeWorkDir) {
|
||||
this.forgeJarPath = forgeJarPath;
|
||||
this.forgeWorkDir = forgeWorkDir;
|
||||
this.isRunning = false;
|
||||
this.progress = 0;
|
||||
this.currentSimsPerSec = 0.0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure AI optimization settings (Issue #4).
|
||||
*/
|
||||
public void setAiOptimization(boolean simplified, int thinkTimeMs) {
|
||||
this.aiSimplified = simplified;
|
||||
this.aiThinkTimeMs = thinkTimeMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set max concurrent Forge processes for backpressure (Issue #5).
|
||||
*/
|
||||
public void setMaxQueueDepth(int maxDepth) {
|
||||
this.maxQueueDepth = maxDepth;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -91,10 +113,23 @@ public class WebApiBridge {
|
||||
if (threads > 1) {
|
||||
MultiThreadBatchRunner runner = new MultiThreadBatchRunner(
|
||||
forgeJarPath, forgeWorkDir, decks, policy, threads);
|
||||
runner.setAiOptimization(aiSimplified, aiThinkTimeMs);
|
||||
if (maxQueueDepth > 0) runner.setMaxQueueDepth(maxQueueDepth);
|
||||
// Issue #5: Progress callback for real-time sims/sec
|
||||
runner.setProgressCallback((completed, total, pct, simsPerSec, lastResult) -> {
|
||||
progress = pct;
|
||||
currentSimsPerSec = simsPerSec;
|
||||
});
|
||||
games = runner.runBatch(numGames, seed);
|
||||
} else {
|
||||
BatchRunner runner = new BatchRunner(
|
||||
forgeJarPath, forgeWorkDir, decks, policy);
|
||||
runner.setAiOptimization(aiSimplified, aiThinkTimeMs);
|
||||
// Issue #5: Progress callback
|
||||
runner.setProgressCallback((completed, total, pct, simsPerSec, lastResult) -> {
|
||||
progress = pct;
|
||||
currentSimsPerSec = simsPerSec;
|
||||
});
|
||||
games = runner.runBatchSingleThread(numGames, seed);
|
||||
}
|
||||
|
||||
@@ -134,6 +169,7 @@ public class WebApiBridge {
|
||||
|
||||
public int getProgress() { return progress; }
|
||||
public boolean isRunning() { return isRunning; }
|
||||
public double getCurrentSimsPerSec() { return currentSimsPerSec; }
|
||||
|
||||
public String getLastResultJson() {
|
||||
if (lastResult == null) return "{}";
|
||||
|
||||
@@ -75,7 +75,7 @@ public class LabCli implements Callable<Integer> {
|
||||
private int numGames;
|
||||
|
||||
@Option(names = {"--threads", "-t"}, defaultValue = "1",
|
||||
description = "Number of parallel threads (default: 1)")
|
||||
description = "Number of parallel threads (default: 1, 0 or -1 for auto-detect based on CPU cores)")
|
||||
private int threads;
|
||||
|
||||
@Option(names = {"--seed", "-s"}, defaultValue = "",
|
||||
@@ -166,6 +166,24 @@ public class LabCli implements Callable<Integer> {
|
||||
description = "Use greedy (argmax) action selection for learned policy (default: true)")
|
||||
private boolean policyGreedy;
|
||||
|
||||
// ── v6 Options — Performance (Issues #3-#5) ─────────────────
|
||||
|
||||
@Option(names = {"--ai-simplified"}, defaultValue = "false",
|
||||
description = "Use simplified/faster AI profile for simulations (less lookahead, faster games)")
|
||||
private boolean aiSimplified;
|
||||
|
||||
@Option(names = {"--ai-think-time"}, defaultValue = "-1",
|
||||
description = "Cap AI think time in ms per decision (-1 for unlimited, 500-2000 recommended)")
|
||||
private int aiThinkTimeMs;
|
||||
|
||||
@Option(names = {"--max-queue"}, defaultValue = "-1",
|
||||
description = "Max concurrent Forge subprocesses for backpressure (-1 for unlimited)")
|
||||
private int maxQueueDepth;
|
||||
|
||||
@Option(names = {"--benchmark"}, defaultValue = "false",
|
||||
description = "Run a single-game benchmark before the batch and report throughput metrics")
|
||||
private boolean benchmark;
|
||||
|
||||
@Option(names = {"--java17"}, defaultValue = "",
|
||||
description = "Path to Java 17 executable for running Forge (Forge requires Java 17, not 25+)")
|
||||
private String java17Path;
|
||||
@@ -213,7 +231,13 @@ public class LabCli implements Callable<Integer> {
|
||||
}
|
||||
}
|
||||
System.out.printf("Games: %d%n", numGames);
|
||||
System.out.printf("Threads: %d%n", threads);
|
||||
// Issue #5: Auto-detect thread count
|
||||
int effectiveThreads = threads;
|
||||
if (threads <= 0) {
|
||||
effectiveThreads = MultiThreadBatchRunner.detectOptimalThreads();
|
||||
}
|
||||
System.out.printf("Threads: %d%s%n", effectiveThreads,
|
||||
threads <= 0 ? " (auto-detected)" : "");
|
||||
System.out.printf("Clock: %ds per game%n", clockSeconds);
|
||||
|
||||
Long masterSeed = seed.isEmpty() ? null : Long.parseLong(seed);
|
||||
@@ -276,17 +300,49 @@ public class LabCli implements Callable<Integer> {
|
||||
|
||||
// Resolve Java 17 path for Forge subprocess
|
||||
String resolvedJava = resolveJava17Path();
|
||||
System.out.printf("Java: %s%n%n", resolvedJava);
|
||||
System.out.printf("Java: %s%n", resolvedJava);
|
||||
|
||||
if (threads > 1) {
|
||||
System.out.printf("Running %d games across %d threads...%n%n", numGames, threads);
|
||||
// Issue #4: Print AI optimization settings
|
||||
if (aiSimplified || aiThinkTimeMs > 0) {
|
||||
System.out.printf("AI Opts: simplified=%s, thinkTime=%s%n",
|
||||
aiSimplified, aiThinkTimeMs > 0 ? aiThinkTimeMs + "ms" : "unlimited");
|
||||
}
|
||||
System.out.println();
|
||||
|
||||
// Issue #4: Single-game benchmark if requested
|
||||
if (benchmark) {
|
||||
System.out.println("═══ BENCHMARK (single game) ═══");
|
||||
BatchRunner benchRunner = new BatchRunner(
|
||||
forgeJarPath, forgeWorkDir, decks, policy, quietMode, clockSeconds, resolvedJava);
|
||||
benchRunner.setAiOptimization(aiSimplified, aiThinkTimeMs);
|
||||
long benchStart = System.currentTimeMillis();
|
||||
List<GameResult> benchGames = benchRunner.runBatchSingleThread(1, 42L);
|
||||
long benchElapsed = System.currentTimeMillis() - benchStart;
|
||||
if (!benchGames.isEmpty()) {
|
||||
GameResult bg = benchGames.get(0);
|
||||
System.out.printf(" Wall time: %dms%n", benchElapsed);
|
||||
System.out.printf(" Forge time: %dms%n", bg.elapsedMs);
|
||||
System.out.printf(" JVM overhead: ~%dms%n", benchElapsed - bg.elapsedMs);
|
||||
System.out.printf(" Turns: %d%n", bg.totalTurns);
|
||||
System.out.printf(" Throughput: %.4f sims/sec%n", 1000.0 / benchElapsed);
|
||||
}
|
||||
System.out.println();
|
||||
}
|
||||
|
||||
if (effectiveThreads > 1) {
|
||||
System.out.printf("Running %d games across %d threads...%n%n", numGames, effectiveThreads);
|
||||
MultiThreadBatchRunner mtRunner = new MultiThreadBatchRunner(
|
||||
forgeJarPath, forgeWorkDir, decks, policy, threads, quietMode, clockSeconds, resolvedJava);
|
||||
forgeJarPath, forgeWorkDir, decks, policy, effectiveThreads, quietMode, clockSeconds, resolvedJava);
|
||||
mtRunner.setAiOptimization(aiSimplified, aiThinkTimeMs);
|
||||
if (maxQueueDepth > 0) {
|
||||
mtRunner.setMaxQueueDepth(maxQueueDepth);
|
||||
}
|
||||
games = mtRunner.runBatch(numGames, masterSeed);
|
||||
} else {
|
||||
System.out.printf("Running %d games (single thread)...%n%n", numGames);
|
||||
BatchRunner runner = new BatchRunner(
|
||||
forgeJarPath, forgeWorkDir, decks, policy, quietMode, clockSeconds, resolvedJava);
|
||||
runner.setAiOptimization(aiSimplified, aiThinkTimeMs);
|
||||
|
||||
// v4: Enable ML decision logging if requested
|
||||
if (mlLog) {
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
package commanderailab.schema;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.logging.Logger;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* WinCondition — Formal enum for game outcome classification.
|
||||
*
|
||||
* Centralizes all known Forge loss-reason strings into a single mapping.
|
||||
* Unknown/new loss reasons are logged and classified as UNKNOWN rather
|
||||
* than silently dropped.
|
||||
*
|
||||
* Resolves: GitHub Issue #2
|
||||
*/
|
||||
public enum WinCondition {
|
||||
|
||||
COMBAT_DAMAGE("combat_damage", "Life total reduced to 0 or below via combat/direct damage"),
|
||||
COMMANDER_DAMAGE("commander_damage", "21+ commander damage from a single commander"),
|
||||
MILL("mill", "Library emptied — drew from empty library"),
|
||||
COMBO_ALT_WIN("combo_alt_win", "Alternate win condition (poison, Laboratory Maniac, etc.)"),
|
||||
LIFE_DRAIN("life_drain", "Life total reduced by non-combat sources (drain, burn spells)"),
|
||||
CONCESSION("concession", "Opponent conceded"),
|
||||
TIMEOUT("timeout", "Game clock expired or draw"),
|
||||
UNKNOWN("unknown", "Unrecognized win condition — see logs");
|
||||
|
||||
private static final Logger LOG = Logger.getLogger(WinCondition.class.getName());
|
||||
|
||||
private final String label;
|
||||
private final String description;
|
||||
|
||||
WinCondition(String label, String description) {
|
||||
this.label = label;
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
public String getLabel() { return label; }
|
||||
public String getDescription() { return description; }
|
||||
|
||||
// ── Forge loss-reason string → WinCondition mapping ─────────────────
|
||||
//
|
||||
// Known Forge quiet-mode loss reasons (from forge-gui-desktop source):
|
||||
// "life total reached 0"
|
||||
// "life total reached -N"
|
||||
// "ran out of cards"
|
||||
// "drew from an empty library"
|
||||
// "drew from empty library"
|
||||
// "received 21 or more commander damage"
|
||||
// "got 10 or more poison counters"
|
||||
// "an opponent has won the game"
|
||||
// "conceded"
|
||||
// "all opponents have lost"
|
||||
|
||||
private static final List<LossReasonMapping> LOSS_REASON_MAPPINGS = List.of(
|
||||
// Commander damage — must check BEFORE generic life-total patterns
|
||||
new LossReasonMapping(
|
||||
Pattern.compile("commander\\s+damage", Pattern.CASE_INSENSITIVE),
|
||||
COMMANDER_DAMAGE
|
||||
),
|
||||
|
||||
// Poison counters
|
||||
new LossReasonMapping(
|
||||
Pattern.compile("poison\\s+counter", Pattern.CASE_INSENSITIVE),
|
||||
COMBO_ALT_WIN
|
||||
),
|
||||
|
||||
// Mill — empty library
|
||||
new LossReasonMapping(
|
||||
Pattern.compile("ran\\s+out\\s+of\\s+cards|empty\\s+library|drew\\s+from\\s+.+library", Pattern.CASE_INSENSITIVE),
|
||||
MILL
|
||||
),
|
||||
|
||||
// Alternate win conditions (e.g., "wins the game", "won the game")
|
||||
new LossReasonMapping(
|
||||
Pattern.compile("alternate\\s+win|wins\\s+the\\s+game|won\\s+the\\s+game", Pattern.CASE_INSENSITIVE),
|
||||
COMBO_ALT_WIN
|
||||
),
|
||||
|
||||
// Concession
|
||||
new LossReasonMapping(
|
||||
Pattern.compile("concede", Pattern.CASE_INSENSITIVE),
|
||||
CONCESSION
|
||||
),
|
||||
|
||||
// Life total reached 0 or below — generic combat/damage
|
||||
new LossReasonMapping(
|
||||
Pattern.compile("life\\s+total\\s+reached", Pattern.CASE_INSENSITIVE),
|
||||
COMBAT_DAMAGE
|
||||
),
|
||||
|
||||
// Timeout / clock / draw
|
||||
new LossReasonMapping(
|
||||
Pattern.compile("clock|time\\s+limit|timed?\\s*out", Pattern.CASE_INSENSITIVE),
|
||||
TIMEOUT
|
||||
)
|
||||
);
|
||||
|
||||
/**
|
||||
* Classify win condition from a map of loser seat → loss reason strings,
|
||||
* plus the full Forge output for additional context scanning.
|
||||
*
|
||||
* @param lossReasons Map of seat index (as string) → Forge loss reason text
|
||||
* @param fullOutput Complete Forge stdout for fallback pattern matching
|
||||
* @return The classified WinCondition
|
||||
*/
|
||||
public static WinCondition classify(Map<String, String> lossReasons, String fullOutput) {
|
||||
String combinedReasons = String.join(" ", lossReasons.values()).toLowerCase();
|
||||
String lowerOutput = fullOutput.toLowerCase();
|
||||
|
||||
// First pass: check combined loss reason strings
|
||||
for (LossReasonMapping mapping : LOSS_REASON_MAPPINGS) {
|
||||
if (mapping.pattern.matcher(combinedReasons).find()) {
|
||||
return mapping.condition;
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: scan full output for patterns not in loss reasons
|
||||
for (LossReasonMapping mapping : LOSS_REASON_MAPPINGS) {
|
||||
if (mapping.pattern.matcher(lowerOutput).find()) {
|
||||
// For timeout, only classify if there's no winner (no loss reasons)
|
||||
if (mapping.condition == TIMEOUT && !lossReasons.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
return mapping.condition;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for draw indicators in full output
|
||||
if (lowerOutput.contains("draw") && lossReasons.isEmpty()) {
|
||||
return TIMEOUT;
|
||||
}
|
||||
|
||||
// UNKNOWN — log for review so we can add new mappings
|
||||
if (!lossReasons.isEmpty()) {
|
||||
LOG.warning("[WinCondition] Unrecognized loss reasons — logging for review:");
|
||||
for (Map.Entry<String, String> entry : lossReasons.entrySet()) {
|
||||
LOG.warning(" Seat " + entry.getKey() + ": \"" + entry.getValue() + "\"");
|
||||
}
|
||||
}
|
||||
|
||||
return UNKNOWN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert label string back to enum (for deserialization / backward compat).
|
||||
*/
|
||||
public static WinCondition fromLabel(String label) {
|
||||
if (label == null) return UNKNOWN;
|
||||
for (WinCondition wc : values()) {
|
||||
if (wc.label.equals(label)) return wc;
|
||||
}
|
||||
LOG.warning("[WinCondition] Unknown label: \"" + label + "\"");
|
||||
return UNKNOWN;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return label;
|
||||
}
|
||||
|
||||
// ── Internal mapping record ─────────────────────────────────────────
|
||||
|
||||
private record LossReasonMapping(Pattern pattern, WinCondition condition) {}
|
||||
}
|
||||
Reference in new issue
Block a user