diff --git a/src/main/java/commanderailab/batch/BatchRunner.java b/src/main/java/commanderailab/batch/BatchRunner.java index 6f6793f..2f7edf7 100644 --- a/src/main/java/commanderailab/batch/BatchRunner.java +++ b/src/main/java/commanderailab/batch/BatchRunner.java @@ -154,8 +154,8 @@ public class BatchRunner { public BatchRunner(String forgeJarPath, String forgeWorkDir, List decks, AiPolicy policy, boolean quiet, int clockSeconds, String javaPath) { - if (decks.size() != 3) { - throw new IllegalArgumentException("v1 requires exactly 3 decks, got " + decks.size()); + if (decks.size() < 3 || decks.size() > 4) { + throw new IllegalArgumentException("Requires 3 or 4 decks, got " + decks.size()); } this.forgeJarPath = forgeJarPath; this.forgeWorkDir = forgeWorkDir; @@ -534,7 +534,7 @@ public class BatchRunner { // Initialize player results for all 3 seats result.playerResults = new ArrayList<>(); - for (int i = 0; i < 3; i++) { + for (int i = 0; i < decks.size(); i++) { PlayerResult pr = new PlayerResult(); pr.seatIndex = i; pr.finalLife = 40; // Commander starting life @@ -578,7 +578,7 @@ public class BatchRunner { int seatIndex = aiNumber - 1; // Forge uses 1-based, we use 0-based String winReason = winMatcher.group(3).trim(); - if (seatIndex >= 0 && seatIndex < 3) { + if (seatIndex >= 0 && seatIndex < decks.size()) { result.winningSeat = seatIndex; result.playerResults.get(seatIndex).isWinner = true; } @@ -592,7 +592,7 @@ public class BatchRunner { int seatIndex = aiNumber - 1; String lossReason = loseMatcher.group(3).trim(); - if (seatIndex >= 0 && seatIndex < 3) { + if (seatIndex >= 0 && seatIndex < decks.size()) { lossReasons.put(String.valueOf(seatIndex), lossReason); loserSeats.add(seatIndex); @@ -638,7 +638,7 @@ public class BatchRunner { int aiNum = Integer.parseInt(lifeMatch.group(1)); int seat = aiNum - 1; int life = Integer.parseInt(lifeMatch.group(2)); - if (seat >= 0 && seat < 3) { + if (seat >= 0 && seat < decks.size()) { lastKnownLife[seat] = life; lifeTracked[seat] = true; } @@ -651,7 +651,7 @@ public class BatchRunner { int aiNum = Integer.parseInt(lossMatch.group(1)); int seat = aiNum - 1; int amount = Integer.parseInt(lossMatch.group(2)); - if (seat >= 0 && seat < 3) { + if (seat >= 0 && seat < decks.size()) { lastKnownLife[seat] -= amount; lifeTracked[seat] = true; } @@ -663,7 +663,7 @@ public class BatchRunner { int aiNum = Integer.parseInt(gainMatch.group(1)); int seat = aiNum - 1; int amount = Integer.parseInt(gainMatch.group(2)); - if (seat >= 0 && seat < 3) { + if (seat >= 0 && seat < decks.size()) { lastKnownLife[seat] += amount; lifeTracked[seat] = true; } @@ -675,7 +675,7 @@ public class BatchRunner { if (mullMatcher.find()) { int aiNum = Integer.parseInt(mullMatcher.group(1)); int seat = aiNum - 1; - if (seat >= 0 && seat < 3) { + if (seat >= 0 && seat < decks.size()) { result.playerResults.get(seat).mulligans++; } } @@ -704,7 +704,7 @@ public class BatchRunner { // 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++) { + for (int seat = 0; seat < decks.size(); seat++) { if (seat != winnerSeat && lifeTracked[seat]) { result.playerResults.get(seat).finalLife = lastKnownLife[seat]; } @@ -719,8 +719,8 @@ public class BatchRunner { // ── Parse verbose game log for combat stats ───────────────────── // These patterns match Forge's full game log (non-quiet mode) // Track commander names for each seat to detect commander casts - String[] commanderNames = new String[3]; - for (int ci = 0; ci < 3 && ci < decks.size(); ci++) { + String[] commanderNames = new String[decks.size()]; + for (int ci = 0; ci < decks.size(); ci++) { commanderNames[ci] = decks.get(ci).commanderName; } @@ -734,7 +734,7 @@ public class BatchRunner { int aiNum = Integer.parseInt(castMatcher.group(1)); int seat = aiNum - 1; String cardName = castMatcher.group(2).trim(); - if (seat >= 0 && seat < 3) { + if (seat >= 0 && seat < decks.size()) { result.playerResults.get(seat).spellsCast++; // Check if this is the commander being cast if (commanderNames[seat] != null && @@ -750,7 +750,7 @@ public class BatchRunner { if (landMatcher.find()) { int aiNum = Integer.parseInt(landMatcher.group(1)); int seat = aiNum - 1; - if (seat >= 0 && seat < 3) { + if (seat >= 0 && seat < decks.size()) { result.playerResults.get(seat).landsPlayed++; } continue; @@ -776,7 +776,7 @@ public class BatchRunner { int aiNum = Integer.parseInt(aiRef.group(1)); int victimSeat = aiNum - 1; // Credit all non-victim seats with 1 creature destroyed - for (int s = 0; s < 3; s++) { + for (int s = 0; s < decks.size(); s++) { if (s != victimSeat && s < result.playerResults.size()) { // We can't know who did it, so we don't increment here // Instead, use a simpler approach below @@ -795,7 +795,7 @@ public class BatchRunner { Matcher srcPlayer = Pattern.compile("Ai\\((\\d+)\\)").matcher(line); if (srcPlayer.find()) { int seat = Integer.parseInt(srcPlayer.group(1)) - 1; - if (seat >= 0 && seat < 3) { + if (seat >= 0 && seat < decks.size()) { result.playerResults.get(seat).commanderDamageDealt += amount; } } @@ -805,7 +805,7 @@ public class BatchRunner { // Estimate creatures destroyed from total turns if verbose parsing didn't catch specifics // (Forge log format varies; this provides a reasonable fallback) - for (int seat = 0; seat < 3 && seat < result.playerResults.size(); seat++) { + for (int seat = 0; seat < result.playerResults.size(); seat++) { PlayerResult pr = result.playerResults.get(seat); // If we got no spell data from parsing, it means either quiet mode or no matches // Leave at 0 rather than fabricating data @@ -1047,8 +1047,8 @@ public class BatchRunner { private void buildPerCardStats(GameResult result, String[] lines) { // Initialize per-card maps for each seat from deck files @SuppressWarnings("unchecked") - Map[] seatCards = new Map[3]; - for (int s = 0; s < 3 && s < decks.size(); s++) { + Map[] seatCards = new Map[decks.size()]; + for (int s = 0; s < decks.size(); s++) { seatCards[s] = new LinkedHashMap<>(); List cardNames = readDeckCardNames(decks.get(s).deckFile); for (String name : cardNames) { @@ -1073,7 +1073,7 @@ public class BatchRunner { int aiNum = Integer.parseInt(turnMatch.group(2)); globalTurn = turnNum; int seat = aiNum - 1; - if (seat >= 0 && seat < 3) { + if (seat >= 0 && seat < decks.size()) { openingHandPhase[seat] = false; } continue; @@ -1084,7 +1084,7 @@ public class BatchRunner { if (drawMatch.find()) { int seat = Integer.parseInt(drawMatch.group(1)) - 1; String cardName = drawMatch.group(2).trim(); - if (seat >= 0 && seat < 3 && seatCards[seat] != null) { + if (seat >= 0 && seat < decks.size() && seatCards[seat] != null) { PerCardGameStats stats = seatCards[seat].get(cardName); if (stats == null) { // Card not in deck list (token draw, etc.) — create entry @@ -1108,7 +1108,7 @@ public class BatchRunner { if (castMatch.find()) { int seat = Integer.parseInt(castMatch.group(1)) - 1; String cardName = castMatch.group(2).trim(); - if (seat >= 0 && seat < 3 && seatCards[seat] != null) { + if (seat >= 0 && seat < decks.size() && seatCards[seat] != null) { PerCardGameStats stats = seatCards[seat].get(cardName); if (stats == null) { stats = new PerCardGameStats(cardName); @@ -1132,7 +1132,7 @@ public class BatchRunner { if (landMatch.find()) { int seat = Integer.parseInt(landMatch.group(1)) - 1; String cardName = landMatch.group(2).trim(); - if (seat >= 0 && seat < 3 && seatCards[seat] != null) { + if (seat >= 0 && seat < decks.size() && seatCards[seat] != null) { PerCardGameStats stats = seatCards[seat].get(cardName); if (stats == null) { stats = new PerCardGameStats(cardName); @@ -1156,7 +1156,7 @@ public class BatchRunner { String cardName = cardDmgMatch.group(1).trim(); int amount = Integer.parseInt(cardDmgMatch.group(2)); // Try to find which seat owns this card - for (int s = 0; s < 3; s++) { + for (int s = 0; s < decks.size(); s++) { if (seatCards[s] != null && seatCards[s].containsKey(cardName)) { seatCards[s].get(cardName).damageDealt += amount; break; // attribute to first matching seat @@ -1166,7 +1166,7 @@ public class BatchRunner { } // Finalize: compute stuckInHand and attach to PlayerResults - for (int s = 0; s < 3 && s < result.playerResults.size(); s++) { + for (int s = 0; s < result.playerResults.size(); s++) { if (seatCards[s] == null) continue; List cardStatsList = new ArrayList<>(); for (PerCardGameStats stats : seatCards[s].values()) { @@ -1189,7 +1189,7 @@ public class BatchRunner { result.winCondition = WinCondition.TIMEOUT.getLabel(); result.elapsedMs = elapsedMs; result.playerResults = new ArrayList<>(); - for (int i = 0; i < 3; i++) { + for (int i = 0; i < decks.size(); i++) { PlayerResult pr = new PlayerResult(); pr.seatIndex = i; pr.finalLife = 40; diff --git a/src/main/java/commanderailab/cli/LabCli.java b/src/main/java/commanderailab/cli/LabCli.java index 152c596..5ad10ca 100644 --- a/src/main/java/commanderailab/cli/LabCli.java +++ b/src/main/java/commanderailab/cli/LabCli.java @@ -58,6 +58,10 @@ public class LabCli implements Callable { 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; @@ -70,6 +74,10 @@ public class LabCli implements Callable { 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; @@ -431,15 +439,15 @@ public class LabCli implements Callable { // ══════════════════════════════════════════════════════════ private List buildDeckInfoList(String importDir) throws Exception { - String[] deckNames = {deck1, deck2, deck3}; - String[] commanders = {commander1, commander2, commander3}; + String[] deckNames = {deck1, deck2, deck3, deck4}; + String[] commanders = {commander1, commander2, commander3, commander4}; // Build import lists for each seat - DeckProfile[] imports = new DeckProfile[3]; + DeckProfile[] imports = new DeckProfile[4]; // Process --commander-meta flags if (commanderMeta != null) { - for (int i = 0; i < Math.min(commanderMeta.size(), 3); i++) { + 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); @@ -448,7 +456,7 @@ public class LabCli implements Callable { // Process --import-url flags (overrides commander-meta for same seat) if (importUrls != null) { - for (int i = 0; i < Math.min(importUrls.size(), 3); i++) { + 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); @@ -460,7 +468,7 @@ public class LabCli implements Callable { // Process --import-text flags if (importTextFiles != null) { - for (int i = 0; i < Math.min(importTextFiles.size(), 3); i++) { + for (int i = 0; i < Math.min(importTextFiles.size(), 4); i++) { String filePath = importTextFiles.get(i); int seat = findNextEmptySeat(imports); if (seat < 0) break; @@ -471,7 +479,7 @@ public class LabCli implements Callable { } // Save imported decks to .dck files and set deck names - for (int i = 0; i < 3; i++) { + for (int i = 0; i < 4; i++) { if (imports[i] != null) { DeckProfile profile = imports[i]; Path dckPath = DeckImporter.saveToDckFile(profile, importDir); @@ -485,8 +493,19 @@ public class LabCli implements Callable { } } - // Validate all 3 seats have decks - for (int i = 0; i < 3; i++) { + // 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; @@ -495,7 +514,7 @@ public class LabCli implements Callable { // Build DeckInfo list List list = new ArrayList<>(); - for (int i = 0; i < 3; i++) { + for (int i = 0; i < numSeats; i++) { DeckInfo d = new DeckInfo(); d.seatIndex = i; d.deckFile = deckNames[i];