commit 46a07f548b44724cbf878a9c45dd87a07fc2da01 Author: dan Date: Thu Dec 25 03:28:40 2025 +0300 Add microservices, web UI, and replay tooling diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2d5f243 --- /dev/null +++ b/.env.example @@ -0,0 +1,13 @@ +CATAN_ENV=dev +CATAN_DEBUG=true +CATAN_DATABASE_URL=postgresql+psycopg://catan:catan@db:5432/catan +CATAN_JWT_SECRET=change-me +CATAN_JWT_ALGORITHM=HS256 +CATAN_JWT_EXP_HOURS=168 +CATAN_API_SERVICE_URL=http://api:8000 +CATAN_GAME_SERVICE_URL=http://game:8001 +CATAN_AI_SERVICE_URL=http://ai:8002 +CATAN_ANALYTICS_SERVICE_URL=http://analytics:8003 +CATAN_MODELS_DIR=/models +CATAN_REPLAY_DIR=/replays +CATAN_CORS_ORIGINS=* diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..68c110c --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,27 @@ +name: ci + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + tests: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Install deps + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + - name: Run tests + run: | + pytest diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..97a5892 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +.env +.venv/ +.idea/ +.pytest_cache/ +__pycache__/ +*.pyc +*.pyo +*.egg-info/ +dist/ +build/ +node_modules/ +web/dist/ +models/ diff --git a/Dockerfile.web b/Dockerfile.web new file mode 100644 index 0000000..5510e18 --- /dev/null +++ b/Dockerfile.web @@ -0,0 +1,20 @@ +FROM python:3.11-slim + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +WORKDIR /app + +COPY pyproject.toml README.md /app/ +COPY catan /app/catan + +RUN pip install --no-cache-dir --upgrade pip \ + && pip install --no-cache-dir --index-url https://download.pytorch.org/whl/cpu torch \ + && pip install --no-cache-dir -e . + +ENV CATAN_DATA_DIR=/app/data +ENV CATAN_MODELS_DIR=/app/models + +EXPOSE 8000 + +CMD ["uvicorn", "catan.web.app:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..fabfbdd --- /dev/null +++ b/README.md @@ -0,0 +1,140 @@ +# Settlers of Catan in Python + +Полная реализация «Колонизаторов» (Catan) на Python: ядро движка со всеми правилами базовой версии, SDK для обучения моделей, CLI для аналитики и GUI для ручной игры. + +## Возможности + +- Полный набор правил: генерация поля, стартовые постройки в два раунда, добыча ресурсов, разбойник, торговля, развитие, карты развития, «Самая длинная дорога» и «Самая большая армия». +- Модели гекс/вершин/ребер в виде графа с проверками расстояния и связности. +- SDK (`catan.sdk.CatanEnv`) c перечислением допустимых действий и API, похожим на gym. +- CLI на Typer + Rich для пошаговых партий и дебага. +- GUI на Tkinter с визуализацией гексов, дорог и построек. +- ML-модуль с кодировщиками состояний/действий, случайным агентом, REINFORCE-тренером (PyTorch) и эволюционной стратегией. + +## Установка + +```bash +python -m venv .venv +source .venv/bin/activate +pip install -e . +``` + +## CLI + +Запуск новой партии для трёх игроков: + +```bash +catan-cli "Alice,Bob,Carla" +``` + +Основные команды в интерактивном режиме: + +- `state`, `board`, `corners`, `edges` — вывод состояния. +- `actions` — список допустимых действий из текущего состояния (на базе SDK). +- `roll`, `place `, `build settlement|city|road ` — действия на поле. +- `buy dev`, `play knight|road|yop|monopoly`, `trade bank ...`, `trade player ...`. +- `move robber [victim]`, `discard ...`, `end` для завершения хода. +- `--random-bot <имя>` — назначает игрока случайному агенту (можно указать несколько флагов) для режимов «человек против случайной модели». + +Пример игры против случайного соперника: + +```bash +catan-cli "You,Bot" --random-bot Bot +``` + +## GUI + +``` +catan-gui --players "Alice,Bob,Carla" --seed 42 +``` + +На панели справа расположены кнопки для всех правил: бросок кубиков, строительство, обмены, карты развития, перемещение разбойника и т.д. При необходимости вводов появляются диалоговые окна. Внизу лог событий, на канвасе отображаются гексы, дороги и постройки. + +## SDK / Использование в обучении + +```python +from catan import GameConfig +from catan.sdk import CatanEnv, Action, ActionType + +config = GameConfig(player_names=["Alice", "Bob", "Carla"], seed=123) +env = CatanEnv(config) +obs = env.reset() + +for step in range(10): + legal = env.legal_actions() + action = next(a for a in legal if a.type == ActionType.ROLL) + obs, reward, done, info = env.step(action) + if done: + break +``` + +`legal_actions()` возвращает список `Action`, содержащих тип действия и полезные параметры (например, доступные гексы для разбойника или список ребер для дороги). `env.step()` применяет действие к текущему `Game`, возвращает новое наблюдение, награду (дельта очков) и флаг завершения партии. + +## Структура проекта + +- `catan/data.py` — перечисления и константы (ресурсы, карты, порты, стоимости). +- `catan/board.py` — генерация поля, гексов, перекрестков и портов. +- `catan/player.py` — модель игрока и операции с ресурсами. +- `catan/game.py` — движок игры, правила, учет победных очков. +- `catan/sdk.py` — окружение для обучения / автоматизации. +- `catan/cli.py` и `catan/gui.py` — интерфейсы для людей (CLI поддерживает ботов). +- `catan/ml/` — агенты, кодировщики, обучающие циклы RL/эволюции. +- `catan/learning.py` — CLI-утилиты для запуска reinforcement learning (REINFORCE). + +## Reinforcement Learning + +```bash +catan-learn reinforce --players "Alpha,Beta,Gamma,Delta" --episodes 100 --output policy.pt +``` + +По окончании обучения выводится история наград; при указании `--output` сохраняется `state_dict` PyTorch. + +## Web платформа (микросервисы) + +Монорепозиторий содержит backend-сервисы и веб-интерфейс: + +- `services/api` — gateway + auth + websockets. +- `services/game` — игровой сервер (правила + состояния + трейды). +- `services/ai` — инференс моделей (random / .pt). +- `services/analytics` — статистика, реплеи, экспорт/импорт. +- `web` — React-интерфейс (лобби, партии, реплеи). + +### Запуск в Docker + +```bash +cp .env.example .env +docker compose up --build +``` + +По умолчанию UI доступен на `http://localhost` (если проксировать через Caddy), а API — на `/api`. + +### Caddy (пример) + +``` +catan.danosito.com { + reverse_proxy /api/* api:8000 + reverse_proxy /ws/* api:8000 + reverse_proxy web:80 +} +``` + +### Реплеи + +- Экспорт: `/api/replays/{id}/export` (JSON). +- Импорт: `/api/replays/import` (JSON). +- В UI доступны импорт/экспорт и пошаговый просмотр. + +## Тестирование + +Минимальная проверка: + +```bash +python -m catan.cli "Alice,Bob,Carla" +python -m catan.gui --players "Alice,Bob,Carla" +``` + +Авто-тесты: + +```bash +pytest +``` diff --git a/catan/__init__.py b/catan/__init__.py new file mode 100644 index 0000000..8a77464 --- /dev/null +++ b/catan/__init__.py @@ -0,0 +1,14 @@ +""" +Settlers of Catan engine with SDK, CLI, and GUI interfaces. +""" + +from .game import Game, GameConfig +from .sdk import CatanEnv, Action, ActionType + +__all__ = [ + "Game", + "GameConfig", + "CatanEnv", + "Action", + "ActionType", +] diff --git a/catan/board.py b/catan/board.py new file mode 100644 index 0000000..ec5c7bc --- /dev/null +++ b/catan/board.py @@ -0,0 +1,320 @@ +from __future__ import annotations + +import math +import random +from dataclasses import dataclass, field +from typing import Dict, Iterable, List, Optional, Sequence, Set, Tuple + +from .data import ( + CornerCoord, + EdgeID, + NUMBER_TOKENS, + PORT_SEQUENCE, + Port, + Resource, + RESOURCE_TOKENS, +) + +NeighborVector = Tuple[int, int, int] + +NEIGHBOR_VECTORS: Sequence[NeighborVector] = [ + (1, -1, 0), + (1, 0, -1), + (0, 1, -1), + (-1, 1, 0), + (-1, 0, 1), + (0, -1, 1), +] + +CORNER_OFFSETS: Sequence[NeighborVector] = [ + (2, -1, -1), + (1, 1, -2), + (-1, 2, -1), + (-2, 1, 1), + (-1, -1, 2), + (1, -2, 1), +] + +SQRT_3 = math.sqrt(3.0) + + +def cube_to_pixel(coord: Tuple[float, float, float], scale: float = 1.0) -> Tuple[float, float]: + x, _, z = coord + q = x + r = z + px = scale * (SQRT_3 * q + (SQRT_3 / 2) * r) + py = scale * (1.5 * r) + return px, py + + +@dataclass +class HexTile: + id: int + coord: Tuple[int, int, int] + resource: Resource + number_token: Optional[int] = None + has_robber: bool = False + corners: List[CornerCoord] = field(default_factory=list) + neighbors: Set[int] = field(default_factory=set) + + +@dataclass +class Corner: + id: CornerCoord + adjacent_hexes: Set[int] = field(default_factory=set) + neighbors: Set[CornerCoord] = field(default_factory=set) + port: Optional[Port] = None + owner: Optional[str] = None + building: Optional[str] = None # settlement or city + edges: Set[EdgeID] = field(default_factory=set) + + +@dataclass +class Edge: + id: EdgeID + corners: Tuple[CornerCoord, CornerCoord] + adjacent_hexes: Set[int] = field(default_factory=set) + owner: Optional[str] = None + port: Optional[Port] = None + + +@dataclass +class Board: + hexes: Dict[int, HexTile] + corners: Dict[CornerCoord, Corner] + edges: Dict[EdgeID, Edge] + robber_hex: int + seed: Optional[int] = None + + def __post_init__(self) -> None: + self._corner_order: List[CornerCoord] = sorted( + self.corners.keys(), key=lambda c: cube_to_pixel(c) + ) + self._corner_labels: Dict[CornerCoord, int] = { + coord: idx + 1 for idx, coord in enumerate(self._corner_order) + } + self._edge_order: List[EdgeID] = sorted( + self.edges.keys(), + key=lambda e: cube_to_pixel(tuple((a + b) / 2 for a, b in zip(*e))), + ) + self._edge_labels: Dict[EdgeID, int] = { + edge: idx + 1 for idx, edge in enumerate(self._edge_order) + } + + def corner_label(self, corner_id: CornerCoord) -> int: + return self._corner_labels[corner_id] + + def corner_from_label(self, label: int) -> CornerCoord: + return self._corner_order[label - 1] + + def edge_label(self, edge_id: EdgeID) -> int: + return self._edge_labels[edge_id] + + def edge_from_label(self, label: int) -> EdgeID: + return self._edge_order[label - 1] + + def edge_between(self, a: CornerCoord, b: CornerCoord) -> Optional[Edge]: + edge_id = frozenset({a, b}) + return self.edges.get(edge_id) + + def adjacent_corners(self, corner_id: CornerCoord) -> Set[CornerCoord]: + return self.corners[corner_id].neighbors + + def can_place_settlement( + self, corner_id: CornerCoord, check_distance: bool = True + ) -> bool: + corner = self.corners[corner_id] + if corner.owner is not None: + return False + if check_distance: + for neighbor in corner.neighbors: + if self.corners[neighbor].owner is not None: + return False + return True + + def connected_corners(self, start: CornerCoord) -> Iterable[CornerCoord]: + return self.corners[start].neighbors + + def serialize(self) -> Dict[str, object]: + return { + "hexes": { + hid: { + "coord": tile.coord, + "resource": tile.resource.value, + "number": tile.number_token, + "robber": tile.has_robber, + } + for hid, tile in self.hexes.items() + }, + "corners": { + self.corner_label(cid): { + "coord": cid, + "adjacent_hexes": list(self.corners[cid].adjacent_hexes), + "neighbors": [self.corner_label(n) for n in self.corners[cid].neighbors], + "owner": self.corners[cid].owner, + "building": self.corners[cid].building, + "port": self.corners[cid].port.kind.value if self.corners[cid].port else None, + } + for cid in self.corners + }, + "edges": { + self.edge_label(edge_id): { + "a": self.corner_label(coords[0]), + "b": self.corner_label(coords[1]), + "owner": edge.owner, + } + for edge_id, edge in self.edges.items() + for coords in [list(edge_id)] + }, + } + + +def generate_hex_coords(radius: int = 2) -> List[Tuple[int, int, int]]: + coords: List[Tuple[int, int, int]] = [] + for x in range(-radius, radius + 1): + for y in range(-radius, radius + 1): + z = -x - y + if -radius <= z <= radius: + coords.append((x, y, z)) + return coords + + +def create_board(seed: Optional[int] = None) -> Board: + rng = random.Random(seed) + hex_coords = generate_hex_coords() + rng.shuffle(hex_coords) + resources = RESOURCE_TOKENS.copy() + rng.shuffle(resources) + hexes: Dict[int, HexTile] = {} + coord_map: Dict[Tuple[int, int, int], int] = {} + for idx, coord in enumerate(hex_coords): + resource = resources[idx] + hexes[idx] = HexTile(id=idx, coord=coord, resource=resource) + coord_map[coord] = idx + + for tile in hexes.values(): + for vec in NEIGHBOR_VECTORS: + neighbor_coord = ( + tile.coord[0] + vec[0], + tile.coord[1] + vec[1], + tile.coord[2] + vec[2], + ) + neighbor_id = coord_map.get(neighbor_coord) + if neighbor_id is not None: + tile.neighbors.add(neighbor_id) + + assign_number_tokens(hexes, rng) + + corner_data: Dict[CornerCoord, Dict[str, Set]] = {} + edge_data: Dict[EdgeID, Dict[str, Set]] = {} + robber_hex = next( + tile.id for tile in hexes.values() if tile.resource == Resource.DESERT + ) + hexes[robber_hex].has_robber = True + + for tile in hexes.values(): + corners: List[CornerCoord] = [] + for offset in CORNER_OFFSETS: + corner_coord = ( + 3 * tile.coord[0] + offset[0], + 3 * tile.coord[1] + offset[1], + 3 * tile.coord[2] + offset[2], + ) + data = corner_data.setdefault( + corner_coord, {"hexes": set(), "neighbors": set()} + ) + data["hexes"].add(tile.id) + corners.append(corner_coord) + tile.corners = corners + for i in range(len(corners)): + a = corners[i] + b = corners[(i + 1) % len(corners)] + edge_id = frozenset({a, b}) + edge_info = edge_data.setdefault( + edge_id, {"hexes": set(), "corners": (a, b)} + ) + edge_info["hexes"].add(tile.id) + corner_data[a]["neighbors"].add(b) + corner_data[b]["neighbors"].add(a) + corner_data[a].setdefault("edges", set()).add(edge_id) + corner_data[b].setdefault("edges", set()).add(edge_id) + + corners_obj = { + coord: Corner( + id=coord, + adjacent_hexes=data["hexes"], + neighbors=data["neighbors"], + edges=data.get("edges", set()), + ) + for coord, data in corner_data.items() + } + edges_obj = { + edge_id: Edge( + id=edge_id, + corners=tuple(sorted(edge_id, key=lambda c: cube_to_pixel(c))), + adjacent_hexes=data["hexes"], + ) + for edge_id, data in edge_data.items() + } + + assign_ports(edges_obj, corners_obj) + + return Board( + hexes=hexes, + corners=corners_obj, + edges=edges_obj, + robber_hex=robber_hex, + seed=seed, + ) + + +def assign_number_tokens(hexes: Dict[int, HexTile], rng: random.Random) -> None: + tokens = NUMBER_TOKENS.copy() + attempts = 0 + while True: + attempts += 1 + rng.shuffle(tokens) + idx = 0 + placement_failed = False + for tile in hexes.values(): + if tile.resource == Resource.DESERT: + tile.number_token = None + continue + token = tokens[idx] + idx += 1 + tile.number_token = token + if token in (6, 8): + for neighbor_id in tile.neighbors: + neighbor = hexes[neighbor_id] + if neighbor.number_token in (6, 8): + placement_failed = True + break + if placement_failed: + break + if not placement_failed: + return + if attempts > 5000: + raise RuntimeError("Unable to place number tokens with 6/8 spacing rule") + for tile in hexes.values(): + tile.number_token = None + + +def assign_ports(edges: Dict[EdgeID, Edge], corners: Dict[CornerCoord, Corner]) -> None: + boundary_edges = [ + edge for edge in edges.values() if len(edge.adjacent_hexes) == 1 + ] + if not boundary_edges: + return + boundary_edges.sort( + key=lambda e: math.atan2( + cube_to_pixel(tuple((a + b) / 2 for a, b in zip(*e.corners)))[1], + cube_to_pixel(tuple((a + b) / 2 for a, b in zip(*e.corners)))[0], + ) + ) + step = len(boundary_edges) / len(PORT_SEQUENCE) + for idx, port in enumerate(PORT_SEQUENCE): + edge_index = min(int(idx * step), len(boundary_edges) - 1) + edge = boundary_edges[edge_index] + edge.port = port + for corner_id in edge.corners: + corners[corner_id].port = port diff --git a/catan/cli.py b/catan/cli.py new file mode 100644 index 0000000..3aa7f91 --- /dev/null +++ b/catan/cli.py @@ -0,0 +1,349 @@ +from __future__ import annotations + +import shlex +from typing import Dict, List, Optional + +import typer +from rich.console import Console +from rich.table import Table + +from .data import Resource +from .game import Game, GameConfig, Phase +from .sdk import CatanEnv, ActionType +from .ml.agents import Agent, RandomAgent + +app = typer.Typer(help="Settlers of Catan interactive CLI.") + + +def parse_resource(token: str) -> Resource: + return Resource[token.strip().upper()] + + +class CLIController: + def __init__(self, game: Game, bots: Optional[Dict[str, Agent]] = None): + self.game = game + self.console = Console() + self.env = CatanEnv(game.config) + self.env.game = game + self.bot_agents = bots or {} + + def run(self) -> None: + self.console.print( + "[bold green]Добро пожаловать в CLI Catan.[/bold green] Введите 'help' для списка команд." + ) + while True: + self._resolve_bot_discards() + if self.game.winner: + self.console.print( + f"[bold yellow]{self.game.winner} выиграл партию![/bold yellow]" + ) + break + if self.game.pending_discards: + # Human players must resolve their discards before continuing. + pass + current_name = self.game.current_player.name + if current_name in self.bot_agents and not self.game.pending_discards: + self._run_bot_turn(current_name) + continue + prompt = ( + f"[{self.game.phase.value}] {self.game.current_player.name}> " + ) + try: + raw = input(prompt) + except (EOFError, KeyboardInterrupt): + self.console.print("\nВыход из игры.") + break + command = raw.strip() + if not command: + continue + if command.lower() in {"quit", "exit"}: + break + try: + self.handle_command(command) + except Exception as exc: # noqa: BLE001 + self.console.print(f"[red]{exc}[/red]") + + def handle_command(self, command: str) -> None: + parts = shlex.split(command) + if not parts: + return + action = parts[0].lower() + args = parts[1:] + if action in {"help", "?"}: + self._print_help() + elif action == "state": + self._print_state() + elif action == "board": + self._print_board() + elif action == "corners": + self._print_corners() + elif action == "edges": + self._print_edges() + elif action == "actions": + self._print_actions() + elif action == "roll": + value = self.game.roll_dice() + self.console.print(f"Брошено: {value}") + elif action == "place": + corner = int(args[0]) + road = int(args[1]) + self.game.place_initial_settlement(corner, road) + elif action == "build": + self._handle_build(args) + elif action == "buy": + if args and args[0] == "dev": + card = self.game.buy_development_card() + self.console.print(f"Вы купили карту {card.value}.") + elif action == "play": + self._handle_play(args) + elif action == "trade": + self._handle_trade(args) + elif action == "move": + if args and args[0] == "robber": + hex_id = int(args[1]) + victim = args[2] if len(args) > 2 else None + self.game.move_robber(hex_id, victim) + elif action == "discard": + player = args[0] + losses = self._parse_resource_map(args[1:]) + self.game.discard_cards(player, losses) + elif action == "end": + self.game.end_turn() + else: + self.console.print("[yellow]Неизвестная команда[/yellow]") + + def _resolve_bot_discards(self) -> None: + if not self.game.pending_discards: + return + updated = True + while updated: + updated = False + legal_actions = self.env.legal_actions() + for name in list(self.game.pending_discards.keys()): + if name not in self.bot_agents: + continue + discard_actions = [ + action + for action in legal_actions + if action.type == ActionType.DISCARD and action.payload.get("player") == name + ] + if not discard_actions: + continue + agent = self.bot_agents[name] + chosen = agent.choose_action(self.env, discard_actions) + self.console.print(f"[cyan]{name} (бот) сбрасывает {chosen.payload}[/cyan]") + self.env.step(chosen) + updated = True + break + + def _run_bot_turn(self, name: str) -> None: + agent = self.bot_agents[name] + self.console.print(f"[cyan]{name} (бот) начинает ход[/cyan]") + while True: + legal_actions = self.env.legal_actions() + if not legal_actions: + break + actions = [ + action + for action in legal_actions + if action.type != ActionType.DISCARD + or action.payload.get("player") == name + ] + if not actions: + break + action = agent.choose_action(self.env, actions) + self.console.print( + f"[cyan]{name} (бот) выполняет {action.type.value} {action.payload}[/cyan]" + ) + self.env.step(action) + if ( + self.game.winner + or self.game.current_player.name != name + or action.type == ActionType.END_TURN + or self.game.phase != Phase.MAIN + ): + break + + def _handle_build(self, args: List[str]) -> None: + if not args: + raise ValueError("Укажите что строим.") + target = args[0] + if target == "settlement": + self.game.build_settlement(int(args[1])) + elif target == "city": + self.game.build_city(int(args[1])) + elif target == "road": + self.game.build_road(int(args[1])) + else: + raise ValueError("Неизвестный тип постройки.") + + def _handle_play(self, args: List[str]) -> None: + if not args: + raise ValueError("Какую карту сыграть?") + card = args[0] + if card == "knight": + hex_id = int(args[1]) + victim = args[2] if len(args) > 2 else None + self.game.play_knight(hex_id, victim) + elif card == "road": + edges = [int(arg) for arg in args[1:3]] + self.game.play_road_building(edges) + elif card == "yop": + resources = [parse_resource(arg) for arg in args[1:3]] + self.game.play_year_of_plenty(resources) + elif card == "monopoly": + resource = parse_resource(args[1]) + self.game.play_monopoly(resource) + else: + raise ValueError("Неизвестная карта.") + + def _handle_trade(self, args: List[str]) -> None: + if not args: + return + mode = args[0] + if mode == "bank": + give = parse_resource(args[1]) + receive = parse_resource(args[2]) + amount = int(args[3]) if len(args) > 3 else 1 + self.game.trade_with_bank(give, receive, amount) + elif mode == "player": + target = args[1] + try: + give_index = args.index("give") + receive_index = args.index("receive") + except ValueError: + raise ValueError("Используйте 'trade player give ... receive ...'") + offer = self._parse_resource_map(args[give_index + 1 : receive_index]) + request = self._parse_resource_map(args[receive_index + 1 :]) + self.game.trade_with_player(target, offer, request) + else: + raise ValueError("Неизвестный тип обмена.") + + def _parse_resource_map(self, tokens: List[str]) -> Dict[Resource, int]: + mapping: Dict[Resource, int] = {} + idx = 0 + while idx < len(tokens): + resource = parse_resource(tokens[idx]) + amount = int(tokens[idx + 1]) + mapping[resource] = amount + idx += 2 + return mapping + + def _print_state(self) -> None: + table = Table(title="Состояние игроков") + table.add_column("Игрок") + table.add_column("Очки") + table.add_column("Ресурсы") + table.add_column("Постройки") + for player in self.game.players: + resources = ", ".join( + f"{res.value}:{count}" + for res, count in player.resources.items() + if count + ) or "нет" + buildings = f"S:{len(player.settlements)} C:{len(player.cities)} R:{len(player.roads)}" + table.add_row( + player.name, + str(player.victory_points()), + resources, + buildings, + ) + self.console.print(table) + + def _print_board(self) -> None: + table = Table(title="Гексы") + table.add_column("ID") + table.add_column("Ресурс") + table.add_column("Число") + table.add_column("Разбойник") + for tile in self.game.board.hexes.values(): + table.add_row( + str(tile.id), + tile.resource.value, + str(tile.number_token or "-"), + "Да" if tile.has_robber else "", + ) + self.console.print(table) + + def _print_corners(self) -> None: + table = Table(title="Перекрестки") + table.add_column("ID") + table.add_column("Владелец") + table.add_column("Тип") + table.add_column("Порт") + for corner_id in self.game.board.corners: + corner = self.game.board.corners[corner_id] + label = self.game.board.corner_label(corner_id) + port = corner.port.resource.value if corner.port and corner.port.resource else (corner.port.kind.value if corner.port else "-") + table.add_row( + str(label), + corner.owner or "-", + corner.building or "-", + port, + ) + self.console.print(table) + + def _print_edges(self) -> None: + table = Table(title="Дороги") + table.add_column("ID") + table.add_column("Владелец") + for edge_id in self.game.board.edges: + edge = self.game.board.edges[edge_id] + label = self.game.board.edge_label(edge_id) + table.add_row(str(label), edge.owner or "-") + self.console.print(table) + + def _print_actions(self) -> None: + actions = self.env.legal_actions() + table = Table(title="Доступные действия") + table.add_column("Тип") + table.add_column("Детали") + for action in actions: + table.add_row(action.type.value, str(action.payload)) + self.console.print(table) + + def _print_help(self) -> None: + self.console.print( + """[bold]Команды[/bold]: +- state: показать состояние +- board/corners/edges: показать карту +- actions: возможные действия +- roll: бросок кубиков +- place : стартовая постройка +- build settlement|city|road +- buy dev: купить карту развития +- play knight|road|yop|monopoly ... +- trade bank [amount] +- trade player give ... receive ... +- move robber [victim] +- discard ... +- end: завершить ход +- quit: выйти""" + ) + + +@app.command() +def play( + players: str = typer.Argument(..., help="Список имён игроков через запятую"), + seed: Optional[int] = typer.Option(None, help="Seed для генератора"), + random_bot: List[str] = typer.Option( + [], + "--random-bot", + help="Имя игрока, которым управляет случайный бот. Можно повторять.", + ), +) -> None: + names = [name.strip() for name in players.split(",") if name.strip()] + if len(names) < 2: + raise typer.BadParameter("нужно минимум 2 игрока") + config = GameConfig(player_names=names, seed=seed) + bots: Dict[str, Agent] = {} + for bot_name in random_bot: + if bot_name not in names: + raise typer.BadParameter(f"бот {bot_name} не входит в список игроков") + bots[bot_name] = RandomAgent() + controller = CLIController(Game(config), bots=bots) + controller.run() + + +if __name__ == "__main__": + app() diff --git a/catan/data.py b/catan/data.py new file mode 100644 index 0000000..3a8c5c9 --- /dev/null +++ b/catan/data.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +from typing import Dict, List, Tuple + + +class Resource(Enum): + BRICK = "brick" + LUMBER = "lumber" + WOOL = "wool" + GRAIN = "grain" + ORE = "ore" + DESERT = "desert" + + +class DevelopmentCard(Enum): + KNIGHT = "knight" + ROAD_BUILDING = "road_building" + YEAR_OF_PLENTY = "year_of_plenty" + MONOPOLY = "monopoly" + VICTORY_POINT = "victory_point" + + +class BuildingType(Enum): + SETTLEMENT = "settlement" + CITY = "city" + + +RESOURCE_TOKENS: List[Resource] = [ + Resource.BRICK, + Resource.BRICK, + Resource.BRICK, + Resource.LUMBER, + Resource.LUMBER, + Resource.LUMBER, + Resource.LUMBER, + Resource.WOOL, + Resource.WOOL, + Resource.WOOL, + Resource.WOOL, + Resource.GRAIN, + Resource.GRAIN, + Resource.GRAIN, + Resource.GRAIN, + Resource.ORE, + Resource.ORE, + Resource.ORE, + Resource.DESERT, +] + +NUMBER_TOKENS: List[int] = [ + 2, + 3, + 3, + 4, + 4, + 5, + 5, + 6, + 6, + 8, + 8, + 9, + 9, + 10, + 10, + 11, + 11, + 12, +] + +DEV_CARD_DISTRIBUTION: Dict[DevelopmentCard, int] = { + DevelopmentCard.KNIGHT: 14, + DevelopmentCard.ROAD_BUILDING: 2, + DevelopmentCard.YEAR_OF_PLENTY: 2, + DevelopmentCard.MONOPOLY: 2, + DevelopmentCard.VICTORY_POINT: 5, +} + +BUILD_COSTS: Dict[str, Dict[Resource, int]] = { + "road": {Resource.BRICK: 1, Resource.LUMBER: 1}, + "settlement": { + Resource.BRICK: 1, + Resource.LUMBER: 1, + Resource.WOOL: 1, + Resource.GRAIN: 1, + }, + "city": {Resource.GRAIN: 2, Resource.ORE: 3}, + "development": {Resource.WOOL: 1, Resource.GRAIN: 1, Resource.ORE: 1}, +} + +SETTLEMENT_LIMIT = 5 +CITY_LIMIT = 4 +ROAD_LIMIT = 15 +VICTORY_POINTS_TO_WIN = 10 + + +class PortKind(Enum): + THREE_FOR_ONE = "three_for_one" + TWO_FOR_ONE = "two_for_one" + + +@dataclass(frozen=True) +class Port: + ratio: int + resource: Resource | None + kind: PortKind + + +PORT_SEQUENCE: List[Port] = [ + Port(3, None, PortKind.THREE_FOR_ONE), + Port(2, Resource.BRICK, PortKind.TWO_FOR_ONE), + Port(3, None, PortKind.THREE_FOR_ONE), + Port(2, Resource.WOOL, PortKind.TWO_FOR_ONE), + Port(3, None, PortKind.THREE_FOR_ONE), + Port(2, Resource.GRAIN, PortKind.TWO_FOR_ONE), + Port(2, Resource.LUMBER, PortKind.TWO_FOR_ONE), + Port(3, None, PortKind.THREE_FOR_ONE), + Port(2, Resource.ORE, PortKind.TWO_FOR_ONE), +] + + +CornerCoord = Tuple[int, int, int] +EdgeID = frozenset[CornerCoord] diff --git a/catan/game.py b/catan/game.py new file mode 100644 index 0000000..69c0ebe --- /dev/null +++ b/catan/game.py @@ -0,0 +1,618 @@ +from __future__ import annotations + +import random +from dataclasses import dataclass, field +from enum import Enum +from typing import Dict, Iterable, List, Optional, Sequence, Tuple + +from .board import Board, Corner, Edge, create_board +from .data import ( + BUILD_COSTS, + CornerCoord, + DevelopmentCard, + EdgeID, + PortKind, + Resource, + VICTORY_POINTS_TO_WIN, + DEV_CARD_DISTRIBUTION, +) +from .player import Player + + +class Phase(Enum): + SETUP_ROUND_ONE = "setup_round_one" + SETUP_ROUND_TWO = "setup_round_two" + MAIN = "main" + + +@dataclass +class GameConfig: + player_names: Sequence[str] + colors: Optional[Sequence[str]] = None + seed: Optional[int] = None + + def __post_init__(self) -> None: + if not (2 <= len(self.player_names) <= 4): + raise ValueError("Catan supports 2-4 players in this implementation.") + + +@dataclass +class TurnLog: + player: str + action: str + details: Dict[str, object] = field(default_factory=dict) + + +def initial_bank() -> Dict[Resource, int]: + bank = {resource: 19 for resource in Resource if resource != Resource.DESERT} + return bank + + +def build_dev_deck(rng: random.Random) -> List[DevelopmentCard]: + deck: List[DevelopmentCard] = [] + for card, amount in DEV_CARD_DISTRIBUTION.items(): + deck.extend([card] * amount) + rng.shuffle(deck) + return deck + + +class Game: + def __init__(self, config: GameConfig): + self.config = config + self.rng = random.Random(config.seed) + self.board = create_board(seed=config.seed) + self.players: List[Player] = [] + self._init_players() + self.bank = initial_bank() + self.dev_deck = build_dev_deck(self.rng) + self.phase = Phase.SETUP_ROUND_ONE + self.setup_round_index = 0 + self.setup_progress = 0 + self.turn_order_rounds = [ + list(range(len(self.players))), + list(reversed(range(len(self.players)))), + ] + self.current_player_index = self.turn_order_rounds[0][0] + self.has_rolled = False + self.pending_discards: Dict[str, int] = {} + self.robber_move_required = False + self.last_roll: Optional[int] = None + self.history: List[TurnLog] = [] + self.winner: Optional[str] = None + + def _init_players(self) -> None: + default_colors = ["red", "blue", "orange", "white"] + colors = list(self.config.colors or default_colors) + if len(colors) < len(self.config.player_names): + colors.extend(["player"] * (len(self.config.player_names) - len(colors))) + for name, color in zip(self.config.player_names, colors): + self.players.append(Player(name=name, color=color)) + + @property + def current_player(self) -> Player: + return self.players[self.current_player_index] + + def player_by_name(self, name: str) -> Player: + for player in self.players: + if player.name == name: + return player + raise KeyError(name) + + def place_initial_settlement(self, corner_label: int, road_label: int) -> None: + if self.phase not in (Phase.SETUP_ROUND_ONE, Phase.SETUP_ROUND_TWO): + raise ValueError("Initial placement only during setup.") + player = self.current_player + corner_id = self.board.corner_from_label(corner_label) + road_id = self.board.edge_from_label(road_label) + self._build_settlement(player, corner_id, require_connection=False) + self._build_road( + player, + road_id, + must_touch_corner=corner_id, + ) + if self.phase == Phase.SETUP_ROUND_TWO: + self._grant_initial_resources(player, corner_id) + self._advance_setup_turn() + + def _advance_setup_turn(self) -> None: + sequence = self.turn_order_rounds[self.setup_round_index] + self.setup_progress += 1 + if self.setup_progress >= len(sequence): + if self.phase == Phase.SETUP_ROUND_ONE: + self.phase = Phase.SETUP_ROUND_TWO + self.setup_round_index = 1 + self.setup_progress = 0 + self.current_player_index = self.turn_order_rounds[1][0] + else: + self.phase = Phase.MAIN + self.current_player_index = 0 + self.setup_progress = 0 + self.has_rolled = False + return + self.current_player_index = sequence[self.setup_progress] + + def roll_dice(self) -> int: + if self.phase != Phase.MAIN: + raise ValueError("Cannot roll dice during setup.") + if self.has_rolled: + raise ValueError("Dice already rolled.") + roll = self.rng.randint(1, 6) + self.rng.randint(1, 6) + self.last_roll = roll + if roll == 7: + self._trigger_robber() + else: + self._distribute_resources(roll) + self.has_rolled = True + self.history.append( + TurnLog(player=self.current_player.name, action="roll", details={"value": roll}) + ) + return roll + + def _trigger_robber(self) -> None: + self.pending_discards = {} + for player in self.players: + total = player.total_resources() + if total > 7: + self.pending_discards[player.name] = total // 2 + self.robber_move_required = True + + def discard_cards(self, player_name: str, losses: Dict[Resource, int]) -> None: + if player_name not in self.pending_discards: + raise ValueError("Player is not required to discard.") + required = self.pending_discards[player_name] + if sum(losses.values()) != required: + raise ValueError("Incorrect discard amount.") + player = self.player_by_name(player_name) + for resource, amount in losses.items(): + if player.resources[resource] < amount: + raise ValueError("Player lacks resources to discard.") + for resource, amount in losses.items(): + player.resources[resource] -= amount + self.bank[resource] += amount + del self.pending_discards[player_name] + + def move_robber(self, target_hex: int, victim: Optional[str] = None) -> None: + if not self.robber_move_required or self.pending_discards: + raise ValueError("Cannot move robber yet.") + if target_hex == self.board.robber_hex: + raise ValueError("Robber must move to a new hex.") + if target_hex not in self.board.hexes: + raise ValueError("Invalid hex.") + self.board.hexes[self.board.robber_hex].has_robber = False + self.board.robber_hex = target_hex + self.board.hexes[target_hex].has_robber = True + if victim: + victim_player = self.player_by_name(victim) + if not self._can_steal_from(target_hex, victim_player): + raise ValueError("Victim has no adjacent buildings.") + stolen = self._steal_random_resource(victim_player) + if stolen: + self.current_player.receive_resource(stolen, 1) + self.history.append( + TurnLog( + player=self.current_player.name, + action="steal", + details={"victim": victim_player.name, "resource": stolen.value}, + ) + ) + self.robber_move_required = False + + def _can_steal_from(self, hex_id: int, victim: Player) -> bool: + hex_tile = self.board.hexes[hex_id] + for corner_id in hex_tile.corners: + corner = self.board.corners[corner_id] + if corner.owner == victim.name: + return True + return False + + def _steal_random_resource(self, victim: Player) -> Optional[Resource]: + cards = [res for res, count in victim.resources.items() for _ in range(count)] + if not cards: + return None + resource = self.rng.choice(cards) + victim.resources[resource] -= 1 + return resource + + def _distribute_resources(self, roll: int) -> None: + demand: Dict[Resource, int] = {res: 0 for res in self.bank} + payouts: Dict[str, Dict[Resource, int]] = { + player.name: {res: 0 for res in self.bank} for player in self.players + } + for tile in self.board.hexes.values(): + if tile.number_token != roll or tile.has_robber: + continue + for corner_id in tile.corners: + corner = self.board.corners[corner_id] + if not corner.owner: + continue + player = self.player_by_name(corner.owner) + amount = 1 + if corner.building == "city": + amount = 2 + resource = tile.resource + if resource == Resource.DESERT: + continue + payouts[player.name][resource] += amount + demand[resource] += amount + for resource, amount in demand.items(): + if amount == 0: + continue + if amount > self.bank[resource]: + continue + for player in self.players: + gain = payouts[player.name][resource] + if gain: + player.receive_resource(resource, gain) + self.bank[resource] -= gain + + def build_settlement(self, corner_label: int) -> None: + player = self.current_player + corner_id = self.board.corner_from_label(corner_label) + self._ensure_action_allowed() + if not player.can_afford("settlement"): + raise ValueError("Insufficient resources for settlement.") + self._build_settlement(player, corner_id, require_connection=True) + player.pay_cost("settlement") + for resource, cost in BUILD_COSTS["settlement"].items(): + self.bank[resource] += cost + self.history.append( + TurnLog( + player=player.name, + action="build_settlement", + details={"corner": corner_label}, + ) + ) + self._check_winner() + + def _ensure_action_allowed(self, require_roll: bool = True) -> None: + if self.phase != Phase.MAIN: + raise ValueError("Action only allowed after setup.") + if require_roll and not self.has_rolled: + raise ValueError("Must roll dice first.") + if self.robber_move_required or self.pending_discards: + raise ValueError("Resolve robber first.") + + def _build_settlement( + self, + player: Player, + corner_id: CornerCoord, + require_connection: bool, + ) -> None: + corner = self.board.corners[corner_id] + if not self.board.can_place_settlement( + corner_id, check_distance=True + ): + raise ValueError("Corner occupied or violates distance rule.") + if require_connection and not self._has_adjacent_road(player, corner_id): + raise ValueError("Settlement must connect to player's road.") + if player.settlements_remaining() <= 0: + raise ValueError("No settlements remaining.") + corner.owner = player.name + corner.building = "settlement" + player.settlements.add(corner_id) + + def _has_adjacent_road(self, player: Player, corner_id: CornerCoord) -> bool: + corner = self.board.corners[corner_id] + for edge_id in corner.edges: + if edge_id in player.roads: + return True + return False + + def build_city(self, corner_label: int) -> None: + player = self.current_player + corner_id = self.board.corner_from_label(corner_label) + self._ensure_action_allowed() + if not player.can_afford("city"): + raise ValueError("Insufficient resources for city.") + corner = self.board.corners[corner_id] + if corner.owner != player.name or corner.building != "settlement": + raise ValueError("Must upgrade your settlement.") + if player.cities_remaining() <= 0: + raise ValueError("No cities remaining.") + player.pay_cost("city") + for resource, amount in BUILD_COSTS["city"].items(): + self.bank[resource] += amount + corner.building = "city" + player.settlements.remove(corner_id) + player.cities.add(corner_id) + self.history.append( + TurnLog(player=player.name, action="build_city", details={"corner": corner_label}) + ) + self._check_winner() + + def build_road(self, edge_label: int) -> None: + player = self.current_player + edge_id = self.board.edge_from_label(edge_label) + self._ensure_action_allowed() + if not player.can_afford("road"): + raise ValueError("Insufficient resources for road.") + self._build_road(player, edge_id) + player.pay_cost("road") + for resource, amount in BUILD_COSTS["road"].items(): + self.bank[resource] += amount + self.history.append( + TurnLog(player=player.name, action="build_road", details={"edge": edge_label}) + ) + self._update_longest_road() + + def _build_road( + self, + player: Player, + edge_id: EdgeID, + must_touch_corner: Optional[CornerCoord] = None, + ) -> None: + edge = self.board.edges[edge_id] + if edge.owner: + raise ValueError("Edge already occupied.") + if player.roads_remaining() <= 0: + raise ValueError("No roads remaining.") + if must_touch_corner and must_touch_corner not in edge.corners: + raise ValueError("Road must touch the specified settlement.") + if not self._road_connection_valid(player, edge): + raise ValueError("Road must connect to player's network.") + edge.owner = player.name + player.roads.add(edge_id) + self._update_longest_road() + + def _road_connection_valid(self, player: Player, edge: Edge) -> bool: + for corner_id in edge.corners: + corner = self.board.corners[corner_id] + if corner.owner == player.name: + return True + if corner.owner and corner.owner != player.name: + continue + for neighbor_edge in corner.edges: + neighbor = self.board.edges.get(neighbor_edge) + if neighbor and neighbor.owner == player.name: + return True + return not player.roads # allow first road if none yet + + def buy_development_card(self) -> DevelopmentCard: + player = self.current_player + self._ensure_action_allowed() + if not self.dev_deck: + raise ValueError("No development cards remaining.") + if not player.can_afford("development"): + raise ValueError("Cannot afford development card.") + player.pay_cost("development") + for resource, amount in BUILD_COSTS["development"].items(): + self.bank[resource] += amount + card = self.dev_deck.pop() + player.dev_cards.append(card) + player.new_dev_cards.append(card) + if card == DevelopmentCard.VICTORY_POINT: + player.hidden_points += 1 + self._check_winner() + self.history.append( + TurnLog(player=player.name, action="buy_development", details={"card": card.value}) + ) + return card + + def play_knight(self, target_hex: int, victim: Optional[str]) -> None: + player = self.current_player + self._ensure_action_allowed(require_roll=False) + self._play_dev_card(player, DevelopmentCard.KNIGHT) + self.robber_move_required = True + self.move_robber(target_hex, victim) + player.played_knights += 1 + self._update_largest_army() + + def _play_dev_card(self, player: Player, card: DevelopmentCard) -> None: + if card not in player.dev_cards: + raise ValueError("Card not in inventory.") + if card in player.new_dev_cards: + raise ValueError("Cannot play development card the turn it was purchased.") + player.dev_cards.remove(card) + + def play_road_building(self, edge_labels: Sequence[int]) -> None: + player = self.current_player + self._ensure_action_allowed(require_roll=False) + if len(edge_labels) != 2: + raise ValueError("Road building places exactly two roads.") + self._play_dev_card(player, DevelopmentCard.ROAD_BUILDING) + for label in edge_labels: + edge_id = self.board.edge_from_label(label) + self._build_road(player, edge_id) + + def play_year_of_plenty(self, resources: Sequence[Resource]) -> None: + player = self.current_player + self._ensure_action_allowed(require_roll=False) + if len(resources) != 2: + raise ValueError("Choose two resources.") + self._play_dev_card(player, DevelopmentCard.YEAR_OF_PLENTY) + for resource in resources: + if self.bank[resource] <= 0: + raise ValueError("Bank lacks requested resource.") + player.receive_resource(resource, 1) + self.bank[resource] -= 1 + + def play_monopoly(self, resource: Resource) -> None: + player = self.current_player + self._ensure_action_allowed(require_roll=False) + self._play_dev_card(player, DevelopmentCard.MONOPOLY) + total = 0 + for target in self.players: + if target is player: + continue + amount = target.resources[resource] + if amount: + target.resources[resource] -= amount + total += amount + if total: + player.receive_resource(resource, total) + + def end_turn(self) -> None: + if self.phase != Phase.MAIN: + raise ValueError("Cannot end turn during setup.") + if not self.has_rolled: + raise ValueError("You must roll before ending your turn.") + if self.robber_move_required or self.pending_discards: + raise ValueError("Resolve robber before ending turn.") + self.current_player.new_dev_cards.clear() + self.current_player_index = (self.current_player_index + 1) % len(self.players) + self.has_rolled = False + + def _grant_initial_resources(self, player: Player, corner_id: CornerCoord) -> None: + corner = self.board.corners[corner_id] + for hex_id in corner.adjacent_hexes: + tile = self.board.hexes[hex_id] + resource = tile.resource + if resource == Resource.DESERT: + continue + if self.bank[resource] <= 0: + continue + player.receive_resource(resource, 1) + self.bank[resource] -= 1 + + def trade_with_bank(self, give: Resource, receive: Resource, amount: int = 1) -> None: + player = self.current_player + self._ensure_action_allowed() + ratio = self._bank_trade_ratio(player, give) + required = ratio * amount + if player.resources[give] < required: + raise ValueError("Insufficient resources to trade.") + if self.bank[receive] < amount: + raise ValueError("Bank lacks requested resource.") + player.resources[give] -= required + self.bank[give] += required + player.receive_resource(receive, amount) + self.bank[receive] -= amount + + def _bank_trade_ratio(self, player: Player, resource: Resource) -> int: + ratio = 4 + for corner_id in player.settlements.union(player.cities): + corner = self.board.corners[corner_id] + if not corner.port: + continue + port = corner.port + if port.kind == PortKind.THREE_FOR_ONE: + ratio = min(ratio, 3) + elif port.resource == resource: + ratio = min(ratio, 2) + return ratio + + def trade_with_player( + self, + target_player: str, + offer: Dict[Resource, int], + request: Dict[Resource, int], + ) -> None: + player = self.current_player + self._ensure_action_allowed() + other = self.player_by_name(target_player) + for resource, amount in offer.items(): + if player.resources[resource] < amount: + raise ValueError("Insufficient resources to offer.") + for resource, amount in request.items(): + if other.resources[resource] < amount: + raise ValueError("Player lacks requested resources.") + for resource, amount in offer.items(): + player.resources[resource] -= amount + other.resources[resource] += amount + for resource, amount in request.items(): + other.resources[resource] -= amount + player.resources[resource] += amount + + def _update_longest_road(self) -> None: + longest_length = 0 + longest_owner: Optional[Player] = None + for player in self.players: + length = self._longest_road_length(player) + if length > longest_length: + longest_length = length + longest_owner = player + elif length == longest_length: + longest_owner = None + if longest_length >= 5 and longest_owner: + for player in self.players: + player.longest_road = player is longest_owner + else: + for player in self.players: + player.longest_road = False + if longest_owner: + self._check_winner() + + def _longest_road_length(self, player: Player) -> int: + adjacency = {} + for edge_id in player.roads: + edge = self.board.edges[edge_id] + a, b = edge.corners + adjacency.setdefault(a, []).append((b, edge_id)) + adjacency.setdefault(b, []).append((a, edge_id)) + longest = 0 + for start in adjacency: + longest = max(longest, self._dfs_longest(start, adjacency, set(), player)) + return longest + + def _dfs_longest( + self, + node: CornerCoord, + adjacency: Dict[CornerCoord, List[Tuple[CornerCoord, EdgeID]]], + used_edges: Set[EdgeID], + player: Player, + ) -> int: + best = 0 + for neighbor, edge_id in adjacency.get(node, []): + if edge_id in used_edges: + continue + neighbor_corner = self.board.corners[neighbor] + used_edges.add(edge_id) + if neighbor_corner.owner and neighbor_corner.owner != player.name: + best = max(best, 1) + else: + best = max( + best, 1 + self._dfs_longest(neighbor, adjacency, used_edges, player) + ) + used_edges.remove(edge_id) + return best + + def _update_largest_army(self) -> None: + best = 0 + owner: Optional[Player] = None + for player in self.players: + if player.played_knights > best: + best = player.played_knights + owner = player + elif player.played_knights == best: + owner = None + if best >= 3 and owner: + for player in self.players: + player.largest_army = player is owner + else: + for player in self.players: + player.largest_army = False + if owner: + self._check_winner() + + def _check_winner(self) -> None: + for player in self.players: + if player.victory_points() >= VICTORY_POINTS_TO_WIN: + self.winner = player.name + break + + def observation(self) -> Dict[str, object]: + return { + "phase": self.phase.value, + "current_player": self.current_player.name, + "players": { + player.name: { + "resources": dict(player.resources), + "victory_points": player.victory_points(), + "settlements": [self.board.corner_label(c) for c in player.settlements], + "cities": [self.board.corner_label(c) for c in player.cities], + "roads": [self.board.edge_label(e) for e in player.roads], + "dev_cards": len(player.dev_cards), + "largest_army": player.largest_army, + "longest_road": player.longest_road, + "color": player.color, + } + for player in self.players + }, + "bank": {res.value: amount for res, amount in self.bank.items()}, + "pending_discards": { + name: amount for name, amount in self.pending_discards.items() + }, + "robber_required": self.robber_move_required, + "last_roll": self.last_roll, + "winner": self.winner, + } diff --git a/catan/gui.py b/catan/gui.py new file mode 100644 index 0000000..84984bb --- /dev/null +++ b/catan/gui.py @@ -0,0 +1,313 @@ +from __future__ import annotations + +import math +from typing import Dict, Optional + +import tkinter as tk +from tkinter import messagebox, simpledialog + +import typer + +from .board import cube_to_pixel +from .data import Resource +from .game import Game, GameConfig + + +RESOURCE_COLORS: Dict[str, str] = { + "brick": "#b5651d", + "lumber": "#228b22", + "wool": "#7ec850", + "grain": "#f4d03f", + "ore": "#95a5a6", + "desert": "#f5deb3", +} + + +class CatanGUI: + def __init__(self, game: Game): + self.game = game + self.root = tk.Tk() + self.root.title("Catan GUI") + self.canvas = tk.Canvas(self.root, width=900, height=700, bg="#1c1f24") + self.canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + self.sidebar = tk.Frame(self.root, padx=10, pady=10) + self.sidebar.pack(side=tk.RIGHT, fill=tk.Y) + self.log_widget = tk.Text(self.sidebar, width=40, height=18, state=tk.DISABLED) + self.log_widget.pack(pady=5) + self._add_buttons() + self.draw_board() + self.update_status() + + def _add_buttons(self) -> None: + button_specs = [ + ("Бросить кубики", self.roll_dice), + ("Построить поселение", self.build_settlement), + ("Построить город", self.build_city), + ("Построить дорогу", self.build_road), + ("Купить развитие", self.buy_development), + ("Сыграть рыцаря", self.play_knight), + ("Сыграть дор. стройку", self.play_road_building), + ("Сыграть Изобилие", self.play_year_of_plenty), + ("Сыграть Монополию", self.play_monopoly), + ("Обмен с банком", self.trade_bank), + ("Передвинуть разбойника", self.move_robber), + ("Сбросить карты", self.discard_cards), + ("Стартовая постройка", self.place_initial), + ("Доступные действия", self.show_actions), + ("Завершить ход", self.end_turn), + ] + for title, handler in button_specs: + tk.Button(self.sidebar, text=title, command=handler, width=24).pack(pady=2) + + def run(self) -> None: + self.root.mainloop() + + def log(self, message: str) -> None: + self.log_widget.configure(state=tk.NORMAL) + self.log_widget.insert(tk.END, message + "\n") + self.log_widget.configure(state=tk.DISABLED) + self.log_widget.see(tk.END) + + def draw_board(self) -> None: + self.canvas.delete("all") + size = 60 + for tile in self.game.board.hexes.values(): + px, py = cube_to_pixel(tile.coord, scale=size / math.sqrt(3)) + self._draw_hex(px + 350, py + 300, size * 0.6, RESOURCE_COLORS[tile.resource.value]) + label = tile.number_token or "" + text = f"{label}" + if tile.has_robber: + text += " (R)" + self.canvas.create_text(px + 350, py + 300, text=text, fill="black", font=("Arial", 12, "bold")) + for edge in self.game.board.edges.values(): + if not edge.owner: + continue + a = self._corner_to_pixel(edge.corners[0]) + b = self._corner_to_pixel(edge.corners[1]) + color = self._player_color(edge.owner) + self.canvas.create_line(a[0], a[1], b[0], b[1], fill=color, width=4) + for corner_id, corner in self.game.board.corners.items(): + if not corner.owner: + continue + x, y = self._corner_to_pixel(corner_id) + color = self._player_color(corner.owner) + radius = 8 if corner.building == "settlement" else 12 + self.canvas.create_oval( + x - radius, + y - radius, + x + radius, + y + radius, + fill=color, + outline="white", + ) + + def _draw_hex(self, x: float, y: float, size: float, color: str) -> None: + points = [] + for i in range(6): + angle = math.pi / 3 * i + math.pi / 6 + px = x + size * math.cos(angle) + py = y + size * math.sin(angle) + points.extend([px, py]) + self.canvas.create_polygon(points, fill=color, outline="black", width=1) + + def _corner_to_pixel(self, coord) -> tuple[float, float]: + px, py = cube_to_pixel(coord, scale=35 / math.sqrt(3)) + return px + 350, py + 300 + + def _player_color(self, name: str) -> str: + for player in self.game.players: + if player.name == name: + return player.color + return "white" + + def update_status(self) -> None: + self.log( + f"Ход игрока {self.game.current_player.name} | Фаза: {self.game.phase.value}" + ) + self.draw_board() + + def _ask_int(self, prompt: str, default: Optional[int] = None) -> int: + value = simpledialog.askinteger("Ввод", prompt, initialvalue=default, parent=self.root) + if value is None: + raise ValueError("Операция отменена") + return value + + def _ask_resource(self, prompt: str) -> Resource: + value = simpledialog.askstring("Ввод", prompt, parent=self.root) + if not value: + raise ValueError("Операция отменена") + return Resource[value.strip().upper()] + + def roll_dice(self) -> None: + try: + value = self.game.roll_dice() + self.log(f"Бросок: {value}") + except Exception as exc: # noqa: BLE001 + messagebox.showerror("Ошибка", str(exc)) + self.update_status() + + def build_settlement(self) -> None: + try: + corner = self._ask_int("ID перекрестка") + self.game.build_settlement(corner) + self.log(f"Построено поселение #{corner}") + except Exception as exc: + messagebox.showerror("Ошибка", str(exc)) + self.update_status() + + def build_city(self) -> None: + try: + corner = self._ask_int("ID перекрестка") + self.game.build_city(corner) + self.log(f"Построен город #{corner}") + except Exception as exc: + messagebox.showerror("Ошибка", str(exc)) + self.update_status() + + def build_road(self) -> None: + try: + edge = self._ask_int("ID ребра") + self.game.build_road(edge) + self.log(f"Построена дорога #{edge}") + except Exception as exc: + messagebox.showerror("Ошибка", str(exc)) + self.update_status() + + def buy_development(self) -> None: + try: + card = self.game.buy_development_card() + self.log(f"Куплена карта {card.value}") + except Exception as exc: + messagebox.showerror("Ошибка", str(exc)) + self.update_status() + + def play_knight(self) -> None: + try: + hex_id = self._ask_int("ID гекса для разбойника") + victim = simpledialog.askstring("Ввод", "Кого грабим? (опционально)", parent=self.root) + self.game.play_knight(hex_id, victim or None) + self.log("Сыгран рыцарь") + except Exception as exc: + messagebox.showerror("Ошибка", str(exc)) + self.update_status() + + def play_road_building(self) -> None: + try: + first = self._ask_int("ID первой дороги") + second = self._ask_int("ID второй дороги") + self.game.play_road_building([first, second]) + self.log("Сыграна дорожная стройка") + except Exception as exc: + messagebox.showerror("Ошибка", str(exc)) + self.update_status() + + def play_year_of_plenty(self) -> None: + try: + res1 = self._ask_resource("Ресурс 1") + res2 = self._ask_resource("Ресурс 2") + self.game.play_year_of_plenty([res1, res2]) + self.log("Сыграна карта Изобилия") + except Exception as exc: + messagebox.showerror("Ошибка", str(exc)) + self.update_status() + + def play_monopoly(self) -> None: + try: + res = self._ask_resource("Какой ресурс монополизировать?") + self.game.play_monopoly(res) + self.log("Сыграна Монополия") + except Exception as exc: + messagebox.showerror("Ошибка", str(exc)) + self.update_status() + + def trade_bank(self) -> None: + try: + give = self._ask_resource("Что отдать?") + receive = self._ask_resource("Что получить?") + amount = self._ask_int("Сколько карт получить? (по умолчанию 1)", default=1) + self.game.trade_with_bank(give, receive, amount or 1) + self.log("Обмен с банком завершён") + except Exception as exc: + messagebox.showerror("Ошибка", str(exc)) + self.update_status() + + def move_robber(self) -> None: + try: + hex_id = self._ask_int("ID гекса для разбойника") + victim = simpledialog.askstring("Ввод", "Кого грабим? (опционально)", parent=self.root) + self.game.move_robber(hex_id, victim or None) + self.log("Разбойник передвинут") + except Exception as exc: + messagebox.showerror("Ошибка", str(exc)) + self.update_status() + + def discard_cards(self) -> None: + try: + player = simpledialog.askstring("Ввод", "Кто сбрасывает?", parent=self.root) + if not player: + raise ValueError("Нужно указать игрока.") + cards_str = simpledialog.askstring( + "Ввод", "Введите пары ресурс-количество, напр. 'brick 2 wheat 1'", parent=self.root + ) + if not cards_str: + raise ValueError("Нужно ввести карты для сброса.") + tokens = cards_str.split() + losses = {} + idx = 0 + while idx < len(tokens): + res = Resource[tokens[idx].upper()] + amount = int(tokens[idx + 1]) + losses[res] = amount + idx += 2 + self.game.discard_cards(player, losses) + self.log(f"{player} сбросил карты.") + except Exception as exc: + messagebox.showerror("Ошибка", str(exc)) + self.update_status() + + def place_initial(self) -> None: + try: + corner = self._ask_int("ID перекрестка") + road = self._ask_int("ID ребра") + self.game.place_initial_settlement(corner, road) + self.log("Стартовая постройка размещена") + except Exception as exc: + messagebox.showerror("Ошибка", str(exc)) + self.update_status() + + def show_actions(self) -> None: + env = CatanEnv(self.game.config) + env.game = self.game + actions = env.legal_actions() + text = "\n".join(f"{a.type.value}: {a.payload}" for a in actions) + messagebox.showinfo("Действия", text or "Нет действий") + + def end_turn(self) -> None: + try: + self.game.end_turn() + self.log("Ход завершён") + except Exception as exc: + messagebox.showerror("Ошибка", str(exc)) + self.update_status() + + +cli = typer.Typer(help="Запуск графического интерфейса Catan") + + +@cli.command() +def run( + players: str = typer.Option("Alice,Bob,Carla", help="Игроки через запятую"), + seed: Optional[int] = typer.Option(None, help="Seed генератора"), +) -> None: + names = [name.strip() for name in players.split(",") if name.strip()] + config = GameConfig(player_names=names, seed=seed) + gui = CatanGUI(Game(config)) + gui.run() + + +def main() -> None: + cli() + + +if __name__ == "__main__": + main() diff --git a/catan/learning.py b/catan/learning.py new file mode 100644 index 0000000..d63c733 --- /dev/null +++ b/catan/learning.py @@ -0,0 +1,116 @@ +from __future__ import annotations + +from typing import List, Optional + +import torch +import typer + +from .game import GameConfig +from .sdk import CatanEnv +from .ml.trainers import ReinforcementLearningTrainer +from .ml.selfplay import SelfPlayPPOTrainer, PPOConfig + +app = typer.Typer(help="Reinforcement learning utilities for Catan.") + + +def _parse_players(players: str) -> List[str]: + names = [name.strip() for name in players.split(",") if name.strip()] + if len(names) < 2: + raise typer.BadParameter("Нужно минимум два игрока.") + return names + + +def _parse_layers(hidden: str) -> List[int]: + try: + layers = [int(value) for value in hidden.split(",") if value.strip()] + except ValueError as exc: + raise typer.BadParameter("Слои должны быть целыми числами через запятую.") from exc + return layers or [256, 256] + + +@app.command() +def reinforce( + players: str = typer.Option("Alpha,Beta,Gamma,Delta", help="Игроки через запятую для тренировки"), + episodes: int = typer.Option(50, help="Количество эпизодов обучения"), + seed: Optional[int] = typer.Option(None, help="Seed генератора"), + lr: float = typer.Option(3e-4, help="Learning rate для оптимизации"), + gamma: float = typer.Option(0.99, help="Коэффициент дисконтирования"), + device: str = typer.Option("cpu", help="Устройство PyTorch (cpu/cuda)"), + hidden_layers: str = typer.Option("256,256", help="Слои сети через запятую"), + output: Optional[str] = typer.Option( + None, help="Путь для сохранения state_dict обученной политики" + ), +) -> None: + names = _parse_players(players) + layers = _parse_layers(hidden_layers) + config = GameConfig(player_names=names, seed=seed) + env = CatanEnv(config) + trainer = ReinforcementLearningTrainer( + env, + hidden_layers=layers, + lr=lr, + gamma=gamma, + device=device, + ) + typer.echo(f"Запуск обучения на {episodes} эпизодов...") + rewards = trainer.train(episodes=episodes) + typer.echo("Средние награды по эпизодам:") + for idx, reward in enumerate(rewards, start=1): + typer.echo(f"{idx:03d}: {reward:.3f}") + if output: + torch.save(trainer.policy.state_dict(), output) + typer.echo(f"Модель сохранена в {output}") + + +@app.command() +def selfplay( + players: str = typer.Option("Alpha,Beta,Gamma,Delta", help="Пул игроков через запятую"), + min_players: int = typer.Option(2, help="Минимум игроков в эпизоде"), + max_players: int = typer.Option(4, help="Максимум игроков в эпизоде"), + total_steps: int = typer.Option(500_000, help="Количество шагов сбора опыта"), + rollout_size: int = typer.Option(4096, help="Размер батча rollout"), + mini_batch: int = typer.Option(512, help="Размер минибатча PPO"), + epochs: int = typer.Option(4, help="PPO эпох на обновление"), + gamma: float = typer.Option(0.99, help="Коэффициент дисконтирования"), + gae_lambda: float = typer.Option(0.95, help="Лямбда GAE"), + clip_coef: float = typer.Option(0.2, help="PPO clip"), + entropy_coef: float = typer.Option(0.01, help="Коэффициент энтропии"), + value_coef: float = typer.Option(0.5, help="Коэффициент value loss"), + lr: float = typer.Option(3e-4, help="Learning rate"), + device: str = typer.Option("cpu", help="Устройство PyTorch"), + seed: Optional[int] = typer.Option(None, help="Seed генератора"), + output: Optional[str] = typer.Option(None, help="Путь для сохранения весов"), +) -> None: + names = _parse_players(players) + if min_players < 2: + raise typer.BadParameter("Минимум игроков не может быть меньше 2.") + if max_players < min_players: + raise typer.BadParameter("Максимум игроков должен быть >= минимуму.") + if max_players > len(names): + raise typer.BadParameter("Максимум игроков превышает размер пула.") + config = PPOConfig( + total_steps=total_steps, + rollout_size=rollout_size, + mini_batch_size=mini_batch, + epochs=epochs, + gamma=gamma, + gae_lambda=gae_lambda, + clip_coef=clip_coef, + entropy_coef=entropy_coef, + value_coef=value_coef, + lr=lr, + device=device, + seed=seed, + player_pool=names, + min_players=min_players, + max_players=max_players, + ) + trainer = SelfPlayPPOTrainer(GameConfig(player_names=names, seed=seed), config) + trainer.train() + if output: + trainer.save(output) + typer.echo(f"Self-play модель сохранена в {output}") + + +if __name__ == "__main__": + app() diff --git a/catan/ml/__init__.py b/catan/ml/__init__.py new file mode 100644 index 0000000..d708286 --- /dev/null +++ b/catan/ml/__init__.py @@ -0,0 +1,24 @@ +""" +Machine learning utilities for Catan: +- Agents (random, policy-based) +- Observation/action encoders +- Trainers for reinforcement learning and evolutionary strategies +""" + +from .agents import Agent, PolicyAgent, RandomAgent, finalize_action +from .encoding import encode_action, encode_observation +from .trainers import EvolutionStrategyTrainer, ReinforcementLearningTrainer +from .selfplay import PPOConfig, SelfPlayPPOTrainer + +__all__ = [ + "Agent", + "PolicyAgent", + "RandomAgent", + "encode_action", + "encode_observation", + "ReinforcementLearningTrainer", + "EvolutionStrategyTrainer", + "finalize_action", + "SelfPlayPPOTrainer", + "PPOConfig", +] diff --git a/catan/ml/agents.py b/catan/ml/agents.py new file mode 100644 index 0000000..abe3332 --- /dev/null +++ b/catan/ml/agents.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +import random +from typing import Dict, List, Optional, Protocol + +from ..data import Resource +from ..sdk import Action, ActionType, CatanEnv, parse_resource + + +class Agent(Protocol): + def choose_action(self, env: CatanEnv, legal_actions: List[Action]) -> Action: + """Select an action from the legal action list.""" + + +def _resource_from_value(value: str | Resource | None) -> Optional[Resource]: + if value is None: + return None + if isinstance(value, Resource): + return value + return parse_resource(value) + + +def finalize_action(env: CatanEnv, template: Action, rng: Optional[random.Random] = None) -> Action: + payload = dict(template.payload or {}) + action_type = template.type + + if action_type in {ActionType.MOVE_ROBBER, ActionType.PLAY_KNIGHT}: + options = payload.get("options", []) + if not options: + raise ValueError("No robber options available.") + choice = rng.choice(options) if rng else options[0] + new_payload: Dict[str, object] = {"hex": choice["hex"]} + victims = choice.get("victims") or [] + if victims: + new_payload["victim"] = rng.choice(victims) if rng else victims[0] + return Action(action_type, new_payload) + + if action_type == ActionType.PLAY_ROAD_BUILDING: + edges = payload.get("edges", []) + if not edges: + return Action(ActionType.END_TURN, {}) + if rng: + picks = rng.sample(edges, k=min(2, len(edges))) + while len(picks) < 2: + picks.append(rng.choice(edges)) + else: + picks = edges[:2] if len(edges) >= 2 else edges + edges[: 2 - len(edges)] + return Action(action_type, {"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: + raise ValueError("No bank resources available for Year of Plenty.") + def pick() -> str: + return rng.choice(available) if rng else available[0] + + return Action(action_type, {"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: + raise ValueError("No resources listed for Monopoly.") + selection = rng.choice(choices) if rng else choices[0] + return Action(action_type, {"resource": selection}) + + if action_type == ActionType.DISCARD: + required = payload.get("required") + if not isinstance(required, int): + raise ValueError("Discard action missing required count.") + player_name = payload.get("player", env.game.current_player.name) + player = env.game.player_by_name(player_name) + cards: Dict[Resource, int] = {res: 0 for res in Resource if res != Resource.DESERT} + total = required + resource_list: List[Resource] = [] + for resource, count in player.resources.items(): + if resource == Resource.DESERT or count <= 0: + continue + resource_list.extend([resource] * count) + if rng: + rng.shuffle(resource_list) + else: + resource_list.sort(key=lambda r: (-player.resources[r], r.value)) + for res in resource_list: + if total <= 0: + break + cards[res] += 1 + total -= 1 + card_payload = {res.value: amount for res, amount in cards.items() if amount > 0} + return Action( + ActionType.DISCARD, + {"player": player_name, "cards": card_payload}, + ) + + return Action(action_type, payload) + + +class RandomAgent: + def __init__(self, seed: Optional[int] = None): + self.rng = random.Random(seed) + + def choose_action(self, env: CatanEnv, legal_actions: List[Action]) -> Action: + if not legal_actions: + raise ValueError("No legal actions available.") + template = self.rng.choice(legal_actions) + return finalize_action(env, template, self.rng) + + +class PolicyAgent: + def __init__(self, policy, device: str = "cpu", stochastic: bool = True): + self.policy = policy + self.device = device + self.stochastic = stochastic + + def choose_action(self, env: CatanEnv, legal_actions: List[Action]) -> Action: + from .encoding import encode_action, encode_observation # Lazy import to avoid cycles + import torch + + if not legal_actions: + raise ValueError("No legal actions available.") + obs = encode_observation(env.observe()) + obs_tensor = torch.tensor(obs, dtype=torch.float32, device=self.device) + action_vectors = torch.tensor( + [encode_action(action) for action in legal_actions], + dtype=torch.float32, + device=self.device, + ) + logits = self.policy(obs_tensor, action_vectors) + probs = torch.softmax(logits, dim=0) + if self.stochastic: + dist = torch.distributions.Categorical(probs=probs) + idx = dist.sample().item() + else: + idx = torch.argmax(probs).item() + selected = legal_actions[idx] + return finalize_action(env, selected, None) diff --git a/catan/ml/encoding.py b/catan/ml/encoding.py new file mode 100644 index 0000000..690fced --- /dev/null +++ b/catan/ml/encoding.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +from typing import Dict, List + +import numpy as np + +from ..data import Resource +from ..sdk import Action, ActionType, parse_resource + +RESOURCE_ORDER: List[Resource] = [ + Resource.BRICK, + Resource.LUMBER, + Resource.WOOL, + Resource.GRAIN, + Resource.ORE, +] + +ACTION_TYPES = list(ActionType) + + +def encode_observation(obs: Dict[str, object]) -> np.ndarray: + game = obs["game"] + current_name = game["current_player"] + player_info = game["players"][current_name] + features: List[float] = [] + + resources: Dict[Resource, int] = player_info["resources"] + for resource in RESOURCE_ORDER: + features.append(float(resources.get(resource, 0))) + + features.extend( + [ + float(player_info["victory_points"]), + float(player_info["largest_army"]), + float(player_info["longest_road"]), + float(len(player_info["settlements"])), + float(len(player_info["cities"])), + float(len(player_info["roads"])), + ] + ) + features.append(1.0 if game["phase"] == "main" else 0.0) + features.append(1.0 if game.get("robber_required") else 0.0) + features.append(float(len(game.get("pending_discards", [])))) + bank = game.get("bank", {}) + for resource in RESOURCE_ORDER: + features.append(float(bank.get(resource.value, 0))) + return np.array(features, dtype=np.float32) + + +def _resource_vector(value: object) -> List[float]: + vector = [0.0] * len(RESOURCE_ORDER) + if value is None: + return vector + if isinstance(value, list): + values = value + else: + values = [value] + for item in values: + if not item: + continue + resource = parse_resource(item) if not isinstance(item, Resource) else item + if resource == Resource.DESERT: + continue + idx = RESOURCE_ORDER.index(resource) + vector[idx] += 1.0 + return vector + + +def encode_action(action: Action) -> np.ndarray: + payload = action.payload or {} + vector: List[float] = [] + type_vec = [0.0] * len(ACTION_TYPES) + type_vec[ACTION_TYPES.index(action.type)] = 1.0 + vector.extend(type_vec) + + for key in ("corner", "road", "edge", "hex", "amount"): + value = float(payload.get(key, 0)) + vector.append(value / 100.0) + + vector.extend(_resource_vector(payload.get("resource"))) + vector.extend(_resource_vector(payload.get("give"))) + vector.extend(_resource_vector(payload.get("receive"))) + + return np.array(vector, dtype=np.float32) diff --git a/catan/ml/policies.py b/catan/ml/policies.py new file mode 100644 index 0000000..6d51da8 --- /dev/null +++ b/catan/ml/policies.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from typing import Iterable, Sequence + +import torch +from torch import nn + + +class PolicyNetwork(nn.Module): + def __init__(self, obs_dim: int, action_dim: int, hidden_layers: Sequence[int] = (256, 256)): + super().__init__() + layers: list[nn.Module] = [] + input_dim = obs_dim + action_dim + for hidden in hidden_layers: + layers.append(nn.Linear(input_dim, hidden)) + layers.append(nn.ReLU()) + input_dim = hidden + layers.append(nn.Linear(input_dim, 1)) + self.model = nn.Sequential(*layers) + + def forward(self, obs: torch.Tensor, action_batch: torch.Tensor) -> torch.Tensor: + if obs.dim() == 1: + obs = obs.unsqueeze(0) + obs_expanded = obs.repeat(action_batch.shape[0], 1) + x = torch.cat([obs_expanded, action_batch], dim=1) + return self.model(x).squeeze(-1) diff --git a/catan/ml/selfplay.py b/catan/ml/selfplay.py new file mode 100644 index 0000000..75b836c --- /dev/null +++ b/catan/ml/selfplay.py @@ -0,0 +1,300 @@ +from __future__ import annotations + +import random +from dataclasses import dataclass, field +from typing import Dict, List, Optional, Sequence + +import numpy as np +import torch +from torch import nn +from torch.nn import functional as F + +from ..game import GameConfig +from ..sdk import Action, ActionType, CatanEnv +from .encoding import encode_action, encode_observation +from .agents import finalize_action + + +@dataclass +class Transition: + observation: np.ndarray + action_vectors: np.ndarray + action_index: int + log_prob: float + value: float + reward: float + done: bool + + +@dataclass +class PPOConfig: + total_steps: int = 200_000 + rollout_size: int = 4096 + mini_batch_size: int = 512 + epochs: int = 4 + gamma: float = 0.99 + gae_lambda: float = 0.95 + clip_coef: float = 0.2 + entropy_coef: float = 0.01 + value_coef: float = 0.5 + lr: float = 3e-4 + hidden_sizes: Sequence[int] = (512, 512) + device: str = "cpu" + win_reward: float = 10.0 + loss_penalty: float = 1.0 + seed: Optional[int] = None + player_pool: Optional[Sequence[str]] = None + min_players: Optional[int] = None + max_players: Optional[int] = None + + +class ActionScoringNetwork(nn.Module): + def __init__(self, obs_dim: int, action_dim: int, hidden_sizes: Sequence[int]) -> None: + super().__init__() + layers: List[nn.Module] = [] + input_dim = obs_dim + action_dim + for hidden in hidden_sizes: + layers.extend([nn.Linear(input_dim, hidden), nn.ReLU()]) + input_dim = hidden + layers.append(nn.Linear(input_dim, 1)) + self.network = nn.Sequential(*layers) + + def forward(self, obs: torch.Tensor, action_batch: torch.Tensor) -> torch.Tensor: + if obs.dim() == 1: + obs = obs.unsqueeze(0) + obs_expanded = obs.repeat(action_batch.shape[0], 1) + logits = self.network(torch.cat([obs_expanded, action_batch], dim=-1)) + return logits.squeeze(-1) + + +class ValueNetwork(nn.Module): + def __init__(self, obs_dim: int, hidden_sizes: Sequence[int]) -> None: + super().__init__() + layers: List[nn.Module] = [] + input_dim = obs_dim + for hidden in hidden_sizes: + layers.extend([nn.Linear(input_dim, hidden), nn.ReLU()]) + input_dim = hidden + layers.append(nn.Linear(input_dim, 1)) + self.network = nn.Sequential(*layers) + + def forward(self, obs: torch.Tensor) -> torch.Tensor: + return self.network(obs).squeeze(-1) + + +class SelfPlayPPOTrainer: + def __init__( + self, + game_config: GameConfig, + config: PPOConfig, + ) -> None: + self.cfg = config + self.game_config = game_config + self.device = torch.device(config.device) + self.rng = random.Random(config.seed) + self.player_pool = list(config.player_pool or game_config.player_names) + self.min_players = max(2, config.min_players or len(self.player_pool)) + self.max_players = min(4, config.max_players or len(self.player_pool)) + if self.min_players > self.max_players: + raise ValueError("min_players cannot exceed max_players.") + self.color_map = self._build_color_map(game_config) + self.env = CatanEnv(game_config) + obs_dim = encode_observation(self.env.observe()).shape[0] + dummy_action = encode_action(Action(ActionType.END_TURN, {})) + action_dim = dummy_action.shape[0] + self.actor = ActionScoringNetwork(obs_dim, action_dim, config.hidden_sizes).to(self.device) + self.critic = ValueNetwork(obs_dim, config.hidden_sizes[::-1]).to(self.device) + self.optimizer = torch.optim.Adam( + list(self.actor.parameters()) + list(self.critic.parameters()), + lr=config.lr, + ) + self.buffer: List[Transition] = [] + self.total_steps = 0 + self.update_steps = 0 + self.episode_counter = 0 + self.current_episode_reward = 0.0 + self.recent_rewards: List[float] = [] + self.win_counts: Dict[str, int] = {name: 0 for name in self.player_pool} + self._reset_env_for_episode() + + def train(self, log_interval: int = 10) -> None: + while self.total_steps < self.cfg.total_steps: + steps = self._collect_rollout() + self.total_steps += steps + metrics = self._update_policy() + self.update_steps += 1 + if self.update_steps % log_interval == 0: + avg_reward = np.mean(self.recent_rewards[-log_interval:]) if self.recent_rewards else 0.0 + typer_msg = ( + f"[PPO] step={self.total_steps} updates={self.update_steps} " + f"loss={metrics['loss']:.4f} avg_adv={metrics['adv']:.4f} " + f"reward={avg_reward:.3f}" + ) + print(typer_msg) + + def save(self, path: str) -> None: + state = { + "actor": self.actor.state_dict(), + "critic": self.critic.state_dict(), + "config": self.cfg, + "game_config": self.game_config, + } + torch.save(state, path) + + def _collect_rollout(self) -> int: + self.buffer.clear() + steps = 0 + while steps < self.cfg.rollout_size: + observation = self.env.observe() + obs_vec = encode_observation(observation) + player_name = self.env.game.current_player.name + legal_actions = self.env.legal_actions() + if not legal_actions: + break + encoded_actions = np.stack([encode_action(action) for action in legal_actions]) + value = ( + self.critic(torch.tensor(obs_vec, dtype=torch.float32, device=self.device)) + .detach() + .item() + ) + action_index, log_prob = self._sample_action(obs_vec, encoded_actions, legal_actions) + selected_template = legal_actions[action_index] + resolved_action = finalize_action(self.env, selected_template, self.rng) + _, reward, done, info = self.env.step(resolved_action) + if done and info.get("winner"): + if info["winner"] == player_name: + reward += self.cfg.win_reward + else: + reward -= self.cfg.loss_penalty + self.win_counts[info["winner"]] += 1 + self.buffer.append( + Transition( + observation=obs_vec, + action_vectors=encoded_actions, + action_index=action_index, + log_prob=log_prob, + value=value, + reward=reward, + done=done, + ) + ) + steps += 1 + self.current_episode_reward += reward + if done: + self.episode_counter += 1 + self.recent_rewards.append(self.current_episode_reward) + self.current_episode_reward = 0.0 + self._reset_env_for_episode() + return steps + + def _build_color_map(self, config: GameConfig) -> Dict[str, str]: + if not config.colors: + return {} + return {name: color for name, color in zip(config.player_names, config.colors)} + + def _select_player_names(self) -> List[str]: + count = self.rng.randint(self.min_players, self.max_players) + if count >= len(self.player_pool): + return list(self.player_pool) + return self.rng.sample(self.player_pool, k=count) + + def _reset_env_for_episode(self) -> None: + names = self._select_player_names() + colors = [self.color_map.get(name, "player") for name in names] if self.color_map else None + seed = self.rng.randint(0, 2**31 - 1) + config = GameConfig(player_names=names, colors=colors, seed=seed) + self.env = CatanEnv(config) + + def _sample_action( + self, + obs_vec: np.ndarray, + action_vectors: np.ndarray, + legal_actions: List[Action], + ) -> tuple[int, float]: + obs_tensor = torch.tensor(obs_vec, dtype=torch.float32, device=self.device) + actions_tensor = torch.tensor(action_vectors, dtype=torch.float32, device=self.device) + logits = self.actor(obs_tensor, actions_tensor) + probs = torch.softmax(logits, dim=0) + dist = torch.distributions.Categorical(probs=probs) + index = dist.sample().item() + log_prob = dist.log_prob(torch.tensor(index, device=self.device)).item() + return index, log_prob + + def _update_policy(self) -> Dict[str, float]: + advantages, returns = self._compute_advantages() + advantages = torch.tensor(advantages, dtype=torch.float32, device=self.device) + returns = torch.tensor(returns, dtype=torch.float32, device=self.device) + indices = list(range(len(self.buffer))) + random.shuffle(indices) + losses: List[float] = [] + avg_adv: List[float] = [] + for start in range(0, len(indices), self.cfg.mini_batch_size): + batch_idx = indices[start : start + self.cfg.mini_batch_size] + if not batch_idx: + continue + obs_batch = torch.tensor( + np.stack([self.buffer[i].observation for i in batch_idx]), + dtype=torch.float32, + device=self.device, + ) + old_log_probs = torch.tensor( + [self.buffer[i].log_prob for i in batch_idx], + dtype=torch.float32, + device=self.device, + ) + batch_advantages = advantages[batch_idx] + batch_advantages = (batch_advantages - batch_advantages.mean()) / ( + batch_advantages.std() + 1e-8 + ) + log_probs, entropy = self._evaluate_log_probs(batch_idx) + ratios = torch.exp(log_probs - old_log_probs) + unclipped = ratios * batch_advantages + clipped = torch.clamp(ratios, 1.0 - self.cfg.clip_coef, 1.0 + self.cfg.clip_coef) * batch_advantages + policy_loss = -torch.min(unclipped, clipped).mean() + values = self.critic(obs_batch) + value_loss = F.mse_loss(values, returns[batch_idx]) + entropy_bonus = entropy.mean() + loss = ( + policy_loss + + self.cfg.value_coef * value_loss + - self.cfg.entropy_coef * entropy_bonus + ) + self.optimizer.zero_grad() + loss.backward() + nn.utils.clip_grad_norm_(self.actor.parameters(), max_norm=0.5) + nn.utils.clip_grad_norm_(self.critic.parameters(), max_norm=0.5) + self.optimizer.step() + losses.append(loss.item()) + avg_adv.append(batch_advantages.mean().item()) + return {"loss": float(np.mean(losses)), "adv": float(np.mean(avg_adv))} + + def _evaluate_log_probs(self, batch_idx: List[int]) -> tuple[torch.Tensor, torch.Tensor]: + log_probs: List[torch.Tensor] = [] + entropies: List[torch.Tensor] = [] + for idx in batch_idx: + transition = self.buffer[idx] + obs_tensor = torch.tensor( + transition.observation, dtype=torch.float32, device=self.device + ) + actions_tensor = torch.tensor( + transition.action_vectors, dtype=torch.float32, device=self.device + ) + logits = self.actor(obs_tensor, actions_tensor) + probs = torch.softmax(logits, dim=0) + log_probs.append(torch.log(probs[transition.action_index] + 1e-8)) + entropies.append(-(probs * torch.log(probs + 1e-8)).sum()) + return torch.stack(log_probs), torch.stack(entropies) + + def _compute_advantages(self) -> tuple[List[float], List[float]]: + advantages: List[float] = [] + returns: List[float] = [] + gae = 0.0 + next_value = 0.0 + for transition in reversed(self.buffer): + mask = 0.0 if transition.done else 1.0 + delta = transition.reward + self.cfg.gamma * next_value * mask - transition.value + gae = delta + self.cfg.gamma * self.cfg.gae_lambda * mask * gae + advantages.insert(0, gae) + returns.insert(0, gae + transition.value) + next_value = transition.value + return advantages, returns diff --git a/catan/ml/trainers.py b/catan/ml/trainers.py new file mode 100644 index 0000000..04c5ba6 --- /dev/null +++ b/catan/ml/trainers.py @@ -0,0 +1,158 @@ +from __future__ import annotations + +from dataclasses import dataclass +import random +from typing import List, Sequence + +import numpy as np +import torch +from torch import nn +from torch.nn.utils import parameters_to_vector, vector_to_parameters + +from ..sdk import Action, ActionType, CatanEnv +from .agents import PolicyAgent +from .encoding import encode_action, encode_observation +from .policies import PolicyNetwork + + +def _ensure_tensor(data, device: str = "cpu") -> torch.Tensor: + return torch.tensor(data, dtype=torch.float32, device=device) + + +class ReinforcementLearningTrainer: + def __init__( + self, + env: CatanEnv, + hidden_layers: Sequence[int] = (256, 256), + lr: float = 3e-4, + gamma: float = 0.99, + device: str = "cpu", + ) -> None: + self.env = env + self.gamma = gamma + self.device = device + obs_dim = encode_observation(env.observe()).shape[0] + dummy_action = Action(ActionType.END_TURN, {}) + action_dim = encode_action(dummy_action).shape[0] + self.policy = PolicyNetwork(obs_dim, action_dim, hidden_layers).to(device) + self.optimizer = torch.optim.Adam(self.policy.parameters(), lr=lr) + + def select_action(self, observation, legal_actions: List[Action]) -> tuple[Action, torch.Tensor]: + obs_tensor = _ensure_tensor(observation, self.device) + action_vectors = torch.stack( + [_ensure_tensor(encode_action(action), self.device) for action in legal_actions] + ) + logits = self.policy(obs_tensor, action_vectors) + probs = torch.softmax(logits, dim=0) + dist = torch.distributions.Categorical(probs=probs) + idx = dist.sample() + action = legal_actions[idx.item()] + log_prob = dist.log_prob(idx) + from .agents import finalize_action + + return finalize_action(self.env, action, None), log_prob + + def run_episode(self) -> tuple[List[torch.Tensor], List[float]]: + observation = self.env.reset() + log_probs: List[torch.Tensor] = [] + rewards: List[float] = [] + done = False + while not done: + legal_actions = self.env.legal_actions() + action, log_prob = self.select_action(observation, legal_actions) + observation, reward, done, _ = self.env.step(action) + log_probs.append(log_prob) + rewards.append(float(reward)) + return log_probs, rewards + + def train(self, episodes: int = 50) -> List[float]: + history: List[float] = [] + for _ in range(episodes): + log_probs, rewards = self.run_episode() + returns = self._discounted_returns(rewards) + loss = 0.0 + for log_prob, ret in zip(log_probs, returns): + loss = loss - log_prob * ret + self.optimizer.zero_grad() + loss.backward() + self.optimizer.step() + history.append(sum(rewards)) + return history + + def _discounted_returns(self, rewards: List[float]) -> List[float]: + accumulator = 0.0 + returns: List[float] = [] + for reward in reversed(rewards): + accumulator = reward + self.gamma * accumulator + returns.append(accumulator) + returns.reverse() + mean = np.mean(returns) if returns else 0.0 + std = np.std(returns) if returns else 1.0 + std = std if std > 1e-6 else 1.0 + return [(ret - mean) / std for ret in returns] + + +@dataclass +class EvolutionConfig: + population_size: int = 20 + elite_fraction: float = 0.2 + mutation_scale: float = 0.1 + episodes_per_candidate: int = 1 + + +class EvolutionStrategyTrainer: + def __init__( + self, + env: CatanEnv, + hidden_layers: Sequence[int] = (256, 256), + device: str = "cpu", + config: EvolutionConfig | None = None, + ) -> None: + self.env = env + self.device = device + obs_dim = encode_observation(env.observe()).shape[0] + action_dim = encode_action(Action(ActionType.END_TURN, {})).shape[0] + self.policy = PolicyNetwork(obs_dim, action_dim, hidden_layers).to(device) + self.config = config or EvolutionConfig() + self.vector_length = len(parameters_to_vector(self.policy.parameters())) + + def evaluate(self, weights: torch.Tensor) -> float: + vector_to_parameters(weights, self.policy.parameters()) + agent = PolicyAgent(self.policy, device=self.device, stochastic=False) + total_reward = 0.0 + for episode in range(self.config.episodes_per_candidate): + observation = self.env.reset(seed=episode) + done = False + while not done: + legal_actions = self.env.legal_actions() + action = agent.choose_action(self.env, legal_actions) + observation, reward, done, _ = self.env.step(action) + total_reward += reward + return total_reward + + def evolve(self, generations: int = 20) -> torch.Tensor: + population = [ + torch.randn(self.vector_length, device=self.device) * 0.1 + for _ in range(self.config.population_size) + ] + best_vector = population[0] + best_score = float("-inf") + elite_size = max(1, int(self.config.population_size * self.config.elite_fraction)) + for _ in range(generations): + scores = [] + for candidate in population: + score = self.evaluate(candidate.clone()) + scores.append((score, candidate)) + if score > best_score: + best_score = score + best_vector = candidate.clone() + scores.sort(key=lambda item: item[0], reverse=True) + elites = [candidate for _, candidate in scores[:elite_size]] + new_population = elites.copy() + while len(new_population) < self.config.population_size: + parent = random.choice(elites) + child = parent + torch.randn_like(parent) * self.config.mutation_scale + new_population.append(child) + population = new_population + vector_to_parameters(best_vector, self.policy.parameters()) + return best_vector diff --git a/catan/player.py b/catan/player.py new file mode 100644 index 0000000..faf9e89 --- /dev/null +++ b/catan/player.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Dict, List, Optional, Set + +from .data import ( + BUILD_COSTS, + EdgeID, + Resource, + SETTLEMENT_LIMIT, + CITY_LIMIT, + ROAD_LIMIT, + CornerCoord, + DevelopmentCard, +) + + +def empty_resource_pool() -> Dict[Resource, int]: + return {resource: 0 for resource in Resource if resource != Resource.DESERT} + + +@dataclass +class Player: + name: str + color: str + resources: Dict[Resource, int] = field(default_factory=empty_resource_pool) + roads: Set[EdgeID] = field(default_factory=set) + settlements: Set[CornerCoord] = field(default_factory=set) + cities: Set[CornerCoord] = field(default_factory=set) + dev_cards: List[DevelopmentCard] = field(default_factory=list) + new_dev_cards: List[DevelopmentCard] = field(default_factory=list) + played_knights: int = 0 + hidden_points: int = 0 + largest_army: bool = False + longest_road: bool = False + + def settlements_remaining(self) -> int: + return SETTLEMENT_LIMIT - len(self.settlements) + + def cities_remaining(self) -> int: + return CITY_LIMIT - len(self.cities) + + def roads_remaining(self) -> int: + return ROAD_LIMIT - len(self.roads) + + def add_resources(self, payouts: Dict[Resource, int]) -> None: + for resource, amount in payouts.items(): + if resource == Resource.DESERT: + continue + self.resources[resource] += amount + + def can_afford(self, cost_label: str) -> bool: + cost = BUILD_COSTS[cost_label] + return all(self.resources[res] >= amount for res, amount in cost.items()) + + def pay_cost(self, cost_label: str) -> None: + if not self.can_afford(cost_label): + raise ValueError(f"{self.name} cannot afford {cost_label}") + for resource, amount in BUILD_COSTS[cost_label].items(): + self.resources[resource] -= amount + + def total_resources(self) -> int: + return sum(self.resources.values()) + + def victory_points(self) -> int: + points = len(self.settlements) + 2 * len(self.cities) + self.hidden_points + if self.largest_army: + points += 2 + if self.longest_road: + points += 2 + return points + + def spend_resources(self, cards: Dict[Resource, int]) -> None: + for resource, amount in cards.items(): + if self.resources[resource] < amount: + raise ValueError("Insufficient resources") + for resource, amount in cards.items(): + self.resources[resource] -= amount + + def receive_resource(self, resource: Resource, amount: int = 1) -> None: + if resource != Resource.DESERT: + self.resources[resource] += amount + + def lose_random_cards(self, amount: int, rng) -> Dict[Resource, int]: + lost: Dict[Resource, int] = {res: 0 for res in self.resources} + for _ in range(amount): + available = [res for res, count in self.resources.items() for _ in range(count)] + if not available: + break + res = rng.choice(available) + self.resources[res] -= 1 + lost[res] += 1 + return lost diff --git a/catan/sdk.py b/catan/sdk.py new file mode 100644 index 0000000..82fbcb7 --- /dev/null +++ b/catan/sdk.py @@ -0,0 +1,329 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum +from typing import Dict, List, Optional, Sequence + +from .data import DevelopmentCard, Resource +from .game import Game, GameConfig, Phase + + +class ActionType(Enum): + PLACE_INITIAL = "place_initial" + ROLL = "roll" + BUILD_ROAD = "build_road" + BUILD_SETTLEMENT = "build_settlement" + BUILD_CITY = "build_city" + BUY_DEVELOPMENT = "buy_development" + PLAY_KNIGHT = "play_knight" + PLAY_ROAD_BUILDING = "play_road_building" + PLAY_YEAR_OF_PLENTY = "play_year_of_plenty" + PLAY_MONOPOLY = "play_monopoly" + TRADE_BANK = "trade_bank" + TRADE_PLAYER = "trade_player" + MOVE_ROBBER = "move_robber" + DISCARD = "discard" + END_TURN = "end_turn" + + +@dataclass +class Action: + type: ActionType + payload: Dict[str, object] = field(default_factory=dict) + + +def parse_resource(value: str | Resource) -> Resource: + if isinstance(value, Resource): + return value + return Resource[value.upper()] + + +class CatanEnv: + def __init__(self, config: GameConfig): + self.base_config = config + self.game = Game(config) + + def reset(self, seed: Optional[int] = None): + config = GameConfig( + player_names=self.base_config.player_names, + colors=self.base_config.colors, + seed=seed if seed is not None else self.base_config.seed, + ) + self.game = Game(config) + return self.observe() + + def observe(self) -> Dict[str, object]: + return { + "game": self.game.observation(), + "board": self.game.board.serialize(), + } + + def legal_actions(self) -> List[Action]: + game = self.game + player = game.current_player + actions: List[Action] = [] + if game.phase in (Phase.SETUP_ROUND_ONE, Phase.SETUP_ROUND_TWO): + actions.extend(self._setup_actions()) + return actions + if game.pending_discards: + for name, required in game.pending_discards.items(): + actor = game.player_by_name(name) + actions.append( + Action( + ActionType.DISCARD, + { + "player": name, + "required": required, + "resources": dict(actor.resources), + }, + ) + ) + return actions + if game.robber_move_required: + actions.extend(self._robber_actions()) + return actions + playable_dev = self._playable_dev_cards() + actions.extend(playable_dev) + if not game.has_rolled: + actions.append(Action(ActionType.ROLL)) + return actions + actions.extend(self._build_actions()) + if game.dev_deck and player.can_afford("development"): + actions.append(Action(ActionType.BUY_DEVELOPMENT)) + actions.extend(self._bank_trade_actions()) + actions.extend(self._player_trade_actions()) + actions.append(Action(ActionType.END_TURN)) + return actions + + def _setup_actions(self) -> List[Action]: + actions: List[Action] = [] + board = self.game.board + for corner_id, corner in board.corners.items(): + if not board.can_place_settlement(corner_id, True): + continue + for edge_id in corner.edges: + edge = board.edges[edge_id] + if edge.owner: + continue + payload = { + "corner": board.corner_label(corner_id), + "road": board.edge_label(edge_id), + } + actions.append(Action(ActionType.PLACE_INITIAL, payload)) + return actions + + def _robber_options(self) -> List[Dict[str, object]]: + board = self.game.board + options = [] + for hex_id in board.hexes: + if hex_id == board.robber_hex: + continue + victims = [] + for corner_id in board.hexes[hex_id].corners: + owner = board.corners[corner_id].owner + if owner and owner != self.game.current_player.name: + victims.append(owner) + options.append({"hex": hex_id, "victims": sorted(set(victims))}) + return options + + def _robber_actions(self) -> List[Action]: + return [Action(ActionType.MOVE_ROBBER, {"options": self._robber_options()})] + + def _playable_dev_cards(self) -> List[Action]: + player = self.game.current_player + cards: List[Action] = [] + available = [ + card + for card in player.dev_cards + if card not in player.new_dev_cards + ] + robber_options = self._robber_options() + road_options = self._road_spots() + for card in available: + if card == DevelopmentCard.KNIGHT: + cards.append( + Action(ActionType.PLAY_KNIGHT, {"options": robber_options}) + ) + elif card == DevelopmentCard.ROAD_BUILDING: + cards.append( + Action(ActionType.PLAY_ROAD_BUILDING, {"edges": road_options}) + ) + elif card == DevelopmentCard.YEAR_OF_PLENTY: + cards.append( + Action( + ActionType.PLAY_YEAR_OF_PLENTY, + {"bank": {res.value: amt for res, amt in self.game.bank.items()}}, + ) + ) + elif card == DevelopmentCard.MONOPOLY: + cards.append( + Action( + ActionType.PLAY_MONOPOLY, + {"resources": [res.value for res in Resource if res != Resource.DESERT]}, + ) + ) + return cards + + def _road_spots(self) -> List[int]: + spots: List[int] = [] + board = self.game.board + player = self.game.current_player + for edge_id, edge in board.edges.items(): + if edge.owner: + continue + if self.game._road_connection_valid(player, edge): + spots.append(board.edge_label(edge_id)) + return spots + + def _build_actions(self) -> List[Action]: + actions: List[Action] = [] + game = self.game + player = game.current_player + board = game.board + if player.can_afford("road"): + for edge_label in self._road_spots(): + actions.append(Action(ActionType.BUILD_ROAD, {"edge": edge_label})) + if player.can_afford("settlement") and player.settlements_remaining() > 0: + for corner_id in board.corners: + if not board.can_place_settlement(corner_id, True): + continue + if not game._has_adjacent_road(player, corner_id): + continue + actions.append( + Action( + ActionType.BUILD_SETTLEMENT, + {"corner": board.corner_label(corner_id)}, + ) + ) + if player.can_afford("city") and player.cities_remaining() > 0: + for corner_id in list(player.settlements): + actions.append( + Action( + ActionType.BUILD_CITY, + {"corner": board.corner_label(corner_id)}, + ) + ) + return actions + + def _bank_trade_actions(self) -> List[Action]: + actions: List[Action] = [] + player = self.game.current_player + for give in Resource: + if give == Resource.DESERT: + continue + ratio = self.game._bank_trade_ratio(player, give) + if player.resources[give] < ratio: + continue + for receive in Resource: + if receive == Resource.DESERT: + continue + actions.append( + Action( + ActionType.TRADE_BANK, + { + "give": give.value, + "receive": receive.value, + "amount": 1, + "ratio": ratio, + }, + ) + ) + return actions + + def _player_trade_actions(self) -> List[Action]: + actions: List[Action] = [] + current = self.game.current_player + for give in Resource: + if give == Resource.DESERT or current.resources[give] <= 0: + continue + for opponent in self.game.players: + if opponent is current: + continue + for receive in Resource: + if ( + receive == Resource.DESERT + or opponent.resources[receive] <= 0 + or receive == give + ): + continue + actions.append( + Action( + ActionType.TRADE_PLAYER, + { + "target": opponent.name, + "offer": {give.value: 1}, + "request": {receive.value: 1}, + }, + ) + ) + return actions + + def step(self, action: Action): + actor = self.game.current_player.name + before = self.game.player_by_name(actor).victory_points() + try: + self._apply_action(action) + except ValueError as exc: + info = {"error": str(exc), "invalid": True} + return self.observe(), -1.0, False, info + after = self.game.player_by_name(actor).victory_points() + reward = after - before + return self.observe(), reward, self.game.winner is not None, {"winner": self.game.winner} + + def _apply_action(self, action: Action) -> None: + payload = action.payload or {} + if action.type == ActionType.PLACE_INITIAL: + self.game.place_initial_settlement( + corner_label=int(payload["corner"]), + road_label=int(payload["road"]), + ) + elif action.type == ActionType.ROLL: + self.game.roll_dice() + elif action.type == ActionType.BUILD_ROAD: + self.game.build_road(int(payload["edge"])) + elif action.type == ActionType.BUILD_SETTLEMENT: + self.game.build_settlement(int(payload["corner"])) + elif action.type == ActionType.BUILD_CITY: + self.game.build_city(int(payload["corner"])) + elif action.type == ActionType.BUY_DEVELOPMENT: + self.game.buy_development_card() + elif action.type == ActionType.PLAY_KNIGHT: + self.game.play_knight( + target_hex=int(payload["hex"]), + victim=payload.get("victim"), + ) + elif action.type == ActionType.PLAY_ROAD_BUILDING: + edges = [int(e) for e in payload.get("edges", [])] + self.game.play_road_building(edges) + elif action.type == ActionType.PLAY_YEAR_OF_PLENTY: + resources = [parse_resource(r) for r in payload.get("resources", [])] + self.game.play_year_of_plenty(resources) + elif action.type == ActionType.PLAY_MONOPOLY: + resource = parse_resource(payload["resource"]) + self.game.play_monopoly(resource) + elif action.type == ActionType.TRADE_BANK: + give = parse_resource(payload["give"]) + receive = parse_resource(payload["receive"]) + amount = int(payload.get("amount", 1)) + self.game.trade_with_bank(give, receive, amount) + elif action.type == ActionType.TRADE_PLAYER: + offer = {parse_resource(k): int(v) for k, v in payload.get("offer", {}).items()} + request = { + parse_resource(k): int(v) + for k, v in payload.get("request", {}).items() + } + self.game.trade_with_player(payload["target"], offer, request) + elif action.type == ActionType.MOVE_ROBBER: + self.game.move_robber( + int(payload["hex"]), payload.get("victim") + ) + elif action.type == ActionType.DISCARD: + cards = { + parse_resource(k): int(v) for k, v in payload["cards"].items() + } + player_name = payload.get("player", self.game.current_player.name) + self.game.discard_cards(player_name, cards) + elif action.type == ActionType.END_TURN: + self.game.end_turn() + else: + raise ValueError(f"Unsupported action {action.type}") diff --git a/catan/web/__init__.py b/catan/web/__init__.py new file mode 100644 index 0000000..f972016 --- /dev/null +++ b/catan/web/__init__.py @@ -0,0 +1 @@ +"""Web server package for Catan.""" diff --git a/catan/web/app.py b/catan/web/app.py new file mode 100644 index 0000000..c5d9e73 --- /dev/null +++ b/catan/web/app.py @@ -0,0 +1,648 @@ +from __future__ import annotations + +import asyncio +import datetime as dt +import json +import os +import uuid +from dataclasses import dataclass, field +from pathlib import Path +from typing import Dict, List, Optional + +import jwt +import torch +from fastapi import Depends, FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect +from fastapi.responses import FileResponse +from fastapi.staticfiles import StaticFiles +from passlib.context import CryptContext +from pydantic import BaseModel +from sqlmodel import Field, Session, SQLModel, create_engine, select +from torch.serialization import add_safe_globals + +from ..game import GameConfig +from ..sdk import Action, ActionType, CatanEnv +from ..ml.agents import PolicyAgent, RandomAgent +from ..ml.encoding import encode_action, encode_observation +from ..ml.selfplay import ActionScoringNetwork, PPOConfig + +BASE_DIR = Path(__file__).resolve().parent +PROJECT_DIR = BASE_DIR.parent.parent +STATIC_DIR = BASE_DIR / "static" +DATA_DIR = Path(os.environ.get("CATAN_DATA_DIR", PROJECT_DIR / "data")) +MODELS_DIR = Path(os.environ.get("CATAN_MODELS_DIR", PROJECT_DIR / "models")) +JWT_SECRET = os.environ.get("CATAN_JWT_SECRET", "change-me") +JWT_ALG = "HS256" +TOKEN_EXPIRY_DAYS = 7 + +DATA_DIR.mkdir(parents=True, exist_ok=True) +MODELS_DIR.mkdir(parents=True, exist_ok=True) + +DB_URL = f"sqlite:///{DATA_DIR / 'catan.db'}" +engine = create_engine(DB_URL, echo=False) + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + + +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)) + + +class GameRecord(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + game_id: str = Field(index=True) + created_at: dt.datetime = Field(default_factory=lambda: dt.datetime.now(dt.timezone.utc)) + finished_at: Optional[dt.datetime] = None + winner: Optional[str] = None + players: str + max_players: int + total_turns: int + + +class RegisterRequest(BaseModel): + username: str + password: str + + +class LoginRequest(BaseModel): + username: str + password: str + + +class CreateGameRequest(BaseModel): + name: str + max_players: int + + +class AddAIRequest(BaseModel): + ai_type: str + model_name: Optional[str] = None + + +class ActionRequest(BaseModel): + type: str + payload: Dict[str, object] = Field(default_factory=dict) + + +@dataclass +class PlayerSlot: + 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 + + def to_dict(self) -> Dict[str, object]: + return { + "slot_id": self.slot_id, + "name": self.name, + "user_id": self.user_id, + "is_ai": self.is_ai, + "ai_kind": self.ai_kind, + "ai_model": self.ai_model, + "ready": self.ready, + "color": self.color, + } + + +@dataclass +class GameSession: + id: str + name: str + max_players: int + created_by: str + created_at: dt.datetime + status: str = "lobby" + players: List[PlayerSlot] = field(default_factory=list) + env: Optional[CatanEnv] = None + ai_agents: Dict[str, object] = field(default_factory=dict) + action_log: List[Dict[str, object]] = field(default_factory=list) + winner: Optional[str] = None + lock: asyncio.Lock = field(default_factory=asyncio.Lock) + connections: Dict[WebSocket, str] = field(default_factory=dict) + + def to_lobby(self) -> Dict[str, object]: + return { + "id": self.id, + "name": self.name, + "status": self.status, + "max_players": self.max_players, + "created_by": self.created_by, + "created_at": self.created_at.isoformat(), + "players": [slot.to_dict() for slot in self.players], + } + + +class ModelRegistry: + def __init__(self) -> None: + self._cache: Dict[str, ActionScoringNetwork] = {} + add_safe_globals([PPOConfig, GameConfig]) + + def _load_actor(self, path: Path) -> ActionScoringNetwork: + state = torch.load(path, map_location="cpu", weights_only=False) + game_cfg = state.get("game_config") + cfg = state.get("config") + if game_cfg is None or cfg is None: + raise ValueError("Invalid checkpoint format.") + env = CatanEnv(game_cfg) + obs_dim = encode_observation(env.observe()).shape[0] + action_dim = encode_action(Action(ActionType.END_TURN, {})).shape[0] + actor = ActionScoringNetwork(obs_dim, action_dim, cfg.hidden_sizes) + actor.load_state_dict(state["actor"]) + actor.eval() + return actor + + def get_actor(self, model_name: str) -> ActionScoringNetwork: + path = MODELS_DIR / model_name + if not path.exists(): + raise FileNotFoundError(f"Model not found: {model_name}") + if model_name not in self._cache: + self._cache[model_name] = self._load_actor(path) + return self._cache[model_name] + + +model_registry = ModelRegistry() + + +class GameManager: + def __init__(self) -> None: + self.games: Dict[str, GameSession] = {} + + def create_game(self, name: str, max_players: int, creator: str) -> GameSession: + if max_players < 2 or max_players > 4: + raise ValueError("max_players must be between 2 and 4.") + gid = str(uuid.uuid4()) + session = GameSession( + id=gid, + name=name, + max_players=max_players, + created_by=creator, + created_at=dt.datetime.now(dt.timezone.utc), + players=[PlayerSlot(slot_id=i + 1) for i in range(max_players)], + ) + self.games[gid] = session + return session + + def list_games(self) -> List[Dict[str, object]]: + return [session.to_lobby() for session in self.games.values()] + + def get(self, game_id: str) -> GameSession: + session = self.games.get(game_id) + if not session: + raise KeyError(game_id) + return session + + +manager = GameManager() + + +app = FastAPI(title="Catan Arena") +app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") + + +@app.on_event("startup") +def _startup() -> None: + SQLModel.metadata.create_all(engine) + + +@app.get("/") +def index() -> FileResponse: + return FileResponse(STATIC_DIR / "index.html") + + +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: User) -> str: + now = dt.datetime.now(dt.timezone.utc) + payload = { + "sub": str(user.id), + "username": user.username, + "exp": now + dt.timedelta(days=TOKEN_EXPIRY_DAYS), + } + return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALG) + + +def _get_user_from_token(token: str) -> User: + try: + payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALG]) + except jwt.PyJWTError as exc: + raise HTTPException(status_code=401, detail="Invalid token") from exc + user_id = payload.get("sub") + if not user_id: + raise HTTPException(status_code=401, detail="Invalid token") + with Session(engine) as session: + user = session.get(User, int(user_id)) + if not user: + raise HTTPException(status_code=401, detail="Unknown user") + return user + + +def require_user(request: Request) -> User: + auth = request.headers.get("authorization") + if not auth or not auth.lower().startswith("bearer "): + raise HTTPException(status_code=401, detail="Missing token") + token = auth.split(" ", 1)[1].strip() + return _get_user_from_token(token) + + +def user_from_ws(websocket: WebSocket) -> User: + token = websocket.query_params.get("token") + if not token: + raise HTTPException(status_code=401, detail="Missing token") + return _get_user_from_token(token) + + +def _serialize_action(action: Action) -> Dict[str, object]: + return {"type": action.type.value, "payload": action.payload} + + +def _serialize_legal_actions(env: CatanEnv) -> List[Dict[str, object]]: + return [_serialize_action(action) for action in env.legal_actions()] + + +def _build_state(session: GameSession, username: Optional[str] = None) -> Dict[str, object]: + if not session.env: + return { + "id": session.id, + "status": session.status, + "players": [slot.to_dict() for slot in session.players], + "game": None, + "board": None, + "history": session.action_log, + } + obs = session.env.observe() + game = obs["game"] + if username: + masked_players = {} + for name, pdata in game["players"].items(): + pdata_copy = dict(pdata) + if name != username: + resources = pdata_copy.get("resources", {}) + total = sum(resources.values()) if isinstance(resources, dict) else 0 + pdata_copy["resources"] = {"hidden": total} + masked_players[name] = pdata_copy + game = dict(game) + game["players"] = masked_players + legal_actions: List[Dict[str, object]] = [] + if username and session.env: + current = session.env.game.current_player.name + if username == current or username in session.env.game.pending_discards: + legal_actions = _serialize_legal_actions(session.env) + return { + "id": session.id, + "status": session.status, + "players": [slot.to_dict() for slot in session.players], + "game": game, + "board": obs["board"], + "history": session.action_log[-200:], + "legal_actions": legal_actions, + } + + +def _add_log(session: GameSession, actor: str, action: Action) -> None: + session.action_log.append( + { + "ts": dt.datetime.now(dt.timezone.utc).isoformat(), + "player": actor, + "type": action.type.value, + "payload": action.payload, + } + ) + + +def _list_models() -> List[str]: + if not MODELS_DIR.exists(): + return [] + return sorted([p.name for p in MODELS_DIR.glob("*.pt")]) + + +def _build_ai_name(session: GameSession, base: str) -> str: + existing = {slot.name for slot in session.players if slot.name} + idx = 1 + name = f"{base}-{idx}" + while name in existing: + idx += 1 + name = f"{base}-{idx}" + return name + + +async def _broadcast(session: GameSession) -> None: + to_remove: List[WebSocket] = [] + for ws, username in session.connections.items(): + try: + payload = json.dumps({"type": "state", "data": _build_state(session, username)}) + await ws.send_text(payload) + except Exception: + to_remove.append(ws) + for ws in to_remove: + session.connections.pop(ws, None) + + +def _build_agent(slot: PlayerSlot) -> object: + if slot.ai_kind == "random": + return RandomAgent() + if slot.ai_kind == "model" and slot.ai_model: + actor = model_registry.get_actor(slot.ai_model) + + class ActorPolicy: + def __init__(self, model: ActionScoringNetwork) -> None: + self.model = model + + def __call__(self, obs_tensor, action_vectors): + with torch.no_grad(): + return self.model(obs_tensor, action_vectors) + + return PolicyAgent(ActorPolicy(actor), device="cpu", stochastic=True) + raise ValueError("Unknown AI kind") + + +def _start_game(session: GameSession) -> None: + names = [slot.name for slot in session.players if slot.name] + if len(names) < 2: + raise ValueError("Not enough players to start.") + colors = ["red", "blue", "orange", "white"] + config = GameConfig(player_names=names, colors=colors[: len(names)]) + session.env = CatanEnv(config) + for slot, color in zip(session.players, colors): + if slot.name: + slot.color = color + session.status = "running" + session.ai_agents.clear() + for slot in session.players: + if slot.is_ai and slot.name: + session.ai_agents[slot.name] = _build_agent(slot) + + +def _finish_game(session: GameSession) -> None: + if not session.env: + return + session.status = "finished" + session.winner = session.env.game.winner + with Session(engine) as db: + record = GameRecord( + game_id=session.id, + created_at=session.created_at, + finished_at=dt.datetime.now(dt.timezone.utc), + winner=session.winner, + players=",".join([slot.name or "" for slot in session.players]), + max_players=session.max_players, + total_turns=len(session.action_log), + ) + db.add(record) + db.commit() + + +async def _run_ai_turns(session: GameSession) -> None: + if not session.env: + return + async with session.lock: + while session.status == "running": + current = session.env.game.current_player.name + if current not in session.ai_agents: + break + agent = session.ai_agents[current] + legal = session.env.legal_actions() + if not legal: + break + action = agent.choose_action(session.env, legal) + _add_log(session, current, action) + _, _, done, info = session.env.step(action) + if info.get("invalid"): + session.action_log.append( + { + "ts": dt.datetime.now(dt.timezone.utc).isoformat(), + "player": current, + "type": "invalid", + "payload": {"error": info.get("error")}, + } + ) + break + if done: + _finish_game(session) + break + await _broadcast(session) + + +@app.post("/api/register") +def register(payload: RegisterRequest): + with Session(engine) as session: + existing = session.exec(select(User).where(User.username == payload.username)).first() + if existing: + raise HTTPException(status_code=400, detail="Username already exists") + user = User(username=payload.username, password_hash=_hash_password(payload.password)) + session.add(user) + session.commit() + session.refresh(user) + token = _create_token(user) + return {"token": token, "username": user.username} + + +@app.post("/api/login") +def login(payload: LoginRequest): + with Session(engine) as session: + user = session.exec(select(User).where(User.username == payload.username)).first() + if not user or not _verify_password(payload.password, user.password_hash): + raise HTTPException(status_code=401, detail="Invalid credentials") + token = _create_token(user) + return {"token": token, "username": user.username} + + +@app.get("/api/me") +def me(user: User = Depends(require_user)): + return {"id": user.id, "username": user.username} + + +@app.get("/api/models") +def models(user: User = Depends(require_user)): + return {"models": _list_models()} + + +@app.get("/api/lobby") +def lobby(user: User = Depends(require_user)): + return {"games": manager.list_games()} + + +@app.post("/api/games") +def create_game(payload: CreateGameRequest, user: User = Depends(require_user)): + try: + session = manager.create_game(payload.name, payload.max_players, user.username) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return session.to_lobby() + + +@app.get("/api/games/{game_id}") +def game_state(game_id: str, user: User = Depends(require_user)): + try: + session = manager.get(game_id) + except KeyError as exc: + raise HTTPException(status_code=404, detail="Game not found") from exc + return _build_state(session, user.username) + + +@app.post("/api/games/{game_id}/join") +def join_game(game_id: str, user: User = Depends(require_user)): + try: + session = manager.get(game_id) + except KeyError as exc: + raise HTTPException(status_code=404, detail="Game not found") from exc + for slot in session.players: + if slot.user_id == user.id: + return session.to_lobby() + open_slot = next((slot for slot in session.players if slot.name is None), None) + if not open_slot: + raise HTTPException(status_code=400, detail="No available slots") + open_slot.name = user.username + open_slot.user_id = user.id + open_slot.ready = True + return session.to_lobby() + + +@app.post("/api/games/{game_id}/leave") +def leave_game(game_id: str, user: User = Depends(require_user)): + try: + session = manager.get(game_id) + except KeyError as exc: + raise HTTPException(status_code=404, detail="Game not found") from exc + for slot in session.players: + if slot.user_id == user.id: + slot.name = None + slot.user_id = None + slot.is_ai = False + slot.ai_kind = None + slot.ai_model = None + slot.ready = False + slot.color = None + return session.to_lobby() + + +@app.post("/api/games/{game_id}/add_ai") +def add_ai(game_id: str, payload: AddAIRequest, user: User = Depends(require_user)): + try: + session = manager.get(game_id) + except KeyError as exc: + raise HTTPException(status_code=404, detail="Game not found") from exc + open_slot = next((slot for slot in session.players if slot.name is None), None) + if not open_slot: + raise HTTPException(status_code=400, detail="No available slots") + ai_type = payload.ai_type.lower() + if ai_type == "random": + open_slot.name = _build_ai_name(session, "Random") + open_slot.is_ai = True + open_slot.ai_kind = "random" + open_slot.ready = True + elif ai_type == "model": + if not payload.model_name: + raise HTTPException(status_code=400, detail="Model name required") + if payload.model_name not in _list_models(): + raise HTTPException(status_code=404, detail="Model not found") + open_slot.name = _build_ai_name(session, "Model") + open_slot.is_ai = True + open_slot.ai_kind = "model" + open_slot.ai_model = payload.model_name + open_slot.ready = True + else: + raise HTTPException(status_code=400, detail="Unknown AI type") + return session.to_lobby() + + +@app.post("/api/games/{game_id}/start") +async def start_game(game_id: str, user: User = Depends(require_user)): + try: + session = manager.get(game_id) + except KeyError as exc: + raise HTTPException(status_code=404, detail="Game not found") from exc + if session.status != "lobby": + raise HTTPException(status_code=400, detail="Game already started") + if user.username != session.created_by: + raise HTTPException(status_code=403, detail="Only host can start") + try: + _start_game(session) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + await _broadcast(session) + await _run_ai_turns(session) + return _build_state(session, user.username) + + +@app.post("/api/games/{game_id}/action") +async def take_action(game_id: str, payload: ActionRequest, user: User = Depends(require_user)): + try: + session = manager.get(game_id) + except KeyError as exc: + raise HTTPException(status_code=404, detail="Game not found") from exc + if session.status != "running" or not session.env: + raise HTTPException(status_code=400, detail="Game not running") + try: + action_type = ActionType(payload.type) + except ValueError as exc: + raise HTTPException(status_code=400, detail="Unknown action type") from exc + action = Action(type=action_type, payload=payload.payload) + actor = user.username + current = session.env.game.current_player.name + if action_type == ActionType.DISCARD: + target = payload.payload.get("player") + if target != actor: + raise HTTPException(status_code=403, detail="You can only discard for yourself") + elif actor != current: + raise HTTPException(status_code=403, detail="Not your turn") + + async with session.lock: + _add_log(session, actor, action) + _, _, done, info = session.env.step(action) + if info.get("invalid"): + raise HTTPException(status_code=400, detail=info.get("error", "Invalid action")) + if done: + _finish_game(session) + await _broadcast(session) + await _run_ai_turns(session) + return _build_state(session, user.username) + + +@app.get("/api/stats") +def stats(user: User = Depends(require_user)): + with Session(engine) as session: + records = session.exec(select(GameRecord)).all() + total_games = len(records) + user_games = [r for r in records if user.username in r.players] + user_wins = sum(1 for r in records if r.winner == user.username) + avg_turns = sum(r.total_turns for r in records) / total_games if total_games else 0 + return { + "total_games": total_games, + "user_games": len(user_games), + "user_wins": user_wins, + "avg_turns": avg_turns, + } + + +@app.websocket("/ws/games/{game_id}") +async def game_ws(websocket: WebSocket, game_id: str): + await websocket.accept() + try: + user = user_from_ws(websocket) + except HTTPException: + await websocket.close(code=1008) + return + try: + session = manager.get(game_id) + except KeyError: + await websocket.close(code=1008) + return + session.connections[websocket] = user.username + await websocket.send_text(json.dumps({"type": "state", "data": _build_state(session, user.username)})) + try: + while True: + await websocket.receive_text() + except WebSocketDisconnect: + session.connections.pop(websocket, None) diff --git a/catan/web/static/app.js b/catan/web/static/app.js new file mode 100644 index 0000000..ccc7f8b --- /dev/null +++ b/catan/web/static/app.js @@ -0,0 +1,734 @@ +const app = document.getElementById("app"); +const state = { + token: localStorage.getItem("catan_token"), + username: localStorage.getItem("catan_user"), + lobby: [], + models: [], + stats: null, + currentGame: null, + gameState: null, + ws: null, +}; + +const resourceColors = { + brick: "#c46a44", + lumber: "#4a6d4a", + wool: "#8cc071", + grain: "#e2c065", + ore: "#7a7c83", + desert: "#d8c18f", +}; + +function api(path, options = {}) { + const headers = options.headers || {}; + if (state.token) { + headers.Authorization = `Bearer ${state.token}`; + } + return fetch(path, { ...options, headers }).then(async (resp) => { + const data = await resp.json().catch(() => ({})); + if (!resp.ok) { + throw new Error(data.detail || "Request failed"); + } + return data; + }); +} + +function setAuth(token, username) { + state.token = token; + state.username = username; + localStorage.setItem("catan_token", token); + localStorage.setItem("catan_user", username); +} + +function clearAuth() { + state.token = null; + state.username = null; + localStorage.removeItem("catan_token"); + localStorage.removeItem("catan_user"); +} + +function showToast(message) { + alert(message); +} + +function renderHeader() { + return ` +
+ +
+ ${state.username || "Guest"} + ${state.token ? `` : ""} +
+
+ `; +} + +function renderLogin() { + app.innerHTML = ` + ${renderHeader()} +
+
+
+
Login
+
+ + + +
+
+
+
Create Account
+
+ + + +
+
+
+
+ `; + + document.getElementById("login-btn").addEventListener("click", async () => { + try { + const username = document.getElementById("login-user").value.trim(); + const password = document.getElementById("login-pass").value.trim(); + const data = await api("/api/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, password }), + }); + setAuth(data.token, data.username); + await loadLobby(); + } catch (err) { + showToast(err.message); + } + }); + + document.getElementById("register-btn").addEventListener("click", async () => { + try { + const username = document.getElementById("register-user").value.trim(); + const password = document.getElementById("register-pass").value.trim(); + const data = await api("/api/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, password }), + }); + setAuth(data.token, data.username); + await loadLobby(); + } catch (err) { + showToast(err.message); + } + }); +} + +async function loadLobby() { + try { + const [lobby, models, stats] = await Promise.all([ + api("/api/lobby"), + api("/api/models"), + api("/api/stats"), + ]); + state.lobby = lobby.games || []; + state.models = models.models || []; + state.stats = stats; + renderLobby(); + } catch (err) { + clearAuth(); + renderLogin(); + } +} + +function lobbyGameCard(game) { + const players = game.players + .map((slot) => slot.name || "Open") + .map((name) => `${name}`) + .join(""); + return ` +
+
+ ${game.name} + ${game.status} +
+
${players}
+
+ + + +
+
+ `; +} + +function renderLobby() { + app.innerHTML = ` + ${renderHeader()} +
+
+
+
Lobby
+
+ ${state.lobby.map(lobbyGameCard).join("") || "

No active games yet.

"} +
+
+
+
+
Create Game
+
+ + + +
+
+
+
Your Stats
+
+
Total games: ${state.stats?.total_games ?? 0}
+
Your games: ${state.stats?.user_games ?? 0}
+
Your wins: ${state.stats?.user_wins ?? 0}
+
Avg turns: ${(state.stats?.avg_turns ?? 0).toFixed(1)}
+
+
+
+
AI Models
+
+ ${(state.models || []).map((model) => `${model}`).join("") || "No models uploaded yet."} +
+
+
+
+
+ `; + + document.getElementById("create-game").addEventListener("click", async () => { + try { + const name = document.getElementById("game-name").value.trim() || "Untitled Room"; + const max_players = parseInt(document.getElementById("game-size").value, 10); + await api("/api/games", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name, max_players }), + }); + await loadLobby(); + } catch (err) { + showToast(err.message); + } + }); + + document.querySelectorAll("[data-action='join']").forEach((btn) => { + btn.addEventListener("click", async () => { + try { + const id = btn.dataset.id; + await api(`/api/games/${id}/join`, { method: "POST" }); + openGame(id); + } catch (err) { + showToast(err.message); + } + }); + }); + + document.querySelectorAll("[data-action='ai']").forEach((btn) => { + btn.addEventListener("click", async () => { + const id = btn.dataset.id; + const aiType = prompt("AI type: random or model?") || "random"; + let payload = { ai_type: aiType }; + if (aiType === "model") { + const model = prompt(`Model name: ${state.models.join(", ")}`); + payload.model_name = model; + } + try { + await api(`/api/games/${id}/add_ai`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + await loadLobby(); + } catch (err) { + showToast(err.message); + } + }); + }); + + document.querySelectorAll("[data-action='start']").forEach((btn) => { + btn.addEventListener("click", async () => { + try { + const id = btn.dataset.id; + await api(`/api/games/${id}/start`, { method: "POST" }); + openGame(id); + } catch (err) { + showToast(err.message); + } + }); + }); + + const logoutBtn = document.getElementById("logout-btn"); + if (logoutBtn) { + logoutBtn.addEventListener("click", () => { + clearAuth(); + renderLogin(); + }); + } +} + +function openGame(id) { + state.currentGame = id; + if (state.ws) { + state.ws.close(); + } + const ws = new WebSocket(`${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/ws/games/${id}?token=${state.token}`); + ws.onmessage = (event) => { + const msg = JSON.parse(event.data); + if (msg.type === "state") { + state.gameState = msg.data; + renderGame(); + } + }; + ws.onclose = () => { + state.ws = null; + }; + state.ws = ws; + renderGame(); +} + +function resourceSummary(resources) { + if (!resources) return ""; + if (resources.hidden !== undefined) { + return `Hidden cards: ${resources.hidden}`; + } + return Object.entries(resources) + .map(([k, v]) => `${k}:${v}`) + .join(" · "); +} + +function renderPlayers(game) { + if (!game) return ""; + return Object.entries(game.players) + .map(([name, pdata]) => { + const color = pdata.color || "#333"; + return ` +
+ ${name} + VP ${pdata.victory_points} + ${resourceSummary(pdata.resources)} +
+ `; + }) + .join(""); +} + +function cubeToPixel(coord, scale) { + const [x, , z] = coord; + const sqrt3 = Math.sqrt(3); + return { + x: scale * (sqrt3 * x + (sqrt3 / 2) * z), + y: scale * (1.5 * z), + }; +} + +function hexCorners(cx, cy, size) { + const points = []; + for (let i = 0; i < 6; i++) { + const angle = (Math.PI / 180) * (60 * i - 30); + points.push([cx + size * Math.cos(angle), cy + size * Math.sin(angle)]); + } + return points; +} + +function renderBoard(board, game) { + if (!board || !game) return ""; + const size = 48; + const cornerScale = size / 3; + const hexes = Object.values(board.hexes || {}); + const corners = board.corners || {}; + const edges = Object.values(board.edges || {}); + + const hexPositions = hexes.map((hex) => { + const pos = cubeToPixel(hex.coord, size); + return { ...hex, pos }; + }); + + const cornerPositions = {}; + Object.entries(corners).forEach(([id, corner]) => { + cornerPositions[id] = { + ...corner, + pos: cubeToPixel(corner.coord, cornerScale), + }; + }); + + const allPositions = [ + ...hexPositions.map((h) => h.pos), + ...Object.values(cornerPositions).map((c) => c.pos), + ]; + const minX = Math.min(...allPositions.map((p) => p.x)); + const maxX = Math.max(...allPositions.map((p) => p.x)); + const minY = Math.min(...allPositions.map((p) => p.y)); + const maxY = Math.max(...allPositions.map((p) => p.y)); + const padding = 80; + const viewBox = `${minX - padding} ${minY - padding} ${maxX - minX + padding * 2} ${maxY - minY + padding * 2}`; + + const playerColors = {}; + Object.entries(game.players).forEach(([name, pdata]) => { + playerColors[name] = pdata.color || "#333"; + }); + + const roadLines = edges + .filter((edge) => edge.owner) + .map((edge) => { + const a = cornerPositions[edge.a]; + const b = cornerPositions[edge.b]; + if (!a || !b) return ""; + const color = playerColors[edge.owner] || "#222"; + return ``; + }) + .join(""); + + const cornerNodes = Object.entries(cornerPositions) + .filter(([, corner]) => corner.owner) + .map(([, corner]) => { + const radius = corner.building === "city" ? 10 : 7; + const color = playerColors[corner.owner] || "#222"; + return ``; + }) + .join(""); + + const hexShapes = hexPositions + .map((hex) => { + const points = hexCorners(hex.pos.x, hex.pos.y, size - 4) + .map((pt) => pt.join(",")) + .join(" "); + const fill = resourceColors[hex.resource] || "#e0c89a"; + const number = hex.number || ""; + const robber = hex.robber ? "" : ""; + return ` + + + ${number} + ${robber} + + `; + }) + .join(""); + + return ` + + ${hexShapes} + ${roadLines} + ${cornerNodes} + + `; +} + +function actionLabel(action) { + const payload = action.payload || {}; + switch (action.type) { + case "place_initial": + return `Place initial: corner ${payload.corner} / road ${payload.road}`; + case "build_road": + return `Build road: edge ${payload.edge}`; + case "build_settlement": + return `Build settlement: corner ${payload.corner}`; + case "build_city": + return `Build city: corner ${payload.corner}`; + case "buy_development": + return "Buy development card"; + case "roll": + return "Roll dice"; + case "end_turn": + return "End turn"; + case "trade_bank": + return `Trade bank: ${payload.give} -> ${payload.receive} (ratio ${payload.ratio})`; + case "trade_player": + return `Trade player ${payload.target}: ${JSON.stringify(payload.offer)} for ${JSON.stringify(payload.request)}`; + case "play_knight": + return "Play Knight"; + case "move_robber": + return "Move Robber"; + case "play_road_building": + return "Play Road Building"; + case "play_year_of_plenty": + return "Play Year of Plenty"; + case "play_monopoly": + return "Play Monopoly"; + case "discard": + return `Discard ${payload.required}`; + default: + return `${action.type}`; + } +} + +function renderActionForm(action) { + const payload = action.payload || {}; + const wrapper = document.createElement("div"); + wrapper.className = "action-controls"; + if (action.type === "move_robber" || action.type === "play_knight") { + const options = payload.options || []; + const hexSelect = document.createElement("select"); + hexSelect.className = "input"; + options.forEach((opt) => { + const option = document.createElement("option"); + option.value = opt.hex; + option.textContent = `Hex ${opt.hex}`; + hexSelect.appendChild(option); + }); + const victimSelect = document.createElement("select"); + victimSelect.className = "input"; + function updateVictims() { + const selected = options.find((opt) => String(opt.hex) === hexSelect.value); + victimSelect.innerHTML = ""; + const victims = (selected && selected.victims) || []; + const noneOpt = document.createElement("option"); + noneOpt.value = ""; + noneOpt.textContent = "No victim"; + victimSelect.appendChild(noneOpt); + victims.forEach((victim) => { + const opt = document.createElement("option"); + opt.value = victim; + opt.textContent = victim; + victimSelect.appendChild(opt); + }); + } + hexSelect.addEventListener("change", updateVictims); + updateVictims(); + wrapper.appendChild(hexSelect); + wrapper.appendChild(victimSelect); + wrapper.dataset.builder = "robber"; + wrapper.dataset.options = JSON.stringify(options); + wrapper._getPayload = () => { + const selected = options.find((opt) => String(opt.hex) === hexSelect.value) || { victims: [] }; + const victim = victimSelect.value; + const result = { hex: parseInt(hexSelect.value, 10) }; + if (victim) { + result.victim = victim; + } + return result; + }; + return wrapper; + } + + if (action.type === "play_road_building") { + const edges = payload.edges || []; + const info = document.createElement("div"); + info.className = "small"; + info.textContent = "Select up to two edges"; + wrapper.appendChild(info); + const edgeSelect = document.createElement("select"); + edgeSelect.className = "input"; + edgeSelect.multiple = true; + edges.forEach((edge) => { + const opt = document.createElement("option"); + opt.value = edge; + opt.textContent = `Edge ${edge}`; + edgeSelect.appendChild(opt); + }); + wrapper.appendChild(edgeSelect); + wrapper._getPayload = () => { + const selected = Array.from(edgeSelect.selectedOptions).map((opt) => parseInt(opt.value, 10)); + return { edges: selected.slice(0, 2) }; + }; + return wrapper; + } + + if (action.type === "play_year_of_plenty") { + const bank = payload.bank || {}; + const resources = Object.keys(bank); + const select1 = document.createElement("select"); + const select2 = document.createElement("select"); + select1.className = "input"; + select2.className = "input"; + resources.forEach((res) => { + const opt1 = document.createElement("option"); + opt1.value = res; + opt1.textContent = res; + const opt2 = opt1.cloneNode(true); + select1.appendChild(opt1); + select2.appendChild(opt2); + }); + wrapper.appendChild(select1); + wrapper.appendChild(select2); + wrapper._getPayload = () => ({ resources: [select1.value, select2.value] }); + return wrapper; + } + + if (action.type === "play_monopoly") { + const resources = payload.resources || ["brick", "lumber", "wool", "grain", "ore"]; + const select = document.createElement("select"); + select.className = "input"; + resources.forEach((res) => { + const opt = document.createElement("option"); + opt.value = res; + opt.textContent = res; + select.appendChild(opt); + }); + wrapper.appendChild(select); + wrapper._getPayload = () => ({ resource: select.value }); + return wrapper; + } + + if (action.type === "discard") { + const resources = payload.resources || {}; + const required = payload.required || 0; + const counts = {}; + wrapper.appendChild(document.createTextNode(`Discard ${required} cards`)); + Object.entries(resources).forEach(([res, count]) => { + if (res === "desert" || count <= 0) return; + counts[res] = 0; + const row = document.createElement("div"); + row.style.display = "flex"; + row.style.alignItems = "center"; + row.style.gap = "8px"; + const label = document.createElement("span"); + label.textContent = `${res} (${count})`; + const input = document.createElement("input"); + input.type = "number"; + input.min = 0; + input.max = count; + input.value = 0; + input.className = "input"; + input.style.maxWidth = "80px"; + input.addEventListener("change", () => { + counts[res] = parseInt(input.value, 10) || 0; + }); + row.appendChild(label); + row.appendChild(input); + wrapper.appendChild(row); + }); + wrapper._getPayload = () => ({ player: state.username, cards: counts }); + return wrapper; + } + + wrapper._getPayload = () => payload; + return wrapper; +} + +function renderGame() { + const data = state.gameState; + if (!data) { + app.innerHTML = `${renderHeader()}
Loading...
`; + return; + } + const game = data.game; + const board = data.board; + const legalActions = data.legal_actions || []; + + app.innerHTML = ` + ${renderHeader()} +
+
+
+
+
Players
+
${renderPlayers(game)}
+
+
+
Action Console
+
+
+
+
Timeline
+
${(data.history || []) + .slice() + .reverse() + .map((entry) => `
${entry.player}: ${entry.type}
`) + .join("")}
+
+
+
+ ${renderBoard(board, game)} +
+
+
+
Status
+
+
Phase: ${game?.phase || "-"}
+
Current: ${game?.current_player || "-"}
+
Last roll: ${game?.last_roll || "-"}
+
Winner: ${game?.winner || "-"}
+
+
+
+
Bank
+
${game ? Object.entries(game.bank) + .map(([k, v]) => `${k}: ${v}`) + .join(" · ") : ""}
+
+
+
Lobby
+ +
+
+
+
+ `; + + const actionList = document.getElementById("action-list"); + if (legalActions.length === 0) { + actionList.innerHTML = "
Waiting for your turn...
"; + } else { + const select = document.createElement("select"); + select.className = "input"; + legalActions.forEach((action, idx) => { + const opt = document.createElement("option"); + opt.value = idx; + opt.textContent = actionLabel(action); + select.appendChild(opt); + }); + const formHost = document.createElement("div"); + formHost.className = "action-controls"; + let currentForm = renderActionForm(legalActions[0]); + formHost.appendChild(currentForm); + + select.addEventListener("change", () => { + formHost.innerHTML = ""; + currentForm = renderActionForm(legalActions[select.value]); + formHost.appendChild(currentForm); + }); + + const submit = document.createElement("button"); + submit.className = "button"; + submit.textContent = "Send"; + submit.addEventListener("click", async () => { + const action = legalActions[select.value]; + const payload = currentForm._getPayload ? currentForm._getPayload() : action.payload; + try { + await api(`/api/games/${data.id}/action`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ type: action.type, payload }), + }); + } catch (err) { + showToast(err.message); + } + }); + + actionList.appendChild(select); + actionList.appendChild(formHost); + actionList.appendChild(submit); + } + + document.getElementById("back-lobby").addEventListener("click", async () => { + if (state.ws) { + state.ws.close(); + state.ws = null; + } + await loadLobby(); + }); + + const logoutBtn = document.getElementById("logout-btn"); + if (logoutBtn) { + logoutBtn.addEventListener("click", () => { + clearAuth(); + renderLogin(); + }); + } +} + +(async function bootstrap() { + if (state.token) { + await loadLobby(); + } else { + renderLogin(); + } +})(); diff --git a/catan/web/static/index.html b/catan/web/static/index.html new file mode 100644 index 0000000..52450ef --- /dev/null +++ b/catan/web/static/index.html @@ -0,0 +1,16 @@ + + + + + + Catan Arena + + + + + + +
+ + + diff --git a/catan/web/static/styles.css b/catan/web/static/styles.css new file mode 100644 index 0000000..b537f5d --- /dev/null +++ b/catan/web/static/styles.css @@ -0,0 +1,190 @@ +:root { + color-scheme: light; + --ink: #1e1b16; + --sand: #f4ecd8; + --clay: #d99560; + --ocean: #3a6d7a; + --forest: #3c5a3c; + --sun: #f2c46c; + --stone: #6c6c6c; + --wheat: #d6b35f; + --panel: #fff8e9; + --accent: #b1552f; + --shadow: rgba(30, 27, 22, 0.15); +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: "Space Grotesk", system-ui, sans-serif; + background: radial-gradient(circle at top, #fff6e6 0%, #efe0c2 45%, #e5d0a7 100%); + color: var(--ink); + min-height: 100vh; +} + +#app { + min-height: 100vh; + display: flex; + flex-direction: column; +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 20px 32px; + background: linear-gradient(120deg, rgba(255, 245, 215, 0.95), rgba(255, 231, 186, 0.95)); + box-shadow: 0 8px 24px var(--shadow); + border-bottom: 1px solid rgba(30, 27, 22, 0.1); +} + +.logo { + font-family: "Cinzel", serif; + font-size: 26px; + letter-spacing: 2px; + font-weight: 600; +} + +.badge { + background: var(--accent); + color: white; + padding: 4px 10px; + border-radius: 999px; + font-size: 12px; + text-transform: uppercase; + letter-spacing: 1px; +} + +.container { + flex: 1; + padding: 32px; +} + +.card { + background: var(--panel); + border-radius: 16px; + padding: 20px 24px; + box-shadow: 0 18px 40px var(--shadow); + border: 1px solid rgba(30, 27, 22, 0.08); +} + +.grid { + display: grid; + gap: 24px; +} + +.grid.two { + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); +} + +.grid.three { + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); +} + +.button { + border: none; + border-radius: 10px; + padding: 10px 16px; + font-weight: 600; + cursor: pointer; + background: var(--accent); + color: white; + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.button:hover { + transform: translateY(-1px); + box-shadow: 0 10px 16px rgba(177, 85, 47, 0.3); +} + +.button.secondary { + background: var(--ocean); +} + +.button.ghost { + background: transparent; + border: 1px solid rgba(30, 27, 22, 0.2); + color: var(--ink); +} + +.input { + width: 100%; + padding: 10px 12px; + border-radius: 10px; + border: 1px solid rgba(30, 27, 22, 0.2); + background: white; + font-size: 14px; +} + +.section-title { + font-family: "Cinzel", serif; + font-size: 18px; + margin-bottom: 12px; +} + +.lobby-card { + border: 1px solid rgba(30, 27, 22, 0.08); + border-radius: 14px; + padding: 14px; + background: white; + display: grid; + gap: 10px; +} + +.player-tag { + padding: 4px 8px; + border-radius: 999px; + font-size: 12px; + background: rgba(0, 0, 0, 0.08); +} + +.board-shell { + background: radial-gradient(circle at center, #f7efd9 0%, #eddcb8 60%, #e0c89a 100%); + border-radius: 24px; + padding: 12px; + box-shadow: inset 0 0 40px rgba(255, 255, 255, 0.6); +} + +.board-svg { + width: 100%; + height: 560px; +} + +.panel-stack { + display: grid; + gap: 16px; +} + +.history { + max-height: 240px; + overflow: auto; + font-size: 13px; +} + +.action-list { + display: grid; + gap: 12px; +} + +.action-controls { + display: grid; + gap: 8px; +} + +.small { + font-size: 12px; + opacity: 0.7; +} + +@media (max-width: 900px) { + .container { + padding: 20px; + } + + .board-svg { + height: 420px; + } +} diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..40d3b58 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,72 @@ +version: "3.9" + +services: + db: + image: postgres:16-alpine + environment: + POSTGRES_USER: catan + POSTGRES_PASSWORD: catan + POSTGRES_DB: catan + ports: + - "5432:5432" + volumes: + - catan-db:/var/lib/postgresql/data + + api: + build: + context: . + dockerfile: docker/api.Dockerfile + env_file: .env + ports: + - "8000:8000" + depends_on: + - db + - game + - ai + - analytics + + game: + build: + context: . + dockerfile: docker/game.Dockerfile + env_file: .env + ports: + - "8001:8001" + depends_on: + - db + volumes: + - ./models:/models + + ai: + build: + context: . + dockerfile: docker/ai.Dockerfile + env_file: .env + ports: + - "8002:8002" + depends_on: + - db + volumes: + - ./models:/models + + analytics: + build: + context: . + dockerfile: docker/analytics.Dockerfile + env_file: .env + ports: + - "8003:8003" + depends_on: + - db + + web: + build: + context: . + dockerfile: docker/web.Dockerfile + ports: + - "8080:80" + depends_on: + - api + +volumes: + catan-db: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..0e81f06 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,82 @@ +version: "3.9" + +services: + db: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_USER: catan + POSTGRES_PASSWORD: catan + POSTGRES_DB: catan + volumes: + - catan-db:/var/lib/postgresql/data + + api: + build: + context: . + dockerfile: docker/api.Dockerfile + restart: unless-stopped + env_file: .env + depends_on: + - db + - game + - ai + - analytics + networks: + - default + - caddy-network + + game: + build: + context: . + dockerfile: docker/game.Dockerfile + restart: unless-stopped + env_file: .env + depends_on: + - db + networks: + - default + volumes: + - ./models:/models + + ai: + build: + context: . + dockerfile: docker/ai.Dockerfile + restart: unless-stopped + env_file: .env + depends_on: + - db + networks: + - default + volumes: + - ./models:/models + + analytics: + build: + context: . + dockerfile: docker/analytics.Dockerfile + restart: unless-stopped + env_file: .env + depends_on: + - db + networks: + - default + + web: + build: + context: . + dockerfile: docker/web.Dockerfile + restart: unless-stopped + depends_on: + - api + networks: + - default + - caddy-network + +volumes: + catan-db: + +networks: + caddy-network: + external: true diff --git a/docker/ai.Dockerfile b/docker/ai.Dockerfile new file mode 100644 index 0000000..4bd9e17 --- /dev/null +++ b/docker/ai.Dockerfile @@ -0,0 +1,20 @@ +FROM python:3.11-slim + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +WORKDIR /app + +COPY pyproject.toml README.md /app/ +COPY catan /app/catan +COPY services /app/services + +RUN pip install --no-cache-dir --upgrade pip \ + && pip install --no-cache-dir --index-url https://download.pytorch.org/whl/cpu torch \ + && pip install --no-cache-dir -e . \ + && pip install --no-cache-dir -r services/ai/requirements.txt + +ENV PYTHONPATH=/app +EXPOSE 8002 + +CMD ["uvicorn", "services.ai.app:app", "--host", "0.0.0.0", "--port", "8002"] diff --git a/docker/analytics.Dockerfile b/docker/analytics.Dockerfile new file mode 100644 index 0000000..08f44ae --- /dev/null +++ b/docker/analytics.Dockerfile @@ -0,0 +1,19 @@ +FROM python:3.11-slim + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +WORKDIR /app + +COPY pyproject.toml README.md /app/ +COPY catan /app/catan +COPY services /app/services + +RUN pip install --no-cache-dir --upgrade pip \ + && pip install --no-cache-dir -e . \ + && pip install --no-cache-dir -r services/analytics/requirements.txt + +ENV PYTHONPATH=/app +EXPOSE 8003 + +CMD ["uvicorn", "services.analytics.app:app", "--host", "0.0.0.0", "--port", "8003"] diff --git a/docker/api.Dockerfile b/docker/api.Dockerfile new file mode 100644 index 0000000..61f6faf --- /dev/null +++ b/docker/api.Dockerfile @@ -0,0 +1,19 @@ +FROM python:3.11-slim + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +WORKDIR /app + +COPY pyproject.toml README.md /app/ +COPY catan /app/catan +COPY services /app/services + +RUN pip install --no-cache-dir --upgrade pip \ + && pip install --no-cache-dir -e . \ + && pip install --no-cache-dir -r services/api/requirements.txt + +ENV PYTHONPATH=/app +EXPOSE 8000 + +CMD ["uvicorn", "services.api.app:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/docker/game.Dockerfile b/docker/game.Dockerfile new file mode 100644 index 0000000..531b6d2 --- /dev/null +++ b/docker/game.Dockerfile @@ -0,0 +1,19 @@ +FROM python:3.11-slim + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +WORKDIR /app + +COPY pyproject.toml README.md /app/ +COPY catan /app/catan +COPY services /app/services + +RUN pip install --no-cache-dir --upgrade pip \ + && pip install --no-cache-dir -e . \ + && pip install --no-cache-dir -r services/game/requirements.txt + +ENV PYTHONPATH=/app +EXPOSE 8001 + +CMD ["uvicorn", "services.game.app:app", "--host", "0.0.0.0", "--port", "8001"] diff --git a/docker/web.Dockerfile b/docker/web.Dockerfile new file mode 100644 index 0000000..8594476 --- /dev/null +++ b/docker/web.Dockerfile @@ -0,0 +1,12 @@ +FROM node:20-alpine AS build + +WORKDIR /app +COPY web/package.json web/package-lock.json* /app/ +RUN npm install +COPY web /app +RUN npm run build + +FROM nginx:1.27-alpine +COPY --from=build /app/dist /usr/share/nginx/html +EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..d0fe69d --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,61 @@ +# Catan Microservices Architecture + +## Overview + +This repo is a monorepo with separate services (each container) and a shared core engine. + +Services: +- **api-gateway**: Auth, lobby, websocket fanout, user masking, orchestrates game/AI services. +- **game-service**: Authoritative game state + rule validation, trade offers, AI turn loop triggers. +- **ai-service**: Model inference + random policy; returns actions + debug metadata. +- **analytics-service**: Stats, replays, debug views, replay reconstruction. +- **web**: React UI (served by nginx) for lobby, game, analytics, replays. + +Shared: +- **catan** package: game engine & rules. +- **services/common**: env settings, shared schemas, JWT helpers, db helpers. + +## Runtime data flow + +1. Client logs in via api-gateway (`/api/auth/*`). +2. Client interacts with lobby/game via api-gateway. +3. api-gateway forwards game mutations to game-service. +4. game-service applies actions to the core engine and persists events to Postgres. +5. game-service may call ai-service for AI moves. +6. api-gateway broadcasts updated game state to connected clients (websocket). +7. analytics-service reads events from Postgres to compute stats + replays. + +## Storage + +Postgres (single instance) with service-owned tables: +- api-gateway: users +- game-service: games, game_events, trade_offers +- analytics-service: materialized stats + replay metadata (optional) + +## Replay format + +Replay format is JSON: + +```json +{ + "id": "uuid", + "created_at": "...", + "seed": 123, + "players": ["A", "B"], + "actions": [ + {"idx": 1, "actor": "A", "type": "roll", "payload": {}}, + {"idx": 2, "actor": "A", "type": "build_road", "payload": {"edge": 4}} + ] +} +``` + +analytics-service reconstructs a state at any action index by replaying from seed. + +## Debug mode + +`DEBUG=true` enables: +- full action payloads +- AI logits/probabilities +- full observation snapshots + +These are stored in `game_events.debug_payload` (JSON). diff --git a/docs/replay_format.md b/docs/replay_format.md new file mode 100644 index 0000000..f6413c5 --- /dev/null +++ b/docs/replay_format.md @@ -0,0 +1,32 @@ +# Replay Format + +JSON-формат реплея (v1) хранит сид партии и список применённых действий. Состояние восстанавливается повторным проигрыванием событий. + +Пример: + +```json +{ + "version": 1, + "id": "d7b4b593-0f2a-4c3f-9d90-6f6a8a3ddf2e", + "created_at": "2024-03-01T12:00:00Z", + "seed": 123456, + "players": ["Alice", "Bob"], + "winner": "Alice", + "slots": [ + {"slot_id": 1, "name": "Alice", "user_id": 1, "is_ai": false, "ready": true, "color": "red"}, + {"slot_id": 2, "name": "Bob", "user_id": null, "is_ai": true, "ai_kind": "random", "ready": true, "color": "blue"} + ], + "actions": [ + { + "idx": 1, + "ts": "2024-03-01T12:01:00Z", + "actor": "Alice", + "action": {"type": "roll", "payload": {}}, + "applied": true, + "meta": {} + } + ] +} +``` + +Импорт в аналитический сервис сохраняет реплей в базу и делает его доступным в UI. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..6a7458f --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,40 @@ +[build-system] +requires = ["setuptools>=67", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "catan" +version = "0.1.0" +description = "Full Settlers of Catan engine with SDK, CLI, and GUI interfaces." +readme = "README.md" +authors = [{name = "Codex Agent"}] +license = {text = "MIT"} +requires-python = ">=3.10" +dependencies = [ + "typer>=0.12", + "rich>=13.7", + "numpy>=1.26", +] + +[project.optional-dependencies] +dev = [ + "pytest>=7.4", + "fastapi>=0.115", + "httpx>=0.27", + "sqlmodel>=0.0.16", + "pydantic-settings>=2.2", + "pyjwt>=2.8", + "passlib[bcrypt]>=1.7", + "psycopg[binary]>=3.1", +] +ml = [ + "torch>=2.2", +] + +[project.scripts] +catan-cli = "catan.cli:app" +catan-gui = "catan.gui:main" +catan-learn = "catan.learning:app" + +[tool.setuptools] +packages = {find = {include = ["catan*"]}} diff --git a/services/ai/__init__.py b/services/ai/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/ai/app.py b/services/ai/app.py new file mode 100644 index 0000000..b75ade5 --- /dev/null +++ b/services/ai/app.py @@ -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() diff --git a/services/ai/requirements.txt b/services/ai/requirements.txt new file mode 100644 index 0000000..2357b23 --- /dev/null +++ b/services/ai/requirements.txt @@ -0,0 +1,4 @@ +fastapi>=0.115 +uvicorn[standard]>=0.30 +numpy>=1.26 +pydantic-settings>=2.2 diff --git a/services/analytics/__init__.py b/services/analytics/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/analytics/app.py b/services/analytics/app.py new file mode 100644 index 0000000..e8bc78e --- /dev/null +++ b/services/analytics/app.py @@ -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} diff --git a/services/analytics/requirements.txt b/services/analytics/requirements.txt new file mode 100644 index 0000000..55b43be --- /dev/null +++ b/services/analytics/requirements.txt @@ -0,0 +1,5 @@ +fastapi>=0.115 +uvicorn[standard]>=0.30 +sqlmodel>=0.0.16 +pydantic-settings>=2.2 +psycopg[binary]>=3.1 diff --git a/services/api/__init__.py b/services/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/api/app.py b/services/api/app.py new file mode 100644 index 0000000..30c8b3e --- /dev/null +++ b/services/api/app.py @@ -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) diff --git a/services/api/models.py b/services/api/models.py new file mode 100644 index 0000000..4afa2c4 --- /dev/null +++ b/services/api/models.py @@ -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)) diff --git a/services/api/requirements.txt b/services/api/requirements.txt new file mode 100644 index 0000000..2b8d102 --- /dev/null +++ b/services/api/requirements.txt @@ -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 diff --git a/services/common/__init__.py b/services/common/__init__.py new file mode 100644 index 0000000..125a03a --- /dev/null +++ b/services/common/__init__.py @@ -0,0 +1 @@ +"""Shared utilities for Catan services.""" diff --git a/services/common/auth.py b/services/common/auth.py new file mode 100644 index 0000000..5816293 --- /dev/null +++ b/services/common/auth.py @@ -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 diff --git a/services/common/db.py b/services/common/db.py new file mode 100644 index 0000000..5ddb29e --- /dev/null +++ b/services/common/db.py @@ -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() diff --git a/services/common/schemas.py b/services/common/schemas.py new file mode 100644 index 0000000..c67407a --- /dev/null +++ b/services/common/schemas.py @@ -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] diff --git a/services/common/settings.py b/services/common/settings.py new file mode 100644 index 0000000..445dbdd --- /dev/null +++ b/services/common/settings.py @@ -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() diff --git a/services/game/__init__.py b/services/game/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/game/app.py b/services/game/app.py new file mode 100644 index 0000000..0ff05b9 --- /dev/null +++ b/services/game/app.py @@ -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() diff --git a/services/game/models.py b/services/game/models.py new file mode 100644 index 0000000..6982a1f --- /dev/null +++ b/services/game/models.py @@ -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 diff --git a/services/game/requirements.txt b/services/game/requirements.txt new file mode 100644 index 0000000..3e4c11f --- /dev/null +++ b/services/game/requirements.txt @@ -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 diff --git a/services/game/runtime.py b/services/game/runtime.py new file mode 100644 index 0000000..26afc13 --- /dev/null +++ b/services/game/runtime.py @@ -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() diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..95818b4 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Test utilities package for Catan project.""" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..2b2c0b8 --- /dev/null +++ b/tests/conftest.py @@ -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) diff --git a/tests/e2e/test_api_flow.py b/tests/e2e/test_api_flow.py new file mode 100644 index 0000000..06eee6f --- /dev/null +++ b/tests/e2e/test_api_flow.py @@ -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) diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..e002a1e --- /dev/null +++ b/tests/test_cli.py @@ -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") diff --git a/tests/test_cli_bot.py b/tests/test_cli_bot.py new file mode 100644 index 0000000..28fe929 --- /dev/null +++ b/tests/test_cli_bot.py @@ -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) diff --git a/tests/test_env_actions.py b/tests/test_env_actions.py new file mode 100644 index 0000000..60232a3 --- /dev/null +++ b/tests/test_env_actions.py @@ -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 + ) diff --git a/tests/unit/test_api_auth.py b/tests/unit/test_api_auth.py new file mode 100644 index 0000000..4cbb704 --- /dev/null +++ b/tests/unit/test_api_auth.py @@ -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 diff --git a/tests/unit/test_auth.py b/tests/unit/test_auth.py new file mode 100644 index 0000000..0871c76 --- /dev/null +++ b/tests/unit/test_auth.py @@ -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" diff --git a/tests/utils.py b/tests/utils.py new file mode 100644 index 0000000..614370b --- /dev/null +++ b/tests/utils.py @@ -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 diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..06d3899 --- /dev/null +++ b/uv.lock @@ -0,0 +1,765 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version < '3.11'", +] + +[[package]] +name = "catan" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "rich" }, + { name = "torch" }, + { name = "typer" }, +] + +[package.optional-dependencies] +dev = [ + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "numpy", specifier = ">=1.26" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.4" }, + { name = "rich", specifier = ">=13.7" }, + { name = "torch", specifier = ">=2.2" }, + { name = "typer", specifier = ">=0.12" }, +] +provides-extras = ["dev"] + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "filelock" +version = "3.20.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a7/23/ce7a1126827cedeb958fc043d61745754464eb56c5937c35bbf2b8e26f34/filelock-3.20.1.tar.gz", hash = "sha256:b8360948b351b80f420878d8516519a2204b07aefcdcfd24912a5d33127f188c", size = 19476, upload-time = "2025-12-15T23:54:28.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/7f/a1a97644e39e7316d850784c642093c99df1290a460df4ede27659056834/filelock-3.20.1-py3-none-any.whl", hash = "sha256:15d9e9a67306188a44baa72f569d2bfd803076269365fdea0934385da4dc361a", size = 16666, upload-time = "2025-12-15T23:54:26.874Z" }, +] + +[[package]] +name = "fsspec" +version = "2025.12.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/27/954057b0d1f53f086f681755207dda6de6c660ce133c829158e8e8fe7895/fsspec-2025.12.0.tar.gz", hash = "sha256:c505de011584597b1060ff778bb664c1bc022e87921b0e4f10cc9c44f9635973", size = 309748, upload-time = "2025-12-03T15:23:42.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/c7/b64cae5dba3a1b138d7123ec36bb5ccd39d39939f18454407e5468f4763f/fsspec-2025.12.0-py3-none-any.whl", hash = "sha256:8bf1fe301b7d8acfa6e8571e3b1c3d158f909666642431cc78a1b7b4dbc5ec5b", size = 201422, upload-time = "2025-12-03T15:23:41.434Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, + { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" }, + { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" }, + { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" }, + { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" }, + { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" }, + { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" }, + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "mpmath" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106, upload-time = "2023-03-07T16:47:11.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" }, +] + +[[package]] +name = "networkx" +version = "3.4.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +sdist = { url = "https://files.pythonhosted.org/packages/fd/1d/06475e1cd5264c0b870ea2cc6fdb3e37177c1e565c43f56ff17a10e3937f/networkx-3.4.2.tar.gz", hash = "sha256:307c3669428c5362aab27c8a1260aa8f47c4e91d3891f48be0141738d8d053e1", size = 2151368, upload-time = "2024-10-21T12:39:38.695Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/54/dd730b32ea14ea797530a4479b2ed46a6fb250f682a9cfb997e968bf0261/networkx-3.4.2-py3-none-any.whl", hash = "sha256:df5d4365b724cf81b8c6a7312509d0c22386097011ad1abe274afd5e9d3bbc5f", size = 1723263, upload-time = "2024-10-21T12:39:36.247Z" }, +] + +[[package]] +name = "networkx" +version = "3.6.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" }, +] + +[[package]] +name = "numpy" +version = "2.2.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +sdist = { url = "https://files.pythonhosted.org/packages/76/21/7d2a95e4bba9dc13d043ee156a356c0a8f0c6309dff6b21b4d71a073b8a8/numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd", size = 20276440, upload-time = "2025-05-17T22:38:04.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3e/ed6db5be21ce87955c0cbd3009f2803f59fa08df21b5df06862e2d8e2bdd/numpy-2.2.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b412caa66f72040e6d268491a59f2c43bf03eb6c96dd8f0307829feb7fa2b6fb", size = 21165245, upload-time = "2025-05-17T21:27:58.555Z" }, + { url = "https://files.pythonhosted.org/packages/22/c2/4b9221495b2a132cc9d2eb862e21d42a009f5a60e45fc44b00118c174bff/numpy-2.2.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e41fd67c52b86603a91c1a505ebaef50b3314de0213461c7a6e99c9a3beff90", size = 14360048, upload-time = "2025-05-17T21:28:21.406Z" }, + { url = "https://files.pythonhosted.org/packages/fd/77/dc2fcfc66943c6410e2bf598062f5959372735ffda175b39906d54f02349/numpy-2.2.6-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:37e990a01ae6ec7fe7fa1c26c55ecb672dd98b19c3d0e1d1f326fa13cb38d163", size = 5340542, upload-time = "2025-05-17T21:28:30.931Z" }, + { url = "https://files.pythonhosted.org/packages/7a/4f/1cb5fdc353a5f5cc7feb692db9b8ec2c3d6405453f982435efc52561df58/numpy-2.2.6-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:5a6429d4be8ca66d889b7cf70f536a397dc45ba6faeb5f8c5427935d9592e9cf", size = 6878301, upload-time = "2025-05-17T21:28:41.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/17/96a3acd228cec142fcb8723bd3cc39c2a474f7dcf0a5d16731980bcafa95/numpy-2.2.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:efd28d4e9cd7d7a8d39074a4d44c63eda73401580c5c76acda2ce969e0a38e83", size = 14297320, upload-time = "2025-05-17T21:29:02.78Z" }, + { url = "https://files.pythonhosted.org/packages/b4/63/3de6a34ad7ad6646ac7d2f55ebc6ad439dbbf9c4370017c50cf403fb19b5/numpy-2.2.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc7b73d02efb0e18c000e9ad8b83480dfcd5dfd11065997ed4c6747470ae8915", size = 16801050, upload-time = "2025-05-17T21:29:27.675Z" }, + { url = "https://files.pythonhosted.org/packages/07/b6/89d837eddef52b3d0cec5c6ba0456c1bf1b9ef6a6672fc2b7873c3ec4e2e/numpy-2.2.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:74d4531beb257d2c3f4b261bfb0fc09e0f9ebb8842d82a7b4209415896adc680", size = 15807034, upload-time = "2025-05-17T21:29:51.102Z" }, + { url = "https://files.pythonhosted.org/packages/01/c8/dc6ae86e3c61cfec1f178e5c9f7858584049b6093f843bca541f94120920/numpy-2.2.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8fc377d995680230e83241d8a96def29f204b5782f371c532579b4f20607a289", size = 18614185, upload-time = "2025-05-17T21:30:18.703Z" }, + { url = "https://files.pythonhosted.org/packages/5b/c5/0064b1b7e7c89137b471ccec1fd2282fceaae0ab3a9550f2568782d80357/numpy-2.2.6-cp310-cp310-win32.whl", hash = "sha256:b093dd74e50a8cba3e873868d9e93a85b78e0daf2e98c6797566ad8044e8363d", size = 6527149, upload-time = "2025-05-17T21:30:29.788Z" }, + { url = "https://files.pythonhosted.org/packages/a3/dd/4b822569d6b96c39d1215dbae0582fd99954dcbcf0c1a13c61783feaca3f/numpy-2.2.6-cp310-cp310-win_amd64.whl", hash = "sha256:f0fd6321b839904e15c46e0d257fdd101dd7f530fe03fd6359c1ea63738703f3", size = 12904620, upload-time = "2025-05-17T21:30:48.994Z" }, + { url = "https://files.pythonhosted.org/packages/da/a8/4f83e2aa666a9fbf56d6118faaaf5f1974d456b1823fda0a176eff722839/numpy-2.2.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f9f1adb22318e121c5c69a09142811a201ef17ab257a1e66ca3025065b7f53ae", size = 21176963, upload-time = "2025-05-17T21:31:19.36Z" }, + { url = "https://files.pythonhosted.org/packages/b3/2b/64e1affc7972decb74c9e29e5649fac940514910960ba25cd9af4488b66c/numpy-2.2.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c820a93b0255bc360f53eca31a0e676fd1101f673dda8da93454a12e23fc5f7a", size = 14406743, upload-time = "2025-05-17T21:31:41.087Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9f/0121e375000b5e50ffdd8b25bf78d8e1a5aa4cca3f185d41265198c7b834/numpy-2.2.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3d70692235e759f260c3d837193090014aebdf026dfd167834bcba43e30c2a42", size = 5352616, upload-time = "2025-05-17T21:31:50.072Z" }, + { url = "https://files.pythonhosted.org/packages/31/0d/b48c405c91693635fbe2dcd7bc84a33a602add5f63286e024d3b6741411c/numpy-2.2.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:481b49095335f8eed42e39e8041327c05b0f6f4780488f61286ed3c01368d491", size = 6889579, upload-time = "2025-05-17T21:32:01.712Z" }, + { url = "https://files.pythonhosted.org/packages/52/b8/7f0554d49b565d0171eab6e99001846882000883998e7b7d9f0d98b1f934/numpy-2.2.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b64d8d4d17135e00c8e346e0a738deb17e754230d7e0810ac5012750bbd85a5a", size = 14312005, upload-time = "2025-05-17T21:32:23.332Z" }, + { url = "https://files.pythonhosted.org/packages/b3/dd/2238b898e51bd6d389b7389ffb20d7f4c10066d80351187ec8e303a5a475/numpy-2.2.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba10f8411898fc418a521833e014a77d3ca01c15b0c6cdcce6a0d2897e6dbbdf", size = 16821570, upload-time = "2025-05-17T21:32:47.991Z" }, + { url = "https://files.pythonhosted.org/packages/83/6c/44d0325722cf644f191042bf47eedad61c1e6df2432ed65cbe28509d404e/numpy-2.2.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd48227a919f1bafbdda0583705e547892342c26fb127219d60a5c36882609d1", size = 15818548, upload-time = "2025-05-17T21:33:11.728Z" }, + { url = "https://files.pythonhosted.org/packages/ae/9d/81e8216030ce66be25279098789b665d49ff19eef08bfa8cb96d4957f422/numpy-2.2.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9551a499bf125c1d4f9e250377c1ee2eddd02e01eac6644c080162c0c51778ab", size = 18620521, upload-time = "2025-05-17T21:33:39.139Z" }, + { url = "https://files.pythonhosted.org/packages/6a/fd/e19617b9530b031db51b0926eed5345ce8ddc669bb3bc0044b23e275ebe8/numpy-2.2.6-cp311-cp311-win32.whl", hash = "sha256:0678000bb9ac1475cd454c6b8c799206af8107e310843532b04d49649c717a47", size = 6525866, upload-time = "2025-05-17T21:33:50.273Z" }, + { url = "https://files.pythonhosted.org/packages/31/0a/f354fb7176b81747d870f7991dc763e157a934c717b67b58456bc63da3df/numpy-2.2.6-cp311-cp311-win_amd64.whl", hash = "sha256:e8213002e427c69c45a52bbd94163084025f533a55a59d6f9c5b820774ef3303", size = 12907455, upload-time = "2025-05-17T21:34:09.135Z" }, + { url = "https://files.pythonhosted.org/packages/82/5d/c00588b6cf18e1da539b45d3598d3557084990dcc4331960c15ee776ee41/numpy-2.2.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41c5a21f4a04fa86436124d388f6ed60a9343a6f767fced1a8a71c3fbca038ff", size = 20875348, upload-time = "2025-05-17T21:34:39.648Z" }, + { url = "https://files.pythonhosted.org/packages/66/ee/560deadcdde6c2f90200450d5938f63a34b37e27ebff162810f716f6a230/numpy-2.2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de749064336d37e340f640b05f24e9e3dd678c57318c7289d222a8a2f543e90c", size = 14119362, upload-time = "2025-05-17T21:35:01.241Z" }, + { url = "https://files.pythonhosted.org/packages/3c/65/4baa99f1c53b30adf0acd9a5519078871ddde8d2339dc5a7fde80d9d87da/numpy-2.2.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:894b3a42502226a1cac872f840030665f33326fc3dac8e57c607905773cdcde3", size = 5084103, upload-time = "2025-05-17T21:35:10.622Z" }, + { url = "https://files.pythonhosted.org/packages/cc/89/e5a34c071a0570cc40c9a54eb472d113eea6d002e9ae12bb3a8407fb912e/numpy-2.2.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:71594f7c51a18e728451bb50cc60a3ce4e6538822731b2933209a1f3614e9282", size = 6625382, upload-time = "2025-05-17T21:35:21.414Z" }, + { url = "https://files.pythonhosted.org/packages/f8/35/8c80729f1ff76b3921d5c9487c7ac3de9b2a103b1cd05e905b3090513510/numpy-2.2.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2618db89be1b4e05f7a1a847a9c1c0abd63e63a1607d892dd54668dd92faf87", size = 14018462, upload-time = "2025-05-17T21:35:42.174Z" }, + { url = "https://files.pythonhosted.org/packages/8c/3d/1e1db36cfd41f895d266b103df00ca5b3cbe965184df824dec5c08c6b803/numpy-2.2.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd83c01228a688733f1ded5201c678f0c53ecc1006ffbc404db9f7a899ac6249", size = 16527618, upload-time = "2025-05-17T21:36:06.711Z" }, + { url = "https://files.pythonhosted.org/packages/61/c6/03ed30992602c85aa3cd95b9070a514f8b3c33e31124694438d88809ae36/numpy-2.2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37c0ca431f82cd5fa716eca9506aefcabc247fb27ba69c5062a6d3ade8cf8f49", size = 15505511, upload-time = "2025-05-17T21:36:29.965Z" }, + { url = "https://files.pythonhosted.org/packages/b7/25/5761d832a81df431e260719ec45de696414266613c9ee268394dd5ad8236/numpy-2.2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fe27749d33bb772c80dcd84ae7e8df2adc920ae8297400dabec45f0dedb3f6de", size = 18313783, upload-time = "2025-05-17T21:36:56.883Z" }, + { url = "https://files.pythonhosted.org/packages/57/0a/72d5a3527c5ebffcd47bde9162c39fae1f90138c961e5296491ce778e682/numpy-2.2.6-cp312-cp312-win32.whl", hash = "sha256:4eeaae00d789f66c7a25ac5f34b71a7035bb474e679f410e5e1a94deb24cf2d4", size = 6246506, upload-time = "2025-05-17T21:37:07.368Z" }, + { url = "https://files.pythonhosted.org/packages/36/fa/8c9210162ca1b88529ab76b41ba02d433fd54fecaf6feb70ef9f124683f1/numpy-2.2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c1f9540be57940698ed329904db803cf7a402f3fc200bfe599334c9bd84a40b2", size = 12614190, upload-time = "2025-05-17T21:37:26.213Z" }, + { url = "https://files.pythonhosted.org/packages/f9/5c/6657823f4f594f72b5471f1db1ab12e26e890bb2e41897522d134d2a3e81/numpy-2.2.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0811bb762109d9708cca4d0b13c4f67146e3c3b7cf8d34018c722adb2d957c84", size = 20867828, upload-time = "2025-05-17T21:37:56.699Z" }, + { url = "https://files.pythonhosted.org/packages/dc/9e/14520dc3dadf3c803473bd07e9b2bd1b69bc583cb2497b47000fed2fa92f/numpy-2.2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:287cc3162b6f01463ccd86be154f284d0893d2b3ed7292439ea97eafa8170e0b", size = 14143006, upload-time = "2025-05-17T21:38:18.291Z" }, + { url = "https://files.pythonhosted.org/packages/4f/06/7e96c57d90bebdce9918412087fc22ca9851cceaf5567a45c1f404480e9e/numpy-2.2.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f1372f041402e37e5e633e586f62aa53de2eac8d98cbfb822806ce4bbefcb74d", size = 5076765, upload-time = "2025-05-17T21:38:27.319Z" }, + { url = "https://files.pythonhosted.org/packages/73/ed/63d920c23b4289fdac96ddbdd6132e9427790977d5457cd132f18e76eae0/numpy-2.2.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:55a4d33fa519660d69614a9fad433be87e5252f4b03850642f88993f7b2ca566", size = 6617736, upload-time = "2025-05-17T21:38:38.141Z" }, + { url = "https://files.pythonhosted.org/packages/85/c5/e19c8f99d83fd377ec8c7e0cf627a8049746da54afc24ef0a0cb73d5dfb5/numpy-2.2.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f92729c95468a2f4f15e9bb94c432a9229d0d50de67304399627a943201baa2f", size = 14010719, upload-time = "2025-05-17T21:38:58.433Z" }, + { url = "https://files.pythonhosted.org/packages/19/49/4df9123aafa7b539317bf6d342cb6d227e49f7a35b99c287a6109b13dd93/numpy-2.2.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bc23a79bfabc5d056d106f9befb8d50c31ced2fbc70eedb8155aec74a45798f", size = 16526072, upload-time = "2025-05-17T21:39:22.638Z" }, + { url = "https://files.pythonhosted.org/packages/b2/6c/04b5f47f4f32f7c2b0e7260442a8cbcf8168b0e1a41ff1495da42f42a14f/numpy-2.2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3143e4451880bed956e706a3220b4e5cf6172ef05fcc397f6f36a550b1dd868", size = 15503213, upload-time = "2025-05-17T21:39:45.865Z" }, + { url = "https://files.pythonhosted.org/packages/17/0a/5cd92e352c1307640d5b6fec1b2ffb06cd0dabe7d7b8227f97933d378422/numpy-2.2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b4f13750ce79751586ae2eb824ba7e1e8dba64784086c98cdbbcc6a42112ce0d", size = 18316632, upload-time = "2025-05-17T21:40:13.331Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3b/5cba2b1d88760ef86596ad0f3d484b1cbff7c115ae2429678465057c5155/numpy-2.2.6-cp313-cp313-win32.whl", hash = "sha256:5beb72339d9d4fa36522fc63802f469b13cdbe4fdab4a288f0c441b74272ebfd", size = 6244532, upload-time = "2025-05-17T21:43:46.099Z" }, + { url = "https://files.pythonhosted.org/packages/cb/3b/d58c12eafcb298d4e6d0d40216866ab15f59e55d148a5658bb3132311fcf/numpy-2.2.6-cp313-cp313-win_amd64.whl", hash = "sha256:b0544343a702fa80c95ad5d3d608ea3599dd54d4632df855e4c8d24eb6ecfa1c", size = 12610885, upload-time = "2025-05-17T21:44:05.145Z" }, + { url = "https://files.pythonhosted.org/packages/6b/9e/4bf918b818e516322db999ac25d00c75788ddfd2d2ade4fa66f1f38097e1/numpy-2.2.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0bca768cd85ae743b2affdc762d617eddf3bcf8724435498a1e80132d04879e6", size = 20963467, upload-time = "2025-05-17T21:40:44Z" }, + { url = "https://files.pythonhosted.org/packages/61/66/d2de6b291507517ff2e438e13ff7b1e2cdbdb7cb40b3ed475377aece69f9/numpy-2.2.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fc0c5673685c508a142ca65209b4e79ed6740a4ed6b2267dbba90f34b0b3cfda", size = 14225144, upload-time = "2025-05-17T21:41:05.695Z" }, + { url = "https://files.pythonhosted.org/packages/e4/25/480387655407ead912e28ba3a820bc69af9adf13bcbe40b299d454ec011f/numpy-2.2.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:5bd4fc3ac8926b3819797a7c0e2631eb889b4118a9898c84f585a54d475b7e40", size = 5200217, upload-time = "2025-05-17T21:41:15.903Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4a/6e313b5108f53dcbf3aca0c0f3e9c92f4c10ce57a0a721851f9785872895/numpy-2.2.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:fee4236c876c4e8369388054d02d0e9bb84821feb1a64dd59e137e6511a551f8", size = 6712014, upload-time = "2025-05-17T21:41:27.321Z" }, + { url = "https://files.pythonhosted.org/packages/b7/30/172c2d5c4be71fdf476e9de553443cf8e25feddbe185e0bd88b096915bcc/numpy-2.2.6-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1dda9c7e08dc141e0247a5b8f49cf05984955246a327d4c48bda16821947b2f", size = 14077935, upload-time = "2025-05-17T21:41:49.738Z" }, + { url = "https://files.pythonhosted.org/packages/12/fb/9e743f8d4e4d3c710902cf87af3512082ae3d43b945d5d16563f26ec251d/numpy-2.2.6-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f447e6acb680fd307f40d3da4852208af94afdfab89cf850986c3ca00562f4fa", size = 16600122, upload-time = "2025-05-17T21:42:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/12/75/ee20da0e58d3a66f204f38916757e01e33a9737d0b22373b3eb5a27358f9/numpy-2.2.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:389d771b1623ec92636b0786bc4ae56abafad4a4c513d36a55dce14bd9ce8571", size = 15586143, upload-time = "2025-05-17T21:42:37.464Z" }, + { url = "https://files.pythonhosted.org/packages/76/95/bef5b37f29fc5e739947e9ce5179ad402875633308504a52d188302319c8/numpy-2.2.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8e9ace4a37db23421249ed236fdcdd457d671e25146786dfc96835cd951aa7c1", size = 18385260, upload-time = "2025-05-17T21:43:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/09/04/f2f83279d287407cf36a7a8053a5abe7be3622a4363337338f2585e4afda/numpy-2.2.6-cp313-cp313t-win32.whl", hash = "sha256:038613e9fb8c72b0a41f025a7e4c3f0b7a1b5d768ece4796b674c8f3fe13efff", size = 6377225, upload-time = "2025-05-17T21:43:16.254Z" }, + { url = "https://files.pythonhosted.org/packages/67/0e/35082d13c09c02c011cf21570543d202ad929d961c02a147493cb0c2bdf5/numpy-2.2.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6031dd6dfecc0cf9f668681a37648373bddd6421fff6c66ec1624eed0180ee06", size = 12771374, upload-time = "2025-05-17T21:43:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/9e/3b/d94a75f4dbf1ef5d321523ecac21ef23a3cd2ac8b78ae2aac40873590229/numpy-2.2.6-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0b605b275d7bd0c640cad4e5d30fa701a8d59302e127e5f79138ad62762c3e3d", size = 21040391, upload-time = "2025-05-17T21:44:35.948Z" }, + { url = "https://files.pythonhosted.org/packages/17/f4/09b2fa1b58f0fb4f7c7963a1649c64c4d315752240377ed74d9cd878f7b5/numpy-2.2.6-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:7befc596a7dc9da8a337f79802ee8adb30a552a94f792b9c9d18c840055907db", size = 6786754, upload-time = "2025-05-17T21:44:47.446Z" }, + { url = "https://files.pythonhosted.org/packages/af/30/feba75f143bdc868a1cc3f44ccfa6c4b9ec522b36458e738cd00f67b573f/numpy-2.2.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce47521a4754c8f4593837384bd3424880629f718d87c5d44f8ed763edd63543", size = 16643476, upload-time = "2025-05-17T21:45:11.871Z" }, + { url = "https://files.pythonhosted.org/packages/37/48/ac2a9584402fb6c0cd5b5d1a91dcf176b15760130dd386bbafdbfe3640bf/numpy-2.2.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d042d24c90c41b54fd506da306759e06e568864df8ec17ccc17e9e884634fd00", size = 12812666, upload-time = "2025-05-17T21:45:31.426Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/a4/7a/6a3d14e205d292b738db449d0de649b373a59edb0d0b4493821d0a3e8718/numpy-2.4.0.tar.gz", hash = "sha256:6e504f7b16118198f138ef31ba24d985b124c2c469fe8467007cf30fd992f934", size = 20685720, upload-time = "2025-12-20T16:18:19.023Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/7e/7bae7cbcc2f8132271967aa03e03954fc1e48aa1f3bf32b29ca95fbef352/numpy-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:316b2f2584682318539f0bcaca5a496ce9ca78c88066579ebd11fd06f8e4741e", size = 16940166, upload-time = "2025-12-20T16:15:43.434Z" }, + { url = "https://files.pythonhosted.org/packages/0f/27/6c13f5b46776d6246ec884ac5817452672156a506d08a1f2abb39961930a/numpy-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a2718c1de8504121714234b6f8241d0019450353276c88b9453c9c3d92e101db", size = 12641781, upload-time = "2025-12-20T16:15:45.701Z" }, + { url = "https://files.pythonhosted.org/packages/14/1c/83b4998d4860d15283241d9e5215f28b40ac31f497c04b12fa7f428ff370/numpy-2.4.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:21555da4ec4a0c942520ead42c3b0dc9477441e085c42b0fbdd6a084869a6f6b", size = 5470247, upload-time = "2025-12-20T16:15:47.943Z" }, + { url = "https://files.pythonhosted.org/packages/54/08/cbce72c835d937795571b0464b52069f869c9e78b0c076d416c5269d2718/numpy-2.4.0-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:413aa561266a4be2d06cd2b9665e89d9f54c543f418773076a76adcf2af08bc7", size = 6799807, upload-time = "2025-12-20T16:15:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/ff/be/2e647961cd8c980591d75cdcd9e8f647d69fbe05e2a25613dc0a2ea5fb1a/numpy-2.4.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0feafc9e03128074689183031181fac0897ff169692d8492066e949041096548", size = 14701992, upload-time = "2025-12-20T16:15:51.615Z" }, + { url = "https://files.pythonhosted.org/packages/a2/fb/e1652fb8b6fd91ce6ed429143fe2e01ce714711e03e5b762615e7b36172c/numpy-2.4.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8fdfed3deaf1928fb7667d96e0567cdf58c2b370ea2ee7e586aa383ec2cb346", size = 16646871, upload-time = "2025-12-20T16:15:54.129Z" }, + { url = "https://files.pythonhosted.org/packages/62/23/d841207e63c4322842f7cd042ae981cffe715c73376dcad8235fb31debf1/numpy-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e06a922a469cae9a57100864caf4f8a97a1026513793969f8ba5b63137a35d25", size = 16487190, upload-time = "2025-12-20T16:15:56.147Z" }, + { url = "https://files.pythonhosted.org/packages/bc/a0/6a842c8421ebfdec0a230e65f61e0dabda6edbef443d999d79b87c273965/numpy-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:927ccf5cd17c48f801f4ed43a7e5673a2724bd2171460be3e3894e6e332ef83a", size = 18580762, upload-time = "2025-12-20T16:15:58.524Z" }, + { url = "https://files.pythonhosted.org/packages/0a/d1/c79e0046641186f2134dde05e6181825b911f8bdcef31b19ddd16e232847/numpy-2.4.0-cp311-cp311-win32.whl", hash = "sha256:882567b7ae57c1b1a0250208cc21a7976d8cbcc49d5a322e607e6f09c9e0bd53", size = 6233359, upload-time = "2025-12-20T16:16:00.938Z" }, + { url = "https://files.pythonhosted.org/packages/fc/f0/74965001d231f28184d6305b8cdc1b6fcd4bf23033f6cb039cfe76c9fca7/numpy-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:8b986403023c8f3bf8f487c2e6186afda156174d31c175f747d8934dfddf3479", size = 12601132, upload-time = "2025-12-20T16:16:02.484Z" }, + { url = "https://files.pythonhosted.org/packages/65/32/55408d0f46dfebce38017f5bd931affa7256ad6beac1a92a012e1fbc67a7/numpy-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:3f3096405acc48887458bbf9f6814d43785ac7ba2a57ea6442b581dedbc60ce6", size = 10573977, upload-time = "2025-12-20T16:16:04.77Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ff/f6400ffec95de41c74b8e73df32e3fff1830633193a7b1e409be7fb1bb8c/numpy-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2a8b6bb8369abefb8bd1801b054ad50e02b3275c8614dc6e5b0373c305291037", size = 16653117, upload-time = "2025-12-20T16:16:06.709Z" }, + { url = "https://files.pythonhosted.org/packages/fd/28/6c23e97450035072e8d830a3c411bf1abd1f42c611ff9d29e3d8f55c6252/numpy-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2e284ca13d5a8367e43734148622caf0b261b275673823593e3e3634a6490f83", size = 12369711, upload-time = "2025-12-20T16:16:08.758Z" }, + { url = "https://files.pythonhosted.org/packages/bc/af/acbef97b630ab1bb45e6a7d01d1452e4251aa88ce680ac36e56c272120ec/numpy-2.4.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:49ff32b09f5aa0cd30a20c2b39db3e669c845589f2b7fc910365210887e39344", size = 5198355, upload-time = "2025-12-20T16:16:10.902Z" }, + { url = "https://files.pythonhosted.org/packages/c1/c8/4e0d436b66b826f2e53330adaa6311f5cac9871a5b5c31ad773b27f25a74/numpy-2.4.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:36cbfb13c152b1c7c184ddac43765db8ad672567e7bafff2cc755a09917ed2e6", size = 6545298, upload-time = "2025-12-20T16:16:12.607Z" }, + { url = "https://files.pythonhosted.org/packages/ef/27/e1f5d144ab54eac34875e79037011d511ac57b21b220063310cb96c80fbc/numpy-2.4.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:35ddc8f4914466e6fc954c76527aa91aa763682a4f6d73249ef20b418fe6effb", size = 14398387, upload-time = "2025-12-20T16:16:14.257Z" }, + { url = "https://files.pythonhosted.org/packages/67/64/4cb909dd5ab09a9a5d086eff9586e69e827b88a5585517386879474f4cf7/numpy-2.4.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc578891de1db95b2a35001b695451767b580bb45753717498213c5ff3c41d63", size = 16363091, upload-time = "2025-12-20T16:16:17.32Z" }, + { url = "https://files.pythonhosted.org/packages/9d/9c/8efe24577523ec6809261859737cf117b0eb6fdb655abdfdc81b2e468ce4/numpy-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:98e81648e0b36e325ab67e46b5400a7a6d4a22b8a7c8e8bbfe20e7db7906bf95", size = 16176394, upload-time = "2025-12-20T16:16:19.524Z" }, + { url = "https://files.pythonhosted.org/packages/61/f0/1687441ece7b47a62e45a1f82015352c240765c707928edd8aef875d5951/numpy-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d57b5046c120561ba8fa8e4030fbb8b822f3063910fa901ffadf16e2b7128ad6", size = 18287378, upload-time = "2025-12-20T16:16:22.866Z" }, + { url = "https://files.pythonhosted.org/packages/d3/6f/f868765d44e6fc466467ed810ba9d8d6db1add7d4a748abfa2a4c99a3194/numpy-2.4.0-cp312-cp312-win32.whl", hash = "sha256:92190db305a6f48734d3982f2c60fa30d6b5ee9bff10f2887b930d7b40119f4c", size = 5955432, upload-time = "2025-12-20T16:16:25.06Z" }, + { url = "https://files.pythonhosted.org/packages/d4/b5/94c1e79fcbab38d1ca15e13777477b2914dd2d559b410f96949d6637b085/numpy-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:680060061adb2d74ce352628cb798cfdec399068aa7f07ba9fb818b2b3305f98", size = 12306201, upload-time = "2025-12-20T16:16:26.979Z" }, + { url = "https://files.pythonhosted.org/packages/70/09/c39dadf0b13bb0768cd29d6a3aaff1fb7c6905ac40e9aaeca26b1c086e06/numpy-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:39699233bc72dd482da1415dcb06076e32f60eddc796a796c5fb6c5efce94667", size = 10308234, upload-time = "2025-12-20T16:16:29.417Z" }, + { url = "https://files.pythonhosted.org/packages/a7/0d/853fd96372eda07c824d24adf02e8bc92bb3731b43a9b2a39161c3667cc4/numpy-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a152d86a3ae00ba5f47b3acf3b827509fd0b6cb7d3259665e63dafbad22a75ea", size = 16649088, upload-time = "2025-12-20T16:16:31.421Z" }, + { url = "https://files.pythonhosted.org/packages/e3/37/cc636f1f2a9f585434e20a3e6e63422f70bfe4f7f6698e941db52ea1ac9a/numpy-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:39b19251dec4de8ff8496cd0806cbe27bf0684f765abb1f4809554de93785f2d", size = 12364065, upload-time = "2025-12-20T16:16:33.491Z" }, + { url = "https://files.pythonhosted.org/packages/ed/69/0b78f37ca3690969beee54103ce5f6021709134e8020767e93ba691a72f1/numpy-2.4.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:009bd0ea12d3c784b6639a8457537016ce5172109e585338e11334f6a7bb88ee", size = 5192640, upload-time = "2025-12-20T16:16:35.636Z" }, + { url = "https://files.pythonhosted.org/packages/1d/2a/08569f8252abf590294dbb09a430543ec8f8cc710383abfb3e75cc73aeda/numpy-2.4.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:5fe44e277225fd3dff6882d86d3d447205d43532c3627313d17e754fb3905a0e", size = 6541556, upload-time = "2025-12-20T16:16:37.276Z" }, + { url = "https://files.pythonhosted.org/packages/93/e9/a949885a4e177493d61519377952186b6cbfdf1d6002764c664ba28349b5/numpy-2.4.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f935c4493eda9069851058fa0d9e39dbf6286be690066509305e52912714dbb2", size = 14396562, upload-time = "2025-12-20T16:16:38.953Z" }, + { url = "https://files.pythonhosted.org/packages/99/98/9d4ad53b0e9ef901c2ef1d550d2136f5ac42d3fd2988390a6def32e23e48/numpy-2.4.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8cfa5f29a695cb7438965e6c3e8d06e0416060cf0d709c1b1c1653a939bf5c2a", size = 16351719, upload-time = "2025-12-20T16:16:41.503Z" }, + { url = "https://files.pythonhosted.org/packages/28/de/5f3711a38341d6e8dd619f6353251a0cdd07f3d6d101a8fd46f4ef87f895/numpy-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ba0cb30acd3ef11c94dc27fbfba68940652492bc107075e7ffe23057f9425681", size = 16176053, upload-time = "2025-12-20T16:16:44.552Z" }, + { url = "https://files.pythonhosted.org/packages/2a/5b/2a3753dc43916501b4183532e7ace862e13211042bceafa253afb5c71272/numpy-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:60e8c196cd82cbbd4f130b5290007e13e6de3eca79f0d4d38014769d96a7c475", size = 18277859, upload-time = "2025-12-20T16:16:47.174Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c5/a18bcdd07a941db3076ef489d036ab16d2bfc2eae0cf27e5a26e29189434/numpy-2.4.0-cp313-cp313-win32.whl", hash = "sha256:5f48cb3e88fbc294dc90e215d86fbaf1c852c63dbdb6c3a3e63f45c4b57f7344", size = 5953849, upload-time = "2025-12-20T16:16:49.554Z" }, + { url = "https://files.pythonhosted.org/packages/4f/f1/719010ff8061da6e8a26e1980cf090412d4f5f8060b31f0c45d77dd67a01/numpy-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:a899699294f28f7be8992853c0c60741f16ff199205e2e6cdca155762cbaa59d", size = 12302840, upload-time = "2025-12-20T16:16:51.227Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5a/b3d259083ed8b4d335270c76966cb6cf14a5d1b69e1a608994ac57a659e6/numpy-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:9198f447e1dc5647d07c9a6bbe2063cc0132728cc7175b39dbc796da5b54920d", size = 10308509, upload-time = "2025-12-20T16:16:53.313Z" }, + { url = "https://files.pythonhosted.org/packages/31/01/95edcffd1bb6c0633df4e808130545c4f07383ab629ac7e316fb44fff677/numpy-2.4.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74623f2ab5cc3f7c886add4f735d1031a1d2be4a4ae63c0546cfd74e7a31ddf6", size = 12491815, upload-time = "2025-12-20T16:16:55.496Z" }, + { url = "https://files.pythonhosted.org/packages/59/ea/5644b8baa92cc1c7163b4b4458c8679852733fa74ca49c942cfa82ded4e0/numpy-2.4.0-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:0804a8e4ab070d1d35496e65ffd3cf8114c136a2b81f61dfab0de4b218aacfd5", size = 5320321, upload-time = "2025-12-20T16:16:57.468Z" }, + { url = "https://files.pythonhosted.org/packages/26/4e/e10938106d70bc21319bd6a86ae726da37edc802ce35a3a71ecdf1fdfe7f/numpy-2.4.0-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:02a2038eb27f9443a8b266a66911e926566b5a6ffd1a689b588f7f35b81e7dc3", size = 6641635, upload-time = "2025-12-20T16:16:59.379Z" }, + { url = "https://files.pythonhosted.org/packages/b3/8d/a8828e3eaf5c0b4ab116924df82f24ce3416fa38d0674d8f708ddc6c8aac/numpy-2.4.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1889b3a3f47a7b5bee16bc25a2145bd7cb91897f815ce3499db64c7458b6d91d", size = 14456053, upload-time = "2025-12-20T16:17:01.768Z" }, + { url = "https://files.pythonhosted.org/packages/68/a1/17d97609d87d4520aa5ae2dcfb32305654550ac6a35effb946d303e594ce/numpy-2.4.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:85eef4cb5625c47ee6425c58a3502555e10f45ee973da878ac8248ad58c136f3", size = 16401702, upload-time = "2025-12-20T16:17:04.235Z" }, + { url = "https://files.pythonhosted.org/packages/18/32/0f13c1b2d22bea1118356b8b963195446f3af124ed7a5adfa8fdecb1b6ca/numpy-2.4.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6dc8b7e2f4eb184b37655195f421836cfae6f58197b67e3ffc501f1333d993fa", size = 16242493, upload-time = "2025-12-20T16:17:06.856Z" }, + { url = "https://files.pythonhosted.org/packages/ae/23/48f21e3d309fbc137c068a1475358cbd3a901b3987dcfc97a029ab3068e2/numpy-2.4.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:44aba2f0cafd287871a495fb3163408b0bd25bbce135c6f621534a07f4f7875c", size = 18324222, upload-time = "2025-12-20T16:17:09.392Z" }, + { url = "https://files.pythonhosted.org/packages/ac/52/41f3d71296a3dcaa4f456aaa3c6fc8e745b43d0552b6bde56571bb4b4a0f/numpy-2.4.0-cp313-cp313t-win32.whl", hash = "sha256:20c115517513831860c573996e395707aa9fb691eb179200125c250e895fcd93", size = 6076216, upload-time = "2025-12-20T16:17:11.437Z" }, + { url = "https://files.pythonhosted.org/packages/35/ff/46fbfe60ab0710d2a2b16995f708750307d30eccbb4c38371ea9e986866e/numpy-2.4.0-cp313-cp313t-win_amd64.whl", hash = "sha256:b48e35f4ab6f6a7597c46e301126ceba4c44cd3280e3750f85db48b082624fa4", size = 12444263, upload-time = "2025-12-20T16:17:13.182Z" }, + { url = "https://files.pythonhosted.org/packages/a3/e3/9189ab319c01d2ed556c932ccf55064c5d75bb5850d1df7a482ce0badead/numpy-2.4.0-cp313-cp313t-win_arm64.whl", hash = "sha256:4d1cfce39e511069b11e67cd0bd78ceff31443b7c9e5c04db73c7a19f572967c", size = 10378265, upload-time = "2025-12-20T16:17:15.211Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ed/52eac27de39d5e5a6c9aadabe672bc06f55e24a3d9010cd1183948055d76/numpy-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c95eb6db2884917d86cde0b4d4cf31adf485c8ec36bf8696dd66fa70de96f36b", size = 16647476, upload-time = "2025-12-20T16:17:17.671Z" }, + { url = "https://files.pythonhosted.org/packages/77/c0/990ce1b7fcd4e09aeaa574e2a0a839589e4b08b2ca68070f1acb1fea6736/numpy-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:65167da969cd1ec3a1df31cb221ca3a19a8aaa25370ecb17d428415e93c1935e", size = 12374563, upload-time = "2025-12-20T16:17:20.216Z" }, + { url = "https://files.pythonhosted.org/packages/37/7c/8c5e389c6ae8f5fd2277a988600d79e9625db3fff011a2d87ac80b881a4c/numpy-2.4.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:3de19cfecd1465d0dcf8a5b5ea8b3155b42ed0b639dba4b71e323d74f2a3be5e", size = 5203107, upload-time = "2025-12-20T16:17:22.47Z" }, + { url = "https://files.pythonhosted.org/packages/e6/94/ca5b3bd6a8a70a5eec9a0b8dd7f980c1eff4b8a54970a9a7fef248ef564f/numpy-2.4.0-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:6c05483c3136ac4c91b4e81903cb53a8707d316f488124d0398499a4f8e8ef51", size = 6538067, upload-time = "2025-12-20T16:17:24.001Z" }, + { url = "https://files.pythonhosted.org/packages/79/43/993eb7bb5be6761dde2b3a3a594d689cec83398e3f58f4758010f3b85727/numpy-2.4.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36667db4d6c1cea79c8930ab72fadfb4060feb4bfe724141cd4bd064d2e5f8ce", size = 14411926, upload-time = "2025-12-20T16:17:25.822Z" }, + { url = "https://files.pythonhosted.org/packages/03/75/d4c43b61de473912496317a854dac54f1efec3eeb158438da6884b70bb90/numpy-2.4.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9a818668b674047fd88c4cddada7ab8f1c298812783e8328e956b78dc4807f9f", size = 16354295, upload-time = "2025-12-20T16:17:28.308Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0a/b54615b47ee8736a6461a4bb6749128dd3435c5a759d5663f11f0e9af4ac/numpy-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1ee32359fb7543b7b7bd0b2f46294db27e29e7bbdf70541e81b190836cd83ded", size = 16190242, upload-time = "2025-12-20T16:17:30.993Z" }, + { url = "https://files.pythonhosted.org/packages/98/ce/ea207769aacad6246525ec6c6bbd66a2bf56c72443dc10e2f90feed29290/numpy-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e493962256a38f58283de033d8af176c5c91c084ea30f15834f7545451c42059", size = 18280875, upload-time = "2025-12-20T16:17:33.327Z" }, + { url = "https://files.pythonhosted.org/packages/17/ef/ec409437aa962ea372ed601c519a2b141701683ff028f894b7466f0ab42b/numpy-2.4.0-cp314-cp314-win32.whl", hash = "sha256:6bbaebf0d11567fa8926215ae731e1d58e6ec28a8a25235b8a47405d301332db", size = 6002530, upload-time = "2025-12-20T16:17:35.729Z" }, + { url = "https://files.pythonhosted.org/packages/5f/4a/5cb94c787a3ed1ac65e1271b968686521169a7b3ec0b6544bb3ca32960b0/numpy-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:3d857f55e7fdf7c38ab96c4558c95b97d1c685be6b05c249f5fdafcbd6f9899e", size = 12435890, upload-time = "2025-12-20T16:17:37.599Z" }, + { url = "https://files.pythonhosted.org/packages/48/a0/04b89db963af9de1104975e2544f30de89adbf75b9e75f7dd2599be12c79/numpy-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:bb50ce5fb202a26fd5404620e7ef820ad1ab3558b444cb0b55beb7ef66cd2d63", size = 10591892, upload-time = "2025-12-20T16:17:39.649Z" }, + { url = "https://files.pythonhosted.org/packages/53/e5/d74b5ccf6712c06c7a545025a6a71bfa03bdc7e0568b405b0d655232fd92/numpy-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:355354388cba60f2132df297e2d53053d4063f79077b67b481d21276d61fc4df", size = 12494312, upload-time = "2025-12-20T16:17:41.714Z" }, + { url = "https://files.pythonhosted.org/packages/c2/08/3ca9cc2ddf54dfee7ae9a6479c071092a228c68aef08252aa08dac2af002/numpy-2.4.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:1d8f9fde5f6dc1b6fc34df8162f3b3079365468703fee7f31d4e0cc8c63baed9", size = 5322862, upload-time = "2025-12-20T16:17:44.145Z" }, + { url = "https://files.pythonhosted.org/packages/87/74/0bb63a68394c0c1e52670cfff2e309afa41edbe11b3327d9af29e4383f34/numpy-2.4.0-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:e0434aa22c821f44eeb4c650b81c7fbdd8c0122c6c4b5a576a76d5a35625ecd9", size = 6644986, upload-time = "2025-12-20T16:17:46.203Z" }, + { url = "https://files.pythonhosted.org/packages/06/8f/9264d9bdbcf8236af2823623fe2f3981d740fc3461e2787e231d97c38c28/numpy-2.4.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40483b2f2d3ba7aad426443767ff5632ec3156ef09742b96913787d13c336471", size = 14457958, upload-time = "2025-12-20T16:17:48.017Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d9/f9a69ae564bbc7236a35aa883319364ef5fd41f72aa320cc1cbe66148fe2/numpy-2.4.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d9e6a7664ddd9746e20b7325351fe1a8408d0a2bf9c63b5e898290ddc8f09544", size = 16398394, upload-time = "2025-12-20T16:17:50.409Z" }, + { url = "https://files.pythonhosted.org/packages/34/c7/39241501408dde7f885d241a98caba5421061a2c6d2b2197ac5e3aa842d8/numpy-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ecb0019d44f4cdb50b676c5d0cb4b1eae8e15d1ed3d3e6639f986fc92b2ec52c", size = 16241044, upload-time = "2025-12-20T16:17:52.661Z" }, + { url = "https://files.pythonhosted.org/packages/7c/95/cae7effd90e065a95e59fe710eeee05d7328ed169776dfdd9f789e032125/numpy-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d0ffd9e2e4441c96a9c91ec1783285d80bf835b677853fc2770a89d50c1e48ac", size = 18321772, upload-time = "2025-12-20T16:17:54.947Z" }, + { url = "https://files.pythonhosted.org/packages/96/df/3c6c279accd2bfb968a76298e5b276310bd55d243df4fa8ac5816d79347d/numpy-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:77f0d13fa87036d7553bf81f0e1fe3ce68d14c9976c9851744e4d3e91127e95f", size = 6148320, upload-time = "2025-12-20T16:17:57.249Z" }, + { url = "https://files.pythonhosted.org/packages/92/8d/f23033cce252e7a75cae853d17f582e86534c46404dea1c8ee094a9d6d84/numpy-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b1f5b45829ac1848893f0ddf5cb326110604d6df96cdc255b0bf9edd154104d4", size = 12623460, upload-time = "2025-12-20T16:17:58.963Z" }, + { url = "https://files.pythonhosted.org/packages/a4/4f/1f8475907d1a7c4ef9020edf7f39ea2422ec896849245f00688e4b268a71/numpy-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:23a3e9d1a6f360267e8fbb38ba5db355a6a7e9be71d7fce7ab3125e88bb646c8", size = 10661799, upload-time = "2025-12-20T16:18:01.078Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ef/088e7c7342f300aaf3ee5f2c821c4b9996a1bef2aaf6a49cc8ab4883758e/numpy-2.4.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b54c83f1c0c0f1d748dca0af516062b8829d53d1f0c402be24b4257a9c48ada6", size = 16819003, upload-time = "2025-12-20T16:18:03.41Z" }, + { url = "https://files.pythonhosted.org/packages/ff/ce/a53017b5443b4b84517182d463fc7bcc2adb4faa8b20813f8e5f5aeb5faa/numpy-2.4.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:aabb081ca0ec5d39591fc33018cd4b3f96e1a2dd6756282029986d00a785fba4", size = 12567105, upload-time = "2025-12-20T16:18:05.594Z" }, + { url = "https://files.pythonhosted.org/packages/77/58/5ff91b161f2ec650c88a626c3905d938c89aaadabd0431e6d9c1330c83e2/numpy-2.4.0-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:8eafe7c36c8430b7794edeab3087dec7bf31d634d92f2af9949434b9d1964cba", size = 5395590, upload-time = "2025-12-20T16:18:08.031Z" }, + { url = "https://files.pythonhosted.org/packages/1d/4e/f1a084106df8c2df8132fc437e56987308e0524836aa7733721c8429d4fe/numpy-2.4.0-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:2f585f52b2baf07ff3356158d9268ea095e221371f1074fadea2f42544d58b4d", size = 6709947, upload-time = "2025-12-20T16:18:09.836Z" }, + { url = "https://files.pythonhosted.org/packages/63/09/3d8aeb809c0332c3f642da812ac2e3d74fc9252b3021f8c30c82e99e3f3d/numpy-2.4.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32ed06d0fe9cae27d8fb5f400c63ccee72370599c75e683a6358dd3a4fb50aaf", size = 14535119, upload-time = "2025-12-20T16:18:12.105Z" }, + { url = "https://files.pythonhosted.org/packages/fd/7f/68f0fc43a2cbdc6bb239160c754d87c922f60fbaa0fa3cd3d312b8a7f5ee/numpy-2.4.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:57c540ed8fb1f05cb997c6761cd56db72395b0d6985e90571ff660452ade4f98", size = 16475815, upload-time = "2025-12-20T16:18:14.433Z" }, + { url = "https://files.pythonhosted.org/packages/11/73/edeacba3167b1ca66d51b1a5a14697c2c40098b5ffa01811c67b1785a5ab/numpy-2.4.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a39fb973a726e63223287adc6dafe444ce75af952d711e400f3bf2b36ef55a7b", size = 12489376, upload-time = "2025-12-20T16:18:16.524Z" }, +] + +[[package]] +name = "nvidia-cublas-cu12" +version = "12.8.4.1" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/61/e24b560ab2e2eaeb3c839129175fb330dfcfc29e5203196e5541a4c44682/nvidia_cublas_cu12-12.8.4.1-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:8ac4e771d5a348c551b2a426eda6193c19aa630236b418086020df5ba9667142", size = 594346921, upload-time = "2025-03-07T01:44:31.254Z" }, +] + +[[package]] +name = "nvidia-cuda-cupti-cu12" +version = "12.8.90" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/02/2adcaa145158bf1a8295d83591d22e4103dbfd821bcaf6f3f53151ca4ffa/nvidia_cuda_cupti_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ea0cb07ebda26bb9b29ba82cda34849e73c166c18162d3913575b0c9db9a6182", size = 10248621, upload-time = "2025-03-07T01:40:21.213Z" }, +] + +[[package]] +name = "nvidia-cuda-nvrtc-cu12" +version = "12.8.93" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/6b/32f747947df2da6994e999492ab306a903659555dddc0fbdeb9d71f75e52/nvidia_cuda_nvrtc_cu12-12.8.93-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:a7756528852ef889772a84c6cd89d41dfa74667e24cca16bb31f8f061e3e9994", size = 88040029, upload-time = "2025-03-07T01:42:13.562Z" }, +] + +[[package]] +name = "nvidia-cuda-runtime-cu12" +version = "12.8.90" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/9b/a997b638fcd068ad6e4d53b8551a7d30fe8b404d6f1804abf1df69838932/nvidia_cuda_runtime_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:adade8dcbd0edf427b7204d480d6066d33902cab2a4707dcfc48a2d0fd44ab90", size = 954765, upload-time = "2025-03-07T01:40:01.615Z" }, +] + +[[package]] +name = "nvidia-cudnn-cu12" +version = "9.10.2.21" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-cublas-cu12" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/51/e123d997aa098c61d029f76663dedbfb9bc8dcf8c60cbd6adbe42f76d049/nvidia_cudnn_cu12-9.10.2.21-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:949452be657fa16687d0930933f032835951ef0892b37d2d53824d1a84dc97a8", size = 706758467, upload-time = "2025-06-06T21:54:08.597Z" }, +] + +[[package]] +name = "nvidia-cufft-cu12" +version = "11.3.3.83" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-nvjitlink-cu12" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/13/ee4e00f30e676b66ae65b4f08cb5bcbb8392c03f54f2d5413ea99a5d1c80/nvidia_cufft_cu12-11.3.3.83-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d2dd21ec0b88cf61b62e6b43564355e5222e4a3fb394cac0db101f2dd0d4f74", size = 193118695, upload-time = "2025-03-07T01:45:27.821Z" }, +] + +[[package]] +name = "nvidia-cufile-cu12" +version = "1.13.1.3" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bb/fe/1bcba1dfbfb8d01be8d93f07bfc502c93fa23afa6fd5ab3fc7c1df71038a/nvidia_cufile_cu12-1.13.1.3-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1d069003be650e131b21c932ec3d8969c1715379251f8d23a1860554b1cb24fc", size = 1197834, upload-time = "2025-03-07T01:45:50.723Z" }, +] + +[[package]] +name = "nvidia-curand-cu12" +version = "10.3.9.90" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/aa/6584b56dc84ebe9cf93226a5cde4d99080c8e90ab40f0c27bda7a0f29aa1/nvidia_curand_cu12-10.3.9.90-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:b32331d4f4df5d6eefa0554c565b626c7216f87a06a4f56fab27c3b68a830ec9", size = 63619976, upload-time = "2025-03-07T01:46:23.323Z" }, +] + +[[package]] +name = "nvidia-cusolver-cu12" +version = "11.7.3.90" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-cublas-cu12" }, + { name = "nvidia-cusparse-cu12" }, + { name = "nvidia-nvjitlink-cu12" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/48/9a13d2975803e8cf2777d5ed57b87a0b6ca2cc795f9a4f59796a910bfb80/nvidia_cusolver_cu12-11.7.3.90-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:4376c11ad263152bd50ea295c05370360776f8c3427b30991df774f9fb26c450", size = 267506905, upload-time = "2025-03-07T01:47:16.273Z" }, +] + +[[package]] +name = "nvidia-cusparse-cu12" +version = "12.5.8.93" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-nvjitlink-cu12" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/f5/e1854cb2f2bcd4280c44736c93550cc300ff4b8c95ebe370d0aa7d2b473d/nvidia_cusparse_cu12-12.5.8.93-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1ec05d76bbbd8b61b06a80e1eaf8cf4959c3d4ce8e711b65ebd0443bb0ebb13b", size = 288216466, upload-time = "2025-03-07T01:48:13.779Z" }, +] + +[[package]] +name = "nvidia-cusparselt-cu12" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/79/12978b96bd44274fe38b5dde5cfb660b1d114f70a65ef962bcbbed99b549/nvidia_cusparselt_cu12-0.7.1-py3-none-manylinux2014_x86_64.whl", hash = "sha256:f1bb701d6b930d5a7cea44c19ceb973311500847f81b634d802b7b539dc55623", size = 287193691, upload-time = "2025-02-26T00:15:44.104Z" }, +] + +[[package]] +name = "nvidia-nccl-cu12" +version = "2.27.5" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6e/89/f7a07dc961b60645dbbf42e80f2bc85ade7feb9a491b11a1e973aa00071f/nvidia_nccl_cu12-2.27.5-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ad730cf15cb5d25fe849c6e6ca9eb5b76db16a80f13f425ac68d8e2e55624457", size = 322348229, upload-time = "2025-06-26T04:11:28.385Z" }, +] + +[[package]] +name = "nvidia-nvjitlink-cu12" +version = "12.8.93" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f6/74/86a07f1d0f42998ca31312f998bd3b9a7eff7f52378f4f270c8679c77fb9/nvidia_nvjitlink_cu12-12.8.93-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:81ff63371a7ebd6e6451970684f916be2eab07321b73c9d244dc2b4da7f73b88", size = 39254836, upload-time = "2025-03-07T01:49:55.661Z" }, +] + +[[package]] +name = "nvidia-nvshmem-cu12" +version = "3.3.20" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/6c/99acb2f9eb85c29fc6f3a7ac4dccfd992e22666dd08a642b303311326a97/nvidia_nvshmem_cu12-3.3.20-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d00f26d3f9b2e3c3065be895e3059d6479ea5c638a3f38c9fec49b1b9dd7c1e5", size = 124657145, upload-time = "2025-08-04T20:25:19.995Z" }, +] + +[[package]] +name = "nvidia-nvtx-cu12" +version = "12.8.90" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/eb/86626c1bbc2edb86323022371c39aa48df6fd8b0a1647bc274577f72e90b/nvidia_nvtx_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5b17e2001cc0d751a5bc2c6ec6d26ad95913324a4adb86788c944f8ce9ba441f", size = 89954, upload-time = "2025-03-07T01:42:44.131Z" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "rich" +version = "14.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fb/d2/8920e102050a0de7bfabeb4c4614a49248cf8d5d7a8d01885fbb24dc767a/rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4", size = 219990, upload-time = "2025-10-09T14:16:53.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393, upload-time = "2025-10-09T14:16:51.245Z" }, +] + +[[package]] +name = "setuptools" +version = "80.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/5d/3bf57dcd21979b887f014ea83c24ae194cfcd12b9e0fda66b957c69d1fca/setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c", size = 1319958, upload-time = "2025-05-27T00:56:51.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922", size = 1201486, upload-time = "2025-05-27T00:56:49.664Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "sympy" +version = "1.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mpmath" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" }, +] + +[[package]] +name = "tomli" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/ed/3f73f72945444548f33eba9a87fc7a6e969915e7b1acc8260b30e1f76a2f/tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549", size = 17392, upload-time = "2025-10-08T22:01:47.119Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/2e/299f62b401438d5fe1624119c723f5d877acc86a4c2492da405626665f12/tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45", size = 153236, upload-time = "2025-10-08T22:01:00.137Z" }, + { url = "https://files.pythonhosted.org/packages/86/7f/d8fffe6a7aefdb61bced88fcb5e280cfd71e08939da5894161bd71bea022/tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba", size = 148084, upload-time = "2025-10-08T22:01:01.63Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/24935fb6a2ee63e86d80e4d3b58b222dafaf438c416752c8b58537c8b89a/tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf", size = 234832, upload-time = "2025-10-08T22:01:02.543Z" }, + { url = "https://files.pythonhosted.org/packages/89/da/75dfd804fc11e6612846758a23f13271b76d577e299592b4371a4ca4cd09/tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441", size = 242052, upload-time = "2025-10-08T22:01:03.836Z" }, + { url = "https://files.pythonhosted.org/packages/70/8c/f48ac899f7b3ca7eb13af73bacbc93aec37f9c954df3c08ad96991c8c373/tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845", size = 239555, upload-time = "2025-10-08T22:01:04.834Z" }, + { url = "https://files.pythonhosted.org/packages/ba/28/72f8afd73f1d0e7829bfc093f4cb98ce0a40ffc0cc997009ee1ed94ba705/tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c", size = 245128, upload-time = "2025-10-08T22:01:05.84Z" }, + { url = "https://files.pythonhosted.org/packages/b6/eb/a7679c8ac85208706d27436e8d421dfa39d4c914dcf5fa8083a9305f58d9/tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456", size = 96445, upload-time = "2025-10-08T22:01:06.896Z" }, + { url = "https://files.pythonhosted.org/packages/0a/fe/3d3420c4cb1ad9cb462fb52967080575f15898da97e21cb6f1361d505383/tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be", size = 107165, upload-time = "2025-10-08T22:01:08.107Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b7/40f36368fcabc518bb11c8f06379a0fd631985046c038aca08c6d6a43c6e/tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac", size = 154891, upload-time = "2025-10-08T22:01:09.082Z" }, + { url = "https://files.pythonhosted.org/packages/f9/3f/d9dd692199e3b3aab2e4e4dd948abd0f790d9ded8cd10cbaae276a898434/tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22", size = 148796, upload-time = "2025-10-08T22:01:10.266Z" }, + { url = "https://files.pythonhosted.org/packages/60/83/59bff4996c2cf9f9387a0f5a3394629c7efa5ef16142076a23a90f1955fa/tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f", size = 242121, upload-time = "2025-10-08T22:01:11.332Z" }, + { url = "https://files.pythonhosted.org/packages/45/e5/7c5119ff39de8693d6baab6c0b6dcb556d192c165596e9fc231ea1052041/tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52", size = 250070, upload-time = "2025-10-08T22:01:12.498Z" }, + { url = "https://files.pythonhosted.org/packages/45/12/ad5126d3a278f27e6701abde51d342aa78d06e27ce2bb596a01f7709a5a2/tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8", size = 245859, upload-time = "2025-10-08T22:01:13.551Z" }, + { url = "https://files.pythonhosted.org/packages/fb/a1/4d6865da6a71c603cfe6ad0e6556c73c76548557a8d658f9e3b142df245f/tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6", size = 250296, upload-time = "2025-10-08T22:01:14.614Z" }, + { url = "https://files.pythonhosted.org/packages/a0/b7/a7a7042715d55c9ba6e8b196d65d2cb662578b4d8cd17d882d45322b0d78/tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876", size = 97124, upload-time = "2025-10-08T22:01:15.629Z" }, + { url = "https://files.pythonhosted.org/packages/06/1e/f22f100db15a68b520664eb3328fb0ae4e90530887928558112c8d1f4515/tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878", size = 107698, upload-time = "2025-10-08T22:01:16.51Z" }, + { url = "https://files.pythonhosted.org/packages/89/48/06ee6eabe4fdd9ecd48bf488f4ac783844fd777f547b8d1b61c11939974e/tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b", size = 154819, upload-time = "2025-10-08T22:01:17.964Z" }, + { url = "https://files.pythonhosted.org/packages/f1/01/88793757d54d8937015c75dcdfb673c65471945f6be98e6a0410fba167ed/tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae", size = 148766, upload-time = "2025-10-08T22:01:18.959Z" }, + { url = "https://files.pythonhosted.org/packages/42/17/5e2c956f0144b812e7e107f94f1cc54af734eb17b5191c0bbfb72de5e93e/tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b", size = 240771, upload-time = "2025-10-08T22:01:20.106Z" }, + { url = "https://files.pythonhosted.org/packages/d5/f4/0fbd014909748706c01d16824eadb0307115f9562a15cbb012cd9b3512c5/tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf", size = 248586, upload-time = "2025-10-08T22:01:21.164Z" }, + { url = "https://files.pythonhosted.org/packages/30/77/fed85e114bde5e81ecf9bc5da0cc69f2914b38f4708c80ae67d0c10180c5/tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f", size = 244792, upload-time = "2025-10-08T22:01:22.417Z" }, + { url = "https://files.pythonhosted.org/packages/55/92/afed3d497f7c186dc71e6ee6d4fcb0acfa5f7d0a1a2878f8beae379ae0cc/tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05", size = 248909, upload-time = "2025-10-08T22:01:23.859Z" }, + { url = "https://files.pythonhosted.org/packages/f8/84/ef50c51b5a9472e7265ce1ffc7f24cd4023d289e109f669bdb1553f6a7c2/tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606", size = 96946, upload-time = "2025-10-08T22:01:24.893Z" }, + { url = "https://files.pythonhosted.org/packages/b2/b7/718cd1da0884f281f95ccfa3a6cc572d30053cba64603f79d431d3c9b61b/tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999", size = 107705, upload-time = "2025-10-08T22:01:26.153Z" }, + { url = "https://files.pythonhosted.org/packages/19/94/aeafa14a52e16163008060506fcb6aa1949d13548d13752171a755c65611/tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e", size = 154244, upload-time = "2025-10-08T22:01:27.06Z" }, + { url = "https://files.pythonhosted.org/packages/db/e4/1e58409aa78eefa47ccd19779fc6f36787edbe7d4cd330eeeedb33a4515b/tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3", size = 148637, upload-time = "2025-10-08T22:01:28.059Z" }, + { url = "https://files.pythonhosted.org/packages/26/b6/d1eccb62f665e44359226811064596dd6a366ea1f985839c566cd61525ae/tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc", size = 241925, upload-time = "2025-10-08T22:01:29.066Z" }, + { url = "https://files.pythonhosted.org/packages/70/91/7cdab9a03e6d3d2bb11beae108da5bdc1c34bdeb06e21163482544ddcc90/tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0", size = 249045, upload-time = "2025-10-08T22:01:31.98Z" }, + { url = "https://files.pythonhosted.org/packages/15/1b/8c26874ed1f6e4f1fcfeb868db8a794cbe9f227299402db58cfcc858766c/tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879", size = 245835, upload-time = "2025-10-08T22:01:32.989Z" }, + { url = "https://files.pythonhosted.org/packages/fd/42/8e3c6a9a4b1a1360c1a2a39f0b972cef2cc9ebd56025168c4137192a9321/tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005", size = 253109, upload-time = "2025-10-08T22:01:34.052Z" }, + { url = "https://files.pythonhosted.org/packages/22/0c/b4da635000a71b5f80130937eeac12e686eefb376b8dee113b4a582bba42/tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463", size = 97930, upload-time = "2025-10-08T22:01:35.082Z" }, + { url = "https://files.pythonhosted.org/packages/b9/74/cb1abc870a418ae99cd5c9547d6bce30701a954e0e721821df483ef7223c/tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8", size = 107964, upload-time = "2025-10-08T22:01:36.057Z" }, + { url = "https://files.pythonhosted.org/packages/54/78/5c46fff6432a712af9f792944f4fcd7067d8823157949f4e40c56b8b3c83/tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77", size = 163065, upload-time = "2025-10-08T22:01:37.27Z" }, + { url = "https://files.pythonhosted.org/packages/39/67/f85d9bd23182f45eca8939cd2bc7050e1f90c41f4a2ecbbd5963a1d1c486/tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf", size = 159088, upload-time = "2025-10-08T22:01:38.235Z" }, + { url = "https://files.pythonhosted.org/packages/26/5a/4b546a0405b9cc0659b399f12b6adb750757baf04250b148d3c5059fc4eb/tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530", size = 268193, upload-time = "2025-10-08T22:01:39.712Z" }, + { url = "https://files.pythonhosted.org/packages/42/4f/2c12a72ae22cf7b59a7fe75b3465b7aba40ea9145d026ba41cb382075b0e/tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b", size = 275488, upload-time = "2025-10-08T22:01:40.773Z" }, + { url = "https://files.pythonhosted.org/packages/92/04/a038d65dbe160c3aa5a624e93ad98111090f6804027d474ba9c37c8ae186/tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67", size = 272669, upload-time = "2025-10-08T22:01:41.824Z" }, + { url = "https://files.pythonhosted.org/packages/be/2f/8b7c60a9d1612a7cbc39ffcca4f21a73bf368a80fc25bccf8253e2563267/tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f", size = 279709, upload-time = "2025-10-08T22:01:43.177Z" }, + { url = "https://files.pythonhosted.org/packages/7e/46/cc36c679f09f27ded940281c38607716c86cf8ba4a518d524e349c8b4874/tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0", size = 107563, upload-time = "2025-10-08T22:01:44.233Z" }, + { url = "https://files.pythonhosted.org/packages/84/ff/426ca8683cf7b753614480484f6437f568fd2fda2edbdf57a2d3d8b27a0b/tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba", size = 119756, upload-time = "2025-10-08T22:01:45.234Z" }, + { url = "https://files.pythonhosted.org/packages/77/b8/0135fadc89e73be292b473cb820b4f5a08197779206b33191e801feeae40/tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b", size = 14408, upload-time = "2025-10-08T22:01:46.04Z" }, +] + +[[package]] +name = "torch" +version = "2.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "fsspec" }, + { name = "jinja2" }, + { name = "networkx", version = "3.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "networkx", version = "3.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "nvidia-cublas-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cuda-cupti-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cuda-nvrtc-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cuda-runtime-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cudnn-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cufft-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cufile-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-curand-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cusolver-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cusparse-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cusparselt-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-nccl-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-nvjitlink-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-nvshmem-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-nvtx-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "setuptools", marker = "python_full_version >= '3.12'" }, + { name = "sympy" }, + { name = "triton", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "typing-extensions" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/56/9577683b23072075ed2e40d725c52c2019d71a972fab8e083763da8e707e/torch-2.9.1-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:1cc208435f6c379f9b8fdfd5ceb5be1e3b72a6bdf1cb46c0d2812aa73472db9e", size = 104207681, upload-time = "2025-11-12T15:19:56.48Z" }, + { url = "https://files.pythonhosted.org/packages/38/45/be5a74f221df8f4b609b78ff79dc789b0cc9017624544ac4dd1c03973150/torch-2.9.1-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:9fd35c68b3679378c11f5eb73220fdcb4e6f4592295277fbb657d31fd053237c", size = 899794036, upload-time = "2025-11-12T15:21:01.886Z" }, + { url = "https://files.pythonhosted.org/packages/67/95/a581e8a382596b69385a44bab2733f1273d45c842f5d4a504c0edc3133b6/torch-2.9.1-cp310-cp310-win_amd64.whl", hash = "sha256:2af70e3be4a13becba4655d6cc07dcfec7ae844db6ac38d6c1dafeb245d17d65", size = 110969861, upload-time = "2025-11-12T15:21:30.145Z" }, + { url = "https://files.pythonhosted.org/packages/ad/51/1756dc128d2bf6ea4e0a915cb89ea5e730315ff33d60c1ff56fd626ba3eb/torch-2.9.1-cp310-none-macosx_11_0_arm64.whl", hash = "sha256:a83b0e84cc375e3318a808d032510dde99d696a85fe9473fc8575612b63ae951", size = 74452222, upload-time = "2025-11-12T15:20:46.223Z" }, + { url = "https://files.pythonhosted.org/packages/15/db/c064112ac0089af3d2f7a2b5bfbabf4aa407a78b74f87889e524b91c5402/torch-2.9.1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:62b3fd888277946918cba4478cf849303da5359f0fb4e3bfb86b0533ba2eaf8d", size = 104220430, upload-time = "2025-11-12T15:20:31.705Z" }, + { url = "https://files.pythonhosted.org/packages/56/be/76eaa36c9cd032d3b01b001e2c5a05943df75f26211f68fae79e62f87734/torch-2.9.1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:d033ff0ac3f5400df862a51bdde9bad83561f3739ea0046e68f5401ebfa67c1b", size = 899821446, upload-time = "2025-11-12T15:20:15.544Z" }, + { url = "https://files.pythonhosted.org/packages/47/cc/7a2949e38dfe3244c4df21f0e1c27bce8aedd6c604a587dd44fc21017cb4/torch-2.9.1-cp311-cp311-win_amd64.whl", hash = "sha256:0d06b30a9207b7c3516a9e0102114024755a07045f0c1d2f2a56b1819ac06bcb", size = 110973074, upload-time = "2025-11-12T15:21:39.958Z" }, + { url = "https://files.pythonhosted.org/packages/1e/ce/7d251155a783fb2c1bb6837b2b7023c622a2070a0a72726ca1df47e7ea34/torch-2.9.1-cp311-none-macosx_11_0_arm64.whl", hash = "sha256:52347912d868653e1528b47cafaf79b285b98be3f4f35d5955389b1b95224475", size = 74463887, upload-time = "2025-11-12T15:20:36.611Z" }, + { url = "https://files.pythonhosted.org/packages/0f/27/07c645c7673e73e53ded71705045d6cb5bae94c4b021b03aa8d03eee90ab/torch-2.9.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:da5f6f4d7f4940a173e5572791af238cb0b9e21b1aab592bd8b26da4c99f1cd6", size = 104126592, upload-time = "2025-11-12T15:20:41.62Z" }, + { url = "https://files.pythonhosted.org/packages/19/17/e377a460603132b00760511299fceba4102bd95db1a0ee788da21298ccff/torch-2.9.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:27331cd902fb4322252657f3902adf1c4f6acad9dcad81d8df3ae14c7c4f07c4", size = 899742281, upload-time = "2025-11-12T15:22:17.602Z" }, + { url = "https://files.pythonhosted.org/packages/b1/1a/64f5769025db846a82567fa5b7d21dba4558a7234ee631712ee4771c436c/torch-2.9.1-cp312-cp312-win_amd64.whl", hash = "sha256:81a285002d7b8cfd3fdf1b98aa8df138d41f1a8334fd9ea37511517cedf43083", size = 110940568, upload-time = "2025-11-12T15:21:18.689Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ab/07739fd776618e5882661d04c43f5b5586323e2f6a2d7d84aac20d8f20bd/torch-2.9.1-cp312-none-macosx_11_0_arm64.whl", hash = "sha256:c0d25d1d8e531b8343bea0ed811d5d528958f1dcbd37e7245bc686273177ad7e", size = 74479191, upload-time = "2025-11-12T15:21:25.816Z" }, + { url = "https://files.pythonhosted.org/packages/20/60/8fc5e828d050bddfab469b3fe78e5ab9a7e53dda9c3bdc6a43d17ce99e63/torch-2.9.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:c29455d2b910b98738131990394da3e50eea8291dfeb4b12de71ecf1fdeb21cb", size = 104135743, upload-time = "2025-11-12T15:21:34.936Z" }, + { url = "https://files.pythonhosted.org/packages/f2/b7/6d3f80e6918213babddb2a37b46dbb14c15b14c5f473e347869a51f40e1f/torch-2.9.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:524de44cd13931208ba2c4bde9ec7741fd4ae6bfd06409a604fc32f6520c2bc9", size = 899749493, upload-time = "2025-11-12T15:24:36.356Z" }, + { url = "https://files.pythonhosted.org/packages/a6/47/c7843d69d6de8938c1cbb1eba426b1d48ddf375f101473d3e31a5fc52b74/torch-2.9.1-cp313-cp313-win_amd64.whl", hash = "sha256:545844cc16b3f91e08ce3b40e9c2d77012dd33a48d505aed34b7740ed627a1b2", size = 110944162, upload-time = "2025-11-12T15:21:53.151Z" }, + { url = "https://files.pythonhosted.org/packages/28/0e/2a37247957e72c12151b33a01e4df651d9d155dd74d8cfcbfad15a79b44a/torch-2.9.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5be4bf7496f1e3ffb1dd44b672adb1ac3f081f204c5ca81eba6442f5f634df8e", size = 74830751, upload-time = "2025-11-12T15:21:43.792Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f7/7a18745edcd7b9ca2381aa03353647bca8aace91683c4975f19ac233809d/torch-2.9.1-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:30a3e170a84894f3652434b56d59a64a2c11366b0ed5776fab33c2439396bf9a", size = 104142929, upload-time = "2025-11-12T15:21:48.319Z" }, + { url = "https://files.pythonhosted.org/packages/f4/dd/f1c0d879f2863ef209e18823a988dc7a1bf40470750e3ebe927efdb9407f/torch-2.9.1-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:8301a7b431e51764629208d0edaa4f9e4c33e6df0f2f90b90e261d623df6a4e2", size = 899748978, upload-time = "2025-11-12T15:23:04.568Z" }, + { url = "https://files.pythonhosted.org/packages/1f/9f/6986b83a53b4d043e36f3f898b798ab51f7f20fdf1a9b01a2720f445043d/torch-2.9.1-cp313-cp313t-win_amd64.whl", hash = "sha256:2e1c42c0ae92bf803a4b2409fdfed85e30f9027a66887f5e7dcdbc014c7531db", size = 111176995, upload-time = "2025-11-12T15:22:01.618Z" }, + { url = "https://files.pythonhosted.org/packages/40/60/71c698b466dd01e65d0e9514b5405faae200c52a76901baf6906856f17e4/torch-2.9.1-cp313-none-macosx_11_0_arm64.whl", hash = "sha256:2c14b3da5df416cf9cb5efab83aa3056f5b8cd8620b8fde81b4987ecab730587", size = 74480347, upload-time = "2025-11-12T15:21:57.648Z" }, + { url = "https://files.pythonhosted.org/packages/48/50/c4b5112546d0d13cc9eaa1c732b823d676a9f49ae8b6f97772f795874a03/torch-2.9.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1edee27a7c9897f4e0b7c14cfc2f3008c571921134522d5b9b5ec4ebbc69041a", size = 74433245, upload-time = "2025-11-12T15:22:39.027Z" }, + { url = "https://files.pythonhosted.org/packages/81/c9/2628f408f0518b3bae49c95f5af3728b6ab498c8624ab1e03a43dd53d650/torch-2.9.1-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:19d144d6b3e29921f1fc70503e9f2fc572cde6a5115c0c0de2f7ca8b1483e8b6", size = 104134804, upload-time = "2025-11-12T15:22:35.222Z" }, + { url = "https://files.pythonhosted.org/packages/28/fc/5bc91d6d831ae41bf6e9e6da6468f25330522e92347c9156eb3f1cb95956/torch-2.9.1-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:c432d04376f6d9767a9852ea0def7b47a7bbc8e7af3b16ac9cf9ce02b12851c9", size = 899747132, upload-time = "2025-11-12T15:23:36.068Z" }, + { url = "https://files.pythonhosted.org/packages/63/5d/e8d4e009e52b6b2cf1684bde2a6be157b96fb873732542fb2a9a99e85a83/torch-2.9.1-cp314-cp314-win_amd64.whl", hash = "sha256:d187566a2cdc726fc80138c3cdb260970fab1c27e99f85452721f7759bbd554d", size = 110934845, upload-time = "2025-11-12T15:22:48.367Z" }, + { url = "https://files.pythonhosted.org/packages/bd/b2/2d15a52516b2ea3f414643b8de68fa4cb220d3877ac8b1028c83dc8ca1c4/torch-2.9.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cb10896a1f7fedaddbccc2017ce6ca9ecaaf990f0973bdfcf405439750118d2c", size = 74823558, upload-time = "2025-11-12T15:22:43.392Z" }, + { url = "https://files.pythonhosted.org/packages/86/5c/5b2e5d84f5b9850cd1e71af07524d8cbb74cba19379800f1f9f7c997fc70/torch-2.9.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:0a2bd769944991c74acf0c4ef23603b9c777fdf7637f115605a4b2d8023110c7", size = 104145788, upload-time = "2025-11-12T15:23:52.109Z" }, + { url = "https://files.pythonhosted.org/packages/a9/8c/3da60787bcf70add986c4ad485993026ac0ca74f2fc21410bc4eb1bb7695/torch-2.9.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:07c8a9660bc9414c39cac530ac83b1fb1b679d7155824144a40a54f4a47bfa73", size = 899735500, upload-time = "2025-11-12T15:24:08.788Z" }, + { url = "https://files.pythonhosted.org/packages/db/2b/f7818f6ec88758dfd21da46b6cd46af9d1b3433e53ddbb19ad1e0da17f9b/torch-2.9.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c88d3299ddeb2b35dcc31753305612db485ab6f1823e37fb29451c8b2732b87e", size = 111163659, upload-time = "2025-11-12T15:23:20.009Z" }, +] + +[[package]] +name = "triton" +version = "3.5.1" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/6e/676ab5019b4dde8b9b7bab71245102fc02778ef3df48218b298686b9ffd6/triton-3.5.1-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5fc53d849f879911ea13f4a877243afc513187bc7ee92d1f2c0f1ba3169e3c94", size = 170320692, upload-time = "2025-11-11T17:40:46.074Z" }, + { url = "https://files.pythonhosted.org/packages/b0/72/ec90c3519eaf168f22cb1757ad412f3a2add4782ad3a92861c9ad135d886/triton-3.5.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:61413522a48add32302353fdbaaf92daaaab06f6b5e3229940d21b5207f47579", size = 170425802, upload-time = "2025-11-11T17:40:53.209Z" }, + { url = "https://files.pythonhosted.org/packages/f2/50/9a8358d3ef58162c0a415d173cfb45b67de60176e1024f71fbc4d24c0b6d/triton-3.5.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d2c6b915a03888ab931a9fd3e55ba36785e1fe70cbea0b40c6ef93b20fc85232", size = 170470207, upload-time = "2025-11-11T17:41:00.253Z" }, + { url = "https://files.pythonhosted.org/packages/27/46/8c3bbb5b0a19313f50edcaa363b599e5a1a5ac9683ead82b9b80fe497c8d/triton-3.5.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3f4346b6ebbd4fad18773f5ba839114f4826037c9f2f34e0148894cd5dd3dba", size = 170470410, upload-time = "2025-11-11T17:41:06.319Z" }, + { url = "https://files.pythonhosted.org/packages/37/92/e97fcc6b2c27cdb87ce5ee063d77f8f26f19f06916aa680464c8104ef0f6/triton-3.5.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0b4d2c70127fca6a23e247f9348b8adde979d2e7a20391bfbabaac6aebc7e6a8", size = 170579924, upload-time = "2025-11-11T17:41:12.455Z" }, + { url = "https://files.pythonhosted.org/packages/a4/e6/c595c35e5c50c4bc56a7bac96493dad321e9e29b953b526bbbe20f9911d0/triton-3.5.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0637b1efb1db599a8e9dc960d53ab6e4637db7d4ab6630a0974705d77b14b60", size = 170480488, upload-time = "2025-11-11T17:41:18.222Z" }, + { url = "https://files.pythonhosted.org/packages/16/b5/b0d3d8b901b6a04ca38df5e24c27e53afb15b93624d7fd7d658c7cd9352a/triton-3.5.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bac7f7d959ad0f48c0e97d6643a1cc0fd5786fe61cb1f83b537c6b2d54776478", size = 170582192, upload-time = "2025-11-11T17:41:23.963Z" }, +] + +[[package]] +name = "typer" +version = "0.20.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6d/c1/933d30fd7a123ed981e2a1eedafceab63cb379db0402e438a13bc51bbb15/typer-0.20.1.tar.gz", hash = "sha256:68585eb1b01203689c4199bc440d6be616f0851e9f0eb41e4a778845c5a0fd5b", size = 105968, upload-time = "2025-12-19T16:48:56.302Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/52/1f2df7e7d1be3d65ddc2936d820d4a3d9777a54f4204f5ca46b8513eff77/typer-0.20.1-py3-none-any.whl", hash = "sha256:4b3bde918a67c8e03d861aa02deca90a95bbac572e71b1b9be56ff49affdb5a8", size = 47381, upload-time = "2025-12-19T16:48:53.679Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..72085bc --- /dev/null +++ b/web/index.html @@ -0,0 +1,15 @@ + + + + + + Catan Arena + + + + + +
+ + + diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..8afa4d4 --- /dev/null +++ b/web/package.json @@ -0,0 +1,20 @@ +{ + "name": "catan-web", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.23.1" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.1", + "vite": "^5.4.10" + } +} diff --git a/web/src/App.jsx b/web/src/App.jsx new file mode 100644 index 0000000..3dbfe28 --- /dev/null +++ b/web/src/App.jsx @@ -0,0 +1,807 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { Routes, Route, Link, useNavigate, useParams } from 'react-router-dom'; +import { apiFetch, clearToken, getToken, getUsername, setToken, wsUrl } from './api.js'; + +const resourceColors = { + brick: '#c46a44', + lumber: '#4a6d4a', + wool: '#8cc071', + grain: '#e2c065', + ore: '#7a7c83', + desert: '#d8c18f', +}; + +function downloadJson(data, filename) { + const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + link.click(); + URL.revokeObjectURL(url); +} + +function Header({ user }) { + const navigate = useNavigate(); + return ( +
+
CATAN ARENA
+
+ {user && ( + <> + Lobby + Analytics + Replays + + )} + {user || 'Guest'} + {user && ( + + )} +
+
+ ); +} + +function LoginPage({ onAuth }) { + const [login, setLogin] = useState({ username: '', password: '' }); + const [register, setRegister] = useState({ username: '', password: '' }); + const navigate = useNavigate(); + + async function submitLogin() { + const data = await apiFetch('/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(login), + }); + setToken(data.token, data.username); + onAuth(data.username); + navigate('/'); + } + + async function submitRegister() { + const data = await apiFetch('/api/auth/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(register), + }); + setToken(data.token, data.username); + onAuth(data.username); + navigate('/'); + } + + return ( +
+
+
+
Login
+
+ setLogin({ ...login, username: e.target.value })} + /> + setLogin({ ...login, password: e.target.value })} + /> + +
+
+
+
Create Account
+
+ setRegister({ ...register, username: e.target.value })} + /> + setRegister({ ...register, password: e.target.value })} + /> + +
+
+
+
+ ); +} + +function LobbyPage() { + const [games, setGames] = useState([]); + const [models, setModels] = useState([]); + const [stats, setStats] = useState(null); + const [create, setCreate] = useState({ name: '', max_players: 4 }); + const navigate = useNavigate(); + + async function load() { + const [lobby, modelList, statsData] = await Promise.all([ + apiFetch('/api/lobby'), + apiFetch('/api/models'), + apiFetch('/api/stats'), + ]); + setGames(lobby.games || []); + setModels(modelList.models || []); + setStats(statsData); + } + + useEffect(() => { + load().catch(console.error); + }, []); + + async function createGame() { + await apiFetch('/api/games', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(create), + }); + await load(); + } + + async function joinGame(id) { + await apiFetch(`/api/games/${id}/join`, { method: 'POST' }); + navigate(`/game/${id}`); + } + + async function watchGame(id) { + navigate(`/game/${id}`); + } + + async function addAi(id) { + const type = window.prompt('AI type: random or model?', 'random'); + if (!type) return; + const payload = { ai_type: type }; + if (type === 'model') { + payload.model_name = window.prompt(`Model name: ${models.join(', ')}`); + } + await apiFetch(`/api/games/${id}/add-ai`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + await load(); + } + + async function startGame(id) { + await apiFetch(`/api/games/${id}/start`, { method: 'POST' }); + navigate(`/game/${id}`); + } + + return ( +
+
+
+
Lobby
+
+ {games.length === 0 &&
No active games.
} + {games.map((game) => ( +
+
+ {game.name} + {game.status} +
+
+ {game.players.map((slot) => ( + + {slot.name || 'Open'} + + ))} +
+
+ + + + +
+
+ ))} +
+
+
+
+
Create Game
+
+ setCreate({ ...create, name: e.target.value })} + /> + + +
+
+
+
Your Stats
+ {stats && ( +
+
Total games: {stats.total_games}
+
Finished games: {stats.finished_games}
+
Your games: {stats.user_games}
+
Your wins: {stats.user_wins}
+
Avg turns: {Number(stats.avg_turns || 0).toFixed(1)}
+
+ )} +
+
+
AI Models
+
+ {models.map((model) => ( + {model} + ))} + {models.length === 0 &&
No models found.
} +
+
+
+
+
+ ); +} + +function cubeToPixel(coord, scale) { + const [x, , z] = coord; + const sqrt3 = Math.sqrt(3); + return { + x: scale * (sqrt3 * x + (sqrt3 / 2) * z), + y: scale * (1.5 * z), + }; +} + +function hexCorners(cx, cy, size) { + const points = []; + for (let i = 0; i < 6; i += 1) { + const angle = (Math.PI / 180) * (60 * i - 30); + points.push([cx + size * Math.cos(angle), cy + size * Math.sin(angle)]); + } + return points; +} + +function Board({ board, game }) { + if (!board || !game) return null; + const size = 48; + const cornerScale = size / 3; + const hexes = Object.values(board.hexes || {}); + const corners = board.corners || {}; + const edges = Object.values(board.edges || {}); + + const hexPositions = hexes.map((hex) => ({ ...hex, pos: cubeToPixel(hex.coord, size) })); + const cornerPositions = {}; + Object.entries(corners).forEach(([id, corner]) => { + cornerPositions[id] = { ...corner, pos: cubeToPixel(corner.coord, cornerScale) }; + }); + + const allPositions = [ + ...hexPositions.map((h) => h.pos), + ...Object.values(cornerPositions).map((c) => c.pos), + ]; + const minX = Math.min(...allPositions.map((p) => p.x)); + const maxX = Math.max(...allPositions.map((p) => p.x)); + const minY = Math.min(...allPositions.map((p) => p.y)); + const maxY = Math.max(...allPositions.map((p) => p.y)); + const padding = 80; + const viewBox = `${minX - padding} ${minY - padding} ${maxX - minX + padding * 2} ${maxY - minY + padding * 2}`; + + const playerColors = {}; + Object.entries(game.players || {}).forEach(([name, pdata]) => { + playerColors[name] = pdata.color || '#333'; + }); + + const roadLines = edges + .filter((edge) => edge.owner) + .map((edge) => { + const a = cornerPositions[edge.a]; + const b = cornerPositions[edge.b]; + if (!a || !b) return ''; + const color = playerColors[edge.owner] || '#222'; + return ``; + }) + .join(''); + + const cornerNodes = Object.entries(cornerPositions) + .filter(([, corner]) => corner.owner) + .map(([, corner]) => { + const radius = corner.building === 'city' ? 10 : 7; + const color = playerColors[corner.owner] || '#222'; + return ``; + }) + .join(''); + + const hexShapes = hexPositions + .map((hex) => { + const points = hexCorners(hex.pos.x, hex.pos.y, size - 4) + .map((pt) => pt.join(',')) + .join(' '); + const fill = resourceColors[hex.resource] || '#e0c89a'; + const number = hex.number || ''; + const robber = hex.robber ? "" : ''; + return ` + + + ${number} + ${robber} + + `; + }) + .join(''); + + return ( + + ); +} + +function ActionForm({ action, onSubmit }) { + const [payload, setPayload] = useState(action.payload || {}); + + useEffect(() => { + setPayload(action.payload || {}); + }, [action]); + + if (action.type === 'move_robber' || action.type === 'play_knight') { + const options = action.payload.options || []; + const selected = payload.hex ?? (options[0]?.hex ?? 0); + const victims = (options.find((opt) => opt.hex === selected) || {}).victims || []; + return ( +
+ + + +
+ ); + } + + if (action.type === 'play_road_building') { + const edges = action.payload.edges || []; + return ( +
+
Select up to two edges
+ + +
+ ); + } + + if (action.type === 'play_year_of_plenty') { + const bank = action.payload.bank || {}; + const resources = Object.keys(bank); + return ( +
+ + + +
+ ); + } + + if (action.type === 'play_monopoly') { + const resources = action.payload.resources || ['brick', 'lumber', 'wool', 'grain', 'ore']; + return ( +
+ + +
+ ); + } + + if (action.type === 'discard') { + const resources = action.payload.resources || {}; + return ( +
+ {Object.entries(resources).map(([res, count]) => ( +
+ {res} ({count}) + { + setPayload({ + ...payload, + cards: { ...payload.cards, [res]: parseInt(e.target.value, 10) || 0 }, + }); + }} + /> +
+ ))} + +
+ ); + } + + return ; +} + +function GamePage() { + const { gameId } = useParams(); + const [state, setState] = useState(null); + const [legalActions, setLegalActions] = useState([]); + const [selectedIdx, setSelectedIdx] = useState(0); + const [trade, setTrade] = useState({ to_player: '', offer: '', request: '' }); + + useEffect(() => { + let socket; + async function load() { + const data = await apiFetch(`/api/games/${gameId}`); + setState(data); + setLegalActions(data.legal_actions || []); + socket = new WebSocket(wsUrl(`/ws/games/${gameId}`)); + socket.onmessage = (event) => { + const msg = JSON.parse(event.data); + if (msg.type === 'state') { + setState(msg.data); + setLegalActions(msg.data.legal_actions || []); + setSelectedIdx(0); + } + }; + } + load().catch(console.error); + return () => socket && socket.close(); + }, [gameId]); + + const currentAction = legalActions[selectedIdx]; + + async function submitAction(payload) { + await apiFetch(`/api/games/${gameId}/action`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type: currentAction.type, payload }), + }); + } + + async function submitTrade() { + const offer = trade.offer.split(',').reduce((acc, item) => { + const [res, amt] = item.split(':').map((v) => v.trim()); + if (res && amt) acc[res] = parseInt(amt, 10) || 0; + return acc; + }, {}); + const request = trade.request.split(',').reduce((acc, item) => { + const [res, amt] = item.split(':').map((v) => v.trim()); + if (res && amt) acc[res] = parseInt(amt, 10) || 0; + return acc; + }, {}); + await apiFetch(`/api/games/${gameId}/trade/offer`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + from_player: getUsername(), + to_player: trade.to_player || null, + offer, + request, + }), + }); + } + + async function respondTrade(tradeId, accept) { + await apiFetch(`/api/games/${gameId}/trade/${tradeId}/respond`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ player: getUsername(), accept }), + }); + } + + if (!state) { + return
Loading...
; + } + + return ( +
+
+
+
+
Players
+
+ {state.game && Object.entries(state.game.players).map(([name, pdata]) => ( +
+ {name} + VP {pdata.victory_points} + {pdata.resources?.hidden !== undefined ? `Hidden ${pdata.resources.hidden}` : JSON.stringify(pdata.resources)} +
+ ))} +
+
+
+
Actions
+ {legalActions.length === 0 &&
Waiting for your turn…
} + {legalActions.length > 0 && ( +
+ + +
+ )} +
+
+
Trades
+
+ {state.pending_trades?.length === 0 &&
No offers.
} + {state.pending_trades?.map((offer) => ( +
+
{offer.from_player} → {offer.to_player || 'any'} | {JSON.stringify(offer.offer)} for {JSON.stringify(offer.request)}
+ {offer.to_player === getUsername() || offer.to_player === null ? ( +
+ + +
+ ) : null} +
+ ))} +
+ setTrade({ ...trade, to_player: e.target.value })} /> + setTrade({ ...trade, offer: e.target.value })} /> + setTrade({ ...trade, request: e.target.value })} /> + +
+
+
+
+
+ +
+
+
+
Status
+
+
Phase: {state.game?.phase || '-'}
+
Current: {state.game?.current_player || '-'}
+
Last roll: {state.game?.last_roll || '-'}
+
Winner: {state.game?.winner || '-'}
+
+
+
+
AI Debug
+
+ {state.history?.slice().reverse().find((entry) => entry.meta && Object.keys(entry.meta).length > 0) + ? JSON.stringify(state.history.slice().reverse().find((entry) => entry.meta && Object.keys(entry.meta).length > 0).meta) + : 'No debug data.'} +
+
+
+
History
+
+ {(state.history || []).slice().reverse().map((entry) => ( +
{entry.actor}: {entry.action.type}
+ ))} +
+
+
+
+
+ ); +} + +function AnalyticsPage() { + const [stats, setStats] = useState(null); + useEffect(() => { + apiFetch('/api/stats').then(setStats).catch(console.error); + }, []); + return ( +
+
+
Global Stats
+ {stats && ( +
+
Total games: {stats.total_games}
+
Finished: {stats.finished_games}
+
Average turns: {Number(stats.avg_turns || 0).toFixed(1)}
+
+ )} +
+
+ ); +} + +function ReplaysPage() { + const [replays, setReplays] = useState([]); + const navigate = useNavigate(); + useEffect(() => { + apiFetch('/api/replays').then((data) => setReplays(data.replays || [])).catch(console.error); + }, []); + + async function exportReplay(id) { + const data = await apiFetch(`/api/replays/${id}/export`); + downloadJson(data, `catan-replay-${id}.json`); + } + + async function importReplay(event) { + const file = event.target.files?.[0]; + if (!file) return; + const text = await file.text(); + const payload = JSON.parse(text); + await apiFetch('/api/replays/import', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + event.target.value = ''; + const data = await apiFetch('/api/replays'); + setReplays(data.replays || []); + } + + return ( +
+
+
Replays
+
+ + +
+
+ {replays.map((replay) => ( +
+
{replay.players.join(', ')} | Winner: {replay.winner || '-'}
+
Actions: {replay.total_actions}
+
+ + +
+
+ ))} + {replays.length === 0 &&
No replays yet.
} +
+
+
+ ); +} + +function ReplayViewer() { + const { replayId } = useParams(); + const [detail, setDetail] = useState(null); + const [step, setStep] = useState(0); + const [state, setState] = useState(null); + + useEffect(() => { + apiFetch(`/api/replays/${replayId}`).then(setDetail).catch(console.error); + }, [replayId]); + + useEffect(() => { + apiFetch(`/api/replays/${replayId}/state?step=${step}`).then(setState).catch(console.error); + }, [replayId, step]); + + if (!detail) { + return
Loading replay…
; + } + + async function exportReplay() { + const data = await apiFetch(`/api/replays/${replayId}/export`); + downloadJson(data, `catan-replay-${replayId}.json`); + } + + return ( +
+
+
+
Replay {detail.id}
+
+
Players: {detail.players.join(', ')}
+
Winner: {detail.winner || '-'}
+
Actions: {detail.total_actions}
+ + setStep(parseInt(e.target.value, 10))} + /> +
Step {step}
+
+
+
+ +
+
+
+ ); +} + +export default function App() { + const [user, setUser] = useState(getUsername()); + const hasToken = getToken(); + + useEffect(() => { + if (!hasToken) { + setUser(null); + } + }, [hasToken]); + + return ( +
+
+ + } /> + : } /> + } /> + } /> + } /> + } /> + +
+ ); +} diff --git a/web/src/api.js b/web/src/api.js new file mode 100644 index 0000000..5b81507 --- /dev/null +++ b/web/src/api.js @@ -0,0 +1,37 @@ +export function getToken() { + return localStorage.getItem('catan_token'); +} + +export function setToken(token, username) { + localStorage.setItem('catan_token', token); + localStorage.setItem('catan_user', username); +} + +export function clearToken() { + localStorage.removeItem('catan_token'); + localStorage.removeItem('catan_user'); +} + +export function getUsername() { + return localStorage.getItem('catan_user'); +} + +export async function apiFetch(path, options = {}) { + const headers = options.headers || {}; + const token = getToken(); + if (token) { + headers.Authorization = `Bearer ${token}`; + } + const response = await fetch(path, { ...options, headers }); + const data = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(data.detail || 'Request failed'); + } + return data; +} + +export function wsUrl(path) { + const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'; + const token = getToken(); + return `${proto}://${window.location.host}${path}?token=${token}`; +} diff --git a/web/src/main.jsx b/web/src/main.jsx new file mode 100644 index 0000000..6737e09 --- /dev/null +++ b/web/src/main.jsx @@ -0,0 +1,13 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import { BrowserRouter } from 'react-router-dom'; +import App from './App.jsx'; +import './styles.css'; + +ReactDOM.createRoot(document.getElementById('root')).render( + + + + + +); diff --git a/web/src/styles.css b/web/src/styles.css new file mode 100644 index 0000000..6cbc978 --- /dev/null +++ b/web/src/styles.css @@ -0,0 +1,180 @@ +:root { + color-scheme: light; + --ink: #1e1b16; + --sand: #f4ecd8; + --clay: #d99560; + --ocean: #3a6d7a; + --forest: #3c5a3c; + --sun: #f2c46c; + --stone: #6c6c6c; + --wheat: #d6b35f; + --panel: #fff8e9; + --accent: #b1552f; + --shadow: rgba(30, 27, 22, 0.15); +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: "Space Grotesk", sans-serif; + background: radial-gradient(circle at top, #fff6e6 0%, #efe0c2 45%, #e5d0a7 100%); + color: var(--ink); + min-height: 100vh; +} + +a { + color: inherit; + text-decoration: none; +} + +#root { + min-height: 100vh; +} + +.app { + min-height: 100vh; + display: flex; + flex-direction: column; +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 18px 28px; + background: linear-gradient(120deg, rgba(255, 245, 215, 0.95), rgba(255, 231, 186, 0.95)); + box-shadow: 0 8px 24px var(--shadow); + border-bottom: 1px solid rgba(30, 27, 22, 0.1); +} + +.logo { + font-family: "Cinzel", serif; + font-size: 24px; + letter-spacing: 3px; +} + +.nav { + display: flex; + gap: 16px; + align-items: center; +} + +.container { + flex: 1; + padding: 28px; +} + +.grid { + display: grid; + gap: 24px; +} + +.grid.two { + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); +} + +.grid.three { + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); +} + +.card { + background: var(--panel); + border-radius: 16px; + padding: 18px 22px; + box-shadow: 0 18px 40px var(--shadow); + border: 1px solid rgba(30, 27, 22, 0.08); +} + +.section-title { + font-family: "Cinzel", serif; + font-size: 18px; + margin-bottom: 12px; +} + +.input { + width: 100%; + padding: 10px 12px; + border-radius: 10px; + border: 1px solid rgba(30, 27, 22, 0.2); + background: white; + font-size: 14px; +} + +.button { + border: none; + border-radius: 10px; + padding: 10px 16px; + font-weight: 600; + cursor: pointer; + background: var(--accent); + color: white; + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.button.secondary { + background: var(--ocean); +} + +.button.ghost { + background: transparent; + border: 1px solid rgba(30, 27, 22, 0.2); + color: var(--ink); +} + +.badge { + background: var(--accent); + color: white; + padding: 4px 10px; + border-radius: 999px; + font-size: 12px; + text-transform: uppercase; + letter-spacing: 1px; +} + +.player-tag { + padding: 4px 8px; + border-radius: 999px; + font-size: 12px; + background: rgba(0, 0, 0, 0.08); +} + +.panel-stack { + display: grid; + gap: 12px; +} + +.board-shell { + background: radial-gradient(circle at center, #f7efd9 0%, #eddcb8 60%, #e0c89a 100%); + border-radius: 24px; + padding: 12px; + box-shadow: inset 0 0 40px rgba(255, 255, 255, 0.6); +} + +.board-svg { + width: 100%; + height: 520px; +} + +.history { + max-height: 240px; + overflow: auto; + font-size: 13px; +} + +.small { + font-size: 12px; + opacity: 0.7; +} + +@media (max-width: 900px) { + .container { + padding: 18px; + } + + .board-svg { + height: 360px; + } +} diff --git a/web/vite.config.js b/web/vite.config.js new file mode 100644 index 0000000..d0d12d2 --- /dev/null +++ b/web/vite.config.js @@ -0,0 +1,16 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + plugins: [react()], + server: { + port: 5173, + proxy: { + '/api': 'http://localhost:8000', + '/ws': { + target: 'ws://localhost:8000', + ws: true, + }, + }, + }, +});