From ce54e24716806f424d6219ff2b61fadfe362f3fb Mon Sep 17 00:00:00 2001 From: Ryan Newton Date: Sat, 15 Aug 2026 11:15:31 -0700 Subject: [PATCH 1/2] feat(headless): port simulation and TUI to current Forge Extract the desktop simulator into a reusable headless Maven module, retain the desktop compatibility facade, and provide terminal, random, and zero controllers. Preserve current upstream seed and AI-profile options while fixing direct file-path deck loading. Test Results Summary: - mvn -pl forge-headless -am test: 11 tests passed (3 forge-game, 8 forge-headless), 0 failed - packaged headless assembly successfully - seeded two-game simulation repeated with identical normalized SHA-256 output - random-controller launcher smoke completed with a winner --- forge-gui-desktop/pom.xml | 5 + .../main/java/forge/view/SimulateMatch.java | 413 +---- forge-headless/README.md | 28 + forge-headless/pom.xml | 93 + .../main/java/forge/headless/AgentType.java | 26 + .../java/forge/headless/HeadlessGuiBase.java | 274 +++ .../forge/headless/LobbyPlayerRandom.java | 48 + .../src/main/java/forge/headless/Main.java | 54 + .../headless/PlayerControllerRandom.java | 100 ++ .../forge/headless/PlayerControllerTUI.java | 1586 +++++++++++++++++ .../forge/headless/PlayerControllerZero.java | 40 + .../java/forge/headless/RandomController.java | 158 ++ .../java/forge/headless/SimulateMatch.java | 420 +++++ .../main/java/forge/headless/TUICommand.java | 88 + .../main/java/forge/headless/TUIGuiBase.java | 86 + .../main/java/forge/headless/TextUIGame.java | 477 +++++ .../forge/headless/TimeLimitedCodeBlock.java | 49 + .../java/forge/headless/GameLogCapture.java | 135 ++ .../forge/headless/SimulateMatchTest.java | 24 + .../forge/headless/SystemOutputCapture.java | 114 ++ .../test/java/forge/headless/TUIGameTest.java | 249 +++ .../forge/headless/TUIInteractiveTest.java | 237 +++ .../java/forge/headless/TargetingTest.java | 211 +++ .../forge/headless/TestPassController.java | 46 + .../forge/headless/TestRandomController.java | 182 ++ forge-headless/test_decks/grizzly_bears.dck | 9 + forge-headless/test_decks/monored.dck | 23 + forge-headless/test_decks/simple_bolt.dck | 9 + headless.sh | 31 + pom.xml | 1 + 30 files changed, 4823 insertions(+), 393 deletions(-) create mode 100644 forge-headless/README.md create mode 100644 forge-headless/pom.xml create mode 100644 forge-headless/src/main/java/forge/headless/AgentType.java create mode 100644 forge-headless/src/main/java/forge/headless/HeadlessGuiBase.java create mode 100644 forge-headless/src/main/java/forge/headless/LobbyPlayerRandom.java create mode 100644 forge-headless/src/main/java/forge/headless/Main.java create mode 100644 forge-headless/src/main/java/forge/headless/PlayerControllerRandom.java create mode 100644 forge-headless/src/main/java/forge/headless/PlayerControllerTUI.java create mode 100644 forge-headless/src/main/java/forge/headless/PlayerControllerZero.java create mode 100644 forge-headless/src/main/java/forge/headless/RandomController.java create mode 100644 forge-headless/src/main/java/forge/headless/SimulateMatch.java create mode 100644 forge-headless/src/main/java/forge/headless/TUICommand.java create mode 100644 forge-headless/src/main/java/forge/headless/TUIGuiBase.java create mode 100644 forge-headless/src/main/java/forge/headless/TextUIGame.java create mode 100644 forge-headless/src/main/java/forge/headless/TimeLimitedCodeBlock.java create mode 100644 forge-headless/src/test/java/forge/headless/GameLogCapture.java create mode 100644 forge-headless/src/test/java/forge/headless/SimulateMatchTest.java create mode 100644 forge-headless/src/test/java/forge/headless/SystemOutputCapture.java create mode 100644 forge-headless/src/test/java/forge/headless/TUIGameTest.java create mode 100644 forge-headless/src/test/java/forge/headless/TUIInteractiveTest.java create mode 100644 forge-headless/src/test/java/forge/headless/TargetingTest.java create mode 100644 forge-headless/src/test/java/forge/headless/TestPassController.java create mode 100644 forge-headless/src/test/java/forge/headless/TestRandomController.java create mode 100644 forge-headless/test_decks/grizzly_bears.dck create mode 100644 forge-headless/test_decks/monored.dck create mode 100644 forge-headless/test_decks/simple_bolt.dck create mode 100755 headless.sh 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..c0e9e52854ee --- /dev/null +++ b/forge-headless/README.md @@ -0,0 +1,28 @@ +# 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 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 +``` + +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..396502dd1070 --- /dev/null +++ b/forge-headless/src/main/java/forge/headless/Main.java @@ -0,0 +1,54 @@ +package forge.headless; + +import forge.gui.GuiBase; + +/** + * Main entry point for Forge headless mode. + */ +public final class Main { + /** + * Main entry point for Forge headless simulation + */ + public static void main(final String[] args) { + // 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()); + + if (args.length == 0) { + System.out.println("Forge Headless Mode"); + System.out.println("Usage: java -jar forge-headless.jar "); + System.out.println("Known modes:"); + System.out.println(" sim - Simulation mode (AI vs AI)"); + System.out.println(" tui - Text UI mode (Interactive gameplay)"); + System.out.println("Run 'java -jar forge-headless.jar sim' for simulation help"); + System.out.println("Run 'java -jar forge-headless.jar tui' for TUI help"); + System.exit(1); + } + + // Command line startup + String mode = args[0].toLowerCase(); + + switch (mode) { + case "sim": + SimulateMatch.simulate(args); + break; + + case "tui": + TextUIGame.run(args); + break; + + default: + System.out.println("Unknown mode: " + mode); + System.out.println("Known modes: 'sim', 'tui'"); + break; + } + + System.exit(0); + } + + // 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..ddaa08038ec1 --- /dev/null +++ b/forge-headless/src/main/java/forge/headless/SimulateMatch.java @@ -0,0 +1,420 @@ +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) { + 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"); + 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("Syntax: forge.exe sim -d ... -D [D] -n [N] -m [M] -t [T] -p [P] -f [F] -s [S] -a [A] -r -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("\tr - use fast random controllers instead of the normal AI (not supported for tournaments)"); + 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()); + } + } + + 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..405767537a57 --- /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 = "Text UI Mode - Interactive Forge Gameplay", + 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 = "Set random seed for deterministic testing") + 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..47e9a9756ac7 --- /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) { + System.out.println("=== Forge Text UI Mode ==="); + + // 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) { + // 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/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..203cd9c000ce --- /dev/null +++ b/headless.sh @@ -0,0 +1,31 @@ +#!/bin/bash +set -euo pipefail + +# Get the directory where this script is located +script_dir=$(cd "$(dirname "$0")" && pwd) + +# Convert any relative deck paths to absolute paths +# Relative paths are resolved relative to the script's location, not the caller's pwd +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's a relative path (doesn't start with /), resolve relative to script directory + if [[ "$arg" != /* ]]; then + arg="$script_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" && time 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 From 642e1f1add8fadfe717a430fb429fccf5db13ea5 Mon Sep 17 00:00:00 2001 From: Ryan Newton Date: Sun, 16 Aug 2026 08:28:02 -0700 Subject: [PATCH 2/2] docs(headless): make launcher help self-explanatory Give the headless launcher conventional top-level help, readable simulation options and examples, clean terminal help, caller-relative deck paths, and quiet process replacement without shell timing noise. Test Results Summary: - mvn -pl forge-headless -am test: 13 tests passed (3 forge-game, 10 forge-headless), 0 failed - mvn -pl forge-headless -am package -DskipTests: passed - ./headless.sh --help, sim --help, and tui --help: passed --- forge-headless/README.md | 3 + .../src/main/java/forge/headless/Main.java | 59 +++++++++++++------ .../java/forge/headless/SimulateMatch.java | 47 ++++++++++----- .../main/java/forge/headless/TUICommand.java | 4 +- .../main/java/forge/headless/TextUIGame.java | 4 +- .../java/forge/headless/MainHelpTest.java | 44 ++++++++++++++ headless.sh | 9 +-- 7 files changed, 128 insertions(+), 42 deletions(-) create mode 100644 forge-headless/src/test/java/forge/headless/MainHelpTest.java diff --git a/forge-headless/README.md b/forge-headless/README.md index c0e9e52854ee..ede7f65ab352 100644 --- a/forge-headless/README.md +++ b/forge-headless/README.md @@ -16,10 +16,13 @@ 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. diff --git a/forge-headless/src/main/java/forge/headless/Main.java b/forge-headless/src/main/java/forge/headless/Main.java index 396502dd1070..8155bda4e2b5 100644 --- a/forge-headless/src/main/java/forge/headless/Main.java +++ b/forge-headless/src/main/java/forge/headless/Main.java @@ -7,45 +7,68 @@ */ public final class Main { /** - * Main entry point for Forge headless simulation + * 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()); - if (args.length == 0) { - System.out.println("Forge Headless Mode"); - System.out.println("Usage: java -jar forge-headless.jar "); - System.out.println("Known modes:"); - System.out.println(" sim - Simulation mode (AI vs AI)"); - System.out.println(" tui - Text UI mode (Interactive gameplay)"); - System.out.println("Run 'java -jar forge-headless.jar sim' for simulation help"); - System.out.println("Run 'java -jar forge-headless.jar tui' for TUI help"); - System.exit(1); - } - // Command line startup String mode = args[0].toLowerCase(); switch (mode) { case "sim": SimulateMatch.simulate(args); - break; + return 0; case "tui": TextUIGame.run(args); - break; + return 0; default: - System.out.println("Unknown mode: " + mode); - System.out.println("Known modes: 'sim', 'tui'"); - break; + 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); + } - System.exit(0); + 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 diff --git a/forge-headless/src/main/java/forge/headless/SimulateMatch.java b/forge-headless/src/main/java/forge/headless/SimulateMatch.java index ddaa08038ec1..f5412fbdf2a6 100644 --- a/forge-headless/src/main/java/forge/headless/SimulateMatch.java +++ b/forge-headless/src/main/java/forge/headless/SimulateMatch.java @@ -37,10 +37,15 @@ 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); - System.out.println("Simulation mode"); if (args.length < 4) { + System.err.println("Missing required deck arguments."); argumentHelp(); return; } @@ -199,21 +204,31 @@ public static void simulate(String[] args) { } 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] -r -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("\tr - use fast random controllers instead of the normal AI (not supported for tournaments)"); - 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."); + 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) { diff --git a/forge-headless/src/main/java/forge/headless/TUICommand.java b/forge-headless/src/main/java/forge/headless/TUICommand.java index 405767537a57..3626ea898c0d 100644 --- a/forge-headless/src/main/java/forge/headless/TUICommand.java +++ b/forge-headless/src/main/java/forge/headless/TUICommand.java @@ -8,7 +8,7 @@ * Command-line interface definition for the Text UI mode using picocli. */ @Command(name = "tui", - description = "Text UI Mode - Interactive Forge Gameplay", + description = "Play or observe a Forge game in an interactive terminal.", mixinStandardHelpOptions = true, version = "Forge TUI 2.0") public class TUICommand implements Runnable { @@ -46,7 +46,7 @@ public class TUICommand implements Runnable { boolean numericChoices = false; @Option(names = "--seed", - description = "Set random seed for deterministic testing") + description = "Seed Forge's random-number generator for a repeatable game") Long seed; @Option(names = "--start-state", diff --git a/forge-headless/src/main/java/forge/headless/TextUIGame.java b/forge-headless/src/main/java/forge/headless/TextUIGame.java index 47e9a9756ac7..15b7a60f91a2 100644 --- a/forge-headless/src/main/java/forge/headless/TextUIGame.java +++ b/forge-headless/src/main/java/forge/headless/TextUIGame.java @@ -31,8 +31,6 @@ public class TextUIGame { public static void run(String[] args) { - System.out.println("=== Forge Text UI Mode ==="); - // 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); @@ -53,6 +51,8 @@ public static void run(String[] args) { * 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; 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/headless.sh b/headless.sh index 203cd9c000ce..3bf2e7a37665 100755 --- a/headless.sh +++ b/headless.sh @@ -3,16 +3,17 @@ 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 -# Relative paths are resolved relative to the script's location, not the caller's pwd +# 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's a relative path (doesn't start with /), resolve relative to script directory + # If it is a relative path, preserve the caller's interpretation of it. if [[ "$arg" != /* ]]; then - arg="$script_dir/$arg" + arg="$caller_dir/$arg" fi fi args+=("$arg") @@ -28,4 +29,4 @@ if [[ -z "$jar_file" ]]; then exit 1 fi -cd "$target_dir" && time java -jar "$(basename "$jar_file")" "${args[@]}" +cd "$target_dir" && exec java -jar "$(basename "$jar_file")" "${args[@]}"