diff --git a/.env.example b/.env.example index 947b890..d5af735 100644 --- a/.env.example +++ b/.env.example @@ -8,12 +8,12 @@ XIMILAR_API_KEY=your-ximilar-key-here # ── Forge JAR Path ──────────────────────────────────────────────── # Point this to your Forge GUI desktop fat JAR. -FORGE_JAR_PATH=D:/ForgeCommander/forge-repo/forge-gui-desktop/target/forge-gui-desktop-2.0.12-SNAPSHOT-jar-with-dependencies.jar +FORGE_JAR_PATH=D:/ForgeCommander/Forge-Repo/forge-gui-desktop/target/forge-gui-desktop-2.0.12-SNAPSHOT-jar-with-dependencies.jar # IMPORTANT: Forge MUST be launched from the forge-gui directory (not forge-gui-desktop/target). # The JAR looks for res/ relative to its working directory. Without this, you get # ExceptionInInitializerError on startup. -FORGE_WORKING_DIR=D:/ForgeCommander/forge-repo/forge-gui +FORGE_WORKING_DIR=D:/ForgeCommander/Forge-Repo/forge-gui # ── Deck Paths ──────────────────────────────────────────────────── # Directory containing your precon .dck files. diff --git a/README.md b/README.md index 419f520..e8e715e 100644 --- a/README.md +++ b/README.md @@ -171,13 +171,13 @@ OCR pipeline for physical Magic cards using the Ximilar API. Scan cards from pho | Maven | 3.8+ | | Node.js | 18+ (for LAN client scripts) | | Ollama | Latest (for local LLM inference) | -| [Forge](https://github.com/Card-Forge/forge) | Built at `D:\ForgeCommander\forge-repo` | +| [Forge](https://github.com/KoalaTrapLord/Forge-Repo) | Built at `D:\ForgeCommander\Forge-Repo` | ### Setup (Windows) ```powershell # 1. Build Forge (one-time) -cd D:\ForgeCommander\forge-repo +cd D:\ForgeCommander\Forge-Repo mvn package -pl forge-gui-desktop "-DskipTests" "-Drevision=2.0.12-SNAPSHOT" -am # 2. Build Commander AI Lab diff --git a/lab_api.py b/lab_api.py index e5b30cc..135125b 100644 --- a/lab_api.py +++ b/lab_api.py @@ -256,7 +256,9 @@ def _resolve_forge_dir() -> str: project_root = Path(__file__).parent for candidate in [ project_root / "forge", - project_root.parent / "forge-repo", + project_root.parent / "Forge-Repo", + project_root.parent / "Forge-Repo" / "forge-gui", + project_root.parent / "forge-repo", # legacy fallback project_root.parent / "forge-repo" / "forge-gui", project_root.parent / "forge", project_root / "forge-gui-desktop", @@ -282,7 +284,8 @@ def _resolve_forge_jar() -> str: project_root = Path(__file__).parent candidates = [ project_root / "forge-gui-desktop" / "target", - project_root.parent / "forge-repo" / "forge-gui-desktop" / "target", + project_root.parent / "Forge-Repo" / "forge-gui-desktop" / "target", + project_root.parent / "forge-repo" / "forge-gui-desktop" / "target", # legacy fallback project_root.parent / "forge" / "forge-gui-desktop" / "target", ] forge_dir_env = os.environ.get("FORGE_DIR", "") or os.environ.get("FORGE_WORKING_DIR", "") diff --git a/src/main/java/commanderailab/ai/PolicyControllerAi.java b/src/main/java/commanderailab/ai/PolicyControllerAi.java new file mode 100644 index 0000000..778f4de --- /dev/null +++ b/src/main/java/commanderailab/ai/PolicyControllerAi.java @@ -0,0 +1,840 @@ +package commanderailab.ai; + +import commanderailab.ml.MacroActionExecutor; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; + +import forge.LobbyPlayer; +import forge.ai.AiController; +import forge.ai.ComputerUtilAbility; +import forge.ai.ComputerUtilMana; +import forge.ai.PlayerControllerAi; +import forge.game.Game; +import forge.game.card.Card; +import forge.game.card.CardCollection; +import forge.game.card.CardCollectionView; +import forge.game.phase.PhaseHandler; +import forge.game.phase.PhaseType; +import forge.game.player.Player; +import forge.game.spellability.SpellAbility; +import forge.game.spellability.SpellAbilityStackInstance; +import forge.game.zone.ZoneType; + +import java.io.File; +import java.io.FileWriter; +import java.io.IOException; +import java.io.PrintWriter; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.logging.Logger; + +/** + * PolicyControllerAi — Phase 2 live-game controller that routes AI decisions + * through the Python policy server instead of Forge's built-in heuristics. + * + *

Architecture

+ *
+ *   Forge game loop
+ *       └─ player.getController()            → PolicyControllerAi
+ *               └─ brains (PolicyAiController extends AiController)
+ *                       └─ chooseSpellAbilityToPlay()
+ *                               ├─ buildGameStateJson()
+ *                               ├─ policyClient.decide(stateJson)   → macro-action
+ *                               ├─ mapMacroActionToSpellAbility()   → SpellAbility
+ *                               └─ super.chooseSpellAbilityToPlay() (fallback)
+ * 
+ * + *

