diff --git a/src/main/java/commanderailab/bridge/GameSession.java b/src/main/java/commanderailab/bridge/GameSession.java index 5e21f0c..f0a485f 100644 --- a/src/main/java/commanderailab/bridge/GameSession.java +++ b/src/main/java/commanderailab/bridge/GameSession.java @@ -2,6 +2,7 @@ package commanderailab.bridge; import com.google.gson.Gson; import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; import com.google.gson.JsonObject; import com.google.gson.JsonParser; import commanderailab.ai.PolicyClient; @@ -19,11 +20,72 @@ import java.util.function.Consumer; * * AI turns now consult the policy server for macro-action decisions * instead of using random stub logic. + * + * STATE VECTOR (Issue #83 Step 1) + * buildStateVector(seat) emits a float[29] global scalar block that + * matches the STATE_DIMS layout defined in ml/config/scope.py: + * + * Per-player block — 14 features × 2 players = 28 floats + * indices [0..13] = active seat ("self") + * indices [14..27] = averaged opponent seats + * index [28] = turn_number / MAX_TURNS + * + * Per-player feature layout (offset 0 within each block): + * [0] life_total / 40.0 + * [1] commander_damage / 21.0 (stub 0.0 — wire from Forge in Step 4) + * [2] mana_available / 20.0 (stub 0.0 — wire from Forge in Step 4) + * [3] commander_tax / 10.0 + * [4] cards_in_hand / 15.0 + * [5] cards_in_graveyard / 100.0 + * [6] creatures_on_battlefield / 30.0 + * [7] total_power_on_board / 100.0 (stub = creature count — wire P/T in Step 4) + * [8] is_active_player (0.0 or 1.0) + * [9] phase_onehot[main1] (0.0 or 1.0) + * [10] phase_onehot[combat] (0.0 or 1.0) + * [11] phase_onehot[main2] (0.0 or 1.0) + * [12] phase_onehot[end] (0.0 or 1.0) + * [13] (reserved / padding — always 0.0) + * + * buildStateSnapshot() appends "state_vector": [...] so PolicyClient + * sends a machine-readable vector directly usable by the Python encoder + * without re-parsing nested human-readable fields. */ public class GameSession { private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); + // ---------------------------------------------------------------- + // State vector constants — must mirror ml/config/scope.py STATE_DIMS + // ---------------------------------------------------------------- + private static final int PER_PLAYER_FEATURES = 14; + private static final int NUM_PLAYERS_IN_VECTOR = 2; // self + opponents + private static final int GLOBAL_SCALAR_DIM = PER_PLAYER_FEATURES * NUM_PLAYERS_IN_VECTOR + 1; // 29 + private static final float MAX_LIFE = 40.0f; + private static final float MAX_COMMANDER_DAMAGE = 21.0f; + private static final float MAX_MANA = 20.0f; + private static final float MAX_COMMANDER_TAX = 10.0f; + private static final float MAX_HAND = 15.0f; + private static final float MAX_GRAVEYARD = 100.0f; + private static final float MAX_CREATURES = 30.0f; + private static final float MAX_POWER = 100.0f; + private static final float MAX_TURNS = 25.0f; + + // Phase name → one-hot index (matches PHASE_TO_IDX in scope.py) + private static final Map PHASE_ONEHOT_IDX; + static { + PHASE_ONEHOT_IDX = new HashMap<>(); + PHASE_ONEHOT_IDX.put("MAIN1", 0); + PHASE_ONEHOT_IDX.put("BEGIN_COMBAT", 1); + PHASE_ONEHOT_IDX.put("DECLARE_ATTACKERS", 1); + PHASE_ONEHOT_IDX.put("DECLARE_BLOCKERS", 1); + PHASE_ONEHOT_IDX.put("DAMAGE", 1); + PHASE_ONEHOT_IDX.put("END_COMBAT", 1); + PHASE_ONEHOT_IDX.put("MAIN2", 2); + PHASE_ONEHOT_IDX.put("END", 3); + PHASE_ONEHOT_IDX.put("CLEANUP", 3); + // UNTAP, UPKEEP, DRAW all map to no active slot (all zeros) + } + private final List deckNames; private final Long seed; private final String forgeJarPath; @@ -100,13 +162,13 @@ public class GameSession { public int getTurnNumber() { return turnNumber; } public void stop() { running = false; } + // ================================================================ + // Game loop + // ================================================================ + /** * Run the game loop. Calls onStateChange after every action resolves. * Blocks until the game ends or stop() is called. - * - * PHASE 2: AI turns now consult the PolicyClient for decisions. - * The policy server returns a macro-action which is applied to game state. - * Falls back to PASS_PRIORITY if the policy server is unreachable. */ public void run(Consumer> onStateChange) throws InterruptedException { running = true; @@ -138,7 +200,6 @@ public class GameSession { awaitingHumanInput = false; } else if (!isHumanTurn && isInteractivePhase) { - // Phase 2: consult PolicyClient for AI decision priorityPlayer = activePlayer; Map snapshot = buildStateSnapshot(); String policyAction = consultPolicy(activePlayer, snapshot); @@ -146,11 +207,9 @@ public class GameSession { System.out.println("[Session] AI-" + activePlayer + " policy action: " + policyAction); } else { - // Non-interactive phase — small delay for readability Thread.sleep(300); } - // Advance phase phaseIndex++; if (phaseIndex >= PHASES.length) { phaseIndex = 0; @@ -163,15 +222,115 @@ public class GameSession { System.out.println("[Session] Game ended after " + turnNumber + " turns."); } - // --------------------------------------------------------- - // Policy integration (Phase 2) - // --------------------------------------------------------- + // ================================================================ + // State vector (Issue #83 Step 1) + // ================================================================ /** - * Consult the Python policy server for an AI decision. - * Sends the current game snapshot and returns the recommended macro-action. - * Falls back to PASS_PRIORITY on error. + * Build the global scalar state vector for the given seat. + * + * Returns float[29] matching STATE_DIMS.global_features (29) in + * ml/config/scope.py: + * indices [0..13] = self (seat) per-player features + * indices [14..27] = mean-pooled opponent per-player features + * index [28] = turn_number / MAX_TURNS + * + * Stub fields (mana_available, commander_damage, total_power_on_board) + * are 0.0 until real Forge board data is wired in Step 4. */ + public float[] buildStateVector(int seat) { + float[] vec = new float[GLOBAL_SCALAR_DIM]; // 29 zeros + + // --- Self block: indices 0..13 --- + fillPlayerBlock(vec, 0, seat, true); + + // --- Opponent block: indices 14..27 (mean of all non-self seats) --- + int numOpponents = 0; + float[] oppAccum = new float[PER_PLAYER_FEATURES]; + for (int i = 0; i < deckNames.size(); i++) { + if (i == seat) continue; + float[] oppBlock = buildPlayerFeatures(i, false); + for (int f = 0; f < PER_PLAYER_FEATURES; f++) { + oppAccum[f] += oppBlock[f]; + } + numOpponents++; + } + if (numOpponents > 0) { + for (int f = 0; f < PER_PLAYER_FEATURES; f++) { + vec[PER_PLAYER_FEATURES + f] = oppAccum[f] / numOpponents; + } + } + + // --- Global: turn_number at index 28 --- + vec[28] = Math.min(turnNumber / MAX_TURNS, 1.0f); + + return vec; + } + + /** + * Fill 14 per-player features into vec starting at offset. + */ + private void fillPlayerBlock(float[] vec, int offset, int seat, boolean isSelf) { + float[] block = buildPlayerFeatures(seat, isSelf); + System.arraycopy(block, 0, vec, offset, PER_PLAYER_FEATURES); + } + + /** + * Build 14 per-player features for a single seat. + * + * Feature layout (must match per_player_features in scope.py): + * [0] life_total / MAX_LIFE + * [1] commander_damage / MAX_COMMANDER_DAMAGE (stub 0.0) + * [2] mana_available / MAX_MANA (stub 0.0) + * [3] commander_tax / MAX_COMMANDER_TAX + * [4] cards_in_hand / MAX_HAND + * [5] cards_in_graveyard / MAX_GRAVEYARD + * [6] creatures_on_battlefield / MAX_CREATURES + * [7] total_power_on_board / MAX_POWER (stub = creature count) + * [8] is_active_player (1.0 if this seat == activePlayer) + * [9] phase_onehot[main1] + * [10] phase_onehot[combat] + * [11] phase_onehot[main2] + * [12] phase_onehot[end] + * [13] (reserved — 0.0) + */ + private float[] buildPlayerFeatures(int seat, boolean isSelf) { + float[] f = new float[PER_PLAYER_FEATURES]; + + f[0] = clamp(life[seat] / MAX_LIFE); + f[1] = 0.0f; // commander_damage stub — wire from Forge in Step 4 + f[2] = 0.0f; // mana_available stub — wire from Forge in Step 4 + f[3] = clamp(commanderTax[seat] / MAX_COMMANDER_TAX); + f[4] = clamp(hands[seat].size() / MAX_HAND); + f[5] = clamp(graveyards[seat].size() / MAX_GRAVEYARD); + + // Creature count — all battlefield cards treated as creatures until + // real card type data arrives in Step 4 + float creatureCount = battlefields[seat].size(); + f[6] = clamp(creatureCount / MAX_CREATURES); + f[7] = clamp(creatureCount / MAX_POWER); // stub: power = creature count + + f[8] = (seat == activePlayer) ? 1.0f : 0.0f; + + // Phase one-hot (indices 9..12) + Integer phaseSlot = PHASE_ONEHOT_IDX.get(phase); + if (phaseSlot != null) { + f[9 + phaseSlot] = 1.0f; + } + // f[13] = 0.0f (reserved) + + return f; + } + + /** Clamp a normalized value to [0, 1]. */ + private static float clamp(float v) { + return Math.max(0.0f, Math.min(1.0f, v)); + } + + // ================================================================ + // Policy integration + // ================================================================ + private String consultPolicy(int seat, Map snapshot) { try { String stateJson = GSON.toJson(snapshot); @@ -188,12 +347,9 @@ public class GameSession { return "PASS_PRIORITY"; } - /** - * Apply a policy-recommended macro-action for an AI player. - */ private void applyPolicyAction(int seat, String action, Random rng) { switch (action) { - case "CAST_SPELL" -> { + case "CAST_SPELL", "cast_creature", "cast_ramp", "cast_draw", "cast_commander" -> { if (!hands[seat].isEmpty()) { String card = hands[seat].remove(0); battlefields[seat].add(card); @@ -206,7 +362,7 @@ public class GameSession { battlefields[seat].add(card); } } - case "ATTACK" -> { + case "ATTACK_OPPONENT", "ATTACK" -> { int target = (seat + 1) % life.length; if (life[target] > 0) { int dmg = rng.nextInt(3) + 1; @@ -214,14 +370,25 @@ public class GameSession { System.out.println("[Session] AI-" + seat + " attacks player " + target + " for " + dmg); } } - case "PASS_PRIORITY" -> { /* no-op */ } + case "cast_removal" -> { + // Find an opponent with creatures and remove one + for (int i = 0; i < deckNames.size(); i++) { + if (i != seat && !battlefields[i].isEmpty()) { + String removed = battlefields[i].remove(0); + graveyards[i].add(removed); + System.out.println("[Session] AI-" + seat + " removed: " + removed + " from seat " + i); + break; + } + } + } + case "hold_mana", "pass", "PASS_PRIORITY", "HOLD_MANA" -> { /* no-op */ } default -> System.out.println("[Session] Unknown policy action: " + action); } } - // --------------------------------------------------------- + // ================================================================ // Human action handling - // --------------------------------------------------------- + // ================================================================ private void applyAction(String actionJson) { try { @@ -249,9 +416,9 @@ public class GameSession { return alive <= 1 || turnNumber > 50; } - // --------------------------------------------------------- + // ================================================================ // State serialization - // --------------------------------------------------------- + // ================================================================ /** Returns a full { type: "STATE", state: {...} } JSON string. */ public String buildStateMessage() { @@ -261,10 +428,19 @@ public class GameSession { return GSON.toJson(msg); } - /** Returns the raw state snapshot map (serialized by caller). */ + /** + * Returns the raw state snapshot map. + * + * Includes a "state_vector" key containing a float[29] global scalar + * vector (Issue #83 Step 1) so PolicyClient.decide() sends a + * machine-readable vector the Python /api/policy/decide endpoint can + * pass directly to the ml/encoder without re-parsing nested fields. + * + * Stub fields within the vector are clearly marked in buildPlayerFeatures(). + */ public Map buildStateSnapshot() { Map state = new LinkedHashMap<>(); - state.put("schema", "1.0.0"); + state.put("schema", "1.1.0"); // bumped from 1.0.0 — state_vector added state.put("phase", phase); state.put("turnNumber", turnNumber); state.put("activePlayer", activePlayer); @@ -272,6 +448,14 @@ public class GameSession { state.put("awaitingInput", awaitingHumanInput); state.put("inputPrompt", awaitingHumanInput ? "Choose an action or pass priority" : null); + // Machine-readable global scalar vector for the Python encoder + // Emitted for activePlayer's perspective + float[] sv = buildStateVector(activePlayer); + List svList = new ArrayList<>(sv.length); + for (float v : sv) svList.add(v); + state.put("state_vector", svList); + state.put("state_vector_dim", sv.length); // sanity: should always be 29 + List> players = new ArrayList<>(); for (int i = 0; i < deckNames.size(); i++) { Map p = new LinkedHashMap<>(); @@ -293,7 +477,6 @@ public class GameSession { state.put("players", players); state.put("stack", List.of()); - // Legal actions — only populated when it's the human's turn List> legalActions = new ArrayList<>(); if (awaitingHumanInput) { legalActions.add(Map.of("type", "PASS_PRIORITY", "label", "Pass Priority"));