feat: add WebSocket support for Unity game state streaming (#107)

* feat: add ws/ package for WebSocket game state streaming

* feat: add WebSocket ConnectionManager with per-game broadcasting

* feat: add GameStateTracker with deep-diff delta engine

* feat: add WebSocket route with delta push and REST bridge endpoints

* feat: register ws_game_router in lab_api.py
This commit is contained in:
KoalaTrapLord authored and GitHub committed 2026-03-25 19:51:15 -04:00
1 parent af2a71d1a5
commit 3906b93c6a
5 files changed
+561 -1

No files matched your search

+2 -1
View File
@@ -36,11 +36,11 @@ from routes.deepseek import router as deepseek_router
from routes.deckgen import router as deckgen_router from routes.deckgen import router as deckgen_router
from routes.coach import router as coach_router, init_coach_service from routes.coach import router as coach_router, init_coach_service
from routes.ml import router as ml_router from routes.ml import router as ml_router
from routes.ws_game import router as ws_game_router
log = logging.getLogger("commander_ai_lab.api") log = logging.getLogger("commander_ai_lab.api")
# ── CORS origins (env-configurable) ──────────────────────────── # ── CORS origins (env-configurable) ────────────────────────────
_DEFAULT_ORIGINS = "http://localhost:5173,http://localhost:8080"
_allowed = os.environ.get("ALLOWED_ORIGINS", _DEFAULT_ORIGINS) _allowed = os.environ.get("ALLOWED_ORIGINS", _DEFAULT_ORIGINS)
allowed_origins = [o.strip() for o in _allowed.split(",") if o.strip()] allowed_origins = [o.strip() for o in _allowed.split(",") if o.strip()]
@@ -63,6 +63,7 @@ app.include_router(scanner_router)
app.include_router(deepseek_router) app.include_router(deepseek_router)
app.include_router(deckgen_router) app.include_router(deckgen_router)
app.include_router(coach_router) app.include_router(coach_router)
app.include_router(ws_game_router)
app.include_router(ml_router) app.include_router(ml_router)
+182
View File
@@ -0,0 +1,182 @@
"""WebSocket route for real-time game state streaming.
Endpoints:
WS /ws/game/{game_id} — Stream state deltas to Unity
POST /api/ws/game/{game_id}/push — Push a state update (called by Forge IPC)
POST /api/ws/game/{game_id}/decision — Push AI decision event
POST /api/ws/game/{game_id}/end — Signal game over
GET /api/ws/stats — Connection statistics
Unity connects to the WebSocket endpoint and receives:
1. An initial full snapshot on first state push
2. Subsequent delta-only messages as state changes
3. Decision events when the AI acts
4. A game_over event when the game ends
"""
from __future__ import annotations
import logging
import uuid
from typing import Optional
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from pydantic import BaseModel, Field
from ws.connection_manager import game_manager
from ws.game_state import get_tracker, remove_tracker
log = logging.getLogger("routes.ws_game")
router = APIRouter(tags=["websocket"])
# ────────────────────────────────────────────────────────────
# WebSocket endpoint — Unity connects here
# ────────────────────────────────────────────────────────────
@router.websocket("/ws/game/{game_id}")
async def game_ws(websocket: WebSocket, game_id: str):
"""WebSocket endpoint for Unity game-state streaming.
Query params:
client_id — optional unique identifier for this client
The connection stays open for the duration of the game.
The server pushes messages; the client can send:
{"type": "ping"} — keep-alive
{"type": "request_snapshot"} — request a full re-sync
"""
client_id = (
websocket.query_params.get("client_id")
or f"unity-{uuid.uuid4().hex[:8]}"
)
client = await game_manager.connect(websocket, game_id, client_id)
# If the tracker already has a baseline, send the current state
tracker = get_tracker(game_id)
if tracker.has_baseline:
snapshot_msg = tracker.update(tracker._last_snapshot)
await websocket.send_json(snapshot_msg)
try:
while True:
data = await websocket.receive_json()
msg_type = data.get("type", "")
if msg_type == "ping":
await websocket.send_json({"type": "pong"})
elif msg_type == "request_snapshot":
if tracker.has_baseline:
# Re-send current state as full snapshot
import copy
snap = copy.deepcopy(tracker._last_snapshot)
tracker._seq += 1
import time
await websocket.send_json({
"type": "snapshot",
"seq": tracker._seq,
"game_id": game_id,
"timestamp": time.time(),
"state": snap,
})
else:
await websocket.send_json({
"type": "error",
"message": "No game state available yet",
})
except WebSocketDisconnect:
pass
except Exception as e:
log.warning("WS error game=%s client=%s: %s", game_id, client_id, e)
finally:
await game_manager.disconnect(game_id, client_id)
# ────────────────────────────────────────────────────────────
# REST endpoints — called by Forge IPC / policy server
# ────────────────────────────────────────────────────────────
class PushStateRequest(BaseModel):
"""Game state snapshot pushed from Forge."""
game_id: str = ""
turn: int = 0
phase: str = ""
active_player: int = 0
players: list = Field(default_factory=list)
stack: list = Field(default_factory=list)
legal_actions: list = Field(default_factory=list)
class DecisionEvent(BaseModel):
"""AI decision event pushed after policy inference."""
action: str
action_index: int = 0
confidence: float = 0.0
probabilities: dict = Field(default_factory=dict)
inference_ms: float = 0.0
class GameOverEvent(BaseModel):
"""Game-over event."""
winner_seat: int = -1
reason: str = ""
turns_played: int = 0
@router.post("/api/ws/game/{game_id}/push")
async def push_game_state(game_id: str, req: PushStateRequest):
"""Push a game state update to all connected Unity clients.
Called by the Forge IPC bridge or policy server after each
state change. The delta engine diffs against the previous
state and broadcasts only what changed.
"""
tracker = get_tracker(game_id)
snapshot = req.dict()
delta_msg = tracker.update(snapshot)
sent = await game_manager.broadcast_to_game(game_id, delta_msg)
return {
"status": "ok",
"type": delta_msg["type"],
"seq": delta_msg["seq"],
"clients_notified": sent,
"changes": len(delta_msg.get("changes", [])),
}
@router.post("/api/ws/game/{game_id}/decision")
async def push_decision(game_id: str, event: DecisionEvent):
"""Push an AI decision event to connected Unity clients."""
tracker = get_tracker(game_id)
msg = tracker.make_decision_event(
action=event.action,
action_index=event.action_index,
confidence=event.confidence,
probabilities=event.probabilities,
inference_ms=event.inference_ms,
)
sent = await game_manager.broadcast_to_game(game_id, msg)
return {"status": "ok", "clients_notified": sent}
@router.post("/api/ws/game/{game_id}/end")
async def push_game_over(game_id: str, event: GameOverEvent):
"""Signal game over to all connected clients and clean up."""
tracker = get_tracker(game_id)
msg = tracker.make_game_over_event(
winner_seat=event.winner_seat,
reason=event.reason,
turns_played=event.turns_played,
)
sent = await game_manager.broadcast_to_game(game_id, msg)
remove_tracker(game_id)
return {"status": "ok", "clients_notified": sent}
@router.get("/api/ws/stats")
async def ws_stats():
"""Return WebSocket connection statistics."""
return game_manager.stats()
+1
View File
@@ -0,0 +1 @@
"""WebSocket module for real-time game state streaming to Unity."""
+165
View File
@@ -0,0 +1,165 @@
"""WebSocket connection manager for game-state streaming.
Manages per-game-session WebSocket connections so the server can
push state deltas to every connected Unity client in real time.
"""
from __future__ import annotations
import asyncio
import logging
import time
from dataclasses import dataclass, field
from typing import Dict, List, Optional, Set
from fastapi import WebSocket, WebSocketDisconnect
log = logging.getLogger("ws.connection_manager")
@dataclass
class ClientInfo:
"""Metadata for a single connected WebSocket client."""
ws: WebSocket
client_id: str
game_id: str
connected_at: float = field(default_factory=time.time)
last_ping: float = field(default_factory=time.time)
messages_sent: int = 0
class ConnectionManager:
"""Manages WebSocket connections grouped by game_id.
Usage:
manager = ConnectionManager()
await manager.connect(websocket, game_id, client_id)
await manager.broadcast_to_game(game_id, payload)
manager.disconnect(websocket, game_id)
"""
def __init__(self, heartbeat_interval: float = 30.0):
# game_id -> set of ClientInfo
self._games: Dict[str, Dict[str, ClientInfo]] = {}
self._heartbeat_interval = heartbeat_interval
self._lock = asyncio.Lock()
# ── Connect / Disconnect ───────────────────────────────
async def connect(
self, ws: WebSocket, game_id: str, client_id: str
) -> ClientInfo:
"""Accept and register a WebSocket connection."""
await ws.accept()
client = ClientInfo(ws=ws, client_id=client_id, game_id=game_id)
async with self._lock:
if game_id not in self._games:
self._games[game_id] = {}
self._games[game_id][client_id] = client
log.info(
"WS connected: client=%s game=%s (total=%d)",
client_id, game_id, len(self._games[game_id]),
)
return client
async def disconnect(self, game_id: str, client_id: str) -> None:
"""Remove a client from the game session."""
async with self._lock:
game = self._games.get(game_id)
if game and client_id in game:
del game[client_id]
if not game:
del self._games[game_id]
log.info("WS disconnected: client=%s game=%s", client_id, game_id)
# ── Broadcasting ───────────────────────────────────────
async def broadcast_to_game(
self, game_id: str, payload: dict
) -> int:
"""Send a JSON payload to every client in a game session.
Returns the number of clients that received the message.
Silently removes clients whose connections are broken.
"""
async with self._lock:
game = self._games.get(game_id)
if not game:
return 0
clients = list(game.values())
sent = 0
dead: List[str] = []
for client in clients:
try:
await client.ws.send_json(payload)
client.messages_sent += 1
sent += 1
except Exception:
dead.append(client.client_id)
# Clean up broken connections
if dead:
async with self._lock:
game = self._games.get(game_id)
if game:
for cid in dead:
game.pop(cid, None)
if not game:
del self._games[game_id]
log.warning(
"Removed %d dead connection(s) from game %s",
len(dead), game_id,
)
return sent
async def send_to_client(
self, game_id: str, client_id: str, payload: dict
) -> bool:
"""Send a JSON payload to a specific client."""
async with self._lock:
game = self._games.get(game_id)
if not game:
return False
client = game.get(client_id)
if not client:
return False
try:
await client.ws.send_json(payload)
client.messages_sent += 1
return True
except Exception:
await self.disconnect(game_id, client_id)
return False
# ── Queries ────────────────────────────────────────────
def get_game_clients(self, game_id: str) -> List[str]:
"""Return client IDs connected to a game."""
game = self._games.get(game_id, {})
return list(game.keys())
def active_games(self) -> List[str]:
"""Return all game IDs with active connections."""
return list(self._games.keys())
@property
def total_connections(self) -> int:
return sum(len(g) for g in self._games.values())
def stats(self) -> dict:
"""Return connection statistics."""
return {
"active_games": len(self._games),
"total_connections": self.total_connections,
"games": {
gid: {
"clients": len(clients),
"client_ids": list(clients.keys()),
}
for gid, clients in self._games.items()
},
}
# Module-level singleton
game_manager = ConnectionManager()
+211
View File
@@ -0,0 +1,211 @@
"""Game-state delta engine.
Compares successive game-state snapshots and produces minimal
JSON deltas so that Unity only receives what changed — not
the full 4-player board every tick.
Delta format sent over WebSocket:
{
"type": "delta" | "snapshot" | "decision" | "game_over",
"seq": <monotonic int>,
"game_id": "...",
"turn": 5,
"phase": "main_1",
"timestamp": 1711400000.0,
"changes": [
{"path": "players.0.life", "op": "replace", "value": 37},
{"path": "players.1.battlefield", "op": "add", "value": {"name": "Sol Ring", ...}},
{"path": "stack", "op": "replace", "value": [...]},
]
}
"""
from __future__ import annotations
import copy
import logging
import time
from typing import Any, Dict, List, Optional, Tuple
log = logging.getLogger("ws.game_state")
def _deep_diff(
old: Any, new: Any, path: str = ""
) -> List[Dict[str, Any]]:
"""Recursively diff two JSON-serialisable structures.
Returns a list of RFC-6902-style patch operations:
{"op": "replace"|"add"|"remove", "path": "...", "value": ...}
"""
changes: List[Dict[str, Any]] = []
if old is None and new is not None:
return [{"op": "add", "path": path, "value": new}]
if old is not None and new is None:
return [{"op": "remove", "path": path}]
if type(old) != type(new):
return [{"op": "replace", "path": path, "value": new}]
if isinstance(new, dict):
all_keys = set(list(old.keys()) + list(new.keys()))
for key in sorted(all_keys):
child_path = f"{path}.{key}" if path else key
if key not in old:
changes.append({"op": "add", "path": child_path, "value": new[key]})
elif key not in new:
changes.append({"op": "remove", "path": child_path})
else:
changes.extend(_deep_diff(old[key], new[key], child_path))
return changes
if isinstance(new, list):
# For lists (battlefield, hand, graveyard, stack) we do a
# simple length+element comparison. Zone lists change
# frequently and a full LCS diff is overkill for game state.
if old == new:
return []
# If the list is short or changed significantly, send whole list
if len(old) != len(new) or _list_diff_ratio(old, new) > 0.5:
return [{"op": "replace", "path": path, "value": new}]
# Element-wise diff for same-length lists with few changes
for i, (o, n) in enumerate(zip(old, new)):
child_path = f"{path}.{i}"
changes.extend(_deep_diff(o, n, child_path))
return changes
# Scalar comparison
if old != new:
return [{"op": "replace", "path": path, "value": new}]
return []
def _list_diff_ratio(old: list, new: list) -> float:
"""Fraction of elements that differ between two same-length lists."""
if not old:
return 0.0
diffs = sum(1 for a, b in zip(old, new) if a != b)
return diffs / len(old)
class GameStateTracker:
"""Tracks per-game state and emits deltas on each update.
Usage:
tracker = GameStateTracker(game_id)
delta = tracker.update(new_snapshot)
# delta is a dict ready to send via WebSocket
"""
def __init__(self, game_id: str):
self.game_id = game_id
self._last_snapshot: Optional[Dict] = None
self._seq: int = 0
self._created_at: float = time.time()
def update(self, snapshot: Dict) -> Dict:
"""Compare snapshot with previous state, return delta message.
First call returns a full snapshot message (type="snapshot").
Subsequent calls return delta messages (type="delta").
"""
self._seq += 1
now = time.time()
if self._last_snapshot is None:
# First update — send full snapshot
self._last_snapshot = copy.deepcopy(snapshot)
return {
"type": "snapshot",
"seq": self._seq,
"game_id": self.game_id,
"turn": snapshot.get("turn", 0),
"phase": snapshot.get("phase", ""),
"timestamp": now,
"state": snapshot,
}
# Compute delta
changes = _deep_diff(self._last_snapshot, snapshot)
self._last_snapshot = copy.deepcopy(snapshot)
if not changes:
return {
"type": "heartbeat",
"seq": self._seq,
"game_id": self.game_id,
"timestamp": now,
}
return {
"type": "delta",
"seq": self._seq,
"game_id": self.game_id,
"turn": snapshot.get("turn", 0),
"phase": snapshot.get("phase", ""),
"timestamp": now,
"changes": changes,
}
def make_decision_event(
self,
action: str,
action_index: int,
confidence: float,
probabilities: Optional[Dict[str, float]] = None,
inference_ms: float = 0.0,
) -> Dict:
"""Build a decision event message for the Unity client."""
self._seq += 1
return {
"type": "decision",
"seq": self._seq,
"game_id": self.game_id,
"timestamp": time.time(),
"action": action,
"action_index": action_index,
"confidence": confidence,
"probabilities": probabilities or {},
"inference_ms": inference_ms,
}
def make_game_over_event(
self,
winner_seat: int,
reason: str = "",
turns_played: int = 0,
) -> Dict:
"""Build a game-over event message."""
self._seq += 1
return {
"type": "game_over",
"seq": self._seq,
"game_id": self.game_id,
"timestamp": time.time(),
"winner_seat": winner_seat,
"reason": reason,
"turns_played": turns_played,
}
@property
def sequence(self) -> int:
return self._seq
@property
def has_baseline(self) -> bool:
return self._last_snapshot is not None
# Per-game tracker registry
_trackers: Dict[str, GameStateTracker] = {}
def get_tracker(game_id: str) -> GameStateTracker:
"""Get or create a state tracker for a game."""
if game_id not in _trackers:
_trackers[game_id] = GameStateTracker(game_id)
return _trackers[game_id]
def remove_tracker(game_id: str) -> None:
"""Remove a game tracker (called on game end)."""
_trackers.pop(game_id, None)