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

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

13
.env.example Normal file
View File

@@ -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=*

27
.gitea/workflows/ci.yml Normal file
View File

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

13
.gitignore vendored Normal file
View File

@@ -0,0 +1,13 @@
.env
.venv/
.idea/
.pytest_cache/
__pycache__/
*.pyc
*.pyo
*.egg-info/
dist/
build/
node_modules/
web/dist/
models/

20
Dockerfile.web Normal file
View File

@@ -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"]

140
README.md Normal file
View File

@@ -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 <corner> <road>`, `build settlement|city|road <id>` — действия на поле.
- `buy dev`, `play knight|road|yop|monopoly`, `trade bank ...`, `trade player ...`.
- `move robber <hex> [victim]`, `discard <player> <resource> <amount> ...`, `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
```

14
catan/__init__.py Normal file
View File

@@ -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",
]

320
catan/board.py Normal file
View File

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

349
catan/cli.py Normal file
View File

@@ -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 <name> 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 <corner> <road>: стартовая постройка
- build settlement|city|road <id>
- buy dev: купить карту развития
- play knight|road|yop|monopoly ...
- trade bank <give> <receive> [amount]
- trade player <name> give ... receive ...
- move robber <hex> [victim]
- discard <player> <res> <amount> ...
- 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()

125
catan/data.py Normal file
View File

@@ -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]

618
catan/game.py Normal file
View File

@@ -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,
}

313
catan/gui.py Normal file
View File

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

116
catan/learning.py Normal file
View File

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

24
catan/ml/__init__.py Normal file
View File

@@ -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",
]

138
catan/ml/agents.py Normal file
View File

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

84
catan/ml/encoding.py Normal file
View File

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

26
catan/ml/policies.py Normal file
View File

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

300
catan/ml/selfplay.py Normal file
View File

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

158
catan/ml/trainers.py Normal file
View File

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

93
catan/player.py Normal file
View File

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

329
catan/sdk.py Normal file
View File

@@ -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}")

1
catan/web/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Web server package for Catan."""

648
catan/web/app.py Normal file
View File

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

734
catan/web/static/app.js Normal file
View File

@@ -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 `
<div class="header">
<div class="logo">CATAN ARENA</div>
<div style="display:flex; gap:12px; align-items:center;">
<span class="small">${state.username || "Guest"}</span>
${state.token ? `<button class="button ghost" id="logout-btn">Logout</button>` : ""}
</div>
</div>
`;
}
function renderLogin() {
app.innerHTML = `
${renderHeader()}
<div class="container">
<div class="grid two">
<div class="card">
<div class="section-title">Login</div>
<div class="panel-stack">
<input class="input" id="login-user" placeholder="Username" />
<input class="input" id="login-pass" type="password" placeholder="Password" />
<button class="button" id="login-btn">Enter Arena</button>
</div>
</div>
<div class="card">
<div class="section-title">Create Account</div>
<div class="panel-stack">
<input class="input" id="register-user" placeholder="Username" />
<input class="input" id="register-pass" type="password" placeholder="Password" />
<button class="button secondary" id="register-btn">Register</button>
</div>
</div>
</div>
</div>
`;
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) => `<span class="player-tag">${name}</span>`)
.join("");
return `
<div class="lobby-card">
<div style="display:flex; justify-content:space-between; align-items:center;">
<strong>${game.name}</strong>
<span class="badge">${game.status}</span>
</div>
<div style="display:flex; flex-wrap:wrap; gap:6px;">${players}</div>
<div style="display:flex; gap:8px; flex-wrap:wrap;">
<button class="button" data-action="join" data-id="${game.id}">Join</button>
<button class="button ghost" data-action="ai" data-id="${game.id}">Add AI</button>
<button class="button secondary" data-action="start" data-id="${game.id}">Start</button>
</div>
</div>
`;
}
function renderLobby() {
app.innerHTML = `
${renderHeader()}
<div class="container">
<div class="grid two">
<div class="card">
<div class="section-title">Lobby</div>
<div class="panel-stack">
${state.lobby.map(lobbyGameCard).join("") || "<p>No active games yet.</p>"}
</div>
</div>
<div class="panel-stack">
<div class="card">
<div class="section-title">Create Game</div>
<div class="panel-stack">
<input class="input" id="game-name" placeholder="Game name" />
<select class="input" id="game-size">
<option value="2">2 players</option>
<option value="3">3 players</option>
<option value="4" selected>4 players</option>
</select>
<button class="button" id="create-game">Create</button>
</div>
</div>
<div class="card">
<div class="section-title">Your Stats</div>
<div class="panel-stack">
<div>Total games: ${state.stats?.total_games ?? 0}</div>
<div>Your games: ${state.stats?.user_games ?? 0}</div>
<div>Your wins: ${state.stats?.user_wins ?? 0}</div>
<div>Avg turns: ${(state.stats?.avg_turns ?? 0).toFixed(1)}</div>
</div>
</div>
<div class="card">
<div class="section-title">AI Models</div>
<div class="panel-stack">
${(state.models || []).map((model) => `<span class="player-tag">${model}</span>`).join("") || "<span class=\"small\">No models uploaded yet.</span>"}
</div>
</div>
</div>
</div>
</div>
`;
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 `
<div style="display:flex; align-items:center; gap:8px;">
<span class="player-tag" style="background:${color}; color:white;">${name}</span>
<span class="small">VP ${pdata.victory_points}</span>
<span class="small">${resourceSummary(pdata.resources)}</span>
</div>
`;
})
.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 `<line x1="${a.pos.x}" y1="${a.pos.y}" x2="${b.pos.x}" y2="${b.pos.y}" stroke="${color}" stroke-width="6" stroke-linecap="round" />`;
})
.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 `<circle cx="${corner.pos.x}" cy="${corner.pos.y}" r="${radius}" fill="${color}" stroke="#fff" stroke-width="2" />`;
})
.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 ? "<circle r='8' fill='rgba(0,0,0,0.4)' />" : "";
return `
<g>
<polygon points="${points}" fill="${fill}" stroke="rgba(0,0,0,0.2)" stroke-width="2" />
<text x="${hex.pos.x}" y="${hex.pos.y}" text-anchor="middle" font-size="16" fill="#3a2a1a" font-weight="600">${number}</text>
<g transform="translate(${hex.pos.x}, ${hex.pos.y + 20})">${robber}</g>
</g>
`;
})
.join("");
return `
<svg class="board-svg" viewBox="${viewBox}">
${hexShapes}
${roadLines}
${cornerNodes}
</svg>
`;
}
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()}<div class="container">Loading...</div>`;
return;
}
const game = data.game;
const board = data.board;
const legalActions = data.legal_actions || [];
app.innerHTML = `
${renderHeader()}
<div class="container">
<div class="grid three">
<div class="panel-stack">
<div class="card">
<div class="section-title">Players</div>
<div class="panel-stack">${renderPlayers(game)}</div>
</div>
<div class="card">
<div class="section-title">Action Console</div>
<div class="action-list" id="action-list"></div>
</div>
<div class="card">
<div class="section-title">Timeline</div>
<div class="history">${(data.history || [])
.slice()
.reverse()
.map((entry) => `<div>${entry.player}: ${entry.type}</div>`)
.join("")}</div>
</div>
</div>
<div class="board-shell">
${renderBoard(board, game)}
</div>
<div class="panel-stack">
<div class="card">
<div class="section-title">Status</div>
<div class="panel-stack">
<div>Phase: ${game?.phase || "-"}</div>
<div>Current: ${game?.current_player || "-"}</div>
<div>Last roll: ${game?.last_roll || "-"}</div>
<div>Winner: ${game?.winner || "-"}</div>
</div>
</div>
<div class="card">
<div class="section-title">Bank</div>
<div>${game ? Object.entries(game.bank)
.map(([k, v]) => `${k}: ${v}`)
.join(" · ") : ""}</div>
</div>
<div class="card">
<div class="section-title">Lobby</div>
<button class="button ghost" id="back-lobby">Back to Lobby</button>
</div>
</div>
</div>
</div>
`;
const actionList = document.getElementById("action-list");
if (legalActions.length === 0) {
actionList.innerHTML = "<div class='small'>Waiting for your turn...</div>";
} 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();
}
})();

