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
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) {}
|
||||
}
|
||||
Reference in new issue
Block a user