Add microservices, web UI, and replay tooling
Some checks failed
ci / tests (push) Has been cancelled

This commit is contained in:
dan
2025-12-25 03:28:40 +03:00
commit 46a07f548b
72 changed files with 9142 additions and 0 deletions

View File

231
services/analytics/app.py Normal file
View 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}

View File

@@ -0,0 +1,5 @@
fastapi>=0.115
uvicorn[standard]>=0.30
sqlmodel>=0.0.16
pydantic-settings>=2.2
psycopg[binary]>=3.1