View File

@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Catan Arena</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@400;600&family=Space+Grotesk:wght@400;600&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/static/styles.css" />
</head>
<body>
<div id="app"></div>
<script src="/static/app.js"></script>
</body>
</html>

190
catan/web/static/styles.css Normal file
View File

@@ -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;
}
}

72
docker-compose.dev.yml Normal file
View File

@@ -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:

82
docker-compose.yml Normal file
View File

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

20
docker/ai.Dockerfile Normal file
View File

@@ -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"]

View File

@@ -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"]

19
docker/api.Dockerfile Normal file
View File

@@ -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"]

19
docker/game.Dockerfile Normal file
View File

@@ -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"]

12
docker/web.Dockerfile Normal file
View File

@@ -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;"]

61
docs/architecture.md Normal file
View File

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

32
docs/replay_format.md Normal file
View File

@@ -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.

40
pyproject.toml Normal file
View File

@@ -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*"]}}

0
services/ai/__init__.py Normal file
View File

185
services/ai/app.py Normal file
View File

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

View File

@@ -0,0 +1,4 @@
fastapi>=0.115
uvicorn[standard]>=0.30
numpy>=1.26
pydantic-settings>=2.2

View File

231
services/analytics/app.py Normal file
View File

@@ -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}

View File

@@ -0,0 +1,5 @@
fastapi>=0.115
uvicorn[standard]>=0.30
sqlmodel>=0.0.16
pydantic-settings>=2.2
psycopg[binary]>=3.1

0
services/api/__init__.py Normal file
View File

360
services/api/app.py Normal file
View File

