From 74d24d606cfd5c743f0f9dfbf5089836949ef76b Mon Sep 17 00:00:00 2001 From: KoalaTrapLord <265133236+KoalaTrapLord@users.noreply.github.com> Date: Fri, 3 Apr 2026 17:42:01 -0400 Subject: [PATCH] fix: wire PolicyClient into real Forge IPC path via Priority interception Forge's sim mode does not honour --policy/--policyServer CLI flags, so the Python brain was never called during live games. AI only played lands because Forge ran its built-in AI for every decision. Fix: when parseForgeLine() detects a Priority event for an AI seat, the session now immediately calls policyClient.decide() with the current state snapshot and POSTs the result to /api/policy/override before Forge reads the next line. This mirrors what handleHumanTurn() already does for seat 0. The synthetic fallback path is unchanged. --- .../commanderailab/bridge/GameSession.java | 95 ++++++++++++++++++- 1 file changed, 91 insertions(+), 4 deletions(-) diff --git a/src/main/java/commanderailab/bridge/GameSession.java b/src/main/java/commanderailab/bridge/GameSession.java index e2ecd2f..3e19835 100644 --- a/src/main/java/commanderailab/bridge/GameSession.java +++ b/src/main/java/commanderailab/bridge/GameSession.java @@ -30,9 +30,13 @@ import java.util.regex.*; * waits on {@code humanActionQueue}, converts the action to the * macro-action string expected by /api/policy/decide, and injects it * via POST /api/policy/override so the policy server returns it. - * 4. Publishes a full state snapshot to the WebSocket frontend after every + * 4. AI seats (1+) are intercepted at every Priority event: the session + * calls policyClient.decide() with the current state snapshot and + * immediately POSTs the result to /api/policy/override so Forge uses + * the learned policy instead of its built-in AI. + * 5. Publishes a full state snapshot to the WebSocket frontend after every * meaningful Forge output event. - * 5. Falls back to the synthetic stub loop if Forge cannot be started + * 6. Falls back to the synthetic stub loop if Forge cannot be started * (e.g., missing JAR, wrong Java version). * * Zone sync model: @@ -120,6 +124,12 @@ public class GameSession { private volatile boolean gameOver = false; private volatile long elapsedMs = 0; + // Tracks whether policy was injected for the current priority window + // to avoid double-injecting on duplicate log lines. + private volatile int lastPolicyInjectionTurn = -1; + private volatile String lastPolicyInjectionPhase = ""; + private volatile int lastPolicyInjectionSeat = -1; + private final int numSeats; private final int[] life; private final int[] poison; @@ -309,6 +319,10 @@ public class GameSession { watchdog.setDaemon(true); watchdog.start(); + // Connect to policy server before the game loop begins + boolean policyConnected = policyClient.connect(); + log("[Session] Policy server connection: " + (policyConnected ? "OK" : "UNAVAILABLE — AI uses Forge built-in")); + // Emit initial state so UI shows a board immediately onStateChange.accept(buildStateSnapshot()); @@ -326,19 +340,29 @@ public class GameSession { reader.setDaemon(true); reader.start(); - // Main loop: drain line queue and service human input + // Main loop: drain line queue and service human/AI input while (!gameOver && running) { String line = lineQueue.poll(200, TimeUnit.MILLISECONDS); if (line != null) { lastActivityEpoch.incrementAndGet(); boolean changed = parseForgeLine(line); if (changed) onStateChange.accept(buildStateSnapshot()); + // Human seat if (awaitingHumanInput) handleHumanTurn(onStateChange); } else if (forgeDone.get() && lineQueue.isEmpty()) { break; } } + // Flush online learning tuples and submit reward if game finished + if (gameOver && policyClient.isServerAvailable()) { + double reward = winningSeat != null && winningSeat != 0 ? 1.0 : -0.5; + policyClient.submitReward("game-" + seed, reward); + policyClient.flushCollectedTuples(); + log(String.format("[Session] Submitted game reward=%.1f and flushed %d tuples", + reward, policyClient.getPendingTuples())); + } + watchdog.interrupt(); try { forgeProcess.waitFor(10, TimeUnit.SECONDS); } catch (InterruptedException ignored) {} @@ -432,8 +456,28 @@ public class GameSession { // Priority Matcher mPrio = P_PRIORITY.matcher(line); if (mPrio.find()) { - priorityPlayer = clampSeat(Integer.parseInt(mPrio.group(1)) - 1); + int seat = clampSeat(Integer.parseInt(mPrio.group(1)) - 1); + priorityPlayer = seat; changed = true; + + // ── Policy injection for AI seats ──────────────────────── + // Forge doesn't honour --policyServer natively, so we intercept + // every AI priority window here and push the policy decision to + // /api/policy/override before Forge's next read. + if (seat != 0 && policyClient.isServerAvailable()) { + // Deduplicate: only inject once per (turn, phase, seat) window + boolean alreadyInjected = + lastPolicyInjectionTurn == turnNumber + && lastPolicyInjectionPhase.equals(phase) + && lastPolicyInjectionSeat == seat; + + if (!alreadyInjected) { + lastPolicyInjectionTurn = turnNumber; + lastPolicyInjectionPhase = phase; + lastPolicyInjectionSeat = seat; + injectAiPolicyDecision(seat); + } + } } // Life totals @@ -535,6 +579,49 @@ public class GameSession { return changed; } + // ---------------------------------------------------------------- + // AI policy injection (real Forge IPC path) + // ---------------------------------------------------------------- + + /** + * Ask the Python policy server what action AI seat {@code seat} should + * take given the current game state, then POST it to /api/policy/override + * so that the next time Forge calls /api/policy/decide for this seat it + * receives the pre-computed answer without an extra round-trip. + * + * This is the core wiring that was missing: Forge's sim mode does not + * natively call --policyServer, so we intercept every Priority log line + * and drive the decision from the Java side instead. + */ + private void injectAiPolicyDecision(int seat) { + try { + PolicyClient.PolicyDecision decision = + policyClient.decide(GSON.toJson(buildStateSnapshot()), seat); + + if (decision == null || decision.action() == null) { + log(String.format("[Policy] seat=%d turn=%d phase=%s → null decision, skipping override", + seat, turnNumber, phase)); + return; + } + + String action = decision.action(); + log(String.format("[Policy] seat=%d turn=%d phase=%s → %s (conf=%.2f)", + seat, turnNumber, phase, action, decision.confidence())); + + // Push the decision to the override endpoint so Forge's next + // /api/policy/decide call for this seat returns it immediately. + JsonObject body = new JsonObject(); + body.addProperty("seat", seat); + body.addProperty("action", action); + httpPost(policyServerUrl + "/api/policy/override", body.toString()); + + } catch (Exception e) { + // Non-fatal: Forge falls back to its built-in AI for this window + log(String.format("[Policy] seat=%d override failed (%s) — Forge built-in takes over", + seat, e.getMessage())); + } + } + // ---------------------------------------------------------------- // Human turn handling // ----------------------------------------------------------------