Wire Forge IPC into POST /api/game/start via run_batch_subprocess + --policy flag
This commit is contained in:
1 parent
f2e87feb4e
commit
85af163123
1 file changed
+209
-65
+209
-65
@@ -1,11 +1,29 @@
|
||||
"""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)
|
||||
POST /api/game/start -- create and launch a live Forge game session
|
||||
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
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import uuid
|
||||
from typing import List, Optional
|
||||
@@ -13,14 +31,39 @@ from typing import List, Optional
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from models.state import BatchState
|
||||
from services.forge_runner import run_batch_subprocess
|
||||
|
||||
log = logging.getLogger("routes.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
|
||||
# ───────────────────────────────────────────────────────────────
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class SeatConfig(BaseModel):
|
||||
seat_index: int = 0
|
||||
@@ -31,12 +74,16 @@ class SeatConfig(BaseModel):
|
||||
|
||||
class StartGameRequest(BaseModel):
|
||||
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):
|
||||
game_id: str
|
||||
status: str = "started"
|
||||
ws_url: str
|
||||
policy_enabled: bool = False
|
||||
|
||||
|
||||
class GameActionRequest(BaseModel):
|
||||
@@ -54,73 +101,139 @@ class GameActionResponse(BaseModel):
|
||||
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] = {}
|
||||
|
||||
|
||||
# ───────────────────────────────────────────────────────────────
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /api/game/start
|
||||
# ───────────────────────────────────────────────────────────────
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/api/game/start", response_model=StartGameResponse)
|
||||
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.
|
||||
Returns a game_id that Unity uses to connect to the WebSocket at
|
||||
/ws/game/{game_id}.
|
||||
Resolves deck names from the seat config, launches the Forge JAR
|
||||
as a background subprocess with --policy routing enabled (unless
|
||||
use_learned_policy=False), and returns a game_id immediately.
|
||||
|
||||
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.
|
||||
The Forge JAR will begin calling POST /api/policy/decide for every
|
||||
AI seat decision as soon as it is up. Human actions are queued via
|
||||
POST /api/game/action and polled by the JAR via GET /api/game/{id}/actions.
|
||||
"""
|
||||
if not req.seats:
|
||||
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
|
||||
|
||||
# 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] = {
|
||||
"game_id": game_id,
|
||||
"seats": [s.dict() for s in req.seats],
|
||||
"status": "active",
|
||||
"game_id": game_id,
|
||||
"seats": [s.dict() for s in req.seats],
|
||||
"status": "active",
|
||||
"pending_actions": [],
|
||||
"batch_state": state,
|
||||
}
|
||||
|
||||
log.info(
|
||||
"Game session created: game_id=%s seats=%d",
|
||||
game_id,
|
||||
len(req.seats),
|
||||
"Game session created: game_id=%s seats=%d decks=%s policy=%s greedy=%s",
|
||||
game_id, len(req.seats), decks, policy_style if req.use_learned_policy else "off",
|
||||
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(
|
||||
game_id=game_id,
|
||||
status="started",
|
||||
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
|
||||
# ───────────────────────────────────────────────────────────────
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@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)
|
||||
Called by Unity's HumanActionBar when the human seat has priority.
|
||||
The action is queued in the session; the Forge IPC polling loop
|
||||
drains it via GET /api/game/{game_id}/actions.
|
||||
"""
|
||||
valid_actions = {
|
||||
"pass_priority", "end_turn",
|
||||
@@ -142,47 +255,73 @@ async def game_action(req: GameActionRequest):
|
||||
|
||||
session = _sessions.get(req.game_id)
|
||||
if session is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Game session '{req.game_id}' not found.",
|
||||
)
|
||||
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']}).",
|
||||
detail=f"Game '{req.game_id}' is not active (status={session['status']}).",
|
||||
)
|
||||
|
||||
action_record = {
|
||||
"seat": req.seat,
|
||||
"action": req.action,
|
||||
session["pending_actions"].append({
|
||||
"seat": req.seat,
|
||||
"action": req.action,
|
||||
"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")
|
||||
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.
|
||||
Clears the queue on each call (consume-once semantics).
|
||||
"""
|
||||
session = _sessions.get(game_id)
|
||||
if session is None:
|
||||
@@ -193,17 +332,22 @@ async def poll_actions(game_id: str):
|
||||
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)."""
|
||||
"""List all tracked game sessions (debug)."""
|
||||
return {
|
||||
"count": len(_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()
|
||||
],
|
||||
}
|
||||
Reference in new issue
Block a user