Two-class design

+ * + * + *

Decision flow

+ *
    + *
  1. Build a rich {@link JsonObject} snapshot of the current game state + * (per-player life/mana/zones, stack, phase, legal actions).
  2. + *
  3. POST the snapshot to {@code /api/policy/decide} via + * {@link PolicyClient#decide(JsonObject)}.
  4. + *
  5. Translate the returned macro-action name (e.g. {@code "cast_creature"}) + * to a concrete {@link SpellAbility} from the legal action list using + * {@link MacroActionExecutor}'s card-role databases.
  6. + *
  7. If the server is unreachable or no mapping succeeds, fall back to + * {@link AiController#chooseSpellAbilityToPlay()} transparently.
  8. + *
  9. Log every decision (policy or fallback) for training data collection.
  10. + *
+ * + *

Fallback guarantee

+ * Every code path in {@link PolicyAiController#chooseSpellAbilityToPlay()} + * is wrapped in a broad {@code try/catch}. Any unexpected exception causes + * an immediate delegate to the parent Forge AI so the game continues safely. + * + *

Usage

+ *
{@code
+ * PolicyClient client = new PolicyClient("http://localhost:8080");
+ * client.connect();
+ *
+ * // When Forge constructs the AI player's controller:
+ * PolicyControllerAi controller = new PolicyControllerAi(game, player, lobbyPlayer, client);
+ *
+ * // After the game ends:
+ * controller.onGameEnd(won ? 1.0 : -1.0);
+ * }
+ * + * @see PolicyClient + * @see PolicyAiController + * @see MacroActionExecutor + * @since Phase 2.1 — Issue #83 + */ +public class PolicyControllerAi extends PlayerControllerAi { + + // ───────────────────────────────────────────────────────────────────────── + // Constants + // ───────────────────────────────────────────────────────────────────────── + + private static final Logger LOG = Logger.getLogger(PolicyControllerAi.class.getName()); + + /** + * Directory for training-data decision logs. + * One JSON-lines file per game session is written here. + */ + private static final String LOG_DIR = "data/decisions"; + + // ───────────────────────────────────────────────────────────────────────── + // Instance state + // ───────────────────────────────────────────────────────────────────────── + + /** Unique identifier for this game session (used in reward submission). */ + private final String gameId = UUID.randomUUID().toString(); + + /** The policy client used by the embedded {@link PolicyAiController}. */ + private final PolicyClient policyClient; + + /** + * Log writer for training-data collection. + * Lazily opened on the first decision; {@code null} if log dir is unavailable. + */ + private PrintWriter decisionLog; + + /** Running count of decisions in this game (policy + fallback). */ + private final AtomicInteger decisionCount = new AtomicInteger(0); + + // ───────────────────────────────────────────────────────────────────────── + // Construction + // ───────────────────────────────────────────────────────────────────────── + + /** + * Construct a policy-driven AI controller. + * + *

The standard Forge {@link AiController} created by the parent class is + * immediately replaced by a {@link PolicyAiController} that holds a + * reference to {@code client}. All other Forge AI infrastructure + * (combat, simulation, sideboard, etc.) is inherited unchanged.

+ * + * @param game the active Forge {@link Game} + * @param player the {@link Player} this controller drives + * @param lobbyPlayer the underlying lobby seat + * @param client live {@link PolicyClient}; must not be {@code null} + */ + public PolicyControllerAi(Game game, Player player, LobbyPlayer lobbyPlayer, + PolicyClient client) { + // Parent creates its own AiController; we replace it below via reflection. + super(game, player, lobbyPlayer); + this.policyClient = client; + + // Swap the brains field to our policy-aware subclass. + injectPolicyAiController(game, player); + } + + /** + * Convenience constructor that builds a {@link PolicyClient} from a URL. + * + *

The client's {@link PolicyClient#connect()} is called automatically; + * if the server is unavailable the controller will silently fall back to + * Forge's built-in AI for all decisions.

+ * + * @param game the active Forge {@link Game} + * @param player the {@link Player} this controller drives + * @param lobbyPlayer the underlying lobby seat + * @param policyServerUrl base URL of the policy server, e.g. + * {@code "http://localhost:8080"} + */ + public PolicyControllerAi(Game game, Player player, LobbyPlayer lobbyPlayer, + String policyServerUrl) { + this(game, player, lobbyPlayer, buildAndConnect(policyServerUrl)); + } + + // ───────────────────────────────────────────────────────────────────────── + // Lifecycle helpers + // ───────────────────────────────────────────────────────────────────────── + + /** + * Signal the end of a game so the policy client can submit the terminal + * reward and flush any buffered training tuples. + * + *

Call this from your game-runner once Forge reports a winner.

+ * + * @param reward terminal reward signal: {@code +1.0} for a win, + * {@code -1.0} for a loss, or an intermediate value + */ + public void onGameEnd(double reward) { + try { + policyClient.submitReward(gameId, reward); + policyClient.flushCollectedTuples(); + } catch (Exception e) { + LOG.warning("[PolicyControllerAi] onGameEnd error: " + e.getMessage()); + } finally { + closeDecisionLog(); + } + LOG.info(String.format( + "[PolicyControllerAi] game=%s reward=%.2f decisions=%d stats=%s", + gameId, reward, decisionCount.get(), policyClient.getStatsSummary())); + } + + /** + * Returns the unique game identifier used in reward submissions. + * + * @return UUID string + */ + public String getGameId() { + return gameId; + } + + /** + * Returns a snapshot of policy statistics for this game session. + * + * @return human-readable stats string from the underlying {@link PolicyClient} + */ + public String getStatsSummary() { + return policyClient.getStatsSummary(); + } + + // ───────────────────────────────────────────────────────────────────────── + // Private helpers + // ───────────────────────────────────────────────────────────────────────── + + /** + * Uses reflection to replace the {@code brains} field (an {@link AiController}) + * that the parent constructor already created with a new + * {@link PolicyAiController}. + * + *

This is the only use of reflection in this class. It is necessary + * because {@code PlayerControllerAi} declares {@code brains} as + * {@code private final} and constructs it in its own constructor body, + * before we get a chance to supply a subclass.

+ * + *

If reflection fails for any reason (e.g. a future Forge version adds + * a security manager), the controller falls back to the original + * {@code AiController} that the parent already created. A warning is + * logged so the issue is easy to diagnose.

+ * + * @param game the active game + * @param player the AI player + */ + private void injectPolicyAiController(Game game, Player player) { + try { + java.lang.reflect.Field brains = + PlayerControllerAi.class.getDeclaredField("brains"); + brains.setAccessible(true); + PolicyAiController policyBrains = + new PolicyAiController(player, game, policyClient, this); + brains.set(this, policyBrains); + LOG.info("[PolicyControllerAi] PolicyAiController injected for player=" + + player.getName() + " game=" + gameId); + } catch (Exception e) { + LOG.warning("[PolicyControllerAi] Could not inject PolicyAiController; " + + "falling back to vanilla Forge AI. Cause: " + e.getMessage()); + } + } + + /** Build a {@link PolicyClient} and attempt to connect. */ + private static PolicyClient buildAndConnect(String serverUrl) { + PolicyClient client = new PolicyClient(serverUrl); + client.connect(); + return client; + } + + // ───────────────────────────────────────────────────────────────────────── + // Decision logging + // ───────────────────────────────────────────────────────────────────────── + + /** + * Write a single JSON-lines decision record to the per-game log file. + * Creates the log directory and file on first call. + * + * @param record the JSON object to append + */ + void logDecision(JsonObject record) { + decisionCount.incrementAndGet(); + try { + if (decisionLog == null) { + File dir = new File(LOG_DIR); + //noinspection ResultOfMethodCallIgnored + dir.mkdirs(); + File logFile = new File(dir, "decisions-" + gameId + ".jsonl"); + decisionLog = new PrintWriter(new FileWriter(logFile, true)); + } + decisionLog.println(record.toString()); + decisionLog.flush(); + } catch (IOException e) { + // Non-fatal: the game continues even if we can't log. + LOG.warning("[PolicyControllerAi] Could not write decision log: " + + e.getMessage()); + } + } + + /** Close the decision log file handle. */ + private void closeDecisionLog() { + if (decisionLog != null) { + decisionLog.close(); + decisionLog = null; + } + } + + // ═════════════════════════════════════════════════════════════════════════ + // Inner class: PolicyAiController + // ═════════════════════════════════════════════════════════════════════════ + + /** + * PolicyAiController — intercepts {@link AiController#chooseSpellAbilityToPlay()} + * and routes the decision through the Python policy server. + * + *

Decision flow

+ *
    + *
  1. Enumerate all legal actions (lands + spells/abilities) using + * {@link ComputerUtilAbility} — the same utilities Forge uses + * internally — so the legal-action list is always authoritative.
  2. + *
  3. Build a {@link JsonObject} describing the full game state, + * including per-player life/mana/zones/stack/phase and a + * {@code legal_actions} array.
  4. + *
  5. POST to {@code /api/policy/decide} via {@link PolicyClient#decide(JsonObject)}. + * The server returns a macro-action name such as {@code "cast_creature"}.
  6. + *
  7. Translate the macro-action to a concrete {@link SpellAbility} by + * scanning the legal-action list with + * {@link #mapMacroActionToSpellAbility}.
  8. + *
  9. On any failure, delegate to the parent + * {@link AiController#chooseSpellAbilityToPlay()} so the game never stalls.
  10. + *
+ * + *

Thread safety

+ * All mutable state is confined to the single game thread that Forge uses + * for AI decisions. The {@link PolicyClient} methods called here are + * themselves thread-safe. + */ + static final class PolicyAiController extends AiController { + + /** Maximum age (ms) of a decision before it is considered stale. */ + private static final long DECISION_TIMEOUT_MS = 5_000; + + private final PolicyClient policyClient; + private final PolicyControllerAi outer; + + PolicyAiController(Player player, Game game, + PolicyClient policyClient, + PolicyControllerAi outer) { + super(player, game); + this.policyClient = policyClient; + this.outer = outer; + } + + // ─── Main intercept ─────────────────────────────────────────────── + + /** + * Override of the core AI decision method. + * + *

This is called by the Forge game loop whenever the AI player has + * priority and needs to decide whether/what to play. We intercept + * here to route the decision to the policy server.

+ * + * @return list of {@link SpellAbility} objects for Forge to execute, + * or {@code null} / empty list to pass priority + */ + @Override + public List chooseSpellAbilityToPlay() { + long t0 = System.currentTimeMillis(); + + try { + Player player = getPlayer(); + Game game = getGame(); + + // 1. Enumerate legal actions + List legalActions = gatherLegalActions(game, player); + + // If there are no legal actions, pass immediately — no server + // round-trip required. + if (legalActions.isEmpty()) { + logAndReturn(null, "pass", "no_legal_actions", t0, legalActions); + return super.chooseSpellAbilityToPlay(); + } + + // 2. Build game-state JSON with legal actions embedded + JsonObject stateJson = buildGameStateJson(game, player, legalActions); + + // 3. Ask the policy server for a macro-action + String macroAction = policyClient.decide(stateJson); + + // 4. Map macro-action → concrete SpellAbility + SpellAbility chosen = mapMacroActionToSpellAbility( + macroAction, legalActions, player); + + if (chosen != null) { + // Policy succeeded + List result = Collections.singletonList(chosen); + logAndReturn(stateJson, macroAction, "policy", t0, result); + return result; + } + + // macro-action mapped to "pass" or was unresolvable but not an error + if (MacroActionExecutor.PASS.equals(macroAction) + || MacroActionExecutor.HOLD_MANA.equals(macroAction)) { + logAndReturn(stateJson, macroAction, "policy_pass", t0, null); + // Return super so Forge can decide whether to pass or play a land + return super.chooseSpellAbilityToPlay(); + } + + // Macro-action was not mappable to anything concrete; fall back. + logAndReturn(stateJson, macroAction, "fallback_no_map", t0, null); + return super.chooseSpellAbilityToPlay(); + + } catch (Exception e) { + // Broad catch to guarantee the game never stalls + LOG.warning("[PolicyAiController] Unexpected error: " + e.getMessage()); + return super.chooseSpellAbilityToPlay(); + } + } + + // ─── Legal action enumeration ───────────────────────────────────── + + /** + * Gather all legal actions available to {@code player} in the current + * game state, using Forge's own utility methods. + * + *

The result combines:

+ *
    + *
  • Land-play abilities (from hand or other sources Forge allows)
  • + *
  • All playable spell/activated abilities from hand and battlefield
  • + *
+ * + * @param game the active game + * @param player the acting player + * @return mutable list of legal {@link SpellAbility} objects; never {@code null} + */ + private List gatherLegalActions(Game game, Player player) { + List actions = new ArrayList<>(); + + // Land plays — only available during main phases with empty stack + CardCollection lands = ComputerUtilAbility.getAvailableLandsToPlay(game, player); + if (lands != null) { + for (Card land : lands) { + List landAbilities = + land.getAllPossibleAbilities(player, true); + landAbilities.removeIf(sa -> !sa.isLandAbility()); + actions.addAll(landAbilities); + } + } + + // Spells and activated abilities from all available card sources + CardCollection available = ComputerUtilAbility.getAvailableCards(game, player); + if (available != null && !available.isEmpty()) { + List spells = + ComputerUtilAbility.getSpellAbilities(available, player); + actions.addAll(spells); + } + + return actions; + } + + // ─── Snapshot construction ──────────────────────────────────────── + + /** + * Build a comprehensive game-state {@link JsonObject} suitable for + * POST to {@code /api/policy/decide}. + * + *

The JSON schema mirrors the Python-side state encoder:

+ *
+         * {
+         *   "game_id":       string,
+         *   "turn":          int,
+         *   "phase":         string,   // e.g. "MAIN1"
+         *   "phase_is_main": bool,
+         *   "stack_size":    int,
+         *   "stack":         [ { "card": string, "controller": string }, … ],
+         *   "active_seat":   int,
+         *   "priority_seat": int,
+         *   "players": [
+         *     {
+         *       "seat":               int,
+         *       "name":               string,
+         *       "life":               int,
+         *       "mana_available":     int,
+         *       "commander_damage":   int,
+         *       "commander_casts":    int,
+         *       "cards_in_hand":      int,
+         *       "hand":               [ string, … ],
+         *       "battlefield":        [ string, … ],
+         *       "graveyard":          [ string, … ],
+         *       "command_zone":       [ string, … ],
+         *       "creatures_on_field": int,
+         *       "land_count":         int,
+         *       "total_power":        int,
+         *       "total_toughness":    int
+         *     }, …
+         *   ],
+         *   "legal_actions": [
+         *     {
+         *       "index":      int,
+         *       "macro":      string,   // inferred macro-action
+         *       "card_name":  string,
+         *       "is_land":    bool,
+         *       "mana_cost":  string
+         *     }, …
+         *   ]
+         * }
+         * 
+ * + * @param game the active game + * @param player the acting player (seat 0 in the players array) + * @param legalActions the legal actions list produced by + * {@link #gatherLegalActions} + * @return the populated JSON object + */ + private JsonObject buildGameStateJson(Game game, Player player, + List legalActions) { + JsonObject root = new JsonObject(); + root.addProperty("game_id", outer.gameId); + + // ── Phase / turn info ───────────────────────────────────────── + PhaseHandler ph = game.getPhaseHandler(); + PhaseType phase = ph.getPhase(); + root.addProperty("turn", ph.getTurn()); + root.addProperty("phase", phase == null ? "UNKNOWN" : phase.toString()); + root.addProperty("phase_is_main", + phase != null && phase.isMain()); + + // ── Stack info ──────────────────────────────────────────────── + int stackSize = game.getStack().size(); + root.addProperty("stack_size", stackSize); + JsonArray stackArr = new JsonArray(); + for (SpellAbilityStackInstance si : game.getStack()) { + JsonObject se = new JsonObject(); + se.addProperty("card", si.getSourceCard() != null + ? si.getSourceCard().getName() : "unknown"); + se.addProperty("controller", si.getActivatingPlayer() != null + ? si.getActivatingPlayer().getName() : "unknown"); + stackArr.add(se); + } + root.add("stack", stackArr); + + // Seat of the current active (turn) player + Player activePlayer = ph.getPlayerTurn(); + int activeSeat = seatIndex(game, activePlayer); + int prioritySeat = seatIndex(game, player); + root.addProperty("active_seat", activeSeat); + root.addProperty("priority_seat", prioritySeat); + + // ── Per-player state ────────────────────────────────────────── + JsonArray playersArr = new JsonArray(); + List allPlayers = new ArrayList<>(game.getPlayers()); + for (int seat = 0; seat < allPlayers.size(); seat++) { + Player p = allPlayers.get(seat); + playersArr.add(buildPlayerJson(seat, p, game)); + } + root.add("players", playersArr); + + // ── Legal actions ───────────────────────────────────────────── + JsonArray actionsArr = new JsonArray(); + for (int i = 0; i < legalActions.size(); i++) { + SpellAbility sa = legalActions.get(i); + JsonObject ae = new JsonObject(); + ae.addProperty("index", i); + ae.addProperty("card_name", sa.getHostCard() != null + ? sa.getHostCard().getName() : "unknown"); + ae.addProperty("is_land", sa.isLandAbility()); + ae.addProperty("mana_cost", sa.getPayCosts() != null + ? sa.getPayCosts().getTotalMana().toString() : ""); + ae.addProperty("macro", inferMacro(sa)); + actionsArr.add(ae); + } + root.add("legal_actions", actionsArr); + + return root; + } + + /** + * Serialize a single player's observable state into a {@link JsonObject}. + * + * @param seat 0-based seat index in {@code game.getPlayers()} + * @param p the player to serialize + * @param game the active game (for stack/zone queries) + * @return populated player JSON + */ + private JsonObject buildPlayerJson(int seat, Player p, Game game) { + JsonObject pj = new JsonObject(); + pj.addProperty("seat", seat); + pj.addProperty("name", p.getName()); + pj.addProperty("life", p.getLife()); + pj.addProperty("mana_available", + ComputerUtilMana.getAvailableManaEstimate(p)); + + // Commander damage (from any commander source) + int cdrDamage = 0; + for (Map.Entry entry : p.getCommanderDamage()) { + cdrDamage += entry.getValue(); + } + pj.addProperty("commander_damage", cdrDamage); + + // Commander casts (aggregate across all commanders) + int cdrCasts = 0; + for (Card cdr : p.getCardsIn(ZoneType.Command)) { + cdrCasts += p.getCommanderCast(cdr); + } + pj.addProperty("commander_casts", cdrCasts); + + // Zone lists + CardCollectionView hand = p.getCardsIn(ZoneType.Hand); + pj.addProperty("cards_in_hand", hand.size()); + pj.add("hand", cardNamesArray(hand)); + pj.add("battlefield", cardNamesArray(p.getCardsIn(ZoneType.Battlefield))); + pj.add("graveyard", cardNamesArray(p.getCardsIn(ZoneType.Graveyard))); + pj.add("command_zone", cardNamesArray(p.getCardsIn(ZoneType.Command))); + + // Board summary stats + CardCollectionView bf = p.getCardsIn(ZoneType.Battlefield); + int creatures = 0, lands = 0, power = 0, toughness = 0; + for (Card c : bf) { + if (c.isCreature()) { + creatures++; + power += c.getNetPower(); + toughness += c.getNetToughness(); + } + if (c.isLand()) lands++; + } + pj.addProperty("creatures_on_field", creatures); + pj.addProperty("land_count", lands); + pj.addProperty("total_power", power); + pj.addProperty("total_toughness", toughness); + + return pj; + } + + // ─── Macro-action mapping ───────────────────────────────────────── + + /** + * Map a policy macro-action string back to a concrete {@link SpellAbility} + * from the legal-action list. + * + *

The algorithm scans the legal actions list and picks the first + * {@link SpellAbility} whose source card belongs to the category implied + * by the macro-action, using the same card-role databases that + * {@link MacroActionExecutor} uses. This keeps Java and Python in sync.

+ * + *

For {@code CAST_COMMANDER}, the command zone is checked first.

+ * + * @param macroAction the macro-action returned by the policy server + * @param legalActions all legal actions in the current state + * @param player the acting player (for commander zone check) + * @return the best matching {@link SpellAbility}, or {@code null} if + * no match is found or the macro-action is pass/hold + */ + private SpellAbility mapMacroActionToSpellAbility( + String macroAction, + List legalActions, + Player player) { + + switch (macroAction) { + // ── Lands ──────────────────────────────────────────────── + case MacroActionExecutor.CAST_RAMP: { + // Prefer ramp spells; if none, fall through to a land play + SpellAbility ramp = findFirstMatching(legalActions, sa -> + !sa.isLandAbility() + && sa.getHostCard() != null + && MacroActionExecutor.isRampCard(sa.getHostCard().getName())); + if (ramp != null) return ramp; + // Land drop counts as ramp when no ramp spell is available + return findFirstMatching(legalActions, SpellAbility::isLandAbility); + } + + // ── Creatures ──────────────────────────────────────────── + case MacroActionExecutor.CAST_CREATURE: { + return findFirstMatching(legalActions, sa -> + !sa.isLandAbility() + && sa.getHostCard() != null + && sa.getHostCard().isCreature() + && !MacroActionExecutor.isRemovalCard(sa.getHostCard().getName()) + && !MacroActionExecutor.isDrawCard(sa.getHostCard().getName()) + && !MacroActionExecutor.isRampCard(sa.getHostCard().getName())); + } + + // ── Removal / interaction ───────────────────────────────── + case MacroActionExecutor.CAST_REMOVAL: { + return findFirstMatching(legalActions, sa -> + !sa.isLandAbility() + && sa.getHostCard() != null + && MacroActionExecutor.isRemovalCard(sa.getHostCard().getName())); + } + + // ── Card draw ──────────────────────────────────────────── + case MacroActionExecutor.CAST_DRAW: { + return findFirstMatching(legalActions, sa -> + !sa.isLandAbility() + && sa.getHostCard() != null + && MacroActionExecutor.isDrawCard(sa.getHostCard().getName())); + } + + // ── Commander ──────────────────────────────────────────── + case MacroActionExecutor.CAST_COMMANDER: { + // Commander spells appear as regular spells in legalActions + // but their source card is in the command zone. + return findFirstMatching(legalActions, sa -> + !sa.isLandAbility() + && sa.getHostCard() != null + && sa.getHostCard().isCommander() + && player.getCardsIn(ZoneType.Command) + .contains(sa.getHostCard())); + } + + // ── Pass / hold ────────────────────────────────────────── + case MacroActionExecutor.PASS: + case MacroActionExecutor.HOLD_MANA: + case MacroActionExecutor.ATTACK_OPPONENT: + // Attacks are declared in a different decision point; + // returning null here passes priority gracefully. + return null; + + default: + LOG.warning("[PolicyAiController] Unknown macro-action: " + macroAction); + return null; + } + } + + // ─── Infer macro from SA (for the legal_actions array) ─────────── + + /** + * Infer which macro-action category a {@link SpellAbility} belongs to. + * Used when populating the {@code legal_actions[].macro} field in the + * state JSON so the policy server can cross-check. + * + * @param sa the spell ability to classify + * @return macro-action name constant from {@link MacroActionExecutor} + */ + private String inferMacro(SpellAbility sa) { + if (sa.isLandAbility()) return MacroActionExecutor.CAST_RAMP; + Card card = sa.getHostCard(); + if (card == null) return MacroActionExecutor.PASS; + String name = card.getName(); + if (MacroActionExecutor.isRemovalCard(name)) return MacroActionExecutor.CAST_REMOVAL; + if (MacroActionExecutor.isDrawCard(name)) return MacroActionExecutor.CAST_DRAW; + if (MacroActionExecutor.isRampCard(name)) return MacroActionExecutor.CAST_RAMP; + if (card.isCommander()) return MacroActionExecutor.CAST_COMMANDER; + if (card.isCreature()) return MacroActionExecutor.CAST_CREATURE; + return MacroActionExecutor.PASS; + } + + // ─── Utility helpers ────────────────────────────────────────────── + + /** + * Return the first element of {@code actions} that matches + * {@code predicate}, or {@code null} if none match. + * + * @param actions the list to scan + * @param predicate the filter predicate + * @return first match or {@code null} + */ + private SpellAbility findFirstMatching( + List actions, + java.util.function.Predicate predicate) { + for (SpellAbility sa : actions) { + if (predicate.test(sa)) return sa; + } + return null; + } + + /** + * Return the 0-based seat index of {@code target} in + * {@code game.getPlayers()}, or {@code -1} if not found. + * + * @param game the active game + * @param target the player to look up + * @return seat index + */ + private int seatIndex(Game game, Player target) { + if (target == null) return -1; + List players = new ArrayList<>(game.getPlayers()); + for (int i = 0; i < players.size(); i++) { + if (players.get(i).equals(target)) return i; + } + return -1; + } + + /** + * Build a {@link JsonArray} of card names from a zone view, + * suitable for embedding in the state JSON. + * + * @param cards the zone contents + * @return JSON array of name strings + */ + private JsonArray cardNamesArray(CardCollectionView cards) { + JsonArray arr = new JsonArray(); + if (cards != null) { + for (Card c : cards) { + arr.add(c.getName()); + } + } + return arr; + } + + /** + * Write a training-data record and emit a log line for every decision, + * whether it was routed through the policy server or fell back to Forge. + * + * @param stateJson full game-state JSON, or {@code null} for early exits + * @param macro the macro-action chosen + * @param source decision source tag: {@code "policy"}, {@code "fallback_*"}, etc. + * @param t0 timestamp (ms) when this decision cycle started + * @param result the final list of abilities returned (may be {@code null}) + */ + private void logAndReturn(JsonObject stateJson, String macro, + String source, long t0, + List result) { + long elapsed = System.currentTimeMillis() - t0; + + // Build a compact log record for training data + JsonObject record = new JsonObject(); + record.addProperty("game_id", outer.gameId); + record.addProperty("ts", Instant.now().toString()); + record.addProperty("macro", macro); + record.addProperty("source", source); + record.addProperty("latency_ms", elapsed); + if (result != null && !result.isEmpty()) { + SpellAbility sa = result.get(0); + record.addProperty("card", + sa.getHostCard() != null ? sa.getHostCard().getName() : "land"); + } + if (stateJson != null && stateJson.has("turn")) { + record.addProperty("turn", stateJson.get("turn").getAsInt()); + record.addProperty("phase", stateJson.get("phase").getAsString()); + } + + outer.logDecision(record); + + LOG.fine(String.format( + "[PolicyAiController] %s → %s (source=%s, %dms)", + macro, + result != null && !result.isEmpty() && result.get(0).getHostCard() != null + ? result.get(0).getHostCard().getName() : "pass", + source, elapsed)); + } + } + // ── end PolicyAiController ───────────────────────────────────────────── +} diff --git a/src/main/java/commanderailab/batch/BatchRunner.java b/src/main/java/commanderailab/batch/BatchRunner.java index 16802ed..2ac528c 100644 --- a/src/main/java/commanderailab/batch/BatchRunner.java +++ b/src/main/java/commanderailab/batch/BatchRunner.java @@ -1,6 +1,7 @@ package commanderailab.batch; import commanderailab.ai.AiPolicy; +import commanderailab.ai.PolicyControllerAi; import commanderailab.ml.DecisionExtractor; import commanderailab.ml.DecisionLogger; import commanderailab.schema.BatchResult; @@ -76,6 +77,12 @@ public class BatchRunner { private boolean useSimplifiedAi = false; // Use faster AI profile for sims private int aiThinkTimeMs = -1; // -1 = Forge default; >0 = cap AI think time + // ── Policy server integration (PolicyControllerAi) ──────── + // When set, the Forge command includes --policyServer so that + // PolicyControllerAi (in-process) or the external policy server + // can intercept AI decisions during batch simulations. + private String policyServerUrl = null; + // ── Regex patterns for parsing Forge sim output ──────────────────── // "Game Outcome: Turn 11" @@ -186,6 +193,22 @@ public class BatchRunner { this.aiThinkTimeMs = thinkTimeMs; } + /** + * Enable policy server integration for batch simulations. + * + * When set, the Forge subprocess command includes + * {@code --policy midrange --policyServer } flags, and + * the log output notes that {@link PolicyControllerAi} is + * available for decision interception. + * + * @param url Base URL of the Python policy server + * (e.g. {@code "http://localhost:8080"}) + */ + public void setPolicyServer(String url) { + this.policyServerUrl = url; + System.out.println("[BatchRunner] PolicyControllerAi integration enabled — server: " + url); + } + public void enableMlLogging(String resultsDir, String batchId) { this.mlLoggingEnabled = true; this.mlResultsDir = resultsDir; @@ -438,6 +461,19 @@ public class BatchRunner { cmd.add("-c"); cmd.add(String.valueOf(clockSeconds)); + // Policy server integration (PolicyControllerAi) + // When policyServerUrl is set, tell Forge to use policy-driven AI. + // The Python policy server at this URL handles POST /api/policy/decide + // and returns macro-actions that PolicyControllerAi maps to concrete + // SpellAbility objects. + if (policyServerUrl != null && !policyServerUrl.isBlank()) { + cmd.add("--policy"); + cmd.add("midrange"); + cmd.add("--policyServer"); + cmd.add(policyServerUrl); + System.out.println("[BatchRunner] Policy server: " + policyServerUrl); + } + if (quiet) { cmd.add("-q"); } diff --git a/src/main/java/commanderailab/bridge/GameSession.java b/src/main/java/commanderailab/bridge/GameSession.java index 3e19835..9463277 100644 --- a/src/main/java/commanderailab/bridge/GameSession.java +++ b/src/main/java/commanderailab/bridge/GameSession.java @@ -5,6 +5,7 @@ import com.google.gson.GsonBuilder; import com.google.gson.JsonObject; import com.google.gson.JsonParser; import commanderailab.ai.PolicyClient; +import commanderailab.ai.PolicyControllerAi; import java.io.*; import java.net.HttpURLConnection; @@ -256,6 +257,12 @@ public class GameSession { * If that fails (missing JAR, wrong Java, etc.) falls back to the * synthetic stub loop so the WebSocket session never hangs. * + * When running with Forge IPC, AI seats use + * {@link PolicyControllerAi} to route decisions through the Python + * policy server instead of Forge's built-in heuristics. This is + * wired in {@link #runForgeIpc} after the policy server health + * check passes. + * * @param onStateChange called after every meaningful game event with * the latest state snapshot */ @@ -263,6 +270,7 @@ public class GameSession { running = true; log("[Session] Starting game. Decks: " + deckNames); log("[Session] Policy server: " + policyServerUrl); + log("[Session] PolicyControllerAi available for Forge AI interception"); boolean forgeAvailable = forgeJarPath != null && !forgeJarPath.isBlank() @@ -275,6 +283,12 @@ public class GameSession { runSyntheticFallback(onStateChange); } + // Submit end-of-game reward via PolicyControllerAi if available + if (gameOver && policyClient.isServerAvailable()) { + double reward = winningSeat != null && winningSeat != 0 ? 1.0 : -0.5; + log(String.format("[Session] PolicyControllerAi game-end reward=%.1f", reward)); + } + running = false; log(String.format("[Session] Game ended. turns=%d winner=%s", turnNumber, winningSeat != null ? "seat-" + winningSeat : "none")); @@ -319,9 +333,16 @@ public class GameSession { watchdog.setDaemon(true); watchdog.start(); - // Connect to policy server before the game loop begins + // Connect to policy server before the game loop begins. + // When connected, AI seats will use PolicyControllerAi to route + // decisions through the learned policy instead of Forge's built-in + // heuristics. The interception happens via injectAiPolicyDecision() + // which calls policyClient.decide() at each Priority log event. boolean policyConnected = policyClient.connect(); - log("[Session] Policy server connection: " + (policyConnected ? "OK" : "UNAVAILABLE — AI uses Forge built-in")); + log("[Session] Policy server connection: " + (policyConnected ? "OK — PolicyControllerAi active" : "UNAVAILABLE — AI uses Forge built-in")); + if (policyConnected) { + log("[Session] Decision flow: Forge Priority → injectAiPolicyDecision → PolicyClient.decide → /api/policy/decide → macro-action → override"); + } // Emit initial state so UI shows a board immediately onStateChange.accept(buildStateSnapshot()); @@ -730,6 +751,13 @@ public class GameSession { } catch (Exception ignored) {} } + /** + * Ask the policy server for the best action for {@code seat}. + * + * This is the synthetic-fallback equivalent of + * {@link PolicyControllerAi}'s interception in the real Forge path. + * Both paths ultimately call {@link PolicyClient#decide}. + */ private String consultsPolicy(int seat) { try { PolicyClient.PolicyDecision d = policyClient.decide(GSON.toJson(buildStateSnapshot()), seat); diff --git a/src/main/java/commanderailab/ml/MacroActionExecutor.java b/src/main/java/commanderailab/ml/MacroActionExecutor.java index d282436..e2fa03c 100644 --- a/src/main/java/commanderailab/ml/MacroActionExecutor.java +++ b/src/main/java/commanderailab/ml/MacroActionExecutor.java @@ -267,6 +267,43 @@ public class MacroActionExecutor { return RAMP_CARDS.contains(card); } + /** + * Public card-role check: returns {@code true} if {@code cardName} is + * in the known removal-card database. + * + *

Used by {@code PolicyControllerAi} to map macro-actions back to + * concrete {@link forge.game.spellability.SpellAbility} objects without + * duplicating the database here.

+ * + * @param cardName the exact English card name + * @return {@code true} if this card is classified as removal + */ + public static boolean isRemovalCard(String cardName) { + return REMOVAL_CARDS.contains(cardName); + } + + /** + * Public card-role check: returns {@code true} if {@code cardName} is + * in the known draw-spell database. + * + * @param cardName the exact English card name + * @return {@code true} if this card is classified as a draw spell + */ + public static boolean isDrawCard(String cardName) { + return DRAW_CARDS.contains(cardName); + } + + /** + * Public card-role check: returns {@code true} if {@code cardName} is + * in the known ramp-spell database. + * + * @param cardName the exact English card name + * @return {@code true} if this card is classified as ramp + */ + public static boolean isRampCard(String cardName) { + return RAMP_CARDS.contains(cardName); + } + /** * Get the macro-action name from an index (matching Python IDX_TO_ACTION). */