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

0
services/ai/__init__.py Normal file
View File

185
services/ai/app.py Normal file
View File

@@ -0,0 +1,185 @@
from __future__ import annotations
import random
from typing import Any, Dict, List
import numpy as np
import torch
from fastapi import FastAPI, HTTPException
from torch.serialization import add_safe_globals
from catan.data import Resource
from catan.ml.encoding import encode_action, encode_observation
from catan.game import GameConfig
from catan.ml.selfplay import ActionScoringNetwork, PPOConfig
from catan.sdk import Action, ActionType, parse_resource
from services.common.schemas import AIRequest, AIResponse, ActionSchema
from services.common.settings import settings
app = FastAPI(title="Catan AI Service")
add_safe_globals([PPOConfig, GameConfig])
class ModelRegistry:
def __init__(self) -> None:
self._cache: Dict[str, ActionScoringNetwork] = {}
def list_models(self) -> List[str]:
path = settings.models_dir
import os
if not os.path.isdir(path):
return []
return sorted([name for name in os.listdir(path) if name.endswith(".pt")])
def load(self, name: str) -> ActionScoringNetwork:
if name in self._cache:
return self._cache[name]
path = f"{settings.models_dir}/{name}"
state = torch.load(path, map_location="cpu", weights_only=False)
cfg = state.get("config")
if cfg is None:
raise ValueError("Invalid model config")
input_dim = state["actor"]["network.0.weight"].shape[1]
action_dim = encode_action(Action(ActionType.END_TURN, {})).shape[0]
obs_dim = input_dim - action_dim
actor = ActionScoringNetwork(obs_dim, action_dim, cfg.hidden_sizes)
actor.load_state_dict(state["actor"])
actor.eval()
self._cache[name] = actor
return actor
registry = ModelRegistry()
def _deserialize_resources(resources: Dict[str, int]) -> Dict[Resource, int]:
return {parse_resource(k): int(v) for k, v in resources.items()}
def _deserialize_observation(obs: Dict[str, Any]) -> Dict[str, Any]:
game = dict(obs["game"])
players = {}
for name, info in game["players"].items():
data = dict(info)
if isinstance(data.get("resources"), dict) and "hidden" not in data["resources"]:
data["resources"] = _deserialize_resources(data["resources"])
players[name] = data
game["players"] = players
if "bank" in game and isinstance(game["bank"], dict):
game["bank"] = {parse_resource(k): int(v) for k, v in game["bank"].items()}
return {"game": game, "board": obs["board"]}
def _finalize_action(template: ActionSchema, rng: random.Random) -> ActionSchema:
payload = dict(template.payload or {})
action_type = ActionType(template.type)
if action_type in {ActionType.MOVE_ROBBER, ActionType.PLAY_KNIGHT}:
options = payload.get("options", [])
if not options:
return ActionSchema(type=ActionType.END_TURN.value, payload={})
choice = rng.choice(options)
new_payload: Dict[str, Any] = {"hex": choice["hex"]}
victims = choice.get("victims") or []
if victims:
new_payload["victim"] = rng.choice(victims)
return ActionSchema(type=action_type.value, payload=new_payload)
if action_type == ActionType.PLAY_ROAD_BUILDING:
edges = payload.get("edges", [])
if not edges:
return ActionSchema(type=ActionType.END_TURN.value, payload={})
picks = rng.sample(edges, k=min(2, len(edges)))
while len(picks) < 2:
picks.append(rng.choice(edges))
return ActionSchema(type=action_type.value, payload={"edges": picks[:2]})
if action_type == ActionType.PLAY_YEAR_OF_PLENTY:
bank = payload.get("bank", {})
available = [res for res, amount in bank.items() if amount > 0]
if not available:
available = list(bank.keys())
if not available:
return ActionSchema(type=ActionType.END_TURN.value, payload={})
pick = rng.choice(available)
return ActionSchema(type=action_type.value, payload={"resources": [pick, pick]})
if action_type == ActionType.PLAY_MONOPOLY:
choices = payload.get("resources") or [res.value for res in Resource if res != Resource.DESERT]
if not choices:
return ActionSchema(type=ActionType.END_TURN.value, payload={})
return ActionSchema(type=action_type.value, payload={"resource": rng.choice(choices)})
if action_type == ActionType.DISCARD:
required = payload.get("required")
resources = payload.get("resources") or {}
if not isinstance(required, int):
return ActionSchema(type=ActionType.END_TURN.value, payload={})
pool = []
for res, count in resources.items():
if res == "desert" or count <= 0:
continue
pool.extend([res] * int(count))
rng.shuffle(pool)
cards: Dict[str, int] = {}
for res in pool[:required]:
cards[res] = cards.get(res, 0) + 1
return ActionSchema(type=action_type.value, payload={"player": payload.get("player"), "cards": cards})
return ActionSchema(type=action_type.value, payload=payload)
def _choose_action(obs: Dict[str, Any], legal_actions: List[ActionSchema], agent: Dict[str, Any], debug: bool) -> AIResponse:
rng = random.Random()
kind = agent.get("kind", "random")
if not legal_actions:
return AIResponse(action=ActionSchema(type=ActionType.END_TURN.value, payload={}))
if kind == "random":
template = rng.choice(legal_actions)
return AIResponse(action=_finalize_action(template, rng))
if kind == "model":
model_name = agent.get("model")
if not model_name:
raise HTTPException(status_code=400, detail="Model name required")
actor = registry.load(model_name)
obs_vec = encode_observation(_deserialize_observation(obs))
actions_vec = np.stack([encode_action(Action(ActionType(a.type), a.payload)) for a in legal_actions])
obs_tensor = torch.tensor(obs_vec, dtype=torch.float32)
action_tensor = torch.tensor(actions_vec, dtype=torch.float32)
logits = actor(obs_tensor, action_tensor)
probs = torch.softmax(logits, dim=0)
if agent.get("stochastic", True):
dist = torch.distributions.Categorical(probs=probs)
idx = dist.sample().item()
else:
idx = torch.argmax(probs).item()
selected = legal_actions[idx]
finalized = _finalize_action(selected, rng)
debug_payload = {}
if debug:
debug_payload = {
"logits": logits.detach().cpu().tolist(),
"probs": probs.detach().cpu().tolist(),
"index": idx,
"model": model_name,
}
return AIResponse(action=finalized, debug=debug_payload)
raise HTTPException(status_code=400, detail="Unknown agent kind")
@app.get("/health")
def health() -> Dict[str, str]:
return {"status": "ok"}
@app.get("/models")
def list_models() -> Dict[str, List[str]]:
return {"models": registry.list_models()}
@app.post("/act")
def act(payload: AIRequest) -> Dict[str, Any]:
response = _choose_action(payload.observation, payload.legal_actions, payload.agent, payload.debug)
return response.model_dump()

