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:
1 parent
af2a71d1a5
commit
3906b93c6a
5 files changed
+561
-1
No files matched your search
+2
-1
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""WebSocket module for real-time game state streaming to Unity."""
|
||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
Reference in new issue
Block a user