diff --git a/lab_api.py b/lab_api.py
index 0a829f0..1cd1741 100644
--- a/lab_api.py
+++ b/lab_api.py
@@ -42,6 +42,7 @@ from routes.deckgen import router as deckgen_router
from routes.coach import router as coach_router, init_coach_service
from routes.ml import router as ml_router
from routes.ws_game import router as ws_game_router
+from routes.game import router as game_router
log = logging.getLogger("commander_ai_lab.api")
@@ -50,7 +51,7 @@ _allowed = os.environ.get("ALLOWED_ORIGINS", _DEFAULT_ORIGINS)
allowed_origins = [o.strip() for o in _allowed.split(",") if o.strip()]
-# ── Lifespan (replaces deprecated @app.on_event) ───────────────────────────────────────
+# ── Lifespan (replaces deprecated @app.on_event) ────────────────────────────────────────────────────────
@asynccontextmanager
async def _lifespan(application: FastAPI):
# ──── STARTUP ────
@@ -113,7 +114,7 @@ async def _lifespan(application: FastAPI):
await async_close_db()
-# ── App ───────────────────────────────────────────────────────────────────────
+# ── App ─────────────────────────────────────────────────────────────────────────────
app = FastAPI(title="Commander AI Lab API", version="3.0.0", lifespan=_lifespan)
app.add_middleware(
CORSMiddleware,
@@ -135,9 +136,10 @@ app.include_router(deckgen_router)
app.include_router(coach_router)
app.include_router(ws_game_router)
app.include_router(ml_router)
+app.include_router(game_router) # live game session routes
-# ── API endpoints ────────────────────────────────────────────────────────────────
+# ── API endpoints ─────────────────────────────────────────────────────────────────────────────
# NOTE: all @app.get routes MUST be registered before app.mount() calls.
# Once StaticFiles is mounted at "/", it registers a wildcard catch-all that
# shadows any routes added after it.
@@ -147,7 +149,7 @@ async def health_check():
"""Health check endpoint for load balancers and Unity client polling."""
return {"status": "ok"}
-# ── Static UI (MUST come after all @app.get routes) ────────────────────────────
+# ── Static UI (MUST come after all @app.get routes) ──────────────────────────────────────────
from pathlib import Path as _Path
_legacy_ui_dir = _Path(__file__).parent / "ui"
_spa_dir = _Path(__file__).parent / "frontend" / "commander-ai-lab-ui" / "dist"
@@ -168,7 +170,7 @@ elif _spa_dir.exists():
return FileResponse(str(_spa_dir / "index.html"))
-# ── Argument parsing + auto-detection helpers ───────────────────────────────────────
+# ── Argument parsing + auto-detection helpers ────────────────────────────────────────────────────────
def _parse_args():
p = argparse.ArgumentParser(description="Commander AI Lab API Server")
p.add_argument("--forge-jar", default=os.environ.get("FORGE_JAR", ""))
diff --git a/routes/game.py b/routes/game.py
new file mode 100644
index 0000000..17779f1
--- /dev/null
+++ b/routes/game.py
@@ -0,0 +1,209 @@
+"""Live game session routes.
+
+Endpoints:
+ POST /api/game/start — create a new live game session (called by LobbySetupModal)
+ POST /api/game/action — submit a human player action (called by HumanActionBar)
+"""
+from __future__ import annotations
+
+import logging
+import uuid
+from typing import List, Optional
+
+from fastapi import APIRouter, HTTPException
+from pydantic import BaseModel, Field
+
+log = logging.getLogger("routes.game")
+
+router = APIRouter(tags=["game"])
+
+
+# ───────────────────────────────────────────────────────────────
+# Request / Response models
+# ───────────────────────────────────────────────────────────────
+
+class SeatConfig(BaseModel):
+ seat_index: int = 0
+ seat_type: str = "AI" # "Human" | "AI Aggro" | "AI Control" | "AI Combo" | "AI Political"
+ deck_name: str = ""
+ player_name: str = ""
+
+
+class StartGameRequest(BaseModel):
+ seats: List[SeatConfig] = Field(default_factory=list)
+
+
+class StartGameResponse(BaseModel):
+ game_id: str
+ status: str = "started"
+ ws_url: str
+
+
+class GameActionRequest(BaseModel):
+ game_id: str
+ seat: int = 0
+ action: str # "pass_priority" | "end_turn" | "declare_attackers" |
+ # "declare_blockers" | "select_target"
+ target_id: Optional[str] = None
+ extra: dict = Field(default_factory=dict)
+
+
+class GameActionResponse(BaseModel):
+ status: str = "ok"
+ game_id: str
+ action: str
+
+
+# ───────────────────────────────────────────────────────────────
+# In-memory session store (replace with DB / Redis for persistence)
+# ───────────────────────────────────────────────────────────────
+
+# { game_id: { "seats": [...], "status": "active" | "ended", "pending_actions": [...] } }
+_sessions: dict[str, dict] = {}
+
+
+# ───────────────────────────────────────────────────────────────
+# POST /api/game/start
+# ───────────────────────────────────────────────────────────────
+
+@router.post("/api/game/start", response_model=StartGameResponse)
+async def start_game(req: StartGameRequest):
+ """Create a new live game session.
+
+ Called by Unity's LobbySetupModal after the player configures seats.
+ Returns a game_id that Unity uses to connect to the WebSocket at
+ /ws/game/{game_id}.
+
+ TODO: Wire Forge engine launch here once the IPC layer is ready.
+ For now, creates a session record and returns the game_id so Unity
+ can connect and receive state pushes from the Forge IPC client.
+ """
+ if not req.seats:
+ raise HTTPException(status_code=400, detail="At least one seat is required.")
+
+ game_id = uuid.uuid4().hex
+
+ _sessions[game_id] = {
+ "game_id": game_id,
+ "seats": [s.dict() for s in req.seats],
+ "status": "active",
+ "pending_actions": [],
+ }
+
+ log.info(
+ "Game session created: game_id=%s seats=%d",
+ game_id,
+ len(req.seats),
+ )
+
+ return StartGameResponse(
+ game_id=game_id,
+ status="started",
+ ws_url=f"/ws/game/{game_id}",
+ )
+
+
+# ───────────────────────────────────────────────────────────────
+# POST /api/game/action
+# ───────────────────────────────────────────────────────────────
+
+@router.post("/api/game/action", response_model=GameActionResponse)
+async def game_action(req: GameActionRequest):
+ """Submit a human player action during a live game.
+
+ Called by Unity's HumanActionBar when seat 0 (human) has priority.
+ The action is queued in the session's pending_actions list, where
+ the Forge IPC polling loop will pick it up.
+
+ Valid actions:
+ pass_priority — pass priority to the next player
+ end_turn — end the current turn
+ declare_attackers — declare attacking creatures
+ declare_blockers — declare blocking creatures
+ select_target — select a target (requires target_id)
+ """
+ valid_actions = {
+ "pass_priority", "end_turn",
+ "declare_attackers", "declare_blockers",
+ "select_target",
+ }
+
+ if req.action not in valid_actions:
+ raise HTTPException(
+ status_code=400,
+ detail=f"Unknown action '{req.action}'. Valid: {sorted(valid_actions)}",
+ )
+
+ if req.action == "select_target" and not req.target_id:
+ raise HTTPException(
+ status_code=400,
+ detail="select_target requires a target_id.",
+ )
+
+ session = _sessions.get(req.game_id)
+ if session is None:
+ raise HTTPException(
+ status_code=404,
+ detail=f"Game session '{req.game_id}' not found.",
+ )
+
+ if session["status"] != "active":
+ raise HTTPException(
+ status_code=409,
+ detail=f"Game session '{req.game_id}' is not active (status={session['status']}).",
+ )
+
+ action_record = {
+ "seat": req.seat,
+ "action": req.action,
+ "target_id": req.target_id,
+ "extra": req.extra,
+ }
+ session["pending_actions"].append(action_record)
+
+ log.info(
+ "Action queued: game_id=%s seat=%d action=%s",
+ req.game_id, req.seat, req.action,
+ )
+
+ return GameActionResponse(
+ status="ok",
+ game_id=req.game_id,
+ action=req.action,
+ )
+
+
+# ───────────────────────────────────────────────────────────────
+# GET /api/game/{game_id}/actions (Forge IPC polling endpoint)
+# ───────────────────────────────────────────────────────────────
+
+@router.get("/api/game/{game_id}/actions")
+async def poll_actions(game_id: str):
+ """Drain and return all pending human actions for a game session.
+
+ Called by the Forge IPC polling loop to consume human player decisions.
+ Clears the queue on each call (consume-once semantics).
+ """
+ session = _sessions.get(game_id)
+ if session is None:
+ raise HTTPException(status_code=404, detail=f"Game session '{game_id}' not found.")
+
+ actions = session["pending_actions"].copy()
+ session["pending_actions"].clear()
+ return {"game_id": game_id, "actions": actions}
+
+
+# ───────────────────────────────────────────────────────────────
+# GET /api/game/sessions (debug)
+# ───────────────────────────────────────────────────────────────
+
+@router.get("/api/game/sessions")
+async def list_sessions():
+ """List active game sessions (debug endpoint)."""
+ return {
+ "count": len(_sessions),
+ "sessions": [
+ {"game_id": gid, "status": s["status"], "seats": len(s["seats"])}
+ for gid, s in _sessions.items()
+ ],
+ }
diff --git a/unity-client/Assets/Scripts/Services/GameWebSocketClient.cs b/unity-client/Assets/Scripts/Services/GameWebSocketClient.cs
index 7174db0..249a99a 100644
--- a/unity-client/Assets/Scripts/Services/GameWebSocketClient.cs
+++ b/unity-client/Assets/Scripts/Services/GameWebSocketClient.cs
@@ -11,30 +11,38 @@ using CommanderAILab.UI;
namespace CommanderAILab.Services
{
///
- /// WebSocket client for the live game backend (ws://localhost:8080/ws/game).
- /// - Connects on Start and auto-reconnects on disconnect.
- /// - Sends a ping every 15 s to keep the connection alive.
- /// - All GameStateManager.ApplyServerEvent calls are dispatched
- /// on the Unity main thread via a thread-safe queue.
+ /// WebSocket client for the live game backend.
+ /// Connects to ws://{serverHost}/ws/game/{gameId}.
+ ///
+ /// game_id is written to PlayerPrefs["GameId"] by LobbySetupModal
+ /// after a successful POST /api/game/start response.
+ ///
+ /// ServerUrl PlayerPref should be the HTTP base URL, e.g.
+ /// "http://localhost:8080" — this client converts it to ws://.
///
public class GameWebSocketClient : MonoBehaviour
{
- [SerializeField] private string serverUrl = "ws://localhost:8080/ws/game";
- [SerializeField] private float reconnectDelay = 3f;
- [SerializeField] private float pingIntervalSec = 15f;
+ [SerializeField] private string defaultServerUrl = "http://localhost:8080";
+ [SerializeField] private float reconnectDelay = 3f;
+ [SerializeField] private float pingIntervalSec = 15f;
private System.Net.WebSockets.ClientWebSocket _ws;
private CancellationTokenSource _cts;
private readonly ConcurrentQueue<(string type, string payload)> _mainThreadQueue = new();
private bool _running;
private float _pingTimer;
+ private string _wsUrl;
// ── Lifecycle ────────────────────────────────────────────────────────
private void Start()
{
- // Allow override via PlayerPrefs so users can point at a remote server
- string saved = PlayerPrefs.GetString("ServerUrl", "");
- if (!string.IsNullOrEmpty(saved)) serverUrl = saved.TrimEnd('/') + "/ws/game";
+ _wsUrl = BuildWsUrl();
+ if (string.IsNullOrEmpty(_wsUrl))
+ {
+ Debug.LogError("[GameWebSocketClient] Could not build WS URL — PlayerPrefs 'GameId' is missing. " +
+ "LobbySetupModal must set it after /api/game/start.");
+ return;
+ }
_running = true;
StartCoroutine(ConnectLoop());
@@ -62,6 +70,28 @@ namespace CommanderAILab.Services
_ws?.Dispose();
}
+ // ── URL construction ────────────────────────────────────────────────
+ ///
+ /// Builds ws://{host}/ws/game/{gameId} from PlayerPrefs.
+ /// ServerUrl pref stores the HTTP base URL (e.g. http://localhost:8080).
+ /// GameId pref stores the game_id returned by /api/game/start.
+ ///
+ private string BuildWsUrl()
+ {
+ string gameId = PlayerPrefs.GetString("GameId", "");
+ if (string.IsNullOrEmpty(gameId))
+ return string.Empty;
+
+ string httpBase = PlayerPrefs.GetString("ServerUrl", defaultServerUrl).TrimEnd('/');
+
+ // Convert http(s):// to ws(s)://
+ string wsBase = httpBase
+ .Replace("https://", "wss://")
+ .Replace("http://", "ws://");
+
+ return $"{wsBase}/ws/game/{gameId}";
+ }
+
// ── Connection loop ──────────────────────────────────────────────────
private IEnumerator ConnectLoop()
{
@@ -71,19 +101,19 @@ namespace CommanderAILab.Services
_ws = new System.Net.WebSockets.ClientWebSocket();
bool connected = false;
- var connectTask = _ws.ConnectAsync(new Uri(serverUrl), _cts.Token);
+ var connectTask = _ws.ConnectAsync(new Uri(_wsUrl), _cts.Token);
yield return new WaitUntil(() => connectTask.IsCompleted);
if (!connectTask.IsFaulted && !connectTask.IsCanceled)
{
connected = true;
- Debug.Log("[GameWebSocketClient] Connected to " + serverUrl);
+ Debug.Log("[GameWebSocketClient] Connected to " + _wsUrl);
_ = ReceiveLoop();
}
else
{
- Debug.LogWarning($"[GameWebSocketClient] Connect failed: {connectTask.Exception?.Message}");
+ Debug.LogWarning($"[GameWebSocketClient] Connect failed ({_wsUrl}): {connectTask.Exception?.Message}");
}
if (!connected)
@@ -92,7 +122,6 @@ namespace CommanderAILab.Services
}
else
{
- // Stay here until the WS closes
yield return new WaitUntil(() =>
_ws.State != System.Net.WebSockets.WebSocketState.Open);
@@ -123,8 +152,7 @@ namespace CommanderAILab.Services
System.Net.WebSockets.WebSocketMessageType.Close)
break;
- string raw = sb.ToString();
- ParseAndEnqueue(raw);
+ ParseAndEnqueue(sb.ToString());
}
}
catch (OperationCanceledException) { }
@@ -141,9 +169,7 @@ namespace CommanderAILab.Services
{
var env = JsonConvert.DeserializeObject(raw);
if (env == null) return;
-
- if (env.type == "pong") return; // ignore keepalive responses
-
+ if (env.type == "pong") return;
_mainThreadQueue.Enqueue((env.type, env.payload ?? raw));
}
catch
@@ -152,7 +178,7 @@ namespace CommanderAILab.Services
}
}
- // ── Send (public for HumanActionBar fallback) ────────────────────────
+ // ── Send (public for external callers) ───────────────────────────────
public async Task SendRawAsync(string json)
{
if (_ws == null ||
@@ -170,7 +196,7 @@ namespace CommanderAILab.Services
}
}
- // ── POCO ─────────────────────────────────────────────────────────────
+ // ── POCO ────────────────────────────────────────────────────────────────
[Serializable]
private class WsEnvelope
{