Wire Forge IPC into POST /api/game/start via run_batch_subprocess + --policy flag

This commit is contained in:
KoalaTrapLord committed 2026-04-03 17:57:35 -04:00
1 parent f2e87feb4e
commit 85af163123
1 file changed
+203 -59
+203 -59
View File
@@ -1,11 +1,29 @@
"""Live game session routes. """Live game session routes.
Endpoints: Endpoints:
POST /api/game/start — create a new live game session (called by LobbySetupModal) POST /api/game/start -- create and launch a live Forge game session
POST /api/game/action — submit a human player action (called by HumanActionBar) POST /api/game/action -- submit a human player action (called by HumanActionBar)
GET /api/game/{game_id}/status -- poll session status and last known game state
GET /api/game/{game_id}/actions -- Forge IPC: drain pending human actions
GET /api/game/sessions -- list active sessions (debug)
Forge IPC flow
--------------
1. POST /api/game/start
- Validates seats, resolves deck names
- Picks a playstyle per AI seat from seat_type (e.g. "AI Aggro" -> "aggro")
- Calls run_batch_subprocess(num_games=1, use_learned_policy=True, ...)
which builds the Java command with --policy <style> --policyServer http://localhost:8080
and spawns the Forge JAR in a background thread
- Returns game_id + ws_url immediately (non-blocking)
2. Forge JAR (once launched) calls POST /api/policy/decide on every AI decision
3. Human actions are queued via POST /api/game/action and drained by
GET /api/game/{game_id}/actions (polled by the Forge IPC loop inside the JAR)
4. GET /api/game/{game_id}/status returns live log tail + completion state
""" """
from __future__ import annotations from __future__ import annotations
import asyncio
import logging import logging
import uuid import uuid
from typing import List, Optional from typing import List, Optional
@@ -13,14 +31,39 @@ from typing import List, Optional
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from models.state import BatchState
from services.forge_runner import run_batch_subprocess
log = logging.getLogger("routes.game") log = logging.getLogger("routes.game")
router = APIRouter(tags=["game"]) router = APIRouter(tags=["game"])
# ---------------------------------------------------------------------------
# Playstyle mapping: seat_type string -> policy playstyle token
# ---------------------------------------------------------------------------
# ─────────────────────────────────────────────────────────────── _PLAYSTYLE_MAP: dict[str, str] = {
"ai aggro": "aggro",
"ai control": "control",
"ai combo": "combo",
"ai political": "political",
"ai midrange": "midrange",
"ai": "midrange", # bare "AI" seat -> midrange default
"human": None, # human seats skipped for policy
}
def _seat_playstyle(seat_type: str) -> Optional[str]:
"""Map a SeatConfig.seat_type string to a policy playstyle token.
Returns None for human seats (no policy needed).
"""
return _PLAYSTYLE_MAP.get(seat_type.lower().strip(), "midrange")
# ---------------------------------------------------------------------------
# Request / Response models # Request / Response models
# ─────────────────────────────────────────────────────────────── # ---------------------------------------------------------------------------
class SeatConfig(BaseModel): class SeatConfig(BaseModel):
seat_index: int = 0 seat_index: int = 0
@@ -31,12 +74,16 @@ class SeatConfig(BaseModel):
class StartGameRequest(BaseModel): class StartGameRequest(BaseModel):
seats: List[SeatConfig] = Field(default_factory=list) seats: List[SeatConfig] = Field(default_factory=list)
clock: int = Field(default=6000, description="Per-game wall-clock limit in seconds (0 = unlimited)")
use_learned_policy: bool = Field(default=True, description="Route AI decisions through /api/policy/decide")
policy_greedy: bool = Field(default=False, description="Use greedy (argmax) action selection instead of sampling")
class StartGameResponse(BaseModel): class StartGameResponse(BaseModel):
game_id: str game_id: str
status: str = "started" status: str = "started"
ws_url: str ws_url: str
policy_enabled: bool = False
class GameActionRequest(BaseModel): class GameActionRequest(BaseModel):
@@ -54,73 +101,139 @@ class GameActionResponse(BaseModel):
action: str action: str
# ─────────────────────────────────────────────────────────────── # ---------------------------------------------------------------------------
# In-memory session store (replace with DB / Redis for persistence) # In-memory session store
# ─────────────────────────────────────────────────────────────── # ---------------------------------------------------------------------------
# { game_id: { "seats": [...], "status": "active" | "ended", "pending_actions": [...] } } # { game_id: { "seats": [...], "status": "active"|"ended"|"error",
# "batch_state": BatchState|None, "pending_actions": [...] } }
_sessions: dict[str, dict] = {} _sessions: dict[str, dict] = {}
# ─────────────────────────────────────────────────────────────── # ---------------------------------------------------------------------------
# POST /api/game/start # POST /api/game/start
# ─────────────────────────────────────────────────────────────── # ---------------------------------------------------------------------------
@router.post("/api/game/start", response_model=StartGameResponse) @router.post("/api/game/start", response_model=StartGameResponse)
async def start_game(req: StartGameRequest): async def start_game(req: StartGameRequest):
"""Create a new live game session. """Create and launch a live Forge game session.
Called by Unity's LobbySetupModal after the player configures seats. Resolves deck names from the seat config, launches the Forge JAR
Returns a game_id that Unity uses to connect to the WebSocket at as a background subprocess with --policy routing enabled (unless
/ws/game/{game_id}. use_learned_policy=False), and returns a game_id immediately.
TODO: Wire Forge engine launch here once the IPC layer is ready. The Forge JAR will begin calling POST /api/policy/decide for every
For now, creates a session record and returns the game_id so Unity AI seat decision as soon as it is up. Human actions are queued via
can connect and receive state pushes from the Forge IPC client. POST /api/game/action and polled by the JAR via GET /api/game/{id}/actions.
""" """
if not req.seats: if not req.seats:
raise HTTPException(status_code=400, detail="At least one seat is required.") raise HTTPException(status_code=400, detail="At least one seat is required.")
# Collect deck names from seats that have one, preserving seat order.
decks = [s.deck_name for s in req.seats if s.deck_name.strip()]
if not decks:
raise HTTPException(status_code=400, detail="At least one seat must have a deck_name.")
# Determine playstyle from the first AI seat (the JAR uses a single global
# playstyle for its --policy flag; per-seat styles are a future extension).
policy_style = "midrange"
for seat in req.seats:
ps = _seat_playstyle(seat.seat_type)
if ps is not None: # skip human seats
policy_style = ps
break
game_id = uuid.uuid4().hex game_id = uuid.uuid4().hex
# Build a BatchState so the forge_runner watchdog and progress tracking
# work the same way they do for overnight batch runs.
state = BatchState(batch_id=game_id)
_sessions[game_id] = { _sessions[game_id] = {
"game_id": game_id, "game_id": game_id,
"seats": [s.dict() for s in req.seats], "seats": [s.dict() for s in req.seats],
"status": "active", "status": "active",
"pending_actions": [], "pending_actions": [],
"batch_state": state,
} }
log.info( log.info(
"Game session created: game_id=%s seats=%d", "Game session created: game_id=%s seats=%d decks=%s policy=%s greedy=%s",
game_id, game_id, len(req.seats), decks, policy_style if req.use_learned_policy else "off",
len(req.seats), req.policy_greedy,
)
# Launch Forge subprocess in the background (non-blocking).
# num_games=1 -> single live game, threads=1, no seed.
# clock is passed through from the request (default 6000s for live play).
asyncio.create_task(
_run_and_finalize(
game_id=game_id,
state=state,
decks=decks,
clock=req.clock,
use_learned_policy=req.use_learned_policy,
policy_style=policy_style,
policy_greedy=req.policy_greedy,
)
) )
return StartGameResponse( return StartGameResponse(
game_id=game_id, game_id=game_id,
status="started", status="started",
ws_url=f"/ws/game/{game_id}", ws_url=f"/ws/game/{game_id}",
policy_enabled=req.use_learned_policy,
) )
# ─────────────────────────────────────────────────────────────── async def _run_and_finalize(
game_id: str,
state: BatchState,
decks: list,
clock: int,
use_learned_policy: bool,
policy_style: str,
policy_greedy: bool,
):
"""Background task: run Forge subprocess then mark session ended."""
try:
await run_batch_subprocess(
state=state,
decks=decks,
num_games=1,
threads=1,
seed=None,
clock=clock,
output_path=f"results/live-{game_id}.json",
use_learned_policy=use_learned_policy,
policy_style=policy_style,
policy_greedy=policy_greedy,
)
except Exception as exc:
log.error("Game %s subprocess error: %s", game_id, exc)
if game_id in _sessions:
_sessions[game_id]["status"] = "error"
return
if game_id in _sessions:
_sessions[game_id]["status"] = "ended" if not state.error else "error"
log.info(
"Game %s finished: status=%s error=%s",
game_id, _sessions[game_id]["status"], state.error or "none",
)
# ---------------------------------------------------------------------------
# POST /api/game/action # POST /api/game/action
# ─────────────────────────────────────────────────────────────── # ---------------------------------------------------------------------------
@router.post("/api/game/action", response_model=GameActionResponse) @router.post("/api/game/action", response_model=GameActionResponse)
async def game_action(req: GameActionRequest): async def game_action(req: GameActionRequest):
"""Submit a human player action during a live game. """Submit a human player action during a live game.
Called by Unity's HumanActionBar when seat 0 (human) has priority. Called by Unity's HumanActionBar when the human seat has priority.
The action is queued in the session's pending_actions list, where The action is queued in the session; the Forge IPC polling loop
the Forge IPC polling loop will pick it up. drains it via GET /api/game/{game_id}/actions.
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 = { valid_actions = {
"pass_priority", "end_turn", "pass_priority", "end_turn",
@@ -142,47 +255,73 @@ async def game_action(req: GameActionRequest):
session = _sessions.get(req.game_id) session = _sessions.get(req.game_id)
if session is None: if session is None:
raise HTTPException( raise HTTPException(status_code=404, detail=f"Game session '{req.game_id}' not found.")
status_code=404,
detail=f"Game session '{req.game_id}' not found.",
)
if session["status"] != "active": if session["status"] != "active":
raise HTTPException( raise HTTPException(
status_code=409, status_code=409,
detail=f"Game session '{req.game_id}' is not active (status={session['status']}).", detail=f"Game '{req.game_id}' is not active (status={session['status']}).",
) )
action_record = { session["pending_actions"].append({
"seat": req.seat, "seat": req.seat,
"action": req.action, "action": req.action,
"target_id": req.target_id, "target_id": req.target_id,
"extra": req.extra, "extra": req.extra,
})
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}/status
# ---------------------------------------------------------------------------
@router.get("/api/game/{game_id}/status")
async def game_status(game_id: str):
"""Return live status, log tail, and completion info for a game session.
Useful for polling from the frontend or a test script to track
whether Forge has finished launching and started calling /decide.
"""
session = _sessions.get(game_id)
if session is None:
raise HTTPException(status_code=404, detail=f"Game session '{game_id}' not found.")
state: Optional[BatchState] = session.get("batch_state")
log_tail = []
completed = 0
total = 1
error = None
if state:
log_tail = state.log_lines[-20:] # last 20 lines
completed = state.completed_games
total = max(state.total_games, 1)
error = state.error
return {
"game_id": game_id,
"status": session["status"],
"seats": session["seats"],
"progress": {"completed": completed, "total": total},
"log_tail": log_tail,
"error": error,
"pending_actions": len(session["pending_actions"]),
} }
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) # GET /api/game/{game_id}/actions (Forge IPC polling)
# ─────────────────────────────────────────────────────────────── # ---------------------------------------------------------------------------
@router.get("/api/game/{game_id}/actions") @router.get("/api/game/{game_id}/actions")
async def poll_actions(game_id: str): async def poll_actions(game_id: str):
"""Drain and return all pending human actions for a game session. """Drain and return all pending human actions (consume-once).
Called by the Forge IPC polling loop to consume human player decisions. 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) session = _sessions.get(game_id)
if session is None: if session is None:
@@ -193,17 +332,22 @@ async def poll_actions(game_id: str):
return {"game_id": game_id, "actions": actions} return {"game_id": game_id, "actions": actions}
# ─────────────────────────────────────────────────────────────── # ---------------------------------------------------------------------------
# GET /api/game/sessions (debug) # GET /api/game/sessions (debug)
# ─────────────────────────────────────────────────────────────── # ---------------------------------------------------------------------------
@router.get("/api/game/sessions") @router.get("/api/game/sessions")
async def list_sessions(): async def list_sessions():
"""List active game sessions (debug endpoint).""" """List all tracked game sessions (debug)."""
return { return {
"count": len(_sessions), "count": len(_sessions),
"sessions": [ "sessions": [
{"game_id": gid, "status": s["status"], "seats": len(s["seats"])} {
"game_id": gid,
"status": s["status"],
"seats": len(s["seats"]),
"pending": len(s["pending_actions"]),
}
for gid, s in _sessions.items() for gid, s in _sessions.items()
], ],
} }