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

1
tests/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Test utilities package for Catan project."""

39
tests/conftest.py Normal file
View File

@@ -0,0 +1,39 @@
from __future__ import annotations
from typing import Generator
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from sqlmodel import SQLModel
@pytest.fixture(scope="session")
def sqlite_url(tmp_path_factory: pytest.TempPathFactory) -> str:
db_path = tmp_path_factory.mktemp("db") / "test.db"
return f"sqlite:///{db_path}"
@pytest.fixture(autouse=True)
def sqlite_db(sqlite_url: str) -> Generator[None, None, None]:
engine = create_engine(sqlite_url, connect_args={"check_same_thread": False})
from services.common import db as common_db
common_db.engine = engine
common_db.SessionLocal = sessionmaker(bind=engine, class_=Session, expire_on_commit=False)
import services.api.app as api_app
import services.game.app as game_app
import services.analytics.app as analytics_app
import services.api.models # noqa: F401
import services.game.models # noqa: F401
api_app.engine = engine
api_app.SessionLocal = common_db.SessionLocal
game_app.engine = engine
analytics_app.engine = engine
SQLModel.metadata.drop_all(engine)
SQLModel.metadata.create_all(engine)
yield
SQLModel.metadata.drop_all(engine)

View File

@@ -0,0 +1,72 @@
from __future__ import annotations
import asyncio
import httpx
from fastapi import FastAPI
from fastapi.testclient import TestClient
from services.api import app as api_app
from services.game import app as game_app
from services.analytics import app as analytics_app
def _install_asgi_clients() -> list[httpx.AsyncClient]:
created: list[httpx.AsyncClient] = []
def make_client(app: FastAPI, base_url: str) -> httpx.AsyncClient:
client = httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url=base_url)
created.append(client)
return client
api_app.clients["game"] = make_client(game_app.app, "http://game")
api_app.clients["analytics"] = make_client(analytics_app.app, "http://analytics")
dummy_ai = FastAPI()
@dummy_ai.get("/models")
def _models():
return {"models": []}
@dummy_ai.post("/act")
def _act():
return {"action": {"type": "end_turn", "payload": {}}, "debug": {}}
api_app.clients["ai"] = make_client(dummy_ai, "http://ai")
return created
def _close_clients(clients: list[httpx.AsyncClient]) -> None:
for client in clients:
try:
asyncio.get_event_loop().run_until_complete(client.aclose())
except RuntimeError:
asyncio.run(client.aclose())
def test_api_game_flow() -> None:
with TestClient(api_app.app) as client:
created = _install_asgi_clients()
response = client.post("/api/auth/register", json={"username": "eve", "password": "secret"})
assert response.status_code == 200
token = response.json()["token"]
headers = {"Authorization": f"Bearer {token}"}
response = client.post("/api/games", json={"name": "Arena", "max_players": 2}, headers=headers)
assert response.status_code == 200
game_id = response.json()["id"]
response = client.post(f"/api/games/{game_id}/join", headers=headers)
assert response.status_code == 200
response = client.post(
f"/api/games/{game_id}/add-ai",
json={"ai_type": "random"},
headers=headers,
)
assert response.status_code == 200
response = client.post(f"/api/games/{game_id}/start", headers=headers)
assert response.status_code == 200
assert response.json()["status"] == "running"
_close_clients(created)

52
tests/test_cli.py Normal file
View File

@@ -0,0 +1,52 @@
from __future__ import annotations
import pytest
from catan.cli import CLIController
from catan.game import Game, GameConfig, Phase
from catan.data import Resource
from tests.utils import find_initial_spot, find_connected_edge, grant_resources
def make_controller(seed: int = 7) -> CLIController:
game = Game(GameConfig(player_names=["Alice", "Bob"], seed=seed))
controller = CLIController(game)
_complete_setup(controller)
return controller
def _complete_setup(controller: CLIController) -> None:
game = controller.game
while game.phase != Phase.MAIN:
corner, road = find_initial_spot(game)
controller.handle_command(f"place {corner} {road}")
def test_cli_blocks_building_before_roll() -> None:
controller = make_controller()
grant_resources(controller.game, controller.game.current_player.name)
edge_label = find_connected_edge(controller.game)
with pytest.raises(ValueError):
controller.handle_command(f"build road {edge_label}")
def test_cli_allows_build_after_roll_and_cost_paid() -> None:
controller = make_controller()
player_name = controller.game.current_player.name
grant_resources(controller.game, player_name)
edge_label = find_connected_edge(controller.game)
before = len(controller.game.player_by_name(player_name).roads)
controller.handle_command("roll")
controller.handle_command(f"build road {edge_label}")
player = controller.game.player_by_name(player_name)
assert len(player.roads) == before + 1
# Ensure resources spent
assert player.resources[Resource.BRICK] == 4
assert player.resources[Resource.LUMBER] == 4
def test_cli_prevents_double_roll() -> None:
controller = make_controller()
controller.handle_command("roll")
with pytest.raises(ValueError):
controller.handle_command("roll")

34
tests/test_cli_bot.py Normal file
View File

@@ -0,0 +1,34 @@
from __future__ import annotations
from catan.cli import CLIController
from catan.game import Game, GameConfig, Phase
from catan.ml.agents import RandomAgent
from tests.utils import find_initial_spot
def test_human_vs_random_bot_session() -> None:
controller = CLIController(
Game(GameConfig(player_names=["Human", "Bot"], seed=9)),
bots={"Bot": RandomAgent(seed=3)},
)
# Complete setup: human manually places, bot uses random agent.
while controller.game.phase != Phase.MAIN:
current = controller.game.current_player.name
if current == "Human":
corner, road = find_initial_spot(controller.game)
controller.handle_command(f"place {corner} {road}")
else:
controller._run_bot_turn(current) # noqa: SLF001 - exercising CLI internals
# Play a few turns: human rolls and ends, bot plays automatically.
for _ in range(3):
assert controller.game.current_player.name == "Human"
controller.handle_command("roll")
controller.handle_command("end")
assert controller.game.current_player.name == "Bot"
controller._run_bot_turn("Bot") # noqa: SLF001
# Ensure bot activity is recorded in history.
assert any(log.player == "Bot" for log in controller.game.history)

80
tests/test_env_actions.py Normal file
View File

@@ -0,0 +1,80 @@
from __future__ import annotations
from catan.data import Resource
from catan.game import GameConfig, Phase
from catan.sdk import ActionType, CatanEnv
from tests.utils import find_initial_spot
def _auto_setup(env: CatanEnv) -> None:
game = env.game
while game.phase != Phase.MAIN:
corner, road = find_initial_spot(game)
game.place_initial_settlement(corner, road)
def test_bank_trade_action_and_port_ratio() -> None:
env = CatanEnv(GameConfig(player_names=["Alice", "Bob"], seed=5))
_auto_setup(env)
game = env.game
game.phase = Phase.MAIN
game.has_rolled = True
alice = game.players[0]
board = game.board
# Remove any accidental port bonuses from initial placements.
for corner_id in list(alice.settlements):
board.corners[corner_id].port = None
# Base bank trade (4:1)
alice.resources[Resource.BRICK] = 4
actions = env.legal_actions()
bank_actions = [
action for action in actions if action.type == ActionType.TRADE_BANK
]
base_ratios = [
action.payload["ratio"]
for action in bank_actions
if action.payload["give"] == "brick"
]
assert base_ratios and min(base_ratios) == 4
# Attach Alice to a brick port for 2:1 ratio.
port_corner = next(
corner_id
for corner_id, corner in board.corners.items()
if corner.port and corner.port.resource == Resource.BRICK
)
board.corners[port_corner].owner = alice.name
board.corners[port_corner].building = "settlement"
alice.settlements.add(port_corner)
alice.resources[Resource.BRICK] = 2
actions = env.legal_actions()
bank_actions = [
action for action in actions if action.type == ActionType.TRADE_BANK
]
assert any(action.payload["give"] == "brick" and action.payload["ratio"] == 2 for action in bank_actions)
def test_player_trade_actions_available() -> None:
env = CatanEnv(GameConfig(player_names=["Alice", "Bob"], seed=11))
_auto_setup(env)
game = env.game
game.phase = Phase.MAIN
game.has_rolled = True
alice, bob = game.players
alice.resources[Resource.WOOL] = 1
bob.resources[Resource.ORE] = 1
actions = env.legal_actions()
trade_actions = [
action for action in actions if action.type == ActionType.TRADE_PLAYER
]
assert trade_actions, "Expected player trade actions"
assert any(
action.payload["target"] == "Bob"
and action.payload["offer"] == {"wool": 1}
and action.payload["request"] == {"ore": 1}
for action in trade_actions
)

View File

@@ -0,0 +1,24 @@
from __future__ import annotations
from fastapi.testclient import TestClient
from services.api.app import app
def test_register_and_login() -> None:
with TestClient(app) as client:
response = client.post("/api/auth/register", json={"username": "alice", "password": "secret"})
assert response.status_code == 200
token = response.json().get("token")
assert token
response = client.post("/api/auth/login", json={"username": "alice", "password": "secret"})
assert response.status_code == 200
def test_register_duplicate() -> None:
with TestClient(app) as client:
response = client.post("/api/auth/register", json={"username": "bob", "password": "secret"})
assert response.status_code == 200
response = client.post("/api/auth/register", json={"username": "bob", "password": "secret"})
assert response.status_code == 400

17
tests/unit/test_auth.py Normal file
View File

@@ -0,0 +1,17 @@
from __future__ import annotations
from services.common.auth import create_token, decode_token, hash_password, verify_password
def test_password_hash_roundtrip() -> None:
secret = "dragon"
hashed = hash_password(secret)
assert verify_password(secret, hashed)
assert not verify_password("wrong", hashed)
def test_token_roundtrip() -> None:
token = create_token(42, "alice")
payload = decode_token(token)
assert payload["sub"] == "42"
assert payload["username"] == "alice"

38
tests/utils.py Normal file
View File

@@ -0,0 +1,38 @@
from __future__ import annotations
from typing import Tuple
from catan.data import Resource
from catan.game import Game
def find_initial_spot(game: Game) -> Tuple[int, int]:
board = game.board
for corner_id in sorted(board.corners, key=board.corner_label):
if not board.can_place_settlement(corner_id, True):
continue
edges = sorted(board.corners[corner_id].edges, key=board.edge_label)
for edge_id in edges:
if board.edges[edge_id].owner:
continue
return board.corner_label(corner_id), board.edge_label(edge_id)
raise RuntimeError("No available initial placement")
def find_connected_edge(game: Game) -> int:
player = game.current_player
board = game.board
for edge_id, edge in board.edges.items():
if edge.owner:
continue
if game._road_connection_valid(player, edge): # noqa: SLF001 - test helper
return board.edge_label(edge_id)
raise RuntimeError("No valid edge to extend road network")
def grant_resources(game: Game, player_name: str, amount: int = 5) -> None:
player = game.player_by_name(player_name)
for resource in Resource:
if resource == Resource.DESERT:
continue
player.resources[resource] = amount