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:
KoalaTrapLord committed 2026-03-12 01:26:36 +00:00
1 parent a5fe8cb3fa
commit a0adfc37e8
7 files changed
+736 -81

No files matched your search

@@ -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) {}
}