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 {