Add microservices, web UI, and replay tooling
Some checks failed
ci / tests (push) Has been cancelled
Some checks failed
ci / tests (push) Has been cancelled
This commit is contained in:
13
.env.example
Normal file
13
.env.example
Normal 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
27
.gitea/workflows/ci.yml
Normal 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
13
.gitignore
vendored
Normal 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
20
Dockerfile.web
Normal 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
140
README.md
Normal 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
14
catan/__init__.py
Normal 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
320
catan/board.py
Normal 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
349
catan/cli.py
Normal 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
125
catan/data.py
Normal 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
618
catan/game.py
Normal 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
313
catan/gui.py
Normal 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
116
catan/learning.py
Normal 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
24
catan/ml/__init__.py
Normal 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
138
catan/ml/agents.py
Normal 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
84
catan/ml/encoding.py
Normal 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
26
catan/ml/policies.py
Normal 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
300
catan/ml/selfplay.py
Normal 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
158
catan/ml/trainers.py
Normal 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
93
catan/player.py
Normal 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
329
catan/sdk.py
Normal 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
1
catan/web/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Web server package for Catan."""
|
||||
648
catan/web/app.py
Normal file
648
catan/web/app.py
Normal 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
734
catan/web/static/app.js
Normal 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();
|
||||
}
|
||||
})();
|
||||
16
catan/web/static/index.html
Normal file
16
catan/web/static/index.html
Normal 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
190
catan/web/static/styles.css
Normal 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
72
docker-compose.dev.yml
Normal 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
82
docker-compose.yml
Normal 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
20
docker/ai.Dockerfile
Normal 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"]
|
||||
19
docker/analytics.Dockerfile
Normal file
19
docker/analytics.Dockerfile
Normal 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
19
docker/api.Dockerfile
Normal 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
19
docker/game.Dockerfile
Normal 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
12
docker/web.Dockerfile
Normal 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
61
docs/architecture.md
Normal 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
32
docs/replay_format.md
Normal 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
40
pyproject.toml
Normal 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
0
services/ai/__init__.py
Normal file
185
services/ai/app.py
Normal file
185
services/ai/app.py
Normal 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()
|
||||
4
services/ai/requirements.txt
Normal file
4
services/ai/requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
fastapi>=0.115
|
||||
uvicorn[standard]>=0.30
|
||||
numpy>=1.26
|
||||
pydantic-settings>=2.2
|
||||
0
services/analytics/__init__.py
Normal file
0
services/analytics/__init__.py
Normal file
231
services/analytics/app.py
Normal file
231
services/analytics/app.py
Normal 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}
|
||||
5
services/analytics/requirements.txt
Normal file
5
services/analytics/requirements.txt
Normal 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
0
services/api/__init__.py
Normal file
360
services/api/app.py
Normal file
360
services/api/app.py
Normal 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
13
services/api/models.py
Normal 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))
|
||||
8
services/api/requirements.txt
Normal file
8
services/api/requirements.txt
Normal 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
|
||||
1
services/common/__init__.py
Normal file
1
services/common/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Shared utilities for Catan services."""
|
||||
47
services/common/auth.py
Normal file
47
services/common/auth.py
Normal file
@@ -0,0 +1,47 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
from typing import Optional
|
||||
|
||||
import jwt
|
||||
from passlib.context import CryptContext
|
||||
|
||||
from .settings import settings
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def verify_password(password: str, password_hash: str) -> bool:
|
||||
return pwd_context.verify(password, password_hash)
|
||||
|
||||
|
||||
def create_token(user_id: int, username: str) -> str:
|
||||
now = dt.datetime.now(dt.timezone.utc)
|
||||
payload = {
|
||||
"sub": str(user_id),
|
||||
"username": username,
|
||||
"exp": now + dt.timedelta(hours=settings.jwt_exp_hours),
|
||||
}
|
||||
return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
|
||||
|
||||
|
||||
def decode_token(token: str) -> dict:
|
||||
return jwt.decode(token, settings.jwt_secret, algorithms=[settings.jwt_algorithm])
|
||||
|
||||
|
||||
def get_token_subject(token: str) -> Optional[int]:
|
||||
try:
|
||||
payload = decode_token(token)
|
||||
except jwt.PyJWTError:
|
||||
return None
|
||||
sub = payload.get("sub")
|
||||
if not sub:
|
||||
return None
|
||||
try:
|
||||
return int(sub)
|
||||
except ValueError:
|
||||
return None
|
||||
24
services/common/db.py
Normal file
24
services/common/db.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from .settings import settings
|
||||
|
||||
engine = create_engine(settings.database_url, pool_pre_ping=True)
|
||||
SessionLocal = sessionmaker(bind=engine, class_=Session, expire_on_commit=False)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def session_scope() -> Session:
|
||||
session = SessionLocal()
|
||||
try:
|
||||
yield session
|
||||
session.commit()
|
||||
except Exception:
|
||||
session.rollback()
|
||||
raise
|
||||
finally:
|
||||
session.close()
|
||||
135
services/common/schemas.py
Normal file
135
services/common/schemas.py
Normal file
@@ -0,0 +1,135 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ActionSchema(BaseModel):
|
||||
type: str
|
||||
payload: Dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class ActionLogSchema(BaseModel):
|
||||
idx: int
|
||||
ts: dt.datetime
|
||||
actor: str
|
||||
action: ActionSchema
|
||||
applied: bool = True
|
||||
meta: Dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class TradeOfferSchema(BaseModel):
|
||||
id: str
|
||||
from_player: str
|
||||
to_player: Optional[str] = None
|
||||
offer: Dict[str, int]
|
||||
request: Dict[str, int]
|
||||
status: str
|
||||
created_at: dt.datetime
|
||||
|
||||
|
||||
class GameSlotSchema(BaseModel):
|
||||
slot_id: int
|
||||
name: Optional[str] = None
|
||||
user_id: Optional[int] = None
|
||||
is_ai: bool = False
|
||||
ai_kind: Optional[str] = None
|
||||
ai_model: Optional[str] = None
|
||||
ready: bool = False
|
||||
color: Optional[str] = None
|
||||
|
||||
|
||||
class GameSummarySchema(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
status: str
|
||||
max_players: int
|
||||
created_by: str
|
||||
created_at: dt.datetime
|
||||
players: List[GameSlotSchema]
|
||||
|
||||
|
||||
class GameStateSchema(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
status: str
|
||||
max_players: int
|
||||
created_by: str
|
||||
created_at: dt.datetime
|
||||
players: List[GameSlotSchema]
|
||||
game: Optional[Dict[str, Any]] = None
|
||||
board: Optional[Dict[str, Any]] = None
|
||||
legal_actions: List[ActionSchema] = Field(default_factory=list)
|
||||
pending_trades: List[TradeOfferSchema] = Field(default_factory=list)
|
||||
history: List[ActionLogSchema] = Field(default_factory=list)
|
||||
|
||||
|
||||
class CreateGameRequest(BaseModel):
|
||||
name: str
|
||||
max_players: int
|
||||
created_by: Optional[str] = None
|
||||
|
||||
|
||||
class JoinGameRequest(BaseModel):
|
||||
username: str
|
||||
user_id: int
|
||||
|
||||
|
||||
class AddAIRequest(BaseModel):
|
||||
ai_type: str
|
||||
model_name: Optional[str] = None
|
||||
|
||||
|
||||
class TradeOfferRequest(BaseModel):
|
||||
from_player: str
|
||||
to_player: Optional[str] = None
|
||||
offer: Dict[str, int]
|
||||
request: Dict[str, int]
|
||||
|
||||
|
||||
class TradeRespondRequest(BaseModel):
|
||||
player: str
|
||||
accept: bool
|
||||
|
||||
|
||||
class ActionRequest(BaseModel):
|
||||
actor: str
|
||||
action: ActionSchema
|
||||
|
||||
|
||||
class AIRequest(BaseModel):
|
||||
observation: Dict[str, Any]
|
||||
legal_actions: List[ActionSchema]
|
||||
agent: Dict[str, Any]
|
||||
debug: bool = False
|
||||
|
||||
|
||||
class AIResponse(BaseModel):
|
||||
action: ActionSchema
|
||||
debug: Dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class ReplayMeta(BaseModel):
|
||||
id: str
|
||||
created_at: dt.datetime
|
||||
players: List[str]
|
||||
winner: Optional[str] = None
|
||||
total_actions: int
|
||||
|
||||
|
||||
class ReplayDetail(ReplayMeta):
|
||||
seed: int
|
||||
actions: List[ActionLogSchema]
|
||||
|
||||
|
||||
class ReplayArchive(BaseModel):
|
||||
version: int = 1
|
||||
id: str
|
||||
created_at: dt.datetime
|
||||
seed: int
|
||||
players: List[str]
|
||||
winner: Optional[str] = None
|
||||
slots: List[GameSlotSchema]
|
||||
actions: List[ActionLogSchema]
|
||||
29
services/common/settings.py
Normal file
29
services/common/settings.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_prefix="CATAN_", env_file=".env", extra="ignore")
|
||||
|
||||
env: str = "dev"
|
||||
debug: bool = False
|
||||
|
||||
database_url: str = "postgresql+psycopg://catan:catan@db:5432/catan"
|
||||
|
||||
jwt_secret: str = "change-me"
|
||||
jwt_algorithm: str = "HS256"
|
||||
jwt_exp_hours: int = 24 * 7
|
||||
|
||||
api_service_url: str = "http://api:8000"
|
||||
game_service_url: str = "http://game:8001"
|
||||
ai_service_url: str = "http://ai:8002"
|
||||
analytics_service_url: str = "http://analytics:8003"
|
||||
|
||||
models_dir: str = "/models"
|
||||
replay_dir: str = "/replays"
|
||||
|
||||
cors_origins: str = "*"
|
||||
|
||||
|
||||
settings = Settings()
|
||||
0
services/game/__init__.py
Normal file
0
services/game/__init__.py
Normal file
478
services/game/app.py
Normal file
478
services/game/app.py
Normal 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
44
services/game/models.py
Normal 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
|
||||
6
services/game/requirements.txt
Normal file
6
services/game/requirements.txt
Normal 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
177
services/game/runtime.py
Normal 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
1
tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Test utilities package for Catan project."""
|
||||
39
tests/conftest.py
Normal file
39
tests/conftest.py
Normal 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)
|
||||
72
tests/e2e/test_api_flow.py
Normal file
72
tests/e2e/test_api_flow.py
Normal 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
52
tests/test_cli.py
Normal 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
34
tests/test_cli_bot.py
Normal 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
80
tests/test_env_actions.py
Normal 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
|
||||
)
|
||||
24
tests/unit/test_api_auth.py
Normal file
24
tests/unit/test_api_auth.py
Normal 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
17
tests/unit/test_auth.py
Normal 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
38
tests/utils.py
Normal 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
765
uv.lock
generated
Normal 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
15
web/index.html
Normal 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
20
web/package.json
Normal 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
807
web/src/App.jsx
Normal 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
37
web/src/api.js
Normal 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
13
web/src/main.jsx
Normal 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
180
web/src/styles.css
Normal 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
16
web/vite.config.js
Normal 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user