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

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

View File

@@ -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()