Add microservices, web UI, and replay tooling
Some checks failed
ci / tests (push) Has been cancelled
Some checks failed
ci / tests (push) Has been cancelled
This commit is contained in:
0
services/analytics/__init__.py
Normal file
0
services/analytics/__init__.py
Normal file
231
services/analytics/app.py
Normal file
231
services/analytics/app.py
Normal file
@@ -0,0 +1,231 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from sqlmodel import SQLModel, select
|
||||
|
||||
from catan.game import GameConfig
|
||||
from catan.sdk import Action, ActionType, CatanEnv
|
||||
from services.common.db import engine, session_scope
|
||||
from services.common.schemas import ReplayArchive, ReplayDetail, ReplayMeta
|
||||
from services.game.models import Game, GameEvent
|
||||
|
||||
app = FastAPI(title="Catan Analytics")
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
def _startup() -> None:
|
||||
SQLModel.metadata.create_all(engine)
|
||||
|
||||
|
||||
def _serialize_resources(resources: Dict[Any, int]) -> Dict[str, int]:
|
||||
return {str(getattr(res, "value", res)): int(val) for res, val in resources.items()}
|
||||
|
||||
|
||||
def _serialize_observation(observation: Dict[str, Any]) -> Dict[str, Any]:
|
||||
game = dict(observation["game"])
|
||||
players = {}
|
||||
for name, info in game["players"].items():
|
||||
pdata = dict(info)
|
||||
if isinstance(pdata.get("resources"), dict):
|
||||
pdata["resources"] = _serialize_resources(pdata["resources"])
|
||||
players[name] = pdata
|
||||
game["players"] = players
|
||||
if "bank" in game:
|
||||
game["bank"] = _serialize_resources(game["bank"])
|
||||
return {"game": game, "board": observation["board"]}
|
||||
|
||||
|
||||
def _load_game(game_id: str) -> Game:
|
||||
with session_scope() as session:
|
||||
game = session.get(Game, game_id)
|
||||
if not game:
|
||||
raise HTTPException(status_code=404, detail="Game not found")
|
||||
return game
|
||||
|
||||
|
||||
def _load_events(game_id: str) -> List[GameEvent]:
|
||||
with session_scope() as session:
|
||||
return session.exec(
|
||||
select(GameEvent).where(GameEvent.game_id == game_id).order_by(GameEvent.idx)
|
||||
).all()
|
||||
|
||||
|
||||
def _replay_state(game: Game, events: List[GameEvent], step: int) -> Dict[str, Any]:
|
||||
slots = game.slots.get("slots", [])
|
||||
names = [slot.get("name") for slot in slots if slot.get("name")]
|
||||
colors = [slot.get("color", "player") for slot in slots if slot.get("name")]
|
||||
env = CatanEnv(GameConfig(player_names=names, colors=colors, seed=game.seed))
|
||||
applied_events = [event for event in events if event.applied]
|
||||
for event in applied_events[:step]:
|
||||
action = Action(ActionType(event.action_type), event.payload)
|
||||
env.step(action)
|
||||
return _serialize_observation(env.observe())
|
||||
|
||||
|
||||
def _build_archive(game: Game, events: List[GameEvent]) -> ReplayArchive:
|
||||
slots = game.slots.get("slots", [])
|
||||
players = [slot.get("name") for slot in slots if slot.get("name")]
|
||||
actions = [
|
||||
{
|
||||
"idx": event.idx,
|
||||
"ts": event.ts,
|
||||
"actor": event.actor,
|
||||
"action": {"type": event.action_type, "payload": event.payload},
|
||||
"applied": event.applied,
|
||||
"meta": event.debug_payload or {},
|
||||
}
|
||||
for event in events
|
||||
]
|
||||
return ReplayArchive(
|
||||
id=game.id,
|
||||
created_at=game.created_at,
|
||||
seed=game.seed,
|
||||
players=players,
|
||||
winner=game.winner,
|
||||
slots=slots,
|
||||
actions=actions,
|
||||
)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health() -> Dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.get("/stats")
|
||||
def stats(user: Optional[str] = None) -> Dict[str, Any]:
|
||||
with session_scope() as session:
|
||||
games = session.exec(select(Game)).all()
|
||||
total_games = len(games)
|
||||
finished_games = [g for g in games if g.status == "finished"]
|
||||
avg_turns = 0.0
|
||||
if finished_games:
|
||||
with session_scope() as session:
|
||||
counts = []
|
||||
for game in finished_games:
|
||||
count = session.exec(
|
||||
select(GameEvent).where(GameEvent.game_id == game.id, GameEvent.applied == True)
|
||||
).all()
|
||||
counts.append(len(count))
|
||||
avg_turns = sum(counts) / len(counts) if counts else 0.0
|
||||
if not user:
|
||||
return {
|
||||
"total_games": total_games,
|
||||
"finished_games": len(finished_games),
|
||||
"avg_turns": avg_turns,
|
||||
}
|
||||
user_games = [g for g in games if user in [slot.get("name") for slot in g.slots.get("slots", [])]]
|
||||
user_wins = sum(1 for g in games if g.winner == user)
|
||||
return {
|
||||
"total_games": total_games,
|
||||
"finished_games": len(finished_games),
|
||||
"avg_turns": avg_turns,
|
||||
"user_games": len(user_games),
|
||||
"user_wins": user_wins,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/replays")
|
||||
def list_replays() -> Dict[str, Any]:
|
||||
with session_scope() as session:
|
||||
games = session.exec(select(Game).where(Game.status == "finished").order_by(Game.created_at.desc())).all()
|
||||
items: List[ReplayMeta] = []
|
||||
with session_scope() as session:
|
||||
for game in games:
|
||||
actions = session.exec(
|
||||
select(GameEvent).where(GameEvent.game_id == game.id, GameEvent.applied == True)
|
||||
).all()
|
||||
players = [slot.get("name") for slot in game.slots.get("slots", []) if slot.get("name")]
|
||||
items.append(
|
||||
ReplayMeta(
|
||||
id=game.id,
|
||||
created_at=game.created_at,
|
||||
players=players,
|
||||
winner=game.winner,
|
||||
total_actions=len(actions),
|
||||
)
|
||||
)
|
||||
return {"replays": [item.model_dump() for item in items]}
|
||||
|
||||
|
||||
@app.get("/replays/{replay_id}")
|
||||
def replay_detail(replay_id: str) -> Dict[str, Any]:
|
||||
game = _load_game(replay_id)
|
||||
events = _load_events(replay_id)
|
||||
applied = [event for event in events if event.applied]
|
||||
players = [slot.get("name") for slot in game.slots.get("slots", []) if slot.get("name")]
|
||||
actions = [
|
||||
{
|
||||
"idx": event.idx,
|
||||
"ts": event.ts,
|
||||
"actor": event.actor,
|
||||
"action": {"type": event.action_type, "payload": event.payload},
|
||||
"applied": event.applied,
|
||||
"meta": event.debug_payload or {},
|
||||
}
|
||||
for event in applied
|
||||
]
|
||||
detail = ReplayDetail(
|
||||
id=game.id,
|
||||
created_at=game.created_at,
|
||||
seed=game.seed,
|
||||
players=players,
|
||||
winner=game.winner,
|
||||
total_actions=len(applied),
|
||||
actions=actions,
|
||||
)
|
||||
return detail.model_dump()
|
||||
|
||||
|
||||
@app.get("/replays/{replay_id}/state")
|
||||
def replay_state(replay_id: str, step: int = 0) -> Dict[str, Any]:
|
||||
game = _load_game(replay_id)
|
||||
events = _load_events(replay_id)
|
||||
state = _replay_state(game, events, step)
|
||||
return state
|
||||
|
||||
|
||||
@app.get("/replays/{replay_id}/export")
|
||||
def export_replay(replay_id: str) -> Dict[str, Any]:
|
||||
game = _load_game(replay_id)
|
||||
events = _load_events(replay_id)
|
||||
archive = _build_archive(game, events)
|
||||
return archive.model_dump()
|
||||
|
||||
|
||||
@app.post("/replays/import")
|
||||
def import_replay(payload: ReplayArchive) -> Dict[str, Any]:
|
||||
with session_scope() as session:
|
||||
existing = session.get(Game, payload.id)
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="Replay already exists")
|
||||
now = dt.datetime.now(dt.timezone.utc)
|
||||
game = Game(
|
||||
id=payload.id,
|
||||
name=f"Replay {payload.id[:8]}",
|
||||
status="finished",
|
||||
max_players=len(payload.slots) or len(payload.players),
|
||||
created_by="import",
|
||||
created_at=payload.created_at,
|
||||
updated_at=now,
|
||||
seed=payload.seed,
|
||||
slots={"slots": [slot.model_dump() for slot in payload.slots]},
|
||||
winner=payload.winner,
|
||||
)
|
||||
session.add(game)
|
||||
for action in payload.actions:
|
||||
event = GameEvent(
|
||||
game_id=payload.id,
|
||||
idx=action.idx,
|
||||
ts=action.ts,
|
||||
actor=action.actor,
|
||||
action_type=action.action.type,
|
||||
payload=action.action.payload,
|
||||
applied=action.applied,
|
||||
debug_payload=action.meta or None,
|
||||
)
|
||||
session.add(event)
|
||||
return {"status": "imported", "id": payload.id}
|
||||
5
services/analytics/requirements.txt
Normal file
5
services/analytics/requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
fastapi>=0.115
|
||||
uvicorn[standard]>=0.30
|
||||
sqlmodel>=0.0.16
|
||||
pydantic-settings>=2.2
|
||||
psycopg[binary]>=3.1
|
||||
Reference in New Issue
Block a user