diff --git a/pom.xml b/pom.xml index 1d50075..95184dd 100644 --- a/pom.xml +++ b/pom.xml @@ -67,6 +67,13 @@ 3.45.1.0 + + + org.java-websocket + Java-WebSocket + 1.5.6 + + org.junit.jupiter diff --git a/src/main/java/commanderailab/bridge/GameSession.java b/src/main/java/commanderailab/bridge/GameSession.java new file mode 100644 index 0000000..66bb004 --- /dev/null +++ b/src/main/java/commanderailab/bridge/GameSession.java @@ -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 deckNames; + private final Long seed; + private final String forgeJarPath; + private final String forgeWorkDir; + private final BlockingQueue 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[] hands; + private final List[] battlefields; + private final List[] graveyards; + private final List[] 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 deckNames, Long seed, + String forgeJarPath, String forgeWorkDir, + BlockingQueue 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 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 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 buildStateSnapshot() { + Map 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> players = new ArrayList<>(); + for (int i = 0; i < deckNames.size(); i++) { + Map 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> 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; + } +} diff --git a/src/main/java/commanderailab/bridge/GameWsServer.java b/src/main/java/commanderailab/bridge/GameWsServer.java new file mode 100644 index 0000000..0fbecbd --- /dev/null +++ b/src/main/java/commanderailab/bridge/GameWsServer.java @@ -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 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 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(); + } +} diff --git a/src/main/java/commanderailab/cli/WsServerCommand.java b/src/main/java/commanderailab/cli/WsServerCommand.java new file mode 100644 index 0000000..71c2d4a --- /dev/null +++ b/src/main/java/commanderailab/cli/WsServerCommand.java @@ -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 { + + @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; + } +}