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/ai/__init__.py
Normal file
0
services/ai/__init__.py
Normal file
185
services/ai/app.py
Normal file
185
services/ai/app.py
Normal 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()
|
||||
4
services/ai/requirements.txt
Normal file
4
services/ai/requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
fastapi>=0.115
|
||||
uvicorn[standard]>=0.30
|
||||
numpy>=1.26
|
||||
pydantic-settings>=2.2
|
||||
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
|
||||
0
services/api/__init__.py
Normal file
0
services/api/__init__.py
Normal file
360
services/api/app.py
Normal file
360
services/api/app.py
Normal 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
13
services/api/models.py
Normal 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))
|
||||
8
services/api/requirements.txt
Normal file
8
services/api/requirements.txt
Normal 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
|
||||
1
services/common/__init__.py
Normal file
1
services/common/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Shared utilities for Catan services."""
|
||||
47
services/common/auth.py
Normal file
47
services/common/auth.py
Normal 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
24
services/common/db.py
Normal 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
135
services/common/schemas.py
Normal 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]
|
||||
29
services/common/settings.py
Normal file
29
services/common/settings.py
Normal 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()
|
||||
0
services/game/__init__.py
Normal file
0
services/game/__init__.py
Normal file
478
services/game/app.py
Normal file
478
services/game/app.py
Normal 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
44
services/game/models.py
Normal 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
|
||||
6
services/game/requirements.txt
Normal file
6
services/game/requirements.txt
Normal 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
177
services/game/runtime.py
Normal 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()
|
||||
Reference in New Issue
Block a user