GameSession.java references these APIs which were missing from PolicyClient, causing 3 compilation errors. Added: - getBaseUrl() getter - PolicyDecision record (action, confidence) - decide(String, int) overload that delegates to decide(JsonObject)
360 lines
13 KiB
Java
360 lines
13 KiB
Java
package commanderailab.ai;
|
|
|
|
import commanderailab.ml.LearnedPolicy;
|
|
import commanderailab.ml.MacroActionExecutor;
|
|
import commanderailab.ml.PolicyServerClient;
|
|
import commanderailab.schema.DecisionSnapshot;
|
|
|
|
import com.google.gson.Gson;
|
|
import com.google.gson.GsonBuilder;
|
|
import com.google.gson.JsonArray;
|
|
import com.google.gson.JsonObject;
|
|
|
|
import java.io.*;
|
|
import java.net.HttpURLConnection;
|
|
import java.net.URL;
|
|
import java.nio.charset.StandardCharsets;
|
|
import java.util.*;
|
|
import java.util.concurrent.ConcurrentLinkedQueue;
|
|
|
|
/**
|
|
* PolicyClient — Phase 2 bridge between Forge decision points and the
|
|
* Python policy server for live online training.
|
|
*
|
|
* This class extends the existing LearnedPolicy/PolicyServerClient
|
|
* infrastructure with two key Phase 2 capabilities:
|
|
*
|
|
* 1. Calls the new /api/policy/decide endpoint (purpose-built for live
|
|
* IPC with richer state including all zones, stack, and priority)
|
|
* instead of the batch-oriented /api/ml/predict.
|
|
*
|
|
* 2. Collects (state, action, reward) tuples in real-time during live
|
|
* Forge games and flushes them to the policy server for online PPO
|
|
* updates via POST /api/policy/collect.
|
|
*
|
|
* Falls back to ForgeBuiltinPolicy heuristics if the policy server
|
|
* is unreachable or returns an error.
|
|
*
|
|
* Usage:
|
|
* PolicyClient client = new PolicyClient("http://localhost:8080");
|
|
* if (client.connect()) {
|
|
* // At each Forge decision point:
|
|
* String action = client.decide(fullGameState);
|
|
* // After game ends:
|
|
* client.submitReward(gameId, reward);
|
|
* client.flushCollectedTuples();
|
|
* }
|
|
*
|
|
* Refs: Issue #83 Phase 2.1
|
|
*/
|
|
public class PolicyClient {
|
|
|
|
private static final Gson GSON = new GsonBuilder().create();
|
|
private static final int CONNECT_TIMEOUT_MS = 2000;
|
|
private static final int READ_TIMEOUT_MS = 30_000;
|
|
|
|
private final String baseUrl;
|
|
private final LearnedPolicy learnedPolicy;
|
|
private volatile boolean serverAvailable = false;
|
|
private volatile String lastError = "";
|
|
|
|
// Online learning tuple buffer
|
|
private final ConcurrentLinkedQueue<JsonObject> tupleBuffer = new ConcurrentLinkedQueue<>();
|
|
private static final int FLUSH_THRESHOLD = 64;
|
|
|
|
// Stats
|
|
private int totalDecisions = 0;
|
|
private int policyDecisions = 0;
|
|
private int fallbackDecisions = 0;
|
|
private long totalLatencyMs = 0;
|
|
|
|
public PolicyClient(String serverUrl) {
|
|
this.baseUrl = serverUrl.endsWith("/")
|
|
? serverUrl.substring(0, serverUrl.length() - 1)
|
|
: serverUrl;
|
|
this.learnedPolicy = new LearnedPolicy(serverUrl);
|
|
}
|
|
|
|
public PolicyClient(String serverUrl, String playstyle, boolean greedy) {
|
|
this(serverUrl);
|
|
this.learnedPolicy.setPlaystyle(playstyle);
|
|
this.learnedPolicy.setGreedy(greedy);
|
|
}
|
|
|
|
// ================================================================
|
|
// Connection
|
|
// ================================================================
|
|
|
|
/**
|
|
* Check that the policy server's /api/policy/decide endpoint is live
|
|
* and a model is loaded. Falls back to checking /api/ml/model if the
|
|
* new endpoint isn't available yet.
|
|
*/
|
|
public boolean connect() {
|
|
try {
|
|
String json = httpGet(baseUrl + "/api/policy/health");
|
|
serverAvailable = json.contains("\"ready\":true")
|
|
|| json.contains("\"status\":\"ok\"");
|
|
} catch (Exception e) {
|
|
// Fall back to legacy health check
|
|
serverAvailable = learnedPolicy.connect();
|
|
}
|
|
if (serverAvailable) {
|
|
System.out.println("[PolicyClient] Connected to policy server");
|
|
} else {
|
|
System.out.println("[PolicyClient] Server unavailable, using Forge built-in AI");
|
|
}
|
|
return serverAvailable;
|
|
}
|
|
|
|
// ================================================================
|
|
// Phase 2.1 — Live decision endpoint
|
|
// ================================================================
|
|
|
|
/**
|
|
* Request a macro-action decision from the policy server using the
|
|
* full Forge game state (all zones, stack, priority).
|
|
*
|
|
* This calls POST /api/policy/decide which is purpose-built for the
|
|
* live IPC loop, as opposed to /api/ml/predict which was designed
|
|
* for batch inference.
|
|
*
|
|
* @param gameState Full game state JSON with all zones and priority info
|
|
* @return macro-action name (e.g., "cast_creature"), or "pass" on failure
|
|
*/
|
|
public String decide(JsonObject gameState) {
|
|
totalDecisions++;
|
|
long t0 = System.currentTimeMillis();
|
|
|
|
if (!serverAvailable) {
|
|
fallbackDecisions++;
|
|
return MacroActionExecutor.PASS;
|
|
}
|
|
|
|
try {
|
|
// Add inference params
|
|
gameState.addProperty("playstyle", learnedPolicy.getPlaystyle());
|
|
gameState.addProperty("greedy", learnedPolicy.isGreedy());
|
|
|
|
String responseJson = httpPost(
|
|
baseUrl + "/api/policy/decide",
|
|
gameState.toString()
|
|
);
|
|
|
|
JsonObject resp = GSON.fromJson(responseJson, JsonObject.class);
|
|
long elapsed = System.currentTimeMillis() - t0;
|
|
totalLatencyMs += elapsed;
|
|
|
|
if (resp.has("action") && !resp.has("error")) {
|
|
policyDecisions++;
|
|
String action = resp.get("action").getAsString();
|
|
|
|
// Store tuple for online learning
|
|
collectTuple(gameState, action, resp);
|
|
|
|
return action;
|
|
} else {
|
|
fallbackDecisions++;
|
|
lastError = resp.has("error")
|
|
? resp.get("error").getAsString()
|
|
: "Unknown error";
|
|
return MacroActionExecutor.PASS;
|
|
}
|
|
} catch (Exception e) {
|
|
fallbackDecisions++;
|
|
lastError = e.getMessage();
|
|
return MacroActionExecutor.PASS;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Convenience: decide from a DecisionSnapshot (delegates to the
|
|
* existing LearnedPolicy path if /api/policy/decide is unavailable).
|
|
*/
|
|
public String decide(DecisionSnapshot snapshot) {
|
|
return learnedPolicy.decideAction(snapshot);
|
|
}
|
|
|
|
/**
|
|
* Resolve a decision to a concrete Forge action.
|
|
*/
|
|
public MacroActionExecutor.ActionResolution resolveAction(
|
|
JsonObject gameState,
|
|
List<String> hand,
|
|
List<String> battlefield,
|
|
List<String> commandZone,
|
|
int manaAvailable,
|
|
String commanderName,
|
|
int commanderTax
|
|
) {
|
|
String macroAction = decide(gameState);
|
|
return MacroActionExecutor.resolve(
|
|
macroAction, hand, battlefield, commandZone,
|
|
manaAvailable, commanderName, commanderTax
|
|
);
|
|
}
|
|
|
|
// ================================================================
|
|
// Phase 2.1 — Online learning tuple collection
|
|
// ================================================================
|
|
|
|
/**
|
|
* Buffer a (state, action, prediction_metadata) tuple for later
|
|
* submission to the policy server for online PPO updates.
|
|
*/
|
|
private void collectTuple(JsonObject state, String action, JsonObject prediction) {
|
|
JsonObject tuple = new JsonObject();
|
|
tuple.add("state", state);
|
|
tuple.addProperty("action", action);
|
|
if (prediction.has("action_index")) {
|
|
tuple.addProperty("action_index", prediction.get("action_index").getAsInt());
|
|
}
|
|
if (prediction.has("log_prob")) {
|
|
tuple.addProperty("log_prob", prediction.get("log_prob").getAsDouble());
|
|
}
|
|
if (prediction.has("value")) {
|
|
tuple.addProperty("value", prediction.get("value").getAsDouble());
|
|
}
|
|
tuple.addProperty("timestamp", System.currentTimeMillis());
|
|
|
|
tupleBuffer.add(tuple);
|
|
|
|
// Auto-flush when buffer is full
|
|
if (tupleBuffer.size() >= FLUSH_THRESHOLD) {
|
|
flushCollectedTuples();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Submit end-of-game reward for all tuples in the current game.
|
|
* Call this after a Forge game completes.
|
|
*/
|
|
public void submitReward(String gameId, double reward) {
|
|
try {
|
|
JsonObject body = new JsonObject();
|
|
body.addProperty("game_id", gameId);
|
|
body.addProperty("reward", reward);
|
|
httpPost(baseUrl + "/api/policy/reward", body.toString());
|
|
} catch (Exception e) {
|
|
System.err.println("[PolicyClient] Failed to submit reward: " + e.getMessage());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Flush buffered (state, action, reward) tuples to the policy server
|
|
* for online PPO updates via POST /api/policy/collect.
|
|
*/
|
|
public void flushCollectedTuples() {
|
|
if (tupleBuffer.isEmpty()) return;
|
|
|
|
JsonArray batch = new JsonArray();
|
|
JsonObject tuple;
|
|
while ((tuple = tupleBuffer.poll()) != null) {
|
|
batch.add(tuple);
|
|
}
|
|
|
|
try {
|
|
JsonObject body = new JsonObject();
|
|
body.add("tuples", batch);
|
|
body.addProperty("count", batch.size());
|
|
httpPost(baseUrl + "/api/policy/collect", body.toString());
|
|
System.out.println("[PolicyClient] Flushed " + batch.size() + " tuples");
|
|
} catch (Exception e) {
|
|
System.err.println("[PolicyClient] Failed to flush tuples: " + e.getMessage());
|
|
// Re-queue on failure
|
|
for (int i = 0; i < batch.size(); i++) {
|
|
tupleBuffer.add(batch.get(i).getAsJsonObject());
|
|
}
|
|
}
|
|
}
|
|
|
|
// ================================================================
|
|
// Accessors
|
|
// ================================================================
|
|
|
|
public boolean isServerAvailable() { return serverAvailable; }
|
|
public String getLastError() { return lastError; }
|
|
public int getTotalDecisions() { return totalDecisions; }
|
|
public int getPolicyDecisions() { return policyDecisions; }
|
|
public String getBaseUrl() { return baseUrl; }
|
|
|
|
/** Decision result record for GameSession integration. */
|
|
public record PolicyDecision(String action, double confidence) {}
|
|
|
|
/**
|
|
* Decide from a JSON string + seat index. Used by GameSession.
|
|
* Converts to JsonObject and delegates to decide(JsonObject).
|
|
*/
|
|
public PolicyDecision decide(String stateJson, int seat) {
|
|
JsonObject state = GSON.fromJson(stateJson, JsonObject.class);
|
|
state.addProperty("seat", seat);
|
|
String action = decide(state);
|
|
return new PolicyDecision(action, 1.0);
|
|
}
|
|
|
|
public int getFallbackDecisions() { return fallbackDecisions; }
|
|
public int getPendingTuples() { return tupleBuffer.size(); }
|
|
|
|
public double getAverageLatencyMs() {
|
|
return policyDecisions > 0
|
|
? (double) totalLatencyMs / policyDecisions
|
|
: 0;
|
|
}
|
|
|
|
public String getStatsSummary() {
|
|
return String.format(
|
|
"PolicyClient[decisions=%d, policy=%d, fallback=%d, avg=%.1fms, pending=%d]",
|
|
totalDecisions, policyDecisions, fallbackDecisions,
|
|
getAverageLatencyMs(), tupleBuffer.size()
|
|
);
|
|
}
|
|
|
|
// ================================================================
|
|
// HTTP helpers
|
|
// ================================================================
|
|
|
|
private String httpGet(String url) throws IOException {
|
|
HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection();
|
|
conn.setRequestMethod("GET");
|
|
conn.setConnectTimeout(CONNECT_TIMEOUT_MS);
|
|
conn.setReadTimeout(READ_TIMEOUT_MS);
|
|
conn.setRequestProperty("Accept", "application/json");
|
|
int code = conn.getResponseCode();
|
|
if (code != 200) {
|
|
throw new IOException("HTTP " + code + ": " + readStream(conn.getErrorStream()));
|
|
}
|
|
return readStream(conn.getInputStream());
|
|
}
|
|
|
|
private String httpPost(String url, String jsonBody) throws IOException {
|
|
HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection();
|
|
conn.setRequestMethod("POST");
|
|
conn.setConnectTimeout(CONNECT_TIMEOUT_MS);
|
|
conn.setReadTimeout(READ_TIMEOUT_MS);
|
|
conn.setDoOutput(true);
|
|
conn.setRequestProperty("Content-Type", "application/json");
|
|
conn.setRequestProperty("Accept", "application/json");
|
|
try (OutputStream os = conn.getOutputStream()) {
|
|
os.write(jsonBody.getBytes(StandardCharsets.UTF_8));
|
|
}
|
|
int code = conn.getResponseCode();
|
|
if (code != 200) {
|
|
throw new IOException("HTTP " + code + ": " + readStream(conn.getErrorStream()));
|
|
}
|
|
return readStream(conn.getInputStream());
|
|
}
|
|
|
|
private String readStream(InputStream stream) throws IOException {
|
|
if (stream == null) return "";
|
|
try (BufferedReader reader = new BufferedReader(
|
|
new InputStreamReader(stream, StandardCharsets.UTF_8))) {
|
|
StringBuilder sb = new StringBuilder();
|
|
String line;
|
|
while ((line = reader.readLine()) != null) {
|
|
sb.append(line);
|
|
}
|
|
return sb.toString();
|
|
}
|
|
}
|
|
}
|