Forge's sim mode looks up decks by NAME in its own %APPDATA%/Forge/decks/commander/ directory. Passing a full path like D:\...\precon-decks\Counterpunch.dck causes 'Could not load deck'. Now strips the path and .dck extension so Forge sees just the bare name (e.g. 'Counterpunch') and finds it in its deck folder. Precon .dck files must be copied to %APPDATA%/Forge/decks/commander/ for Forge to find them.
1182 lines
50 KiB
Java
1182 lines
50 KiB
Java
package commanderailab.batch;
|
|
|
|
import commanderailab.ai.AiPolicy;
|
|
import commanderailab.ai.PolicyControllerAi;
|
|
import commanderailab.ml.DecisionExtractor;
|
|
import commanderailab.ml.DecisionLogger;
|
|
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.*;
|
|
import java.nio.charset.StandardCharsets;
|
|
import java.nio.file.*;
|
|
import java.time.Instant;
|
|
import java.time.LocalDateTime;
|
|
import java.time.format.DateTimeFormatter;
|
|
import java.util.*;
|
|
import java.util.regex.*;
|
|
|
|
/**
|
|
* BatchRunner — Single-threaded batch simulation runner.
|
|
*
|
|
* 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
|
|
*
|
|
* Deck path resolution (priority order):
|
|
* 1. PRECON_DECKS_DIR environment variable
|
|
* 2. D:\ForgeCommander\commander-ai-lab\precon-decks (hardcoded project default)
|
|
* 3. %APPDATA%\Forge\decks\commander (legacy Forge location)
|
|
*
|
|
* Forge output format (with -q flag):
|
|
* Game Outcome: Turn 11
|
|
* Game Outcome: Ai(1)-Edgar Markov has won because all opponents have lost
|
|
* Game Outcome: Ai(2)-Grimgrin has lost because life total reached 0
|
|
* Game Outcome: Ai(3)-Xyris has lost because life total reached 0
|
|
* Match Result: Ai(1)-Edgar Markov: 1 Ai(2)-Grimgrin: 0 Ai(3)-Xyris: 0
|
|
* Game Result: Game 1 ended in 9201 ms. Ai(1)-Edgar Markov has won!
|
|
*/
|
|
public class BatchRunner {
|
|
|
|
// ── Hardcoded project-default precon directory ────────────────────────
|
|
private static final String DEFAULT_PRECON_DIR =
|
|
"D:\\ForgeCommander\\commander-ai-lab\\precon-decks";
|
|
|
|
private final String forgeJarPath; // Path to forge-gui-desktop JAR (jar-with-dependencies)
|
|
private final String forgeWorkDir; // Working directory for Forge (where res/ folder is)
|
|
private final List<DeckInfo> decks; // 3 or 4 decks
|
|
private final AiPolicy policy;
|
|
private final boolean quiet;
|
|
private final int clockSeconds; // Max seconds per game before draw (default 120)
|
|
private String javaPath = "java"; // Path to Java 17 executable (Forge requires Java 17)
|
|
|
|
// ── ML Decision Logging ───────────────────────────────────
|
|
private boolean mlLoggingEnabled = false;
|
|
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
|
|
|
|
// ── Policy server integration (PolicyControllerAi) ────────
|
|
// When set, the Forge command includes --policyServer so that
|
|
// PolicyControllerAi (in-process) or the external policy server
|
|
// can intercept AI decisions during batch simulations.
|
|
private String policyServerUrl = null;
|
|
|
|
// ── Regex patterns for parsing Forge sim output ────────────────────
|
|
|
|
// "Game Outcome: Turn 11"
|
|
private static final Pattern TURN_PATTERN =
|
|
Pattern.compile("Game Outcome:\\s*Turn\\s+(\\d+)");
|
|
|
|
// "Game Outcome: Ai(1)-Edgar Markov has won because all opponents have lost"
|
|
private static final Pattern WINNER_PATTERN =
|
|
Pattern.compile("Game Outcome:\\s*Ai\\(?(\\d+)\\)?-(.+?)\\s+has won because (.+)");
|
|
|
|
// "Game Outcome: Ai(2)-Grimgrin has lost because life total reached 0"
|
|
private static final Pattern LOSER_PATTERN =
|
|
Pattern.compile("Game Outcome:\\s*Ai\\(?(\\d+)\\)?-(.+?)\\s+has lost because (.+)");
|
|
|
|
// "Game Result: Game 1 ended in 9201 ms. Ai(1)-Edgar Markov has won!"
|
|
private static final Pattern GAME_RESULT_PATTERN =
|
|
Pattern.compile("Game Result:\\s*Game\\s+(\\d+)\\s+ended in\\s+(\\d+)\\s*ms\\.\\s*(.+?)\\s+has won!");
|
|
|
|
// "Game Result: Game 1 ended in 9201 ms. It's a draw!" (timeout/draw case)
|
|
private static final Pattern GAME_DRAW_PATTERN =
|
|
Pattern.compile("Game Result:\\s*Game\\s+(\\d+)\\s+ended in\\s+(\\d+)\\s*ms\\..+draw", Pattern.CASE_INSENSITIVE);
|
|
|
|
// "Match Result: Ai(1)-Edgar Markov: 1 Ai(2)-Grimgrin: 0 Ai(3)-Xyris: 0"
|
|
private static final Pattern MATCH_RESULT_PATTERN =
|
|
Pattern.compile("Match Result:");
|
|
|
|
// ── Life total tracking patterns (Issue #1) ─────────────────────────
|
|
private static final Pattern LIFE_CHANGE_PATTERN =
|
|
Pattern.compile("Life:\\s*Life:\\s*Ai\\(?(\\d+)\\)?-.+?\\s+(\\d+)\\s*>\\s*(-?\\d+)");
|
|
|
|
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);
|
|
|
|
private static final Pattern LIFE_LOSS_PATTERN =
|
|
Pattern.compile("Ai\\(?(\\d+)\\)?-.+?\\s+loses\\s+(\\d+)\\s+life", Pattern.CASE_INSENSITIVE);
|
|
|
|
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 ────────────
|
|
|
|
private static final Pattern CAST_PATTERN =
|
|
Pattern.compile("(?:Add To Stack:\\s*)?Ai\\(?(\\d+)\\)?-[^\\s].*?\\s+casts?\\s+(.+?)(?:\\s+targeting.*)?$", Pattern.CASE_INSENSITIVE);
|
|
|
|
private static final Pattern LAND_PLAY_PATTERN =
|
|
Pattern.compile("(?:Land:\\s*)?Ai\\(?(\\d+)\\)?-[^\\s].*?\\s+play(?:s|ed)\\s+(.+?)(?:\\s+\\(\\d+\\))?(?:\\.|$)", Pattern.CASE_INSENSITIVE);
|
|
|
|
private static final Pattern ZONE_CHANGE_PATTERN =
|
|
Pattern.compile("Zone Change:\\s*(.+?)\\s+(?:\\(\\d+\\)\\s+)?was put into .* from Battlefield", Pattern.CASE_INSENSITIVE);
|
|
private static final Pattern CREATURE_DESTROYED_PATTERN =
|
|
Pattern.compile("(is destroyed|dies|is put into .* graveyard from the battlefield)", Pattern.CASE_INSENSITIVE);
|
|
|
|
private static final Pattern DAMAGE_PATTERN =
|
|
Pattern.compile("deals\\s+(\\d+)\\s+(?:combat\\s+)?damage\\s+to\\s+Ai\\(?(\\d+)\\)?", Pattern.CASE_INSENSITIVE);
|
|
|
|
private static final Pattern PLAYER_DAMAGE_SOURCE_PATTERN =
|
|
Pattern.compile("(?:Damage:\\s*)?(.+?)\\s+deals\\s+(\\d+)\\s+(?:combat\\s+)?damage\\s+to\\s+Ai\\(?(\\d+)\\)?", Pattern.CASE_INSENSITIVE);
|
|
private static final Pattern CMDR_DAMAGE_PATTERN =
|
|
Pattern.compile("Ai\\(?(\\d+)\\)?.*?commander.*?damage.*?(\\d+)", Pattern.CASE_INSENSITIVE);
|
|
|
|
// ── Per-card tracking patterns (verbose log) ─────────────────────────
|
|
|
|
private static final Pattern DRAW_PATTERN =
|
|
Pattern.compile("Ai\\(?(\\d+)\\)?-[^\\s].*?\\s+draws\\s+(.+?)(?:\\.|$)", Pattern.CASE_INSENSITIVE);
|
|
|
|
private static final Pattern VERBOSE_TURN_PATTERN =
|
|
Pattern.compile("Turn\\s+(\\d+)\\s+\\(Ai\\(?(\\d+)\\)?", Pattern.CASE_INSENSITIVE);
|
|
|
|
private static final Pattern CARD_DAMAGE_PATTERN =
|
|
Pattern.compile("(.+?)\\s+deals\\s+(\\d+)\\s+(?:combat\\s+)?damage", Pattern.CASE_INSENSITIVE);
|
|
|
|
public BatchRunner(String forgeJarPath, String forgeWorkDir, List<DeckInfo> decks, AiPolicy policy) {
|
|
this(forgeJarPath, forgeWorkDir, decks, policy, false,
|
|
decks.size() >= 4 ? 300 : 180);
|
|
}
|
|
|
|
public BatchRunner(String forgeJarPath, String forgeWorkDir, List<DeckInfo> decks,
|
|
AiPolicy policy, boolean quiet, int clockSeconds) {
|
|
this(forgeJarPath, forgeWorkDir, decks, policy, quiet, clockSeconds, "java");
|
|
}
|
|
|
|
public BatchRunner(String forgeJarPath, String forgeWorkDir, List<DeckInfo> decks,
|
|
AiPolicy policy, boolean quiet, int clockSeconds, String javaPath) {
|
|
if (decks.size() < 3 || decks.size() > 4) {
|
|
throw new IllegalArgumentException("Requires 3 or 4 decks, got " + decks.size());
|
|
}
|
|
this.forgeJarPath = forgeJarPath;
|
|
this.forgeWorkDir = forgeWorkDir;
|
|
this.decks = decks;
|
|
this.policy = policy;
|
|
this.quiet = quiet;
|
|
this.clockSeconds = clockSeconds;
|
|
this.javaPath = (javaPath != null && !javaPath.isEmpty()) ? javaPath : "java";
|
|
}
|
|
|
|
// ── Configuration methods ─────────────────────────────────────────
|
|
|
|
public void setProgressCallback(ProgressCallback callback) {
|
|
this.progressCallback = callback;
|
|
}
|
|
|
|
public void setUseProcessPool(boolean usePool) {
|
|
this.useProcessPool = usePool;
|
|
}
|
|
|
|
public void setAiOptimization(boolean simplified, int thinkTimeMs) {
|
|
this.useSimplifiedAi = simplified;
|
|
this.aiThinkTimeMs = thinkTimeMs;
|
|
}
|
|
|
|
/**
|
|
* Enable policy server integration for batch simulations.
|
|
*
|
|
* When set, the Forge subprocess command includes
|
|
* {@code --policy midrange --policyServer <url>} flags, and
|
|
* the log output notes that {@link PolicyControllerAi} is
|
|
* available for decision interception.
|
|
*
|
|
* @param url Base URL of the Python policy server
|
|
* (e.g. {@code "http://localhost:8080"})
|
|
*/
|
|
public void setPolicyServer(String url) {
|
|
this.policyServerUrl = url;
|
|
System.out.println("[BatchRunner] PolicyControllerAi integration enabled — server: " + url);
|
|
}
|
|
|
|
public void enableMlLogging(String resultsDir, String batchId) {
|
|
this.mlLoggingEnabled = true;
|
|
this.mlResultsDir = resultsDir;
|
|
try {
|
|
this.mlDecisionLogger = new DecisionLogger(resultsDir, batchId);
|
|
System.out.println("[ML] Decision logging enabled for batch: " + batchId);
|
|
} catch (IOException e) {
|
|
System.err.println("[ML] Failed to initialize decision logger: " + e.getMessage());
|
|
this.mlLoggingEnabled = false;
|
|
}
|
|
}
|
|
|
|
public void closeMlLogger() {
|
|
if (mlDecisionLogger != null) {
|
|
mlDecisionLogger.close();
|
|
}
|
|
}
|
|
|
|
public boolean isMlLoggingEnabled() {
|
|
return mlLoggingEnabled;
|
|
}
|
|
|
|
public DecisionLogger getMlDecisionLogger() {
|
|
return mlDecisionLogger;
|
|
}
|
|
|
|
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();
|
|
long startMs = System.currentTimeMillis();
|
|
|
|
try {
|
|
GameResult result = runSingleGame(i, gameSeed);
|
|
if (result.elapsedMs == 0) {
|
|
result.elapsedMs = System.currentTimeMillis() - startMs;
|
|
}
|
|
results.add(result);
|
|
|
|
System.out.printf("[Game %d/%d] Winner: %s | Turns: %d | Time: %dms%n",
|
|
i + 1, numGames,
|
|
result.winningSeat != null
|
|
? decks.get(result.winningSeat).deckName
|
|
: "DRAW",
|
|
result.totalTurns, result.elapsedMs);
|
|
|
|
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);
|
|
results.add(failed);
|
|
}
|
|
}
|
|
|
|
shutdownWorkerPool();
|
|
|
|
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;
|
|
}
|
|
|
|
private GameResult runSingleGame(int gameIndex, long gameSeed) throws IOException, InterruptedException {
|
|
List<String> cmd = buildForgeCommand();
|
|
|
|
String cmdStr = String.join(" ", cmd);
|
|
System.out.println("[DEBUG] Forge command: " + cmdStr);
|
|
System.out.println("[DEBUG] Working dir: " + forgeWorkDir);
|
|
|
|
ProcessBuilder pb = new ProcessBuilder(cmd);
|
|
pb.directory(new File(forgeWorkDir));
|
|
pb.redirectErrorStream(true);
|
|
pb.environment().remove("JAVA_TOOL_OPTIONS");
|
|
|
|
long timeoutSeconds = clockSeconds + 120;
|
|
|
|
long jvmStartMs = System.currentTimeMillis();
|
|
Process process = pb.start();
|
|
long jvmReadyMs = System.currentTimeMillis() - jvmStartMs;
|
|
System.out.printf("[PERF] JVM startup: %dms%n", jvmReadyMs);
|
|
|
|
StringBuilder output = new StringBuilder();
|
|
Thread watchdog = new Thread(() -> {
|
|
try {
|
|
Thread.sleep(timeoutSeconds * 1000);
|
|
if (process.isAlive()) {
|
|
System.err.println("[WATCHDOG] Forge process exceeded timeout (" + timeoutSeconds + "s). Killing.");
|
|
process.destroyForcibly();
|
|
}
|
|
} catch (InterruptedException ignored) {}
|
|
}, "ForgeWatchdog-" + gameIndex);
|
|
watchdog.setDaemon(true);
|
|
watchdog.start();
|
|
|
|
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
|
|
String line;
|
|
while ((line = reader.readLine()) != null) {
|
|
output.append(line).append("\n");
|
|
}
|
|
}
|
|
|
|
int exitCode = process.waitFor();
|
|
watchdog.interrupt();
|
|
|
|
String rawOutput = output.toString();
|
|
String[] outLines = rawOutput.split("\n");
|
|
System.out.printf("[DEBUG] Forge exit=%d, output=%d lines, length=%d chars%n",
|
|
exitCode, outLines.length, rawOutput.length());
|
|
if (rawOutput.isBlank()) {
|
|
System.out.println("[DEBUG] Forge produced NO output — deck may not be found or Forge failed silently");
|
|
} else {
|
|
int show = Math.min(5, outLines.length);
|
|
for (int li = 0; li < show; li++) {
|
|
System.out.println("[DEBUG] OUT> " + outLines[li]);
|
|
}
|
|
if (outLines.length > 10) {
|
|
System.out.println("[DEBUG] ... (" + (outLines.length - 10) + " lines omitted) ...");
|
|
for (int li = outLines.length - 5; li < outLines.length; li++) {
|
|
if (li >= 0) System.out.println("[DEBUG] OUT> " + outLines[li]);
|
|
}
|
|
}
|
|
}
|
|
|
|
writeDebugLog(gameIndex, cmdStr, rawOutput, exitCode);
|
|
|
|
return parseForgeOutput(gameIndex, gameSeed, rawOutput, exitCode);
|
|
}
|
|
|
|
private void writeDebugLog(int gameIndex, String command, String output, int exitCode) {
|
|
try {
|
|
Path logDir = Path.of(forgeWorkDir).getParent();
|
|
if (logDir == null) logDir = Path.of(".");
|
|
Path logFile = logDir.resolve("forge-sim-debug.log");
|
|
|
|
StringBuilder log = new StringBuilder();
|
|
log.append("\n════════════════════════════════════════════════════════════\n");
|
|
log.append("Game ").append(gameIndex).append(" @ ");
|
|
log.append(LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
|
|
log.append("\nCommand: ").append(command);
|
|
log.append("\nWorking Dir: ").append(forgeWorkDir);
|
|
log.append("\nExit Code: ").append(exitCode);
|
|
log.append("\n── RAW OUTPUT ──────────────────────────────────────────────\n");
|
|
if (output.isBlank()) {
|
|
log.append("<< NO OUTPUT >>\n");
|
|
} else {
|
|
log.append(output);
|
|
}
|
|
log.append("── END OUTPUT ──────────────────────────────────────────────\n");
|
|
|
|
Files.writeString(logFile, log.toString(), StandardCharsets.UTF_8,
|
|
StandardOpenOption.CREATE, StandardOpenOption.APPEND);
|
|
|
|
System.out.println("[DEBUG] Log written to: " + logFile.toAbsolutePath());
|
|
} catch (Exception e) {
|
|
System.err.println("[DEBUG] Failed to write debug log: " + e.getMessage());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Build the Forge sim command.
|
|
*
|
|
* Deck paths are passed as FULL ABSOLUTE PATHS (e.g.
|
|
* D:\ForgeCommander\commander-ai-lab\precon-decks\Elven_Empire.dck)
|
|
* so Forge can find them regardless of working directory.
|
|
*
|
|
* Path resolution for each deck:
|
|
* 1. If deckFile is already an absolute path that exists → use as-is
|
|
* 2. Resolve against PRECON_DECKS_DIR env var
|
|
* 3. Resolve against hardcoded DEFAULT_PRECON_DIR
|
|
* 4. Resolve against %APPDATA%\Forge\decks\commander (legacy)
|
|
* 5. Fall back to bare name (let Forge try its own lookup)
|
|
*/
|
|
private List<String> buildForgeCommand() {
|
|
List<String> cmd = new ArrayList<>();
|
|
|
|
cmd.add(javaPath);
|
|
|
|
cmd.add("-Xmx4096m");
|
|
cmd.add("-XX:+UseSerialGC");
|
|
cmd.add("-XX:TieredStopAtLevel=1");
|
|
cmd.add("-Dio.netty.tryReflectionSetAccessible=true");
|
|
cmd.add("-Dfile.encoding=UTF-8");
|
|
|
|
if (aiThinkTimeMs > 0) {
|
|
cmd.add("-Dforge.ai.thinkTimeMs=" + aiThinkTimeMs);
|
|
}
|
|
if (useSimplifiedAi) {
|
|
cmd.add("-Dforge.ai.simplified=true");
|
|
}
|
|
|
|
String[] addOpens = {
|
|
"java.desktop/java.beans=ALL-UNNAMED",
|
|
"java.desktop/javax.swing.border=ALL-UNNAMED",
|
|
"java.desktop/javax.swing.event=ALL-UNNAMED",
|
|
"java.desktop/sun.swing=ALL-UNNAMED",
|
|
"java.desktop/java.awt.image=ALL-UNNAMED",
|
|
"java.desktop/java.awt.color=ALL-UNNAMED",
|
|
"java.desktop/sun.awt.image=ALL-UNNAMED",
|
|
"java.desktop/javax.swing=ALL-UNNAMED",
|
|
"java.desktop/java.awt=ALL-UNNAMED",
|
|
"java.base/java.util=ALL-UNNAMED",
|
|
"java.base/java.lang=ALL-UNNAMED",
|
|
"java.base/java.lang.reflect=ALL-UNNAMED",
|
|
"java.base/java.text=ALL-UNNAMED",
|
|
"java.desktop/java.awt.font=ALL-UNNAMED",
|
|
"java.base/jdk.internal.misc=ALL-UNNAMED",
|
|
"java.base/sun.nio.ch=ALL-UNNAMED",
|
|
"java.base/java.nio=ALL-UNNAMED",
|
|
"java.base/java.math=ALL-UNNAMED",
|
|
"java.base/java.util.concurrent=ALL-UNNAMED",
|
|
"java.base/java.net=ALL-UNNAMED",
|
|
};
|
|
for (String module : addOpens) {
|
|
cmd.add("--add-opens");
|
|
cmd.add(module);
|
|
}
|
|
|
|
cmd.add("-jar");
|
|
cmd.add(forgeJarPath);
|
|
|
|
cmd.add("sim");
|
|
|
|
// Forge's sim mode looks up decks by NAME inside its own
|
|
// decks/commander/ directory (%APPDATA%/Forge/decks/commander/).
|
|
// It does NOT accept full paths — passing a path like
|
|
// D:\...\precon-decks\Counterpunch.dck causes "Could not load deck".
|
|
//
|
|
// Strip the path and .dck extension so Forge sees just the bare
|
|
// name (e.g. "Counterpunch") and finds it in its own folder.
|
|
cmd.add("-d");
|
|
for (DeckInfo deck : decks) {
|
|
String resolvedPath = resolveFullDeckPath(deck.deckFile);
|
|
// Extract just the deck name: "Counterpunch" from full path
|
|
String deckName = resolvedPath;
|
|
int lastSep = Math.max(deckName.lastIndexOf('/'), deckName.lastIndexOf('\\'));
|
|
if (lastSep >= 0) deckName = deckName.substring(lastSep + 1);
|
|
if (deckName.toLowerCase().endsWith(".dck")) {
|
|
deckName = deckName.substring(0, deckName.length() - 4);
|
|
}
|
|
cmd.add(deckName);
|
|
System.out.println("[DECK] Resolved: " + resolvedPath + " → Forge name: " + deckName);
|
|
}
|
|
|
|
cmd.add("-f");
|
|
cmd.add("commander");
|
|
|
|
cmd.add("-n");
|
|
cmd.add("1");
|
|
|
|
cmd.add("-c");
|
|
cmd.add(String.valueOf(clockSeconds));
|
|
|
|
// Policy server integration (PolicyControllerAi)
|
|
// When policyServerUrl is set, tell Forge to use policy-driven AI.
|
|
// The Python policy server at this URL handles POST /api/policy/decide
|
|
// and returns macro-actions that PolicyControllerAi maps to concrete
|
|
// SpellAbility objects.
|
|
if (policyServerUrl != null && !policyServerUrl.isBlank()) {
|
|
cmd.add("--policy");
|
|
cmd.add("midrange");
|
|
cmd.add("--policyServer");
|
|
cmd.add(policyServerUrl);
|
|
System.out.println("[BatchRunner] Policy server: " + policyServerUrl);
|
|
}
|
|
|
|
if (quiet) {
|
|
cmd.add("-q");
|
|
}
|
|
|
|
return cmd;
|
|
}
|
|
|
|
/**
|
|
* Resolve a deck file name to its full absolute path.
|
|
*
|
|
* Priority:
|
|
* 1. Already an absolute path that exists on disk → return as-is
|
|
* 2. PRECON_DECKS_DIR env var / precon-decks folder
|
|
* 3. Hardcoded DEFAULT_PRECON_DIR
|
|
* 4. %APPDATA%\Forge\decks\commander (legacy)
|
|
* 5. Bare name with .dck appended (Forge internal lookup fallback)
|
|
*/
|
|
private String resolveFullDeckPath(String deckFile) {
|
|
// Ensure .dck extension
|
|
String name = deckFile.endsWith(".dck") ? deckFile : deckFile + ".dck";
|
|
|
|
// 1. Already an absolute path?
|
|
Path absolute = Path.of(name);
|
|
if (absolute.isAbsolute() && Files.exists(absolute)) {
|
|
return absolute.toString();
|
|
}
|
|
|
|
// 2. PRECON_DECKS_DIR env var
|
|
String envDir = System.getenv("PRECON_DECKS_DIR");
|
|
if (envDir != null && !envDir.isBlank()) {
|
|
Path candidate = Path.of(envDir, name);
|
|
if (Files.exists(candidate)) {
|
|
return candidate.toAbsolutePath().toString();
|
|
}
|
|
}
|
|
|
|
// 3. Hardcoded project default
|
|
Path defaultCandidate = Path.of(DEFAULT_PRECON_DIR, name);
|
|
if (Files.exists(defaultCandidate)) {
|
|
return defaultCandidate.toAbsolutePath().toString();
|
|
}
|
|
|
|
// 4. Legacy %APPDATA%\Forge\decks\commander
|
|
String appdata = System.getenv("APPDATA");
|
|
if (appdata != null) {
|
|
Path legacyCandidate = Path.of(appdata, "Forge", "decks", "commander", name);
|
|
if (Files.exists(legacyCandidate)) {
|
|
return legacyCandidate.toAbsolutePath().toString();
|
|
}
|
|
}
|
|
|
|
// 5. Fallback — Forge will attempt its own lookup
|
|
System.err.println("[WARN] Could not resolve deck path for: " + name
|
|
+ " — passing bare name to Forge (may fail)");
|
|
return name;
|
|
}
|
|
|
|
private GameResult parseForgeOutput(int gameIndex, long gameSeed, String output, int exitCode) {
|
|
GameResult result = new GameResult();
|
|
result.gameIndex = gameIndex;
|
|
result.gameSeed = gameSeed;
|
|
result.winCondition = "unknown";
|
|
result.totalTurns = 0;
|
|
result.elapsedMs = 0;
|
|
|
|
result.playerResults = new ArrayList<>();
|
|
for (int i = 0; i < decks.size(); i++) {
|
|
PlayerResult pr = new PlayerResult();
|
|
pr.seatIndex = i;
|
|
pr.finalLife = 40;
|
|
pr.mulligans = 0;
|
|
pr.isWinner = false;
|
|
pr.commanderDamageDealt = 0;
|
|
pr.commanderCasts = 0;
|
|
pr.landsPlayed = 0;
|
|
pr.spellsCast = 0;
|
|
pr.creaturesDestroyed = 0;
|
|
result.playerResults.add(pr);
|
|
}
|
|
|
|
if (output.isBlank()) {
|
|
result.winCondition = WinCondition.TIMEOUT.getLabel();
|
|
return result;
|
|
}
|
|
|
|
String[] lines = output.split("\n");
|
|
Map<String, String> lossReasons = new HashMap<>();
|
|
|
|
int numPlayers = decks.size();
|
|
int[] lastKnownLife = new int[numPlayers];
|
|
boolean[] lifeTracked = new boolean[numPlayers];
|
|
for (int i = 0; i < numPlayers; i++) {
|
|
lastKnownLife[i] = 40;
|
|
lifeTracked[i] = false;
|
|
}
|
|
Set<Integer> loserSeats = new HashSet<>();
|
|
Set<Integer> winnerCandidates = new HashSet<>();
|
|
int maxVerboseTurn = 0;
|
|
for (String line : lines) {
|
|
line = line.trim();
|
|
if (line.startsWith("Game Outcome:") || line.startsWith("Game Result:") || line.startsWith("Match Result:")) {
|
|
System.out.println("[OUTCOME-LINE] " + line);
|
|
}
|
|
|
|
Matcher turnMatcher = TURN_PATTERN.matcher(line);
|
|
if (turnMatcher.find()) {
|
|
result.totalTurns = Integer.parseInt(turnMatcher.group(1));
|
|
continue;
|
|
}
|
|
|
|
Matcher verboseTurn = VERBOSE_TURN_PATTERN.matcher(line);
|
|
if (verboseTurn.find()) {
|
|
int t = Integer.parseInt(verboseTurn.group(1));
|
|
if (t > maxVerboseTurn) maxVerboseTurn = t;
|
|
}
|
|
|
|
Matcher winMatcher = WINNER_PATTERN.matcher(line);
|
|
if (winMatcher.find()) {
|
|
int aiNumber = Integer.parseInt(winMatcher.group(1));
|
|
int seatIndex = aiNumber - 1;
|
|
if (seatIndex >= 0 && seatIndex < decks.size()) {
|
|
winnerCandidates.add(seatIndex);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
Matcher loseMatcher = LOSER_PATTERN.matcher(line);
|
|
if (loseMatcher.find()) {
|
|
int aiNumber = Integer.parseInt(loseMatcher.group(1));
|
|
int seatIndex = aiNumber - 1;
|
|
String lossReason = loseMatcher.group(3).trim();
|
|
|
|
if (seatIndex >= 0 && seatIndex < decks.size()) {
|
|
lossReasons.put(String.valueOf(seatIndex), lossReason);
|
|
loserSeats.add(seatIndex);
|
|
|
|
if (lossReason.contains("life total reached 0")) {
|
|
result.playerResults.get(seatIndex).finalLife = 0;
|
|
lastKnownLife[seatIndex] = 0;
|
|
} else if (lossReason.contains("life total reached")) {
|
|
Matcher lifeMatcher = Pattern.compile("life total reached (-?\\d+)").matcher(lossReason);
|
|
if (lifeMatcher.find()) {
|
|
int life = Integer.parseInt(lifeMatcher.group(1));
|
|
result.playerResults.get(seatIndex).finalLife = life;
|
|
lastKnownLife[seatIndex] = life;
|
|
}
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
|
|
Matcher gameResultMatcher = GAME_RESULT_PATTERN.matcher(line);
|
|
if (gameResultMatcher.find()) {
|
|
result.elapsedMs = Long.parseLong(gameResultMatcher.group(2));
|
|
continue;
|
|
}
|
|
|
|
Matcher drawMatcher = GAME_DRAW_PATTERN.matcher(line);
|
|
if (drawMatcher.find()) {
|
|
result.elapsedMs = Long.parseLong(drawMatcher.group(2));
|
|
result.winningSeat = null;
|
|
result.winCondition = WinCondition.TIMEOUT.getLabel();
|
|
continue;
|
|
}
|
|
|
|
Matcher lifeChangeMatch = LIFE_CHANGE_PATTERN.matcher(line);
|
|
if (lifeChangeMatch.find()) {
|
|
int aiNum = Integer.parseInt(lifeChangeMatch.group(1));
|
|
int seat = aiNum - 1;
|
|
int newLife = Integer.parseInt(lifeChangeMatch.group(3));
|
|
if (seat >= 0 && seat < decks.size()) {
|
|
lastKnownLife[seat] = newLife;
|
|
lifeTracked[seat] = true;
|
|
}
|
|
}
|
|
|
|
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 < decks.size()) {
|
|
lastKnownLife[seat] = life;
|
|
lifeTracked[seat] = true;
|
|
}
|
|
}
|
|
|
|
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 < decks.size()) {
|
|
lastKnownLife[seat] -= amount;
|
|
lifeTracked[seat] = true;
|
|
}
|
|
}
|
|
|
|
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 < decks.size()) {
|
|
lastKnownLife[seat] += amount;
|
|
lifeTracked[seat] = true;
|
|
}
|
|
}
|
|
|
|
if (line.toLowerCase().contains("mulligan")) {
|
|
Matcher mullMatcher = Pattern.compile("Ai\\(?(\\d+)\\)?").matcher(line);
|
|
if (mullMatcher.find()) {
|
|
int aiNum = Integer.parseInt(mullMatcher.group(1));
|
|
int seat = aiNum - 1;
|
|
if (seat >= 0 && seat < decks.size()) {
|
|
result.playerResults.get(seat).mulligans++;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (winnerCandidates.size() == 1) {
|
|
int winSeat = winnerCandidates.iterator().next();
|
|
result.winningSeat = winSeat;
|
|
result.playerResults.get(winSeat).isWinner = true;
|
|
System.out.printf("[WINNER] Single winner: seat %d%n", winSeat);
|
|
} else if (winnerCandidates.size() > 1) {
|
|
int bestSeat = -1;
|
|
int bestLife = Integer.MIN_VALUE;
|
|
for (int seat : winnerCandidates) {
|
|
int life = lifeTracked[seat] ? lastKnownLife[seat] : 40;
|
|
if (life > bestLife) {
|
|
bestLife = life;
|
|
bestSeat = seat;
|
|
}
|
|
}
|
|
if (bestSeat >= 0) {
|
|
result.winningSeat = bestSeat;
|
|
result.playerResults.get(bestSeat).isWinner = true;
|
|
System.out.printf("[WINNER] Resolved from %d candidates: seat %d (life=%d).%n",
|
|
winnerCandidates.size(), bestSeat, bestLife);
|
|
}
|
|
}
|
|
|
|
WinCondition condition = WinCondition.classify(lossReasons, output);
|
|
result.winCondition = condition.getLabel();
|
|
|
|
for (int seat = 0; seat < decks.size(); seat++) {
|
|
if (lifeTracked[seat]) {
|
|
result.playerResults.get(seat).finalLife = lastKnownLife[seat];
|
|
}
|
|
}
|
|
if (result.winningSeat != null) {
|
|
int winnerSeat = result.winningSeat;
|
|
if (lifeTracked[winnerSeat]) {
|
|
System.out.printf("[LIFE] Winner seat %d: final life = %d (tracked from log)%n",
|
|
winnerSeat, lastKnownLife[winnerSeat]);
|
|
} else {
|
|
System.out.printf("[LIFE] Winner seat %d: life not tracked in log (quiet mode?), defaulting to 40%n",
|
|
winnerSeat);
|
|
}
|
|
}
|
|
|
|
if (result.totalTurns == 0 && maxVerboseTurn > 0) {
|
|
result.totalTurns = maxVerboseTurn;
|
|
System.out.printf("[TURNS] Using verbose log turn count: %d%n", maxVerboseTurn);
|
|
} else if (result.totalTurns == 0) {
|
|
result.totalTurns = 1;
|
|
}
|
|
|
|
String[] commanderNames = new String[decks.size()];
|
|
for (int ci = 0; ci < decks.size(); ci++) {
|
|
commanderNames[ci] = decks.get(ci).commanderName;
|
|
}
|
|
for (int ci2 = 0; ci2 < decks.size(); ci2++) {
|
|
if (commanderNames[ci2] == null || commanderNames[ci2].isEmpty()
|
|
|| commanderNames[ci2].equals(decks.get(ci2).deckName)
|
|
|| commanderNames[ci2].equals(decks.get(ci2).deckFile)) {
|
|
String detected = readCommanderCardName(decks.get(ci2).deckFile);
|
|
if (detected != null && !detected.isEmpty()) {
|
|
commanderNames[ci2] = detected;
|
|
System.out.printf("[CMDR] Seat %d: auto-detected commander '%s' from .dck file%n", ci2, detected);
|
|
}
|
|
}
|
|
}
|
|
for (int ci3 = 0; ci3 < decks.size(); ci3++) {
|
|
System.out.printf("[CMDR-INFO] Seat %d: commander='%s', deckName='%s'%n",
|
|
ci3, commanderNames[ci3], decks.get(ci3).deckName);
|
|
}
|
|
|
|
for (String line : lines) {
|
|
String lower = line.trim().toLowerCase();
|
|
if (lower.contains("cast") || lower.contains("play") || lower.contains("damage") || lower.contains("add to stack") || lower.contains("land:")) {
|
|
System.out.println("[COMBAT-LINE] " + line.trim());
|
|
}
|
|
line = line.trim();
|
|
if (line.isEmpty()) continue;
|
|
|
|
Matcher castMatcher = CAST_PATTERN.matcher(line);
|
|
if (castMatcher.find()) {
|
|
int aiNum = Integer.parseInt(castMatcher.group(1));
|
|
int seat = aiNum - 1;
|
|
String cardName = castMatcher.group(2).trim();
|
|
if (seat >= 0 && seat < decks.size()) {
|
|
result.playerResults.get(seat).spellsCast++;
|
|
if (commanderNames[seat] != null &&
|
|
cardName.equalsIgnoreCase(commanderNames[seat])) {
|
|
result.playerResults.get(seat).commanderCasts++;
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
|
|
Matcher landMatcher = LAND_PLAY_PATTERN.matcher(line);
|
|
if (landMatcher.find()) {
|
|
int aiNum = Integer.parseInt(landMatcher.group(1));
|
|
int seat = aiNum - 1;
|
|
if (seat >= 0 && seat < decks.size()) {
|
|
result.playerResults.get(seat).landsPlayed++;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
Matcher playerDmgMatcher = PLAYER_DAMAGE_SOURCE_PATTERN.matcher(line);
|
|
if (playerDmgMatcher.find()) {
|
|
String sourceCard = playerDmgMatcher.group(1).trim();
|
|
int dmgAmount = Integer.parseInt(playerDmgMatcher.group(2));
|
|
int targetAiNum = Integer.parseInt(playerDmgMatcher.group(3));
|
|
int targetSeat = targetAiNum - 1;
|
|
for (int ci = 0; ci < decks.size(); ci++) {
|
|
if (commanderNames[ci] != null && sourceCard.equalsIgnoreCase(commanderNames[ci])) {
|
|
if (ci >= 0 && ci < result.playerResults.size()) {
|
|
result.playerResults.get(ci).commanderDamageDealt += dmgAmount;
|
|
System.out.printf("[CMDR-DMG] %s (seat %d) dealt %d cmdr damage to seat %d%n",
|
|
sourceCard, ci, dmgAmount, targetSeat);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (line.toLowerCase().contains("commander damage")) {
|
|
Matcher cdmg = Pattern.compile("(\\d+)\\s+commander\\s+damage").matcher(line.toLowerCase());
|
|
if (cdmg.find()) {
|
|
int amount = Integer.parseInt(cdmg.group(1));
|
|
Matcher srcPlayer = Pattern.compile("Ai\\(?(\\d+)\\)?").matcher(line);
|
|
if (srcPlayer.find()) {
|
|
int seat = Integer.parseInt(srcPlayer.group(1)) - 1;
|
|
if (seat >= 0 && seat < decks.size()) {
|
|
result.playerResults.get(seat).commanderDamageDealt += amount;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
System.out.printf("[PARSE-SUMMARY] Game %d: total lines=%d%n", gameIndex, lines.length);
|
|
for (int seat = 0; seat < result.playerResults.size(); seat++) {
|
|
PlayerResult pr = result.playerResults.get(seat);
|
|
System.out.printf("[PARSE] Game %d Seat %d: spells=%d, lands=%d, cmdrCasts=%d, cmdrDmg=%d, life=%d%n",
|
|
gameIndex, seat, pr.spellsCast, pr.landsPlayed, pr.commanderCasts, pr.commanderDamageDealt, pr.finalLife);
|
|
}
|
|
|
|
if (!quiet) {
|
|
try {
|
|
buildPerCardStats(result, lines);
|
|
} catch (Exception e) {
|
|
System.err.println("[COACH] Per-card stat tracking failed: " + e.getMessage());
|
|
}
|
|
}
|
|
|
|
// ML decision logging: extract state+action snapshots from the
|
|
// verbose Forge game log for the training pipeline.
|
|
// Note: the !quiet guard was removed — ML logging now works in all
|
|
// modes. DecisionExtractor parses the same log lines regardless.
|
|
if (mlLoggingEnabled && mlDecisionLogger != null) {
|
|
try {
|
|
String gid = "game-" + gameIndex + "-" + gameSeed;
|
|
String[] cmdNames = new String[numPlayers];
|
|
List<List<String>> deckCardLists = new ArrayList<>();
|
|
|
|
for (int di = 0; di < numPlayers; di++) {
|
|
cmdNames[di] = decks.get(di).commanderName;
|
|
deckCardLists.add(readDeckCardNames(decks.get(di).deckFile));
|
|
}
|
|
|
|
DecisionExtractor extractor = new DecisionExtractor(
|
|
numPlayers, gid, cmdNames, deckCardLists);
|
|
List<DecisionSnapshot> decisions = extractor.processGameLog(lines);
|
|
|
|
Map<Integer, Map<String, String>> deckMeta = new HashMap<>();
|
|
for (int di = 0; di < numPlayers; di++) {
|
|
Map<String, String> meta = new HashMap<>();
|
|
meta.put("name", decks.get(di).deckName);
|
|
meta.put("commander", decks.get(di).commanderName);
|
|
meta.put("archetype", decks.get(di).archetype != null ? decks.get(di).archetype : "midrange");
|
|
deckMeta.put(di, meta);
|
|
}
|
|
|
|
String outcome = result.winningSeat != null
|
|
? "win_seat_" + result.winningSeat
|
|
: "draw";
|
|
|
|
mlDecisionLogger.logGameDecisions(decisions, deckMeta, outcome);
|
|
|
|
if (!decisions.isEmpty()) {
|
|
System.out.printf("[ML] Game %d: %d decision snapshots extracted%n",
|
|
gameIndex, decisions.size());
|
|
}
|
|
} catch (Exception e) {
|
|
System.err.println("[ML] Decision extraction failed for game " + gameIndex + ": " + e.getMessage());
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
static class ForgeWorker {
|
|
private Process process;
|
|
private boolean alive = false;
|
|
|
|
void shutdown() {
|
|
if (process != null && process.isAlive()) {
|
|
process.destroyForcibly();
|
|
}
|
|
alive = false;
|
|
}
|
|
}
|
|
|
|
public interface ProgressCallback {
|
|
void onProgress(int completed, int total, int percentDone,
|
|
double simsPerSec, GameResult lastResult);
|
|
}
|
|
|
|
private List<String> readDeckCardNames(String deckFilePath) {
|
|
List<String> names = new ArrayList<>();
|
|
try {
|
|
Path path = resolveExistingDeckPath(deckFilePath);
|
|
if (path == null) {
|
|
System.err.println("[COACH] Deck file not found: " + deckFilePath);
|
|
return names;
|
|
}
|
|
List<String> lines = Files.readAllLines(path);
|
|
String section = "Main";
|
|
Pattern cardLine = Pattern.compile("^(\\d+)\\s+(.+?)(?:\\|(.+))?$");
|
|
|
|
for (String line : lines) {
|
|
line = line.trim();
|
|
if (line.isEmpty() || line.startsWith("//")) continue;
|
|
if (line.startsWith("[") && line.endsWith("]")) {
|
|
section = line.substring(1, line.length() - 1);
|
|
continue;
|
|
}
|
|
if (line.startsWith("Name=")) continue;
|
|
if (section.equalsIgnoreCase("Sideboard")) continue;
|
|
|
|
Matcher m = cardLine.matcher(line);
|
|
if (m.matches()) {
|
|
String cardName = m.group(2).trim();
|
|
int qty = Integer.parseInt(m.group(1));
|
|
for (int q = 0; q < qty; q++) {
|
|
names.add(cardName);
|
|
}
|
|
}
|
|
}
|
|
} catch (Exception e) {
|
|
System.err.println("[COACH] Failed to read deck file: " + deckFilePath + " - " + e.getMessage());
|
|
}
|
|
return names;
|
|
}
|
|
|
|
/**
|
|
* Resolve a deck file name/path to an existing Path on disk.
|
|
* Uses the same priority as resolveFullDeckPath but returns null if not found.
|
|
*/
|
|
private Path resolveExistingDeckPath(String deckFile) {
|
|
String name = deckFile.endsWith(".dck") ? deckFile : deckFile + ".dck";
|
|
|
|
// 1. Already absolute
|
|
Path absolute = Path.of(name);
|
|
if (absolute.isAbsolute() && Files.exists(absolute)) return absolute;
|
|
|
|
// 2. PRECON_DECKS_DIR env var
|
|
String envDir = System.getenv("PRECON_DECKS_DIR");
|
|
if (envDir != null && !envDir.isBlank()) {
|
|
Path candidate = Path.of(envDir, name);
|
|
if (Files.exists(candidate)) return candidate;
|
|
}
|
|
|
|
// 3. Hardcoded default
|
|
Path defaultCandidate = Path.of(DEFAULT_PRECON_DIR, name);
|
|
if (Files.exists(defaultCandidate)) return defaultCandidate;
|
|
|
|
// 4. Legacy %APPDATA%\Forge\decks\commander
|
|
String appdata = System.getenv("APPDATA");
|
|
if (appdata != null) {
|
|
Path legacyCandidate = Path.of(appdata, "Forge", "decks", "commander", name);
|
|
if (Files.exists(legacyCandidate)) return legacyCandidate;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* @deprecated Use resolveExistingDeckPath instead.
|
|
*/
|
|
private Path resolveForgeDecksPath(String deckName) {
|
|
return resolveExistingDeckPath(deckName);
|
|
}
|
|
|
|
private void buildPerCardStats(GameResult result, String[] lines) {
|
|
@SuppressWarnings("unchecked")
|
|
Map<String, PerCardGameStats>[] seatCards = new Map[decks.size()];
|
|
for (int s = 0; s < decks.size(); s++) {
|
|
seatCards[s] = new LinkedHashMap<>();
|
|
List<String> cardNames = readDeckCardNames(decks.get(s).deckFile);
|
|
for (String name : cardNames) {
|
|
if (!seatCards[s].containsKey(name)) {
|
|
seatCards[s].put(name, new PerCardGameStats(name));
|
|
}
|
|
}
|
|
}
|
|
|
|
int globalTurn = 0;
|
|
boolean[] openingHandPhase = new boolean[decks.size()];
|
|
java.util.Arrays.fill(openingHandPhase, true);
|
|
|
|
for (String rawLine : lines) {
|
|
String line = rawLine.trim();
|
|
if (line.isEmpty()) continue;
|
|
|
|
Matcher turnMatch = VERBOSE_TURN_PATTERN.matcher(line);
|
|
if (turnMatch.find()) {
|
|
int turnNum = Integer.parseInt(turnMatch.group(1));
|
|
int aiNum = Integer.parseInt(turnMatch.group(2));
|
|
globalTurn = turnNum;
|
|
int seat = aiNum - 1;
|
|
if (seat >= 0 && seat < decks.size()) {
|
|
openingHandPhase[seat] = false;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
Matcher drawMatch = DRAW_PATTERN.matcher(line);
|
|
if (drawMatch.find()) {
|
|
int seat = Integer.parseInt(drawMatch.group(1)) - 1;
|
|
String cardName = drawMatch.group(2).trim();
|
|
if (seat >= 0 && seat < decks.size() && seatCards[seat] != null) {
|
|
PerCardGameStats stats = seatCards[seat].get(cardName);
|
|
if (stats == null) {
|
|
stats = new PerCardGameStats(cardName);
|
|
seatCards[seat].put(cardName, stats);
|
|
}
|
|
if (!stats.drawn) {
|
|
stats.drawn = true;
|
|
stats.turnDrawn = globalTurn;
|
|
}
|
|
if (openingHandPhase[seat]) {
|
|
stats.inOpeningHand = true;
|
|
stats.keptInOpeningHand = true;
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
|
|
Matcher castMatch = CAST_PATTERN.matcher(line);
|
|
if (castMatch.find()) {
|
|
int seat = Integer.parseInt(castMatch.group(1)) - 1;
|
|
String cardName = castMatch.group(2).trim();
|
|
if (seat >= 0 && seat < decks.size() && seatCards[seat] != null) {
|
|
PerCardGameStats stats = seatCards[seat].get(cardName);
|
|
if (stats == null) {
|
|
stats = new PerCardGameStats(cardName);
|
|
seatCards[seat].put(cardName, stats);
|
|
}
|
|
if (!stats.cast) {
|
|
stats.cast = true;
|
|
stats.turnCast = globalTurn;
|
|
}
|
|
if (!stats.drawn) {
|
|
stats.drawn = true;
|
|
stats.turnDrawn = globalTurn;
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
|
|
Matcher landMatch = LAND_PLAY_PATTERN.matcher(line);
|
|
if (landMatch.find()) {
|
|
int seat = Integer.parseInt(landMatch.group(1)) - 1;
|
|
String cardName = landMatch.group(2).trim();
|
|
if (seat >= 0 && seat < decks.size() && seatCards[seat] != null) {
|
|
PerCardGameStats stats = seatCards[seat].get(cardName);
|
|
if (stats == null) {
|
|
stats = new PerCardGameStats(cardName);
|
|
seatCards[seat].put(cardName, stats);
|
|
}
|
|
if (!stats.cast) {
|
|
stats.cast = true;
|
|
stats.turnCast = globalTurn;
|
|
}
|
|
if (!stats.drawn) {
|
|
stats.drawn = true;
|
|
stats.turnDrawn = globalTurn;
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
|
|
Matcher cardDmgMatch = CARD_DAMAGE_PATTERN.matcher(line);
|
|
if (cardDmgMatch.find()) {
|
|
String cardName = cardDmgMatch.group(1).trim();
|
|
int amount = Integer.parseInt(cardDmgMatch.group(2));
|
|
for (int s = 0; s < decks.size(); s++) {
|
|
if (seatCards[s] != null && seatCards[s].containsKey(cardName)) {
|
|
seatCards[s].get(cardName).damageDealt += amount;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
for (int s = 0; s < result.playerResults.size(); s++) {
|
|
if (seatCards[s] == null) continue;
|
|
List<PerCardGameStats> cardStatsList = new ArrayList<>();
|
|
for (PerCardGameStats stats : seatCards[s].values()) {
|
|
stats.computeStuckInHand();
|
|
cardStatsList.add(stats);
|
|
}
|
|
result.playerResults.get(s).cardStats = cardStatsList;
|
|
}
|
|
}
|
|
|
|
private GameResult createFailedGame(int gameIndex, long gameSeed, long elapsedMs) {
|
|
GameResult result = new GameResult();
|
|
result.gameIndex = gameIndex;
|
|
result.gameSeed = gameSeed;
|
|
result.winningSeat = null;
|
|
result.totalTurns = 0;
|
|
result.winCondition = WinCondition.TIMEOUT.getLabel();
|
|
result.elapsedMs = elapsedMs;
|
|
result.playerResults = new ArrayList<>();
|
|
for (int i = 0; i < decks.size(); i++) {
|
|
PlayerResult pr = new PlayerResult();
|
|
pr.seatIndex = i;
|
|
pr.finalLife = 40;
|
|
pr.mulligans = 0;
|
|
pr.isWinner = false;
|
|
result.playerResults.add(pr);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
private String readCommanderCardName(String deckFilePath) {
|
|
try {
|
|
Path path = resolveExistingDeckPath(deckFilePath);
|
|
if (path == null) return null;
|
|
|
|
List<String> lines = Files.readAllLines(path);
|
|
boolean inCommanderSection = false;
|
|
Pattern cardLine = Pattern.compile("^(\\d+)\\s+(.+?)(?:\\|(.+))?$");
|
|
for (String line : lines) {
|
|
line = line.trim();
|
|
if (line.isEmpty() || line.startsWith("//")) continue;
|
|
if (line.startsWith("[") && line.endsWith("]")) {
|
|
inCommanderSection = line.equalsIgnoreCase("[Commander]");
|
|
continue;
|
|
}
|
|
if (line.startsWith("Name=")) continue;
|
|
if (inCommanderSection) {
|
|
Matcher m = cardLine.matcher(line);
|
|
if (m.matches()) {
|
|
return m.group(2).trim();
|
|
}
|
|
}
|
|
}
|
|
} catch (Exception e) {
|
|
System.err.println("[CMDR] Failed to read commander from .dck: " + deckFilePath + " - " + e.getMessage());
|
|
}
|
|
return null;
|
|
}
|
|
}
|