feat: PolicyControllerAi integration — Forge AI decision interception via policy server
## New File - PolicyControllerAi.java (840 lines): Extends Forge's PlayerControllerAi to intercept AI decisions at chooseSpellAbilityToPlay() and route them through the Python policy server. Falls back to Forge built-in AI on any failure. Inner PolicyAiController overrides AiController with full legal action gathering via ComputerUtilAbility. ## Wiring - GameSession.java: Import + Javadoc for PolicyControllerAi decision flow. Logs decision pipeline when policy server connects. End-of-game reward submission via PolicyControllerAi lifecycle. - BatchRunner.java: New setPolicyServer(url) method. Appends --policy midrange --policyServer <url> to Forge subprocess command when policy server URL is configured. ## Path Updates (Forge-Repo rename) - README.md: Forge link → KoalaTrapLord/Forge-Repo, build path updated - .env.example: FORGE_JAR_PATH and FORGE_WORKING_DIR → Forge-Repo - lab_api.py: Auto-detect paths try Forge-Repo first, forge-repo as legacy ## MacroActionExecutor - Added public isRemovalCard(), isDrawCard(), isRampCard() wrappers so PolicyControllerAi can consult the same card-role databases. Refs: Issue #83 Phase 2.4
This commit is contained in:
1 parent
4cae97ab44
commit
a23fdc55a4
7 files changed
+952
-8
No files matched your search
+2
-2
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
+5
-2
@@ -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", "")
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
* <h2>Architecture</h2>
|
||||
* <pre>
|
||||
* Forge game loop
|
||||
* └─ player.getController() → PolicyControllerAi
|
||||
* └─ brains (PolicyAiController extends AiController)
|
||||
* └─ chooseSpellAbilityToPlay()
|
||||
* ├─ buildGameStateJson()
|
||||
* ├─ policyClient.decide(stateJson) → macro-action
|
||||
* ├─ mapMacroActionToSpellAbility() → SpellAbility
|
||||
* └─ super.chooseSpellAbilityToPlay() (fallback)
|
||||
* </pre>
|
||||
*
|
||||
* <h2>Two-class design</h2>
|
||||
* <ul>
|
||||
* <li>{@link PolicyAiController} — inner subclass of {@link AiController}
|
||||
* that overrides {@code chooseSpellAbilityToPlay()}. This is the
|
||||
* intercept point closest to the game loop.</li>
|
||||
* <li>{@link PolicyControllerAi} — subclass of {@link PlayerControllerAi}
|
||||
* that swaps Forge's default {@code AiController} for a
|
||||
* {@code PolicyAiController} and exposes lifecycle helpers
|
||||
* (game-end reward submission, statistics).</li>
|
||||
* </ul>
|
||||
*
|
||||
* <h2>Decision flow</h2>
|
||||
* <ol>
|
||||
* <li>Build a rich {@link JsonObject} snapshot of the current game state
|
||||
* (per-player life/mana/zones, stack, phase, legal actions).</li>
|
||||
* <li>POST the snapshot to {@code /api/policy/decide} via
|
||||
* {@link PolicyClient#decide(JsonObject)}.</li>
|
||||
* <li>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.</li>
|
||||
* <li>If the server is unreachable or no mapping succeeds, fall back to
|
||||
* {@link AiController#chooseSpellAbilityToPlay()} transparently.</li>
|
||||
* <li>Log every decision (policy or fallback) for training data collection.</li>
|
||||
* </ol>
|
||||
*
|
||||
* <h2>Fallback guarantee</h2>
|
||||
* 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.
|
||||
*
|
||||
* <h2>Usage</h2>
|
||||
* <pre>{@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);
|
||||
* }</pre>
|
||||
*
|
||||
* @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.
|
||||
*
|
||||
* <p>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.</p>
|
||||
*
|
||||
* @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.
|
||||
*
|
||||
* <p>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.</p>
|
||||
*
|
||||
* @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.
|
||||
*
|
||||
* <p>Call this from your game-runner once Forge reports a winner.</p>
|
||||
*
|
||||
* @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}.
|
||||
*
|
||||
* <p>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.</p>
|
||||
*
|
||||
* <p>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.</p>
|
||||
*
|
||||
* @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.
|
||||
*
|
||||
* <h2>Decision flow</h2>
|
||||
* <ol>
|
||||
* <li>Enumerate all legal actions (lands + spells/abilities) using
|
||||
* {@link ComputerUtilAbility} — the same utilities Forge uses
|
||||
* internally — so the legal-action list is always authoritative.</li>
|
||||
* <li>Build a {@link JsonObject} describing the full game state,
|
||||
* including per-player life/mana/zones/stack/phase and a
|
||||
* {@code legal_actions} array.</li>
|
||||
* <li>POST to {@code /api/policy/decide} via {@link PolicyClient#decide(JsonObject)}.
|
||||
* The server returns a macro-action name such as {@code "cast_creature"}.</li>
|
||||
* <li>Translate the macro-action to a concrete {@link SpellAbility} by
|
||||
* scanning the legal-action list with
|
||||
* {@link #mapMacroActionToSpellAbility}.</li>
|
||||
* <li>On any failure, delegate to the parent
|
||||
* {@link AiController#chooseSpellAbilityToPlay()} so the game never stalls.</li>
|
||||
* </ol>
|
||||
*
|
||||
* <h2>Thread safety</h2>
|
||||
* 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.
|
||||
*
|
||||
* <p>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.</p>
|
||||
*
|
||||
* @return list of {@link SpellAbility} objects for Forge to execute,
|
||||
* or {@code null} / empty list to pass priority
|
||||
*/
|
||||
@Override
|
||||
public List<SpellAbility> chooseSpellAbilityToPlay() {
|
||||
long t0 = System.currentTimeMillis();
|
||||
|
||||
try {
|
||||
Player player = getPlayer();
|
||||
Game game = getGame();
|
||||
|
||||
// 1. Enumerate legal actions
|
||||
List<SpellAbility> 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<SpellAbility> 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.
|
||||
*
|
||||
* <p>The result combines:</p>
|
||||
* <ul>
|
||||
* <li>Land-play abilities (from hand or other sources Forge allows)</li>
|
||||
* <li>All playable spell/activated abilities from hand and battlefield</li>
|
||||
* </ul>
|
||||
*
|
||||
* @param game the active game
|
||||
* @param player the acting player
|
||||
* @return mutable list of legal {@link SpellAbility} objects; never {@code null}
|
||||
*/
|
||||
private List<SpellAbility> gatherLegalActions(Game game, Player player) {
|
||||
List<SpellAbility> 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<SpellAbility> 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<SpellAbility> 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}.
|
||||
*
|
||||
* <p>The JSON schema mirrors the Python-side state encoder:</p>
|
||||
* <pre>
|
||||
* {
|
||||
* "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
|
||||
* }, …
|
||||
* ]
|
||||
* }
|
||||
* </pre>
|
||||
*
|
||||
* @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<SpellAbility> 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<Player> 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<Card, Integer> 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.
|
||||
*
|
||||
* <p>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.</p>
|
||||
*
|
||||
* <p>For {@code CAST_COMMANDER}, the command zone is checked first.</p>
|
||||
*
|
||||
* @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<SpellAbility> 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<SpellAbility> actions,
|
||||
java.util.function.Predicate<SpellAbility> 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<Player> 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<SpellAbility> 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 ─────────────────────────────────────────────
|
||||
}
|
||||
@@ -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 <url>} 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");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
* <p>Used by {@code PolicyControllerAi} to map macro-actions back to
|
||||
* concrete {@link forge.game.spellability.SpellAbility} objects without
|
||||
* duplicating the database here.</p>
|
||||
*
|
||||
* @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).
|
||||
*/
|
||||
|
||||
Reference in new issue
Block a user