diff --git a/forge-gui-desktop/pom.xml b/forge-gui-desktop/pom.xml
index e1f3e4339eee..a9957fc92b0d 100644
--- a/forge-gui-desktop/pom.xml
+++ b/forge-gui-desktop/pom.xml
@@ -318,6 +318,11 @@ try {
forge-gui
${project.version}
+
+ forge
+ forge-headless
+ ${project.version}
+
com.miglayout
miglayout-swing
diff --git a/forge-gui-desktop/src/main/java/forge/view/SimulateMatch.java b/forge-gui-desktop/src/main/java/forge/view/SimulateMatch.java
index aa3d6ae022a7..7705dbdb69e5 100644
--- a/forge-gui-desktop/src/main/java/forge/view/SimulateMatch.java
+++ b/forge-gui-desktop/src/main/java/forge/view/SimulateMatch.java
@@ -1,403 +1,30 @@
package forge.view;
-import java.io.File;
-import java.util.*;
-import java.util.concurrent.TimeUnit;
-import java.util.concurrent.TimeoutException;
-
-import org.apache.commons.lang3.time.StopWatch;
-
-import forge.LobbyPlayer;
-import forge.ai.AiProfileUtil;
-import forge.deck.Deck;
-import forge.deck.DeckGroup;
-import forge.deck.io.DeckSerializer;
-import forge.game.Game;
-import forge.game.GameEndReason;
-import forge.game.GameLogEntry;
-import forge.game.GameLogEntryType;
-import forge.game.GameRules;
-import forge.game.GameType;
import forge.game.Match;
-import forge.game.player.RegisteredPlayer;
-import forge.gamemodes.tournament.system.AbstractTournament;
-import forge.gamemodes.tournament.system.TournamentBracket;
-import forge.gamemodes.tournament.system.TournamentPairing;
-import forge.gamemodes.tournament.system.TournamentPlayer;
-import forge.gamemodes.tournament.system.TournamentRoundRobin;
-import forge.gamemodes.tournament.system.TournamentSwiss;
-import forge.localinstance.properties.ForgeConstants;
-import forge.model.FModel;
-import forge.player.GamePlayerUtil;
-import forge.util.Lang;
-import forge.util.MyRandom;
-import forge.util.TextUtil;
-import forge.util.WordUtil;
-import forge.util.storage.IStorage;
-
-public class SimulateMatch {
- public static void simulate(String[] args) {
- FModel.initialize(null, null);
-
- System.out.println("Simulation mode");
- if (args.length < 4) {
- argumentHelp();
- return;
- }
-
- final Map> params = new HashMap<>();
- List options = null;
-
- for (int i = 1; i < args.length; i++) {
- // "sim" is in the 0th slot
- final String a = args[i];
-
- if (a.charAt(0) == '-') {
- if (a.length() < 2) {
- System.err.println("Error at argument " + a);
- argumentHelp();
- return;
- }
-
- options = new ArrayList<>();
- params.put(a.substring(1), options);
- } else if (options != null) {
- options.add(a);
- } else {
- System.err.println("Illegal parameter usage");
- return;
- }
- }
-
- int nGames = 1;
- if (params.containsKey("n")) {
- // Number of games should only be a single string
- nGames = Integer.parseInt(params.get("n").get(0));
- }
-
- int matchSize = 0;
- if (params.containsKey("m")) {
- // Match size ("best of X games")
- matchSize = Integer.parseInt(params.get("m").get(0));
- }
-
- boolean outputGamelog = !params.containsKey("q");
-
- Long seed = null;
- if (params.containsKey("s")) {
- seed = Long.parseLong(params.get("s").get(0));
- MyRandom.setRandom(new Random(seed));
- }
-
- GameType type = GameType.Constructed;
- if (params.containsKey("f")) {
- type = GameType.valueOf(WordUtil.capitalize(params.get("f").get(0)));
- }
-
- GameRules rules = new GameRules(type);
- rules.setAppliedVariants(EnumSet.of(type));
-
- if (matchSize != 0) {
- rules.setGamesPerMatch(matchSize);
- }
-
- if (params.containsKey("t")) {
- simulateTournament(params, rules, outputGamelog);
- System.out.flush();
- return;
- }
-
- List pp = new ArrayList<>();
- StringBuilder sb = new StringBuilder();
-
- int i = 1;
-
- // Optional AI profile per player, in the same order as the decks. Lets a run pit one set of
- // AI settings against another, which is the only way to tell from the results whether an AI
- // change actually helped.
- List aiProfiles = params.get("a");
- if (aiProfiles != null) {
- for (String profile : aiProfiles) {
- if (!AiProfileUtil.getProfilesDisplayList().contains(profile)) {
- System.out.println(TextUtil.concatNoSpace("Unknown AI profile - ", profile,
- ". Available profiles: ", String.join(", ", AiProfileUtil.getProfilesDisplayList())));
- return;
- }
- }
- }
-
- if (params.containsKey("d")) {
- for (String deck : params.get("d")) {
- Deck d = deckFromCommandLineParameter(deck, type);
- if (d == null) {
- System.out.println(TextUtil.concatNoSpace("Could not load deck - ", deck, ", match cannot start"));
- return;
- }
- if (i > 1) {
- sb.append(" vs ");
- }
- String profile = aiProfiles != null && aiProfiles.size() >= i ? aiProfiles.get(i - 1) : "";
- String name = TextUtil.concatNoSpace("Ai(", String.valueOf(i), ")-", d.getName());
- sb.append(name);
- if (!profile.isEmpty()) {
- sb.append(" [").append(profile).append("]");
- }
-
- RegisteredPlayer rp;
-
- if (type.equals(GameType.Commander)) {
- rp = RegisteredPlayer.forCommander(d);
- } else {
- rp = new RegisteredPlayer(d);
- }
- rp.setPlayer(GamePlayerUtil.createAiPlayer(name, i - 1, profile));
- pp.add(rp);
- i++;
- }
- }
-
- if (params.containsKey("c")) {
- rules.setSimTimeout(Integer.parseInt(params.get("c").get(0)));
- }
-
- sb.append(" - ").append(Lang.nounWithNumeral(nGames, "game")).append(" of ").append(type);
- if (seed != null) {
- sb.append(" seed ").append(seed);
- }
-
- System.out.println(sb.toString());
-
- Match mc = new Match(rules, pp, "Test");
-
- if (matchSize != 0) {
- int iGame = 0;
- while (!mc.isMatchOver()) {
- // play games until the match ends
- simulateSingleMatch(mc, iGame, outputGamelog);
- iGame++;
- }
- } else {
- for (int iGame = 0; iGame < nGames; iGame++) {
- simulateSingleMatch(mc, iGame, outputGamelog);
- }
- }
-
- System.out.flush();
- }
-
- private static void argumentHelp() {
- System.out.println("Syntax: forge.exe sim -d ... -D [D] -n [N] -m [M] -t [T] -p [P] -f [F] -s [S] -a [A] -q");
- System.out.println("\tsim - stands for simulation mode");
- System.out.println("\tdeck1 (or deck2,...,X) - constructed deck name or filename (has to be quoted when contains multiple words)");
- System.out.println("\tdeck is treated as file if it ends with a dot followed by three numbers or letters");
- System.out.println("\tD - absolute directory to load decks from");
- System.out.println("\tN - number of games, defaults to 1 (Ignores match setting)");
- System.out.println("\tM - Play full match of X games, typically 1,3,5 games. (Optional, overrides N)");
- System.out.println("\tT - Type of tournament to run with all provided decks (Bracket, RoundRobin, Swiss)");
- System.out.println("\tP - Amount of players per match (used only with Tournaments, defaults to 2)");
- System.out.println("\tF - format of games, defaults to constructed");
- System.out.println("\tS - RNG seed for simulation");
- System.out.println("\tA - AI profile per player, in the same order as the decks (e.g. -a Default Experimental)");
- System.out.println("\tc - Clock flag. Set the maximum time in seconds before calling the match a draw, defaults to 120.");
- System.out.println("\tq - Quiet flag. Output just the game result, not the entire game log.");
- }
-
- public static void simulateSingleMatch(final Match mc, int iGame, boolean outputGamelog) {
- final StopWatch sw = new StopWatch();
- sw.start();
- final Game g1 = mc.createGame();
- // will run match in the same thread
- try {
- TimeLimitedCodeBlock.runWithTimeout(() -> {
- mc.startGame(g1);
- sw.stop();
- }, mc.getRules().getSimTimeout(), TimeUnit.SECONDS);
- } catch (TimeoutException e) {
- System.out.println("Stopping slow match as draw");
- } catch (Exception | StackOverflowError e) {
- e.printStackTrace();
- } finally {
- if (sw.isStarted()) {
- sw.stop();
- }
- g1.setGameOver(GameEndReason.Draw);
- }
-
- List log;
- if (outputGamelog) {
- log = g1.getGameLog().getLogEntries(null);
- } else {
- log = g1.getGameLog().getLogEntries(GameLogEntryType.MATCH_RESULTS);
- }
- Collections.reverse(log);
- for (GameLogEntry l : log) {
- System.out.println(l);
- }
-
- // If both players life totals to 0 in a single turn, the game should end in a draw
- if (g1.getOutcome().isDraw()) {
- System.out.printf("\nGame Result: Game %d ended in a Draw! Took %d ms.%n", 1 + iGame, sw.getTime());
- } else {
- System.out.printf("\nGame Result: Game %d ended in %d ms. %s has won!\n%n", 1 + iGame, sw.getTime(), g1.getOutcome().getWinningLobbyPlayer().getName());
- }
+/**
+ * Compatibility facade for the simulation runner, which now lives in the headless module.
+ *
+ * @deprecated Use {@link forge.headless.SimulateMatch}.
+ */
+@Deprecated
+public final class SimulateMatch {
+ private SimulateMatch() {
}
- private static void simulateTournament(Map> params, GameRules rules, boolean outputGamelog) {
- String tournament = params.get("t").get(0);
- AbstractTournament tourney = null;
- int matchPlayers = params.containsKey("p") ? Integer.parseInt(params.get("p").get(0)) : 2;
-
- DeckGroup deckGroup = new DeckGroup("SimulatedTournament");
- List players = new ArrayList<>();
- int numPlayers = 0;
- if (params.containsKey("d")) {
- for (String deck : params.get("d")) {
- Deck d = deckFromCommandLineParameter(deck, rules.getGameType());
- if (d == null) {
- System.out.println(TextUtil.concatNoSpace("Could not load deck - ", deck, ", match cannot start"));
- return;
- }
-
- deckGroup.addAiDeck(d);
- players.add(new TournamentPlayer(GamePlayerUtil.createAiPlayer(d.getName(), 0), numPlayers));
- numPlayers++;
- }
- }
-
- if (params.containsKey("D")) {
- // Direc
- String foldName = params.get("D").get(0);
- File folder = new File(foldName);
- if (!folder.isDirectory()) {
- System.out.println("Directory not found - " + foldName);
- } else {
- for (File deck : folder.listFiles((dir, name) -> name.endsWith(".dck"))) {
- Deck d = DeckSerializer.fromFile(deck);
- if (d == null) {
- System.out.println(TextUtil.concatNoSpace("Could not load deck - ", deck.getName(), ", match cannot start"));
- return;
- }
- deckGroup.addAiDeck(d);
- players.add(new TournamentPlayer(GamePlayerUtil.createAiPlayer(d.getName(), 0), numPlayers));
- numPlayers++;
- }
- }
- }
-
- if (numPlayers == 0) {
- System.out.println("No decks/Players found. Please try again.");
- }
-
- if ("bracket".equalsIgnoreCase(tournament)) {
- tourney = new TournamentBracket(players, matchPlayers);
- } else if ("roundrobin".equalsIgnoreCase(tournament)) {
- tourney = new TournamentRoundRobin(players, matchPlayers);
- } else if ("swiss".equalsIgnoreCase(tournament)) {
- tourney = new TournamentSwiss(players, matchPlayers);
- }
- if (tourney == null) {
- System.out.println("Failed to initialize tournament, bailing out");
- return;
- }
-
- tourney.initializeTournament();
-
- String lastWinner = "";
- int curRound = 0;
- System.out.println(TextUtil.concatNoSpace("Starting a ", tournament, " tournament with ",
- String.valueOf(numPlayers), " players over ",
- String.valueOf(tourney.getTotalRounds()), " rounds"));
- while (!tourney.isTournamentOver()) {
- if (tourney.getActiveRound() != curRound) {
- if (curRound != 0) {
- System.out.println(TextUtil.concatNoSpace("End Round - ", String.valueOf(curRound)));
- }
- curRound = tourney.getActiveRound();
- System.out.println();
- System.out.println(TextUtil.concatNoSpace("Round ", String.valueOf(curRound), " Pairings:"));
-
- for (TournamentPairing pairing : tourney.getActivePairings()) {
- System.out.println(pairing.outputHeader());
- }
- System.out.println();
- }
-
- TournamentPairing pairing = tourney.getNextPairing();
- List regPlayers = AbstractTournament.registerTournamentPlayers(pairing, deckGroup);
-
- StringBuilder sb = new StringBuilder();
- sb.append("Round ").append(tourney.getActiveRound()).append(" - ");
- sb.append(pairing.outputHeader());
- System.out.println(sb.toString());
-
- if (!pairing.isBye()) {
- Match mc = new Match(rules, regPlayers, "TourneyMatch");
-
- int exceptions = 0;
- int iGame = 0;
- while (!mc.isMatchOver()) {
- // play games until the match ends
- try {
- simulateSingleMatch(mc, iGame, outputGamelog);
- iGame++;
- } catch (Exception e) {
- exceptions++;
- System.out.println(e.toString());
- if (exceptions > 5) {
- System.out.println("Exceeded number of exceptions thrown. Abandoning match...");
- break;
- } else {
- System.out.println("Game threw exception. Abandoning game and continuing...");
- }
- }
-
- }
- LobbyPlayer winner = mc.getWinner().getPlayer();
- for (TournamentPlayer tp : pairing.getPairedPlayers()) {
- if (winner.equals(tp.getPlayer())) {
- pairing.setWinner(tp);
- lastWinner = winner.getName();
- System.out.println(TextUtil.concatNoSpace("Match Winner - ", lastWinner, "!"));
- System.out.println();
- break;
- }
- }
- }
-
- tourney.reportMatchCompletion(pairing);
- }
- tourney.outputTournamentResults();
- }
-
- public static Match simulateOffthreadGame(List decks, GameType format, int games) {
- return null;
+ /**
+ * @deprecated Use {@link forge.headless.SimulateMatch#simulate(String[])}.
+ */
+ @Deprecated
+ public static void simulate(String[] args) {
+ forge.headless.SimulateMatch.simulate(args);
}
- private static Deck deckFromCommandLineParameter(String deckname, GameType type) {
- int dotpos = deckname.lastIndexOf('.');
- if (dotpos > 0 && dotpos == deckname.length() - 4) {
- String baseDir = type.equals(GameType.Commander) ?
- ForgeConstants.DECK_COMMANDER_DIR : ForgeConstants.DECK_CONSTRUCTED_DIR;
-
- File f = new File(baseDir + deckname);
- if (!f.exists()) {
- System.out.println("No deck found in " + baseDir);
- }
-
- return DeckSerializer.fromFile(f);
- }
-
- IStorage deckStore = null;
-
- // Add other game types here...
- if (type.equals(GameType.Commander)) {
- deckStore = FModel.getDecks().getCommander();
- } else {
- deckStore = FModel.getDecks().getConstructed();
- }
-
- return deckStore.get(deckname);
+ /**
+ * @deprecated Use {@link forge.headless.SimulateMatch#simulateSingleMatch(Match, int, boolean)}.
+ */
+ @Deprecated
+ public static void simulateSingleMatch(Match match, int gameNumber, boolean outputGameLog) {
+ forge.headless.SimulateMatch.simulateSingleMatch(match, gameNumber, outputGameLog);
}
-
}
diff --git a/forge-headless/README.md b/forge-headless/README.md
new file mode 100644
index 000000000000..ede7f65ab352
--- /dev/null
+++ b/forge-headless/README.md
@@ -0,0 +1,31 @@
+# Forge Headless
+
+`forge-headless` runs Forge games without the desktop or mobile user interface. It supports:
+
+- normal Forge AI simulations through `sim`;
+- a terminal controller through `tui`; and
+- a lightweight random controller for simulation benchmarks.
+
+Build and test the module and its dependencies from the repository root:
+
+```sh
+mvn -pl forge-headless -am test
+mvn -pl forge-headless -am package -DskipTests
+```
+
+After packaging, the repository-level launcher selects the assembled headless JAR:
+
+```sh
+./headless.sh --help
+./headless.sh sim -d forge-headless/test_decks/monored.dck forge-headless/test_decks/monored.dck -n 1 -s 42
+./headless.sh tui forge-headless/test_decks/monored.dck forge-headless/test_decks/monored.dck --seed 42
+```
+
+Run `./headless.sh --help` for a description of each command and its options.
+
+Simulation retains the desktop runner's options, including seeded runs (`-s`) and per-player AI
+profiles (`-a`). Add `-r` to replace the normal AI seats with faster random controllers. Random
+controller mode cannot be combined with AI profiles or tournament mode.
+
+The Java tests use the small decks under `test_decks/` and exercise pass, random, targeting, and
+scripted terminal-controller paths in-process.
diff --git a/forge-headless/pom.xml b/forge-headless/pom.xml
new file mode 100644
index 000000000000..6d483370d802
--- /dev/null
+++ b/forge-headless/pom.xml
@@ -0,0 +1,93 @@
+
+
+
+ 4.0.0
+
+
+ forge
+ forge
+ ${revision}
+
+
+ forge-headless
+ Forge Headless
+ Headless simulation and AI testing module
+
+
+
+
+ maven-jar-plugin
+
+
+
+ forge.headless.Main
+
+
+
+
+
+ maven-assembly-plugin
+
+ false
+
+ jar-with-dependencies
+
+
+
+ forge.headless.Main
+
+
+
+
+
+ make-assembly
+ package
+
+ single
+
+
+
+
+
+
+
+
+
+ forge
+ forge-core
+ ${project.version}
+
+
+ forge
+ forge-game
+ ${project.version}
+
+
+ forge
+ forge-ai
+ ${project.version}
+
+
+ forge
+ forge-gui
+ ${project.version}
+
+
+ org.apache.commons
+ commons-lang3
+ 3.18.0
+
+
+ info.picocli
+ picocli
+ 4.7.5
+
+
+ junit
+ junit
+ 4.13.2
+ test
+
+
+
+
diff --git a/forge-headless/src/main/java/forge/headless/AgentType.java b/forge-headless/src/main/java/forge/headless/AgentType.java
new file mode 100644
index 000000000000..aa2d9ec6e51f
--- /dev/null
+++ b/forge-headless/src/main/java/forge/headless/AgentType.java
@@ -0,0 +1,26 @@
+package forge.headless;
+
+/**
+ * Defines the types of agents that can control a player in TUI mode.
+ */
+public enum AgentType {
+ /**
+ * Interactive text UI - reads choices from stdin
+ */
+ TUI,
+
+ /**
+ * AI-controlled player using Forge's built-in AI
+ */
+ AI,
+
+ /**
+ * Random agent - makes random valid choices
+ */
+ RANDOM,
+
+ /**
+ * Zero agent - always chooses option 0 (pass priority)
+ */
+ ZERO
+}
diff --git a/forge-headless/src/main/java/forge/headless/HeadlessGuiBase.java b/forge-headless/src/main/java/forge/headless/HeadlessGuiBase.java
new file mode 100644
index 000000000000..44aee4f4cc9d
--- /dev/null
+++ b/forge-headless/src/main/java/forge/headless/HeadlessGuiBase.java
@@ -0,0 +1,274 @@
+package forge.headless;
+
+import forge.gamemodes.match.HostedMatch;
+import forge.gui.download.GuiDownloadService;
+import forge.gui.interfaces.IGuiBase;
+import forge.gui.interfaces.IGuiGame;
+import forge.item.PaperCard;
+import forge.localinstance.skin.FSkinProp;
+import forge.localinstance.skin.ISkinImage;
+import forge.sound.IAudioClip;
+import forge.sound.IAudioMusic;
+import forge.util.BuildInfo;
+import forge.util.ImageFetcher;
+
+import org.jupnp.UpnpServiceConfiguration;
+
+import java.io.File;
+import java.io.IOException;
+import java.net.URISyntaxException;
+import java.util.Collection;
+import java.util.List;
+import java.util.function.Consumer;
+
+import forge.util.FSerializableFunction;
+
+/**
+ * Minimal headless implementation of IGuiBase for simulation mode.
+ * Most methods are no-ops or throw UnsupportedOperationException.
+ */
+public class HeadlessGuiBase implements IGuiBase {
+
+ @Override
+ public boolean isRunningOnDesktop() {
+ return false;
+ }
+
+ @Override
+ public boolean isLibgdxPort() {
+ return false;
+ }
+
+ @Override
+ public String getCurrentVersion() {
+ return BuildInfo.getVersionString();
+ }
+
+ @Override
+ public String getAssetsDir() {
+ // For development builds (git or SNAPSHOT), look for resources in the forge-gui module
+ String version = BuildInfo.getVersionString().toLowerCase();
+ if (version.contains("git") || version.contains("snapshot")) {
+ // Check if we're running from forge-headless/target directory
+ File targetResDir = new File("../../forge-gui/res");
+ if (targetResDir.exists() && targetResDir.isDirectory()) {
+ return "../../forge-gui/";
+ }
+ // Fallback for other development scenarios
+ return "../forge-gui/";
+ }
+ // For release builds, resources are in the current directory
+ return "";
+ }
+
+ @Override
+ public ImageFetcher getImageFetcher() {
+ return null; // Not needed for headless simulation
+ }
+
+ @Override
+ public void invokeInEdtNow(Runnable runnable) {
+ runnable.run();
+ }
+
+ @Override
+ public void invokeInEdtLater(Runnable runnable) {
+ runnable.run();
+ }
+
+ @Override
+ public void invokeInEdtAndWait(Runnable proc) {
+ proc.run();
+ }
+
+ @Override
+ public boolean isGuiThread() {
+ return true;
+ }
+
+ @Override
+ public ISkinImage getSkinIcon(FSkinProp skinProp) {
+ return null;
+ }
+
+ @Override
+ public ISkinImage getUnskinnedIcon(String path) {
+ return null;
+ }
+
+ @Override
+ public ISkinImage getCardArt(PaperCard card) {
+ return null;
+ }
+
+ @Override
+ public ISkinImage getCardArt(PaperCard card, boolean backFace) {
+ return null;
+ }
+
+ @Override
+ public ISkinImage createLayeredImage(PaperCard card, FSkinProp background, String overlayFilename, float opacity) {
+ return null;
+ }
+
+ @Override
+ public void showBugReportDialog(String title, String text, boolean showExitAppBtn) {
+ System.err.println("Bug Report: " + title);
+ System.err.println(text);
+ }
+
+ @Override
+ public void showImageDialog(ISkinImage image, String message, String title) {
+ System.out.println(title + ": " + message);
+ }
+
+ @Override
+ public int showOptionDialog(String message, String title, FSkinProp icon, List options, int defaultOption) {
+ return defaultOption;
+ }
+
+ @Override
+ public String showInputDialog(String message, String title, FSkinProp icon, String initialInput, List inputOptions, boolean isNumeric) {
+ return initialInput;
+ }
+
+ @Override
+ public List getChoices(String message, int min, int max, Collection choices, Collection selected, FSerializableFunction display) {
+ return List.copyOf(selected);
+ }
+
+ @Override
+ public List order(String title, String top, int remainingObjectsMin, int remainingObjectsMax, List sourceChoices, List destChoices) {
+ return destChoices;
+ }
+
+ @Override
+ public String showFileDialog(String title, String defaultDir) {
+ return defaultDir;
+ }
+
+ @Override
+ public File getSaveFile(File defaultFile) {
+ return defaultFile;
+ }
+
+ @Override
+ public void download(GuiDownloadService service, Consumer callback) {
+ throw new UnsupportedOperationException("Downloads not supported in headless mode");
+ }
+
+ public void refreshSkin() {
+ // No-op
+ }
+
+ @Override
+ public boolean hasNetGame() {
+ return false;
+ }
+
+ @Override
+ public void showCardList(String title, String message, List list) {
+ System.out.println(title + ": " + message);
+ }
+
+ @Override
+ public boolean showBoxedProduct(String title, String message, List list) {
+ return false;
+ }
+
+ @Override
+ public PaperCard chooseCard(String title, String message, List list) {
+ return list.isEmpty() ? null : list.get(0);
+ }
+
+ @Override
+ public int getAvatarCount() {
+ return 0;
+ }
+
+ @Override
+ public int getSleevesCount() {
+ return 0;
+ }
+
+ @Override
+ public void copyToClipboard(String text) {
+ // No-op
+ }
+
+ @Override
+ public void browseToUrl(String url) throws IOException, URISyntaxException {
+ System.out.println("Browse to: " + url);
+ }
+
+ @Override
+ public boolean isSupportedAudioFormat(File file) {
+ // Headless mode doesn't support audio
+ return false;
+ }
+
+ @Override
+ public IAudioClip createAudioClip(String filename) {
+ return null;
+ }
+
+ @Override
+ public IAudioMusic createAudioMusic(String filename) {
+ return null;
+ }
+
+ @Override
+ public void startAltSoundSystem(String filename, boolean isSynchronized) {
+ // No-op
+ }
+
+ @Override
+ public void clearImageCache() {
+ // No-op
+ }
+
+ @Override
+ public void showSpellShop() {
+ throw new UnsupportedOperationException("Spell shop not supported in headless mode");
+ }
+
+ @Override
+ public void showBazaar() {
+ throw new UnsupportedOperationException("Bazaar not supported in headless mode");
+ }
+
+ @Override
+ public IGuiGame getNewGuiGame() {
+ return null;
+ }
+
+ @Override
+ public HostedMatch hostMatch() {
+ return null;
+ }
+
+ @Override
+ public void runBackgroundTask(String message, Runnable task) {
+ task.run();
+ }
+
+ @Override
+ public String encodeSymbols(String str, boolean formatReminderText) {
+ return str;
+ }
+
+ @Override
+ public void preventSystemSleep(boolean preventSleep) {
+ // No-op
+ }
+
+ @Override
+ public float getScreenScale() {
+ return 1.0f;
+ }
+
+ @Override
+ public UpnpServiceConfiguration getUpnpPlatformService() {
+ return null;
+ }
+}
diff --git a/forge-headless/src/main/java/forge/headless/LobbyPlayerRandom.java b/forge-headless/src/main/java/forge/headless/LobbyPlayerRandom.java
new file mode 100644
index 000000000000..060817e728cc
--- /dev/null
+++ b/forge-headless/src/main/java/forge/headless/LobbyPlayerRandom.java
@@ -0,0 +1,48 @@
+package forge.headless;
+
+import forge.LobbyPlayer;
+import forge.game.Game;
+import forge.game.player.IGameEntitiesFactory;
+import forge.game.player.Player;
+import forge.game.player.PlayerController;
+
+import java.util.Random;
+
+/**
+ * LobbyPlayer that creates RandomController players for benchmarking.
+ * Much faster than full AI since it doesn't do deep game tree analysis.
+ */
+public class LobbyPlayerRandom extends LobbyPlayer implements IGameEntitiesFactory {
+
+ private final Random random;
+
+ public LobbyPlayerRandom(String name) {
+ this(name, new Random());
+ }
+
+ public LobbyPlayerRandom(String name, long seed) {
+ this(name, new Random(seed));
+ }
+
+ public LobbyPlayerRandom(String name, Random random) {
+ super(name);
+ this.random = random;
+ }
+
+ @Override
+ public PlayerController createMindSlaveController(Player master, Player slave) {
+ return new RandomController(slave.getGame(), slave, this, random);
+ }
+
+ @Override
+ public Player createIngamePlayer(Game game, final int id) {
+ Player player = new Player(getName(), game, id);
+ player.setFirstController(new RandomController(game, player, this, random));
+ return player;
+ }
+
+ @Override
+ public void hear(LobbyPlayer player, String message) {
+ // Random player doesn't listen to chat
+ }
+}
diff --git a/forge-headless/src/main/java/forge/headless/Main.java b/forge-headless/src/main/java/forge/headless/Main.java
new file mode 100644
index 000000000000..8155bda4e2b5
--- /dev/null
+++ b/forge-headless/src/main/java/forge/headless/Main.java
@@ -0,0 +1,77 @@
+package forge.headless;
+
+import forge.gui.GuiBase;
+
+/**
+ * Main entry point for Forge headless mode.
+ */
+public final class Main {
+ /**
+ * Main entry point for Forge headless commands.
+ */
+ public static void main(final String[] args) {
+ System.exit(run(args));
+ }
+
+ static int run(final String[] args) {
+ if (args.length == 0) {
+ printHelp();
+ return 1;
+ }
+ if (isHelpArgument(args[0])) {
+ printHelp();
+ return 0;
+ }
+
+ // HACK - temporary solution to "Comparison method violates it's general contract!" crash
+ System.setProperty("java.util.Arrays.useLegacyMergeSort", "true");
+
+ // Setup headless GUI interface (minimal implementation, no actual GUI)
+ GuiBase.setInterface(new HeadlessGuiBase());
+
+ // Command line startup
+ String mode = args[0].toLowerCase();
+
+ switch (mode) {
+ case "sim":
+ SimulateMatch.simulate(args);
+ return 0;
+
+ case "tui":
+ TextUIGame.run(args);
+ return 0;
+
+ default:
+ System.err.println("Unknown command: " + args[0]);
+ System.err.println("Run './headless.sh --help' to see the available commands.");
+ return 2;
+ }
+ }
+
+ private static boolean isHelpArgument(final String argument) {
+ return "--help".equals(argument) || "-h".equals(argument) || "help".equalsIgnoreCase(argument);
+ }
+
+ private static void printHelp() {
+ System.out.println("Forge Headless");
+ System.out.println();
+ System.out.println("Run Forge games without starting the desktop or mobile application.");
+ System.out.println();
+ System.out.println("Usage:");
+ System.out.println(" ./headless.sh [options]");
+ System.out.println();
+ System.out.println("Commands:");
+ System.out.println(" sim Run automated games between Forge AI players.");
+ System.out.println(" tui Play or observe a game in an interactive terminal.");
+ System.out.println();
+ System.out.println("Examples:");
+ System.out.println(" ./headless.sh sim -d deck1.dck deck2.dck -n 10");
+ System.out.println(" ./headless.sh tui deck1.dck deck2.dck --p1 tui --p2 ai");
+ System.out.println();
+ System.out.println("Run './headless.sh --help' for command-specific options.");
+ }
+
+ // disallow instantiation
+ private Main() {
+ }
+}
diff --git a/forge-headless/src/main/java/forge/headless/PlayerControllerRandom.java b/forge-headless/src/main/java/forge/headless/PlayerControllerRandom.java
new file mode 100644
index 000000000000..9dce92d1c123
--- /dev/null
+++ b/forge-headless/src/main/java/forge/headless/PlayerControllerRandom.java
@@ -0,0 +1,100 @@
+package forge.headless;
+
+import forge.LobbyPlayer;
+import forge.game.Game;
+import forge.game.player.Player;
+import forge.util.MyRandom;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.Reader;
+
+/**
+ * Agent that makes random valid choices.
+ * Useful for fuzz testing and exploring different game paths.
+ */
+public class PlayerControllerRandom extends PlayerControllerTUI {
+
+ public PlayerControllerRandom(Game game, Player p, LobbyPlayer lp, boolean askMana, boolean numericChoices) {
+ super(game, p, lp, askMana, numericChoices, new RandomChoiceReader());
+ }
+
+ @Override
+ public boolean isAI() {
+ return true; // Act like an AI for game logic purposes
+ }
+
+ /**
+ * A BufferedReader that generates random numeric choices when readLine() is called.
+ * Package-private so PlayerControllerTUI can detect and configure it.
+ */
+ static class RandomChoiceReader extends BufferedReader {
+ private int currentMin = 0;
+ private int currentMax = 0;
+ private int consecutiveCalls = 0; // Track repeated calls with same range
+
+ public RandomChoiceReader() {
+ super(new EmptyReader());
+ }
+
+ /**
+ * Set the valid range for the next choice.
+ * This should be called before readLine() to ensure valid random choices.
+ */
+ public void setRange(int min, int max) {
+ // Only reset counter if range actually changed
+ if (this.currentMin != min || this.currentMax != max) {
+ this.consecutiveCalls = 0;
+ }
+ this.currentMin = min;
+ this.currentMax = max;
+ }
+
+ @Override
+ public String readLine() throws IOException {
+ consecutiveCalls++;
+
+ // After a few calls with the same range, increase probability of ending loop
+ // This handles attacker/blocker selection loops
+ int endLoopChance = Math.min(consecutiveCalls * 10, 50); // Increases to 50% max
+ if (consecutiveCalls >= 2 && MyRandom.getRandom().nextInt(100) < endLoopChance) {
+ return ""; // Empty string to end loops
+ }
+
+ // Generate a random choice within the valid range
+ if (currentMax < currentMin) {
+ // Invalid range, default to 0
+ return "0";
+ }
+
+ // Generate a random number within the valid range [min, max]
+ // Bias toward choosing minimum value (30% chance) as it often means "pass" or "skip"
+ int range = currentMax - currentMin + 1;
+ int randomValue = MyRandom.getRandom().nextInt(100);
+
+ if (randomValue < 30) {
+ // 30% chance to choose minimum
+ return String.valueOf(currentMin);
+ } else {
+ // 70% chance to choose any value in the range
+ int choice = currentMin + MyRandom.getRandom().nextInt(range);
+ return String.valueOf(choice);
+ }
+ }
+
+ /**
+ * Dummy Reader that does nothing
+ */
+ private static class EmptyReader extends Reader {
+ @Override
+ public int read(char[] cbuf, int off, int len) throws IOException {
+ return -1; // EOF
+ }
+
+ @Override
+ public void close() throws IOException {
+ // No-op
+ }
+ }
+ }
+}
diff --git a/forge-headless/src/main/java/forge/headless/PlayerControllerTUI.java b/forge-headless/src/main/java/forge/headless/PlayerControllerTUI.java
new file mode 100644
index 000000000000..0d731bb64c4d
--- /dev/null
+++ b/forge-headless/src/main/java/forge/headless/PlayerControllerTUI.java
@@ -0,0 +1,1586 @@
+package forge.headless;
+
+import com.google.common.collect.Multimap;
+import forge.LobbyPlayer;
+import forge.ai.ComputerUtilMana;
+import forge.card.MagicColor;
+import forge.game.GameEntity;
+import forge.ai.PlayerControllerAi;
+import forge.deck.DeckSection;
+import forge.game.Game;
+import forge.game.card.Card;
+import forge.game.card.CardCollection;
+import forge.game.combat.Combat;
+import forge.game.combat.CombatUtil;
+import forge.game.phase.PhaseHandler;
+import forge.game.player.Player;
+import forge.game.spellability.SpellAbility;
+import forge.game.zone.ZoneType;
+import forge.item.PaperCard;
+import forge.util.collect.FCollectionView;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * TUI Player Controller - Extends AI controller but overrides key methods
+ * to provide interactive text-based gameplay.
+ *
+ * For now, this only supports two actions:
+ * 1. Pass priority (do nothing)
+ * 2. Play a land from hand
+ */
+public class PlayerControllerTUI extends PlayerControllerAi {
+
+ private final BufferedReader reader;
+ private final boolean askMana; // Whether to prompt for mana abilities
+ private final boolean numericChoices; // Whether to use numeric-only mode
+
+ // Choice tracking statistics
+ private int totalChoicesMade = 0;
+ private int totalChoiceOptions = 0;
+
+ public PlayerControllerTUI(Game game, Player p, LobbyPlayer lp) {
+ this(game, p, lp, false, false);
+ }
+
+ public PlayerControllerTUI(Game game, Player p, LobbyPlayer lp, boolean askMana) {
+ this(game, p, lp, askMana, false);
+ }
+
+ public PlayerControllerTUI(Game game, Player p, LobbyPlayer lp, boolean askMana, boolean numericChoices) {
+ this(game, p, lp, askMana, numericChoices, new BufferedReader(new InputStreamReader(System.in)));
+ }
+
+ /**
+ * Constructor with injectable reader for testing.
+ */
+ public PlayerControllerTUI(Game game, Player p, LobbyPlayer lp, BufferedReader reader) {
+ this(game, p, lp, false, false, reader);
+ }
+
+ /**
+ * Full constructor with all options.
+ */
+ public PlayerControllerTUI(Game game, Player p, LobbyPlayer lp, boolean askMana, boolean numericChoices, BufferedReader reader) {
+ super(game, p, lp);
+ this.askMana = askMana;
+ this.numericChoices = numericChoices;
+ this.reader = reader;
+ }
+
+ /**
+ * Get statistics about choices made during the game.
+ */
+ public int getTotalChoicesMade() {
+ return totalChoicesMade;
+ }
+
+ public int getTotalChoiceOptions() {
+ return totalChoiceOptions;
+ }
+
+ @Override
+ public boolean isAI() {
+ return false; // We're a human player
+ }
+
+ @Override
+ public void revealAnte(String message, Multimap removedAnteCards) {
+ // No-op for TUI - we don't need to show ante cards
+ System.out.println("[Ante] " + message);
+ }
+
+ @Override
+ public void revealAISkipCards(String message, Map>> deckCards) {
+ // No-op for TUI - we don't need to show AI-unplayable cards
+ // This is called during game setup to show cards the AI can't use
+ }
+
+ @Override
+ public void revealUnsupported(Map> unsupported) {
+ // No-op for TUI
+ }
+
+ @Override
+ public boolean chooseTargetsFor(SpellAbility sa) {
+ if (!sa.usesTargeting()) {
+ return true; // No targeting needed
+ }
+
+ forge.game.spellability.TargetRestrictions tgt = sa.getTargetRestrictions();
+ if (tgt == null) {
+ return true; // No target restrictions
+ }
+
+ System.out.println("\n=== Choose Target for " + sa.getHostCard().getName() + " ===");
+
+ // Collect all valid targets
+ List validTargets = new ArrayList<>();
+ Game game = getGame();
+
+ // Check all players as potential targets
+ for (Player p : game.getPlayers()) {
+ if (sa.canTarget(p)) {
+ validTargets.add(p);
+ }
+ }
+
+ // Check all cards on battlefield as potential targets
+ for (Player p : game.getPlayers()) {
+ for (Card c : p.getCardsIn(ZoneType.Battlefield)) {
+ if (sa.canTarget(c)) {
+ validTargets.add(c);
+ }
+ }
+ }
+
+ // Check spells on the stack as potential targets (for counterspells, etc.)
+ for (var stackItem : game.getStack()) {
+ SpellAbility stackSa = stackItem.getSpellAbility();
+ if (stackSa != null && sa.canTarget(stackSa)) {
+ validTargets.add(stackSa);
+ }
+ }
+
+ // Check other zones if needed (graveyard, hand, etc.)
+ // For most burn spells, battlefield and players are enough
+
+ if (validTargets.isEmpty()) {
+ System.out.println(" No valid targets available.");
+ return false;
+ }
+
+ // Show valid targets
+ System.out.println("Select a target:");
+ for (int i = 0; i < validTargets.size(); i++) {
+ forge.game.GameObject target = validTargets.get(i);
+ String desc = formatTarget(target);
+ System.out.println(" " + i + ". " + desc);
+ }
+
+ // Get user choice
+ int choice = getIntInput(0, validTargets.size() - 1);
+ forge.game.GameObject chosen = validTargets.get(choice);
+
+ // Set the target
+ sa.getTargets().add(chosen);
+
+ System.out.println(">> Targeting " + formatTarget(chosen) + "\n");
+ return true;
+ }
+
+ /**
+ * Format a game object (player, card, or spell) for display.
+ */
+ private String formatTarget(forge.game.GameObject target) {
+ if (target instanceof Player) {
+ Player p = (Player) target;
+ return p.getName() + " (Life: " + p.getLife() + ")";
+ } else if (target instanceof SpellAbility) {
+ SpellAbility sa = (SpellAbility) target;
+ Card source = sa.getHostCard();
+ String desc = "Spell: " + source.getName();
+ if (sa.getActivatingPlayer() != null) {
+ desc += " [" + sa.getActivatingPlayer().getName() + "]";
+ }
+ return desc;
+ } else if (target instanceof Card) {
+ Card c = (Card) target;
+ String desc = c.getName();
+ if (c.isCreature()) {
+ desc += " (" + c.getNetPower() + "/" + c.getNetToughness() + ")";
+ }
+ if (c.getController() != null) {
+ desc += " [" + c.getController().getName() + "]";
+ }
+ return desc;
+ }
+ return target.toString();
+ }
+
+ /**
+ * Check if a spell ability has valid targets available.
+ * Returns true if the spell doesn't use targeting, or if it has at least one valid target.
+ */
+ private boolean hasValidTargets(SpellAbility sa) {
+ // If doesn't use targeting, it's always valid
+ if (!sa.usesTargeting()) {
+ return true;
+ }
+
+ forge.game.spellability.TargetRestrictions tgt = sa.getTargetRestrictions();
+ if (tgt == null) {
+ return true; // No target restrictions
+ }
+
+ Game game = getGame();
+
+ // Check if any players are valid targets
+ for (Player p : game.getPlayers()) {
+ if (sa.canTarget(p)) {
+ return true;
+ }
+ }
+
+ // Check if any cards on battlefield are valid targets
+ for (Player p : game.getPlayers()) {
+ for (Card c : p.getCardsIn(ZoneType.Battlefield)) {
+ if (sa.canTarget(c)) {
+ return true;
+ }
+ }
+ }
+
+ // Check other zones if needed (graveyard, hand, etc.)
+ // Most targeted spells check battlefield and players, but some may target other zones
+ for (Player p : game.getPlayers()) {
+ for (Card c : p.getCardsIn(ZoneType.Graveyard)) {
+ if (sa.canTarget(c)) {
+ return true;
+ }
+ }
+ }
+
+ // Check spells on the stack (important for counterspells!)
+ for (var stackItem : game.getStack()) {
+ SpellAbility stackSa = stackItem.getSpellAbility();
+ if (stackSa != null && sa.canTarget(stackSa)) {
+ return true;
+ }
+ }
+
+ // No valid targets found
+ return false;
+ }
+
+ @Override
+ public List chooseSpellAbilityToPlay() {
+ // Print any new game log entries
+ TUIGuiBase.printNewLogEntries();
+
+ Game game = getGame();
+ PhaseHandler ph = game.getPhaseHandler();
+ boolean isMyTurn = ph.isPlayerTurn(player);
+ boolean isMainPhase = ph.is(forge.game.phase.PhaseType.MAIN1) || ph.is(forge.game.phase.PhaseType.MAIN2);
+ boolean isPostCombatMain = ph.is(forge.game.phase.PhaseType.MAIN2);
+ boolean isEndStep = ph.is(forge.game.phase.PhaseType.END_OF_TURN);
+ boolean stackHasItems = !game.getStack().isEmpty();
+
+ // Get playable actions from hand
+ List landAbilities = getPlayableLands();
+ List creatureAbilities = new ArrayList<>();
+ List artifactAbilities = new ArrayList<>();
+ List instantAbilities = new ArrayList<>();
+ List sorceryAbilities = new ArrayList<>();
+
+ // In any main phase, check for castable sorcery-speed spells
+ if (isMainPhase) {
+ creatureAbilities = getCastableCreaturesAndArtifacts(true);
+ artifactAbilities = getCastableCreaturesAndArtifacts(false);
+ sorceryAbilities = getCastableSorceries();
+ }
+
+ // Instants can be cast at any time we have priority
+ instantAbilities = getCastableInstants();
+
+ // Get activated abilities from permanents on the battlefield
+ List activatedAbilities = getActivatedAbilities();
+
+ // Count total available actions
+ int totalActions = landAbilities.size() + creatureAbilities.size() + artifactAbilities.size() +
+ instantAbilities.size() + sorceryAbilities.size() + activatedAbilities.size();
+
+ // Smart auto-passing on opponent's turn
+ if (!isMyTurn) {
+ // Only prompt on opponent's turn if:
+ // 1. Stack has items (we might want to respond), OR
+ // 2. It's end step AND we have instants (classic "at end of your turn" timing), OR
+ // 3. We have activated abilities
+
+ boolean hasInstantSpeedActions = !instantAbilities.isEmpty() || !activatedAbilities.isEmpty();
+ boolean shouldPrompt = stackHasItems || (isEndStep && hasInstantSpeedActions);
+
+ if (!shouldPrompt) {
+ // Silently pass - don't spam output during opponent's turn
+ return null;
+ }
+
+ // If no actions available even though we should prompt, auto-pass with minimal output
+ if (totalActions == 0) {
+ return null;
+ }
+ } else {
+ // On our turn, if there are no options besides passing, auto-pass without prompting
+ if (totalActions == 0) {
+ System.out.println(">> Auto-passing priority (no actions available)...\n");
+ return null;
+ }
+ }
+
+ // Deduplicate abilities - only show unique actions
+ List uniqueLands = deduplicateByCardName(landAbilities);
+ List uniqueCreatures = deduplicateByCardName(creatureAbilities);
+ List uniqueArtifacts = deduplicateByCardName(artifactAbilities);
+ List uniqueSorceries = deduplicateByCardName(sorceryAbilities);
+ List uniqueInstants = deduplicateByCardName(instantAbilities);
+ List uniqueActivated = deduplicateByDescription(activatedAbilities);
+
+ // Recalculate total actions after deduplication
+ int uniqueActions = uniqueLands.size() + uniqueCreatures.size() + uniqueArtifacts.size() +
+ uniqueInstants.size() + uniqueSorceries.size() + uniqueActivated.size();
+
+ // Show options to user with context-appropriate header
+ if (!isMyTurn) {
+ System.out.println("\n=== OPPONENT'S TURN ===");
+ if (stackHasItems) {
+ System.out.println("[Respond to spell on stack]");
+ } else if (isEndStep) {
+ System.out.println("[End of opponent's turn]");
+ }
+ } else {
+ // Only display full game state on our own turn
+ displayGameState();
+
+ System.out.println("\n=== YOUR TURN ===");
+ if (isPostCombatMain) {
+ System.out.println("[Post-Combat Main Phase]");
+ } else if (isMainPhase) {
+ System.out.println("[Pre-Combat Main Phase]");
+ }
+ }
+
+ System.out.println("What would you like to do?");
+ System.out.println(" 0. Pass priority (do nothing)");
+
+ int optionNum = 1;
+
+ // Show land options
+ for (SpellAbility sa : uniqueLands) {
+ Card land = sa.getHostCard();
+ System.out.println(" " + optionNum + ". Play land: " + land.getName());
+ optionNum++;
+ }
+
+ // Show creature options
+ for (SpellAbility sa : uniqueCreatures) {
+ Card creature = sa.getHostCard();
+ System.out.println(" " + optionNum + ". Cast creature: " + creature.getName() +
+ " (" + creature.getNetPower() + "/" + creature.getNetToughness() + ") - " +
+ creature.getManaCost());
+ optionNum++;
+ }
+
+ // Show artifact options
+ for (SpellAbility sa : uniqueArtifacts) {
+ Card artifact = sa.getHostCard();
+ System.out.println(" " + optionNum + ". Cast artifact: " + artifact.getName() +
+ " - " + artifact.getManaCost());
+ optionNum++;
+ }
+
+ // Show sorcery options
+ for (SpellAbility sa : uniqueSorceries) {
+ Card sorcery = sa.getHostCard();
+ System.out.println(" " + optionNum + ". Cast sorcery: " + sorcery.getName() +
+ " - " + sorcery.getManaCost());
+ optionNum++;
+ }
+
+ // Show instant options
+ for (SpellAbility sa : uniqueInstants) {
+ Card instant = sa.getHostCard();
+ System.out.println(" " + optionNum + ". Cast instant: " + instant.getName() +
+ " - " + instant.getManaCost());
+ optionNum++;
+ }
+
+ // Show activated ability options
+ for (SpellAbility sa : uniqueActivated) {
+ Card source = sa.getHostCard();
+ String description = sa.getDescription();
+ if (description == null || description.isEmpty()) {
+ description = "Activate ability";
+ }
+ System.out.println(" " + optionNum + ". " + source.getName() + ": " + description);
+ optionNum++;
+ }
+
+ // Get user input
+ int choice = getIntInput(0, uniqueActions);
+
+ // Track choice statistics
+ totalChoicesMade++;
+ totalChoiceOptions += (uniqueActions + 1); // +1 for pass option
+
+ if (choice == 0) {
+ System.out.println(">> Passing priority...\n");
+ return null; // Pass priority
+ } else {
+ // Find the chosen ability
+ SpellAbility chosen = null;
+ int idx = choice - 1;
+
+ if (idx < uniqueLands.size()) {
+ chosen = uniqueLands.get(idx);
+ System.out.println(">> Playing " + chosen.getHostCard().getName() + "...\n");
+ } else if ((idx -= uniqueLands.size()) < uniqueCreatures.size()) {
+ chosen = uniqueCreatures.get(idx);
+ System.out.println(">> Casting " + chosen.getHostCard().getName() + "...\n");
+ } else if ((idx -= uniqueCreatures.size()) < uniqueArtifacts.size()) {
+ chosen = uniqueArtifacts.get(idx);
+ System.out.println(">> Casting " + chosen.getHostCard().getName() + "...\n");
+ } else if ((idx -= uniqueArtifacts.size()) < uniqueSorceries.size()) {
+ chosen = uniqueSorceries.get(idx);
+ System.out.println(">> Casting " + chosen.getHostCard().getName() + "...\n");
+ } else if ((idx -= uniqueSorceries.size()) < uniqueInstants.size()) {
+ chosen = uniqueInstants.get(idx);
+ System.out.println(">> Casting " + chosen.getHostCard().getName() + "...\n");
+ } else if ((idx -= uniqueInstants.size()) < uniqueActivated.size()) {
+ chosen = uniqueActivated.get(idx);
+ System.out.println(">> Activating " + chosen.getHostCard().getName() + "...\n");
+ }
+
+ // For spells that require targeting, choose targets now before returning
+ if (chosen != null && chosen.isSpell() && chosen.usesTargeting()) {
+ if (!chooseTargetsFor(chosen)) {
+ // Targeting failed - don't cast the spell
+ System.out.println("Targeting cancelled. Spell not cast.\n");
+ return null;
+ }
+ }
+
+ return Collections.singletonList(chosen);
+ }
+ }
+
+ /**
+ * Get playable land abilities from the player's hand.
+ */
+ private List getPlayableLands() {
+ List lands = new ArrayList<>();
+
+ for (Card c : player.getCardsIn(ZoneType.Hand)) {
+ if (c.isLand()) {
+ for (SpellAbility sa : c.getAllPossibleAbilities(player, true)) {
+ if (sa.isLandAbility() && sa.canPlay()) {
+ lands.add(sa);
+ break; // Only need one land ability per land
+ }
+ }
+ }
+ }
+
+ return lands;
+ }
+
+ /**
+ * Get castable creature or artifact spells from the player's hand.
+ * @param creatures if true, get creatures; if false, get artifacts
+ */
+ private List getCastableCreaturesAndArtifacts(boolean creatures) {
+ List spells = new ArrayList<>();
+
+ for (Card c : player.getCardsIn(ZoneType.Hand)) {
+ // Check if we're looking for the right type
+ if (creatures && !c.isCreature()) continue;
+ if (!creatures && !c.isArtifact()) continue;
+ // Skip if it's also a land (like artifact lands)
+ if (c.isLand()) continue;
+
+ // Get the main spell ability (casting the card)
+ for (SpellAbility sa : c.getAllPossibleAbilities(player, true)) {
+ // We want spell abilities that can be cast from hand
+ if (sa.isSpell() && sa.canPlay()) {
+ // Check if player can actually pay the mana cost right now
+ sa.setActivatingPlayer(player);
+ if (ComputerUtilMana.canPayManaCost(sa, player, 0, false)) {
+ spells.add(sa);
+ }
+ break; // Only need the first castable ability
+ }
+ }
+ }
+
+ return spells;
+ }
+
+ /**
+ * Get castable sorcery spells from the player's hand.
+ */
+ private List getCastableSorceries() {
+ List spells = new ArrayList<>();
+
+ for (Card c : player.getCardsIn(ZoneType.Hand)) {
+ if (!c.isSorcery()) continue;
+
+ // Get the main spell ability (casting the card)
+ for (SpellAbility sa : c.getAllPossibleAbilities(player, true)) {
+ // We want spell abilities that can be cast from hand
+ if (sa.isSpell() && sa.canPlay()) {
+ // Check if player can actually pay the mana cost right now
+ sa.setActivatingPlayer(player);
+ if (ComputerUtilMana.canPayManaCost(sa, player, 0, false)) {
+ // Only include if spell has valid targets (or doesn't need targets)
+ if (hasValidTargets(sa)) {
+ spells.add(sa);
+ }
+ }
+ break; // Only need the first castable ability
+ }
+ }
+ }
+
+ return spells;
+ }
+
+ /**
+ * Get castable instant spells from the player's hand.
+ */
+ private List getCastableInstants() {
+ List spells = new ArrayList<>();
+
+ for (Card c : player.getCardsIn(ZoneType.Hand)) {
+ if (!c.isInstant()) continue;
+
+ // Get the main spell ability (casting the card)
+ for (SpellAbility sa : c.getAllPossibleAbilities(player, true)) {
+ // We want spell abilities that can be cast from hand
+ if (sa.isSpell() && sa.canPlay()) {
+ // Check if player can actually pay the mana cost right now
+ sa.setActivatingPlayer(player);
+ if (ComputerUtilMana.canPayManaCost(sa, player, 0, false)) {
+ // Only include if spell has valid targets (or doesn't need targets)
+ if (hasValidTargets(sa)) {
+ spells.add(sa);
+ }
+ }
+ break; // Only need the first castable ability
+ }
+ }
+ }
+
+ return spells;
+ }
+
+ /**
+ * Get activated abilities from permanents on the battlefield.
+ * Filters out mana abilities unless askMana is true.
+ */
+ private List getActivatedAbilities() {
+ List abilities = new ArrayList<>();
+
+ // Check all permanents we control
+ for (Card c : player.getCardsIn(ZoneType.Battlefield)) {
+ // Get all abilities for this permanent
+ for (SpellAbility sa : c.getAllPossibleAbilities(player, true)) {
+ // We want activated abilities (not spells, not triggered)
+ // Activated abilities are not spells and can be played from the battlefield
+ if (!sa.isSpell() && sa.canPlay() && !sa.isTrigger()) {
+ // Skip mana abilities unless askMana is enabled
+ if (!askMana && sa.isManaAbility()) {
+ continue;
+ }
+
+ // Check if player can pay the activation cost
+ sa.setActivatingPlayer(player);
+ // Check if player can actually afford the cost right now
+ if (ComputerUtilMana.canPayManaCost(sa, player, 0, false)) {
+ abilities.add(sa);
+ }
+ }
+ }
+ }
+
+ return abilities;
+ }
+
+ /**
+ * Display the current game state to the user.
+ */
+ private void displayGameState() {
+ Game game = getGame();
+ PhaseHandler ph = game.getPhaseHandler();
+
+ System.out.println();
+ System.out.println("=".repeat(60));
+ System.out.println("Turn " + ph.getTurn() + " - " + ph.getPlayerTurn().getName() + "'s turn");
+ System.out.println("Phase: " + ph.getPhase().nameForUi);
+
+ Player priorityPlayer = ph.getPriorityPlayer();
+ if (priorityPlayer != null) {
+ System.out.println("Priority: " + priorityPlayer.getName());
+ }
+
+ // Display stack contents if not empty
+ if (game.getStack().isEmpty()) {
+ System.out.println("Stack: Empty");
+ } else {
+ System.out.println("Stack: " + game.getStack().size() + " item(s)");
+ // Show stack contents from top to bottom
+ var stackItems = game.getStack();
+ int stackPos = stackItems.size();
+ for (var item : stackItems) {
+ SpellAbility sa = item.getSpellAbility();
+ if (sa != null) {
+ Card source = sa.getHostCard();
+ String controller = item.getActivatingPlayer() != null ?
+ item.getActivatingPlayer().getName() : "Unknown";
+ System.out.println(" " + stackPos + ". " + source.getName() +
+ " (" + controller + ")");
+ if (sa.hasParam("Description")) {
+ System.out.println(" " + sa.getParam("Description"));
+ }
+ stackPos--;
+ }
+ }
+ }
+ System.out.println();
+
+ // Display all players
+ for (Player p : game.getPlayers()) {
+ displayPlayerInfo(p, game);
+ }
+
+ System.out.println("-".repeat(60));
+ }
+
+ /**
+ * Display information about a single player.
+ */
+ private void displayPlayerInfo(Player p, Game game) {
+ boolean isCurrentPlayer = p == game.getPhaseHandler().getPlayerTurn();
+ boolean isThisPlayer = p == player;
+
+ String marker;
+ if (isThisPlayer) {
+ marker = ">>> [YOU] ";
+ } else if (isCurrentPlayer) {
+ marker = ">>> ";
+ } else {
+ marker = " ";
+ }
+
+ System.out.println(marker + p.getName());
+ System.out.println(marker + " Life: " + p.getLife());
+ System.out.println(marker + " Hand: " + p.getZone(ZoneType.Hand).size() + " cards");
+
+ if (isThisPlayer) {
+ // Show the human player their hand
+ List hand = new ArrayList<>();
+ for (Card c : p.getCardsIn(ZoneType.Hand)) {
+ hand.add(c);
+ }
+ if (!hand.isEmpty()) {
+ System.out.println(marker + " Your hand:");
+ for (Card c : hand) {
+ System.out.println(marker + " - " + c.getName());
+ }
+ }
+ }
+
+ System.out.println(marker + " Library: " + p.getZone(ZoneType.Library).size() + " cards");
+ System.out.println(marker + " Graveyard: " + p.getZone(ZoneType.Graveyard).size() + " cards");
+ System.out.println(marker + " Lands played this turn: " + p.getLandsPlayedThisTurn());
+
+ // Show mana pool if non-empty (only for this player)
+ if (isThisPlayer) {
+ int white = p.getManaPool().getAmountOfColor(MagicColor.WHITE);
+ int blue = p.getManaPool().getAmountOfColor(MagicColor.BLUE);
+ int black = p.getManaPool().getAmountOfColor(MagicColor.BLACK);
+ int red = p.getManaPool().getAmountOfColor(MagicColor.RED);
+ int green = p.getManaPool().getAmountOfColor(MagicColor.GREEN);
+ int colorless = p.getManaPool().getAmountOfColor(MagicColor.COLORLESS);
+ int total = white + blue + black + red + green + colorless;
+
+ if (total > 0) {
+ StringBuilder manaPoolStr = new StringBuilder();
+ manaPoolStr.append(marker).append(" Mana pool: ");
+ List manaComponents = new ArrayList<>();
+ if (white > 0) manaComponents.add(white + " White");
+ if (blue > 0) manaComponents.add(blue + " Blue");
+ if (black > 0) manaComponents.add(black + " Black");
+ if (red > 0) manaComponents.add(red + " Red");
+ if (green > 0) manaComponents.add(green + " Green");
+ if (colorless > 0) manaComponents.add(colorless + " Colorless");
+ manaPoolStr.append(String.join(", ", manaComponents));
+ System.out.println(manaPoolStr.toString());
+ }
+ }
+
+ // Show battlefield
+ List lands = new ArrayList<>();
+ List creatures = new ArrayList<>();
+ List others = new ArrayList<>();
+
+ for (Card c : p.getCardsIn(ZoneType.Battlefield)) {
+ if (c.isLand()) {
+ lands.add(c);
+ } else if (c.isCreature()) {
+ creatures.add(c);
+ } else {
+ others.add(c);
+ }
+ }
+
+ System.out.println(marker + " Lands in play: " + lands.size());
+ if (!lands.isEmpty() && (isThisPlayer || lands.size() <= 10)) {
+ for (Card land : lands) {
+ System.out.println(marker + " - " + land.getName() + (land.isTapped() ? " (tapped)" : ""));
+ }
+ }
+
+ System.out.println(marker + " Creatures: " + creatures.size());
+ if (!creatures.isEmpty() && (isThisPlayer || creatures.size() <= 10)) {
+ for (Card creature : creatures) {
+ System.out.println(marker + " - " + creature.getName() +
+ " (" + creature.getNetPower() + "/" + creature.getNetToughness() + ")" +
+ (creature.isTapped() ? " (tapped)" : "") +
+ (creature.isSick() ? " (summoning sickness)" : ""));
+ }
+ }
+
+ if (!others.isEmpty()) {
+ System.out.println(marker + " Other permanents: " + others.size());
+ if (isThisPlayer || others.size() <= 5) {
+ for (Card other : others) {
+ System.out.println(marker + " - " + other.getName());
+ }
+ }
+ }
+
+ System.out.println();
+ }
+
+ /**
+ * Get integer input from the user within a specified range.
+ * In numeric mode, only accepts numeric input.
+ * In normal mode, also handles special commands: "?" for help, "v" for viewing cards, "g" for graveyards.
+ */
+ private int getIntInput(int min, int max) {
+ while (true) {
+ // Inform the reader about the valid range (for random/scripted agents)
+ if (reader instanceof PlayerControllerRandom.RandomChoiceReader) {
+ ((PlayerControllerRandom.RandomChoiceReader) reader).setRange(min, max);
+ }
+
+ // Standardized prompt format - always show for logging/debugging
+ if (numericChoices) {
+ System.out.print("Enter choice (" + min + "-" + max + "): ");
+ } else {
+ System.out.print("Enter choice (" + min + "-" + max + ", or ?): ");
+ }
+
+ try {
+ String line = reader.readLine();
+ if (line == null) {
+ System.out.println("Input error, using default choice: " + min);
+ return min;
+ }
+
+ line = line.trim();
+ if (line.isEmpty()) {
+ System.out.println("No input provided, using default choice: " + min);
+ return min;
+ }
+
+ // Handle special commands only in normal (non-numeric) mode
+ if (!numericChoices) {
+ if (line.equals("?")) {
+ showHelp();
+ continue;
+ }
+
+ if (line.equalsIgnoreCase("v")) {
+ viewCard();
+ continue;
+ }
+
+ if (line.equalsIgnoreCase("g")) {
+ viewGraveyards();
+ continue;
+ }
+
+ if (line.equalsIgnoreCase("b")) {
+ displayGameState();
+ continue;
+ }
+
+ if (line.equalsIgnoreCase("s")) {
+ viewStack();
+ continue;
+ }
+ }
+
+ int choice = Integer.parseInt(line);
+ if (choice >= min && choice <= max) {
+ return choice;
+ }
+
+ System.out.println("Invalid choice. Please enter a number between " + min + " and " + max + ".");
+ } catch (IOException e) {
+ System.err.println("Error reading input: " + e.getMessage());
+ System.out.println("Using default choice: " + min);
+ return min;
+ } catch (NumberFormatException e) {
+ if (numericChoices) {
+ System.out.println("Invalid input. Please enter a number.");
+ } else {
+ System.out.println("Invalid input. Please enter a number or '?' for help.");
+ }
+ }
+ }
+ }
+
+ /**
+ * Display help information for the TUI interface.
+ */
+ private void showHelp() {
+ System.out.println();
+ System.out.println("=== HELP ===");
+ System.out.println("Commands:");
+ System.out.println(" 0-9 - Select an action by number");
+ System.out.println(" ? - Show this help");
+ System.out.println(" v - View a card (see detailed card text)");
+ System.out.println(" g - View all graveyards");
+ System.out.println(" b - View battlefield / game state");
+ System.out.println(" s - View the stack");
+ System.out.println();
+ System.out.println("During your turn, you can:");
+ System.out.println(" - Play lands (if you haven't used your land drop)");
+ System.out.println(" - Cast spells from your hand");
+ System.out.println(" - Pass priority (0) to move to the next phase");
+ System.out.println();
+ System.out.println("You will be prompted repeatedly until you pass priority.");
+ System.out.println("============");
+ System.out.println();
+ }
+
+ /**
+ * Allow the user to view detailed information about a card.
+ * Shows cards from hand, both players' battlefields, and graveyards.
+ */
+ private void viewCard() {
+ Game game = getGame();
+ List allCards = new ArrayList<>();
+
+ // Collect cards from player's hand
+ for (Card c : player.getCardsIn(ZoneType.Hand)) {
+ allCards.add(c);
+ }
+
+ // Collect cards from all battlefields
+ for (Player p : game.getPlayers()) {
+ for (Card c : p.getCardsIn(ZoneType.Battlefield)) {
+ allCards.add(c);
+ }
+ }
+
+ // Collect cards from all graveyards
+ for (Player p : game.getPlayers()) {
+ for (Card c : p.getCardsIn(ZoneType.Graveyard)) {
+ allCards.add(c);
+ }
+ }
+
+ if (allCards.isEmpty()) {
+ System.out.println("No cards to view.");
+ return;
+ }
+
+ // Remove duplicates by card name (keep first occurrence)
+ List uniqueCards = new ArrayList<>();
+ java.util.Set seenNames = new java.util.HashSet<>();
+ for (Card c : allCards) {
+ if (!seenNames.contains(c.getName())) {
+ uniqueCards.add(c);
+ seenNames.add(c.getName());
+ }
+ }
+
+ // Sort alphabetically by name
+ uniqueCards.sort((c1, c2) -> c1.getName().compareTo(c2.getName()));
+
+ System.out.println();
+ System.out.println("=== VIEW CARD ===");
+ System.out.println("Select a card to view:");
+
+ for (int i = 0; i < uniqueCards.size(); i++) {
+ Card c = uniqueCards.get(i);
+ String location;
+ if (c.getZone().is(ZoneType.Hand)) {
+ location = "[Hand]";
+ } else if (c.getZone().is(ZoneType.Graveyard)) {
+ location = "[Graveyard - " + c.getController().getName() + "]";
+ } else {
+ location = "[Battlefield - " + c.getController().getName() + "]";
+ }
+ System.out.println(" " + i + ". " + c.getName() + " " + location);
+ }
+
+ System.out.print("Enter card number (or press Enter to cancel): ");
+ try {
+ String line = reader.readLine();
+ if (line == null || line.trim().isEmpty()) {
+ System.out.println("Cancelled.");
+ return;
+ }
+
+ int cardIndex = Integer.parseInt(line.trim());
+ if (cardIndex < 0 || cardIndex >= uniqueCards.size()) {
+ System.out.println("Invalid card number.");
+ return;
+ }
+
+ Card selectedCard = uniqueCards.get(cardIndex);
+ displayCardDetails(selectedCard);
+
+ } catch (IOException e) {
+ System.err.println("Error reading input: " + e.getMessage());
+ } catch (NumberFormatException e) {
+ System.out.println("Invalid input.");
+ }
+ }
+
+ /**
+ * Display detailed information about a card.
+ */
+ private void displayCardDetails(Card card) {
+ System.out.println();
+ System.out.println("=".repeat(60));
+ System.out.println(card.getName() + " " + card.getManaCost());
+ System.out.println("-".repeat(60));
+
+ // Show card type line
+ StringBuilder typeLine = new StringBuilder();
+ if (!card.getType().isEmpty()) {
+ typeLine.append(card.getType().toString());
+ }
+
+ System.out.println("Type: " + typeLine.toString());
+
+ // Show creature stats if applicable
+ if (card.isCreature()) {
+ System.out.println("Power/Toughness: " + card.getNetPower() + "/" + card.getNetToughness());
+ }
+
+ // Show oracle text
+ String oracleText = card.getOracleText();
+ if (oracleText != null && !oracleText.isEmpty()) {
+ System.out.println();
+ System.out.println("Text:");
+ // Replace literal \n with actual newlines
+ String formattedText = oracleText.replace("\\n", "\n");
+ System.out.println(formattedText);
+ }
+
+ // Show current state and location
+ if (card.getZone().is(ZoneType.Battlefield)) {
+ System.out.println();
+ System.out.println("Current State:");
+ System.out.println(" Controller: " + card.getController().getName());
+ if (card.isTapped()) {
+ System.out.println(" [TAPPED]");
+ }
+ if (card.isSick()) {
+ System.out.println(" [Summoning Sickness]");
+ }
+ } else if (card.getZone().is(ZoneType.Hand)) {
+ System.out.println();
+ System.out.println("Location: In hand");
+ } else if (card.getZone().is(ZoneType.Graveyard)) {
+ System.out.println();
+ System.out.println("Location: In " + card.getController().getName() + "'s graveyard");
+ }
+
+ System.out.println("=".repeat(60));
+ System.out.println();
+ }
+
+ /**
+ * Display the contents of all players' graveyards.
+ */
+ private void viewGraveyards() {
+ Game game = getGame();
+ System.out.println();
+ System.out.println("=== GRAVEYARDS ===");
+
+ for (Player p : game.getPlayers()) {
+ List graveyard = new ArrayList<>();
+ for (Card c : p.getCardsIn(ZoneType.Graveyard)) {
+ graveyard.add(c);
+ }
+
+ System.out.println();
+ System.out.println(p.getName() + "'s Graveyard (" + graveyard.size() + " cards):");
+
+ if (graveyard.isEmpty()) {
+ System.out.println(" (empty)");
+ } else {
+ // Sort by card type for better readability
+ graveyard.sort((c1, c2) -> {
+ // Sort order: Creature, Instant, Sorcery, Artifact, Enchantment, Land, Other
+ int priority1 = getCardTypePriority(c1);
+ int priority2 = getCardTypePriority(c2);
+ if (priority1 != priority2) {
+ return Integer.compare(priority1, priority2);
+ }
+ return c1.getName().compareTo(c2.getName());
+ });
+
+ for (Card c : graveyard) {
+ String cardInfo = " - " + c.getName();
+ if (c.isCreature()) {
+ cardInfo += " (" + c.getNetPower() + "/" + c.getNetToughness() + ")";
+ }
+ cardInfo += " - " + getCardTypeString(c);
+ System.out.println(cardInfo);
+ }
+ }
+ }
+
+ System.out.println();
+ System.out.println("==================");
+ System.out.println();
+ }
+
+ /**
+ * Display the current stack contents.
+ */
+ private void viewStack() {
+ Game game = getGame();
+ System.out.println();
+ System.out.println("=== STACK ===");
+
+ if (game.getStack().isEmpty()) {
+ System.out.println("Stack is empty.");
+ } else {
+ System.out.println("Stack contents (top to bottom):");
+ System.out.println();
+
+ var stackItems = game.getStack();
+ int stackPos = stackItems.size();
+ for (var item : stackItems) {
+ SpellAbility sa = item.getSpellAbility();
+ if (sa != null) {
+ Card source = sa.getHostCard();
+ String controller = item.getActivatingPlayer() != null ?
+ item.getActivatingPlayer().getName() : "Unknown";
+
+ System.out.println(" " + stackPos + ". " + source.getName() + " (" + controller + ")");
+
+ // Show spell description if available
+ if (sa.hasParam("Description")) {
+ System.out.println(" " + sa.getParam("Description"));
+ }
+
+ // Show oracle text for more context
+ String oracleText = source.getOracleText();
+ if (oracleText != null && !oracleText.isEmpty()) {
+ String formattedText = oracleText.replace("\\n", " ");
+ // Truncate if too long
+ if (formattedText.length() > 150) {
+ formattedText = formattedText.substring(0, 147) + "...";
+ }
+ System.out.println(" " + formattedText);
+ }
+
+ System.out.println();
+ stackPos--;
+ }
+ }
+ }
+
+ System.out.println("=============");
+ System.out.println();
+ }
+
+ /**
+ * Get a priority value for sorting cards by type.
+ */
+ private int getCardTypePriority(Card c) {
+ if (c.isCreature()) return 1;
+ if (c.isInstant()) return 2;
+ if (c.isSorcery()) return 3;
+ if (c.isArtifact()) return 4;
+ if (c.isEnchantment()) return 5;
+ if (c.isLand()) return 6;
+ return 7;
+ }
+
+ /**
+ * Get a simple type string for a card.
+ */
+ private String getCardTypeString(Card c) {
+ if (c.isCreature()) return "Creature";
+ if (c.isInstant()) return "Instant";
+ if (c.isSorcery()) return "Sorcery";
+ if (c.isArtifact()) return "Artifact";
+ if (c.isEnchantment()) return "Enchantment";
+ if (c.isLand()) return "Land";
+ return "Other";
+ }
+
+ @Override
+ public void declareAttackers(Player attacker, Combat combat) {
+ // Print any new game log entries
+ TUIGuiBase.printNewLogEntries();
+
+ // Get all possible attackers
+ CardCollection possibleAttackers = CombatUtil.getPossibleAttackers(attacker);
+
+ if (possibleAttackers.isEmpty()) {
+ System.out.println(">> No creatures available to attack. Skipping combat...\n");
+ return;
+ }
+
+ System.out.println();
+ System.out.println("=".repeat(60));
+ System.out.println("=== DECLARE ATTACKERS ===");
+ System.out.println("=".repeat(60));
+
+ // Get possible defenders
+ FCollectionView possibleDefenders = CombatUtil.getAllPossibleDefenders(attacker);
+ List defendersList = new ArrayList<>();
+ for (GameEntity defender : possibleDefenders) {
+ defendersList.add(defender);
+ }
+
+ List selectedAttackers = new ArrayList<>();
+
+ if (numericChoices) {
+ // Numeric-only mode: use structured decision tree
+ while (true) {
+ // Show available attackers with 0 = "No further attackers"
+ System.out.println("\nDeclare attackers:");
+ System.out.println(" 0. No further attackers");
+ for (int i = 0; i < possibleAttackers.size(); i++) {
+ Card creature = possibleAttackers.get(i);
+ String status = "";
+ if (selectedAttackers.contains(creature)) {
+ status = " [ATTACKING]";
+ }
+ System.out.println(" " + (i + 1) + ". " + creature.getName() +
+ " (" + creature.getNetPower() + "/" + creature.getNetToughness() + ")" + status);
+ }
+
+ int choice = getIntInput(0, possibleAttackers.size());
+
+ if (choice == 0) {
+ // Done selecting attackers
+ break;
+ }
+
+ Card selectedAttacker = possibleAttackers.get(choice - 1);
+
+ // Check if already selected
+ if (selectedAttackers.contains(selectedAttacker)) {
+ System.out.println(">> " + selectedAttacker.getName() + " is already attacking.");
+ continue;
+ }
+
+ // Choose defender
+ GameEntity defender = null;
+ if (defendersList.size() == 1) {
+ defender = defendersList.get(0);
+ } else {
+ // Multiple defenders - let user choose
+ System.out.println("\nChoose defender for " + selectedAttacker.getName() + ":");
+ for (int i = 0; i < defendersList.size(); i++) {
+ System.out.println(" " + i + ". " + getDefenderName(defendersList.get(i)));
+ }
+
+ int defenderChoice = getIntInput(0, defendersList.size() - 1);
+ defender = defendersList.get(defenderChoice);
+ }
+
+ // Add the attacker
+ combat.addAttacker(selectedAttacker, defender);
+ selectedAttackers.add(selectedAttacker);
+ System.out.println(">> " + selectedAttacker.getName() + " attacks " + getDefenderName(defender));
+ }
+ } else {
+ // Text mode: allow "done" command
+ // Show available attackers
+ System.out.println("\nAvailable attackers:");
+ for (int i = 0; i < possibleAttackers.size(); i++) {
+ Card creature = possibleAttackers.get(i);
+ System.out.println(" " + i + ". " + creature.getName() +
+ " (" + creature.getNetPower() + "/" + creature.getNetToughness() + ")" +
+ (creature.isTapped() ? " (tapped)" : "") +
+ (creature.isSick() ? " (summoning sickness)" : ""));
+ }
+
+ System.out.println("\nChoose attackers one at a time (or enter 'done' when finished):");
+
+ // Let user select attackers
+ while (true) {
+ // Set range for random agents
+ if (reader instanceof PlayerControllerRandom.RandomChoiceReader) {
+ ((PlayerControllerRandom.RandomChoiceReader) reader).setRange(0, possibleAttackers.size() - 1);
+ }
+
+ System.out.print("Enter attacker number (or 'done'): ");
+ try {
+ String line = reader.readLine();
+ if (line == null || line.trim().isEmpty() || line.trim().equalsIgnoreCase("done")) {
+ break;
+ }
+
+ int attackerIndex = Integer.parseInt(line.trim());
+ if (attackerIndex < 0 || attackerIndex >= possibleAttackers.size()) {
+ System.out.println("Invalid attacker number.");
+ continue;
+ }
+
+ Card selectedAttacker = possibleAttackers.get(attackerIndex);
+
+ // Check if already selected
+ if (selectedAttackers.contains(selectedAttacker)) {
+ System.out.println(selectedAttacker.getName() + " is already attacking.");
+ continue;
+ }
+
+ // If only one defender, attack them automatically
+ GameEntity defender = null;
+ if (defendersList.size() == 1) {
+ defender = defendersList.get(0);
+ System.out.println(">> " + selectedAttacker.getName() + " attacks " + getDefenderName(defender));
+ } else {
+ // Multiple defenders - let user choose
+ System.out.println("\nChoose defender for " + selectedAttacker.getName() + ":");
+ for (int i = 0; i < defendersList.size(); i++) {
+ System.out.println(" " + i + ". " + getDefenderName(defendersList.get(i)));
+ }
+
+ // Set range for random agents
+ if (reader instanceof PlayerControllerRandom.RandomChoiceReader) {
+ ((PlayerControllerRandom.RandomChoiceReader) reader).setRange(0, defendersList.size() - 1);
+ }
+
+ System.out.print("Enter defender number: ");
+ String defenderLine = reader.readLine();
+ if (defenderLine == null || defenderLine.trim().isEmpty()) {
+ System.out.println("Cancelled attacker selection.");
+ continue;
+ }
+
+ int defenderIndex = Integer.parseInt(defenderLine.trim());
+ if (defenderIndex < 0 || defenderIndex >= defendersList.size()) {
+ System.out.println("Invalid defender number.");
+ continue;
+ }
+ defender = defendersList.get(defenderIndex);
+ System.out.println(">> " + selectedAttacker.getName() + " attacks " + getDefenderName(defender));
+ }
+
+ // Add the attacker
+ combat.addAttacker(selectedAttacker, defender);
+ selectedAttackers.add(selectedAttacker);
+
+ System.out.println(" Current attackers: " + selectedAttackers.size());
+
+ } catch (IOException e) {
+ System.err.println("Error reading input: " + e.getMessage());
+ break;
+ } catch (NumberFormatException e) {
+ System.out.println("Invalid input. Enter a number or 'done'.");
+ }
+ }
+ }
+
+ if (selectedAttackers.isEmpty()) {
+ System.out.println(">> No attackers declared.\n");
+ } else {
+ System.out.println(">> Declared " + selectedAttackers.size() + " attacker(s).\n");
+ }
+ }
+
+ @Override
+ public void declareBlockers(Player defender, Combat combat) {
+ // Print any new game log entries
+ TUIGuiBase.printNewLogEntries();
+
+ // Get all attackers
+ CardCollection attackers = combat.getAttackers();
+
+ if (attackers.isEmpty()) {
+ System.out.println(">> No attackers to block.\n");
+ return;
+ }
+
+ // Get possible blockers
+ CardCollection possibleBlockers = defender.getCreaturesInPlay();
+ if (possibleBlockers.isEmpty()) {
+ System.out.println(">> No creatures available to block.\n");
+ return;
+ }
+
+ System.out.println();
+ System.out.println("=".repeat(60));
+ System.out.println("=== DECLARE BLOCKERS ===");
+ System.out.println("=".repeat(60));
+
+ if (numericChoices) {
+ // Numeric-only mode: ask for each attacker individually
+ for (int attackerIdx = 0; attackerIdx < attackers.size(); attackerIdx++) {
+ Card attacker = attackers.get(attackerIdx);
+ GameEntity defendingEntity = combat.getDefenderByAttacker(attacker);
+
+ System.out.println("\nAttacker " + attackerIdx + ": " + attacker.getName() +
+ " (" + attacker.getNetPower() + "/" + attacker.getNetToughness() + ")" +
+ " attacking " + getDefenderName(defendingEntity));
+
+ // Show available blockers with 0 = "No further blockers"
+ while (true) {
+ System.out.println("Who should block this attacker?");
+ System.out.println(" 0. No further blockers");
+ for (int i = 0; i < possibleBlockers.size(); i++) {
+ Card creature = possibleBlockers.get(i);
+ String status = "";
+ if (combat.isBlocking(creature)) {
+ status = " [BLOCKING]";
+ }
+ System.out.println(" " + (i + 1) + ". " + creature.getName() +
+ " (" + creature.getNetPower() + "/" + creature.getNetToughness() + ")" + status);
+ }
+
+ int choice = getIntInput(0, possibleBlockers.size());
+
+ if (choice == 0) {
+ // No more blockers for this attacker
+ break;
+ }
+
+ Card blocker = possibleBlockers.get(choice - 1);
+
+ // Check if blocker can block this attacker
+ if (!CombatUtil.canBlock(attacker, blocker, combat)) {
+ System.out.println(">> " + blocker.getName() + " cannot block " + attacker.getName() + ".");
+ continue;
+ }
+
+ // Add the blocker
+ combat.addBlocker(attacker, blocker);
+ System.out.println(">> " + blocker.getName() + " blocks " + attacker.getName());
+ }
+ }
+ } else {
+ // Text mode: allow "X blocks Y" format
+ // Show attackers
+ System.out.println("\nAttacking creatures:");
+ for (int i = 0; i < attackers.size(); i++) {
+ Card attacker = attackers.get(i);
+ GameEntity defendingEntity = combat.getDefenderByAttacker(attacker);
+ System.out.println(" " + i + ". " + attacker.getName() +
+ " (" + attacker.getNetPower() + "/" + attacker.getNetToughness() + ")" +
+ " attacking " + getDefenderName(defendingEntity));
+ }
+
+ // Show available blockers
+ System.out.println("\nAvailable blockers:");
+ for (int i = 0; i < possibleBlockers.size(); i++) {
+ Card creature = possibleBlockers.get(i);
+ System.out.println(" " + i + ". " + creature.getName() +
+ " (" + creature.getNetPower() + "/" + creature.getNetToughness() + ")" +
+ (creature.isTapped() ? " (tapped)" : ""));
+ }
+
+ System.out.println("\nDeclare blockers (or enter 'done' when finished):");
+ System.out.println("Format: blocks ");
+ System.out.println("Example: 0 blocks 1");
+
+ // Let user assign blockers
+ while (true) {
+ System.out.print("Enter block assignment (or 'done'): ");
+ try {
+ String line = reader.readLine();
+ if (line == null || line.trim().isEmpty() || line.trim().equalsIgnoreCase("done")) {
+ break;
+ }
+
+ // Parse "X blocks Y" format
+ String[] parts = line.trim().split("\\s+");
+ if (parts.length != 3 || !parts[1].equalsIgnoreCase("blocks")) {
+ System.out.println("Invalid format. Use: blocks ");
+ continue;
+ }
+
+ int blockerIndex = Integer.parseInt(parts[0]);
+ int attackerIndex = Integer.parseInt(parts[2]);
+
+ if (blockerIndex < 0 || blockerIndex >= possibleBlockers.size()) {
+ System.out.println("Invalid blocker number.");
+ continue;
+ }
+
+ if (attackerIndex < 0 || attackerIndex >= attackers.size()) {
+ System.out.println("Invalid attacker number.");
+ continue;
+ }
+
+ Card blocker = possibleBlockers.get(blockerIndex);
+ Card attacker = attackers.get(attackerIndex);
+
+ // Check if blocker can block this attacker
+ if (!CombatUtil.canBlock(attacker, blocker, combat)) {
+ System.out.println(blocker.getName() + " cannot block " + attacker.getName() + ".");
+ continue;
+ }
+
+ // Add the blocker
+ combat.addBlocker(attacker, blocker);
+ System.out.println(">> " + blocker.getName() + " blocks " + attacker.getName());
+
+ } catch (IOException e) {
+ System.err.println("Error reading input: " + e.getMessage());
+ break;
+ } catch (NumberFormatException e) {
+ System.out.println("Invalid input. Use: blocks ");
+ } catch (ArrayIndexOutOfBoundsException e) {
+ System.out.println("Invalid format. Use: blocks ");
+ }
+ }
+ }
+
+ System.out.println(">> Blockers declared.\n");
+ }
+
+ /**
+ * Get a human-readable name for a defender (player or planeswalker).
+ */
+ private String getDefenderName(GameEntity defender) {
+ if (defender instanceof Player) {
+ Player p = (Player) defender;
+ return p.getName() + " (Life: " + p.getLife() + ")";
+ }
+ // For planeswalkers and other entities
+ return defender.toString();
+ }
+
+ @Override
+ public boolean confirmTrigger(forge.game.trigger.WrappedAbility wrapper) {
+ // Mandatory triggers always fire
+ if (wrapper.isMandatory()) {
+ return true;
+ }
+
+ // Optional triggers - ask the user
+ SpellAbility sa = wrapper.getWrappedAbility();
+ Card source = sa.getHostCard();
+
+ System.out.println();
+ System.out.println("=".repeat(60));
+ System.out.println("=== OPTIONAL TRIGGER ===");
+ System.out.println("=".repeat(60));
+ System.out.println("Source: " + source.getName());
+ System.out.println("Ability: " + sa.getDescription());
+ System.out.println();
+ System.out.print("Do you want to use this trigger? (y/n): ");
+
+ try {
+ String line = reader.readLine();
+ if (line == null || line.trim().isEmpty()) {
+ return false; // Default to no
+ }
+
+ String response = line.trim().toLowerCase();
+ boolean result = response.startsWith("y");
+
+ if (result) {
+ System.out.println(">> Trigger accepted\n");
+ } else {
+ System.out.println(">> Trigger declined\n");
+ }
+
+ return result;
+ } catch (IOException e) {
+ System.err.println("Error reading input: " + e.getMessage());
+ return false; // Default to no on error
+ }
+ }
+
+ @Override
+ public CardCollection chooseCardsToDiscardToMaximumHandSize(int numDiscard) {
+ // Print any new game log entries
+ TUIGuiBase.printNewLogEntries();
+
+ System.out.println();
+ System.out.println("=".repeat(60));
+ System.out.println("=== DISCARD TO HAND SIZE ===");
+ System.out.println("=".repeat(60));
+
+ // Get cards in hand
+ List hand = new ArrayList<>();
+ for (Card c : player.getCardsIn(ZoneType.Hand)) {
+ hand.add(c);
+ }
+
+ CardCollection toDiscard = new CardCollection();
+
+ // Let user select cards to discard
+ for (int discardNum = 1; discardNum <= numDiscard; discardNum++) {
+ // Show remaining cards with combined prompt
+ List remaining = new ArrayList<>();
+ for (Card c : hand) {
+ if (!toDiscard.contains(c)) {
+ remaining.add(c);
+ }
+ }
+
+ System.out.println("Your hand (" + remaining.size() + " cards), select " + discardNum + " of " + numDiscard + " to discard:");
+ for (int i = 0; i < remaining.size(); i++) {
+ System.out.println(" " + i + ". " + remaining.get(i).getName());
+ }
+
+ int choice = getIntInput(0, remaining.size() - 1);
+ Card chosen = remaining.get(choice);
+ toDiscard.add(chosen);
+
+ System.out.println(">> Will discard: " + chosen.getName());
+ System.out.println();
+ }
+
+ System.out.println(">> Discarding " + numDiscard + " card(s)...\n");
+ return toDiscard;
+ }
+
+ /**
+ * Deduplicate spell abilities by card name, keeping first occurrence.
+ * Used for lands, creatures, artifacts, sorceries, and instants.
+ */
+ private List deduplicateByCardName(List abilities) {
+ List unique = new ArrayList<>();
+ java.util.Set seenNames = new java.util.HashSet<>();
+ for (SpellAbility sa : abilities) {
+ String name = sa.getHostCard().getName();
+ if (!seenNames.contains(name)) {
+ unique.add(sa);
+ seenNames.add(name);
+ }
+ }
+ return unique;
+ }
+
+ /**
+ * Deduplicate activated abilities by card name + description.
+ * Different abilities on the same card will show separately.
+ */
+ private List deduplicateByDescription(List abilities) {
+ List unique = new ArrayList<>();
+ java.util.Set seenKeys = new java.util.HashSet<>();
+ for (SpellAbility sa : abilities) {
+ String key = sa.getHostCard().getName() + ":" + sa.getDescription();
+ if (!seenKeys.contains(key)) {
+ unique.add(sa);
+ seenKeys.add(key);
+ }
+ }
+ return unique;
+ }
+}
diff --git a/forge-headless/src/main/java/forge/headless/PlayerControllerZero.java b/forge-headless/src/main/java/forge/headless/PlayerControllerZero.java
new file mode 100644
index 000000000000..01aa3245458c
--- /dev/null
+++ b/forge-headless/src/main/java/forge/headless/PlayerControllerZero.java
@@ -0,0 +1,40 @@
+package forge.headless;
+
+import forge.LobbyPlayer;
+import forge.game.Game;
+import forge.game.player.Player;
+
+import java.io.BufferedReader;
+import java.io.StringReader;
+
+/**
+ * Agent that always chooses option 0 (pass priority / do nothing).
+ * Useful for testing scenarios where a player should be passive.
+ */
+public class PlayerControllerZero extends PlayerControllerTUI {
+
+ public PlayerControllerZero(Game game, Player p, LobbyPlayer lp, boolean askMana, boolean numericChoices) {
+ // Use a BufferedReader that always returns "0"
+ super(game, p, lp, askMana, numericChoices, createZeroReader());
+ }
+
+ private static BufferedReader createZeroReader() {
+ // This reader will be called repeatedly, so we create an infinite stream of "0\n"
+ return new BufferedReader(new StringReader(generateInfiniteZeros()));
+ }
+
+ private static String generateInfiniteZeros() {
+ // Generate a very long string of "0\n" repeated many times
+ // This should be enough for any reasonable game
+ StringBuilder sb = new StringBuilder();
+ for (int i = 0; i < 100000; i++) {
+ sb.append("0\n");
+ }
+ return sb.toString();
+ }
+
+ @Override
+ public boolean isAI() {
+ return true; // Act like an AI for game logic purposes
+ }
+}
diff --git a/forge-headless/src/main/java/forge/headless/RandomController.java b/forge-headless/src/main/java/forge/headless/RandomController.java
new file mode 100644
index 000000000000..656fa911ea89
--- /dev/null
+++ b/forge-headless/src/main/java/forge/headless/RandomController.java
@@ -0,0 +1,158 @@
+package forge.headless;
+
+import forge.LobbyPlayer;
+import forge.ai.ComputerUtilMana;
+import forge.ai.PlayerControllerAi;
+import forge.game.Game;
+import forge.game.card.Card;
+import forge.game.phase.PhaseHandler;
+import forge.game.player.Player;
+import forge.game.spellability.SpellAbility;
+import forge.game.zone.ZoneType;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Random;
+
+/**
+ * Controller that makes random valid choices for benchmarking.
+ * Much faster than full AI since it doesn't do deep game tree analysis.
+ */
+public class RandomController extends PlayerControllerAi {
+
+ private final Random random;
+
+ public RandomController(Game game, Player p, LobbyPlayer lp) {
+ this(game, p, lp, new Random());
+ }
+
+ public RandomController(Game game, Player p, LobbyPlayer lp, Random random) {
+ super(game, p, lp);
+ this.random = random;
+ }
+
+ @Override
+ public boolean isAI() {
+ return true; // Still counts as AI for game rules
+ }
+
+ @Override
+ public List chooseSpellAbilityToPlay() {
+ PhaseHandler ph = getGame().getPhaseHandler();
+ boolean isMainPhase = ph.is(forge.game.phase.PhaseType.MAIN1) || ph.is(forge.game.phase.PhaseType.MAIN2);
+
+ // Get playable actions from hand
+ List landAbilities = getPlayableLands();
+ List spellAbilities = new ArrayList<>();
+
+ // In any main phase, check for castable sorcery-speed spells
+ if (isMainPhase) {
+ spellAbilities.addAll(getCastableCreatures());
+ spellAbilities.addAll(getCastableSorceries());
+ }
+
+ // Instants can be cast at any time
+ spellAbilities.addAll(getCastableInstants());
+
+ // Combine all options
+ List allOptions = new ArrayList<>();
+ allOptions.addAll(landAbilities);
+ allOptions.addAll(spellAbilities);
+
+ // If no options, pass
+ if (allOptions.isEmpty()) {
+ return null;
+ }
+
+ // Randomly choose: 30% chance to pass, 70% chance to do something
+ if (random.nextDouble() < 0.3) {
+ return null; // Pass
+ }
+
+ // Choose a random action
+ int choice = random.nextInt(allOptions.size());
+ return Collections.singletonList(allOptions.get(choice));
+ }
+
+ private List getPlayableLands() {
+ List lands = new ArrayList<>();
+ for (Card c : player.getCardsIn(ZoneType.Hand)) {
+ if (c.isLand()) {
+ for (SpellAbility sa : c.getAllPossibleAbilities(player, true)) {
+ if (sa.getActivatingPlayer() == null) {
+ sa.setActivatingPlayer(player);
+ }
+ if (sa.isLandAbility() && sa.canPlay()) {
+ lands.add(sa);
+ break;
+ }
+ }
+ }
+ }
+ return lands;
+ }
+
+ private List getCastableCreatures() {
+ List spells = new ArrayList<>();
+ for (Card c : player.getCardsIn(ZoneType.Hand)) {
+ if (c.isCreature() && !c.isLand()) {
+ for (SpellAbility sa : c.getAllPossibleAbilities(player, true)) {
+ if (sa.getActivatingPlayer() == null) {
+ sa.setActivatingPlayer(player);
+ }
+ if (sa.usesTargeting()) {
+ continue;
+ }
+ if (sa.isSpell() && sa.canPlay() && ComputerUtilMana.canPayManaCost(sa, player, 0, false)) {
+ spells.add(sa);
+ break;
+ }
+ }
+ }
+ }
+ return spells;
+ }
+
+ private List getCastableSorceries() {
+ List spells = new ArrayList<>();
+ for (Card c : player.getCardsIn(ZoneType.Hand)) {
+ if (c.isSorcery()) {
+ for (SpellAbility sa : c.getAllPossibleAbilities(player, true)) {
+ if (sa.getActivatingPlayer() == null) {
+ sa.setActivatingPlayer(player);
+ }
+ if (sa.usesTargeting()) {
+ continue;
+ }
+ if (sa.isSpell() && sa.canPlay() && ComputerUtilMana.canPayManaCost(sa, player, 0, false)) {
+ spells.add(sa);
+ break;
+ }
+ }
+ }
+ }
+ return spells;
+ }
+
+ private List getCastableInstants() {
+ List spells = new ArrayList<>();
+ for (Card c : player.getCardsIn(ZoneType.Hand)) {
+ if (c.isInstant()) {
+ for (SpellAbility sa : c.getAllPossibleAbilities(player, true)) {
+ if (sa.getActivatingPlayer() == null) {
+ sa.setActivatingPlayer(player);
+ }
+ if (sa.usesTargeting()) {
+ continue;
+ }
+ if (sa.isSpell() && sa.canPlay() && ComputerUtilMana.canPayManaCost(sa, player, 0, false)) {
+ spells.add(sa);
+ break;
+ }
+ }
+ }
+ }
+ return spells;
+ }
+}
diff --git a/forge-headless/src/main/java/forge/headless/SimulateMatch.java b/forge-headless/src/main/java/forge/headless/SimulateMatch.java
new file mode 100644
index 000000000000..f5412fbdf2a6
--- /dev/null
+++ b/forge-headless/src/main/java/forge/headless/SimulateMatch.java
@@ -0,0 +1,435 @@
+package forge.headless;
+
+import java.io.File;
+import java.util.*;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+import org.apache.commons.lang3.time.StopWatch;
+
+import forge.LobbyPlayer;
+import forge.ai.AiProfileUtil;
+import forge.deck.Deck;
+import forge.deck.DeckGroup;
+import forge.deck.io.DeckSerializer;
+import forge.game.Game;
+import forge.game.GameEndReason;
+import forge.game.GameLogEntry;
+import forge.game.GameLogEntryType;
+import forge.game.GameRules;
+import forge.game.GameType;
+import forge.game.Match;
+import forge.game.player.RegisteredPlayer;
+import forge.gamemodes.tournament.system.AbstractTournament;
+import forge.gamemodes.tournament.system.TournamentBracket;
+import forge.gamemodes.tournament.system.TournamentPairing;
+import forge.gamemodes.tournament.system.TournamentPlayer;
+import forge.gamemodes.tournament.system.TournamentRoundRobin;
+import forge.gamemodes.tournament.system.TournamentSwiss;
+import forge.localinstance.properties.ForgeConstants;
+import forge.model.FModel;
+import forge.player.GamePlayerUtil;
+import forge.util.Lang;
+import forge.util.MyRandom;
+import forge.util.TextUtil;
+import forge.util.WordUtil;
+import forge.util.storage.IStorage;
+
+public class SimulateMatch {
+ public static void simulate(String[] args) {
+ if (args.length == 2 && ("--help".equals(args[1]) || "-h".equals(args[1]))) {
+ argumentHelp();
+ return;
+ }
+
+ FModel.initialize(null, null);
+
+ if (args.length < 4) {
+ System.err.println("Missing required deck arguments.");
+ argumentHelp();
+ return;
+ }
+
+ final Map> params = new HashMap<>();
+ List options = null;
+
+ for (int i = 1; i < args.length; i++) {
+ // "sim" is in the 0th slot
+ final String a = args[i];
+
+ if (a.charAt(0) == '-') {
+ if (a.length() < 2) {
+ System.err.println("Error at argument " + a);
+ argumentHelp();
+ return;
+ }
+
+ options = new ArrayList<>();
+ params.put(a.substring(1), options);
+ } else if (options != null) {
+ options.add(a);
+ } else {
+ System.err.println("Illegal parameter usage");
+ return;
+ }
+ }
+
+ int nGames = 1;
+ if (params.containsKey("n")) {
+ // Number of games should only be a single string
+ nGames = Integer.parseInt(params.get("n").get(0));
+ }
+
+ int matchSize = 0;
+ if (params.containsKey("m")) {
+ // Match size ("best of X games")
+ matchSize = Integer.parseInt(params.get("m").get(0));
+ }
+
+ boolean outputGamelog = !params.containsKey("q");
+ boolean useRandomController = params.containsKey("r");
+
+ Long seed = null;
+ if (params.containsKey("s")) {
+ seed = Long.parseLong(params.get("s").get(0));
+ MyRandom.setRandom(new Random(seed));
+ }
+
+ GameType type = GameType.Constructed;
+ if (params.containsKey("f")) {
+ type = GameType.valueOf(WordUtil.capitalize(params.get("f").get(0)));
+ }
+
+ GameRules rules = new GameRules(type);
+ rules.setAppliedVariants(EnumSet.of(type));
+
+ if (matchSize != 0) {
+ rules.setGamesPerMatch(matchSize);
+ }
+
+ if (params.containsKey("t")) {
+ if (useRandomController) {
+ System.err.println("Random-controller mode is not supported for tournaments.");
+ return;
+ }
+ simulateTournament(params, rules, outputGamelog);
+ System.out.flush();
+ return;
+ }
+
+ List pp = new ArrayList<>();
+ StringBuilder sb = new StringBuilder();
+
+ int i = 1;
+
+ // Optional AI profile per player, in the same order as the decks. Lets a run pit one set of
+ // AI settings against another, which is the only way to tell from the results whether an AI
+ // change actually helped.
+ List aiProfiles = params.get("a");
+ if (aiProfiles != null && useRandomController) {
+ System.err.println("AI profiles cannot be combined with random-controller mode.");
+ return;
+ }
+ if (aiProfiles != null) {
+ for (String profile : aiProfiles) {
+ if (!AiProfileUtil.getProfilesDisplayList().contains(profile)) {
+ System.out.println(TextUtil.concatNoSpace("Unknown AI profile - ", profile,
+ ". Available profiles: ", String.join(", ", AiProfileUtil.getProfilesDisplayList())));
+ return;
+ }
+ }
+ }
+
+ if (params.containsKey("d")) {
+ for (String deck : params.get("d")) {
+ Deck d = deckFromCommandLineParameter(deck, type);
+ if (d == null) {
+ System.out.println(TextUtil.concatNoSpace("Could not load deck - ", deck, ", match cannot start"));
+ return;
+ }
+ if (i > 1) {
+ sb.append(" vs ");
+ }
+ String profile = aiProfiles != null && aiProfiles.size() >= i ? aiProfiles.get(i - 1) : "";
+ String controllerName = useRandomController ? "Random" : "Ai";
+ String name = TextUtil.concatNoSpace(controllerName, "(", String.valueOf(i), ")-", d.getName());
+ sb.append(name);
+ if (!profile.isEmpty()) {
+ sb.append(" [").append(profile).append("]");
+ }
+
+ RegisteredPlayer rp;
+
+ if (type.equals(GameType.Commander)) {
+ rp = RegisteredPlayer.forCommander(d);
+ } else {
+ rp = new RegisteredPlayer(d);
+ }
+ LobbyPlayer lobbyPlayer = useRandomController
+ ? new LobbyPlayerRandom(name, MyRandom.getRandom().nextLong())
+ : GamePlayerUtil.createAiPlayer(name, i - 1, profile);
+ rp.setPlayer(lobbyPlayer);
+ pp.add(rp);
+ i++;
+ }
+ }
+
+ if (params.containsKey("c")) {
+ rules.setSimTimeout(Integer.parseInt(params.get("c").get(0)));
+ }
+
+ sb.append(" - ").append(Lang.nounWithNumeral(nGames, "game")).append(" of ").append(type);
+ if (seed != null) {
+ sb.append(" seed ").append(seed);
+ }
+
+ System.out.println(sb.toString());
+
+ Match mc = new Match(rules, pp, "Test");
+
+ if (matchSize != 0) {
+ int iGame = 0;
+ while (!mc.isMatchOver()) {
+ // play games until the match ends
+ simulateSingleMatch(mc, iGame, outputGamelog);
+ iGame++;
+ }
+ } else {
+ for (int iGame = 0; iGame < nGames; iGame++) {
+ simulateSingleMatch(mc, iGame, outputGamelog);
+ }
+ }
+
+ System.out.flush();
+ }
+
+ private static void argumentHelp() {
+ System.out.println("Run automated Forge games without a graphical interface.");
+ System.out.println();
+ System.out.println("Usage:");
+ System.out.println(" ./headless.sh sim -d [more decks...] [options]");
+ System.out.println();
+ System.out.println("Decks may be Forge deck names or .dck file paths. Quote names containing spaces.");
+ System.out.println();
+ System.out.println("Options:");
+ System.out.println(" -d Decks to play. At least two are required.");
+ System.out.println(" -D Directory used to resolve deck filenames.");
+ System.out.println(" -n Number of independent games to run (default: 1).");
+ System.out.println(" -m Play a match of this many games instead of using -n.");
+ System.out.println(" -f Forge game type, such as Constructed or Commander.");
+ System.out.println(" -s Seed Forge's random-number generator for repeatable runs.");
+ System.out.println(" -a AI profile for each deck, in deck order.");
+ System.out.println(" -r Use lightweight random controllers instead of Forge AI.");
+ System.out.println(" -c End a game as a draw after this timeout (default: 120).");
+ System.out.println(" -q Print only game results, without the full game log.");
+ System.out.println(" -t Run a Bracket, RoundRobin, or Swiss tournament.");
+ System.out.println(" -p Players per tournament match (default: 2).");
+ System.out.println(" -h, --help Show this help and exit.");
+ System.out.println();
+ System.out.println("Examples:");
+ System.out.println(" ./headless.sh sim -d red.dck blue.dck -n 20 -s 42");
+ System.out.println(" ./headless.sh sim -d deck-a.dck deck-b.dck -a Default Experimental -n 50 -q");
+ }
+
+ public static void simulateSingleMatch(final Match mc, int iGame, boolean outputGamelog) {
+ final StopWatch sw = new StopWatch();
+ sw.start();
+
+ final Game g1 = mc.createGame();
+ // will run match in the same thread
+ try {
+ TimeLimitedCodeBlock.runWithTimeout(() -> {
+ mc.startGame(g1);
+ sw.stop();
+ }, mc.getRules().getSimTimeout(), TimeUnit.SECONDS);
+ } catch (TimeoutException e) {
+ System.out.println("Stopping slow match as draw");
+ } catch (Exception | StackOverflowError e) {
+ e.printStackTrace();
+ } finally {
+ if (sw.isStarted()) {
+ sw.stop();
+ }
+ g1.setGameOver(GameEndReason.Draw);
+ }
+
+ List log;
+ if (outputGamelog) {
+ log = g1.getGameLog().getLogEntries(null);
+ } else {
+ log = g1.getGameLog().getLogEntries(GameLogEntryType.MATCH_RESULTS);
+ }
+ Collections.reverse(log);
+ for (GameLogEntry l : log) {
+ System.out.println(l);
+ }
+
+ // If both players life totals to 0 in a single turn, the game should end in a draw
+ if (g1.getOutcome().isDraw()) {
+ System.out.printf("\nGame Result: Game %d ended in a Draw! Took %d ms.%n", 1 + iGame, sw.getTime());
+ } else {
+ System.out.printf("\nGame Result: Game %d ended in %d ms. %s has won!\n%n", 1 + iGame, sw.getTime(), g1.getOutcome().getWinningLobbyPlayer().getName());
+ }
+ }
+
+ private static void simulateTournament(Map> params, GameRules rules, boolean outputGamelog) {
+ String tournament = params.get("t").get(0);
+ AbstractTournament tourney = null;
+ int matchPlayers = params.containsKey("p") ? Integer.parseInt(params.get("p").get(0)) : 2;
+
+ DeckGroup deckGroup = new DeckGroup("SimulatedTournament");
+ List players = new ArrayList<>();
+ int numPlayers = 0;
+ if (params.containsKey("d")) {
+ for (String deck : params.get("d")) {
+ Deck d = deckFromCommandLineParameter(deck, rules.getGameType());
+ if (d == null) {
+ System.out.println(TextUtil.concatNoSpace("Could not load deck - ", deck, ", match cannot start"));
+ return;
+ }
+
+ deckGroup.addAiDeck(d);
+ players.add(new TournamentPlayer(GamePlayerUtil.createAiPlayer(d.getName(), 0), numPlayers));
+ numPlayers++;
+ }
+ }
+
+ if (params.containsKey("D")) {
+ // Direc
+ String foldName = params.get("D").get(0);
+ File folder = new File(foldName);
+ if (!folder.isDirectory()) {
+ System.out.println("Directory not found - " + foldName);
+ } else {
+ for (File deck : folder.listFiles((dir, name) -> name.endsWith(".dck"))) {
+ Deck d = DeckSerializer.fromFile(deck);
+ if (d == null) {
+ System.out.println(TextUtil.concatNoSpace("Could not load deck - ", deck.getName(), ", match cannot start"));
+ return;
+ }
+ deckGroup.addAiDeck(d);
+ players.add(new TournamentPlayer(GamePlayerUtil.createAiPlayer(d.getName(), 0), numPlayers));
+ numPlayers++;
+ }
+ }
+ }
+
+ if (numPlayers == 0) {
+ System.out.println("No decks/Players found. Please try again.");
+ }
+
+ if ("bracket".equalsIgnoreCase(tournament)) {
+ tourney = new TournamentBracket(players, matchPlayers);
+ } else if ("roundrobin".equalsIgnoreCase(tournament)) {
+ tourney = new TournamentRoundRobin(players, matchPlayers);
+ } else if ("swiss".equalsIgnoreCase(tournament)) {
+ tourney = new TournamentSwiss(players, matchPlayers);
+ }
+ if (tourney == null) {
+ System.out.println("Failed to initialize tournament, bailing out");
+ return;
+ }
+
+ tourney.initializeTournament();
+
+ String lastWinner = "";
+ int curRound = 0;
+ System.out.println(TextUtil.concatNoSpace("Starting a ", tournament, " tournament with ",
+ String.valueOf(numPlayers), " players over ",
+ String.valueOf(tourney.getTotalRounds()), " rounds"));
+ while (!tourney.isTournamentOver()) {
+ if (tourney.getActiveRound() != curRound) {
+ if (curRound != 0) {
+ System.out.println(TextUtil.concatNoSpace("End Round - ", String.valueOf(curRound)));
+ }
+ curRound = tourney.getActiveRound();
+ System.out.println();
+ System.out.println(TextUtil.concatNoSpace("Round ", String.valueOf(curRound), " Pairings:"));
+
+ for (TournamentPairing pairing : tourney.getActivePairings()) {
+ System.out.println(pairing.outputHeader());
+ }
+ System.out.println();
+ }
+
+ TournamentPairing pairing = tourney.getNextPairing();
+ List regPlayers = AbstractTournament.registerTournamentPlayers(pairing, deckGroup);
+
+ StringBuilder sb = new StringBuilder();
+ sb.append("Round ").append(tourney.getActiveRound()).append(" - ");
+ sb.append(pairing.outputHeader());
+ System.out.println(sb.toString());
+
+ if (!pairing.isBye()) {
+ Match mc = new Match(rules, regPlayers, "TourneyMatch");
+
+ int exceptions = 0;
+ int iGame = 0;
+ while (!mc.isMatchOver()) {
+ // play games until the match ends
+ try {
+ simulateSingleMatch(mc, iGame, outputGamelog);
+ iGame++;
+ } catch (Exception e) {
+ exceptions++;
+ System.out.println(e.toString());
+ if (exceptions > 5) {
+ System.out.println("Exceeded number of exceptions thrown. Abandoning match...");
+ break;
+ } else {
+ System.out.println("Game threw exception. Abandoning game and continuing...");
+ }
+ }
+
+ }
+ LobbyPlayer winner = mc.getWinner().getPlayer();
+ for (TournamentPlayer tp : pairing.getPairedPlayers()) {
+ if (winner.equals(tp.getPlayer())) {
+ pairing.setWinner(tp);
+ lastWinner = winner.getName();
+ System.out.println(TextUtil.concatNoSpace("Match Winner - ", lastWinner, "!"));
+ System.out.println();
+ break;
+ }
+ }
+ }
+
+ tourney.reportMatchCompletion(pairing);
+ }
+ tourney.outputTournamentResults();
+ }
+
+ public static Match simulateOffthreadGame(List decks, GameType format, int games) {
+ return null;
+ }
+
+ static Deck deckFromCommandLineParameter(String deckname, GameType type) {
+ int dotpos = deckname.lastIndexOf('.');
+ if (dotpos > 0 && dotpos == deckname.length() - 4) {
+ File f = new File(deckname);
+ if (!f.exists()) {
+ String baseDir = type.equals(GameType.Commander) ?
+ ForgeConstants.DECK_COMMANDER_DIR : ForgeConstants.DECK_CONSTRUCTED_DIR;
+ f = new File(baseDir, deckname);
+ }
+ if (!f.exists()) {
+ System.out.println("No deck found: " + deckname);
+ return null;
+ }
+
+ return DeckSerializer.fromFile(f);
+ }
+
+ IStorage deckStore = null;
+
+ // Add other game types here...
+ if (type.equals(GameType.Commander)) {
+ deckStore = FModel.getDecks().getCommander();
+ } else {
+ deckStore = FModel.getDecks().getConstructed();
+ }
+
+ return deckStore.get(deckname);
+ }
+
+}
diff --git a/forge-headless/src/main/java/forge/headless/TUICommand.java b/forge-headless/src/main/java/forge/headless/TUICommand.java
new file mode 100644
index 000000000000..3626ea898c0d
--- /dev/null
+++ b/forge-headless/src/main/java/forge/headless/TUICommand.java
@@ -0,0 +1,88 @@
+package forge.headless;
+
+import picocli.CommandLine.Command;
+import picocli.CommandLine.Option;
+import picocli.CommandLine.Parameters;
+
+/**
+ * Command-line interface definition for the Text UI mode using picocli.
+ */
+@Command(name = "tui",
+ description = "Play or observe a Forge game in an interactive terminal.",
+ mixinStandardHelpOptions = true,
+ version = "Forge TUI 2.0")
+public class TUICommand implements Runnable {
+
+ @Parameters(index = "0",
+ description = "Deck file (.dck) or deck name. " +
+ "Paths can be absolute or relative to current directory.")
+ String deck1;
+
+ @Parameters(index = "1",
+ arity = "0..1",
+ description = "(Optional) Deck for player 2. If omitted, same deck as player 1.")
+ String deck2;
+
+ @Option(names = {"-f", "--format"},
+ description = "Game format (default: Constructed)")
+ String gameType = "Constructed";
+
+ @Option(names = {"--p1", "--p1-agent"},
+ description = "Player 1 agent type: ${COMPLETION-CANDIDATES} (default: ${DEFAULT-VALUE})",
+ converter = CaseInsensitiveAgentTypeConverter.class)
+ AgentType player1Agent = AgentType.TUI;
+
+ @Option(names = {"--p2", "--p2-agent"},
+ description = "Player 2 agent type: ${COMPLETION-CANDIDATES} (default: ${DEFAULT-VALUE})",
+ converter = CaseInsensitiveAgentTypeConverter.class)
+ AgentType player2Agent = AgentType.AI;
+
+ @Option(names = "--askmana",
+ description = "Prompt for mana abilities (default: ${DEFAULT-VALUE})")
+ boolean askMana = false;
+
+ @Option(names = "--numeric-choices",
+ description = "Use numeric-only input (no text commands)")
+ boolean numericChoices = false;
+
+ @Option(names = "--seed",
+ description = "Seed Forge's random-number generator for a repeatable game")
+ Long seed;
+
+ @Option(names = "--start-state",
+ description = "Load game state from .pzl file")
+ String startStatePath;
+
+ // Legacy support
+ @Option(names = "--player2-tui",
+ hidden = true,
+ description = "Deprecated: use --p2=tui instead")
+ boolean player2Tui = false;
+
+ @Override
+ public void run() {
+ // Handle legacy flag
+ if (player2Tui) {
+ System.out.println("Warning: --player2-tui is deprecated, use --p2=tui instead");
+ player2Agent = AgentType.TUI;
+ }
+
+ // If deck2 not provided, use deck1 for both players
+ if (deck2 == null || deck2.isEmpty()) {
+ deck2 = deck1;
+ }
+
+ // Delegate to the actual game logic
+ TextUIGame.runGame(this);
+ }
+
+ /**
+ * Case-insensitive converter for AgentType enum.
+ */
+ static class CaseInsensitiveAgentTypeConverter implements picocli.CommandLine.ITypeConverter {
+ @Override
+ public AgentType convert(String value) throws Exception {
+ return AgentType.valueOf(value.toUpperCase());
+ }
+ }
+}
diff --git a/forge-headless/src/main/java/forge/headless/TUIGuiBase.java b/forge-headless/src/main/java/forge/headless/TUIGuiBase.java
new file mode 100644
index 000000000000..9c72ed790627
--- /dev/null
+++ b/forge-headless/src/main/java/forge/headless/TUIGuiBase.java
@@ -0,0 +1,86 @@
+package forge.headless;
+
+import forge.game.Game;
+import forge.game.GameLogEntry;
+import forge.gui.GuiBase;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Extended HeadlessGuiBase for TUI mode that provides game log access.
+ */
+public class TUIGuiBase extends HeadlessGuiBase {
+
+ private static volatile Game currentGame = null;
+ private static volatile int lastLogIndex = 0;
+ private static volatile boolean tuiModeActive = false;
+
+ /**
+ * Install the TUI GUI base as the global interface.
+ */
+ public static void install() {
+ GuiBase.setInterface(new TUIGuiBase());
+ tuiModeActive = true;
+ }
+
+ /**
+ * Check if TUI mode is currently active.
+ * In TUI mode, all player actions (including draws) are logged since
+ * both players share the same console view.
+ */
+ public static boolean isTUIMode() {
+ return tuiModeActive;
+ }
+
+ /**
+ * Set the current game being played in TUI mode.
+ */
+ public static void setCurrentGame(Game game) {
+ currentGame = game;
+ lastLogIndex = 0;
+ }
+
+ /**
+ * Get new log entries since the last check.
+ * Returns entries with the +++ prefix for easy identification.
+ */
+ public static List getNewLogEntries() {
+ List newEntries = new ArrayList<>();
+
+ if (currentGame == null) {
+ return newEntries;
+ }
+
+ List allEntries = currentGame.getGameLog().getLogEntries(null);
+
+ // The game log is stored in reverse chronological order (newest first)
+ // So we need to track from the end and work backwards
+ int totalEntries = allEntries.size();
+
+ // Calculate how many new entries there are
+ int newEntriesCount = totalEntries - lastLogIndex;
+
+ if (newEntriesCount > 0) {
+ // New entries are at indices [0, newEntriesCount)
+ // But we want to print them in chronological order (oldest to newest)
+ for (int i = newEntriesCount - 1; i >= 0; i--) {
+ GameLogEntry entry = allEntries.get(i);
+ // Prefix with +++ for TUI log entries
+ newEntries.add("+++ " + entry.toString());
+ }
+ }
+
+ lastLogIndex = totalEntries;
+ return newEntries;
+ }
+
+ /**
+ * Print any new log entries to stdout.
+ */
+ public static void printNewLogEntries() {
+ for (String entry : getNewLogEntries()) {
+ System.out.println(entry);
+ }
+ }
+}
diff --git a/forge-headless/src/main/java/forge/headless/TextUIGame.java b/forge-headless/src/main/java/forge/headless/TextUIGame.java
new file mode 100644
index 000000000000..15b7a60f91a2
--- /dev/null
+++ b/forge-headless/src/main/java/forge/headless/TextUIGame.java
@@ -0,0 +1,477 @@
+package forge.headless;
+
+import forge.LobbyPlayer;
+import forge.deck.Deck;
+import forge.deck.io.DeckSerializer;
+import forge.game.*;
+import forge.game.card.Card;
+import forge.game.phase.PhaseType;
+import forge.game.player.Player;
+import forge.game.player.RegisteredPlayer;
+import forge.game.zone.ZoneType;
+import forge.item.PaperCard;
+import forge.localinstance.properties.ForgeConstants;
+import forge.model.FModel;
+import forge.player.GamePlayerUtil;
+import forge.util.FileUtil;
+import forge.util.FileSection;
+import forge.util.MyRandom;
+import picocli.CommandLine;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Random;
+
+/**
+ * Text UI game mode for Forge.
+ * Allows interactive gameplay through a text-based interface.
+ */
+public class TextUIGame {
+
+ public static void run(String[] args) {
+ // Strip the "tui" command from args - Main.java passes it but picocli doesn't need it
+ String[] tuiArgs = new String[args.length - 1];
+ System.arraycopy(args, 1, tuiArgs, 0, args.length - 1);
+
+ // Use picocli to parse command line arguments
+ TUICommand command = new TUICommand();
+ CommandLine cmd = new CommandLine(command);
+ cmd.setUnmatchedArgumentsAllowed(false);
+
+ int exitCode = cmd.execute(tuiArgs);
+ if (exitCode != 0) {
+ System.exit(exitCode);
+ }
+ }
+
+ /**
+ * Run the game with parsed command-line options.
+ * Called by TUICommand after picocli parses the arguments.
+ */
+ public static void runGame(TUICommand cmd) {
+ System.out.println("=== Forge Text UI Mode ===");
+
+ // Extract parsed options from command
+ String humanDeckName = cmd.deck1;
+ String aiDeckName = cmd.deck2;
+ String gameTypeStr = cmd.gameType;
+ AgentType player1Agent = cmd.player1Agent;
+ AgentType player2Agent = cmd.player2Agent;
+ boolean askMana = cmd.askMana;
+ boolean numericChoices = cmd.numericChoices;
+ Long seed = cmd.seed;
+ String startStatePath = cmd.startStatePath;
+
+ // Set random seed if provided (must be done BEFORE creating the game)
+ if (seed != null) {
+ System.out.println("Setting random seed: " + seed);
+ MyRandom.setRandom(new Random(seed));
+ }
+
+ // NOW load the card database after all arguments have been validated
+ // Use lazy loading to only load cards from the decks being played
+ System.out.println("Initializing Forge (lazy card loading enabled)...");
+ Thread cardLoadingThread = new Thread(() -> {
+ FModel.initialize(null, null);
+ }, "CardLoadingThread");
+ cardLoadingThread.start();
+
+ // Install TUI GUI base which intercepts game log messages
+ TUIGuiBase.install();
+
+ // Wait for card loading to complete
+ try {
+ cardLoadingThread.join();
+ } catch (InterruptedException e) {
+ System.err.println("Card loading interrupted: " + e.getMessage());
+ Thread.currentThread().interrupt();
+ return;
+ }
+ System.out.println("Card database loaded successfully.");
+
+ // Now parse GameType (requires FModel to be initialized)
+ GameType type;
+ try {
+ type = GameType.valueOf(gameTypeStr);
+ } catch (IllegalArgumentException e) {
+ System.err.println("Invalid game type: " + gameTypeStr);
+ System.err.println("Valid options: Constructed, Commander, etc.");
+ return;
+ }
+
+ // Load decks
+ Deck humanDeck = loadDeck(humanDeckName, type);
+ Deck aiDeck = loadDeck(aiDeckName, type);
+
+ if (humanDeck == null || aiDeck == null) {
+ System.out.println("Failed to load decks. Exiting.");
+ return;
+ }
+
+ System.out.println("Starting game: Player 1 (" + humanDeck.getName() + ", " + player1Agent + ") vs Player 2 (" + aiDeck.getName() + ", " + player2Agent + ")");
+ System.out.println();
+
+ // Create players
+ List players = new ArrayList<>();
+
+ // Player 1
+ RegisteredPlayer player1;
+ if (type.equals(GameType.Commander)) {
+ player1 = RegisteredPlayer.forCommander(humanDeck);
+ } else {
+ player1 = new RegisteredPlayer(humanDeck);
+ }
+ // Create LobbyPlayer based on agent type
+ LobbyPlayer player1Lobby;
+ if (player1Agent == AgentType.AI) {
+ player1Lobby = GamePlayerUtil.createAiPlayer("AI-" + humanDeck.getName(), 0);
+ } else {
+ player1Lobby = GamePlayerUtil.getGuiPlayer("Player 1", 0, 0, false);
+ }
+ player1.setPlayer(player1Lobby);
+ players.add(player1);
+
+ // Player 2
+ RegisteredPlayer player2;
+ if (type.equals(GameType.Commander)) {
+ player2 = RegisteredPlayer.forCommander(aiDeck);
+ } else {
+ player2 = new RegisteredPlayer(aiDeck);
+ }
+ // Create LobbyPlayer based on agent type
+ LobbyPlayer player2Lobby;
+ if (player2Agent == AgentType.AI) {
+ player2Lobby = GamePlayerUtil.createAiPlayer("AI-" + aiDeck.getName(), 0);
+ } else {
+ player2Lobby = GamePlayerUtil.getGuiPlayer("Player 2", 1, 1, false);
+ }
+ player2.setPlayer(player2Lobby);
+ players.add(player2);
+
+ // Create and start the match
+ GameRules rules = new GameRules(type);
+ Match match = new Match(rules, players, "TUI Game");
+
+ Game game = match.createGame();
+
+ // Replace player controllers with TUI controllers
+ // We need to do this after game creation but BEFORE startGame
+ // because startGame calls prepareAllZones which may call controller methods
+ Player player1GamePlayer = null;
+ Player player2GamePlayer = null;
+
+ for (Player p : game.getPlayers()) {
+ if (p.getLobbyPlayer() == player1Lobby) {
+ player1GamePlayer = p;
+ System.out.println("Found player 1: " + p.getName());
+ } else if (p.getLobbyPlayer() == player2Lobby) {
+ player2GamePlayer = p;
+ System.out.println("Found player 2: " + p.getName());
+ }
+ }
+
+ // Install controller for player 1 based on agent type
+ if (player1GamePlayer != null && player1Agent != AgentType.AI) {
+ installController(player1GamePlayer, player1Lobby, game, player1Agent, askMana, numericChoices);
+ }
+
+ // Install controller for player 2 based on agent type
+ if (player2GamePlayer != null && player2Agent != AgentType.AI) {
+ installController(player2GamePlayer, player2Lobby, game, player2Agent, askMana, numericChoices);
+ }
+
+ // Set the current game for log monitoring
+ TUIGuiBase.setCurrentGame(game);
+
+ // Load game state from .pzl file if provided
+ if (startStatePath != null) {
+ System.out.println("Loading game state from: " + startStatePath);
+ if (!loadGameState(game, startStatePath)) {
+ System.err.println("Failed to load game state. Exiting.");
+ return;
+ }
+ }
+
+ // Start the game
+ System.out.println("Game starting...");
+ System.out.println("=".repeat(60));
+
+ if (startStatePath != null) {
+ // Game state was loaded, so the game is already set up
+ // Just run the game loop
+ game.getAction().invoke(() -> {
+ game.getPhaseHandler().devModeSet(game.getPhaseHandler().getPhase(),
+ game.getPhaseHandler().getPlayerTurn(),
+ game.getPhaseHandler().getTurn());
+ });
+ } else {
+ // Normal game start
+ match.startGame(game);
+ }
+
+ // Game is over
+ System.out.println();
+ System.out.println("=".repeat(60));
+ System.out.println("GAME OVER");
+
+ if (game.getOutcome().isDraw()) {
+ System.out.println("Result: Draw!");
+ } else {
+ System.out.println("Winner: " + game.getOutcome().getWinningLobbyPlayer().getName());
+ }
+
+ // Print choice statistics if available
+ System.out.println();
+ System.out.println("=== Choice Statistics ===");
+
+ if (player1GamePlayer != null && player1GamePlayer.getController() instanceof PlayerControllerTUI) {
+ PlayerControllerTUI tuiController1 = (PlayerControllerTUI) player1GamePlayer.getController();
+ System.out.println("Player 1 (" + player1GamePlayer.getName() + "):");
+ System.out.println(" Total choices made: " + tuiController1.getTotalChoicesMade());
+ System.out.println(" Total options presented: " + tuiController1.getTotalChoiceOptions());
+ if (tuiController1.getTotalChoicesMade() > 0) {
+ double avgOptions = (double) tuiController1.getTotalChoiceOptions() / tuiController1.getTotalChoicesMade();
+ System.out.printf(" Average options per choice: %.2f%n", avgOptions);
+ }
+ }
+
+ if (player2GamePlayer != null && player2GamePlayer.getController() instanceof PlayerControllerTUI) {
+ PlayerControllerTUI tuiController2 = (PlayerControllerTUI) player2GamePlayer.getController();
+ System.out.println("Player 2 (" + player2GamePlayer.getName() + "):");
+ System.out.println(" Total choices made: " + tuiController2.getTotalChoicesMade());
+ System.out.println(" Total options presented: " + tuiController2.getTotalChoiceOptions());
+ if (tuiController2.getTotalChoicesMade() > 0) {
+ double avgOptions = (double) tuiController2.getTotalChoiceOptions() / tuiController2.getTotalChoicesMade();
+ System.out.printf(" Average options per choice: %.2f%n", avgOptions);
+ }
+ }
+ }
+
+ /**
+ * Helper method to install a controller for a player using reflection.
+ */
+ private static void installController(Player gamePlayer, LobbyPlayer lobbyPlayer, Game game,
+ AgentType agentType, boolean askMana, boolean numericChoices) {
+ try {
+ forge.game.player.PlayerController controller;
+
+ switch (agentType) {
+ case TUI:
+ controller = new PlayerControllerTUI(game, gamePlayer, lobbyPlayer, askMana, numericChoices);
+ break;
+ case ZERO:
+ controller = new PlayerControllerZero(game, gamePlayer, lobbyPlayer, askMana, numericChoices);
+ break;
+ case RANDOM:
+ controller = new PlayerControllerRandom(game, gamePlayer, lobbyPlayer, askMana, numericChoices);
+ break;
+ case AI:
+ // AI controller is already installed, no need to replace
+ return;
+ default:
+ System.err.println("Unknown agent type: " + agentType);
+ return;
+ }
+
+ java.lang.reflect.Field controllerField = Player.class.getDeclaredField("controller");
+ controllerField.setAccessible(true);
+ controllerField.set(gamePlayer, controller);
+ System.out.println(agentType + " Controller installed for player: " + gamePlayer.getName());
+ } catch (Exception e) {
+ System.err.println("Failed to install controller: " + e.getMessage());
+ e.printStackTrace();
+ }
+ }
+
+ private static Deck loadDeck(String deckName, GameType type) {
+ int dotPos = deckName.lastIndexOf('.');
+ if (dotPos > 0 && dotPos == deckName.length() - 4) {
+ // It's a file - try to resolve it in this order:
+ // 1. As-is (absolute or relative to current directory)
+ // 2. Relative to the base deck directory
+ File f = new File(deckName);
+
+ // If not found as-is, try with the base directory
+ if (!f.exists()) {
+ String baseDir = type.equals(GameType.Commander) ?
+ ForgeConstants.DECK_COMMANDER_DIR : ForgeConstants.DECK_CONSTRUCTED_DIR;
+ f = new File(baseDir, deckName);
+ }
+
+ if (!f.exists()) {
+ System.out.println("Deck file not found: " + deckName);
+ System.out.println(" Tried as: " + new File(deckName).getAbsolutePath());
+ String baseDir = type.equals(GameType.Commander) ?
+ ForgeConstants.DECK_COMMANDER_DIR : ForgeConstants.DECK_CONSTRUCTED_DIR;
+ System.out.println(" Tried in: " + new File(baseDir, deckName).getAbsolutePath());
+ return null;
+ }
+
+ return DeckSerializer.fromFile(f);
+ }
+
+ // It's a deck name
+ if (type.equals(GameType.Commander)) {
+ return FModel.getDecks().getCommander().get(deckName);
+ } else {
+ return FModel.getDecks().getConstructed().get(deckName);
+ }
+ }
+
+ /**
+ * Loads a game state from a .pzl file and applies it to the game.
+ * This is a simplified implementation that directly manipulates zones.
+ *
+ * @param game The game to apply the state to
+ * @param puzzleFilePath Path to the .pzl file
+ * @return true if successful, false otherwise
+ */
+ private static boolean loadGameState(Game game, String puzzleFilePath) {
+ try {
+ File puzzleFile = new File(puzzleFilePath);
+ if (!puzzleFile.exists()) {
+ System.err.println("Puzzle file not found: " + puzzleFilePath);
+ return false;
+ }
+
+ // Read and parse the puzzle file
+ List pfData = FileUtil.readFile(puzzleFilePath);
+ Map> puzzleSections = FileSection.parseSections(pfData);
+
+ // Get the [state] section
+ List stateLines = puzzleSections.get("state");
+ if (stateLines == null || stateLines.isEmpty()) {
+ System.err.println("No [state] section found in puzzle file");
+ return false;
+ }
+
+ // Parse state into a map
+ Map stateMap = new java.util.HashMap<>();
+ for (String line : stateLines) {
+ if (line.contains("=")) {
+ String[] parts = line.split("=", 2);
+ stateMap.put(parts[0].trim(), parts[1].trim());
+ }
+ }
+
+ // Apply state to game within the game's action thread
+ final Map finalStateMap = stateMap;
+ game.getAction().invoke(() -> applyPuzzleState(game, finalStateMap));
+
+ System.out.println("Game state loaded successfully from: " + puzzleFilePath);
+ return true;
+ } catch (Exception e) {
+ System.err.println("Error loading game state from puzzle file: " + e.getMessage());
+ e.printStackTrace();
+ return false;
+ }
+ }
+
+ /**
+ * Applies the parsed puzzle state to the game.
+ * Must be called within game.getAction().invoke()
+ */
+ private static void applyPuzzleState(Game game, Map state) {
+ try {
+ List players = game.getPlayers();
+ if (players.size() < 2) {
+ throw new RuntimeException("Game must have at least 2 players");
+ }
+
+ Player humanPlayer = players.get(0);
+ Player aiPlayer = players.get(1);
+
+ // Set life totals
+ if (state.containsKey("humanlife")) {
+ humanPlayer.setLife(Integer.parseInt(state.get("humanlife")), null);
+ }
+ if (state.containsKey("ailife")) {
+ aiPlayer.setLife(Integer.parseInt(state.get("ailife")), null);
+ }
+
+ // Clear all zones first by removing cards one by one
+ clearZone(game, humanPlayer, ZoneType.Hand);
+ clearZone(game, humanPlayer, ZoneType.Library);
+ clearZone(game, humanPlayer, ZoneType.Battlefield);
+ clearZone(game, humanPlayer, ZoneType.Graveyard);
+
+ clearZone(game, aiPlayer, ZoneType.Hand);
+ clearZone(game, aiPlayer, ZoneType.Library);
+ clearZone(game, aiPlayer, ZoneType.Battlefield);
+ clearZone(game, aiPlayer, ZoneType.Graveyard);
+
+ // Add cards to zones
+ addCardsToZone(game, humanPlayer, ZoneType.Hand, state.get("humanhand"));
+ addCardsToZone(game, humanPlayer, ZoneType.Library, state.get("humanlibrary"));
+ addCardsToZone(game, humanPlayer, ZoneType.Battlefield, state.get("humanbattlefield"));
+ addCardsToZone(game, humanPlayer, ZoneType.Graveyard, state.get("humangraveyard"));
+
+ addCardsToZone(game, aiPlayer, ZoneType.Hand, state.get("aihand"));
+ addCardsToZone(game, aiPlayer, ZoneType.Library, state.get("ailibrary"));
+ addCardsToZone(game, aiPlayer, ZoneType.Battlefield, state.get("aibattlefield"));
+ addCardsToZone(game, aiPlayer, ZoneType.Graveyard, state.get("aigraveyard"));
+
+ // Set turn and phase
+ int turn = state.containsKey("turn") ? Integer.parseInt(state.get("turn")) : 1;
+ String activePlayerStr = state.get("activeplayer");
+ Player activePlayer = "ai".equalsIgnoreCase(activePlayerStr) ? aiPlayer : humanPlayer;
+
+ String phaseStr = state.get("activephase");
+ PhaseType phase = phaseStr != null ? PhaseType.smartValueOf(phaseStr) : PhaseType.MAIN1;
+
+ game.getPhaseHandler().devModeSet(phase, activePlayer, turn);
+
+ System.out.println("Puzzle state applied: Turn " + turn + ", " + activePlayer.getName() + "'s " + phase);
+ } catch (Exception e) {
+ System.err.println("Error applying puzzle state: " + e.getMessage());
+ e.printStackTrace();
+ }
+ }
+
+ /**
+ * Clears all cards from a zone
+ */
+ private static void clearZone(Game game, Player player, ZoneType zone) {
+ // Make a copy of the card list to avoid concurrent modification
+ List cards = new ArrayList();
+ for (Card card : player.getZone(zone).getCards()) {
+ cards.add(card);
+ }
+ for (Card card : cards) {
+ game.getAction().exile(card, null, null);
+ }
+ }
+
+ /**
+ * Adds cards to a zone from a semicolon-separated string
+ */
+ private static void addCardsToZone(Game game, Player player, ZoneType zone, String cardListStr) {
+ if (cardListStr == null || cardListStr.trim().isEmpty()) {
+ return;
+ }
+
+ String[] cardNames = cardListStr.split(";");
+ for (String cardName : cardNames) {
+ cardName = cardName.trim();
+ if (!cardName.isEmpty()) {
+ try {
+ // Create a card from the name
+ PaperCard paperCard = FModel.getMagicDb().getCommonCards().getCard(cardName);
+ if (paperCard == null) {
+ System.err.println("Warning: Card not found: " + cardName);
+ continue;
+ }
+
+ Card card = Card.fromPaperCard(paperCard, player);
+
+ // Add to the appropriate zone
+ player.getZone(zone).add(card);
+ } catch (Exception e) {
+ System.err.println("Error adding card " + cardName + " to " + zone + ": " + e.getMessage());
+ }
+ }
+ }
+ }
+}
diff --git a/forge-headless/src/main/java/forge/headless/TimeLimitedCodeBlock.java b/forge-headless/src/main/java/forge/headless/TimeLimitedCodeBlock.java
new file mode 100644
index 000000000000..69382e587d05
--- /dev/null
+++ b/forge-headless/src/main/java/forge/headless/TimeLimitedCodeBlock.java
@@ -0,0 +1,49 @@
+package forge.headless;
+
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+/**
+ * Created by maustin on 08/02/2018.
+ */
+public class TimeLimitedCodeBlock {
+
+ public static void runWithTimeout(final Runnable runnable, long timeout, TimeUnit timeUnit) throws Exception {
+ runWithTimeout(() -> {
+ runnable.run();
+ return null;
+ }, timeout, timeUnit);
+ }
+
+ public static T runWithTimeout(Callable callable, long timeout, TimeUnit timeUnit) throws Exception {
+ final ExecutorService executor = Executors.newSingleThreadExecutor();
+ final Future future = executor.submit(callable);
+ executor.shutdown(); // This does not cancel the already-scheduled task.
+ try {
+ return future.get(timeout, timeUnit);
+ }
+ catch (TimeoutException e) {
+ //remove this if you do not want to cancel the job in progress
+ //or set the argument to 'false' if you do not want to interrupt the thread
+ future.cancel(true);
+ throw e;
+ }
+ catch (ExecutionException e) {
+ //unwrap the root cause
+ Throwable t = e.getCause();
+ if (t instanceof Error) {
+ throw (Error) t;
+ } else if (t instanceof Exception) {
+ throw (Exception) t;
+ } else {
+ throw new IllegalStateException(t);
+ }
+ }
+ }
+
+}
diff --git a/forge-headless/src/test/java/forge/headless/GameLogCapture.java b/forge-headless/src/test/java/forge/headless/GameLogCapture.java
new file mode 100644
index 000000000000..53b235ff5304
--- /dev/null
+++ b/forge-headless/src/test/java/forge/headless/GameLogCapture.java
@@ -0,0 +1,135 @@
+package forge.headless;
+
+import forge.game.Game;
+import forge.game.GameLogEntry;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Utility for capturing and analyzing game logs during tests.
+ * Provides methods to check game invariants.
+ */
+public class GameLogCapture {
+
+ private final Game game;
+ private final List capturedLogs;
+
+ public GameLogCapture(Game game) {
+ this.game = game;
+ this.capturedLogs = new ArrayList<>();
+ }
+
+ /**
+ * Capture all log entries from the game.
+ */
+ public void captureLogs() {
+ List entries = game.getGameLog().getLogEntries(null);
+ capturedLogs.clear();
+ // Logs are in reverse order (newest first), so reverse them
+ for (int i = entries.size() - 1; i >= 0; i--) {
+ capturedLogs.add(entries.get(i).toString());
+ }
+ }
+
+ /**
+ * Get all captured log entries.
+ */
+ public List getCapturedLogs() {
+ return new ArrayList<>(capturedLogs);
+ }
+
+ /**
+ * Get the maximum turn number reached in the game.
+ */
+ public int getMaxTurn() {
+ Pattern turnPattern = Pattern.compile("Turn (\\d+)");
+ int maxTurn = 0;
+ for (String log : capturedLogs) {
+ Matcher m = turnPattern.matcher(log);
+ if (m.find()) {
+ int turn = Integer.parseInt(m.group(1));
+ if (turn > maxTurn) {
+ maxTurn = turn;
+ }
+ }
+ }
+ return maxTurn;
+ }
+
+ /**
+ * Check if a land was played during the game.
+ */
+ public boolean hasLandPlayed() {
+ for (String log : capturedLogs) {
+ if (log.contains("played Mountain") ||
+ log.contains("played Plains") ||
+ log.contains("played Forest") ||
+ log.contains("played Island") ||
+ log.contains("played Swamp")) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Check if any creatures were cast during the game.
+ */
+ public boolean hasCreatureCast() {
+ for (String log : capturedLogs) {
+ if (log.contains("cast") && log.contains("Creature")) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Check if any spell was cast during the game.
+ */
+ public boolean hasSpellCast() {
+ for (String log : capturedLogs) {
+ if (log.contains("cast")) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Check if there are any error messages in the logs.
+ * Also captures System.out/err messages by checking if specific error patterns occurred.
+ */
+ public boolean hasErrors() {
+ for (String log : capturedLogs) {
+ if (log.contains("error") || log.contains("Error") ||
+ log.contains("Exception") || log.contains("failed")) {
+ return true;
+ }
+ // Check for specific error patterns that indicate problems
+ if (log.contains("cost was not paid for")) {
+ return true;
+ }
+ if (log.contains("Couldn't add to stack, failed to target")) {
+ return true;
+ }
+ if (log.contains("Did not have activator set")) {
+ return true;
+ }
+ if (log.contains("AI failed to play")) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Get all logs as a single string.
+ */
+ public String getLogsAsString() {
+ return String.join("\n", capturedLogs);
+ }
+}
diff --git a/forge-headless/src/test/java/forge/headless/MainHelpTest.java b/forge-headless/src/test/java/forge/headless/MainHelpTest.java
new file mode 100644
index 000000000000..3892c22e6356
--- /dev/null
+++ b/forge-headless/src/test/java/forge/headless/MainHelpTest.java
@@ -0,0 +1,44 @@
+package forge.headless;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.Test;
+
+public class MainHelpTest {
+ @Test
+ public void topLevelHelpExplainsCommandsAndExamples() {
+ SystemOutputCapture output = new SystemOutputCapture();
+ output.startCapture();
+ int exitCode;
+ try {
+ exitCode = Main.run(new String[] {"--help"});
+ } finally {
+ output.stopCapture();
+ }
+
+ assertEquals(0, exitCode);
+ assertTrue(output.getOutput().contains("Run Forge games without starting"));
+ assertTrue(output.getOutput().contains("sim Run automated games"));
+ assertTrue(output.getOutput().contains("tui Play or observe a game"));
+ assertTrue(output.getOutput().contains("Examples:"));
+ assertFalse(output.getOutput().contains("Unknown command"));
+ }
+
+ @Test
+ public void simulationHelpExplainsRequiredDecksAndOptions() {
+ SystemOutputCapture output = new SystemOutputCapture();
+ output.startCapture();
+ try {
+ SimulateMatch.simulate(new String[] {"sim", "--help"});
+ } finally {
+ output.stopCapture();
+ }
+
+ assertTrue(output.getOutput().contains("Run automated Forge games"));
+ assertTrue(output.getOutput().contains("At least two are required"));
+ assertTrue(output.getOutput().contains("-s "));
+ assertTrue(output.getOutput().contains("Examples:"));
+ }
+}
diff --git a/forge-headless/src/test/java/forge/headless/SimulateMatchTest.java b/forge-headless/src/test/java/forge/headless/SimulateMatchTest.java
new file mode 100644
index 000000000000..1cb1372d772c
--- /dev/null
+++ b/forge-headless/src/test/java/forge/headless/SimulateMatchTest.java
@@ -0,0 +1,24 @@
+package forge.headless;
+
+import static org.junit.Assert.assertNotNull;
+
+import java.nio.file.Path;
+
+import org.junit.Test;
+
+import forge.game.GameType;
+
+public class SimulateMatchTest {
+ private static final Path TEST_DECK = Path.of("test_decks", "monored.dck");
+
+ @Test
+ public void loadsDeckFromRelativeFilePath() {
+ assertNotNull(SimulateMatch.deckFromCommandLineParameter(TEST_DECK.toString(), GameType.Constructed));
+ }
+
+ @Test
+ public void loadsDeckFromAbsoluteFilePath() {
+ assertNotNull(SimulateMatch.deckFromCommandLineParameter(
+ TEST_DECK.toAbsolutePath().toString(), GameType.Constructed));
+ }
+}
diff --git a/forge-headless/src/test/java/forge/headless/SystemOutputCapture.java b/forge-headless/src/test/java/forge/headless/SystemOutputCapture.java
new file mode 100644
index 000000000000..d81b26d96638
--- /dev/null
+++ b/forge-headless/src/test/java/forge/headless/SystemOutputCapture.java
@@ -0,0 +1,114 @@
+package forge.headless;
+
+import java.io.ByteArrayOutputStream;
+import java.io.PrintStream;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Utility for capturing System.out and System.err during tests.
+ * Helps detect error messages that aren't logged to the game log.
+ */
+public class SystemOutputCapture {
+ private final ByteArrayOutputStream outputStream;
+ private final ByteArrayOutputStream errorStream;
+ private final PrintStream originalOut;
+ private final PrintStream originalErr;
+ private boolean capturing = false;
+
+ public SystemOutputCapture() {
+ this.outputStream = new ByteArrayOutputStream();
+ this.errorStream = new ByteArrayOutputStream();
+ this.originalOut = System.out;
+ this.originalErr = System.err;
+ }
+
+ /**
+ * Start capturing System.out and System.err.
+ */
+ public void startCapture() {
+ if (!capturing) {
+ System.setOut(new PrintStream(outputStream));
+ System.setErr(new PrintStream(errorStream));
+ capturing = true;
+ }
+ }
+
+ /**
+ * Stop capturing and restore original streams.
+ */
+ public void stopCapture() {
+ if (capturing) {
+ System.setOut(originalOut);
+ System.setErr(originalErr);
+ capturing = false;
+ }
+ }
+
+ /**
+ * Get the captured output.
+ */
+ public String getOutput() {
+ return outputStream.toString();
+ }
+
+ /**
+ * Get the captured error output.
+ */
+ public String getErrorOutput() {
+ return errorStream.toString();
+ }
+
+ /**
+ * Get all captured output (both stdout and stderr).
+ */
+ public String getAllOutput() {
+ return getOutput() + getErrorOutput();
+ }
+
+ /**
+ * Check if any error patterns were captured.
+ * NOTE: "Did not have activator set" is treated as a warning, not a fatal error,
+ * since it's self-correcting and comes from deep in the game engine.
+ */
+ public boolean hasErrorPatterns() {
+ String allOutput = getAllOutput();
+ return checkForErrors(allOutput);
+ }
+
+ /**
+ * Get list of error patterns found in output.
+ */
+ public List getErrorPatterns() {
+ List errors = new ArrayList<>();
+ String allOutput = getAllOutput();
+
+ if (allOutput.contains("cost was not paid for")) {
+ errors.add("Mana cost payment failure detected");
+ }
+ if (allOutput.contains("Couldn't add to stack, failed to target")) {
+ errors.add("Targeting failure detected");
+ }
+ if (allOutput.contains("AI failed to play")) {
+ errors.add("AI play failure detected");
+ }
+ // Note: "Did not have activator set" warnings are excluded - they're self-correcting
+
+ return errors;
+ }
+
+ private boolean checkForErrors(String output) {
+ // Only check for actual errors, not self-correcting warnings
+ return output.contains("cost was not paid for") ||
+ output.contains("Couldn't add to stack, failed to target") ||
+ output.contains("AI failed to play");
+ }
+
+ /**
+ * Clear captured output.
+ */
+ public void clear() {
+ outputStream.reset();
+ errorStream.reset();
+ }
+}
diff --git a/forge-headless/src/test/java/forge/headless/TUIGameTest.java b/forge-headless/src/test/java/forge/headless/TUIGameTest.java
new file mode 100644
index 000000000000..9ed305331fa5
--- /dev/null
+++ b/forge-headless/src/test/java/forge/headless/TUIGameTest.java
@@ -0,0 +1,249 @@
+package forge.headless;
+
+import forge.LobbyPlayer;
+import forge.deck.Deck;
+import forge.deck.io.DeckSerializer;
+import forge.game.*;
+import forge.game.player.Player;
+import forge.game.player.RegisteredPlayer;
+import forge.gui.GuiBase;
+import forge.model.FModel;
+import forge.player.GamePlayerUtil;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+import java.io.File;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Random;
+
+import static org.junit.Assert.*;
+
+/**
+ * Unit tests for TUI gameplay with test controllers.
+ * These tests run much faster than the Python e2e tests because
+ * they run in-process without subprocess overhead.
+ */
+public class TUIGameTest {
+
+ @BeforeClass
+ public static void setup() {
+ // Setup headless GUI interface FIRST (before FModel.initialize)
+ // because ForgeConstants needs GuiBase to be set during initialization
+ GuiBase.setInterface(new HeadlessGuiBase());
+
+ // Initialize Forge model once for all tests
+ FModel.initialize(null, null);
+ }
+
+ /**
+ * Test that a pass-only controller loses against the AI.
+ *
+ * Invariants:
+ * - Game completes without errors
+ * - Game takes more than 2 turns
+ * - AI wins (human who passes always loses)
+ * - Some permanents on battlefield by end
+ */
+ @Test
+ public void testPassAgentLoses() {
+ System.out.println("\n=== Testing Pass Agent (always passes) ===");
+
+ // Load test decks
+ Deck deck = loadTestDeck("monored.dck");
+ assertNotNull("Failed to load test deck", deck);
+
+ // Create and run game
+ GameOutcome outcome = runGameWithTestController(deck, deck, true, false);
+
+ // Verify game completed
+ assertNotNull("Game outcome should not be null", outcome);
+ assertFalse("Game should not be a draw", outcome.isDraw());
+
+ // Verify AI won
+ String winnerName = outcome.getWinningLobbyPlayer().getName();
+ assertTrue("AI should win, but winner was: " + winnerName,
+ winnerName.contains("AI") || winnerName.contains("Ai"));
+
+ System.out.println(" ✓ AI won as expected: " + winnerName);
+ }
+
+ /**
+ * Test that a random controller can complete games without errors.
+ *
+ * Invariants:
+ * - Game completes without errors
+ * - Game takes at least 3 turns
+ * - No Java exceptions occur
+ * - No mana payment failures
+ * - No targeting failures
+ * - No activator warnings
+ */
+ @Test
+ public void testRandomAgentCompletes() {
+ System.out.println("\n=== Testing Random Agent ===");
+
+ // Load test decks
+ Deck deck = loadTestDeck("monored.dck");
+ assertNotNull("Failed to load test deck", deck);
+
+ // Capture System.out/err to detect error patterns
+ SystemOutputCapture outputCapture = new SystemOutputCapture();
+ outputCapture.startCapture();
+
+ try {
+ // Create and run game with random controller
+ GameOutcome outcome = runGameWithTestController(deck, deck, false, false);
+
+ // Verify game completed
+ assertNotNull("Game outcome should not be null", outcome);
+ assertTrue("Game should have a winner or be a draw",
+ outcome.getWinningPlayer() != null || outcome.isDraw());
+
+ if (outcome.isDraw()) {
+ System.out.println(" ✓ Game ended in draw");
+ } else {
+ System.out.println(" ✓ Game completed, winner: " + outcome.getWinningLobbyPlayer().getName());
+ }
+ } finally {
+ outputCapture.stopCapture();
+ }
+
+ // Check for error patterns in captured output
+ if (outputCapture.hasErrorPatterns()) {
+ List errors = outputCapture.getErrorPatterns();
+ fail("Random agent generated errors:\n" + String.join("\n", errors) +
+ "\n\nCaptured output:\n" + outputCapture.getAllOutput());
+ }
+ }
+
+ /**
+ * Test that battlefield state progresses over multiple turns.
+ */
+ @Test
+ public void testBattlefieldProgression() {
+ System.out.println("\n=== Testing Battlefield Progression ===");
+
+ // Load test decks
+ Deck deck = loadTestDeck("monored.dck");
+ assertNotNull("Failed to load test deck", deck);
+
+ // Run game and capture details
+ GameOutcome outcome = runGameWithTestController(deck, deck, true, true);
+
+ assertNotNull("Game outcome should not be null", outcome);
+ System.out.println(" ✓ Game progressed with battlefield changes");
+ }
+
+ /**
+ * Helper method to load a test deck.
+ */
+ private Deck loadTestDeck(String deckName) {
+ // Try to find test deck relative to the test class
+ Path testDeckPath = Paths.get("test_decks", deckName);
+ File deckFile = testDeckPath.toFile();
+
+ if (!deckFile.exists()) {
+ // Try absolute path construction
+ String projectRoot = System.getProperty("user.dir");
+ deckFile = Paths.get(projectRoot, "test_decks", deckName).toFile();
+ }
+
+ if (!deckFile.exists()) {
+ fail("Could not find test deck: " + deckName + " (tried: " + deckFile.getAbsolutePath() + ")");
+ }
+
+ return DeckSerializer.fromFile(deckFile);
+ }
+
+ /**
+ * Helper method to run a game with test controllers.
+ *
+ * @param player1Deck Deck for player 1
+ * @param player2Deck Deck for player 2
+ * @param player1Pass If true, player 1 uses pass controller; else random
+ * @param captureDetails If true, capture and print game details
+ * @return The game outcome
+ */
+ private GameOutcome runGameWithTestController(Deck player1Deck, Deck player2Deck,
+ boolean player1Pass, boolean captureDetails) {
+ // Create players
+ List players = new ArrayList<>();
+
+ // Player 1
+ RegisteredPlayer player1 = new RegisteredPlayer(player1Deck);
+ LobbyPlayer player1Lobby = GamePlayerUtil.getGuiPlayer("Player 1", 0, 0, false);
+ player1.setPlayer(player1Lobby);
+ players.add(player1);
+
+ // Player 2 (AI)
+ RegisteredPlayer player2 = new RegisteredPlayer(player2Deck);
+ LobbyPlayer player2Lobby = GamePlayerUtil.createAiPlayer("AI-TestDeck", 0);
+ player2.setPlayer(player2Lobby);
+ players.add(player2);
+
+ // Create and start the match
+ GameRules rules = new GameRules(GameType.Constructed);
+ Match match = new Match(rules, players, "Test Game");
+
+ Game game = match.createGame();
+
+ // Replace player 1's controller with test controller
+ Player player1GamePlayer = null;
+ for (Player p : game.getPlayers()) {
+ if (p.getLobbyPlayer() == player1Lobby) {
+ player1GamePlayer = p;
+ break;
+ }
+ }
+
+ if (player1GamePlayer != null) {
+ try {
+ java.lang.reflect.Field controllerField = Player.class.getDeclaredField("controller");
+ controllerField.setAccessible(true);
+
+ if (player1Pass) {
+ TestPassController testController = new TestPassController(game, player1GamePlayer, player1Lobby);
+ controllerField.set(player1GamePlayer, testController);
+ } else {
+ TestRandomController testController = new TestRandomController(game, player1GamePlayer, player1Lobby,
+ new Random(42)); // Fixed seed for reproducibility
+ controllerField.set(player1GamePlayer, testController);
+ }
+ } catch (Exception e) {
+ fail("Failed to install test controller: " + e.getMessage());
+ }
+ }
+
+ // Capture logs if requested
+ GameLogCapture logCapture = null;
+ if (captureDetails) {
+ logCapture = new GameLogCapture(game);
+ }
+
+ // Start the game
+ match.startGame(game);
+
+ // Capture logs after game completes
+ if (captureDetails && logCapture != null) {
+ logCapture.captureLogs();
+
+ int maxTurn = logCapture.getMaxTurn();
+ boolean hasLand = logCapture.hasLandPlayed();
+ boolean hasCreature = logCapture.hasCreatureCast();
+
+ System.out.println(" Game lasted " + maxTurn + " turns");
+ System.out.println(" Land played: " + hasLand);
+ System.out.println(" Creature cast: " + hasCreature);
+
+ // Verify invariants
+ assertTrue("Game should last more than 2 turns", maxTurn > 2);
+ assertTrue("At least one land should be played", hasLand);
+ assertFalse("No errors should occur", logCapture.hasErrors());
+ }
+
+ return game.getOutcome();
+ }
+}
diff --git a/forge-headless/src/test/java/forge/headless/TUIInteractiveTest.java b/forge-headless/src/test/java/forge/headless/TUIInteractiveTest.java
new file mode 100644
index 000000000000..5d371355b015
--- /dev/null
+++ b/forge-headless/src/test/java/forge/headless/TUIInteractiveTest.java
@@ -0,0 +1,237 @@
+package forge.headless;
+
+import forge.LobbyPlayer;
+import forge.deck.Deck;
+import forge.deck.io.DeckSerializer;
+import forge.game.*;
+import forge.game.player.Player;
+import forge.game.player.RegisteredPlayer;
+import forge.gui.GuiBase;
+import forge.model.FModel;
+import forge.player.GamePlayerUtil;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+import java.io.*;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.junit.Assert.*;
+
+/**
+ * Interactive TUI tests that simulate user input to test gameplay features.
+ * These tests use a scripted input stream to programmatically interact with the TUI.
+ */
+public class TUIInteractiveTest {
+
+ @BeforeClass
+ public static void setup() {
+ // Setup headless GUI interface FIRST (before FModel.initialize)
+ // because ForgeConstants needs GuiBase to be set during initialization
+ GuiBase.setInterface(new HeadlessGuiBase());
+
+ // Initialize Forge model once for all tests
+ FModel.initialize(null, null);
+ }
+
+ /**
+ * Test that simulates a player making choices through the TUI.
+ * The player will:
+ * 1. Play a land on turn 1
+ * 2. Pass priority several times
+ * 3. Cast a creature when they have mana
+ * 4. Eventually pass until the game ends
+ */
+ @Test
+ public void testInteractiveGameWithScriptedInput() {
+ System.out.println("\n=== Testing Interactive TUI with Scripted Input ===");
+
+ // Load test deck
+ Deck deck = loadTestDeck("monored.dck");
+ assertNotNull("Failed to load test deck", deck);
+
+ // Create a script: play land on turn 1, then pass for a while, then play creatures when possible
+ // The script uses mostly "0" (pass) but occasionally tries to take actions
+ String script = buildGameScript();
+
+ // Create game with scripted input
+ GameOutcome outcome = runGameWithScriptedInput(deck, deck, script);
+
+ // Verify game completed
+ assertNotNull("Game outcome should not be null", outcome);
+ assertTrue("Game should have a winner or be a draw",
+ outcome.getWinningPlayer() != null || outcome.isDraw());
+
+ if (outcome.isDraw()) {
+ System.out.println(" ✓ Game ended in draw");
+ } else {
+ System.out.println(" ✓ Game completed, winner: " + outcome.getWinningLobbyPlayer().getName());
+ }
+ }
+
+ /**
+ * Build a script that simulates realistic gameplay.
+ * Strategy: Try to play lands and creatures, but mostly pass.
+ */
+ private String buildGameScript() {
+ StringBuilder script = new StringBuilder();
+
+ // Turn 1: Try to play land (option 1 if available), then pass
+ script.append("1\n"); // Try to play first land
+ script.append("0\n"); // Pass priority
+
+ // Turns 2-5: Try to play land, try to cast spell, then pass a lot
+ for (int turn = 2; turn <= 5; turn++) {
+ script.append("1\n"); // Try to play land
+ script.append("1\n"); // Try to cast first available spell
+ for (int i = 0; i < 20; i++) {
+ script.append("0\n"); // Pass priority many times
+ }
+ }
+
+ // Rest of game: just pass to let it finish
+ for (int i = 0; i < 200; i++) {
+ script.append("0\n");
+ }
+
+ return script.toString();
+ }
+
+ /**
+ * Helper method to load a test deck.
+ */
+ private Deck loadTestDeck(String deckName) {
+ // Try to find test deck relative to the test class
+ Path testDeckPath = Paths.get("test_decks", deckName);
+ File deckFile = testDeckPath.toFile();
+
+ if (!deckFile.exists()) {
+ // Try absolute path construction
+ String projectRoot = System.getProperty("user.dir");
+ deckFile = Paths.get(projectRoot, "test_decks", deckName).toFile();
+ }
+
+ if (!deckFile.exists()) {
+ fail("Could not find test deck: " + deckName + " (tried: " + deckFile.getAbsolutePath() + ")");
+ }
+
+ return DeckSerializer.fromFile(deckFile);
+ }
+
+ /**
+ * Run a game with the TUI controller using scripted input.
+ */
+ private GameOutcome runGameWithScriptedInput(Deck player1Deck, Deck player2Deck, String inputScript) {
+ // Create players
+ List players = new ArrayList<>();
+
+ // Player 1 (TUI with scripted input)
+ RegisteredPlayer player1 = new RegisteredPlayer(player1Deck);
+ LobbyPlayer player1Lobby = GamePlayerUtil.getGuiPlayer("Player 1", 0, 0, false);
+ player1.setPlayer(player1Lobby);
+ players.add(player1);
+
+ // Player 2 (AI)
+ RegisteredPlayer player2 = new RegisteredPlayer(player2Deck);
+ LobbyPlayer player2Lobby = GamePlayerUtil.createAiPlayer("AI-TestDeck", 0);
+ player2.setPlayer(player2Lobby);
+ players.add(player2);
+
+ // Create and start the match
+ GameRules rules = new GameRules(GameType.Constructed);
+ Match match = new Match(rules, players, "TUI Interactive Test");
+
+ Game game = match.createGame();
+
+ // Replace player 1's controller with TUI controller using scripted input
+ Player player1GamePlayer = null;
+ for (Player p : game.getPlayers()) {
+ if (p.getLobbyPlayer() == player1Lobby) {
+ player1GamePlayer = p;
+ break;
+ }
+ }
+
+ if (player1GamePlayer != null) {
+ try {
+ // Create a BufferedReader from our script
+ BufferedReader scriptReader = new BufferedReader(new StringReader(inputScript));
+
+ // Create TUI controller with scripted input
+ PlayerControllerTUI tuiController = new PlayerControllerTUI(game, player1GamePlayer, player1Lobby, scriptReader);
+
+ // Use reflection to set the controller
+ java.lang.reflect.Field controllerField = Player.class.getDeclaredField("controller");
+ controllerField.setAccessible(true);
+ controllerField.set(player1GamePlayer, tuiController);
+
+ System.out.println(" Installed TUI controller with scripted input");
+ } catch (Exception e) {
+ fail("Failed to install TUI controller: " + e.getMessage());
+ }
+ }
+
+ // Redirect stdout to capture TUI output for analysis
+ ByteArrayOutputStream outputCapture = new ByteArrayOutputStream();
+ PrintStream originalOut = System.out;
+ System.setOut(new PrintStream(outputCapture));
+
+ try {
+ // Start the game
+ match.startGame(game);
+
+ // Restore stdout
+ System.setOut(originalOut);
+
+ // Analyze the captured output
+ String output = outputCapture.toString();
+ System.out.println(" Game produced " + output.length() + " bytes of output");
+
+ // Check for common issues in output
+ if (output.contains("Error") || output.contains("Exception")) {
+ System.out.println(" WARNING: Output contains error messages");
+ // Print relevant error lines
+ String[] lines = output.split("\n");
+ for (String line : lines) {
+ if (line.contains("Error") || line.contains("Exception")) {
+ System.out.println(" " + line);
+ }
+ }
+ }
+
+ // Check that the TUI was actually used
+ if (output.contains("YOUR TURN") || output.contains("What would you like to do?")) {
+ System.out.println(" ✓ TUI prompts detected in output");
+ } else {
+ System.out.println(" WARNING: No TUI prompts detected - controller may not have been called");
+ }
+
+ } finally {
+ System.setOut(originalOut);
+ }
+
+ return game.getOutcome();
+ }
+
+ /**
+ * Test that the TUI handles running out of scripted input gracefully.
+ */
+ @Test
+ public void testScriptedInputRunsOut() {
+ System.out.println("\n=== Testing TUI with Limited Script ===");
+
+ Deck deck = loadTestDeck("monored.dck");
+ assertNotNull("Failed to load test deck", deck);
+
+ // Very short script - will run out quickly
+ String shortScript = "0\n0\n0\n";
+
+ GameOutcome outcome = runGameWithScriptedInput(deck, deck, shortScript);
+
+ // Game should still complete (controller should use default choices when input runs out)
+ assertNotNull("Game outcome should not be null", outcome);
+ System.out.println(" ✓ Game handled running out of input");
+ }
+}
diff --git a/forge-headless/src/test/java/forge/headless/TargetingTest.java b/forge-headless/src/test/java/forge/headless/TargetingTest.java
new file mode 100644
index 000000000000..9430c976676f
--- /dev/null
+++ b/forge-headless/src/test/java/forge/headless/TargetingTest.java
@@ -0,0 +1,211 @@
+package forge.headless;
+
+import forge.LobbyPlayer;
+import forge.deck.Deck;
+import forge.deck.DeckSection;
+import forge.game.*;
+import forge.game.card.Card;
+import forge.game.player.Player;
+import forge.game.player.RegisteredPlayer;
+import forge.game.spellability.SpellAbility;
+import forge.gui.GuiBase;
+import forge.item.PaperCard;
+import forge.model.FModel;
+import forge.player.GamePlayerUtil;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.junit.Assert.*;
+
+/**
+ * Test targeting functionality in TUI.
+ * Uses controlled game state to reproduce targeting bugs.
+ */
+public class TargetingTest {
+
+ @BeforeClass
+ public static void setup() {
+ GuiBase.setInterface(new HeadlessGuiBase());
+ FModel.initialize(null, null);
+ }
+
+ /**
+ * Test that Lightning Strike can target creatures.
+ *
+ * Setup:
+ * - Player has Lightning Strike in hand, 2 Mountains on battlefield
+ * - Opponent has a creature on battlefield
+ *
+ * Expected:
+ * - Lightning Strike should be castable
+ * - Should be able to target opponent's creature
+ * - Should successfully add to stack
+ */
+ @Test
+ public void testLightningStrikeTargeting() {
+ System.out.println("\n=== Testing Lightning Strike Targeting ===");
+
+ // Create minimal decks
+ Deck playerDeck = createTestDeck("Lightning Strike", "Mountain");
+ Deck opponentDeck = createTestDeck("Ember Hauler", "Mountain");
+
+ // Create players
+ List players = new ArrayList<>();
+
+ RegisteredPlayer player1 = new RegisteredPlayer(playerDeck);
+ LobbyPlayer player1Lobby = GamePlayerUtil.getGuiPlayer("Player 1", 0, 0, false);
+ player1.setPlayer(player1Lobby);
+ players.add(player1);
+
+ RegisteredPlayer player2 = new RegisteredPlayer(opponentDeck);
+ LobbyPlayer player2Lobby = GamePlayerUtil.createAiPlayer("AI", 0);
+ player2.setPlayer(player2Lobby);
+ players.add(player2);
+
+ // Create game
+ GameRules rules = new GameRules(GameType.Constructed);
+ Match match = new Match(rules, players, "Targeting Test");
+ Game game = match.createGame();
+
+ // Find players
+ Player humanPlayer = null;
+ Player aiPlayer = null;
+ for (Player p : game.getPlayers()) {
+ if (p.getLobbyPlayer() == player1Lobby) {
+ humanPlayer = p;
+ } else if (p.getLobbyPlayer() == player2Lobby) {
+ aiPlayer = p;
+ }
+ }
+
+ assertNotNull("Human player should exist", humanPlayer);
+ assertNotNull("AI player should exist", aiPlayer);
+
+ // Set up controlled game state
+ setupGameState(game, humanPlayer, aiPlayer);
+
+ // Try to cast Lightning Strike
+ testCastingLightningStrike(game, humanPlayer, aiPlayer);
+
+ System.out.println(" ✓ Lightning Strike targeting test completed");
+ }
+
+ /**
+ * Set up a controlled game state for testing.
+ */
+ private void setupGameState(Game game, Player humanPlayer, Player aiPlayer) {
+ System.out.println(" Setting up game state...");
+
+ // Get card database
+ var cardDb = FModel.getMagicDb().getCommonCards();
+
+ // Create Lightning Strike for human player's hand
+ PaperCard lightningStrikePaper = cardDb.getCard("Lightning Strike");
+ if (lightningStrikePaper != null) {
+ Card lightningStrike = Card.fromPaperCard(lightningStrikePaper, humanPlayer);
+ game.getAction().moveToHand(lightningStrike, null);
+ System.out.println(" Added Lightning Strike to hand");
+ }
+
+ // Create 2 Mountains for human player's battlefield
+ PaperCard mountainPaper = cardDb.getCard("Mountain");
+ if (mountainPaper != null) {
+ for (int i = 0; i < 2; i++) {
+ Card mountain = Card.fromPaperCard(mountainPaper, humanPlayer);
+ game.getAction().moveToPlay(mountain, humanPlayer, null, null);
+ }
+ System.out.println(" Added 2 Mountains to battlefield");
+ }
+
+ // Create a creature for AI player's battlefield
+ PaperCard creaturePaper = cardDb.getCard("Ember Hauler");
+ if (creaturePaper != null) {
+ Card creature = Card.fromPaperCard(creaturePaper, aiPlayer);
+ game.getAction().moveToPlay(creature, aiPlayer, null, null);
+ System.out.println(" Added Ember Hauler to AI battlefield");
+ }
+
+ System.out.println(" ✓ Game state setup complete");
+ }
+
+ /**
+ * Test casting Lightning Strike with targeting.
+ */
+ private void testCastingLightningStrike(Game game, Player humanPlayer, Player aiPlayer) {
+ System.out.println(" Testing Lightning Strike cast...");
+
+ // Find Lightning Strike in hand
+ Card lightningStrike = null;
+ for (Card c : humanPlayer.getCardsIn(forge.game.zone.ZoneType.Hand)) {
+ if (c.getName().equals("Lightning Strike")) {
+ lightningStrike = c;
+ break;
+ }
+ }
+
+ if (lightningStrike == null) {
+ System.out.println(" ! Lightning Strike not in hand, skipping test");
+ return;
+ }
+
+ // Find spell ability
+ SpellAbility castSA = null;
+ for (SpellAbility sa : lightningStrike.getAllPossibleAbilities(humanPlayer, true)) {
+ if (sa.isSpell()) {
+ castSA = sa;
+ break;
+ }
+ }
+
+ assertNotNull("Lightning Strike should have cast ability", castSA);
+
+ // Check if it uses targeting
+ if (castSA.usesTargeting()) {
+ System.out.println(" Lightning Strike uses targeting: " + castSA.usesTargeting());
+ System.out.println(" Target restrictions: " + castSA.getTargetRestrictions());
+
+ // Find valid targets
+ List validTargets = new ArrayList<>();
+ for (Card c : aiPlayer.getCardsIn(forge.game.zone.ZoneType.Battlefield)) {
+ if (c.isCreature()) {
+ validTargets.add(c);
+ }
+ }
+
+ System.out.println(" Valid targets found: " + validTargets.size());
+ for (Card target : validTargets) {
+ System.out.println(" - " + target.getName());
+ }
+
+ assertTrue("Should have at least one valid target", validTargets.size() > 0);
+ }
+ }
+
+ /**
+ * Create a simple test deck with specific cards.
+ */
+ private Deck createTestDeck(String mainCard, String land) {
+ Deck deck = new Deck("Test Deck");
+
+ // Add 4 of the main card
+ for (int i = 0; i < 4; i++) {
+ PaperCard card = FModel.getMagicDb().getCommonCards().getCard(mainCard);
+ if (card != null) {
+ deck.getOrCreate(DeckSection.Main).add(card);
+ }
+ }
+
+ // Add 20 lands
+ PaperCard landCard = FModel.getMagicDb().getCommonCards().getCard(land);
+ if (landCard != null) {
+ for (int i = 0; i < 20; i++) {
+ deck.getOrCreate(DeckSection.Main).add(landCard);
+ }
+ }
+
+ return deck;
+ }
+}
diff --git a/forge-headless/src/test/java/forge/headless/TestPassController.java b/forge-headless/src/test/java/forge/headless/TestPassController.java
new file mode 100644
index 000000000000..26ad4258bf3a
--- /dev/null
+++ b/forge-headless/src/test/java/forge/headless/TestPassController.java
@@ -0,0 +1,46 @@
+package forge.headless;
+
+import forge.LobbyPlayer;
+import forge.ai.PlayerControllerAi;
+import forge.game.Game;
+import forge.game.player.Player;
+import forge.game.spellability.SpellAbility;
+
+import java.util.List;
+
+/**
+ * Test controller that always passes priority (chooses no action).
+ * Used for testing that passive play results in a loss.
+ */
+public class TestPassController extends PlayerControllerAi {
+
+ private int choicesMade = 0;
+ private int totalOptions = 0;
+
+ public TestPassController(Game game, Player p, LobbyPlayer lp) {
+ super(game, p, lp);
+ }
+
+ @Override
+ public boolean isAI() {
+ return false; // Act like a human player for testing
+ }
+
+ @Override
+ public List chooseSpellAbilityToPlay() {
+ // Count this as a choice point
+ choicesMade++;
+
+ // We would count options here if we had access to them
+ // For now, just always pass (return null)
+ return null;
+ }
+
+ public int getChoicesMade() {
+ return choicesMade;
+ }
+
+ public int getTotalOptions() {
+ return totalOptions;
+ }
+}
diff --git a/forge-headless/src/test/java/forge/headless/TestRandomController.java b/forge-headless/src/test/java/forge/headless/TestRandomController.java
new file mode 100644
index 000000000000..983d149f2f02
--- /dev/null
+++ b/forge-headless/src/test/java/forge/headless/TestRandomController.java
@@ -0,0 +1,182 @@
+package forge.headless;
+
+import forge.LobbyPlayer;
+import forge.ai.ComputerUtilMana;
+import forge.ai.PlayerControllerAi;
+import forge.game.Game;
+import forge.game.card.Card;
+import forge.game.phase.PhaseHandler;
+import forge.game.player.Player;
+import forge.game.spellability.SpellAbility;
+import forge.game.zone.ZoneType;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Random;
+
+/**
+ * Test controller that makes random valid choices.
+ * Used for testing that the game handles various paths correctly.
+ */
+public class TestRandomController extends PlayerControllerAi {
+
+ private final Random random;
+ private int choicesMade = 0;
+ private int totalOptions = 0;
+
+ public TestRandomController(Game game, Player p, LobbyPlayer lp) {
+ this(game, p, lp, new Random());
+ }
+
+ public TestRandomController(Game game, Player p, LobbyPlayer lp, Random random) {
+ super(game, p, lp);
+ this.random = random;
+ }
+
+ @Override
+ public boolean isAI() {
+ return false; // Act like a human player for testing
+ }
+
+ @Override
+ public List chooseSpellAbilityToPlay() {
+ PhaseHandler ph = getGame().getPhaseHandler();
+ boolean isMainPhase = ph.is(forge.game.phase.PhaseType.MAIN1) || ph.is(forge.game.phase.PhaseType.MAIN2);
+
+ // Get playable actions from hand
+ List landAbilities = getPlayableLands();
+ List spellAbilities = new ArrayList<>();
+
+ // In any main phase, check for castable sorcery-speed spells
+ if (isMainPhase) {
+ spellAbilities.addAll(getCastableCreatures());
+ spellAbilities.addAll(getCastableSorceries());
+ }
+
+ // Instants can be cast at any time
+ spellAbilities.addAll(getCastableInstants());
+
+ // Combine all options
+ List allOptions = new ArrayList<>();
+ allOptions.addAll(landAbilities);
+ allOptions.addAll(spellAbilities);
+
+ // Count this choice
+ choicesMade++;
+ totalOptions += (allOptions.size() + 1); // +1 for pass option
+
+ // If no options, pass
+ if (allOptions.isEmpty()) {
+ return null;
+ }
+
+ // Randomly choose: 30% chance to pass, 70% chance to do something
+ if (random.nextDouble() < 0.3) {
+ return null; // Pass
+ }
+
+ // Choose a random action
+ int choice = random.nextInt(allOptions.size());
+ return Collections.singletonList(allOptions.get(choice));
+ }
+
+ private List getPlayableLands() {
+ List lands = new ArrayList<>();
+ for (Card c : player.getCardsIn(ZoneType.Hand)) {
+ if (c.isLand()) {
+ for (SpellAbility sa : c.getAllPossibleAbilities(player, true)) {
+ // Ensure activator is set before checking
+ if (sa.getActivatingPlayer() == null) {
+ sa.setActivatingPlayer(player);
+ }
+ if (sa.isLandAbility() && sa.canPlay()) {
+ lands.add(sa);
+ break;
+ }
+ }
+ }
+ }
+ return lands;
+ }
+
+ private List getCastableCreatures() {
+ List spells = new ArrayList<>();
+ for (Card c : player.getCardsIn(ZoneType.Hand)) {
+ if (c.isCreature() && !c.isLand()) {
+ for (SpellAbility sa : c.getAllPossibleAbilities(player, true)) {
+ // Ensure activator is set before checking
+ if (sa.getActivatingPlayer() == null) {
+ sa.setActivatingPlayer(player);
+ }
+ // Skip spells that use targeting (not supported by random controller)
+ if (sa.usesTargeting()) {
+ continue;
+ }
+ // Check if can play AND can actually pay the mana cost
+ if (sa.isSpell() && sa.canPlay() && ComputerUtilMana.canPayManaCost(sa, player, 0, false)) {
+ spells.add(sa);
+ break;
+ }
+ }
+ }
+ }
+ return spells;
+ }
+
+ private List getCastableSorceries() {
+ List spells = new ArrayList<>();
+ for (Card c : player.getCardsIn(ZoneType.Hand)) {
+ if (c.isSorcery()) {
+ for (SpellAbility sa : c.getAllPossibleAbilities(player, true)) {
+ // Ensure activator is set before checking
+ if (sa.getActivatingPlayer() == null) {
+ sa.setActivatingPlayer(player);
+ }
+ // Skip spells that use targeting (not supported by random controller)
+ if (sa.usesTargeting()) {
+ continue;
+ }
+ // Check if can play AND can actually pay the mana cost
+ if (sa.isSpell() && sa.canPlay() && ComputerUtilMana.canPayManaCost(sa, player, 0, false)) {
+ spells.add(sa);
+ break;
+ }
+ }
+ }
+ }
+ return spells;
+ }
+
+ private List getCastableInstants() {
+ List spells = new ArrayList<>();
+ for (Card c : player.getCardsIn(ZoneType.Hand)) {
+ if (c.isInstant()) {
+ for (SpellAbility sa : c.getAllPossibleAbilities(player, true)) {
+ // Ensure activator is set before checking
+ if (sa.getActivatingPlayer() == null) {
+ sa.setActivatingPlayer(player);
+ }
+ // Skip spells that use targeting (not supported by random controller)
+ if (sa.usesTargeting()) {
+ continue;
+ }
+ // Check if can play AND can actually pay the mana cost
+ if (sa.isSpell() && sa.canPlay() && ComputerUtilMana.canPayManaCost(sa, player, 0, false)) {
+ spells.add(sa);
+ break;
+ }
+ }
+ }
+ }
+ return spells;
+ }
+
+ public int getChoicesMade() {
+ return choicesMade;
+ }
+
+ public int getTotalOptions() {
+ return totalOptions;
+ }
+}
diff --git a/forge-headless/test_decks/grizzly_bears.dck b/forge-headless/test_decks/grizzly_bears.dck
new file mode 100644
index 000000000000..e86d47704611
--- /dev/null
+++ b/forge-headless/test_decks/grizzly_bears.dck
@@ -0,0 +1,9 @@
+[metadata]
+Name=Simple Bolt Test Deck
+Description=Minimal test deck with Forests and Grizzly Bears.
+
+[Main]
+20 Forest
+40 Grizzly Bears
+
+[Sideboard]
diff --git a/forge-headless/test_decks/monored.dck b/forge-headless/test_decks/monored.dck
new file mode 100644
index 000000000000..9f558ff77e92
--- /dev/null
+++ b/forge-headless/test_decks/monored.dck
@@ -0,0 +1,23 @@
+[metadata]
+Name=monored
+[Main]
+4 Burnout Bashtronaut|DFT|[115]
+4 Burst Lightning|FDN|[192]
+3 Emberheart Challenger|BLB|[133]
+4 Hired Claw|BLB|[140]
+4 Lightning Strike|TLA|[146]
+18 Mountain|TLE|[289]
+3 Nova Hellkite|EOE|[148]
+2 Obliterating Bolt|FDN|[629]
+2 Ojer Axonil, Deepest Might|LCI|[158]
+4 Razorkin Needlehead|DSK|[153]
+4 Rockface Village|BLB|[259]
+4 Screaming Nemesis|DSK|[157]
+2 Soulstone Sanctuary|FDN|[133]
+2 Witchstalker Frenzy|WOE|[159]
+[Sideboard]
+4 Abrade|TDC|[203]
+2 Chandra, Spark Hunter|DFT|[116]
+4 Magebane Lizard|OTJ|[134]
+3 Scorching Shot|OTJ|[145]
+2 Sunspine Lynx|BLB|[155]
diff --git a/forge-headless/test_decks/simple_bolt.dck b/forge-headless/test_decks/simple_bolt.dck
new file mode 100644
index 000000000000..ae6f51b71804
--- /dev/null
+++ b/forge-headless/test_decks/simple_bolt.dck
@@ -0,0 +1,9 @@
+[metadata]
+Name=Simple Bolt Test Deck
+Description=Minimal test deck with Mountains and Lightning Bolts for e2e testing
+
+[Main]
+20 Mountain
+40 Lightning Bolt
+
+[Sideboard]
diff --git a/headless.sh b/headless.sh
new file mode 100755
index 000000000000..3bf2e7a37665
--- /dev/null
+++ b/headless.sh
@@ -0,0 +1,32 @@
+#!/bin/bash
+set -euo pipefail
+
+# Get the directory where this script is located
+script_dir=$(cd "$(dirname "$0")" && pwd)
+caller_dir=$(pwd -P)
+
+# Convert any relative deck paths to absolute paths
+# before changing to the directory that contains the assembled JAR.
+args=()
+for arg in "$@"; do
+ # Check if this looks like a deck file path (ends in .dck and doesn't start with -)
+ if [[ "$arg" == *.dck ]] && [[ "$arg" != -* ]]; then
+ # If it is a relative path, preserve the caller's interpretation of it.
+ if [[ "$arg" != /* ]]; then
+ arg="$caller_dir/$arg"
+ fi
+ fi
+ args+=("$arg")
+done
+
+# Find the JAR file - use the most recently modified if multiple exist
+target_dir="$script_dir/forge-headless/target"
+jar_file=$(ls -t "$target_dir"/forge-headless-*-SNAPSHOT-jar-with-dependencies.jar 2>/dev/null | head -1)
+
+if [[ -z "$jar_file" ]]; then
+ echo "Error: No forge-headless JAR found in $target_dir" >&2
+ echo "Run 'make build' in forge-java/forge-headless first" >&2
+ exit 1
+fi
+
+cd "$target_dir" && exec java -jar "$(basename "$jar_file")" "${args[@]}"
diff --git a/pom.xml b/pom.xml
index 7ac01395876a..9a6ef90c4d69 100644
--- a/pom.xml
+++ b/pom.xml
@@ -66,6 +66,7 @@
forge-game
forge-ai
forge-gui
+ forge-headless
forge-gui-mobile
forge-gui-mobile-dev
forge-gui-desktop