@@ -0,0 +1,360 @@
from __future__ import annotations
import asyncio
from typing import Dict, Optional
import httpx
from fastapi import Depends, FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from sqlmodel import SQLModel, select
from sqlalchemy.exc import IntegrityError
from services.api.models import User
from services.common.auth import create_token, decode_token, hash_password, verify_password
from services.common.db import SessionLocal, engine
from services.common.schemas import (
ActionRequest,
AddAIRequest,
CreateGameRequest,
JoinGameRequest,
TradeOfferRequest,
TradeRespondRequest,
)
from services.common.settings import settings
app = FastAPI(title="Catan API Gateway")
cors_origins = [origin.strip() for origin in settings.cors_origins.split(",") if origin.strip()]
app.add_middleware(
CORSMiddleware,
allow_origins=cors_origins or ["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
clients: Dict[str, httpx.AsyncClient] = {}
ws_connections: Dict[str, Dict[WebSocket, str]] = {}
ai_tasks: Dict[str, asyncio.Task] = {}
@app.on_event("startup")
async def _startup() -> None:
SQLModel.metadata.create_all(engine)
clients["game"] = httpx.AsyncClient(base_url=settings.game_service_url)
clients["analytics"] = httpx.AsyncClient(base_url=settings.analytics_service_url)
clients["ai"] = httpx.AsyncClient(base_url=settings.ai_service_url)
@app.on_event("shutdown")
async def _shutdown() -> None:
for client in clients.values():
await client.aclose()
def _get_current_user(request: Request) -> User:
auth = request.headers.get("authorization", "")
if not auth.lower().startswith("bearer "):
raise HTTPException(status_code=401, detail="Missing token")
token = auth.split(" ", 1)[1].strip()
try:
payload = decode_token(token)
except Exception:
raise HTTPException(status_code=401, detail="Invalid token")
user_id = payload.get("sub")
if not user_id:
raise HTTPException(status_code=401, detail="Invalid token")
with SessionLocal() as session:
user = session.get(User, int(user_id))
if not user:
raise HTTPException(status_code=401, detail="Unknown user")
return user
def _get_user_from_ws(websocket: WebSocket) -> User:
token = websocket.query_params.get("token")
if not token:
raise HTTPException(status_code=401, detail="Missing token")
try:
payload = decode_token(token)
except Exception:
raise HTTPException(status_code=401, detail="Invalid token")
user_id = payload.get("sub")
if not user_id:
raise HTTPException(status_code=401, detail="Invalid token")
with SessionLocal() as session:
user = session.get(User, int(user_id))
if not user:
raise HTTPException(status_code=401, detail="Unknown user")
return user
def _mask_state_for_user(state: dict, username: str) -> dict:
data = dict(state)
game = data.get("game")
if not game:
return data
masked_players = {}
for name, info in game.get("players", {}).items():
pdata = dict(info)
if name != username:
resources = pdata.get("resources") or {}
total = sum(resources.values()) if isinstance(resources, dict) else 0
pdata["resources"] = {"hidden": total}
masked_players[name] = pdata
game["players"] = masked_players
data["game"] = game
return data
async def _broadcast(game_id: str, state: dict) -> None:
connections = ws_connections.get(game_id, {})
dead = []
for ws, username in connections.items():
try:
await ws.send_json({"type": "state", "data": _mask_state_for_user(state, username)})
except Exception:
dead.append(ws)
for ws in dead:
connections.pop(ws, None)
def _is_ai_only(state: dict) -> bool:
slots = state.get("players", [])
if not slots:
return False
return all(slot.get("is_ai") for slot in slots)
async def _auto_advance(game_id: str) -> None:
while True:
response = await clients["game"].post(f"/games/{game_id}/advance")
if response.status_code >= 400:
break
state = response.json()
await _broadcast(game_id, state)
if state.get("status") != "running":
break
await asyncio.sleep(0.2)
def _ensure_task(game_id: str) -> None:
if game_id in ai_tasks and not ai_tasks[game_id].done():
return
ai_tasks[game_id] = asyncio.create_task(_auto_advance(game_id))
@app.get("/health")
def health() -> dict:
return {"status": "ok"}
@app.post("/api/auth/register")
def register(payload: dict):
username = (payload.get("username") or "").strip()
password = (payload.get("password") or "").strip()
if not username or not password:
raise HTTPException(status_code=400, detail="Username and password required")
user = User(username=username, password_hash=hash_password(password))
with SessionLocal() as session:
session.add(user)
try:
session.commit()
except IntegrityError:
session.rollback()
raise HTTPException(status_code=400, detail="Username already exists")
session.refresh(user)
token = create_token(user.id, user.username)
return {"token": token, "username": user.username}
@app.post("/api/auth/login")
def login(payload: dict):
username = (payload.get("username") or "").strip()
password = (payload.get("password") or "").strip()
with SessionLocal() as session:
user = session.exec(select(User).where(User.username == username)).first()
if not user or not verify_password(password, user.password_hash):
raise HTTPException(status_code=401, detail="Invalid credentials")
token = create_token(user.id, user.username)
return {"token": token, "username": user.username}
@app.get("/api/me")
def me(user: User = Depends(_get_current_user)):
return {"id": user.id, "username": user.username}
@app.get("/api/lobby")
async def lobby(user: User = Depends(_get_current_user)):
response = await clients["game"].get("/games")
response.raise_for_status()
return response.json()
@app.post("/api/games")
async def create_game(payload: CreateGameRequest, user: User = Depends(_get_current_user)):
data = payload.model_dump()
data["created_by"] = user.username
response = await clients["game"].post("/games", json=data)
response.raise_for_status()
return response.json()
@app.post("/api/games/{game_id}/join")
async def join_game(game_id: str, user: User = Depends(_get_current_user)):
payload = JoinGameRequest(username=user.username, user_id=user.id)
response = await clients["game"].post(f"/games/{game_id}/join", json=payload.model_dump())
response.raise_for_status()
state = await clients["game"].get(f"/games/{game_id}")
state.raise_for_status()
await _broadcast(game_id, state.json())
return response.json()
@app.post("/api/games/{game_id}/leave")
async def leave_game(game_id: str, user: User = Depends(_get_current_user)):
payload = JoinGameRequest(username=user.username, user_id=user.id)
response = await clients["game"].post(f"/games/{game_id}/leave", json=payload.model_dump())
response.raise_for_status()
state = await clients["game"].get(f"/games/{game_id}")
state.raise_for_status()
await _broadcast(game_id, state.json())
return response.json()
@app.post("/api/games/{game_id}/add-ai")
async def add_ai(game_id: str, payload: AddAIRequest, user: User = Depends(_get_current_user)):
response = await clients["game"].post(f"/games/{game_id}/add_ai", json=payload.model_dump())
response.raise_for_status()
state = await clients["game"].get(f"/games/{game_id}")
state.raise_for_status()
await _broadcast(game_id, state.json())
return response.json()
@app.post("/api/games/{game_id}/start")
async def start_game(game_id: str, user: User = Depends(_get_current_user)):
response = await clients["game"].post(f"/games/{game_id}/start")
response.raise_for_status()
state = response.json()
await _broadcast(game_id, state)
if _is_ai_only(state):
_ensure_task(game_id)
return _mask_state_for_user(state, user.username)
@app.get("/api/games/{game_id}")
async def game_state(game_id: str, user: User = Depends(_get_current_user)):
response = await clients["game"].get(f"/games/{game_id}")
response.raise_for_status()
return _mask_state_for_user(response.json(), user.username)
@app.post("/api/games/{game_id}/action")
async def take_action(game_id: str, payload: dict, user: User = Depends(_get_current_user)):
action = payload.get("action") or {"type": payload.get("type"), "payload": payload.get("payload")}
req = ActionRequest(actor=user.username, action=action)
response = await clients["game"].post(f"/games/{game_id}/action", json=req.model_dump())
if response.status_code >= 400:
raise HTTPException(status_code=response.status_code, detail=response.json().get("detail"))
state = response.json()
await _broadcast(game_id, state)
if _is_ai_only(state):
_ensure_task(game_id)
return _mask_state_for_user(state, user.username)
@app.post("/api/games/{game_id}/trade/offer")
async def offer_trade(game_id: str, payload: TradeOfferRequest, user: User = Depends(_get_current_user)):
if payload.from_player != user.username:
raise HTTPException(status_code=403, detail="Not your player")
response = await clients["game"].post(f"/games/{game_id}/trade/offer", json=payload.model_dump())
response.raise_for_status()
state = await clients["game"].get(f"/games/{game_id}")
state.raise_for_status()
await _broadcast(game_id, state.json())
return response.json()
@app.post("/api/games/{game_id}/trade/{trade_id}/respond")
async def respond_trade(game_id: str, trade_id: str, payload: TradeRespondRequest, user: User = Depends(_get_current_user)):
if payload.player != user.username:
raise HTTPException(status_code=403, detail="Not your player")
response = await clients["game"].post(
f"/games/{game_id}/trade/{trade_id}/respond",
json=payload.model_dump(),
)
response.raise_for_status()
state = await clients["game"].get(f"/games/{game_id}")
state.raise_for_status()
await _broadcast(game_id, state.json())
return response.json()
@app.get("/api/models")
async def list_models(user: User = Depends(_get_current_user)):
response = await clients["ai"].get("/models")
response.raise_for_status()
return response.json()
@app.get("/api/replays")
async def list_replays(user: User = Depends(_get_current_user)):
response = await clients["analytics"].get("/replays")
response.raise_for_status()
return response.json()
@app.get("/api/replays/{replay_id}")
async def replay_detail(replay_id: str, user: User = Depends(_get_current_user)):
response = await clients["analytics"].get(f"/replays/{replay_id}")
response.raise_for_status()
return response.json()
@app.get("/api/replays/{replay_id}/state")
async def replay_state(replay_id: str, step: int = 0, user: User = Depends(_get_current_user)):
response = await clients["analytics"].get(f"/replays/{replay_id}/state", params={"step": step})
response.raise_for_status()
return response.json()
@app.get("/api/replays/{replay_id}/export")
async def replay_export(replay_id: str, user: User = Depends(_get_current_user)):
response = await clients["analytics"].get(f"/replays/{replay_id}/export")
response.raise_for_status()
return response.json()
@app.post("/api/replays/import")
async def replay_import(payload: dict, user: User = Depends(_get_current_user)):
response = await clients["analytics"].post("/replays/import", json=payload)
if response.status_code >= 400:
raise HTTPException(status_code=response.status_code, detail=response.json().get("detail"))
return response.json()
@app.get("/api/stats")
async def stats(user: User = Depends(_get_current_user)):
response = await clients["analytics"].get("/stats", params={"user": user.username})
response.raise_for_status()
return response.json()
@app.websocket("/ws/games/{game_id}")
async def ws_game(websocket: WebSocket, game_id: str):
await websocket.accept()
try:
user = _get_user_from_ws(websocket)
except HTTPException:
await websocket.close(code=1008)
return
ws_connections.setdefault(game_id, {})[websocket] = user.username
try:
response = await clients["game"].get(f"/games/{game_id}")
if response.status_code == 200:
await websocket.send_json({"type": "state", "data": _mask_state_for_user(response.json(), user.username)})
while True:
await websocket.receive_text()
except WebSocketDisconnect:
ws_connections.get(game_id, {}).pop(websocket, None)

13
services/api/models.py Normal file
View File

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

View File

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

View File

@@ -0,0 +1 @@
"""Shared utilities for Catan services."""

47
services/common/auth.py Normal file
View File

@@ -0,0 +1,47 @@
from __future__ import annotations
import datetime as dt
from typing import Optional
import jwt
from passlib.context import CryptContext
from .settings import settings
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(password: str, password_hash: str) -> bool:
return pwd_context.verify(password, password_hash)
def create_token(user_id: int, username: str) -> str:
now = dt.datetime.now(dt.timezone.utc)
payload = {
"sub": str(user_id),
"username": username,
"exp": now + dt.timedelta(hours=settings.jwt_exp_hours),
}
return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
def decode_token(token: str) -> dict:
return jwt.decode(token, settings.jwt_secret, algorithms=[settings.jwt_algorithm])
def get_token_subject(token: str) -> Optional[int]:
try:
payload = decode_token(token)
except jwt.PyJWTError:
return None
sub = payload.get("sub")
if not sub:
return None
try:
return int(sub)
except ValueError:
return None

24
services/common/db.py Normal file
View File

@@ -0,0 +1,24 @@
from __future__ import annotations
from contextlib import contextmanager
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from .settings import settings
engine = create_engine(settings.database_url, pool_pre_ping=True)
SessionLocal = sessionmaker(bind=engine, class_=Session, expire_on_commit=False)
@contextmanager
def session_scope() -> Session:
session = SessionLocal()
try:
yield session
session.commit()
except Exception:
session.rollback()
raise
finally:
session.close()

135
services/common/schemas.py Normal file
View File

@@ -0,0 +1,135 @@
from __future__ import annotations
import datetime as dt
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field
class ActionSchema(BaseModel):
type: str
payload: Dict[str, Any] = Field(default_factory=dict)
class ActionLogSchema(BaseModel):
idx: int
ts: dt.datetime
actor: str
action: ActionSchema
applied: bool = True
meta: Dict[str, Any] = Field(default_factory=dict)
class TradeOfferSchema(BaseModel):
id: str
from_player: str
to_player: Optional[str] = None
offer: Dict[str, int]
request: Dict[str, int]
status: str
created_at: dt.datetime
class GameSlotSchema(BaseModel):
slot_id: int
name: Optional[str] = None
user_id: Optional[int] = None
is_ai: bool = False
ai_kind: Optional[str] = None
ai_model: Optional[str] = None
ready: bool = False
color: Optional[str] = None
class GameSummarySchema(BaseModel):
id: str
name: str
status: str
max_players: int
created_by: str
created_at: dt.datetime
players: List[GameSlotSchema]
class GameStateSchema(BaseModel):
id: str
name: str
status: str
max_players: int
created_by: str
created_at: dt.datetime
players: List[GameSlotSchema]
game: Optional[Dict[str, Any]] = None
board: Optional[Dict[str, Any]] = None
legal_actions: List[ActionSchema] = Field(default_factory=list)
pending_trades: List[TradeOfferSchema] = Field(default_factory=list)
history: List[ActionLogSchema] = Field(default_factory=list)
class CreateGameRequest(BaseModel):
name: str
max_players: int
created_by: Optional[str] = None
class JoinGameRequest(BaseModel):
username: str
user_id: int
class AddAIRequest(BaseModel):
ai_type: str
model_name: Optional[str] = None
class TradeOfferRequest(BaseModel):
from_player: str
to_player: Optional[str] = None
offer: Dict[str, int]
request: Dict[str, int]
class TradeRespondRequest(BaseModel):
player: str
accept: bool
class ActionRequest(BaseModel):
actor: str
action: ActionSchema
class AIRequest(BaseModel):
observation: Dict[str, Any]
legal_actions: List[ActionSchema]
agent: Dict[str, Any]
debug: bool = False
class AIResponse(BaseModel):
action: ActionSchema
debug: Dict[str, Any] = Field(default_factory=dict)
class ReplayMeta(BaseModel):
id: str
created_at: dt.datetime
players: List[str]
winner: Optional[str] = None
total_actions: int
class ReplayDetail(ReplayMeta):
seed: int
actions: List[ActionLogSchema]
class ReplayArchive(BaseModel):
version: int = 1
id: str
created_at: dt.datetime
seed: int
players: List[str]
winner: Optional[str] = None
slots: List[GameSlotSchema]
actions: List[ActionLogSchema]

View File

@@ -0,0 +1,29 @@
from __future__ import annotations
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="CATAN_", env_file=".env", extra="ignore")
env: str = "dev"
debug: bool = False
database_url: str = "postgresql+psycopg://catan:catan@db:5432/catan"
jwt_secret: str = "change-me"
jwt_algorithm: str = "HS256"
jwt_exp_hours: int = 24 * 7
api_service_url: str = "http://api:8000"
game_service_url: str = "http://game:8001"
ai_service_url: str = "http://ai:8002"
analytics_service_url: str = "http://analytics:8003"
models_dir: str = "/models"
replay_dir: str = "/replays"
cors_origins: str = "*"
settings = Settings()

View File

478
services/game/app.py Normal file
View File

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

44
services/game/models.py Normal file
View File

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

View File

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

177
services/game/runtime.py Normal file
View File

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

1
tests/__init__.py Normal file
View File

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

39
tests/conftest.py Normal file
View File

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

View File

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

52
tests/test_cli.py Normal file
View File

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

34
tests/test_cli_bot.py Normal file
View File

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

80
tests/test_env_actions.py Normal file
View File

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

View File

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

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

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

38
tests/utils.py Normal file
View File

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

765
uv.lock generated Normal file
View File

@@ -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" },
]

