feat: add Unity/Godot WebSocket server (Phase 1 stub)

- pom.xml: add Java-WebSocket 1.5.6 dependency
- bridge/GameWsServer.java: WS server on ws://localhost:7654
- bridge/GameSession.java: Phase 1 stub game loop with human-input queue
- cli/LabCli.java: add ws-server subcommand via picocli
This commit is contained in:
KoalaTrapLord committed 2026-03-18 00:02:18 -04:00
1 parent aa5e381c59
commit 4e8a999ebd
4 files changed
+508

No files matched your search

+7
View File
@@ -67,6 +67,13 @@
<version>3.45.1.0</version>
</dependency>
<!-- WebSocket server for Unity/Godot client -->
<dependency>
<groupId>org.java-websocket</groupId>
<artifactId>Java-WebSocket</artifactId>
<version>1.5.6</version>
</dependency>
<!-- Testing -->
<dependency>
<groupId>org.junit.jupiter</groupId>
@@ -0,0 +1,235 @@
package commanderailab.bridge;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.util.*;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.TimeUnit;
import java.util.function.Consumer;
/**
* GameSession — holds state for one live interactive Commander game.
*
* PHASE 1 STUB: generates synthetic game state so you can verify the full
* WebSocket contract round-trips correctly before wiring in real Forge IPC.
*
* Replace run() in Phase 2 once the contract is verified end-to-end.
*/
public class GameSession {
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
private final List<String> deckNames;
private final Long seed;
private final String forgeJarPath;
private final String forgeWorkDir;
private final BlockingQueue<String> humanActionQueue;
private volatile boolean running = false;
private volatile int turnNumber = 1;
// Mutable game state
private String phase = "UNTAP";
private int activePlayer = 0;
private int priorityPlayer = 0;
private final int[] life;
private final int[] poison;
private final List<String>[] hands;
private final List<String>[] battlefields;
private final List<String>[] graveyards;
private final List<String>[] commandZones;
private final int[] commanderTax;
private boolean awaitingHumanInput = false;
private static final String[] PHASES = {
"UNTAP", "UPKEEP", "DRAW",
"MAIN1", "BEGIN_COMBAT", "DECLARE_ATTACKERS",
"DECLARE_BLOCKERS", "DAMAGE", "END_COMBAT",
"MAIN2", "END", "CLEANUP"
};
private int phaseIndex = 0;
@SuppressWarnings("unchecked")
public GameSession(List<String> deckNames, Long seed,
String forgeJarPath, String forgeWorkDir,
BlockingQueue<String> humanActionQueue) {
this.deckNames = deckNames;
this.seed = seed;
this.forgeJarPath = forgeJarPath;
this.forgeWorkDir = forgeWorkDir;
this.humanActionQueue = humanActionQueue;
int seats = Math.min(deckNames.size(), 4);
life = new int[seats];
poison = new int[seats];
hands = new List[seats];
battlefields = new List[seats];
graveyards = new List[seats];
commandZones = new List[seats];
commanderTax = new int[seats];
for (int i = 0; i < seats; i++) {
life[i] = 40;
hands[i] = new ArrayList<>(List.of(
"card_stub_1", "card_stub_2", "card_stub_3",
"card_stub_4", "card_stub_5", "card_stub_6", "card_stub_7"
));
battlefields[i] = new ArrayList<>();
graveyards[i] = new ArrayList<>();
commandZones[i] = new ArrayList<>(List.of(deckNames.get(i) + "_commander"));
}
}
public boolean isRunning() { return running; }
public int getTurnNumber() { return turnNumber; }
public void stop() {
running = false;
}
/**
* Run the game loop. Calls onStateChange after every action resolves.
* Blocks until the game ends or stop() is called.
*
* PHASE 1 STUB: advances through phases automatically; on MAIN1 of seat 0's
* turn, pauses and waits for the human client to send an action.
*/
public void run(Consumer<Object> onStateChange) throws InterruptedException {
running = true;
Random rng = seed != null ? new Random(seed) : new Random();
System.out.println("[Session] Game started. Decks: " + deckNames);
while (running && !isGameOver()) {
String currentPhase = PHASES[phaseIndex];
phase = currentPhase;
activePlayer = (turnNumber - 1) % deckNames.size();
boolean isHumanTurn = (activePlayer == 0);
boolean isInteractivePhase = currentPhase.equals("MAIN1") || currentPhase.equals("MAIN2");
if (isHumanTurn && isInteractivePhase) {
awaitingHumanInput = true;
priorityPlayer = 0;
onStateChange.accept(buildStateSnapshot());
System.out.println("[Session] Awaiting human action for " + currentPhase + "...");
String actionJson = humanActionQueue.poll(120, TimeUnit.SECONDS);
if (actionJson == null) {
System.out.println("[Session] Human timed out — auto passing.");
} else {
applyAction(actionJson);
System.out.println("[Session] Human action applied: " + actionJson);
}
awaitingHumanInput = false;
} else {
// AI turn — small delay for readability
Thread.sleep(300);
// Simulate AI occasionally dealing damage to human
if (!isHumanTurn && currentPhase.equals("DAMAGE") && life[0] > 5) {
life[0] -= rng.nextInt(4);
}
}
// Advance phase
phaseIndex++;
if (phaseIndex >= PHASES.length) {
phaseIndex = 0;
turnNumber++;
}
onStateChange.accept(buildStateSnapshot());
}
running = false;
System.out.println("[Session] Game ended after " + turnNumber + " turns.");
}
private void applyAction(String actionJson) {
try {
JsonObject action = JsonParser.parseString(actionJson).getAsJsonObject();
String type = action.has("type") ? action.get("type").getAsString() : "PASS_PRIORITY";
switch (type) {
case "PLAY_LAND", "CAST_SPELL" -> {
String cardId = action.has("cardId") ? action.get("cardId").getAsString() : null;
if (cardId != null && hands[0].contains(cardId)) {
hands[0].remove(cardId);
battlefields[0].add(cardId);
}
}
case "PASS_PRIORITY" -> { /* no-op, phase advances naturally */ }
default -> System.out.println("[Session] Unknown action type: " + type);
}
} catch (Exception e) {
System.err.println("[Session] Failed to apply action: " + e.getMessage());
}
}
private boolean isGameOver() {
int alive = 0;
for (int lp : life) if (lp > 0) alive++;
// Phase 1 stub: end after 50 turns or only 1 player alive
return alive <= 1 || turnNumber > 50;
}
// ---------------------------------------------------------
// State serialization
// ---------------------------------------------------------
/** Returns a full { type: "STATE", state: {...} } JSON string. */
public String buildStateMessage() {
Map<String, Object> msg = new LinkedHashMap<>();
msg.put("type", "STATE");
msg.put("state", buildStateSnapshot());
return GSON.toJson(msg);
}
/** Returns the raw state snapshot map (serialized by caller). */
public Map<String, Object> buildStateSnapshot() {
Map<String, Object> state = new LinkedHashMap<>();
state.put("schema", "1.0.0");
state.put("phase", phase);
state.put("turnNumber", turnNumber);
state.put("activePlayer", activePlayer);
state.put("priorityPlayer", priorityPlayer);
state.put("awaitingInput", awaitingHumanInput);
state.put("inputPrompt", awaitingHumanInput ? "Choose an action or pass priority" : null);
List<Map<String, Object>> players = new ArrayList<>();
for (int i = 0; i < deckNames.size(); i++) {
Map<String, Object> p = new LinkedHashMap<>();
p.put("seat", i);
p.put("name", i == 0 ? "Human" : "AI-" + i);
p.put("isAI", i != 0);
p.put("deckName", deckNames.get(i));
p.put("life", life[i]);
p.put("poison", poison[i]);
p.put("commanderTax", commanderTax[i]);
p.put("handCount", hands[i].size());
p.put("hand", i == 0 ? hands[i] : List.of()); // hide opponent hands
p.put("battlefield", battlefields[i]);
p.put("graveyard", graveyards[i]);
p.put("commandZone", commandZones[i]);
p.put("manaPool", Map.of("W",0,"U",0,"B",0,"R",0,"G",0,"C",0));
players.add(p);
}
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"));
for (String card : hands[0]) {
legalActions.add(Map.of("type", "CAST_SPELL", "cardId", card, "label", "Cast " + card));
}
}
state.put("legalActions", legalActions);
return state;
}
}
@@ -0,0 +1,204 @@
package commanderailab.bridge;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import org.java_websocket.WebSocket;
import org.java_websocket.handshake.ClientHandshake;
import org.java_websocket.server.WebSocketServer;
import java.net.InetSocketAddress;
import java.util.*;
import java.util.concurrent.*;
/**
* GameWsServer — WebSocket server that exposes the Forge engine to a Unity/Godot client.
*
* Connect at: ws://localhost:7654
*
* Protocol:
* ENGINE -> CLIENT: GameStateSnapshot JSON (pushed after every action resolves)
* CLIENT -> ENGINE: PlayerAction JSON
*
* Message types (CLIENT -> ENGINE):
* { "type": "START_GAME", "decks": ["DeckA","DeckB","DeckC","DeckD"], "seed": 12345 }
* { "type": "PLAYER_ACTION", "action": { ... } }
* { "type": "PING" }
*
* Message types (ENGINE -> CLIENT):
* { "type": "STATE", "state": { ... GameStateSnapshot ... } }
* { "type": "ERROR", "message": "..." }
* { "type": "PONG" }
*/
public class GameWsServer extends WebSocketServer {
private static final int DEFAULT_PORT = 7654;
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
private final String forgeJarPath;
private final String forgeWorkDir;
// Active game session (one game at a time for Phase 1)
private volatile GameSession activeSession;
// Waiting-for-human-input: the session parks here until Unity sends an action
private final BlockingQueue<String> humanActionQueue = new LinkedBlockingQueue<>();
public GameWsServer(String forgeJarPath, String forgeWorkDir) {
super(new InetSocketAddress(DEFAULT_PORT));
this.forgeJarPath = forgeJarPath;
this.forgeWorkDir = forgeWorkDir;
setReuseAddr(true);
}
public GameWsServer(int port, String forgeJarPath, String forgeWorkDir) {
super(new InetSocketAddress(port));
this.forgeJarPath = forgeJarPath;
this.forgeWorkDir = forgeWorkDir;
setReuseAddr(true);
}
// ---------------------------------------------------------
// WebSocket lifecycle
// ---------------------------------------------------------
@Override
public void onOpen(WebSocket conn, ClientHandshake handshake) {
System.out.println("[WS] Client connected: " + conn.getRemoteSocketAddress());
// Send current state if a game is in progress
if (activeSession != null) {
conn.send(activeSession.buildStateMessage());
}
}
@Override
public void onClose(WebSocket conn, int code, String reason, boolean remote) {
System.out.println("[WS] Client disconnected: " + conn.getRemoteSocketAddress());
}
@Override
public void onMessage(WebSocket conn, String message) {
try {
JsonObject msg = JsonParser.parseString(message).getAsJsonObject();
String type = msg.get("type").getAsString();
switch (type) {
case "PING" -> conn.send(buildPong());
case "START_GAME" -> handleStartGame(conn, msg);
case "PLAYER_ACTION" -> handlePlayerAction(conn, msg);
case "STOP_GAME" -> {
if (activeSession != null) activeSession.stop();
activeSession = null;
broadcast(buildInfo("Game stopped."));
}
default -> sendError(conn, "Unknown message type: " + type);
}
} catch (Exception e) {
sendError(conn, "Parse error: " + e.getMessage());
}
}
@Override
public void onError(WebSocket conn, Exception ex) {
System.err.println("[WS] Error: " + ex.getMessage());
}
@Override
public void onStart() {
System.out.println("[WS] GameWsServer started on port " + getPort());
System.out.println("[WS] Connect Unity/Godot to: ws://localhost:" + getPort());
}
// ---------------------------------------------------------
// Message handlers
// ---------------------------------------------------------
private void handleStartGame(WebSocket conn, JsonObject msg) {
if (activeSession != null && activeSession.isRunning()) {
sendError(conn, "A game is already in progress. Send { type: 'STOP_GAME' } first.");
return;
}
List<String> decks = new ArrayList<>();
if (msg.has("decks")) {
msg.getAsJsonArray("decks").forEach(d -> decks.add(d.getAsString()));
}
if (decks.size() < 3) {
sendError(conn, "Need at least 3 deck names in 'decks' array.");
return;
}
Long seed = msg.has("seed") ? msg.get("seed").getAsLong() : null;
System.out.println("[WS] Starting game: " + decks + " seed=" + seed);
// Clear any stale actions from previous game
humanActionQueue.clear();
activeSession = new GameSession(decks, seed, forgeJarPath, forgeWorkDir, humanActionQueue);
// Push initial state immediately
broadcast(activeSession.buildStateMessage());
// Run the game loop on a background thread so WebSocket thread stays free
Thread gameThread = new Thread(() -> {
try {
activeSession.run(state -> broadcast(GSON.toJson(state)));
broadcast(buildInfo("Game over after " + activeSession.getTurnNumber() + " turns."));
} catch (Exception e) {
broadcastError("Game error: " + e.getMessage());
e.printStackTrace();
}
}, "game-loop");
gameThread.setDaemon(true);
gameThread.start();
}
private void handlePlayerAction(WebSocket conn, JsonObject msg) {
if (activeSession == null || !activeSession.isRunning()) {
sendError(conn, "No active game. Send START_GAME first.");
return;
}
if (!msg.has("action")) {
sendError(conn, "Missing 'action' field.");
return;
}
// Put the raw action JSON string onto the queue -- GameSession polls this
humanActionQueue.offer(msg.get("action").toString());
}
// ---------------------------------------------------------
// Broadcast helpers
// ---------------------------------------------------------
private void broadcastError(String message) {
broadcast(buildError(message));
}
private void sendError(WebSocket conn, String message) {
conn.send(buildError(message));
}
private static String buildError(String message) {
JsonObject obj = new JsonObject();
obj.addProperty("type", "ERROR");
obj.addProperty("message", message);
return obj.toString();
}
private static String buildInfo(String message) {
JsonObject obj = new JsonObject();
obj.addProperty("type", "INFO");
obj.addProperty("message", message);
return obj.toString();
}
private static String buildPong() {
JsonObject obj = new JsonObject();
obj.addProperty("type", "PONG");
obj.addProperty("ts", System.currentTimeMillis());
return obj.toString();
}
}
@@ -0,0 +1,62 @@
package commanderailab.cli;
import commanderailab.bridge.GameWsServer;
import picocli.CommandLine.Command;
import picocli.CommandLine.Option;
import java.util.concurrent.Callable;
/**
* WsServerCommand — picocli subcommand that starts the interactive game WebSocket server.
*
* Usage:
* java -jar commander-ai-lab.jar ws-server \
* --forge-jar path/to/forge.jar \
* --forge-dir path/to/forge-gui \
* --port 7654
*
* Then connect Unity/Godot to ws://localhost:7654 and send:
* { "type": "START_GAME", "decks": ["DeckA","DeckB","DeckC","DeckD"], "seed": 42 }
*/
@Command(
name = "ws-server",
mixinStandardHelpOptions = true,
description = "Start the interactive game WebSocket server for Unity/Godot clients."
)
public class WsServerCommand implements Callable<Integer> {
@Option(names = {"--forge-jar", "-F"}, required = true,
description = "Path to Forge desktop JAR (forge-gui-desktop-XXX-jar-with-dependencies.jar)")
private String forgeJarPath;
@Option(names = {"--forge-dir", "-W"}, required = true,
description = "Forge working directory (folder containing res/)")
private String forgeWorkDir;
@Option(names = {"--port", "-p"}, defaultValue = "7654",
description = "WebSocket port (default: 7654)")
private int port;
@Override
public Integer call() throws Exception {
System.out.println("╔══════════════════════════════════════════════════╗");
System.out.println("║ Commander AI Lab — Interactive WS Server ║");
System.out.println("╚══════════════════════════════════════════════════╝");
System.out.println();
System.out.println(" Forge JAR : " + forgeJarPath);
System.out.println(" Forge Dir : " + forgeWorkDir);
System.out.println(" WS Port : " + port);
System.out.println();
GameWsServer server = new GameWsServer(port, forgeJarPath, forgeWorkDir);
server.start();
System.out.println(" Ready — connect Unity/Godot to ws://localhost:" + port);
System.out.println(" Press Ctrl+C to stop.");
System.out.println();
// Block until killed
Thread.currentThread().join();
return 0;
}
}