View File

@@ -0,0 +1,4 @@
fastapi>=0.115
uvicorn[standard]>=0.30
numpy>=1.26
pydantic-settings>=2.2

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

0
services/api/__init__.py Normal file
View File

360
services/api/app.py Normal file
View File

@@ -0,0 +1,360 @@
from __future__ import annotations
import asyncio
from typing import Dict, Optional
import httpx
from fastapi import Depends, FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from sqlmodel import SQLModel, select
from sqlalchemy.exc import IntegrityError
from services.api.models import User
from services.common.auth import create_token, decode_token, hash_password, verify_password
from services.common.db import SessionLocal, engine
from services.common.schemas import (
ActionRequest,
AddAIRequest,
CreateGameRequest,
JoinGameRequest,
TradeOfferRequest,
TradeRespondRequest,
)
from services.common.settings import settings
app = FastAPI(title="Catan API Gateway")
cors_origins = [origin.strip() for origin in settings.cors_origins.split(",") if origin.strip()]
app.add_middleware(
CORSMiddleware,
allow_origins=cors_origins or ["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
clients: Dict[str, httpx.AsyncClient] = {}
ws_connections: Dict[str, Dict[WebSocket, str]] = {}
ai_tasks: Dict[str, asyncio.Task] = {}
@app.on_event("startup")
async def _startup() -> None:
SQLModel.metadata.create_all(engine)
clients["game"] = httpx.AsyncClient(base_url=settings.game_service_url)
clients["analytics"] = httpx.AsyncClient(base_url=settings.analytics_service_url)
clients["ai"] = httpx.AsyncClient(base_url=settings.ai_service_url)
@app.on_event("shutdown")
async def _shutdown() -> None:
for client in clients.values():
await client.aclose()
def _get_current_user(request: Request) -> User:
auth = request.headers.get("authorization", "")
if not auth.lower().startswith("bearer "):
raise HTTPException(status_code=401, detail="Missing token")
token = auth.split(" ", 1)[1].strip()
try:
payload = decode_token(token)
except Exception:
raise HTTPException(status_code=401, detail="Invalid token")
user_id = payload.get("sub")
if not user_id:
raise HTTPException(status_code=401, detail="Invalid token")
with SessionLocal() as session:
user = session.get(User, int(user_id))
if not user:
raise HTTPException(status_code=401, detail="Unknown user")
return user
def _get_user_from_ws(websocket: WebSocket) -> User:
token = websocket.query_params.get("token")
if not token:
raise HTTPException(status_code=401, detail="Missing token")
try:
payload = decode_token(token)
except Exception:
raise HTTPException(status_code=401, detail="Invalid token")
user_id = payload.get("sub")
if not user_id:
raise HTTPException(status_code=401, detail="Invalid token")
with SessionLocal() as session:
user = session.get(User, int(user_id))
if not user:
raise HTTPException(status_code=401, detail="Unknown user")
return user
def _mask_state_for_user(state: dict, username: str) -> dict:
data = dict(state)
game = data.get("game")
if not game:
return data
masked_players = {}
for name, info in game.get("players", {}).items():
pdata = dict(info)
if name != username:
resources = pdata.get("resources") or {}
total = sum(resources.values()) if isinstance(resources, dict) else 0
pdata["resources"] = {"hidden": total}
masked_players[name] = pdata
game["players"] = masked_players
data["game"] = game
return data
async def _broadcast(game_id: str, state: dict) -> None:
connections = ws_connections.get(game_id, {})
dead = []
for ws, username in connections.items():
try:
await ws.send_json({"type": "state", "data": _mask_state_for_user(state, username)})
except Exception:
dead.append(ws)
for ws in dead:
connections.pop(ws, None)
def _is_ai_only(state: dict) -> bool:
slots = state.get("players", [])
if not slots:
return False
return all(slot.get("is_ai") for slot in slots)
async def _auto_advance(game_id: str) -> None:
while True:
response = await clients["game"].post(f"/games/{game_id}/advance")
if response.status_code >= 400:
break
state = response.json()
await _broadcast(game_id, state)
if state.get("status") != "running":
break
await asyncio.sleep(0.2)
def _ensure_task(game_id: str) -> None:
if game_id in ai_tasks and not ai_tasks[game_id].done():
return
ai_tasks[game_id] = asyncio.create_task(_auto_advance(game_id))
@app.get("/health")
def health() -> dict:
return {"status": "ok"}
@app.post("/api/auth/register")
def register(payload: dict):
username = (payload.get("username") or "").strip()
password = (payload.get("password") or "").strip()
if not username or not password:
raise HTTPException(status_code=400, detail="Username and password required")
user = User(username=username, password_hash=hash_password(password))
with SessionLocal() as session:
session.add(user)
try:
session.commit()
except IntegrityError:
session.rollback()
raise HTTPException(status_code=400, detail="Username already exists")
session.refresh(user)
token = create_token(user.id, user.username)
return {"token": token, "username": user.username}
@app.post("/api/auth/login")
def login(payload: dict):
username = (payload.get("username") or "").strip()
password = (payload.get("password") or "").strip()
with SessionLocal() as session:
user = session.exec(select(User).where(User.username == username)).first()
if not user or not verify_password(password, user.password_hash):
raise HTTPException(status_code=401, detail="Invalid credentials")
token = create_token(user.id, user.username)
return {"token": token, "username": user.username}
@app.get("/api/me")
def me(user: User = Depends(_get_current_user)):
return {"id": user.id, "username": user.username}
@app.get("/api/lobby")
async def lobby(user: User = Depends(_get_current_user)):
response = await clients["game"].get("/games")
response.raise_for_status()
return response.json()
@app.post("/api/games")
async def create_game(payload: CreateGameRequest, user: User = Depends(_get_current_user)):
data = payload.model_dump()
data["created_by"] = user.username
response = await clients["game"].post("/games", json=data)
response.raise_for_status()
return response.json()
@app.post("/api/games/{game_id}/join")
async def join_game(game_id: str, user: User = Depends(_get_current_user)):
payload = JoinGameRequest(username=user.username, user_id=user.id)
response = await clients["game"].post(f"/games/{game_id}/join", json=payload.model_dump())
response.raise_for_status()
state = await clients["game"].get(f"/games/{game_id}")
state.raise_for_status()
await _broadcast(game_id, state.json())
return response.json()
@app.post("/api/games/{game_id}/leave")
async def leave_game(game_id: str, user: User = Depends(_get_current_user)):
payload = JoinGameRequest(username=user.username, user_id=user.id)
response = await clients["game"].post(f"/games/{game_id}/leave", json=payload.model_dump())
response.raise_for_status()
state = await clients["game"].get(f"/games/{game_id}")
state.raise_for_status()
await _broadcast(game_id, state.json())
return response.json()
@app.post("/api/games/{game_id}/add-ai")
async def add_ai(game_id: str, payload: AddAIRequest, user: User = Depends(_get_current_user)):
response = await clients["game"].post(f"/games/{game_id}/add_ai", json=payload.model_dump())
response.raise_for_status()
state = await clients["game"].get(f"/games/{game_id}")
state.raise_for_status()
await _broadcast(game_id, state.json())
return response.json()
@app.post("/api/games/{game_id}/start")
async def start_game(game_id: str, user: User = Depends(_get_current_user)):
response = await clients["game"].post(f"/games/{game_id}/start")
response.raise_for_status()
state = response.json()
await _broadcast(game_id, state)
if _is_ai_only(state):
_ensure_task(game_id)
return _mask_state_for_user(state, user.username)
@app.get("/api/games/{game_id}")
async def game_state(game_id: str, user: User = Depends(_get_current_user)):
response = await clients["game"].get(f"/games/{game_id}")
response.raise_for_status()
return _mask_state_for_user(response.json(), user.username)
@app.post("/api/games/{game_id}/action")
async def take_action(game_id: str, payload: dict, user: User = Depends(_get_current_user)):
action = payload.get("action") or {"type": payload.get("type"), "payload": payload.get("payload")}
req = ActionRequest(actor=user.username, action=action)
response = await clients["game"].post(f"/games/{game_id}/action", json=req.model_dump())
if response.status_code >= 400:
raise HTTPException(status_code=response.status_code, detail=response.json().get("detail"))
state = response.json()
await _broadcast(game_id, state)
if _is_ai_only(state):
_ensure_task(game_id)
return _mask_state_for_user(state, user.username)
@app.post("/api/games/{game_id}/trade/offer")
async def offer_trade(game_id: str, payload: TradeOfferRequest, user: User = Depends(_get_current_user)):
if payload.from_player != user.username:
raise HTTPException(status_code=403, detail="Not your player")
response = await clients["game"].post(f"/games/{game_id}/trade/offer", json=payload.model_dump())
response.raise_for_status()
state = await clients["game"].get(f"/games/{game_id}")
state.raise_for_status()
await _broadcast(game_id, state.json())
return response.json()
@app.post("/api/games/{game_id}/trade/{trade_id}/respond")
async def respond_trade(game_id: str, trade_id: str, payload: TradeRespondRequest, user: User = Depends(_get_current_user)):
if payload.player != user.username:
raise HTTPException(status_code=403, detail="Not your player")
response = await clients["game"].post(
f"/games/{game_id}/trade/{trade_id}/respond",
json=payload.model_dump(),
)
response.raise_for_status()
state = await clients["game"].get(f"/games/{game_id}")
state.raise_for_status()
await _broadcast(game_id, state.json())
return response.json()
@app.get("/api/models")
async def list_models(user: User = Depends(_get_current_user)):
response = await clients["ai"].get("/models")
response.raise_for_status()
return response.json()
@app.get("/api/replays")
async def list_replays(user: User = Depends(_get_current_user)):
response = await clients["analytics"].get("/replays")
response.raise_for_status()
return response.json()
@app.get("/api/replays/{replay_id}")
async def replay_detail(replay_id: str, user: User = Depends(_get_current_user)):
response = await clients["analytics"].get(f"/replays/{replay_id}")
response.raise_for_status()
return response.json()
@app.get("/api/replays/{replay_id}/state")
async def replay_state(replay_id: str, step: int = 0, user: User = Depends(_get_current_user)):
response = await clients["analytics"].get(f"/replays/{replay_id}/state", params={"step": step})
response.raise_for_status()
return response.json()
@app.get("/api/replays/{replay_id}/export")
async def replay_export(replay_id: str, user: User = Depends(_get_current_user)):
response = await clients["analytics"].get(f"/replays/{replay_id}/export")
response.raise_for_status()
return response.json()
@app.post("/api/replays/import")
async def replay_import(payload: dict, user: User = Depends(_get_current_user)):
response = await clients["analytics"].post("/replays/import", json=payload)
if response.status_code >= 400:
raise HTTPException(status_code=response.status_code, detail=response.json().get("detail"))
return response.json()
@app.get("/api/stats")
async def stats(user: User = Depends(_get_current_user)):
response = await clients["analytics"].get("/stats", params={"user": user.username})
response.raise_for_status()
return response.json()
@app.websocket("/ws/games/{game_id}")
async def ws_game(websocket: WebSocket, game_id: str):
await websocket.accept()
try:
user = _get_user_from_ws(websocket)
except HTTPException:
await websocket.close(code=1008)
return
ws_connections.setdefault(game_id, {})[websocket] = user.username
try:
response = await clients["game"].get(f"/games/{game_id}")
if response.status_code == 200:
await websocket.send_json({"type": "state", "data": _mask_state_for_user(response.json(), user.username)})
while True:
await websocket.receive_text()
except WebSocketDisconnect:
ws_connections.get(game_id, {}).pop(websocket, None)

13
services/api/models.py Normal file
View File

@@ -0,0 +1,13 @@
from __future__ import annotations
import datetime as dt
from typing import Optional
from sqlmodel import Field, SQLModel
class User(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
username: str = Field(index=True, unique=True)
password_hash: str
created_at: dt.datetime = Field(default_factory=lambda: dt.datetime.now(dt.timezone.utc))

View File

@@ -0,0 +1,8 @@
fastapi>=0.115
uvicorn[standard]>=0.30
httpx>=0.27
sqlmodel>=0.0.16
passlib[bcrypt]>=1.7
PyJWT>=2.9
pydantic-settings>=2.2
psycopg[binary]>=3.1

View File

@@ -0,0 +1 @@
"""Shared utilities for Catan services."""

47
services/common/auth.py Normal file
View File

@@ -0,0 +1,47 @@
from __future__ import annotations
import datetime as dt
from typing import Optional
import jwt
from passlib.context import CryptContext
from .settings import settings
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(password: str, password_hash: str) -> bool:
return pwd_context.verify(password, password_hash)
def create_token(user_id: int, username: str) -> str:
now = dt.datetime.now(dt.timezone.utc)
payload = {
"sub": str(user_id),
"username": username,
"exp": now + dt.timedelta(hours=settings.jwt_exp_hours),
}
return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
def decode_token(token: str) -> dict:
return jwt.decode(token, settings.jwt_secret, algorithms=[settings.jwt_algorithm])
def get_token_subject(token: str) -> Optional[int]:
try:
payload = decode_token(token)
except jwt.PyJWTError:
return None
sub = payload.get("sub")
if not sub:
return None
try:
return int(sub)
except ValueError:
return None

24
services/common/db.py Normal file
View File

@@ -0,0 +1,24 @@
from __future__ import annotations
from contextlib import contextmanager
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from .settings import settings
engine = create_engine(settings.database_url, pool_pre_ping=True)
SessionLocal = sessionmaker(bind=engine, class_=Session, expire_on_commit=False)
@contextmanager
def session_scope() -> Session:
session = SessionLocal()
try:
yield session
session.commit()
except Exception:
session.rollback()
raise
finally:
session.close()

135
services/common/schemas.py Normal file
View File

@@ -0,0 +1,135 @@
from __future__ import annotations
import datetime as dt
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field
class ActionSchema(BaseModel):
type: str
payload: Dict[str, Any] = Field(default_factory=dict)
class ActionLogSchema(BaseModel):
idx: int
ts: dt.datetime
actor: str
action: ActionSchema
applied: bool = True
meta: Dict[str, Any] = Field(default_factory=dict)
class TradeOfferSchema(BaseModel):
id: str
from_player: str
to_player: Optional[str] = None
offer: Dict[str, int]
request: Dict[str, int]
status: str
created_at: dt.datetime
class GameSlotSchema(BaseModel):
slot_id: int
name: Optional[str] = None
user_id: Optional[int] = None
is_ai: bool = False
ai_kind: Optional[str] = None
ai_model: Optional[str] = None
ready: bool = False
color: Optional[str] = None
class GameSummarySchema(BaseModel):
id: str
name: str
status: str
max_players: int
created_by: str
created_at: dt.datetime
players: List[GameSlotSchema]
class GameStateSchema(BaseModel):
id: str
name: str
status: str
max_players: int
created_by: str
created_at: dt.datetime
players: List[GameSlotSchema]
game: Optional[Dict[str, Any]] = None
board: Optional[Dict[str, Any]] = None
legal_actions: List[ActionSchema] = Field(default_factory=list)
pending_trades: List[TradeOfferSchema] = Field(default_factory=list)
history: List[ActionLogSchema] = Field(default_factory=list)
class CreateGameRequest(BaseModel):
name: str
max_players: int
created_by: Optional[str] = None
class JoinGameRequest(BaseModel):
username: str
user_id: int
class AddAIRequest(BaseModel):
ai_type: str
model_name: Optional[str] = None
class TradeOfferRequest(BaseModel):
from_player: str
to_player: Optional[str] = None
offer: Dict[str, int]
request: Dict[str, int]
class TradeRespondRequest(BaseModel):
player: str
accept: bool
class ActionRequest(BaseModel):
actor: str
action: ActionSchema
class AIRequest(BaseModel):
observation: Dict[str, Any]
legal_actions: List[ActionSchema]
agent: Dict[str, Any]
debug: bool = False
class AIResponse(BaseModel):
action: ActionSchema
debug: Dict[str, Any] = Field(default_factory=dict)
class ReplayMeta(BaseModel):
id: str
created_at: dt.datetime
players: List[str]
winner: Optional[str] = None
total_actions: int
class ReplayDetail(ReplayMeta):
seed: int
actions: List[ActionLogSchema]
class ReplayArchive(BaseModel):
version: int = 1
id: str
created_at: dt.datetime
seed: int
players: List[str]
winner: Optional[str] = None
slots: List[GameSlotSchema]
actions: List[ActionLogSchema]

View File

@@ -0,0 +1,29 @@
from __future__ import annotations
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="CATAN_", env_file=".env", extra="ignore")
env: str = "dev"
debug: bool = False
database_url: str = "postgresql+psycopg://catan:catan@db:5432/catan"
jwt_secret: str = "change-me"
jwt_algorithm: str = "HS256"
jwt_exp_hours: int = 24 * 7
api_service_url: str = "http://api:8000"
game_service_url: str = "http://game:8001"
ai_service_url: str = "http://ai:8002"
analytics_service_url: str = "http://analytics:8003"
models_dir: str = "/models"
replay_dir: str = "/replays"
cors_origins: str = "*"
settings = Settings()

View File

478
services/game/app.py Normal file
View File

@@ -0,0 +1,478 @@
from __future__ import annotations
import datetime as dt
import random
from typing import Any, Dict, List, Optional
import httpx
from fastapi import Depends, FastAPI, HTTPException
from sqlmodel import SQLModel
from catan.data import Resource
from catan.game import GameConfig
from catan.sdk import Action, ActionType, CatanEnv
from services.common.db import engine
from services.common.schemas import (
ActionRequest,
ActionSchema,
AddAIRequest,
CreateGameRequest,
GameStateSchema,
GameSummarySchema,
JoinGameRequest,
TradeOfferRequest,
TradeOfferSchema,
TradeRespondRequest,
)
from services.common.settings import settings
from services.game.models import Game, GameEvent, TradeOffer
from services.game.runtime import manager
app = FastAPI(title="Catan Game Service")
@app.on_event("startup")
def _startup() -> None:
SQLModel.metadata.create_all(engine)
def _serialize_resources(resources: Dict[Resource, int]) -> Dict[str, int]:
return {res.value if isinstance(res, Resource) else str(res): int(val) for res, val in resources.items()}
def _serialize_player(player: Dict[str, Any]) -> Dict[str, Any]:
data = dict(player)
resources = data.get("resources", {})
if resources:
data["resources"] = _serialize_resources(resources)
return data
def _serialize_game_observation(observation: Dict[str, Any]) -> Dict[str, Any]:
game = dict(observation["game"])
players = {name: _serialize_player(info) for name, info in game["players"].items()}
game["players"] = players
bank = game.get("bank", {})
if bank:
game["bank"] = _serialize_resources(bank)
return {"game": game, "board": observation["board"]}
def _serialize_action(action: Action) -> ActionSchema:
return ActionSchema(type=action.type.value, payload=action.payload)
def _serialize_legal_actions(actions: List[Action]) -> List[ActionSchema]:
return [_serialize_action(action) for action in actions]
def _slots_to_schema(game: Game) -> List[Dict[str, Any]]:
return game.slots.get("slots", [])
def _to_game_summary(game: Game) -> GameSummarySchema:
return GameSummarySchema(
id=game.id,
name=game.name,
status=game.status,
max_players=game.max_players,
created_by=game.created_by,
created_at=game.created_at,
players=[slot for slot in _slots_to_schema(game)],
)
def _trade_to_schema(trade: TradeOffer) -> TradeOfferSchema:
return TradeOfferSchema(
id=trade.id,
from_player=trade.from_player,
to_player=trade.to_player,
offer=trade.offer,
request=trade.request,
status=trade.status,
created_at=trade.created_at,
)
def _build_state(game: Game, runtime) -> GameStateSchema:
if game.status != "running" or runtime is None:
return GameStateSchema(
id=game.id,
name=game.name,
status=game.status,
max_players=game.max_players,
created_by=game.created_by,
created_at=game.created_at,
players=[slot for slot in _slots_to_schema(game)],
)
obs = _serialize_game_observation(runtime.env.observe())
legal_actions = _serialize_legal_actions(runtime.env.legal_actions())
trades = manager.list_trade_offers(game.id, status="open")
history = manager.list_events(game.id)
return GameStateSchema(
id=game.id,
name=game.name,
status=game.status,
max_players=game.max_players,
created_by=game.created_by,
created_at=game.created_at,
players=[slot for slot in _slots_to_schema(game)],
game=obs["game"],
board=obs["board"],
legal_actions=legal_actions,
pending_trades=[_trade_to_schema(trade) for trade in trades],
history=[
{
"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 history
],
)
def _ensure_game(game_id: str) -> Game:
try:
return manager.get(game_id).game
except KeyError:
raise HTTPException(status_code=404, detail="Game not found")
def _get_runtime(game_id: str):
try:
return manager.get(game_id)
except KeyError:
raise HTTPException(status_code=404, detail="Game not found")
def _find_slot(game: Game, predicate) -> Optional[Dict[str, Any]]:
for slot in game.slots.get("slots", []):
if predicate(slot):
return slot
return None
def _ai_slots(game: Game) -> Dict[str, Dict[str, Any]]:
return {
slot["name"]: slot
for slot in game.slots.get("slots", [])
if slot.get("is_ai") and slot.get("name")
}
def _ai_trade_decision(resources: Dict[str, int], offer: Dict[str, int], request: Dict[str, int]) -> bool:
if any(resources.get(res, 0) < amount for res, amount in request.items()):
return False
offer_value = sum(offer.values())
request_value = sum(request.values())
if offer_value >= request_value:
return True
return random.random() < 0.35
async def _request_ai_action(agent: Dict[str, Any], observation: Dict[str, Any], legal_actions: List[ActionSchema]) -> Dict[str, Any]:
payload = {
"observation": observation,
"legal_actions": [action.model_dump() for action in legal_actions],
"agent": agent,
"debug": settings.debug,
}
async with httpx.AsyncClient(timeout=30.0) as client:
resp = await client.post(f"{settings.ai_service_url}/act", json=payload)
resp.raise_for_status()
return resp.json()
async def _run_ai_turns(runtime) -> None:
game = runtime.game
if game.status != "running":
return
ai_slots = _ai_slots(game)
safety = 0
while safety < 200:
safety += 1
current = runtime.env.game.current_player.name
open_trades = manager.list_trade_offers(game.id, status="open")
for trade in open_trades:
if trade.from_player != current:
continue
target = trade.to_player
if target is None:
candidates = [name for name in ai_slots if name != trade.from_player]
target = candidates[0] if candidates else None
if target and target in ai_slots:
resources = runtime.env.game.player_by_name(target).resources
resources_map = {res.value: count for res, count in resources.items()}
accept = _ai_trade_decision(resources_map, trade.offer, trade.request)
trade.status = "accepted" if accept else "declined"
manager.update_trade_offer(trade)
manager.record_event(
game.id,
target,
Action(ActionType.TRADE_PLAYER if accept else ActionType.END_TURN, {
"trade_id": trade.id,
"accept": accept,
}),
applied=False,
)
if accept:
action = Action(
ActionType.TRADE_PLAYER,
{"target": trade.from_player, "offer": trade.offer, "request": trade.request},
)
_, _, _, info = runtime.env.step(action)
manager.record_event(game.id, trade.from_player, action, applied=True)
break
if current not in ai_slots:
break
slot = ai_slots[current]
observation = _serialize_game_observation(runtime.env.observe())
legal_actions = _serialize_legal_actions(runtime.env.legal_actions())
agent_cfg = {
"kind": slot.get("ai_kind", "random"),
"model": slot.get("ai_model"),
"stochastic": True,
}
response = await _request_ai_action(agent_cfg, observation, legal_actions)
action_data = response.get("action")
debug = response.get("debug") or {}
if settings.debug:
debug = {
**debug,
"observation": observation,
"legal_actions": [action.model_dump() for action in legal_actions],
}
action = Action(ActionType(action_data["type"]), action_data.get("payload") or {})
_, _, done, info = runtime.env.step(action)
manager.record_event(game.id, current, action, applied=True, debug=debug)
if info.get("invalid"):
break
if action.type == ActionType.END_TURN:
_expire_trades(game.id)
if done:
game.status = "finished"
game.winner = runtime.env.game.winner
manager.save_game(game)
break
def _expire_trades(game_id: str) -> None:
offers = manager.list_trade_offers(game_id, status="open")
for offer in offers:
offer.status = "expired"
manager.update_trade_offer(offer)
@app.get("/health")
def health() -> Dict[str, str]:
return {"status": "ok"}
@app.get("/games")
def list_games() -> Dict[str, Any]:
games = manager.list_games()
return {"games": [_to_game_summary(game).model_dump() for game in games]}
@app.post("/games")
def create_game(payload: CreateGameRequest) -> Dict[str, Any]:
if payload.max_players < 2 or payload.max_players > 4:
raise HTTPException(status_code=400, detail="max_players must be 2-4")
game = manager.create_game(payload.name, payload.max_players, created_by=payload.created_by or "host")
return _to_game_summary(game).model_dump()
@app.post("/games/{game_id}/join")
def join_game(game_id: str, payload: JoinGameRequest) -> Dict[str, Any]:
runtime = _get_runtime(game_id)
game = runtime.game
slot = _find_slot(game, lambda s: s.get("user_id") == payload.user_id)
if slot:
return _to_game_summary(game).model_dump()
open_slot = _find_slot(game, lambda s: s.get("name") is None)
if not open_slot:
raise HTTPException(status_code=400, detail="No available slots")
open_slot.update({
"name": payload.username,
"user_id": payload.user_id,
"ready": True,
})
manager.save_game(game)
return _to_game_summary(game).model_dump()
@app.post("/games/{game_id}/leave")
def leave_game(game_id: str, payload: JoinGameRequest) -> Dict[str, Any]:
runtime = _get_runtime(game_id)
game = runtime.game
slot = _find_slot(game, lambda s: s.get("user_id") == payload.user_id)
if not slot:
return _to_game_summary(game).model_dump()
slot.update({
"name": None,
"user_id": None,
"ready": False,
"is_ai": False,
"ai_kind": None,
"ai_model": None,
"color": None,
})
manager.save_game(game)
return _to_game_summary(game).model_dump()
@app.post("/games/{game_id}/add_ai")
def add_ai(game_id: str, payload: AddAIRequest) -> Dict[str, Any]:
runtime = _get_runtime(game_id)
game = runtime.game
open_slot = _find_slot(game, lambda s: s.get("name") is None)
if not open_slot:
raise HTTPException(status_code=400, detail="No available slots")
ai_type = payload.ai_type.lower()
if ai_type not in {"random", "model"}:
raise HTTPException(status_code=400, detail="Unknown AI type")
name_base = "AI" if ai_type == "random" else "Model"
existing = {slot.get("name") for slot in game.slots.get("slots", []) if slot.get("name")}
suffix = 1
name = f"{name_base}-{suffix}"
while name in existing:
suffix += 1
name = f"{name_base}-{suffix}"
open_slot.update({
"name": name,
"is_ai": True,
"ai_kind": ai_type,
"ai_model": payload.model_name,
"ready": True,
})
manager.save_game(game)
return _to_game_summary(game).model_dump()
@app.post("/games/{game_id}/start")
async def start_game(game_id: str) -> Dict[str, Any]:
runtime = _get_runtime(game_id)
game = runtime.game
if game.status != "lobby":
raise HTTPException(status_code=400, detail="Game already started")
slots = game.slots.get("slots", [])
names = [slot.get("name") for slot in slots if slot.get("name")]
if len(names) < 2:
raise HTTPException(status_code=400, detail="Not enough players")
colors = ["red", "blue", "orange", "white"]
for slot, color in zip(slots, colors):
if slot.get("name"):
slot["color"] = color
game.slots["slots"] = slots
game.status = "running"
manager.save_game(game)
runtime.env = CatanEnv(GameConfig(player_names=names, colors=colors[: len(names)], seed=game.seed))
await _run_ai_turns(runtime)
return _build_state(game, runtime).model_dump()
@app.get("/games/{game_id}")
def game_state(game_id: str) -> Dict[str, Any]:
runtime = _get_runtime(game_id)
game = runtime.game
return _build_state(game, runtime).model_dump()
@app.post("/games/{game_id}/action")
async def apply_action(game_id: str, payload: ActionRequest) -> Dict[str, Any]:
runtime = _get_runtime(game_id)
game = runtime.game
if game.status != "running":
raise HTTPException(status_code=400, detail="Game not running")
action_type = ActionType(payload.action.type)
action = Action(type=action_type, payload=payload.action.payload)
actor = payload.actor
current = runtime.env.game.current_player.name
if action.type == ActionType.DISCARD:
target = action.payload.get("player")
if target != actor:
raise HTTPException(status_code=403, detail="Discard only for self")
elif actor != current:
raise HTTPException(status_code=403, detail="Not your turn")
_, _, done, info = runtime.env.step(action)
if info.get("invalid"):
raise HTTPException(status_code=400, detail=info.get("error", "Invalid action"))
manager.record_event(game.id, actor, action, applied=True)
if action.type == ActionType.END_TURN:
_expire_trades(game.id)
if done:
game.status = "finished"
game.winner = runtime.env.game.winner
manager.save_game(game)
await _run_ai_turns(runtime)
return _build_state(game, runtime).model_dump()
@app.post("/games/{game_id}/trade/offer")
async def offer_trade(game_id: str, payload: TradeOfferRequest) -> Dict[str, Any]:
runtime = _get_runtime(game_id)
game = runtime.game
if game.status != "running":
raise HTTPException(status_code=400, detail="Game not running")
current = runtime.env.game.current_player.name
if payload.from_player != current:
raise HTTPException(status_code=403, detail="Only current player can offer trades")
if not runtime.env.game.has_rolled:
raise HTTPException(status_code=400, detail="Roll dice before trading")
trade = manager.create_trade_offer(
game.id,
payload.from_player,
payload.to_player,
payload.offer,
payload.request,
)
manager.record_event(
game.id,
payload.from_player,
Action(ActionType.TRADE_PLAYER, {"trade_id": trade.id, "offer": payload.offer, "request": payload.request}),
applied=False,
)
await _run_ai_turns(runtime)
return _trade_to_schema(trade).model_dump()
@app.post("/games/{game_id}/trade/{trade_id}/respond")
def respond_trade(game_id: str, trade_id: str, payload: TradeRespondRequest) -> Dict[str, Any]:
runtime = _get_runtime(game_id)
trade = next((t for t in manager.list_trade_offers(game_id, status="open") if t.id == trade_id), None)
if not trade:
raise HTTPException(status_code=404, detail="Trade not found")
if trade.to_player and trade.to_player != payload.player:
raise HTTPException(status_code=403, detail="Not target player")
if payload.player == trade.from_player:
raise HTTPException(status_code=400, detail="Cannot accept own trade")
trade.status = "accepted" if payload.accept else "declined"
manager.update_trade_offer(trade)
manager.record_event(
game_id,
payload.player,
Action(ActionType.TRADE_PLAYER, {"trade_id": trade.id, "accept": payload.accept}),
applied=False,
)
if payload.accept:
action = Action(ActionType.TRADE_PLAYER, {
"target": trade.from_player,
"offer": trade.offer,
"request": trade.request,
})
_, _, _, info = runtime.env.step(action)
if info.get("invalid"):
raise HTTPException(status_code=400, detail=info.get("error", "Invalid trade"))
manager.record_event(game_id, trade.from_player, action, applied=True)
return {"status": trade.status}
@app.post("/games/{game_id}/advance")
async def advance_ai(game_id: str) -> Dict[str, Any]:
runtime = _get_runtime(game_id)
await _run_ai_turns(runtime)
return _build_state(runtime.game, runtime).model_dump()

44
services/game/models.py Normal file
View File

@@ -0,0 +1,44 @@
from __future__ import annotations
import datetime as dt
from typing import Any, Dict, Optional
from sqlalchemy import Column, JSON
from sqlmodel import Field, SQLModel
class Game(SQLModel, table=True):
id: str = Field(primary_key=True)
name: str
status: str
max_players: int
created_by: str
created_at: dt.datetime
updated_at: dt.datetime
seed: int
slots: Dict[str, Any] = Field(sa_column=Column(JSON))
winner: Optional[str] = None
class GameEvent(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
game_id: str = Field(index=True)
idx: int
ts: dt.datetime
actor: str
action_type: str
payload: Dict[str, Any] = Field(sa_column=Column(JSON))
applied: bool = True
debug_payload: Optional[Dict[str, Any]] = Field(default=None, sa_column=Column(JSON))
class TradeOffer(SQLModel, table=True):
id: str = Field(primary_key=True)
game_id: str = Field(index=True)
from_player: str
to_player: Optional[str] = None
offer: Dict[str, int] = Field(sa_column=Column(JSON))
request: Dict[str, int] = Field(sa_column=Column(JSON))
status: str
created_at: dt.datetime
updated_at: dt.datetime

View File

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

177
services/game/runtime.py Normal file
View File

@@ -0,0 +1,177 @@
from __future__ import annotations
import datetime as dt
import random
import uuid
from dataclasses import dataclass
from typing import Dict, List, Optional
from sqlmodel import select
from catan.game import GameConfig
from catan.sdk import Action, ActionType, CatanEnv
from services.common.db import session_scope
from services.game.models import Game, GameEvent, TradeOffer
@dataclass
class GameRuntime:
game: Game
env: CatanEnv
action_index: int = 0
def next_action_index(self) -> int:
self.action_index += 1
return self.action_index
class GameRuntimeManager:
def __init__(self) -> None:
self._cache: Dict[str, GameRuntime] = {}
def get(self, game_id: str) -> GameRuntime:
if game_id in self._cache:
return self._cache[game_id]
runtime = self._load_runtime(game_id)
self._cache[game_id] = runtime
return runtime
def drop(self, game_id: str) -> None:
self._cache.pop(game_id, None)
def _load_runtime(self, game_id: str) -> GameRuntime:
with session_scope() as session:
game = session.get(Game, game_id)
if not game:
raise KeyError(game_id)
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")]
config = GameConfig(player_names=names, colors=colors, seed=game.seed)
env = CatanEnv(config)
events = session.exec(
select(GameEvent).where(GameEvent.game_id == game_id, GameEvent.applied == True).order_by(GameEvent.idx)
).all()
for event in events:
action = Action(ActionType(event.action_type), event.payload)
env.step(action)
action_index = events[-1].idx if events else 0
return GameRuntime(game=game, env=env, action_index=action_index)
def create_game(self, name: str, max_players: int, created_by: str) -> Game:
now = dt.datetime.now(dt.timezone.utc)
game_id = str(uuid.uuid4())
seed = random.randint(0, 2**31 - 1)
slots = {
"slots": [
{
"slot_id": idx + 1,
"name": None,
"user_id": None,
"is_ai": False,
"ai_kind": None,
"ai_model": None,
"ready": False,
"color": None,
}
for idx in range(max_players)
]
}
game = Game(
id=game_id,
name=name,
status="lobby",
max_players=max_players,
created_by=created_by,
created_at=now,
updated_at=now,
seed=seed,
slots=slots,
)
with session_scope() as session:
session.add(game)
return game
def save_game(self, game: Game) -> None:
game.updated_at = dt.datetime.now(dt.timezone.utc)
with session_scope() as session:
session.merge(game)
def list_games(self) -> List[Game]:
with session_scope() as session:
return session.exec(select(Game).order_by(Game.created_at.desc())).all()
def record_event(
self,
game_id: str,
actor: str,
action: Action,
applied: bool = True,
debug: Optional[dict] = None,
) -> GameEvent:
with session_scope() as session:
last_idx = session.exec(
select(GameEvent.idx)
.where(GameEvent.game_id == game_id)
.order_by(GameEvent.idx.desc())
.limit(1)
).first()
idx = (last_idx or 0) + 1
event = GameEvent(
game_id=game_id,
idx=idx,
ts=dt.datetime.now(dt.timezone.utc),
actor=actor,
action_type=action.type.value,
payload=action.payload,
applied=applied,
debug_payload=debug,
)
session.add(event)
return event
def list_events(self, 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 create_trade_offer(
self,
game_id: str,
from_player: str,
to_player: Optional[str],
offer: Dict[str, int],
request: Dict[str, int],
) -> TradeOffer:
now = dt.datetime.now(dt.timezone.utc)
offer_id = str(uuid.uuid4())
trade = TradeOffer(
id=offer_id,
game_id=game_id,
from_player=from_player,
to_player=to_player,
offer=offer,
request=request,
status="open",
created_at=now,
updated_at=now,
)
with session_scope() as session:
session.add(trade)
return trade
def update_trade_offer(self, trade: TradeOffer) -> None:
trade.updated_at = dt.datetime.now(dt.timezone.utc)
with session_scope() as session:
session.merge(trade)
def list_trade_offers(self, game_id: str, status: Optional[str] = None) -> List[TradeOffer]:
with session_scope() as session:
query = select(TradeOffer).where(TradeOffer.game_id == game_id)
if status:
query = query.where(TradeOffer.status == status)
return session.exec(query.order_by(TradeOffer.created_at)).all()
manager = GameRuntimeManager()