* chore: add battleground UI task tracker (2026-03-28) * feat(#142): add [NonSerialized] battleground runtime state to CardModel * feat(#143): add LoadArtCrop and LoadCardRaw (RawImage) to ImageCache * feat(#148): add GameStateManager singleton and GameState/SeatState POCOs * feat(#138): add BattlegroundController scene orchestrator * feat(#139): add LifeTrackerWidget, CommanderDamageRow, PoisonCounter * feat(#140): add PhaseTrackerWidget and PhaseStepButton * feat(#141): add TurnOrderBar and TurnOrderPill * feat(#142): add CardZone and CardView battlefield zone components * feat(#143): add CommanderZoneWidget with art crop, cast counter, and tax display * feat(#144): add TokenModel POCO, TokenPanelController, and TokenRowView * chore: mark issues 1-8 as complete in task tracker * feat(#146): add StackZoneController, StackItemView, and ManaPoolDisplay * feat(#147 #150 #151): add LobbySetupModal, ZoneViewerModal, EliminationHandler; add Play Live button to SimulationController * feat(#151): add Play Live button to SimulationController * feat(#149): add GameWebSocketClient with reconnect, ping, and main-thread dispatch * fix: add PostGameAction to ApiClient + update TODO.md all issues complete - ApiClient.cs: add PostGameAction() wrapper → POST /api/game/action (fixes compile error in HumanActionBar.cs) - TODO.md: mark issues #145, #146, #147, #149, #150, #151 as ✅ all 14 battleground issues now committed * feat(game): add routes/game.py + wire lab_api + fix GameWebSocketClient game_id URL - routes/game.py: POST /api/game/start, POST /api/game/action (new) - lab_api.py: import + register game_router - GameWebSocketClient.cs: read game_id from PlayerPrefs, build ws://{host}/ws/game/{game_id} — fixes URL mismatch with ws_game.py * fix(lobby): wire OnConfirm to POST /api/game/start, save game_id to PlayerPrefs - OnConfirm() now calls ApiClient.PostGameStart() with 4-seat config - On success: saves BattleSeats + GameId + WsUrl to PlayerPrefs, loads Battleground scene - On failure: shows inline error label (red), re-enables Confirm button - Added StartGameRequest/Response POCOs matching routes/game.py schema - Added _errorLabel [SerializeField] for user-facing error feedback
210 lines
7.8 KiB
Python
210 lines
7.8 KiB
Python
"""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()
|
|
],
|
|
}
|