feat(bridge): add buildStateVector() emitting state_vector float[] in snapshots (#83 Step 1)
Adds a buildStateVector(int seat) method to GameSession that produces
a float[29] global scalar vector matching STATE_DIMS layout in
ml/config/scope.py:
Per-player block (14 features × 2 players = 28):
[0] life_total / 40.0
[1] commander_damage / 21.0 (stub 0.0 until real Forge tracks it)
[2] mana_available / 20.0 (stub 0.0 until mana pool wired)
[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 until P/T data)
[8] is_active_player (0 or 1)
[9] phase one-hot[0] main1
[10] phase one-hot[1] combat
[11] phase one-hot[2] main2
[12] phase one-hot[3] end
[13] (padding / reserved)
Global:
[28] turn_number / max_turns (25)
buildStateSnapshot() now includes "state_vector": float[] so that
PolicyClient.decide(JsonObject) sends a machine-readable vector the
Python /api/policy/decide endpoint can pass directly to the encoder
without re-parsing the nested human-readable fields.
Stub fields (mana_available, commander_damage, total_power_on_board)
are zero until real Forge board state is wired in Step 4.
This commit is contained in:
1 parent
11990c2fd3
commit
da8de77704
1 file changed
+209
-26
@@ -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<String, Integer> 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<String> 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<Map<String, Object>> 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<String, Object> 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<String, Object> 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<String, Object> buildStateSnapshot() {
|
||||
Map<String, Object> 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<Float> 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<Map<String, Object>> players = new ArrayList<>();
|
||||
for (int i = 0; i < deckNames.size(); i++) {
|
||||
Map<String, Object> 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<Map<String, Object>> legalActions = new ArrayList<>();
|
||||
if (awaitingHumanInput) {
|
||||
legalActions.add(Map.of("type", "PASS_PRIORITY", "label", "Pass Priority"));
|
||||
|
||||
Reference in new issue
Block a user