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

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)