Files
commander-ai-lab/src/main/java/commanderailab/cli/LabCli.java
T

695 lines
31 KiB
Java

package commanderailab.cli;
import commanderailab.ai.*;
import commanderailab.analytics.DeckAnalyzer;
import commanderailab.analytics.DeckAnalyzer.DeckAnalysis;
import commanderailab.batch.*;
import commanderailab.db.LabDatabase;
import commanderailab.meta.*;
import commanderailab.meta.DeckProfile.DeckSource;
import commanderailab.schema.*;
import commanderailab.schema.BatchResult.*;
import commanderailab.stats.StatsAggregator;
import picocli.CommandLine;
import picocli.CommandLine.Command;
import picocli.CommandLine.Option;
import java.io.File;
import java.nio.file.*;
import java.time.Instant;
import java.util.*;
import java.util.concurrent.Callable;
/**
* LabCli — Command-line entry point for Commander AI Lab v3.
*
* v3 adds:
* --import-url Import deck from URL (Archidekt, EDHREC)
* --import-text Import deck from text file (card list)
* --commander-meta Use EDHREC average deck for a commander name
* --meta-file Custom commander→meta mapping JSON
* --import-to Directory to save imported .dck files
*/
@Command(
name = "commander-ai-lab",
mixinStandardHelpOptions = true,
version = "Commander AI Lab v3.0.0",
description = "Headless batch simulation for 3-AI MTG Commander pods with deck import & meta lookup."
)
public class LabCli implements Callable<Integer> {
@Option(names = {"--forge-jar", "-F"}, required = true,
description = "Path to Forge desktop JAR (forge-gui-desktop-XXX-jar-with-dependencies.jar)")
private String forgeJarPath;
@Option(names = {"--forge-dir", "-W"}, required = true,
description = "Working directory for Forge (folder containing res/ — usually forge-gui/)")
private String forgeWorkDir;
@Option(names = {"--deck1", "-d1"}, defaultValue = "",
description = "Commander deck name for seat 0 (as it appears in Forge's deck list)")
private String deck1;
@Option(names = {"--deck2", "-d2"}, defaultValue = "",
description = "Commander deck name for seat 1")
private String deck2;
@Option(names = {"--deck3", "-d3"}, defaultValue = "",
description = "Commander deck name for seat 2")
private String deck3;
@Option(names = {"--deck4", "-d4"}, defaultValue = "",
description = "Commander deck name for seat 3")
private String deck4;
@Option(names = {"--commander1", "-c1"}, defaultValue = "",
description = "Commander card name for seat 0 (for display; uses deck name if blank)")
private String commander1;
@Option(names = {"--commander2", "-c2"}, defaultValue = "",
description = "Commander card name for seat 1")
private String commander2;
@Option(names = {"--commander3", "-c3"}, defaultValue = "",
description = "Commander card name for seat 2")
private String commander3;
@Option(names = {"--commander4", "-c4"}, defaultValue = "",
description = "Commander card name for seat 3")
private String commander4;
@Option(names = {"--games", "-n"}, defaultValue = "10",
description = "Number of games to simulate (default: 10)")
private int numGames;
@Option(names = {"--threads", "-t"}, defaultValue = "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 = "",
description = "Master RNG seed for reproducibility (blank for random)")
private String seed;
@Option(names = {"--output", "-o"}, defaultValue = "results/batch-latest.json",
description = "Output JSON file path")
private String outputPath;
@Option(names = {"--clock", "-c"}, defaultValue = "120",
description = "Max seconds per game before draw (default: 120)")
private int clockSeconds;
@Option(names = {"--verbose", "-v"}, defaultValue = "false",
description = "Show full Forge output (not just parsed results)")
private boolean verbose;
// ── v2 Options ──────────────────────────────────────────────
@Option(names = {"--profile", "-p"}, defaultValue = "default",
description = "AI behavior profile: default, aggro, control, combo, midrange")
private String profileName;
@Option(names = {"--analyze"}, defaultValue = "false",
description = "Run deck analysis before batch (shows mana curve, roles, health checks)")
private boolean analyzeDeck;
@Option(names = {"--db"}, defaultValue = "lab.db",
description = "SQLite database file for result persistence (default: lab.db)")
private String dbPath;
@Option(names = {"--no-db"}, defaultValue = "false",
description = "Disable SQLite persistence (results only saved to JSON file)")
private boolean noDb;
@Option(names = {"--decks-dir"}, defaultValue = "",
description = "Path to Commander decks folder (for --analyze, default: %%APPDATA%%/Forge/decks/commander)")
private String decksDir;
// ── v3 Options — Deck Import & Meta ─────────────────────────
@Option(names = {"--import-url"},
description = "Import deck from URL (Archidekt, EDHREC). Can specify 1-3 times for each seat.")
private List<String> importUrls;
@Option(names = {"--import-text"},
description = "Import deck from text file (card list). Can specify 1-3 times for each seat.")
private List<String> importTextFiles;
@Option(names = {"--commander-meta"},
description = "Fetch EDHREC average deck by commander name. Can specify 1-3 times for each seat.")
private List<String> commanderMeta;
@Option(names = {"--meta-file"}, defaultValue = "commander-meta.json",
description = "Path to commander→meta deck mapping JSON (default: commander-meta.json)")
private String metaFile;
@Option(names = {"--import-to"}, defaultValue = "",
description = "Directory to save imported .dck files (default: Forge decks/commander dir)")
private String importTo;
@Option(names = {"--list-meta"}, defaultValue = "false",
description = "List available commanders in the meta mapping and exit")
private boolean listMeta;
// ── v4 Options — ML Decision Logging ─────────────────────────
@Option(names = {"--ml-log"}, defaultValue = "false",
description = "Enable ML decision logging: extract state+action snapshots for RL training")
private boolean mlLog;
// ── v5 Options — Learned Policy ─────────────────────────────
@Option(names = {"--learned-policy"}, defaultValue = "false",
description = "Use trained neural network policy (requires running lab API server with model loaded)")
private boolean useLearnedPolicy;
@Option(names = {"--policy-server"}, defaultValue = "http://localhost:8080",
description = "Policy server URL (default: http://localhost:8080)")
private String policyServerUrl;
@Option(names = {"--policy-style"}, defaultValue = "midrange",
description = "Playstyle hint for learned policy: aggro, control, midrange, combo")
private String policyStyle;
@Option(names = {"--policy-greedy"}, defaultValue = "true",
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;
@Override
public Integer call() throws Exception {
System.out.println("╔══════════════════════════════════════════════════╗");
System.out.println("║ Commander AI Lab v3.0.0 ║");
System.out.println("║ 3-AI Headless Batch Sim + Deck Import/Meta ║");
System.out.println("╚══════════════════════════════════════════════════╝");
System.out.println();
// Load meta mapping
CommanderMetaService.loadMapping(metaFile);
// Handle --list-meta
if (listMeta) {
printMetaList();
return 0;
}
// Validate Forge paths
if (!new File(forgeJarPath).exists()) {
System.err.println("ERROR: Forge JAR not found: " + forgeJarPath);
return 1;
}
if (!new File(forgeWorkDir).isDirectory()) {
System.err.println("ERROR: Forge work directory not found: " + forgeWorkDir);
return 1;
}
// Resolve import-to directory
String importDir = resolveImportDir();
// Build deck info (with possible imports)
List<DeckInfo> decks = buildDeckInfoList(importDir);
if (decks == null) return 1; // Error already printed
System.out.printf("Pod: %s vs %s vs %s%n",
decks.get(0).deckName, decks.get(1).deckName, decks.get(2).deckName);
for (DeckInfo d : decks) {
if (d.source != null && !d.source.isEmpty()) {
System.out.printf(" Seat %d: %s → %s (%s)%n",
d.seatIndex, d.commanderName, d.source, d.sourceUrl != null ? d.sourceUrl : "");
}
}
System.out.printf("Games: %d%n", numGames);
// 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);
if (masterSeed != null) {
System.out.printf("Seed: %d%n", masterSeed);
}
System.out.println();
// Choose AI policy and profile
AiProfile profile = AiProfile.byName(profileName);
AiPolicy policy;
if (useLearnedPolicy) {
commanderailab.ml.LearnedPolicy learned =
new commanderailab.ml.LearnedPolicy(policyServerUrl, policyStyle, policyGreedy);
if (learned.connect()) {
policy = learned;
System.out.printf("AI Policy: learned-policy (server=%s, style=%s, greedy=%s)%n",
policyServerUrl, policyStyle, policyGreedy);
} else {
System.out.println("[ML] Learned policy unavailable — falling back to Forge built-in AI");
policy = new ForgeBuiltinPolicy();
}
} else {
policy = new ForgeBuiltinPolicy();
}
System.out.printf("AI Policy: %s%n", policy.getName());
System.out.printf("AI Profile: %s — %s%n", profile.getName(), profile.getDescription());
System.out.printf("Engine: Forge sim (headless)%n");
System.out.println();
// v2: Deck analysis (if requested)
if (analyzeDeck) {
String resolvedDecksDir = decksDir.isEmpty() ? resolveForgeDecksDir() : decksDir;
if (resolvedDecksDir != null && !resolvedDecksDir.isEmpty()) {
System.out.println("═══ DECK ANALYSIS ═══");
for (DeckInfo deck : decks) {
try {
DeckAnalysis analysis = DeckAnalyzer.analyzeFromForge(resolvedDecksDir, deck.deckName);
DeckAnalyzer.printSummary(analysis);
System.out.println();
} catch (Exception e) {
System.out.printf(" [WARN] Could not analyze %s: %s%n", deck.deckName, e.getMessage());
}
}
} else {
System.out.println(" [WARN] --decks-dir not set and auto-detect failed. Skipping deck analysis.");
}
}
// Run simulations
long startTime = System.currentTimeMillis();
List<GameResult> games;
// Always run verbose (quiet=false) to capture combat stats from Forge game log.
// The --verbose flag now controls whether full output is echoed to console,
// but the parser always gets the full log for stat extraction.
boolean quietMode = false; // Never quiet — we need verbose output for combat stats
// Resolve Java 17 path for Forge subprocess
String resolvedJava = resolveJava17Path();
System.out.printf("Java: %s%n", resolvedJava);
// 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, 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) {
String batchId = UUID.randomUUID().toString().substring(0, 8);
runner.enableMlLogging("results", batchId);
System.out.println("[ML] Decision logging enabled — data will be saved to results/");
}
games = runner.runBatchSingleThread(numGames, masterSeed);
// Close ML logger
if (mlLog) {
runner.closeMlLogger();
}
}
long elapsedMs = System.currentTimeMillis() - startTime;
// Compute stats
Summary summary = StatsAggregator.computeSummary(games, decks, elapsedMs);
// Build full BatchResult
BatchResult result = new BatchResult();
result.metadata = new Metadata();
result.metadata.batchId = UUID.randomUUID().toString();
result.metadata.timestamp = Instant.now().toString();
result.metadata.totalGames = numGames;
result.metadata.completedGames = games.size();
result.metadata.engineVersion = "forge-2.0.12-SNAPSHOT";
result.metadata.masterSeed = masterSeed;
result.metadata.threads = threads;
result.metadata.elapsedMs = elapsedMs;
result.decks = decks;
result.games = games;
result.summary = summary;
// Export JSON
JsonExporter.writeToFile(result, outputPath);
System.out.println();
// Print summary to console
printSummary(result);
// Validate output
String json = JsonExporter.toJson(result);
boolean valid = JsonExporter.validateBasicStructure(json);
System.out.printf("%nJSON validation: %s%n", valid ? "PASSED" : "FAILED");
System.out.printf("Output written to: %s%n", outputPath);
// v2: Save to SQLite
if (!noDb) {
try (LabDatabase db = new LabDatabase(dbPath)) {
db.saveBatchResult(result);
System.out.printf("Saved to database: %s%n", dbPath);
// Also save deck analyses if we ran them
if (analyzeDeck) {
String resolvedDecksDir = decksDir.isEmpty() ? resolveForgeDecksDir() : decksDir;
if (resolvedDecksDir != null) {
for (DeckInfo deck : decks) {
try {
DeckAnalysis analysis = DeckAnalyzer.analyzeFromForge(resolvedDecksDir, deck.deckName);
db.saveDeckAnalysis(analysis);
} catch (Exception ignore) { }
}
}
}
// Print global stats
var globalStats = db.getGlobalStats();
System.out.printf("%n═══ LIFETIME STATS ═══%n");
System.out.printf(" Total Batches: %s | Total Games: %s | Unique Decks: %s%n",
globalStats.get("totalBatches"),
globalStats.get("totalGames"),
globalStats.get("uniqueDecks"));
} catch (Exception e) {
System.err.printf("WARNING: Database save failed: %s%n", e.getMessage());
}
}
return 0;
}
// ══════════════════════════════════════════════════════════
// Deck Info Construction (v3 — supports import)
// ══════════════════════════════════════════════════════════
private List<DeckInfo> buildDeckInfoList(String importDir) throws Exception {
String[] deckNames = {deck1, deck2, deck3, deck4};
String[] commanders = {commander1, commander2, commander3, commander4};
// Build import lists for each seat
DeckProfile[] imports = new DeckProfile[4];
// Process --commander-meta flags
if (commanderMeta != null) {
for (int i = 0; i < Math.min(commanderMeta.size(), 4); i++) {
String cmdrName = commanderMeta.get(i);
System.out.printf("═══ IMPORT SEAT %d: Commander Meta — %s ═══%n", i, cmdrName);
imports[i] = DeckImporter.importFromEdhrec(cmdrName);
}
}
// Process --import-url flags (overrides commander-meta for same seat)
if (importUrls != null) {
for (int i = 0; i < Math.min(importUrls.size(), 4); i++) {
if (imports[i] != null) continue; // Don't override if already set
String url = importUrls.get(i);
int seat = findNextEmptySeat(imports);
if (seat < 0) break;
System.out.printf("═══ IMPORT SEAT %d: URL — %s ═══%n", seat, url);
imports[seat] = DeckImporter.importFromUrl(url);
}
}
// Process --import-text flags
if (importTextFiles != null) {
for (int i = 0; i < Math.min(importTextFiles.size(), 4); i++) {
String filePath = importTextFiles.get(i);
int seat = findNextEmptySeat(imports);
if (seat < 0) break;
System.out.printf("═══ IMPORT SEAT %d: Text File — %s ═══%n", seat, filePath);
String text = Files.readString(Path.of(filePath));
imports[seat] = DeckImporter.importFromText(text, null);
}
}
// Save imported decks to .dck files and set deck names
for (int i = 0; i < 4; i++) {
if (imports[i] != null) {
DeckProfile profile = imports[i];
Path dckPath = DeckImporter.saveToDckFile(profile, importDir);
String savedName = dckPath.getFileName().toString().replace(".dck", "");
deckNames[i] = savedName;
if (profile.commander != null) {
commanders[i] = profile.commander;
}
System.out.printf(" Seat %d → saved as %s%n", i, dckPath);
System.out.println();
}
}
// Count how many seats have decks (support 3 or 4 player games)
int numSeats = 0;
for (int i = 0; i < 4; i++) {
if (deckNames[i] != null && !deckNames[i].isEmpty()) {
numSeats = i + 1;
}
}
if (numSeats < 3) {
System.err.println("ERROR: At least 3 decks are required. Use --deck1, --deck2, --deck3 (and optionally --deck4).");
return null;
}
// Validate all seats up to numSeats have decks
for (int i = 0; i < numSeats; i++) {
if (deckNames[i] == null || deckNames[i].isEmpty()) {
System.err.printf("ERROR: Seat %d has no deck. Use --deck%d, --import-url, --import-text, or --commander-meta.%n", i, i + 1);
return null;
}
}
// Build DeckInfo list
List<DeckInfo> list = new ArrayList<>();
for (int i = 0; i < numSeats; i++) {
DeckInfo d = new DeckInfo();
d.seatIndex = i;
d.deckFile = deckNames[i];
d.deckName = deckNames[i];
d.commanderName = commanders[i].isEmpty() ? deckNames[i] : commanders[i];
d.colorIdentity = List.of();
d.cardCount = 100;
// v3: Populate source metadata from import
if (imports[i] != null) {
DeckProfile p = imports[i];
d.source = p.source != null ? p.source.label() : null;
d.sourceUrl = p.sourceUrl;
d.archetype = p.archetype;
d.colorIdentity = p.colorIdentity != null ? p.colorIdentity : List.of();
d.cardCount = p.totalCards;
if (p.sampleSize != null) d.sampleSize = p.sampleSize;
d.importedAt = p.importedAt;
}
list.add(d);
}
return list;
}
private int findNextEmptySeat(DeckProfile[] imports) {
for (int i = 0; i < imports.length; i++) {
if (imports[i] == null) return i;
}
return -1;
}
// ══════════════════════════════════════════════════════════
// Helpers
// ══════════════════════════════════════════════════════════
private String resolveImportDir() {
if (!importTo.isEmpty()) return importTo;
// Try Forge decks/commander dir
String decksDir = resolveForgeDecksDir();
if (decksDir != null) return decksDir;
// Fallback to local directory
return "imported-decks";
}
private void printMetaList() {
System.out.println("═══ AVAILABLE COMMANDERS (Meta Mapping) ═══");
System.out.println();
for (String cmdr : CommanderMetaService.getAllCommanders()) {
var entries = CommanderMetaService.getMetaDecks(cmdr);
for (var entry : entries) {
System.out.printf(" %-35s %-20s %s%n",
cmdr,
entry.archetype != null ? entry.archetype : "",
entry.source);
}
}
System.out.printf("%n Total: %d commanders%n", CommanderMetaService.getAllCommanders().size());
System.out.println(" Use --commander-meta \"Commander Name\" to fetch the EDHREC average deck.");
}
private void printSummary(BatchResult result) {
System.out.println("╔══════════════════════════════════════════════════════════╗");
System.out.println("║ BATCH RESULTS ║");
System.out.println("╠══════════════════════════════════════════════════════════╣");
Summary s = result.summary;
for (DeckSummary ds : s.perDeck) {
DeckInfo deckInfo = result.decks.get(ds.seatIndex);
System.out.printf("║ Seat %d: %-40s ║%n", ds.seatIndex, ds.deckName);
if (deckInfo.source != null && !deckInfo.source.isEmpty()) {
System.out.printf("║ Source: %-36s ║%n", deckInfo.source);
}
System.out.printf("║ Win Rate: %5.1f%% (%d W / %d L / %d D)%n",
ds.winRate * 100, ds.wins, ds.losses, ds.draws);
System.out.printf("║ Avg Turns: %5s%n",
ds.avgTurnsToWin != null ? String.format("%.1f", ds.avgTurnsToWin) : "N/A");
System.out.printf("║ Avg Mulligan: %5.2f%n", ds.avgMulligans);
System.out.printf("║ Avg Life: %5.1f%n", ds.avgFinalLife);
if (ds.winConditionBreakdown != null && ds.wins > 0) {
WinConditionBreakdown b = ds.winConditionBreakdown;
System.out.printf("║ Win By: Combat=%d CmdrDmg=%d Combo=%d Drain=%d Mill=%d%n",
b.combat_damage, b.commander_damage, b.combo_alt_win, b.life_drain, b.mill);
}
System.out.println("║");
}
System.out.println("╠══════════════════════════════════════════════════════════╣");
System.out.printf("║ Games: %-5d Avg Turns: %-6.1f Sims/sec: %-6.2f%n",
result.metadata.completedGames, s.avgGameTurns, s.simsPerSecond);
System.out.printf("║ Wall Time: %.1fs%n", result.metadata.elapsedMs / 1000.0);
System.out.println("╚══════════════════════════════════════════════════════════╝");
}
public static void main(String[] args) {
int exitCode = new CommandLine(new LabCli()).execute(args);
System.exit(exitCode);
}
/**
* Resolve the Java 17 executable path for running Forge subprocesses.
* Forge requires Java 17 — newer versions (25+) cause ExceptionInInitializerError.
*
* Priority: --java17 flag > auto-detect Adoptium > auto-detect Oracle > system "java"
*/
private String resolveJava17Path() {
// 1. Explicit --java17 flag
if (java17Path != null && !java17Path.isEmpty()) {
File f = new File(java17Path);
if (f.exists()) return f.getAbsolutePath();
System.err.println("WARNING: --java17 path not found: " + java17Path);
}
// 2. Auto-detect common Java 17 locations on Windows
String[] candidates = {
"C:\\Program Files\\Eclipse Adoptium\\jdk-17.0.18.8-hotspot\\bin\\java.exe",
"C:\\Program Files\\Java\\jdk-17\\bin\\java.exe",
"C:\\Program Files\\Eclipse Adoptium\\jdk-17\\bin\\java.exe",
};
// Also scan for any jdk-17* in Adoptium and Java dirs
String[] searchDirs = {
"C:\\Program Files\\Eclipse Adoptium",
"C:\\Program Files\\Java",
};
for (String dir : searchDirs) {
File d = new File(dir);
if (d.isDirectory()) {
File[] children = d.listFiles();
if (children != null) {
for (File child : children) {
if (child.isDirectory() && child.getName().startsWith("jdk-17")) {
File java = new File(child, "bin/java.exe");
if (java.exists()) return java.getAbsolutePath();
// Linux/Mac
java = new File(child, "bin/java");
if (java.exists()) return java.getAbsolutePath();
}
}
}
}
}
// 3. Check explicit candidate paths
for (String path : candidates) {
File f = new File(path);
if (f.exists()) return f.getAbsolutePath();
}
// 4. Fallback to system java (may be wrong version)
System.err.println("WARNING: Java 17 not found. Forge may crash on Java 25+.");
System.err.println(" Install Adoptium JDK 17 or use --java17 <path>");
return "java";
}
/**
* Auto-detect Forge Commander decks directory.
*/
private String resolveForgeDecksDir() {
// Windows: %APPDATA%\Forge\decks\commander
String appdata = System.getenv("APPDATA");
if (appdata != null) {
File candidate = new File(appdata, "Forge/decks/commander");
if (candidate.isDirectory()) return candidate.getAbsolutePath();
}
// Linux/Mac
String home = System.getProperty("user.home");
for (String path : new String[]{"/.forge/decks/commander", "/Forge/decks/commander"}) {
File candidate = new File(home + path);
if (candidate.isDirectory()) return candidate.getAbsolutePath();
}
return null;
}
}