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:
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()
|
||||
Reference in New Issue
Block a user