15
web/index.html Normal file
View File

@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Catan Arena</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&family=Space+Grotesk:wght@400;600&display=swap" rel="stylesheet" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

20
web/package.json Normal file
View File

@@ -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"
}
}

807
web/src/App.jsx Normal file
View File

@@ -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 (
<div className="header">
<div className="logo">CATAN ARENA</div>
<div className="nav">
{user && (
<>
<Link to="/">Lobby</Link>
<Link to="/analytics">Analytics</Link>
<Link to="/replays">Replays</Link>
</>
)}
<span className="small">{user || 'Guest'}</span>
{user && (
<button
className="button ghost"
onClick={() => {
clearToken();
navigate('/login');
}}
>
Logout
</button>
)}
</div>
</div>
);
}
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 (
<div className="container">
<div className="grid two">
<div className="card">
<div className="section-title">Login</div>
<div className="panel-stack">
<input
className="input"
placeholder="Username"
value={login.username}
onChange={(e) => setLogin({ ...login, username: e.target.value })}
/>
<input
className="input"
type="password"
placeholder="Password"
value={login.password}
onChange={(e) => setLogin({ ...login, password: e.target.value })}
/>
<button className="button" onClick={submitLogin}>Enter Arena</button>
</div>
</div>
<div className="card">
<div className="section-title">Create Account</div>
<div className="panel-stack">
<input
className="input"
placeholder="Username"
value={register.username}
onChange={(e) => setRegister({ ...register, username: e.target.value })}
/>
<input
className="input"
type="password"
placeholder="Password"
value={register.password}
onChange={(e) => setRegister({ ...register, password: e.target.value })}
/>
<button className="button secondary" onClick={submitRegister}>Register</button>
</div>
</div>
</div>
</div>
);
}
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 (
<div className="container">
<div className="grid two">
<div className="card">
<div className="section-title">Lobby</div>
<div className="panel-stack">
{games.length === 0 && <div className="small">No active games.</div>}
{games.map((game) => (
<div className="card" key={game.id}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<strong>{game.name}</strong>
<span className="badge">{game.status}</span>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 8 }}>
{game.players.map((slot) => (
<span className="player-tag" key={slot.slot_id}>
{slot.name || 'Open'}
</span>
))}
</div>
<div style={{ display: 'flex', gap: 8, marginTop: 12, flexWrap: 'wrap' }}>
<button className="button" onClick={() => joinGame(game.id)}>Join</button>
<button className="button ghost" onClick={() => watchGame(game.id)}>Watch</button>
<button className="button secondary" onClick={() => addAi(game.id)}>Add AI</button>
<button className="button" onClick={() => startGame(game.id)}>Start</button>
</div>
</div>
))}
</div>
</div>
<div className="panel-stack">
<div className="card">
<div className="section-title">Create Game</div>
<div className="panel-stack">
<input
className="input"
placeholder="Game name"
value={create.name}
onChange={(e) => setCreate({ ...create, name: e.target.value })}
/>
<select
className="input"
value={create.max_players}
onChange={(e) => setCreate({ ...create, max_players: parseInt(e.target.value, 10) })}
>
<option value={2}>2 players</option>
<option value={3}>3 players</option>
<option value={4}>4 players</option>
</select>
<button className="button" onClick={createGame}>Create</button>
</div>
</div>
<div className="card">
<div className="section-title">Your Stats</div>
{stats && (
<div className="panel-stack">
<div>Total games: {stats.total_games}</div>
<div>Finished games: {stats.finished_games}</div>
<div>Your games: {stats.user_games}</div>
<div>Your wins: {stats.user_wins}</div>
<div>Avg turns: {Number(stats.avg_turns || 0).toFixed(1)}</div>
</div>
)}
</div>
<div className="card">
<div className="section-title">AI Models</div>
<div className="panel-stack">
{models.map((model) => (
<span className="player-tag" key={model}>{model}</span>
))}
{models.length === 0 && <div className="small">No models found.</div>}
</div>
</div>
</div>
</div>
</div>
);
}
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 `<line x1="${a.pos.x}" y1="${a.pos.y}" x2="${b.pos.x}" y2="${b.pos.y}" stroke="${color}" stroke-width="6" stroke-linecap="round" />`;
})
.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 `<circle cx="${corner.pos.x}" cy="${corner.pos.y}" r="${radius}" fill="${color}" stroke="#fff" stroke-width="2" />`;
})
.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 ? "<circle r='8' fill='rgba(0,0,0,0.4)' />" : '';
return `
<g>
<polygon points="${points}" fill="${fill}" stroke="rgba(0,0,0,0.2)" stroke-width="2" />
<text x="${hex.pos.x}" y="${hex.pos.y}" text-anchor="middle" font-size="16" fill="#3a2a1a" font-weight="600">${number}</text>
<g transform="translate(${hex.pos.x}, ${hex.pos.y + 20})">${robber}</g>
</g>
`;
})
.join('');
return (
<svg className="board-svg" viewBox={viewBox} dangerouslySetInnerHTML={{ __html: `${hexShapes}${roadLines}${cornerNodes}` }} />
);
}
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 (
<div className="panel-stack">
<select
className="input"
value={selected}
onChange={(e) => setPayload({ ...payload, hex: parseInt(e.target.value, 10) })}
>
{options.map((opt) => (
<option key={opt.hex} value={opt.hex}>Hex {opt.hex}</option>
))}
</select>
<select
className="input"
value={payload.victim || ''}
onChange={(e) => setPayload({ ...payload, victim: e.target.value || undefined })}
>
<option value="">No victim</option>
{victims.map((victim) => (
<option key={victim} value={victim}>{victim}</option>
))}
</select>
<button className="button" onClick={() => onSubmit(payload)}>Execute</button>
</div>
);
}
if (action.type === 'play_road_building') {
const edges = action.payload.edges || [];
return (
<div className="panel-stack">
<div className="small">Select up to two edges</div>
<select
className="input"
multiple
onChange={(e) => {
const values = Array.from(e.target.selectedOptions).map((opt) => parseInt(opt.value, 10));
setPayload({ edges: values.slice(0, 2) });
}}
>
{edges.map((edge) => (
<option key={edge} value={edge}>Edge {edge}</option>
))}
</select>
<button className="button" onClick={() => onSubmit(payload)}>Execute</button>
</div>
);
}
if (action.type === 'play_year_of_plenty') {
const bank = action.payload.bank || {};
const resources = Object.keys(bank);
return (
<div className="panel-stack">
<select className="input" onChange={(e) => setPayload({ ...payload, res1: e.target.value })}>
{resources.map((res) => (
<option key={res} value={res}>{res}</option>
))}
</select>
<select className="input" onChange={(e) => setPayload({ ...payload, res2: e.target.value })}>
{resources.map((res) => (
<option key={res} value={res}>{res}</option>
))}
</select>
<button
className="button"
onClick={() => onSubmit({ resources: [payload.res1 || resources[0], payload.res2 || resources[0]] })}
>
Execute
</button>
</div>
);
}
if (action.type === 'play_monopoly') {
const resources = action.payload.resources || ['brick', 'lumber', 'wool', 'grain', 'ore'];
return (
<div className="panel-stack">
<select className="input" onChange={(e) => setPayload({ resource: e.target.value })}>
{resources.map((res) => (
<option key={res} value={res}>{res}</option>
))}
</select>
<button className="button" onClick={() => onSubmit({ resource: payload.resource || resources[0] })}>Execute</button>
</div>
);
}
if (action.type === 'discard') {
const resources = action.payload.resources || {};
return (
<div className="panel-stack">
{Object.entries(resources).map(([res, count]) => (
<div key={res} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span className="small">{res} ({count})</span>
<input
className="input"
type="number"
min="0"
max={count}
value={payload.cards?.[res] || 0}
onChange={(e) => {
setPayload({
...payload,
cards: { ...payload.cards, [res]: parseInt(e.target.value, 10) || 0 },
});
}}
/>
</div>
))}
<button className="button" onClick={() => onSubmit({ player: getUsername(), cards: payload.cards || {} })}>Discard</button>
</div>
);
}
return <button className="button" onClick={() => onSubmit(action.payload)}>Execute</button>;
}
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 <div className="container">Loading...</div>;
}
return (
<div className="container">
<div className="grid three">
<div className="panel-stack">
<div className="card">
<div className="section-title">Players</div>
<div className="panel-stack">
{state.game && Object.entries(state.game.players).map(([name, pdata]) => (
<div key={name} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span className="player-tag" style={{ background: pdata.color || '#333', color: 'white' }}>{name}</span>
<span className="small">VP {pdata.victory_points}</span>
<span className="small">{pdata.resources?.hidden !== undefined ? `Hidden ${pdata.resources.hidden}` : JSON.stringify(pdata.resources)}</span>
</div>
))}
</div>
</div>
<div className="card">
<div className="section-title">Actions</div>
{legalActions.length === 0 && <div className="small">Waiting for your turn</div>}
{legalActions.length > 0 && (
<div className="panel-stack">
<select
className="input"
value={selectedIdx}
onChange={(e) => setSelectedIdx(parseInt(e.target.value, 10))}
>
{legalActions.map((action, idx) => (
<option key={`${action.type}-${idx}`} value={idx}>{action.type}</option>
))}
</select>
<ActionForm action={currentAction} onSubmit={submitAction} />
</div>
)}
</div>
<div className="card">
<div className="section-title">Trades</div>
<div className="panel-stack">
{state.pending_trades?.length === 0 && <div className="small">No offers.</div>}
{state.pending_trades?.map((offer) => (
<div key={offer.id} className="panel-stack">
<div className="small">{offer.from_player} {offer.to_player || 'any'} | {JSON.stringify(offer.offer)} for {JSON.stringify(offer.request)}</div>
{offer.to_player === getUsername() || offer.to_player === null ? (
<div style={{ display: 'flex', gap: 8 }}>
<button className="button" onClick={() => respondTrade(offer.id, true)}>Accept</button>
<button className="button ghost" onClick={() => respondTrade(offer.id, false)}>Decline</button>
</div>
) : null}
</div>
))}
<div className="panel-stack">
<input className="input" placeholder="Target player (optional)" value={trade.to_player} onChange={(e) => setTrade({ ...trade, to_player: e.target.value })} />
<input className="input" placeholder="Offer (e.g. brick:1,lumber:1)" value={trade.offer} onChange={(e) => setTrade({ ...trade, offer: e.target.value })} />
<input className="input" placeholder="Request (e.g. grain:1)" value={trade.request} onChange={(e) => setTrade({ ...trade, request: e.target.value })} />
<button className="button secondary" onClick={submitTrade}>Propose Trade</button>
</div>
</div>
</div>
</div>
<div className="board-shell">
<Board board={state.board} game={state.game} />
</div>
<div className="panel-stack">
<div className="card">
<div className="section-title">Status</div>
<div className="panel-stack">
<div>Phase: {state.game?.phase || '-'}</div>
<div>Current: {state.game?.current_player || '-'}</div>
<div>Last roll: {state.game?.last_roll || '-'}</div>
<div>Winner: {state.game?.winner || '-'}</div>
</div>
</div>
<div className="card">
<div className="section-title">AI Debug</div>
<div className="small">
{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.'}
</div>
</div>
<div className="card">
<div className="section-title">History</div>
<div className="history">
{(state.history || []).slice().reverse().map((entry) => (
<div key={entry.idx} className="small">{entry.actor}: {entry.action.type}</div>
))}
</div>
</div>
</div>
</div>
</div>
);
}
function AnalyticsPage() {
const [stats, setStats] = useState(null);
useEffect(() => {
apiFetch('/api/stats').then(setStats).catch(console.error);
}, []);
return (
<div className="container">
<div className="card">
<div className="section-title">Global Stats</div>
{stats && (
<div className="panel-stack">
<div>Total games: {stats.total_games}</div>
<div>Finished: {stats.finished_games}</div>
<div>Average turns: {Number(stats.avg_turns || 0).toFixed(1)}</div>
</div>
)}
</div>
</div>
);
}
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 (
<div className="container">
<div className="card">
<div className="section-title">Replays</div>
<div className="panel-stack">
<label className="button secondary" htmlFor="replay-import">
Import Replay
</label>
<input
id="replay-import"
type="file"
accept="application/json"
style={{ display: 'none' }}
onChange={importReplay}
/>
</div>
<div className="panel-stack">
{replays.map((replay) => (
<div key={replay.id} className="card">
<div>{replay.players.join(', ')} | Winner: {replay.winner || '-'}</div>
<div className="small">Actions: {replay.total_actions}</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<button className="button" onClick={() => navigate(`/replays/${replay.id}`)}>View</button>
<button className="button ghost" onClick={() => exportReplay(replay.id)}>Export</button>
</div>
</div>
))}
{replays.length === 0 && <div className="small">No replays yet.</div>}
</div>
</div>
</div>
);
}
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 <div className="container">Loading replay</div>;
}
async function exportReplay() {
const data = await apiFetch(`/api/replays/${replayId}/export`);
downloadJson(data, `catan-replay-${replayId}.json`);
}
return (
<div className="container">
<div className="grid two">
<div className="card">
<div className="section-title">Replay {detail.id}</div>
<div className="panel-stack">
<div>Players: {detail.players.join(', ')}</div>
<div>Winner: {detail.winner || '-'}</div>
<div>Actions: {detail.total_actions}</div>
<button className="button secondary" onClick={exportReplay}>Export JSON</button>
<input
className="input"
type="range"
min="0"
max={detail.total_actions}
value={step}
onChange={(e) => setStep(parseInt(e.target.value, 10))}
/>
<div className="small">Step {step}</div>
</div>
</div>
<div className="board-shell">
<Board board={state?.board} game={state?.game} />
</div>
</div>
</div>
);
}
export default function App() {
const [user, setUser] = useState(getUsername());
const hasToken = getToken();
useEffect(() => {
if (!hasToken) {
setUser(null);
}
}, [hasToken]);
return (
<div className="app">
<Header user={user} />
<Routes>
<Route path="/login" element={<LoginPage onAuth={setUser} />} />
<Route path="/" element={user ? <LobbyPage /> : <LoginPage onAuth={setUser} />} />
<Route path="/game/:gameId" element={<GamePage />} />
<Route path="/analytics" element={<AnalyticsPage />} />
<Route path="/replays" element={<ReplaysPage />} />
<Route path="/replays/:replayId" element={<ReplayViewer />} />
</Routes>
</div>
);
}

37
web/src/api.js Normal file
View File

@@ -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}`;
}

13
web/src/main.jsx Normal file
View File

@@ -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(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>
);

180
web/src/styles.css Normal file
View File

@@ -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;
}
}

16
web/vite.config.js Normal file
View File

@@ -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,
},
},
},
});