로스트 시티 클래식 코어 이식

맥락:
- 새 레포의 첫 범위를 RL 없는 Lost Cities classic 게임 구현으로 잡았다.
- 기존 tier0-3 실험 축은 제거하고 classic 5-expedition 룰을 기본값으로 둔다.

변경:
- games/classic 아래에 Cython 게임 엔진, env, bots, backend 경계, Rust core와 proto schema를 이식했다.
- setuptools/Cython 빌드 설정과 package data, README, classic port notes를 추가했다.
- 룰, 점수, 마스크, env, canonical state, bot, Rust parity 테스트를 새 경로로 가져왔다.

확인:
- uv run pytest tests/games/classic
- uv run lost-cities-classic
This commit is contained in:
2026-05-06 19:07:46 +09:00
commit 7df6e42904
46 changed files with 7531 additions and 0 deletions
+17
View File
@@ -0,0 +1,17 @@
# Python-generated files
__pycache__/
*.py[oc]
*.so
build/
dist/
wheels/
*.egg-info
# Cython-generated sources
src/coolrl_lost_cities/games/classic/game.c
# Rust build output
target/
# Virtual environments
.venv
+1
View File
@@ -0,0 +1 @@
3.11
+24
View File
@@ -0,0 +1,24 @@
# coolrl-lost-cities
Focused Lost Cities extraction from the legacy `coolrl` repository.
The current implementation starts with the classic two-player card game:
- classic 5-expedition rules by default
- Python/Cython game engine
- Rust core parity checks
- env wrapper
- random, passive-discard, and safe-heuristic bots
- core rule, scoring, mask, env, canonical-state, bot, and Rust parity tests
Training code, Deep CFR, learned-policy evaluation, GUI, and web client are
intentionally outside the first port.
## Development
```bash
uv run pytest tests/games/classic
uv run lost-cities-classic
```
See [classic port notes](docs/classic-port-notes.md) for the current direction.
+82
View File
@@ -0,0 +1,82 @@
# Classic Port Notes
This repository starts as a focused extraction of the Lost Cities game from the
legacy `coolrl` repository. The first target is the classic two-player card
game, without the earlier training-oriented tiers.
## Current Direction
- Implement the classic Lost Cities rules first.
- Treat classic as the initial concrete game under `coolrl_lost_cities.games`.
- Do not carry over `tier0` through `tier3`; those were useful for experiments,
but they should not shape the first public game API.
- Keep backend selection available. The Python/Cython and Rust implementations
should remain swappable behind a small backend boundary.
- Keep the GUI and Rust implementation in scope for the port.
- Keep RL and training code out of the first extraction.
The expected package shape is roughly:
```text
src/coolrl_lost_cities/
games/
classic/
game.pyx
env.py
interfaces.py
backends/
bots/
pygame_pvp.py
fixtures/
assets/
docs/
rust_core/
```
Tests should live outside the package, roughly under:
```text
tests/games/classic/
```
## Out Of Scope For The First Port
- Deep CFR
- General training infrastructure
- Evaluation loops for learned policies
- Web client
- Legacy experiment configs, checkpoints, logs, exports, and analysis artifacts
Bot-vs-bot helpers can stay with the classic game if they are useful for smoke
tests and local play. Broader policy evaluation can be introduced later with the
training layer.
## Later Training Shape
If training is added later, it should not make Deep CFR the center of the
package. Evaluation and policy interfaces should be general enough for multiple
approaches, with Deep CFR as one implementation.
A possible future shape:
```text
src/coolrl_lost_cities/
games/
classic/
training/
policies.py
evaluation.py
deep_cfr/
imitation/
policy_gradient/
```
The game package should expose rules, state transitions, legal actions, scoring,
backend selection, and playable UI. Training code can adapt those pieces later.
## Naming Notes
For now, use `classic` for the five-expedition game. Other variants, such as a
six-expedition version, can be added later if needed. The current port should
avoid adding a variant registry or broad abstraction before there is a second
concrete game to support.
+42
View File
@@ -0,0 +1,42 @@
[project]
name = "coolrl-lost-cities"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
authors = [
{ name = "정시원", email = "sebastianrcnt@gmail.com" }
]
requires-python = ">=3.11"
dependencies = [
"numpy>=1.26.0",
]
[project.scripts]
lost-cities-classic = "coolrl_lost_cities.games.classic:main"
[dependency-groups]
dev = [
"cython>=3.0",
"pytest>=9.0.3",
]
[build-system]
requires = ["setuptools>=69", "wheel", "Cython>=3.0", "numpy>=1.26.0"]
build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find]
where = ["src"]
include = ["coolrl_lost_cities*"]
[tool.setuptools.package-data]
"coolrl_lost_cities.games.classic" = [
"assets/*.json",
"fixtures/*.json",
"docs/*.md",
"schemas/*.proto",
"rust_core/Cargo.lock",
"rust_core/Cargo.toml",
"rust_core/build.rs",
"rust_core/src/*.rs",
"rust_core/src/bin/*.rs",
]
+30
View File
@@ -0,0 +1,30 @@
from __future__ import annotations
from setuptools import Extension, setup
try:
from Cython.Build import cythonize
except ImportError: # pragma: no cover
cythonize = None
extensions: list[Extension] = []
if cythonize is not None:
extensions = cythonize(
[
Extension(
"coolrl_lost_cities.games.classic.game",
["src/coolrl_lost_cities/games/classic/game.pyx"],
)
],
language_level=3,
compiler_directives={
"boundscheck": False,
"wraparound": False,
"cdivision": True,
"initializedcheck": False,
},
)
setup(ext_modules=extensions)
+3
View File
@@ -0,0 +1,3 @@
from __future__ import annotations
__all__: list[str] = []
@@ -0,0 +1,17 @@
from __future__ import annotations
from .game import Card, GameState, IllegalMoveError, LostCitiesConfig, classic_config
__all__ = [
"Card",
"GameState",
"IllegalMoveError",
"LostCitiesConfig",
"classic_config",
]
def main() -> None:
config = classic_config()
state = GameState.new_game(config)
print(f"Lost Cities classic: {config.deck_size} cards, player {state.current_player} to act")
@@ -0,0 +1,60 @@
{
"defaults": {
"font": {
"name": "b612",
"size": "16"
},
"colours": {
"normal_bg": "#050505",
"hovered_bg": "#101010",
"disabled_bg": "#050505",
"selected_bg": "#151515",
"active_bg": "#151515",
"dark_bg": "#000000",
"disabled_dark_bg": "#000000",
"normal_text": "#dddddd",
"hovered_text": "#ffffff",
"disabled_text": "#777777",
"selected_text": "#ffffff",
"active_text": "#ffffff",
"normal_text_shadow": "#00000000",
"hovered_text_shadow": "#00000000",
"disabled_text_shadow": "#00000000",
"selected_text_shadow": "#00000000",
"active_text_shadow": "#00000000",
"normal_border": "#3a3a40",
"hovered_border": "#d6d6dc",
"disabled_border": "#252529",
"selected_border": "#d6d6dc",
"active_border": "#d6d6dc",
"text_shadow": "#00000000"
}
},
"#pvp_control": {
"misc": {
"shape": "rectangle",
"border_width": "1",
"shadow_width": "0",
"shape_corner_radius": "0",
"text_horiz_alignment_padding": "12"
}
},
"button": {
"prototype": "#pvp_control"
},
"drop_down_menu": {
"prototype": "#pvp_control",
"misc": {
"expand_direction": "down"
}
},
"drop_down_menu.button": {
"prototype": "#pvp_control"
},
"drop_down_menu.selection_list.button": {
"prototype": "#pvp_control"
},
"drop_down_menu.#expand_button": {
"prototype": "#pvp_control"
}
}
@@ -0,0 +1,11 @@
from __future__ import annotations
from .factory import build_lost_cities_backend
from .python import PythonLostCitiesBackend
from .rust import RustLostCitiesBackend
__all__ = [
"PythonLostCitiesBackend",
"RustLostCitiesBackend",
"build_lost_cities_backend",
]
@@ -0,0 +1,63 @@
from __future__ import annotations
from typing import Any
from ..game import Card, GameState, LostCitiesConfig
from ..interfaces import Snapshot
def snapshot_summary(snapshot: Snapshot) -> str:
scores = [snapshot.total_score(0), snapshot.total_score(1)]
hand_sizes = [len(hand) for hand in snapshot.hands]
discard_sizes = [len(discard) for discard in snapshot.discards]
phase = "카드" if snapshot.phase == "card" else "뽑기"
return (
f"플레이어={snapshot.current_player} 단계={phase} "
f"턴={snapshot.turn_count} 종료={snapshot.terminal} "
f"덱={len(snapshot.deck)} 손패수={hand_sizes} 점수={scores} "
f"직전버린색={snapshot.pending_discarded_color} "
f"버린더미수={discard_sizes}"
)
def snapshot_from_state(state: GameState) -> Snapshot:
return Snapshot(
config=state.config,
deck=list(state.deck),
hands=[list(hand) for hand in state.hands],
expeditions=[
[list(expedition) for expedition in player_expeditions]
for player_expeditions in state.expeditions
],
discards=[list(discard) for discard in state.discards],
current_player=state.current_player,
phase=state.phase,
pending_discarded_color=state.pending_discarded_color,
turn_count=state.turn_count,
terminal=state.terminal,
legal_mask=state.unified_legal_mask(),
)
def snapshot_from_trace(config_data: dict[str, Any], step: dict[str, Any]) -> Snapshot:
config = LostCitiesConfig(**config_data)
return Snapshot(
config=config,
deck=cards_from_json(step["deck"]),
hands=[cards_from_json(hand) for hand in step["hands"]],
expeditions=[
[cards_from_json(expedition) for expedition in player_expeditions]
for player_expeditions in step["expeditions"]
],
discards=[cards_from_json(discard) for discard in step["discards"]],
current_player=int(step["current_player"]),
phase=str(step["phase"]),
pending_discarded_color=step.get("pending_discarded_color"),
turn_count=int(step["turn_count"]),
terminal=bool(step["terminal"]),
legal_mask=list(step["legal_mask"]),
)
def cards_from_json(cards: list[dict[str, int]]) -> list[Card]:
return [Card.from_snapshot(card) for card in cards]
@@ -0,0 +1,18 @@
from __future__ import annotations
from ..game import LostCitiesConfig
from ..interfaces import BackendName, LostCitiesBackend
from .python import PythonLostCitiesBackend
from .rust import RustLostCitiesBackend
def build_lost_cities_backend(
backend: BackendName,
config: LostCitiesConfig,
seed: int | None,
) -> LostCitiesBackend:
if backend == "python":
return PythonLostCitiesBackend(config, seed)
if backend == "rust":
return RustLostCitiesBackend(config, seed)
raise ValueError(f"unknown backend: {backend}")
@@ -0,0 +1,52 @@
from __future__ import annotations
import logging
from ..game import GameState, LostCitiesConfig
from ..interfaces import BackendName, Snapshot
from .common import snapshot_from_state, snapshot_summary
LOGGER = logging.getLogger("coolrl_lost_cities.games.classic.backends.python")
class PythonLostCitiesBackend:
name: BackendName = "python"
def __init__(self, config: LostCitiesConfig, seed: int | None):
self.config = config
self.seed = seed
self.state = GameState.new_game(config, seed=seed)
self.history: list[GameState] = []
LOGGER.debug("파이썬 백엔드 초기화: %s", snapshot_summary(self.snapshot()))
def snapshot(self) -> Snapshot:
return snapshot_from_state(self.state)
def apply(self, action_id: int) -> None:
before = self.snapshot()
self.history.append(self.state.clone())
self.state.apply_unified_action(action_id)
LOGGER.debug(
"파이썬 액션 적용: 액션=%s 이전={%s} 이후={%s} 되돌리기깊이=%s",
action_id,
snapshot_summary(before),
snapshot_summary(self.snapshot()),
len(self.history),
)
def can_undo(self) -> bool:
return bool(self.history)
def undo(self) -> bool:
if not self.history:
LOGGER.debug("파이썬 되돌리기 무시: 기록이 비어 있음")
return False
before = self.snapshot()
self.state = self.history.pop()
LOGGER.debug(
"파이썬 되돌리기: 이전={%s} 이후={%s} 되돌리기깊이=%s",
snapshot_summary(before),
snapshot_summary(self.snapshot()),
len(self.history),
)
return True
@@ -0,0 +1,108 @@
from __future__ import annotations
import json
import logging
from pathlib import Path
import random
import subprocess
import tempfile
from ..game import Card, LostCitiesConfig, build_deck
from ..interfaces import BackendName, Snapshot
from .common import snapshot_from_trace, snapshot_summary
LOGGER = logging.getLogger("coolrl_lost_cities.games.classic.backends.rust")
class RustLostCitiesBackend:
name: BackendName = "rust"
def __init__(self, config: LostCitiesConfig, seed: int | None):
self.config = config
self.seed = seed
self.initial_deck = _shuffled_deck(config, seed)
self.actions: list[int] = []
self._snapshot = self._run_trace()
LOGGER.debug("러스트 백엔드 초기화: %s", snapshot_summary(self.snapshot()))
def snapshot(self) -> Snapshot:
return self._snapshot
def apply(self, action_id: int) -> None:
before = self.snapshot()
self.actions.append(action_id)
try:
self._snapshot = self._run_trace()
except Exception:
self.actions.pop()
raise
LOGGER.debug(
"러스트 액션 적용: 액션=%s 이전={%s} 이후={%s} 되돌리기깊이=%s",
action_id,
snapshot_summary(before),
snapshot_summary(self.snapshot()),
len(self.actions),
)
def can_undo(self) -> bool:
return bool(self.actions)
def undo(self) -> bool:
if not self.actions:
LOGGER.debug("러스트 되돌리기 무시: 액션 기록이 비어 있음")
return False
before = self.snapshot()
removed = self.actions.pop()
self._snapshot = self._run_trace()
LOGGER.debug(
"러스트 되돌리기: 제거한액션=%s 이전={%s} 이후={%s} 되돌리기깊이=%s",
removed,
snapshot_summary(before),
snapshot_summary(self.snapshot()),
len(self.actions),
)
return True
def _run_trace(self) -> Snapshot:
fixture = {
"config": self.config.to_snapshot(),
"initial_deck": [card.to_snapshot() for card in self.initial_deck],
"steps": [{"action": None}]
+ [{"action": action} for action in self.actions],
}
rust_core = Path(__file__).resolve().parents[1] / "rust_core"
with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as handle:
json.dump(fixture, handle)
fixture_path = Path(handle.name)
try:
result = subprocess.run(
[
"cargo",
"run",
"--quiet",
"--bin",
"lost_cities_probe",
"--",
"trace",
str(fixture_path),
],
cwd=rust_core,
check=True,
text=True,
capture_output=True,
)
except subprocess.CalledProcessError as exc:
message = exc.stderr.strip() or exc.stdout.strip() or str(exc)
raise RuntimeError(f"rust backend failed: {message}") from exc
finally:
fixture_path.unlink(missing_ok=True)
trace = json.loads(result.stdout)
return snapshot_from_trace(trace["config"], trace["steps"][-1])
def _shuffled_deck(config: LostCitiesConfig, seed: int | None) -> list[Card]:
deck = build_deck(config)
rng = random.Random(config.seed if seed is None else seed)
rng.shuffle(deck)
return deck
@@ -0,0 +1,21 @@
from __future__ import annotations
from ..interfaces import BotInput, LostCitiesBot
from .heuristic import SafeHeuristicBot
from .passive import PassiveDiscardBot
from .play import play_game, run_series
from .random import RandomBot
from .registry import DEFAULT_BOT, available_bot_names, build_bot
__all__ = [
"BotInput",
"DEFAULT_BOT",
"LostCitiesBot",
"PassiveDiscardBot",
"RandomBot",
"SafeHeuristicBot",
"available_bot_names",
"build_bot",
"play_game",
"run_series",
]
@@ -0,0 +1,24 @@
from __future__ import annotations
from ..game import GameState
from ..interfaces import BotInput, Snapshot
try:
import numpy as np
except ImportError as exc: # pragma: no cover
raise RuntimeError("numpy is required for Lost Cities bots") from exc
def legal_from_obs(obs_or_state: BotInput) -> np.ndarray:
if isinstance(obs_or_state, GameState):
return np.asarray(obs_or_state.legal_mask(), dtype=bool)
if isinstance(obs_or_state, Snapshot):
return np.asarray(obs_or_state.legal_mask, dtype=bool)
return np.asarray(obs_or_state["legal_mask"], dtype=bool)
def first_legal(legal: list[bool] | np.ndarray) -> int:
legal_indices = np.nonzero(np.asarray(legal, dtype=bool))[0]
if len(legal_indices) == 0:
raise RuntimeError("no legal action available")
return int(legal_indices[0])
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,54 @@
from __future__ import annotations
from ..game import GameState
from ..interfaces import BotInput, Snapshot
from .base import first_legal, legal_from_obs
class PassiveDiscardBot:
"""Baseline that avoids opening expeditions whenever discarding is legal."""
def act(self, obs_or_state: BotInput) -> int:
if isinstance(obs_or_state, GameState):
return self._act_phase_local(
obs_or_state.phase,
obs_or_state.legal_mask(),
obs_or_state.card_action_size,
)
if isinstance(obs_or_state, Snapshot):
return self._act_unified(
obs_or_state.phase,
obs_or_state.legal_mask,
obs_or_state.card_action_size,
)
legal = legal_from_obs(obs_or_state)
return first_legal(legal)
def _act_phase_local(
self,
phase: str,
legal: list[bool],
card_action_size: int,
) -> int:
if phase == "card":
for action in range(1, min(card_action_size, len(legal)), 2):
if legal[action]:
return action
if phase == "draw" and legal and legal[0]:
return 0
return first_legal(legal)
def _act_unified(
self,
phase: str,
legal: list[bool],
card_action_size: int,
) -> int:
if phase == "card":
for action in range(1, min(card_action_size, len(legal)), 2):
if legal[action]:
return action
deck_action = card_action_size
if phase == "draw" and deck_action < len(legal) and legal[deck_action]:
return deck_action
return first_legal(legal)
@@ -0,0 +1,61 @@
from __future__ import annotations
from dataclasses import replace
from ..game import GameState, LostCitiesConfig
from ..interfaces import LostCitiesBot
try:
import numpy as np
except ImportError as exc: # pragma: no cover
raise RuntimeError("numpy is required for Lost Cities bots") from exc
def play_game(
bot0: LostCitiesBot,
bot1: LostCitiesBot,
config: LostCitiesConfig,
*,
seed: int | None = None,
max_steps: int = 10_000,
) -> GameState:
game_config = replace(config, seed=seed) if seed is not None else config
state = GameState.new_game(game_config)
bots = [bot0, bot1]
for _ in range(max_steps):
if state.terminal:
return state
action = bots[state.current_player].act(state)
state.apply_action(action)
raise RuntimeError(f"game exceeded max_steps={max_steps}")
def run_series(
bot0: LostCitiesBot,
bot1: LostCitiesBot,
config: LostCitiesConfig,
*,
games: int = 100,
seed: int = 0,
) -> dict:
diffs: list[int] = []
wins0 = 0
wins1 = 0
draws = 0
for index in range(games):
state = play_game(bot0, bot1, config, seed=seed + index)
diff = state.score_diff(0)
diffs.append(diff)
if diff > 0:
wins0 += 1
elif diff < 0:
wins1 += 1
else:
draws += 1
return {
"games": games,
"avg_diff": float(np.mean(diffs)) if diffs else 0.0,
"wins0": wins0,
"wins1": wins1,
"draws": draws,
}
@@ -0,0 +1,21 @@
from __future__ import annotations
from ..interfaces import BotInput, LostCitiesBot
from .base import legal_from_obs
try:
import numpy as np
except ImportError as exc: # pragma: no cover
raise RuntimeError("numpy is required for Lost Cities bots") from exc
class RandomBot(LostCitiesBot):
def __init__(self, seed: int | None = None):
self.rng = np.random.default_rng(seed)
def act(self, obs_or_state: BotInput) -> int:
legal = legal_from_obs(obs_or_state)
legal_indices = np.nonzero(legal)[0]
if len(legal_indices) == 0:
raise RuntimeError("no legal action available")
return int(self.rng.choice(legal_indices))
@@ -0,0 +1,30 @@
from __future__ import annotations
from collections.abc import Callable
from ..interfaces import LostCitiesBot
from .heuristic import SafeHeuristicBot
from .passive import PassiveDiscardBot
from .random import RandomBot
BotName = str
DEFAULT_BOT: BotName = "random"
BotFactory = Callable[[int | None], LostCitiesBot]
BOT_REGISTRY: dict[BotName, BotFactory] = {
DEFAULT_BOT: RandomBot,
"passive-discard": lambda seed: PassiveDiscardBot(),
"safe-heuristic": lambda seed: SafeHeuristicBot(),
}
def available_bot_names() -> list[BotName]:
return sorted(BOT_REGISTRY)
def build_bot(name: BotName, *, seed: int | None = None) -> LostCitiesBot:
try:
bot_factory = BOT_REGISTRY[name]
except KeyError as exc:
raise ValueError(f"unknown Lost Cities bot: {name}") from exc
return bot_factory(seed)
@@ -0,0 +1,202 @@
# Lost Cities — 게임 핵심 로직 명세서
본 문서는 외부 에이전트가 Lost Cities를 플레이하기 위해 알아야 할 모든 규칙과 상태 전이를 기술한다. 구현(Rust core)과 인터페이스(gRPC proto)는 이 명세를 준수해야 한다.
---
## 1. 개요
Lost Cities는 2인 불완전정보 카드게임이다. 각 플레이어는 자신의 손패로 색깔별 탐험대(expedition)를 꾸리고, 게임 종료 시점의 총점으로 승부를 가린다.
- 플레이어 수: 정확히 2 (인덱스 0과 1)
- 상태 종류: 완전히 관찰 가능(공개) + 부분 관찰 가능(자기 손패만)
- 결정성: config의 seed가 주어지면 초기 덱 셔플이 결정적
---
## 2. 구성 요소
### 2.1 Config
게임은 다음 파라미터로 완전히 기술된다.
| 필드 | 의미 | 기본값 예시 |
|---|---|---|
| `n_colors` | 색깔(탐험대) 수 | 5 |
| `n_ranks` | 색깔당 숫자 카드 종류 수 | 9 |
| `min_rank` | 숫자 카드의 최소 face value | 2 |
| `n_handshakes` | 색깔당 handshake 카드 수 | 3 |
| `hand_size` | 각 플레이어의 손패 크기 | 8 |
| `expedition_penalty` | 탐험대 시작 비용 (음수) | -20 |
| `bonus_threshold` | 보너스 자격 카드 수 | 8 |
| `bonus_amount` | 보너스 점수 | 20 |
| `seed` | 덱 셔플 시드 (선택) | — |
제약: 모든 수치는 양수여야 하며, `deck_size >= 2 * hand_size`여야 한다.
### 2.2 Card
각 카드는 `(color, rank)` 쌍으로 식별된다.
- `color`: 0부터 `n_colors - 1`까지의 정수
- `rank`:
- `0`: handshake (투자) 카드
- `1`부터 `n_ranks`까지: 숫자 카드
- 숫자 카드의 face value: `min_rank + rank - 1`
- 예: `min_rank=2, rank=1` 이면 face value는 2
- 예: `min_rank=2, n_ranks=9` 이면 face value 범위는 2부터 10
- handshake의 face value는 정의상 0 (점수 계산에만 사용)
### 2.3 Deck
덱 구성: 각 색깔마다 `n_handshakes`장의 handshake와 rank 1부터 `n_ranks`까지 숫자 카드 각 1장씩.
총 덱 크기: `n_colors * (n_handshakes + n_ranks)`
---
## 3. 초기화
1. 덱을 config의 seed로 셔플한다.
2. 각 플레이어에게 `hand_size`만큼 카드를 딜한다. 딜 순서는 플레이어를 번갈아가며 1장씩.
3. 각 플레이어의 손패는 항상 정렬 상태로 유지된다 (key: color 오름차순, 같은 color 내에서 rank 오름차순).
4. 각 플레이어의 expedition은 색깔마다 빈 stack으로 초기화된다.
5. 각 색깔의 discard 더미는 빈 상태로 시작한다.
6. `current_player = 0`, `phase = CARD`, `pending_discarded_color = None`, `turn_count = 0`, `terminal = False`.
---
## 4. 턴 구조
한 턴은 정확히 두 phase로 구성된다.
1. **CARD phase** — 손패에서 카드 한 장을 선택해 플레이(play)하거나 버린다(discard).
2. **DRAW phase** — 덱 맨 위 또는 비어 있지 않은 discard 더미 중 하나에서 카드를 손패로 받는다.
DRAW phase가 끝나면 `current_player`가 교체되고 `phase = CARD`로 돌아가며 `turn_count`가 1 증가한다.
단, 아래 6장(종료 조건)에 해당하면 턴 교체 없이 게임이 종료된다.
---
## 5. 액션
### 5.1 CARD phase의 합법 액션
손패의 각 슬롯 `i`에 대해 다음 두 액션이 평가된다.
**PLAY_CARD(slot=i)**
- 대상 카드 `c = hand[i]`
- 해당 색깔의 expedition에서 "마지막 숫자 랭크"를 `last_numeric`이라 하자. expedition에 숫자 카드가 없으면 `last_numeric = 0`.
- `c`가 handshake이면: `last_numeric == 0`일 때만 합법
- `c`가 숫자 카드이면: `c.rank > last_numeric`일 때만 합법
- 합법이면 `c`를 해당 색깔 expedition stack에 push
**DISCARD_CARD(slot=i)**
- 항상 합법
- `c``c.color`의 discard 더미 맨 위에 push
- `pending_discarded_color = c.color`로 기록
CARD phase에서는 DISCARD가 항상 가능하므로 합법 액션이 최소 1개 존재한다.
### 5.2 DRAW phase의 합법 액션
**DRAW_DECK**
- `len(deck) > 0`일 때만 합법
- 덱 맨 위 카드를 손패에 추가한 뒤 손패를 재정렬
**DRAW_DISCARD(color=k)**
- `len(discards[k]) > 0`이고 `k != pending_discarded_color`일 때만 합법
- `discards[k]` 맨 위 카드를 손패에 추가한 뒤 손패를 재정렬
### 5.3 pending_discarded_color
- CARD phase에서 `DISCARD_CARD`로 색깔 `k`를 버렸다면, 같은 턴의 DRAW phase에서 `DRAW_DISCARD(k)`는 금지된다.
- CARD phase에서 `PLAY_CARD`를 했다면 `pending_discarded_color``None`.
- DRAW phase가 끝나면 `pending_discarded_color`는 다음 턴으로 넘어가기 전에 `None`으로 초기화된다.
---
## 6. 종료 조건
게임은 다음 중 하나가 성립하는 즉시 종료된다.
1. DRAW phase에서 카드를 받은 직후 `len(deck) == 0`이 되는 경우. 이 경우 턴 교체 없이 종료.
2. 드물게: DRAW phase에서 합법 액션이 전혀 없는 경우 (덱과 모든 discard가 동시에 사용 불가). 극단적 config에서만 발생.
종료 시 `terminal = True`가 되고 이후 어떤 액션도 적용할 수 없다.
---
## 7. 점수 계산
각 플레이어의 각 색깔 expedition에 대해:
- 카드가 하나도 없으면: 0점
- 카드가 있으면:
- `numeric_sum` = expedition 내 숫자 카드의 face value 합
- `handshakes` = expedition 내 handshake 카드 수
- `score = (numeric_sum + expedition_penalty) * (handshakes + 1)`
- `len(expedition) >= bonus_threshold`이면 `score += bonus_amount`
플레이어의 총점: 모든 색깔 expedition 점수의 합.
`score_diff(p) = total_score(p) - total_score(1 - p)`
---
## 8. 불변식
구현은 아래 불변식을 모든 상태 전이에서 유지해야 한다.
1. **카드 보존**: `len(deck) + Σ|hand[p]| + Σ|expedition[p][c]| + Σ|discard[c]|` 는 항상 `deck_size`와 같다.
2. **expedition monotonicity**: 각 expedition stack 내 숫자 카드는 bottom→top 방향으로 strictly increasing rank.
3. **handshake 위치**: expedition에 숫자 카드가 하나라도 들어가면 이후로 해당 expedition에 handshake를 더 추가할 수 없다.
4. **phase-scoped invariant**: `pending_discarded_color``phase == DRAW`에서만 의미를 가진다.
5. **terminal 단조성**: `terminal`은 한 번 `True`가 되면 `False`로 되돌아가지 않는다.
6. **legal action 존재**: `terminal == False`이면 합법 액션 집합이 비어 있지 않다. `terminal == True`이면 공집합이다.
7. **손패 정렬**: 손패는 항상 `(color, rank)` 오름차순 정렬 상태.
---
## 9. 외부 에이전트를 위한 계약
gRPC 인터페이스로 플레이하는 에이전트가 알아야 할 계약.
### 9.1 상태는 세션 단위로 서버가 유지
클라이언트는 `session_id`로 게임을 식별한다. 한 세션 = 한 게임.
### 9.2 observation은 항상 특정 플레이어 관점
`observer_player`가 보는 정보:
- 자신의 손패 전체
- 자신과 상대의 expedition 전체 (공개 정보)
- 모든 discard 더미 전체 (공개 정보)
- 상대 손패 크기 (내용은 비공개)
- 남은 덱 크기 (내용은 비공개)
### 9.3 action id는 observation scoped
`Action.id`는 그 observation과 함께 반환된 것에 한해서만 유효하다. 상태가 한 번이라도 진행되면 이전 observation의 id는 재사용 불가.
이를 강제하기 위해 모든 observation에는 `state_version`(세션 내 단조증가)이 실리고, `ApplyAction``expected_state_version`을 함께 받아 mismatch 시 거절한다.
### 9.4 합법 액션은 observation에 임베드되어 옴
별도 RPC 호출 없이 `observation.legal_actions` 안에서 전체 리스트와 마스크가 모두 제공된다. 외부 에이전트는 이 리스트에서 `id`만 골라 `ApplyAction`으로 되돌려주면 된다.
### 9.5 보상
`ApplyAction``reward`는 observer 관점이며, terminal 전이에서만 nonzero이고 값은 observer의 `score_diff`. 비terminal 전이의 reward는 0.
---
## 10. 결정성과 재현
동일한 `(config, seed, action_sequence)`는 동일한 상태 전이와 최종 점수를 재생성한다. 로그/리플레이는 이 튜플을 저장하는 것으로 충분하다.
@@ -0,0 +1,73 @@
from __future__ import annotations
from .game import GameState, IllegalMoveError, LostCitiesConfig
try:
import numpy as np
except ImportError as exc: # pragma: no cover
raise RuntimeError("numpy is required for LostCitiesEnv") from exc
class LostCitiesEnv:
def __init__(self, config: LostCitiesConfig | None = None):
self.config = config or LostCitiesConfig()
self.state: GameState | None = None
def reset(self) -> dict:
self.state = GameState.new_game(self.config)
return self._obs()
def step(self, action_id: int) -> tuple[dict, float, bool, dict]:
if self.state is None:
self.reset()
assert self.state is not None
actor = self.state.current_player
self.state.apply_unified_action(self._normalize_action_id(action_id))
done = self.state.terminal
reward = float(self.state.score_diff(actor)) if done else 0.0
return self._obs(), reward, done, {}
def legal_actions(self) -> np.ndarray:
if self.state is None:
self.reset()
assert self.state is not None
return np.asarray(self.state.unified_legal_mask(), dtype=bool)
@property
def current_player(self) -> int:
if self.state is None:
self.reset()
assert self.state is not None
return self.state.current_player
@property
def phase(self) -> str:
if self.state is None:
self.reset()
assert self.state is not None
return self.state.phase
def _obs(self) -> dict:
assert self.state is not None
return {
"spatial": np.zeros((0,), dtype=np.float32),
"scalar": np.zeros((0,), dtype=np.float32),
"legal_mask": np.asarray(self.state.unified_legal_mask(), dtype=bool),
"phase": 0 if self.state.phase == "card" else 1,
"player": self.state.current_player,
}
def _normalize_action_id(self, action_id: int) -> int:
assert self.state is not None
unified_mask = self.state.unified_legal_mask()
if 0 <= action_id < len(unified_mask) and unified_mask[action_id]:
return action_id
local_mask = self.state.legal_mask()
if 0 <= action_id < len(local_mask) and local_mask[action_id]:
return self.state.to_unified_action(action_id)
raise IllegalMoveError(
f"illegal action {action_id} in phase {self.state.phase} "
f"for player {self.state.current_player}"
)
@@ -0,0 +1,85 @@
{
"name": "canonical-small-deterministic",
"config": {
"n_colors": 2,
"n_ranks": 2,
"min_rank": 1,
"n_handshakes": 0,
"hand_size": 1,
"expedition_penalty": 0,
"bonus_threshold": 99,
"bonus_amount": 0,
"seed": null
},
"initial_deck": [
{"color": 0, "rank": 1},
{"color": 0, "rank": 2},
{"color": 1, "rank": 1},
{"color": 1, "rank": 2}
],
"steps": [
{
"action": null,
"phase": "card",
"current_player": 0,
"turn_count": 0,
"terminal": false,
"score_diff_player0": 0,
"legal_mask": [true, true, false, false, false]
},
{
"action": 1,
"phase": "draw",
"current_player": 0,
"turn_count": 0,
"terminal": false,
"score_diff_player0": 0,
"legal_mask": [false, false, true, false, false]
},
{
"action": 2,
"phase": "card",
"current_player": 1,
"turn_count": 1,
"terminal": false,
"score_diff_player0": 0,
"legal_mask": [true, true, false, false, false]
},
{
"action": 0,
"phase": "draw",
"current_player": 1,
"turn_count": 1,
"terminal": false,
"score_diff_player0": -1,
"legal_mask": [false, false, true, false, true]
},
{
"action": 4,
"phase": "card",
"current_player": 0,
"turn_count": 2,
"terminal": false,
"score_diff_player0": -1,
"legal_mask": [true, true, false, false, false]
},
{
"action": 0,
"phase": "draw",
"current_player": 0,
"turn_count": 2,
"terminal": false,
"score_diff_player0": 1,
"legal_mask": [false, false, true, false, false]
},
{
"action": 2,
"phase": "draw",
"current_player": 0,
"turn_count": 3,
"terminal": true,
"score_diff_player0": 1,
"legal_mask": [false, false, false, false, false]
}
]
}
@@ -0,0 +1,718 @@
# cython: language_level=3, boundscheck=False, wraparound=False, cdivision=True, initializedcheck=False
from collections import Counter
from dataclasses import dataclass, fields
import random
from typing import Any, Literal
import numpy as np
cimport cython
Phase = Literal["card", "draw"]
class IllegalMoveError(ValueError):
"""Raised when an action id is not legal for the current state."""
@cython.freelist(256)
cdef class Card:
cdef readonly int color
cdef readonly int rank
def __cinit__(self, color, rank):
self.color = int(color)
self.rank = int(rank)
@property
def is_handshake(self):
return self.rank == 0
cpdef int numeric_value(self, int min_rank):
if self.rank == 0:
return 0
return min_rank + self.rank - 1
def label(self, int min_rank):
if self.rank == 0:
return f"[{self.color}]H"
return f"[{self.color}]{self.numeric_value(min_rank)}"
def to_snapshot(self):
return {"color": self.color, "rank": self.rank}
@classmethod
def from_snapshot(cls, data):
if isinstance(data, Card):
return data
if isinstance(data, dict):
return cls(int(data["color"]), int(data["rank"]))
if isinstance(data, (list, tuple)) and len(data) == 2:
return cls(int(data[0]), int(data[1]))
raise ValueError(f"invalid card snapshot: {data!r}")
def __hash__(self):
return (self.color << 8) | self.rank
def __richcmp__(self, other, int op):
if not isinstance(other, Card):
return NotImplemented
cdef Card o = <Card>other
cdef bint eq = self.color == o.color and self.rank == o.rank
if op == 2: # ==
return eq
if op == 3: # !=
return not eq
cdef bint lt
if self.color != o.color:
lt = self.color < o.color
else:
lt = self.rank < o.rank
if op == 0: # <
return lt
if op == 1: # <=
return lt or eq
if op == 4: # >
return not lt and not eq
if op == 5: # >=
return not lt
return NotImplemented
def __repr__(self):
return f"Card(color={self.color}, rank={self.rank})"
def __reduce__(self):
return (Card, (self.color, self.rank))
@dataclass(frozen=True)
class LostCitiesConfig:
n_colors: int = 5
n_ranks: int = 9
min_rank: int = 2
n_handshakes: int = 3
hand_size: int = 8
expedition_penalty: int = -20
bonus_threshold: int = 8
bonus_amount: int = 20
seed: int | None = None
@property
def deck_size(self) -> int:
return self.n_colors * (self.n_ranks + self.n_handshakes)
@property
def max_rank(self) -> int:
return self.min_rank + self.n_ranks - 1
@property
def card_action_size(self) -> int:
return 2 * self.hand_size
@property
def draw_action_size(self) -> int:
return 1 + self.n_colors
@property
def action_size(self) -> int:
return self.card_action_size + self.draw_action_size
def validate(self) -> None:
if self.n_colors <= 0:
raise ValueError("n_colors must be positive")
if self.n_ranks <= 0:
raise ValueError("n_ranks must be positive")
if self.min_rank <= 0:
raise ValueError("min_rank must be positive")
if self.n_handshakes < 0:
raise ValueError("n_handshakes cannot be negative")
if self.hand_size <= 0:
raise ValueError("hand_size must be positive")
if self.deck_size < 2 * self.hand_size:
raise ValueError("deck must contain at least both initial hands")
if self.bonus_threshold <= 0:
raise ValueError("bonus_threshold must be positive")
def to_snapshot(self) -> dict[str, Any]:
return {field.name: getattr(self, field.name) for field in fields(self)}
def classic_config(*, seed=None):
return LostCitiesConfig(seed=seed)
def config_from_mapping(data):
allowed = LostCitiesConfig.__dataclass_fields__.keys()
kwargs = {key: value for key, value in data.items() if key in allowed}
config = LostCitiesConfig(**kwargs)
config.validate()
return config
def config_to_mapping(config):
return config.to_snapshot()
def load_config(path):
try:
import yaml
except ImportError as exc:
raise RuntimeError("pyyaml is required to load Lost Cities YAML configs") from exc
with open(path, "r", encoding="utf-8") as handle:
data = yaml.safe_load(handle) or {}
if not isinstance(data, dict):
raise ValueError(f"expected mapping in config file: {path}")
return config_from_mapping(data)
def build_deck(config):
config.validate()
cdef list deck = []
cdef int color, rank
cdef int n_colors = config.n_colors
cdef int n_handshakes = config.n_handshakes
cdef int n_ranks = config.n_ranks
for color in range(n_colors):
for _ in range(n_handshakes):
deck.append(Card(color, 0))
for rank in range(1, n_ranks + 1):
deck.append(Card(color, rank))
return deck
def _card_counter(cards):
return Counter(cards)
def _cards_from_snapshot(data):
if not isinstance(data, list):
raise ValueError(f"expected card list snapshot, got {type(data).__name__}")
return [Card.from_snapshot(card) for card in data]
def _cards_to_snapshot(cards):
return [card.to_snapshot() for card in cards]
cdef class GameState:
cdef public object config
cdef public list deck
cdef public list hands
cdef public list expeditions
cdef public list discards
cdef public int current_player
cdef public str phase
cdef public object pending_discarded_color
cdef public int turn_count
cdef public bint terminal
def __init__(
self,
config,
deck=None,
hands=None,
expeditions=None,
discards=None,
int current_player=0,
phase="card",
pending_discarded_color=None,
int turn_count=0,
bint terminal=False,
):
self.config = config
self.deck = list(deck) if deck is not None else []
self.hands = hands if hands is not None else [[], []]
self.expeditions = expeditions if expeditions is not None else [
[[] for _ in range(config.n_colors)],
[[] for _ in range(config.n_colors)],
]
self.discards = discards if discards is not None else [
[] for _ in range(config.n_colors)
]
self.current_player = current_player
self.phase = phase
self.pending_discarded_color = pending_discarded_color
self.turn_count = turn_count
self.terminal = terminal
@classmethod
def new_game(cls, config=None, *, seed=None):
config = config or LostCitiesConfig()
config.validate()
rng = random.Random(config.seed if seed is None else seed)
deck = build_deck(config)
rng.shuffle(deck)
return cls.new_game_from_deck(deck, config)
@classmethod
def new_game_from_deck(cls, deck, config=None):
config = config or LostCitiesConfig()
config.validate()
cards = [Card.from_snapshot(card) for card in deck]
if _card_counter(cards) != _card_counter(build_deck(config)):
raise ValueError("deck must contain exactly the cards defined by config")
state = cls.empty(config)
state.deck = list(cards)
cdef int player
for _ in range(config.hand_size):
for player in range(2):
state.hands[player].append(state.deck.pop())
state.sort_hands()
state.validate_invariants()
return state
@classmethod
def empty(cls, config=None):
config = config or LostCitiesConfig()
config.validate()
return cls(
config=config,
deck=[],
hands=[[], []],
expeditions=[
[[] for _ in range(config.n_colors)],
[[] for _ in range(config.n_colors)],
],
discards=[[] for _ in range(config.n_colors)],
)
@classmethod
def from_snapshot(cls, snapshot, *, validate=True):
config = config_from_mapping(snapshot["config"])
phase = snapshot.get("phase", "card")
if phase not in ("card", "draw"):
raise ValueError(f"invalid phase: {phase!r}")
state = cls(
config=config,
deck=_cards_from_snapshot(snapshot["deck"]),
hands=[
_cards_from_snapshot(snapshot["hands"][0]),
_cards_from_snapshot(snapshot["hands"][1]),
],
expeditions=[
[
_cards_from_snapshot(color_cards)
for color_cards in snapshot["expeditions"][0]
],
[
_cards_from_snapshot(color_cards)
for color_cards in snapshot["expeditions"][1]
],
],
discards=[
_cards_from_snapshot(color_cards)
for color_cards in snapshot["discards"]
],
current_player=int(snapshot.get("current_player", 0)),
phase=phase,
pending_discarded_color=snapshot.get("pending_discarded_color"),
turn_count=int(snapshot.get("turn_count", 0)),
terminal=bool(snapshot.get("terminal", False)),
)
if state.pending_discarded_color is not None:
state.pending_discarded_color = int(state.pending_discarded_color)
if validate:
state.validate_invariants()
return state
def to_snapshot(self):
return {
"config": self.config.to_snapshot(),
"deck": _cards_to_snapshot(self.deck),
"hands": [_cards_to_snapshot(hand) for hand in self.hands],
"expeditions": [
[_cards_to_snapshot(expedition) for expedition in player_expeditions]
for player_expeditions in self.expeditions
],
"discards": [_cards_to_snapshot(discard) for discard in self.discards],
"current_player": self.current_player,
"phase": self.phase,
"pending_discarded_color": self.pending_discarded_color,
"turn_count": self.turn_count,
"terminal": self.terminal,
}
cpdef GameState clone(self):
cdef GameState other = GameState.__new__(GameState)
other.config = self.config
other.deck = list(self.deck)
other.hands = [list(self.hands[0]), list(self.hands[1])]
other.expeditions = [
[list(exp) for exp in self.expeditions[0]],
[list(exp) for exp in self.expeditions[1]],
]
other.discards = [list(pile) for pile in self.discards]
other.current_player = self.current_player
other.phase = self.phase
other.pending_discarded_color = self.pending_discarded_color
other.turn_count = self.turn_count
other.terminal = self.terminal
return other
@property
def card_action_size(self):
return self.config.card_action_size
@property
def draw_action_size(self):
return self.config.draw_action_size
@property
def action_size(self):
return self.config.action_size
def sort_hands(self):
cdef int player
for player in range(2):
self.sort_hand(player)
def sort_hand(self, player=None):
cdef int p = self.current_player if player is None else int(player)
self.hands[p].sort(key=_card_sort_key)
def hand_slots(self, player=None):
cdef int p = self.current_player if player is None else int(player)
cdef list hand = self.hands[p]
cdef int hand_size = self.config.hand_size
cdef int n = len(hand)
cdef int i
cdef list out = []
for i in range(hand_size):
if i < n:
out.append(hand[i])
else:
out.append(None)
return out
cpdef int last_numeric_rank(self, int player, int color):
cdef list expedition = self.expeditions[player][color]
cdef int best = 0
cdef int n = len(expedition)
cdef int i
cdef Card card
for i in range(n):
card = <Card>expedition[i]
if card.rank == 0:
continue
if card.rank > best:
best = card.rank
return best
def has_numeric(self, int player, int color):
return self.last_numeric_rank(player, color) > 0
cpdef bint can_play_card(self, int player, Card card):
cdef int n_colors = self.config.n_colors
cdef int n_ranks = self.config.n_ranks
if card.color < 0 or card.color >= n_colors:
return False
if card.rank < 0 or card.rank > n_ranks:
return False
cdef int last_numeric = self.last_numeric_rank(player, card.color)
if card.rank == 0:
return last_numeric == 0
return card.rank > last_numeric
cpdef list legal_card_mask(self):
cdef int size = self.card_action_size
cdef list mask = [False] * size
if self.terminal:
return mask
cdef list hand = self.hands[self.current_player]
cdef int hand_size = self.config.hand_size
cdef int n = len(hand)
cdef int slot
cdef Card card
for slot in range(hand_size):
if slot >= n:
continue
card = <Card>hand[slot]
mask[2 * slot] = self.can_play_card(self.current_player, card)
mask[2 * slot + 1] = True
return mask
cpdef list legal_draw_mask(self):
cdef int size = self.draw_action_size
cdef list mask = [False] * size
if self.terminal:
return mask
mask[0] = len(self.deck) > 0
cdef int n_colors = self.config.n_colors
cdef int color
cdef object pending = self.pending_discarded_color
for color in range(n_colors):
mask[1 + color] = (
len(self.discards[color]) > 0
and (pending is None or color != pending)
)
return mask
cpdef list legal_mask(self):
if self.phase == "card":
return self.legal_card_mask()
return self.legal_draw_mask()
cpdef list unified_legal_mask(self):
cdef int draw_size = self.draw_action_size
cdef int card_size = self.card_action_size
cdef list result
if self.phase == "card":
result = self.legal_card_mask()
result.extend([False] * draw_size)
return result
result = [False] * card_size
result.extend(self.legal_draw_mask())
return result
cpdef object unified_legal_mask_np(self):
cdef int n_colors = self.config.n_colors
cdef int hand_size = self.config.hand_size
cdef int card_action_size = 2 * hand_size
cdef int draw_action_size = 1 + n_colors
cdef int total = card_action_size + draw_action_size
mask_arr = np.zeros(total, dtype=bool)
cdef unsigned char[::1] view = mask_arr.view(np.uint8)
if self.terminal:
return mask_arr
cdef int slot, n, color
cdef Card card
cdef list hand
cdef int p = self.current_player
cdef object pending
if self.phase == "card":
hand = self.hands[p]
n = len(hand)
for slot in range(hand_size):
if slot >= n:
continue
card = <Card>hand[slot]
if self.can_play_card(p, card):
view[2 * slot] = 1
view[2 * slot + 1] = 1
else:
pending = self.pending_discarded_color
if len(self.deck) > 0:
view[card_action_size] = 1
for color in range(n_colors):
if (
len(self.discards[color]) > 0
and (pending is None or color != pending)
):
view[card_action_size + 1 + color] = 1
return mask_arr
def to_unified_action(self, int action_id, phase=None):
cdef str p = self.phase if phase is None else phase
if p == "card":
if action_id < 0 or action_id >= self.card_action_size:
raise IllegalMoveError(f"card action {action_id} is out of range")
return action_id
if action_id < 0 or action_id >= self.draw_action_size:
raise IllegalMoveError(f"draw action {action_id} is out of range")
return self.card_action_size + action_id
cpdef int from_unified_action(self, int action_id):
if action_id < 0 or action_id >= self.action_size:
raise IllegalMoveError(f"action {action_id} is out of range")
if self.phase == "card":
if action_id >= self.card_action_size:
raise IllegalMoveError(
f"card action {action_id} is illegal during card phase"
)
return action_id
if action_id < self.card_action_size:
raise IllegalMoveError(
f"card action {action_id} is illegal during draw phase"
)
return action_id - self.card_action_size
cpdef apply_action(self, int action_id):
if self.terminal:
raise IllegalMoveError("game is already terminal")
cdef list mask = self.legal_mask()
if action_id < 0 or action_id >= len(mask) or not mask[action_id]:
raise IllegalMoveError(
f"illegal action {action_id} in phase {self.phase} "
f"for player {self.current_player}"
)
if self.phase == "card":
self._apply_card_action(action_id)
else:
self._apply_draw_action(action_id)
cpdef apply_unified_action(self, int action_id):
self.apply_action(self.from_unified_action(action_id))
cdef void _apply_card_action(self, int action_id) except *:
cdef int slot = action_id // 2
cdef bint play = action_id % 2 == 0
cdef Card card = <Card>self.hands[self.current_player].pop(slot)
if play:
self.expeditions[self.current_player][card.color].append(card)
else:
self.discards[card.color].append(card)
self.pending_discarded_color = card.color
self.phase = "draw"
cdef int n_colors = self.config.n_colors
cdef int color
cdef object pending = self.pending_discarded_color
cdef bint any_legal_draw = False
if len(self.deck) == 0:
for color in range(n_colors):
if len(self.discards[color]) > 0 and (pending is None or color != pending):
any_legal_draw = True
break
if not any_legal_draw:
self.terminal = True
cdef void _apply_draw_action(self, int action_id) except *:
cdef Card card
cdef int color
if action_id == 0:
card = <Card>self.deck.pop()
else:
color = action_id - 1
card = <Card>self.discards[color].pop()
self.hands[self.current_player].append(card)
self.sort_hand(self.current_player)
self.pending_discarded_color = None
self.turn_count += 1
if len(self.deck) == 0:
self.terminal = True
return
self.current_player = 1 - self.current_player
self.phase = "card"
cpdef int expedition_score(self, int player, int color):
return score_expedition(self.expeditions[player][color], self.config)
cpdef int total_score(self, int player):
cdef int total = 0
cdef int color
cdef int n_colors = self.config.n_colors
for color in range(n_colors):
total += score_expedition(self.expeditions[player][color], self.config)
return total
cpdef int score_diff(self, int player=0):
cdef int other = 1 - player
return self.total_score(player) - self.total_score(other)
def validate_invariants(self):
self.config.validate()
if self.current_player not in (0, 1):
raise ValueError("current_player must be 0 or 1")
if self.phase not in ("card", "draw"):
raise ValueError(f"invalid phase: {self.phase!r}")
if len(self.hands) != 2:
raise ValueError("hands must contain two players")
if len(self.expeditions) != 2:
raise ValueError("expeditions must contain two players")
if len(self.discards) != self.config.n_colors:
raise ValueError("discard pile count must match n_colors")
all_cards = []
all_cards.extend(self.deck)
for player, hand in enumerate(self.hands):
if len(hand) > self.config.hand_size:
raise ValueError(f"hand {player} exceeds hand_size")
if hand != sorted(hand, key=_card_sort_key):
raise ValueError(f"hand {player} is not sorted")
all_cards.extend(hand)
for player, expeditions in enumerate(self.expeditions):
if len(expeditions) != self.config.n_colors:
raise ValueError("expedition color count must match n_colors")
for color, expedition in enumerate(expeditions):
self._validate_expedition(player, color, expedition)
all_cards.extend(expedition)
for discard in self.discards:
all_cards.extend(discard)
for card in all_cards:
self._validate_card(card)
if _card_counter(all_cards) != _card_counter(build_deck(self.config)):
raise ValueError("card conservation failed")
if self.phase == "card" and self.pending_discarded_color is not None:
raise ValueError("pending_discarded_color must be None during card phase")
if self.pending_discarded_color is not None:
color = self.pending_discarded_color
if color < 0 or color >= self.config.n_colors:
raise ValueError("pending_discarded_color is out of range")
if not self.discards[color]:
raise ValueError("pending discard color must have a discard pile card")
any_legal = any(self.unified_legal_mask())
if self.terminal and any_legal:
raise ValueError("terminal state must have no legal actions")
if not self.terminal and not any_legal:
raise ValueError("non-terminal state must have at least one legal action")
def _validate_card(self, Card card):
if card.color < 0 or card.color >= self.config.n_colors:
raise ValueError(f"card color out of range: {card}")
if card.rank < 0 or card.rank > self.config.n_ranks:
raise ValueError(f"card rank out of range: {card}")
def _validate_expedition(self, int player, int color, list expedition):
cdef bint seen_numeric = False
cdef int last_numeric = 0
cdef Card card
for card in expedition:
if card.color != color:
raise ValueError(
f"player {player} expedition {color} contains wrong color"
)
if card.rank == 0:
if seen_numeric:
raise ValueError(
f"player {player} expedition {color} has handshake after number"
)
continue
seen_numeric = True
if card.rank <= last_numeric:
raise ValueError(
f"player {player} expedition {color} is not strictly increasing"
)
last_numeric = card.rank
def __reduce__(self):
# support pickle via snapshot round-trip
return (_rebuild_game_state, (self.to_snapshot(),))
def _rebuild_game_state(snapshot):
return GameState.from_snapshot(snapshot, validate=False)
def _card_sort_key(Card card):
return (card.color, card.rank)
cpdef int score_expedition(list expedition, config):
cdef int n = len(expedition)
if n == 0:
return 0
cdef int min_rank = config.min_rank
cdef int handshakes = 0
cdef int numeric_sum = 0
cdef int i
cdef Card card
for i in range(n):
card = <Card>expedition[i]
if card.rank == 0:
handshakes += 1
else:
numeric_sum += min_rank + card.rank - 1
cdef int score = (numeric_sum + config.expedition_penalty) * (handshakes + 1)
if n >= config.bonus_threshold:
score += config.bonus_amount
return score
@@ -0,0 +1,67 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Literal, Protocol, TypeAlias, runtime_checkable
from .game import Card, GameState, LostCitiesConfig, score_expedition
BackendName = Literal["python", "rust"]
@dataclass
class Snapshot:
config: LostCitiesConfig
deck: list[Card]
hands: list[list[Card]]
expeditions: list[list[list[Card]]]
discards: list[list[Card]]
current_player: int
phase: str
pending_discarded_color: int | None
turn_count: int
terminal: bool
legal_mask: list[bool]
@property
def card_action_size(self) -> int:
return self.config.card_action_size
@property
def draw_action_size(self) -> int:
return self.config.draw_action_size
def expedition_score(self, player: int, color: int) -> int:
return score_expedition(self.expeditions[player][color], self.config)
def total_score(self, player: int) -> int:
return sum(
self.expedition_score(player, color)
for color in range(self.config.n_colors)
)
def score_diff(self, player: int = 0) -> int:
return self.total_score(player) - self.total_score(1 - player)
BotInput: TypeAlias = dict | GameState | Snapshot
@runtime_checkable
class LostCitiesBot(Protocol):
def act(self, obs_or_state: BotInput) -> int:
"""Choose an action id from the current state or observation."""
@runtime_checkable
class LostCitiesBackend(Protocol):
name: BackendName
config: LostCitiesConfig
seed: int | None
def snapshot(self) -> Snapshot: ...
def apply(self, action_id: int) -> None: ...
def can_undo(self) -> bool: ...
def undo(self) -> bool: ...
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,22 @@
[package]
name = "lost-cities-core"
version = "0.1.0"
edition = "2021"
[lib]
name = "lost_cities_core"
path = "src/lib.rs"
[dependencies]
prost = "0.13"
prost-types = "0.13"
rand = { version = "0.8", features = ["std", "std_rng"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["net", "rt-multi-thread"] }
tokio-stream = { version = "0.1", features = ["net"] }
tonic = { version = "0.12", features = ["transport"] }
[build-dependencies]
protoc-bin-vendored = "3"
tonic-build = "0.12"
@@ -0,0 +1,15 @@
use std::path::PathBuf;
fn main() {
let protoc = protoc_bin_vendored::protoc_bin_path().expect("vendored protoc");
std::env::set_var("PROTOC", protoc);
let proto_dir = PathBuf::from("../schemas");
let proto_file = proto_dir.join("lost_cities.proto");
println!("cargo:rerun-if-changed={}", proto_file.display());
tonic_build::configure()
.compile_protos(&[proto_file], &[proto_dir])
.expect("compile lost_cities proto");
}
@@ -0,0 +1,779 @@
use std::env;
use std::error::Error;
use std::fs;
use std::io;
use lost_cities_core::proto::{
self, lost_cities_client::LostCitiesClient, lost_cities_server::LostCitiesServer,
};
use lost_cities_core::{
Card, Config, EngineErrorKind, GameState, LostCitiesEngine, LostCitiesGrpcService, Phase,
};
use serde::{Deserialize, Serialize};
use tokio::net::TcpListener;
use tokio_stream::wrappers::TcpListenerStream;
use tonic::transport::{Channel, Endpoint, Server};
type ProbeResult<T> = Result<T, Box<dyn Error>>;
#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)]
struct CardJson {
color: u32,
rank: u32,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
struct ConfigJson {
n_colors: usize,
n_ranks: u32,
min_rank: u32,
n_handshakes: u32,
hand_size: usize,
expedition_penalty: i32,
bonus_threshold: usize,
bonus_amount: i32,
seed: Option<u64>,
}
#[derive(Debug, Deserialize)]
struct FixtureInput {
config: ConfigJson,
initial_deck: Vec<CardJson>,
steps: Vec<FixtureStepInput>,
}
#[derive(Debug, Deserialize)]
struct FixtureStepInput {
action: Option<u32>,
}
#[derive(Debug, Serialize)]
struct TraceOutput {
config: ConfigJson,
steps: Vec<StateTraceStep>,
}
#[derive(Debug, Serialize)]
struct StateTraceStep {
action: Option<u32>,
phase: &'static str,
current_player: usize,
turn_count: u32,
terminal: bool,
pending_discarded_color: Option<u32>,
score_diff_player0: i32,
legal_mask: Vec<bool>,
deck: Vec<CardJson>,
hands: Vec<Vec<CardJson>>,
expeditions: Vec<Vec<Vec<CardJson>>>,
discards: Vec<Vec<CardJson>>,
}
#[derive(Debug, Serialize)]
struct ObservationMini {
state_version: u64,
current_player: u32,
observer_player: u32,
phase: String,
terminal: bool,
}
#[derive(Debug, Serialize, PartialEq)]
struct ObservationSummary {
state_version: u64,
current_player: u32,
observer_player: u32,
phase: String,
hand: Vec<CardJson>,
opponent_hand_size: u32,
deck_size: u32,
discards: Vec<Vec<CardJson>>,
my_expeditions: Vec<Vec<CardJson>>,
opponent_expeditions: Vec<Vec<CardJson>>,
legal_mask: Vec<bool>,
terminal: bool,
my_score: i32,
opponent_score: i32,
score_diff: i32,
}
#[derive(Debug, Serialize)]
struct EngineProbeOutput {
duplicate_kind: String,
missing_config_kind: String,
empty_session_kind: String,
unknown_session_kind: String,
invalid_observer_kind: String,
invalid_observer_state_unchanged: bool,
invalid_observer_action_still_applies: bool,
phase_flow: Vec<ObservationMini>,
stale_kind: String,
end_session_counts: Vec<usize>,
off_turn_legal_empty: bool,
full_session_terminal_reward_matches: bool,
full_session_final_scores_match: bool,
terminal_reject_kind: String,
deterministic_match: bool,
}
#[derive(Debug, Serialize)]
struct GrpcProbeOutput {
round_trip_phase: String,
opponent_legal_empty: bool,
stale_code: String,
invalid_observer_code: String,
invalid_observer_state_unchanged: bool,
ended_session_code: String,
}
fn main() -> ProbeResult<()> {
let mut args = env::args().skip(1);
let command = args
.next()
.ok_or_else(|| io::Error::other("expected command"))?;
match command.as_str() {
"defaults" => print_json(&ConfigJson::from_config(&Config::default()))?,
"trace" => {
let path = args
.next()
.ok_or_else(|| io::Error::other("expected fixture path"))?;
print_json(&run_trace(&path)?)?;
}
"engine" => print_json(&run_engine_probe()?)?,
"grpc" => {
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?;
print_json(&runtime.block_on(run_grpc_probe())?)?;
}
_ => return Err(io::Error::other(format!("unknown command: {command}")).into()),
}
Ok(())
}
fn print_json<T: Serialize>(value: &T) -> ProbeResult<()> {
println!("{}", serde_json::to_string_pretty(value)?);
Ok(())
}
fn run_trace(path: &str) -> ProbeResult<TraceOutput> {
let fixture: FixtureInput = serde_json::from_str(&fs::read_to_string(path)?)?;
let config = fixture.config.to_config();
let deck = fixture
.initial_deck
.iter()
.copied()
.map(Card::from)
.collect::<Vec<_>>();
let mut state = GameState::new_game_from_deck(config, deck)?;
let mut steps = Vec::with_capacity(fixture.steps.len());
for step in fixture.steps {
if let Some(action) = step.action {
state.apply_unified_action(action)?;
}
state.validate_invariants().map_err(io::Error::other)?;
steps.push(StateTraceStep::from_state(step.action, &state));
}
Ok(TraceOutput {
config: ConfigJson::from_config(&state.config),
steps,
})
}
fn run_engine_probe() -> ProbeResult<EngineProbeOutput> {
let duplicate_kind = {
let mut engine = LostCitiesEngine::new();
engine.new_game(new_game_request("dup", 7, true))?;
kind_name(
engine
.new_game(new_game_request("dup", 7, true))
.expect_err("duplicate session must fail")
.kind(),
)
};
let missing_config_kind = {
let mut engine = LostCitiesEngine::new();
kind_name(
engine
.new_game(new_game_request("missing-config", 7, false))
.expect_err("missing config must fail")
.kind(),
)
};
let empty_session_kind = {
let mut engine = LostCitiesEngine::new();
kind_name(
engine
.new_game(new_game_request(" ", 7, true))
.expect_err("empty session id must fail")
.kind(),
)
};
let unknown_session_kind = {
let engine = LostCitiesEngine::new();
kind_name(
engine
.get_observation(proto::SessionRef {
session_id: "unknown".to_string(),
observer_player: None,
})
.expect_err("unknown session must fail")
.kind(),
)
};
let (
invalid_observer_kind,
invalid_observer_state_unchanged,
invalid_observer_action_still_applies,
) = {
let mut engine = LostCitiesEngine::new();
let observation = engine.new_game(new_game_request("bad-observer", 7, true))?;
let action_id = first_action(&observation)?;
let err = engine
.apply_action(proto::ApplyActionRequest {
session_id: "bad-observer".to_string(),
action_id,
expected_state_version: observation.state_version,
observer_player: Some(2),
})
.expect_err("invalid observer must fail");
let after_error = engine.get_observation(proto::SessionRef {
session_id: "bad-observer".to_string(),
observer_player: Some(0),
})?;
let state_unchanged = after_error.state_version == observation.state_version
&& after_error.phase == observation.phase
&& after_error.current_player == observation.current_player;
let still_applies = engine
.apply_action(proto::ApplyActionRequest {
session_id: "bad-observer".to_string(),
action_id,
expected_state_version: observation.state_version,
observer_player: Some(0),
})
.is_ok();
(kind_name(err.kind()), state_unchanged, still_applies)
};
let phase_flow = {
let mut engine = LostCitiesEngine::new();
let observation = engine.new_game(new_game_request("phase-flow", 11, true))?;
let first = engine
.apply_action(proto::ApplyActionRequest {
session_id: "phase-flow".to_string(),
action_id: first_action(&observation)?,
expected_state_version: observation.state_version,
observer_player: None,
})?
.observation
.ok_or_else(|| io::Error::other("missing first observation"))?;
let second = engine
.apply_action(proto::ApplyActionRequest {
session_id: "phase-flow".to_string(),
action_id: first_action(&first)?,
expected_state_version: first.state_version,
observer_player: None,
})?
.observation
.ok_or_else(|| io::Error::other("missing second observation"))?;
vec![
ObservationMini::from_observation(&observation),
ObservationMini::from_observation(&first),
ObservationMini::from_observation(&second),
]
};
let stale_kind = {
let mut engine = LostCitiesEngine::new();
let observation = engine.new_game(new_game_request("stale", 3, true))?;
let action_id = first_action(&observation)?;
engine.apply_action(proto::ApplyActionRequest {
session_id: "stale".to_string(),
action_id,
expected_state_version: observation.state_version,
observer_player: None,
})?;
kind_name(
engine
.apply_action(proto::ApplyActionRequest {
session_id: "stale".to_string(),
action_id,
expected_state_version: observation.state_version,
observer_player: None,
})
.expect_err("stale version must fail")
.kind(),
)
};
let end_session_counts = {
let mut engine = LostCitiesEngine::new();
engine.new_game(new_game_request("cleanup", 5, true))?;
let before = engine.session_count();
engine.end_session(session_ref("cleanup", None))?;
engine.end_session(session_ref("cleanup", None))?;
vec![before, engine.session_count()]
};
let off_turn_legal_empty = {
let mut engine = LostCitiesEngine::new();
let config = small_config(9);
engine.new_game(proto::NewGameRequest {
session_id: "hidden".to_string(),
config: Some(config),
})?;
let hidden = engine.get_observation(session_ref("hidden", Some(1)))?;
hidden
.legal_actions
.as_ref()
.map(|legal| legal.actions.is_empty() && legal.mask.iter().all(|value| !value))
.unwrap_or(false)
};
let (
full_session_terminal_reward_matches,
full_session_final_scores_match,
terminal_reject_kind,
) = run_full_session_probe()?;
let deterministic_match = run_deterministic_probe()?;
Ok(EngineProbeOutput {
duplicate_kind,
missing_config_kind,
empty_session_kind,
unknown_session_kind,
invalid_observer_kind,
invalid_observer_state_unchanged,
invalid_observer_action_still_applies,
phase_flow,
stale_kind,
end_session_counts,
off_turn_legal_empty,
full_session_terminal_reward_matches,
full_session_final_scores_match,
terminal_reject_kind,
deterministic_match,
})
}
async fn run_grpc_probe() -> ProbeResult<GrpcProbeOutput> {
let (mut client, server) = spawn_client().await?;
let observation = client
.new_game(new_game_request("grpc-round-trip", 13, true))
.await?
.into_inner();
let opponent_view = client
.get_observation(session_ref("grpc-round-trip", Some(1)))
.await?
.into_inner();
let round_trip_phase = phase_name_proto(observation.phase).to_string();
let opponent_legal_empty = opponent_view
.legal_actions
.map(|legal| legal.actions.is_empty())
.unwrap_or(false);
server.abort();
let (mut client, server) = spawn_client().await?;
let observation = client
.new_game(new_game_request("grpc-stale", 21, true))
.await?
.into_inner();
let action_id = first_action(&observation)?;
client
.apply_action(proto::ApplyActionRequest {
session_id: "grpc-stale".to_string(),
action_id,
expected_state_version: observation.state_version,
observer_player: None,
})
.await?;
let stale_code = format!(
"{:?}",
client
.apply_action(proto::ApplyActionRequest {
session_id: "grpc-stale".to_string(),
action_id,
expected_state_version: observation.state_version,
observer_player: None,
})
.await
.expect_err("stale state_version must fail")
.code()
);
server.abort();
let (mut client, server) = spawn_client().await?;
let observation = client
.new_game(new_game_request("grpc-bad-observer", 31, true))
.await?
.into_inner();
let action_id = first_action(&observation)?;
let invalid_observer_code = format!(
"{:?}",
client
.apply_action(proto::ApplyActionRequest {
session_id: "grpc-bad-observer".to_string(),
action_id,
expected_state_version: observation.state_version,
observer_player: Some(2),
})
.await
.expect_err("invalid observer must fail")
.code()
);
let after_error = client
.get_observation(session_ref("grpc-bad-observer", Some(0)))
.await?
.into_inner();
let invalid_observer_state_unchanged = after_error.state_version == observation.state_version
&& after_error.phase == observation.phase
&& after_error.current_player == observation.current_player;
server.abort();
let (mut client, server) = spawn_client().await?;
client
.new_game(new_game_request("grpc-end-session", 41, true))
.await?;
client
.end_session(session_ref("grpc-end-session", None))
.await?;
client
.end_session(session_ref("grpc-end-session", None))
.await?;
let ended_session_code = format!(
"{:?}",
client
.get_observation(session_ref("grpc-end-session", None))
.await
.expect_err("ended session should not be readable")
.code()
);
server.abort();
Ok(GrpcProbeOutput {
round_trip_phase,
opponent_legal_empty,
stale_code,
invalid_observer_code,
invalid_observer_state_unchanged,
ended_session_code,
})
}
fn run_full_session_probe() -> ProbeResult<(bool, bool, String)> {
let mut engine = LostCitiesEngine::new();
let mut observation = engine.new_game(new_game_request("loop", 17, true))?;
loop {
let action_id = first_action(&observation)?;
let step = engine.apply_action(proto::ApplyActionRequest {
session_id: "loop".to_string(),
action_id,
expected_state_version: observation.state_version,
observer_player: None,
})?;
let next_observation = step
.observation
.ok_or_else(|| io::Error::other("missing loop observation"))?;
if step.terminal {
let observer = next_observation.observer_player;
let other = 1 - observer;
let reward_matches = step.reward as i32 == next_observation.score_diff;
let scores_match = step.final_scores.len() == 2
&& step.final_scores.get(&observer).copied() == Some(next_observation.my_score)
&& step.final_scores.get(&other).copied() == Some(next_observation.opponent_score);
let terminal_reject_kind = kind_name(
engine
.apply_action(proto::ApplyActionRequest {
session_id: "loop".to_string(),
action_id: 0,
expected_state_version: next_observation.state_version,
observer_player: None,
})
.expect_err("terminal game must reject further actions")
.kind(),
);
return Ok((reward_matches, scores_match, terminal_reject_kind));
}
observation = next_observation;
}
}
fn run_deterministic_probe() -> ProbeResult<bool> {
let config = small_config(29);
let mut left = LostCitiesEngine::new();
let mut right = LostCitiesEngine::new();
let mut left_observation = left.new_game(proto::NewGameRequest {
session_id: "det-left".to_string(),
config: Some(config.clone()),
})?;
let mut right_observation = right.new_game(proto::NewGameRequest {
session_id: "det-right".to_string(),
config: Some(config),
})?;
loop {
if observation_summary(&left_observation) != observation_summary(&right_observation) {
return Ok(false);
}
if left_observation.terminal {
return Ok(right_observation.terminal);
}
let action_id = first_action(&left_observation)?;
let left_step = left.apply_action(proto::ApplyActionRequest {
session_id: "det-left".to_string(),
action_id,
expected_state_version: left_observation.state_version,
observer_player: None,
})?;
let right_step = right.apply_action(proto::ApplyActionRequest {
session_id: "det-right".to_string(),
action_id,
expected_state_version: right_observation.state_version,
observer_player: None,
})?;
if left_step.terminal != right_step.terminal
|| left_step.final_scores != right_step.final_scores
|| left_step.reward != right_step.reward
{
return Ok(false);
}
left_observation = left_step
.observation
.ok_or_else(|| io::Error::other("missing left observation"))?;
right_observation = right_step
.observation
.ok_or_else(|| io::Error::other("missing right observation"))?;
}
}
async fn spawn_client() -> ProbeResult<(LostCitiesClient<Channel>, tokio::task::JoinHandle<()>)> {
let listener = TcpListener::bind("127.0.0.1:0").await?;
let addr = listener.local_addr()?;
let incoming = TcpListenerStream::new(listener);
let server = tokio::spawn(async move {
Server::builder()
.add_service(LostCitiesServer::new(LostCitiesGrpcService::default()))
.serve_with_incoming(incoming)
.await
.expect("gRPC server should run");
});
let endpoint = Endpoint::from_shared(format!("http://{}", addr))?;
let client = LostCitiesClient::new(endpoint.connect().await?);
Ok((client, server))
}
fn observation_summary(observation: &proto::GameObservation) -> ObservationSummary {
ObservationSummary {
state_version: observation.state_version,
current_player: observation.current_player,
observer_player: observation.observer_player,
phase: phase_name_proto(observation.phase).to_string(),
hand: observation.hand.iter().map(CardJson::from_proto).collect(),
opponent_hand_size: observation.opponent_hand_size,
deck_size: observation.deck_size,
discards: observation
.discards
.iter()
.map(|discard| discard.cards.iter().map(CardJson::from_proto).collect())
.collect(),
my_expeditions: observation
.my_expeditions
.iter()
.map(|expedition| expedition.cards.iter().map(CardJson::from_proto).collect())
.collect(),
opponent_expeditions: observation
.opponent_expeditions
.iter()
.map(|expedition| expedition.cards.iter().map(CardJson::from_proto).collect())
.collect(),
legal_mask: observation
.legal_actions
.as_ref()
.map(|legal| legal.mask.clone())
.unwrap_or_default(),
terminal: observation.terminal,
my_score: observation.my_score,
opponent_score: observation.opponent_score,
score_diff: observation.score_diff,
}
}
fn first_action(observation: &proto::GameObservation) -> ProbeResult<u32> {
observation
.legal_actions
.as_ref()
.and_then(|actions| actions.actions.first())
.map(|action| action.id)
.ok_or_else(|| io::Error::other("observation has no legal action").into())
}
fn small_config(seed: u64) -> proto::GameConfig {
proto::GameConfig {
n_colors: 2,
n_ranks: 2,
min_rank: 1,
n_handshakes: 0,
hand_size: 1,
expedition_penalty: 0,
bonus_threshold: 99,
bonus_amount: 0,
seed: Some(seed),
}
}
fn new_game_request(session_id: &str, seed: u64, include_config: bool) -> proto::NewGameRequest {
proto::NewGameRequest {
session_id: session_id.to_string(),
config: include_config.then(|| small_config(seed)),
}
}
fn session_ref(session_id: &str, observer_player: Option<u32>) -> proto::SessionRef {
proto::SessionRef {
session_id: session_id.to_string(),
observer_player,
}
}
fn kind_name(kind: EngineErrorKind) -> String {
match kind {
EngineErrorKind::AlreadyExists => "AlreadyExists",
EngineErrorKind::NotFound => "NotFound",
EngineErrorKind::FailedPrecondition => "FailedPrecondition",
EngineErrorKind::InvalidArgument => "InvalidArgument",
}
.to_string()
}
fn phase_name_state(phase: Phase) -> &'static str {
match phase {
Phase::Card => "card",
Phase::Draw => "draw",
}
}
fn phase_name_proto(phase: i32) -> &'static str {
match proto::Phase::try_from(phase) {
Ok(proto::Phase::Card) => "card",
Ok(proto::Phase::Draw) => "draw",
_ => "unspecified",
}
}
impl ConfigJson {
fn to_config(&self) -> Config {
Config {
n_colors: self.n_colors,
n_ranks: self.n_ranks,
min_rank: self.min_rank,
n_handshakes: self.n_handshakes,
hand_size: self.hand_size,
expedition_penalty: self.expedition_penalty,
bonus_threshold: self.bonus_threshold,
bonus_amount: self.bonus_amount,
seed: self.seed,
}
}
fn from_config(config: &Config) -> Self {
Self {
n_colors: config.n_colors,
n_ranks: config.n_ranks,
min_rank: config.min_rank,
n_handshakes: config.n_handshakes,
hand_size: config.hand_size,
expedition_penalty: config.expedition_penalty,
bonus_threshold: config.bonus_threshold,
bonus_amount: config.bonus_amount,
seed: config.seed,
}
}
}
impl From<CardJson> for Card {
fn from(value: CardJson) -> Self {
Self {
color: value.color,
rank: value.rank,
}
}
}
impl From<Card> for CardJson {
fn from(value: Card) -> Self {
Self {
color: value.color,
rank: value.rank,
}
}
}
impl CardJson {
fn from_proto(value: &proto::Card) -> Self {
Self {
color: value.color,
rank: value.rank,
}
}
}
impl StateTraceStep {
fn from_state(action: Option<u32>, state: &GameState) -> Self {
Self {
action,
phase: phase_name_state(state.phase),
current_player: state.current_player,
turn_count: state.turn_count,
terminal: state.terminal,
pending_discarded_color: state.pending_discarded_color,
score_diff_player0: state.score_diff(0),
legal_mask: state.legal_unified_mask(),
deck: state.deck.iter().copied().map(CardJson::from).collect(),
hands: state
.hands
.iter()
.map(|hand| hand.iter().copied().map(CardJson::from).collect())
.collect(),
expeditions: state
.expeditions
.iter()
.map(|expeditions| {
expeditions
.iter()
.map(|expedition| expedition.iter().copied().map(CardJson::from).collect())
.collect()
})
.collect(),
discards: state
.discards
.iter()
.map(|discard| discard.iter().copied().map(CardJson::from).collect())
.collect(),
}
}
}
impl ObservationMini {
fn from_observation(observation: &proto::GameObservation) -> Self {
Self {
state_version: observation.state_version,
current_player: observation.current_player,
observer_player: observation.observer_player,
phase: phase_name_proto(observation.phase).to_string(),
terminal: observation.terminal,
}
}
}
@@ -0,0 +1,131 @@
use crate::error::EngineError;
use crate::proto;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Config {
pub n_colors: usize,
pub n_ranks: u32,
pub min_rank: u32,
pub n_handshakes: u32,
pub hand_size: usize,
pub expedition_penalty: i32,
pub bonus_threshold: usize,
pub bonus_amount: i32,
pub seed: Option<u64>,
}
impl Default for Config {
fn default() -> Self {
Self {
n_colors: 5,
n_ranks: 9,
min_rank: 2,
n_handshakes: 3,
hand_size: 8,
expedition_penalty: -20,
bonus_threshold: 8,
bonus_amount: 20,
seed: None,
}
}
}
impl Config {
pub fn validate(&self) -> Result<(), EngineError> {
if self.n_colors == 0 {
return Err(EngineError::invalid_argument("n_colors must be positive"));
}
if self.n_ranks == 0 {
return Err(EngineError::invalid_argument("n_ranks must be positive"));
}
if self.min_rank == 0 {
return Err(EngineError::invalid_argument("min_rank must be positive"));
}
if self.hand_size == 0 {
return Err(EngineError::invalid_argument("hand_size must be positive"));
}
if self.bonus_threshold == 0 {
return Err(EngineError::invalid_argument(
"bonus_threshold must be positive",
));
}
if self.deck_size() < 2 * self.hand_size {
return Err(EngineError::invalid_argument(
"deck must contain at least both initial hands",
));
}
Ok(())
}
pub fn with_seed(mut self, seed: Option<u64>) -> Self {
self.seed = seed;
self
}
pub fn deck_size(&self) -> usize {
self.n_colors * (self.n_ranks as usize + self.n_handshakes as usize)
}
pub fn card_action_size(&self) -> usize {
self.hand_size * 2
}
pub fn draw_action_size(&self) -> usize {
1 + self.n_colors
}
pub fn action_space_size(&self) -> usize {
self.card_action_size() + self.draw_action_size()
}
}
impl TryFrom<&proto::GameConfig> for Config {
type Error = EngineError;
fn try_from(value: &proto::GameConfig) -> Result<Self, Self::Error> {
let n_colors = usize::try_from(value.n_colors)
.map_err(|_| EngineError::invalid_argument("n_colors is out of range"))?;
let hand_size = usize::try_from(value.hand_size)
.map_err(|_| EngineError::invalid_argument("hand_size is out of range"))?;
let bonus_threshold = usize::try_from(value.bonus_threshold)
.map_err(|_| EngineError::invalid_argument("bonus_threshold is out of range"))?;
let config = Self {
n_colors,
n_ranks: value.n_ranks,
min_rank: value.min_rank,
n_handshakes: value.n_handshakes,
hand_size,
expedition_penalty: value.expedition_penalty,
bonus_threshold,
bonus_amount: value.bonus_amount,
seed: value.seed,
};
config.validate()?;
Ok(config)
}
}
impl TryFrom<proto::GameConfig> for Config {
type Error = EngineError;
fn try_from(value: proto::GameConfig) -> Result<Self, Self::Error> {
Self::try_from(&value)
}
}
impl From<&Config> for proto::GameConfig {
fn from(value: &Config) -> Self {
Self {
n_colors: value.n_colors as u32,
n_ranks: value.n_ranks,
min_rank: value.min_rank,
n_handshakes: value.n_handshakes,
hand_size: value.hand_size as u32,
expedition_penalty: value.expedition_penalty,
bonus_threshold: value.bonus_threshold as u32,
bonus_amount: value.bonus_amount,
seed: value.seed,
}
}
}
@@ -0,0 +1,231 @@
use std::collections::HashMap;
use crate::config::Config;
use crate::error::EngineError;
use crate::proto;
use crate::state::{GameState, Phase};
#[derive(Clone, Debug)]
struct SessionState {
game: GameState,
state_version: u64,
}
impl SessionState {
fn observation(&self, session_id: &str, observer: usize) -> proto::GameObservation {
let game = &self.game;
let can_act = !game.terminal && observer == game.current_player;
let my_score = game.total_score(observer);
let opponent_score = game.total_score(1 - observer);
proto::GameObservation {
session_id: session_id.to_string(),
config: Some((&game.config).into()),
state_version: self.state_version,
observer_player: observer as u32,
current_player: game.current_player as u32,
phase: game.phase.to_proto(),
hand: game.hands[observer]
.iter()
.copied()
.map(|card| card.to_proto(&game.config))
.collect(),
opponent_hand_size: game.hands[1 - observer].len() as u32,
my_expeditions: Self::build_expeditions(observer, game),
opponent_expeditions: Self::build_expeditions(1 - observer, game),
discards: Self::build_discards(game),
deck_size: game.deck.len() as u32,
pending_discarded_color: if game.phase == Phase::Draw {
game.pending_discarded_color
} else {
None
},
legal_actions: Some(game.build_legal_action_set(self.state_version, can_act)),
turn_count: game.turn_count,
terminal: game.terminal,
my_score,
opponent_score,
score_diff: my_score - opponent_score,
}
}
fn final_scores(&self) -> HashMap<u32, i32> {
HashMap::from([(0, self.game.total_score(0)), (1, self.game.total_score(1))])
}
fn build_expeditions(player: usize, game: &GameState) -> Vec<proto::Expedition> {
game.expeditions[player]
.iter()
.enumerate()
.map(|(color, cards)| proto::Expedition {
color: color as u32,
cards: cards
.iter()
.copied()
.map(|card| card.to_proto(&game.config))
.collect(),
current_score: crate::score_expedition(cards, &game.config),
})
.collect()
}
fn build_discards(game: &GameState) -> Vec<proto::DiscardPile> {
game.discards
.iter()
.enumerate()
.map(|(color, cards)| proto::DiscardPile {
color: color as u32,
cards: cards
.iter()
.copied()
.map(|card| card.to_proto(&game.config))
.collect(),
size: cards.len() as u32,
})
.collect()
}
}
#[derive(Default)]
pub struct LostCitiesEngine {
sessions: HashMap<String, SessionState>,
}
impl LostCitiesEngine {
pub fn new() -> Self {
Self::default()
}
pub fn session_count(&self) -> usize {
self.sessions.len()
}
pub fn new_game(
&mut self,
request: proto::NewGameRequest,
) -> Result<proto::GameObservation, EngineError> {
let session_id = request.session_id;
Self::validate_session_id(&session_id)?;
if self.sessions.contains_key(&session_id) {
return Err(EngineError::already_exists(format!(
"session {} already exists",
session_id
)));
}
let config_proto = request
.config
.ok_or_else(|| EngineError::invalid_argument("config is required"))?;
let config = Config::try_from(config_proto)?;
let session = SessionState {
game: GameState::new_game(config)?,
state_version: 0,
};
let observation = session.observation(&session_id, 0);
self.sessions.insert(session_id, session);
Ok(observation)
}
pub fn get_observation(
&self,
request: proto::SessionRef,
) -> Result<proto::GameObservation, EngineError> {
let session_id = request.session_id;
Self::validate_session_id(&session_id)?;
let session = self
.sessions
.get(&session_id)
.ok_or_else(|| EngineError::not_found(format!("unknown session {}", session_id)))?;
let observer = Self::requested_or_default_observer(
request.observer_player,
session.game.current_player,
)?;
Ok(session.observation(&session_id, observer))
}
pub fn apply_action(
&mut self,
request: proto::ApplyActionRequest,
) -> Result<proto::StepResult, EngineError> {
let session_id = request.session_id;
Self::validate_session_id(&session_id)?;
let requested_observer = request
.observer_player
.map(Self::validate_observer)
.transpose()?;
let session = self
.sessions
.get_mut(&session_id)
.ok_or_else(|| EngineError::not_found(format!("unknown session {}", session_id)))?;
if session.game.terminal {
return Err(EngineError::failed_precondition("game is already terminal"));
}
if request.expected_state_version != session.state_version {
return Err(EngineError::failed_precondition(format!(
"expected state_version {}, got {}",
session.state_version, request.expected_state_version
)));
}
session.game.apply_unified_action(request.action_id)?;
session.state_version += 1;
let observer = requested_observer.unwrap_or(session.game.current_player);
let observation = session.observation(&session_id, observer);
let terminal = session.game.terminal;
let reward = if terminal {
f64::from(observation.score_diff)
} else {
0.0
};
let final_scores = if terminal {
session.final_scores()
} else {
HashMap::new()
};
Ok(proto::StepResult {
observation: Some(observation),
reward,
terminal,
final_scores,
})
}
pub fn end_session(&mut self, request: proto::SessionRef) -> Result<(), EngineError> {
Self::validate_session_id(&request.session_id)?;
self.sessions.remove(&request.session_id);
Ok(())
}
fn validate_session_id(session_id: &str) -> Result<(), EngineError> {
if session_id.trim().is_empty() {
return Err(EngineError::invalid_argument(
"session_id must not be empty",
));
}
Ok(())
}
fn requested_or_default_observer(
observer_player: Option<u32>,
default_player: usize,
) -> Result<usize, EngineError> {
observer_player
.map(Self::validate_observer)
.transpose()
.map(|observer| observer.unwrap_or(default_player))
}
fn validate_observer(observer: u32) -> Result<usize, EngineError> {
match observer {
0 => Ok(0),
1 => Ok(1),
_ => Err(EngineError::invalid_argument(
"observer_player must be 0 or 1",
)),
}
}
}
@@ -0,0 +1,57 @@
use std::error::Error;
use std::fmt::{self, Display, Formatter};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum EngineErrorKind {
AlreadyExists,
NotFound,
FailedPrecondition,
InvalidArgument,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct EngineError {
kind: EngineErrorKind,
message: String,
}
impl EngineError {
pub fn new(kind: EngineErrorKind, message: impl Into<String>) -> Self {
Self {
kind,
message: message.into(),
}
}
pub fn kind(&self) -> EngineErrorKind {
self.kind
}
pub fn message(&self) -> &str {
&self.message
}
pub(crate) fn already_exists(message: impl Into<String>) -> Self {
Self::new(EngineErrorKind::AlreadyExists, message)
}
pub(crate) fn not_found(message: impl Into<String>) -> Self {
Self::new(EngineErrorKind::NotFound, message)
}
pub(crate) fn failed_precondition(message: impl Into<String>) -> Self {
Self::new(EngineErrorKind::FailedPrecondition, message)
}
pub(crate) fn invalid_argument(message: impl Into<String>) -> Self {
Self::new(EngineErrorKind::InvalidArgument, message)
}
}
impl Display for EngineError {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
write!(f, "{:?}: {}", self.kind, self.message)
}
}
impl Error for EngineError {}
@@ -0,0 +1,15 @@
pub mod proto {
tonic::include_proto!("lost_cities.v1");
}
mod config;
mod engine;
mod error;
mod service;
mod state;
pub use config::Config;
pub use engine::LostCitiesEngine;
pub use error::{EngineError, EngineErrorKind};
pub use service::LostCitiesGrpcService;
pub use state::{score_expedition, Card, GameState, Phase};
@@ -0,0 +1,82 @@
use std::sync::{Arc, Mutex};
use tonic::{Request, Response, Status};
use crate::error::{EngineError, EngineErrorKind};
use crate::proto;
use crate::LostCitiesEngine;
#[derive(Clone, Default)]
pub struct LostCitiesGrpcService {
engine: Arc<Mutex<LostCitiesEngine>>,
}
impl LostCitiesGrpcService {
pub fn new(engine: LostCitiesEngine) -> Self {
Self {
engine: Arc::new(Mutex::new(engine)),
}
}
}
#[tonic::async_trait]
impl proto::lost_cities_server::LostCities for LostCitiesGrpcService {
async fn new_game(
&self,
request: Request<proto::NewGameRequest>,
) -> Result<Response<proto::GameObservation>, Status> {
let mut engine = self.engine.lock().map_err(|_| poisoned_engine_status())?;
let observation = engine
.new_game(request.into_inner())
.map_err(map_engine_error)?;
Ok(Response::new(observation))
}
async fn get_observation(
&self,
request: Request<proto::SessionRef>,
) -> Result<Response<proto::GameObservation>, Status> {
let engine = self.engine.lock().map_err(|_| poisoned_engine_status())?;
let observation = engine
.get_observation(request.into_inner())
.map_err(map_engine_error)?;
Ok(Response::new(observation))
}
async fn apply_action(
&self,
request: Request<proto::ApplyActionRequest>,
) -> Result<Response<proto::StepResult>, Status> {
let mut engine = self.engine.lock().map_err(|_| poisoned_engine_status())?;
let result = engine
.apply_action(request.into_inner())
.map_err(map_engine_error)?;
Ok(Response::new(result))
}
async fn end_session(
&self,
request: Request<proto::SessionRef>,
) -> Result<Response<()>, Status> {
let mut engine = self.engine.lock().map_err(|_| poisoned_engine_status())?;
engine
.end_session(request.into_inner())
.map_err(map_engine_error)?;
Ok(Response::new(()))
}
}
fn poisoned_engine_status() -> Status {
Status::internal("lost cities engine mutex poisoned")
}
fn map_engine_error(error: EngineError) -> Status {
match error.kind() {
EngineErrorKind::AlreadyExists => Status::already_exists(error.message().to_string()),
EngineErrorKind::NotFound => Status::not_found(error.message().to_string()),
EngineErrorKind::FailedPrecondition => {
Status::failed_precondition(error.message().to_string())
}
EngineErrorKind::InvalidArgument => Status::invalid_argument(error.message().to_string()),
}
}
@@ -0,0 +1,550 @@
use crate::config::Config;
use crate::error::EngineError;
use crate::proto;
use rand::rngs::StdRng;
use rand::seq::SliceRandom;
use rand::SeedableRng;
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Card {
pub color: u32,
pub rank: u32,
}
impl Card {
pub fn is_handshake(self) -> bool {
self.rank == 0
}
pub fn numeric_value(self, min_rank: u32) -> u32 {
if self.is_handshake() {
0
} else {
min_rank + self.rank - 1
}
}
pub fn label(self, min_rank: u32) -> String {
if self.is_handshake() {
format!("[{}]H", self.color)
} else {
format!("[{}]{}", self.color, self.numeric_value(min_rank))
}
}
pub fn to_proto(self, config: &Config) -> proto::Card {
proto::Card {
color: self.color,
rank: self.rank,
numeric_value: self.numeric_value(config.min_rank),
label: self.label(config.min_rank),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Phase {
Card,
Draw,
}
impl Phase {
pub fn to_proto(self) -> i32 {
match self {
Self::Card => proto::Phase::Card as i32,
Self::Draw => proto::Phase::Draw as i32,
}
}
}
#[derive(Clone, Debug)]
pub struct GameState {
pub config: Config,
pub deck: Vec<Card>,
pub hands: [Vec<Card>; 2],
pub expeditions: [Vec<Vec<Card>>; 2],
pub discards: Vec<Vec<Card>>,
pub current_player: usize,
pub phase: Phase,
pub pending_discarded_color: Option<u32>,
pub turn_count: u32,
pub terminal: bool,
}
impl GameState {
pub fn new_game(config: Config) -> Result<Self, EngineError> {
config.validate()?;
let mut deck = build_deck(&config);
match config.seed {
Some(seed) => {
let mut rng = StdRng::seed_from_u64(seed);
deck.shuffle(&mut rng);
}
None => {
let mut rng = rand::thread_rng();
deck.shuffle(&mut rng);
}
}
Self::new_game_from_deck(config, deck)
}
pub fn new_game_from_deck(config: Config, deck: Vec<Card>) -> Result<Self, EngineError> {
config.validate()?;
let mut expected = build_deck(&config);
let mut actual = deck.clone();
expected.sort();
actual.sort();
if actual != expected {
return Err(EngineError::invalid_argument(
"deck must contain exactly the cards defined by config",
));
}
let mut state = Self::empty(config)?;
state.deck = deck;
for _ in 0..state.config.hand_size {
for player in 0..2 {
let card = state
.deck
.pop()
.expect("validated deck must contain both initial hands");
state.hands[player].push(card);
}
}
state.sort_hands();
state
.validate_invariants()
.map_err(EngineError::invalid_argument)?;
Ok(state)
}
pub fn empty(config: Config) -> Result<Self, EngineError> {
config.validate()?;
let n_colors = config.n_colors;
Ok(Self {
config,
deck: Vec::new(),
hands: [Vec::new(), Vec::new()],
expeditions: std::array::from_fn(|_| vec![Vec::new(); n_colors]),
discards: vec![Vec::new(); n_colors],
current_player: 0,
phase: Phase::Card,
pending_discarded_color: None,
turn_count: 0,
terminal: false,
})
}
pub fn sort_hands(&mut self) {
self.sort_hand(0);
self.sort_hand(1);
}
pub fn sort_hand(&mut self, player: usize) {
self.hands[player].sort_by_key(|card| (card.color, card.rank));
}
pub fn last_numeric_rank(&self, player: usize, color: usize) -> u32 {
self.expeditions[player][color]
.iter()
.filter(|card| !card.is_handshake())
.map(|card| card.rank)
.max()
.unwrap_or(0)
}
pub fn can_play_card(&self, player: usize, card: Card) -> bool {
let color = match usize::try_from(card.color) {
Ok(value) if value < self.config.n_colors => value,
_ => return false,
};
if card.rank > self.config.n_ranks {
return false;
}
let last_numeric = self.last_numeric_rank(player, color);
if card.is_handshake() {
last_numeric == 0
} else {
card.rank > last_numeric
}
}
pub fn legal_card_mask_phase(&self) -> Vec<bool> {
let mut mask = vec![false; self.config.card_action_size()];
if self.terminal {
return mask;
}
for (slot, card) in self.hands[self.current_player].iter().copied().enumerate() {
let play_id = Self::play_action_id(slot);
mask[play_id] = self.can_play_card(self.current_player, card);
mask[Self::discard_action_id(slot)] = true;
}
mask
}
pub fn legal_draw_mask_phase(&self) -> Vec<bool> {
let mut mask = vec![false; self.config.draw_action_size()];
if self.terminal {
return mask;
}
mask[0] = !self.deck.is_empty();
for color in 0..self.config.n_colors {
let pending = self.pending_discarded_color == Some(color as u32);
mask[1 + color] = !self.discards[color].is_empty() && !pending;
}
mask
}
pub fn legal_unified_mask(&self) -> Vec<bool> {
if self.terminal {
return vec![false; self.config.action_space_size()];
}
match self.phase {
Phase::Card => {
let mut mask = self.legal_card_mask_phase();
mask.resize(self.config.action_space_size(), false);
mask
}
Phase::Draw => {
let mut mask = vec![false; self.config.card_action_size()];
mask.extend(self.legal_draw_mask_phase());
mask
}
}
}
pub fn apply_unified_action(&mut self, action_id: u32) -> Result<(), EngineError> {
if self.terminal {
return Err(EngineError::failed_precondition("game is already terminal"));
}
let action_index = usize::try_from(action_id)
.map_err(|_| EngineError::failed_precondition("action_id is out of range"))?;
let mask = self.legal_unified_mask();
if action_index >= mask.len() || !mask[action_index] {
return Err(EngineError::failed_precondition(format!(
"illegal action {} in phase {:?} for player {}",
action_id, self.phase, self.current_player
)));
}
match self.phase {
Phase::Card => self.apply_card_action(action_index),
Phase::Draw => self.apply_draw_action(action_index),
}
Ok(())
}
pub fn total_score(&self, player: usize) -> i32 {
self.expeditions[player]
.iter()
.map(|expedition| score_expedition(expedition, &self.config))
.sum()
}
pub fn score_diff(&self, player: usize) -> i32 {
self.total_score(player) - self.total_score(1 - player)
}
pub fn validate_invariants(&self) -> Result<(), String> {
self.config.validate().map_err(|err| err.to_string())?;
if self.current_player > 1 {
return Err("current_player must be 0 or 1".to_string());
}
if self.discards.len() != self.config.n_colors {
return Err("discard pile count must match n_colors".to_string());
}
let mut all_cards = Vec::new();
all_cards.extend(self.deck.iter().copied());
for (player_index, hand) in self.hands.iter().enumerate() {
if hand.len() > self.config.hand_size {
return Err(format!("hand {} exceeds hand_size", player_index));
}
if !hand.windows(2).all(|pair| pair[0] <= pair[1]) {
return Err(format!("hand {} is not sorted", player_index));
}
all_cards.extend(hand.iter().copied());
}
for (player_index, expeditions) in self.expeditions.iter().enumerate() {
if expeditions.len() != self.config.n_colors {
return Err("expedition color count must match n_colors".to_string());
}
for (color, expedition) in expeditions.iter().enumerate() {
let mut seen_numeric = false;
let mut last_numeric = 0;
for card in expedition {
if card.color as usize != color {
return Err(format!(
"player {} expedition {} contains wrong color",
player_index, color
));
}
if card.is_handshake() {
if seen_numeric {
return Err(format!(
"player {} expedition {} has handshake after numeric",
player_index, color
));
}
continue;
}
seen_numeric = true;
if card.rank <= last_numeric {
return Err(format!(
"player {} expedition {} is not strictly increasing",
player_index, color
));
}
last_numeric = card.rank;
}
all_cards.extend(expedition.iter().copied());
}
}
for discard in &self.discards {
all_cards.extend(discard.iter().copied());
}
let mut expected = build_deck(&self.config);
expected.sort();
all_cards.sort();
if all_cards != expected {
return Err("card conservation failed".to_string());
}
if self.phase == Phase::Card && self.pending_discarded_color.is_some() {
return Err("pending_discarded_color must be None during card phase".to_string());
}
if let Some(color) = self.pending_discarded_color {
let color = color as usize;
if color >= self.config.n_colors {
return Err("pending_discarded_color is out of range".to_string());
}
if self.discards[color].is_empty() {
return Err("pending discard color must have a discard pile card".to_string());
}
}
let any_legal = self.legal_unified_mask().into_iter().any(|value| value);
if self.terminal && any_legal {
return Err("terminal state must have no legal actions".to_string());
}
if !self.terminal && !any_legal {
return Err("non-terminal state must have at least one legal action".to_string());
}
Ok(())
}
pub(crate) fn build_legal_action_set(
&self,
state_version: u64,
include_actions: bool,
) -> proto::LegalActionSet {
if self.terminal || !include_actions {
return self.empty_legal_action_set(state_version);
}
let mask = self.legal_unified_mask();
let actions = match self.phase {
Phase::Card => self.build_card_actions(&mask),
Phase::Draw => self.build_draw_actions(&mask),
};
proto::LegalActionSet {
state_version,
actions,
mask,
action_space_size: self.config.action_space_size() as u32,
phase: self.phase.to_proto(),
}
}
fn apply_card_action(&mut self, action_index: usize) {
let slot = action_index / 2;
let play = action_index.is_multiple_of(2);
let card = self.hands[self.current_player].remove(slot);
let color = card.color as usize;
if play {
self.expeditions[self.current_player][color].push(card);
self.pending_discarded_color = None;
} else {
self.discards[color].push(card);
self.pending_discarded_color = Some(card.color);
}
self.phase = Phase::Draw;
if self.deck.is_empty() && !self.has_draw_source() {
self.terminal = true;
}
}
fn apply_draw_action(&mut self, action_index: usize) {
let draw_index = action_index - self.draw_action_offset();
let card = if draw_index == 0 {
self.deck.pop().expect("legal deck draw")
} else {
let color = draw_index - 1;
self.discards[color].pop().expect("legal discard draw")
};
self.hands[self.current_player].push(card);
self.sort_hand(self.current_player);
self.pending_discarded_color = None;
self.turn_count += 1;
if self.deck.is_empty() {
self.terminal = true;
return;
}
self.current_player = 1 - self.current_player;
self.phase = Phase::Card;
}
fn build_card_actions(&self, mask: &[bool]) -> Vec<proto::Action> {
let mut actions = Vec::new();
for (slot, card) in self.hands[self.current_player].iter().copied().enumerate() {
let play_id = Self::play_action_id(slot);
if mask[play_id] {
actions.push(self.card_action(
play_id,
proto::ActionKind::PlayCard,
slot,
card,
"Play",
));
}
let discard_id = Self::discard_action_id(slot);
if mask[discard_id] {
actions.push(self.card_action(
discard_id,
proto::ActionKind::DiscardCard,
slot,
card,
"Discard",
));
}
}
actions
}
fn build_draw_actions(&self, mask: &[bool]) -> Vec<proto::Action> {
let mut actions = Vec::new();
let deck_draw_id = self.draw_action_offset();
if mask[deck_draw_id] {
actions.push(self.draw_action(deck_draw_id, None));
}
for color in 0..self.config.n_colors {
let action_id = self.discard_draw_action_id(color);
if mask[action_id] {
actions.push(self.draw_action(action_id, Some(color)));
}
}
actions
}
fn empty_legal_action_set(&self, state_version: u64) -> proto::LegalActionSet {
proto::LegalActionSet {
state_version,
actions: Vec::new(),
mask: vec![false; self.config.action_space_size()],
action_space_size: self.config.action_space_size() as u32,
phase: self.phase.to_proto(),
}
}
fn has_draw_source(&self) -> bool {
self.legal_draw_mask_phase().into_iter().any(|value| value)
}
fn draw_action_offset(&self) -> usize {
self.config.card_action_size()
}
fn play_action_id(slot: usize) -> usize {
slot * 2
}
fn discard_action_id(slot: usize) -> usize {
Self::play_action_id(slot) + 1
}
fn discard_draw_action_id(&self, color: usize) -> usize {
self.draw_action_offset() + 1 + color
}
fn card_action(
&self,
id: usize,
kind: proto::ActionKind,
slot: usize,
card: Card,
verb: &str,
) -> proto::Action {
proto::Action {
id: id as u32,
kind: kind as i32,
hand_slot: slot as u32,
card: Some(card.to_proto(&self.config)),
discard_color: 0,
description: format!("{verb} {}", card.label(self.config.min_rank)),
}
}
fn draw_action(&self, id: usize, discard_color: Option<usize>) -> proto::Action {
let (kind, discard_color, description) = match discard_color {
Some(color) => (
proto::ActionKind::DrawDiscard,
color as u32,
format!("Draw discard {color}"),
),
None => (proto::ActionKind::DrawDeck, 0, "Draw deck".to_string()),
};
proto::Action {
id: id as u32,
kind: kind as i32,
hand_slot: 0,
card: None,
discard_color,
description,
}
}
}
pub fn build_deck(config: &Config) -> Vec<Card> {
let mut deck = Vec::with_capacity(config.deck_size());
for color in 0..config.n_colors as u32 {
for _ in 0..config.n_handshakes {
deck.push(Card { color, rank: 0 });
}
for rank in 1..=config.n_ranks {
deck.push(Card { color, rank });
}
}
deck
}
pub fn score_expedition(expedition: &[Card], config: &Config) -> i32 {
if expedition.is_empty() {
return 0;
}
let handshakes = expedition.iter().filter(|card| card.is_handshake()).count() as i32;
let numeric_sum = expedition
.iter()
.map(|card| card.numeric_value(config.min_rank) as i32)
.sum::<i32>();
let mut score = (numeric_sum + config.expedition_penalty) * (handshakes + 1);
if expedition.len() >= config.bonus_threshold {
score += config.bonus_amount;
}
score
}
@@ -0,0 +1,225 @@
syntax = "proto3";
package lost_cities.v1;
import "google/protobuf/empty.proto";
// See lost_cities_spec.md for the full game rules.
// This proto is the wire contract for external agents to play Lost Cities
// against a trusted orchestrator. RL training uses in-process PyO3 bindings
// instead and does not go through this service.
// =============================================================
// Core value types
// =============================================================
// A single card. Rank 0 is a handshake (investment) card.
// Rank 1..n_ranks are numeric cards whose printed face value is
// min_rank + rank - 1.
message Card {
uint32 color = 1;
uint32 rank = 2;
uint32 numeric_value = 3; // 0 for handshake, else min_rank + rank - 1
string label = 4; // debug-friendly e.g. "[2]H", "[0]5"
// non-authoritative, do not parse
}
enum Phase {
PHASE_UNSPECIFIED = 0;
PHASE_CARD = 1; // the current player must play or discard a card
PHASE_DRAW = 2; // the current player must draw from deck or a discard pile
}
enum ActionKind {
ACTION_KIND_UNSPECIFIED = 0;
ACTION_KIND_PLAY_CARD = 1;
ACTION_KIND_DISCARD_CARD = 2;
ACTION_KIND_DRAW_DECK = 3;
ACTION_KIND_DRAW_DISCARD = 4;
}
// A resolved legal action.
//
// `id` is an opaque integer handle valid only within the observation it
// was returned with. Clients pass it back via ApplyAction together with
// the observation's state_version.
//
// `kind` plus typed payload fields let semantic agents (LLM, rule-based)
// reason without decoding ids. `description` is a human-readable label
// such as "Play yellow 5" and must not be parsed by the agent.
message Action {
uint32 id = 1;
ActionKind kind = 2;
// Set for PLAY_CARD and DISCARD_CARD.
uint32 hand_slot = 3;
Card card = 4;
// Set for DRAW_DISCARD.
uint32 discard_color = 5;
string description = 6;
}
// =============================================================
// Configuration
// =============================================================
message GameConfig {
uint32 n_colors = 1;
uint32 n_ranks = 2;
uint32 min_rank = 3;
uint32 n_handshakes = 4;
uint32 hand_size = 5;
int32 expedition_penalty = 6; // typically negative, e.g. -20
uint32 bonus_threshold = 7;
int32 bonus_amount = 8;
optional uint64 seed = 9; // absent = nondeterministic shuffle
}
// =============================================================
// Piles and expeditions
// =============================================================
message Expedition {
uint32 color = 1;
repeated Card cards = 2; // bottom to top, push-only during game
int32 current_score = 3; // derived from the game rules
}
message DiscardPile {
uint32 color = 1;
repeated Card cards = 2; // bottom to top; last element is drawable top
uint32 size = 3; // = cards.size(); convenience
}
// =============================================================
// Legal actions
// =============================================================
// The set of legal actions at a specific state.
//
// Invariants:
// - actions are sorted by id ascending
// - for every action a in actions: mask[a.id] == true
// - mask.size() == action_space_size
// - all ids are < action_space_size
// - state_version matches the observation this set was produced for
message LegalActionSet {
uint64 state_version = 1;
repeated Action actions = 2;
repeated bool mask = 3;
uint32 action_space_size = 4;
Phase phase = 5;
}
// =============================================================
// Observation
// =============================================================
// Player-perspective snapshot. `observer_player` identifies whose view
// this is. The observer sees their own hand; the opponent's hand is
// represented only by its size.
message GameObservation {
string session_id = 1;
GameConfig config = 2;
// Monotonically increasing per session. Required for ApplyAction.
uint64 state_version = 3;
uint32 observer_player = 4;
uint32 current_player = 5;
Phase phase = 6;
repeated Card hand = 7; // observer's hand, sorted
uint32 opponent_hand_size = 8;
repeated Expedition my_expeditions = 9;
repeated Expedition opponent_expeditions = 10;
repeated DiscardPile discards = 11;
uint32 deck_size = 12;
// Color just discarded during the current turn's card phase.
// The current player's draw phase may not re-draw that color
// from the discard pile. Cleared at turn boundary.
optional uint32 pending_discarded_color = 13;
// Legal actions for the player to act. Empty when terminal.
// Embedded to avoid a round-trip on the common observe->select->apply loop.
LegalActionSet legal_actions = 14;
uint32 turn_count = 15;
bool terminal = 16;
// Scores are from the observer's perspective.
int32 my_score = 17;
int32 opponent_score = 18;
int32 score_diff = 19; // my_score - opponent_score
}
// =============================================================
// Requests and responses
// =============================================================
message NewGameRequest {
string session_id = 1;
GameConfig config = 2;
}
message SessionRef {
string session_id = 1;
// Defaults to current_player when absent.
optional uint32 observer_player = 2;
}
message ApplyActionRequest {
string session_id = 1;
uint32 action_id = 2;
// Must match the observation the action was selected from.
// Mismatch yields FAILED_PRECONDITION; no state change occurs.
// This makes ApplyAction safely retryable.
uint64 expected_state_version = 3;
// Perspective for the returned observation. Defaults to current_player
// after the action is applied.
optional uint32 observer_player = 4;
}
message StepResult {
GameObservation observation = 1;
// From the observation's observer_player perspective.
// Sparse: nonzero only on the terminal transition, equal to score_diff.
double reward = 2;
bool terminal = 3;
// Populated on terminal transitions. Keyed by player index.
map<uint32, int32> final_scores = 4;
}
// =============================================================
// Service
// =============================================================
service LostCities {
// Start a new game. session_id must be unique; duplicates return
// ALREADY_EXISTS. The returned observation is from player 0's perspective.
rpc NewGame(NewGameRequest) returns (GameObservation);
// Re-read the current state without advancing. Useful after reconnects
// or for a second client joining an existing session.
rpc GetObservation(SessionRef) returns (GameObservation);
// Apply one action. Fails with:
// NOT_FOUND session_id unknown
// FAILED_PRECONDITION expected_state_version mismatch,
// action_id not legal in current state,
// or game already terminal
// INVALID_ARGUMENT malformed request
rpc ApplyAction(ApplyActionRequest) returns (StepResult);
// Release server-side session state. Idempotent.
rpc EndSession(SessionRef) returns (google.protobuf.Empty);
}
+206
View File
@@ -0,0 +1,206 @@
from coolrl_lost_cities.games.classic.bots import (
LostCitiesBot,
RandomBot,
SafeHeuristicBot,
play_game,
)
from coolrl_lost_cities.games.classic.game import Card, GameState, LostCitiesConfig
from coolrl_lost_cities.games.classic.bots.heuristic import draw_from_discard_action
def test_builtin_bots_implement_lost_cities_bot() -> None:
assert isinstance(RandomBot(1), LostCitiesBot)
assert isinstance(SafeHeuristicBot(), LostCitiesBot)
def test_safe_heuristic_mirror_match_finishes() -> None:
state = play_game(
SafeHeuristicBot(),
SafeHeuristicBot(),
LostCitiesConfig(n_colors=3, n_ranks=5, n_handshakes=1, hand_size=5),
seed=2000,
max_steps=200,
)
assert state.terminal is True
def test_safe_heuristic_opponent_value_ignores_hidden_hand() -> None:
config = LostCitiesConfig(n_colors=2, n_ranks=8, hand_size=3)
bot = SafeHeuristicBot()
discard_card = Card(color=0, rank=6)
state_a = GameState.empty(config)
state_a.expeditions[1][0] = [Card(color=0, rank=0), Card(color=0, rank=4)]
state_a.discards[0] = [discard_card]
state_a.hands[1] = [Card(color=0, rank=5)]
state_b = GameState.empty(config)
state_b.expeditions[1][0] = [Card(color=0, rank=0), Card(color=0, rank=4)]
state_b.discards[0] = [discard_card]
state_b.hands[1] = [Card(color=0, rank=5), Card(color=0, rank=7), Card(color=0, rank=8)]
value_a = bot._card_value_for_opponent(
state=state_a,
opponent=1,
card=discard_card,
derived=bot._derived(state_a),
)
value_b = bot._card_value_for_opponent(
state=state_b,
opponent=1,
card=discard_card,
derived=bot._derived(state_b),
)
assert value_a == value_b
def test_safe_heuristic_started_expedition_value_ignores_invalid_lower_followup() -> None:
config = LostCitiesConfig(n_colors=2, n_ranks=8, hand_size=3)
bot = SafeHeuristicBot()
high_card = Card(color=0, rank=8)
base_state = GameState.empty(config)
base_state.expeditions[0][0] = [Card(color=0, rank=4)]
base_state.hands[0] = [high_card]
lower_followup_state = GameState.empty(config)
lower_followup_state.expeditions[0][0] = [Card(color=0, rank=4)]
lower_followup_state.hands[0] = [Card(color=0, rank=5), high_card]
base_value = bot._started_expedition_play_value(
state=base_state,
player=0,
card=high_card,
derived=bot._derived(base_state),
deck_left=config.deck_size,
)
lower_followup_value = bot._started_expedition_play_value(
state=lower_followup_state,
player=0,
card=high_card,
derived=bot._derived(lower_followup_state),
deck_left=config.deck_size,
)
assert lower_followup_value == base_value
def test_safe_heuristic_draws_playable_discard_instead_of_deck() -> None:
config = LostCitiesConfig(n_colors=2, n_ranks=8, hand_size=3)
bot = SafeHeuristicBot()
state = GameState.empty(config)
state.current_player = 0
state.phase = "draw"
state.expeditions[0][0] = [Card(color=0, rank=4)]
state.discards[0] = [Card(color=0, rank=6)]
state.deck = [Card(color=1, rank=8)]
assert bot._act_draw(state) == draw_from_discard_action(0)
def test_safe_heuristic_can_draw_discard_to_deny_opponent_when_losing() -> None:
config = LostCitiesConfig(n_colors=2, n_ranks=8, hand_size=4)
bot = SafeHeuristicBot()
state = GameState.empty(config)
state.current_player = 0
state.phase = "draw"
state.deck = [Card(color=1, rank=8), Card(color=1, rank=7)]
state.hands[0] = [Card(color=0, rank=0), Card(color=0, rank=7)]
state.expeditions[0][1] = [Card(color=1, rank=8)]
state.expeditions[1][0] = [
Card(color=0, rank=0),
Card(color=0, rank=5),
Card(color=0, rank=6),
Card(color=0, rank=7),
Card(color=0, rank=8),
]
state.discards[0] = [Card(color=0, rank=6)]
assert state.score_diff(0) < 0
assert bot._act_draw(state) == draw_from_discard_action(0)
def test_safe_heuristic_classic_self_play_opens_expeditions() -> None:
state = GameState.new_game(LostCitiesConfig(), seed=1)
bot = SafeHeuristicBot()
player0_actions: list[int] = []
for _ in range(60):
if state.terminal:
break
action = bot.act(state)
unified = state.to_unified_action(action)
if state.current_player == 0:
player0_actions.append(unified)
state.apply_unified_action(unified)
play_actions = [
action
for action in player0_actions
if action < state.config.card_action_size and action % 2 == 0
]
assert play_actions
assert any(state.expeditions[0][color] for color in range(state.config.n_colors))
def test_safe_heuristic_avoids_opening_weak_fifth_color() -> None:
config = LostCitiesConfig(n_colors=5, n_ranks=8, hand_size=8)
bot = SafeHeuristicBot()
state = GameState.empty(config)
state.current_player = 0
state.phase = "card"
state.expeditions[0][0] = [Card(color=0, rank=4)]
state.expeditions[0][1] = [Card(color=1, rank=4)]
state.expeditions[0][2] = [Card(color=2, rank=5)]
state.expeditions[0][3] = [Card(color=3, rank=6)]
weak_open = Card(color=4, rank=4)
state.hands[0] = [weak_open, Card(color=4, rank=7), Card(color=0, rank=6)]
state.sort_hand(0)
assert bot._should_open_expedition(
state=state,
player=0,
color=4,
opening_card=weak_open,
derived=bot._derived(state),
deck_left=config.deck_size,
) is False
def test_safe_heuristic_prefers_followup_on_started_expedition() -> None:
config = LostCitiesConfig(n_colors=3, n_ranks=8, hand_size=5)
bot = SafeHeuristicBot()
state = GameState.empty(config)
state.current_player = 0
state.phase = "card"
state.expeditions[0][0] = [Card(color=0, rank=4)]
state.hands[0] = [Card(color=0, rank=6), Card(color=1, rank=4), Card(color=1, rank=7)]
state.sort_hand(0)
action = bot._act_card(state)
chosen = state.hands[0][action // 2]
assert action % 2 == 0
assert chosen.color == 0
def test_safe_heuristic_avoids_unopened_discard_draw_after_four_opens() -> None:
config = LostCitiesConfig(n_colors=5, n_ranks=8, hand_size=8)
bot = SafeHeuristicBot()
state = GameState.empty(config)
state.current_player = 0
state.phase = "draw"
state.deck = [Card(color=0, rank=8), Card(color=1, rank=8)]
state.expeditions[0][0] = [Card(color=0, rank=4)]
state.expeditions[0][1] = [Card(color=1, rank=4)]
state.expeditions[0][2] = [Card(color=2, rank=5)]
state.expeditions[0][3] = [Card(color=3, rank=6)]
state.hands[0] = [Card(color=4, rank=4), Card(color=4, rank=7)]
state.discards[4] = [Card(color=4, rank=5)]
assert bot._act_draw(state) == 0
+136
View File
@@ -0,0 +1,136 @@
import json
import random
from pathlib import Path
import pytest
import coolrl_lost_cities.games.classic as classic
from coolrl_lost_cities.games.classic.game import Card, GameState, LostCitiesConfig
FIXTURE_DIR = Path(classic.__file__).resolve().parent / "fixtures"
def _small_config() -> LostCitiesConfig:
return LostCitiesConfig(
n_colors=2,
n_ranks=2,
min_rank=1,
n_handshakes=0,
hand_size=1,
expedition_penalty=0,
bonus_threshold=99,
bonus_amount=0,
)
def test_new_game_from_deck_uses_explicit_internal_deck_order() -> None:
config = _small_config()
state = GameState.new_game_from_deck(
[
Card(0, 1),
Card(0, 2),
Card(1, 1),
Card(1, 2),
],
config,
)
assert state.hands == [[Card(1, 2)], [Card(1, 1)]]
assert state.deck == [Card(0, 1), Card(0, 2)]
state.validate_invariants()
def test_snapshot_roundtrip_preserves_json_state() -> None:
state = GameState.new_game(LostCitiesConfig(seed=5))
first_action = next(
index for index, legal in enumerate(state.unified_legal_mask()) if legal
)
state.apply_unified_action(first_action)
second_action = next(
index for index, legal in enumerate(state.unified_legal_mask()) if legal
)
state.apply_unified_action(second_action)
payload = json.loads(json.dumps(state.to_snapshot()))
restored = GameState.from_snapshot(payload)
assert restored.to_snapshot() == state.to_snapshot()
restored.validate_invariants()
def test_validate_invariants_detects_card_loss() -> None:
state = GameState.new_game(LostCitiesConfig(seed=7))
state.deck.pop()
with pytest.raises(ValueError, match="card conservation"):
state.validate_invariants()
def test_validate_invariants_detects_bad_expedition_order() -> None:
state = GameState.new_game(LostCitiesConfig(seed=8))
card = state.deck.pop()
state.expeditions[0][card.color].extend([Card(card.color, 2), Card(card.color, 1)])
state.deck.extend([Card(card.color, 2), Card(card.color, 1)])
with pytest.raises(ValueError, match="strictly increasing"):
state.validate_invariants()
def test_canonical_small_fixture_matches_expected_trace() -> None:
fixture = json.loads((FIXTURE_DIR / "canonical_small.json").read_text())
config = LostCitiesConfig(**fixture["config"])
state = GameState.new_game_from_deck(fixture["initial_deck"], config)
for step in fixture["steps"]:
if step["action"] is not None:
state.apply_unified_action(step["action"])
assert state.phase == step["phase"]
assert state.current_player == step["current_player"]
assert state.turn_count == step["turn_count"]
assert state.terminal is step["terminal"]
assert state.score_diff(0) == step["score_diff_player0"]
assert state.unified_legal_mask() == step["legal_mask"]
state.validate_invariants()
def test_random_games_preserve_python_core_invariants() -> None:
config = LostCitiesConfig(
n_colors=3,
n_ranks=5,
min_rank=2,
n_handshakes=1,
hand_size=5,
)
for seed in range(128):
state = GameState.new_game(config, seed=seed)
rng = random.Random(seed ^ 0x5EED)
steps = 0
while not state.terminal:
state.validate_invariants()
legal = [
index
for index, is_legal in enumerate(state.unified_legal_mask())
if is_legal
]
state.apply_unified_action(rng.choice(legal))
steps += 1
assert steps < 1000
state.validate_invariants()
def test_same_seed_and_action_sequence_are_deterministic() -> None:
config = LostCitiesConfig(seed=1234)
left = GameState.new_game(config)
right = GameState.new_game(config)
while True:
assert left.to_snapshot() == right.to_snapshot()
if left.terminal:
break
action = next(
index for index, is_legal in enumerate(left.unified_legal_mask()) if is_legal
)
left.apply_unified_action(action)
right.apply_unified_action(action)
+62
View File
@@ -0,0 +1,62 @@
import numpy as np
from coolrl_lost_cities.games.classic.env import LostCitiesEnv
from coolrl_lost_cities.games.classic.game import Card, GameState, LostCitiesConfig
def test_env_observation_uses_fixed_unified_mask() -> None:
config = LostCitiesConfig()
env = LostCitiesEnv(config)
obs = env.reset()
assert obs["legal_mask"].shape == (config.action_size,)
assert env.phase == "card"
card_action = int(np.nonzero(obs["legal_mask"])[0][0])
obs, _, _, _ = env.step(card_action)
assert env.phase == "draw"
assert obs["legal_mask"].shape == (config.action_size,)
assert np.all(obs["legal_mask"][:config.card_action_size] == 0)
assert np.any(obs["legal_mask"][config.card_action_size:])
def test_env_step_accepts_legacy_draw_action_ids() -> None:
config = LostCitiesConfig()
env = LostCitiesEnv(config)
env.state = GameState.empty(config)
env.state.hands[0] = [Card(0, 1)]
env.state.hands[1] = [Card(1, 1)]
env.state.deck = [Card(2, 1), Card(2, 2)]
env.state.phase = "draw"
obs, reward, done, _ = env.step(0)
assert obs["legal_mask"].shape == (config.action_size,)
assert reward == 0.0
assert done is False
assert env.current_player == 1
assert env.phase == "card"
def test_terminal_reward_is_relative_to_actor() -> None:
config = LostCitiesConfig(
n_colors=2,
n_ranks=1,
min_rank=1,
n_handshakes=0,
hand_size=1,
expedition_penalty=0,
bonus_threshold=99,
)
env = LostCitiesEnv(config)
env.state = GameState.empty(config)
env.state.current_player = 1
env.state.phase = "draw"
env.state.deck = [Card(1, 1)]
env.state.expeditions[1][0] = [Card(0, 1)]
_, reward, done, _ = env.step(config.card_action_size)
assert done is True
assert reward == 1.0
+58
View File
@@ -0,0 +1,58 @@
from coolrl_lost_cities.games.classic.bots import RandomBot
from coolrl_lost_cities.games.classic.game import Card, GameState, LostCitiesConfig
def test_legal_mask_has_action_in_nonterminal_phases() -> None:
state = GameState.new_game(LostCitiesConfig(seed=3))
while not state.terminal:
assert any(state.legal_mask())
action = RandomBot(11).act(state)
state.apply_action(action)
def test_empty_hand_slots_are_masked() -> None:
state = GameState.empty(LostCitiesConfig())
state.hands[0] = [Card(0, 1)]
mask = state.legal_card_mask()
assert mask[0] is True
assert mask[1] is True
assert all(value is False for value in mask[2:])
def test_empty_discard_pile_draw_is_illegal() -> None:
state = GameState.empty(LostCitiesConfig())
state.phase = "draw"
state.deck = [Card(0, 1)]
mask = state.legal_draw_mask()
assert mask[0] is True
assert all(mask[1 + color] is False for color in range(state.config.n_colors))
def test_unified_legal_mask_has_fixed_shape_across_phases() -> None:
config = LostCitiesConfig()
state = GameState.new_game(config, seed=1)
assert len(state.unified_legal_mask()) == config.action_size
action = next(index for index, legal in enumerate(state.legal_mask()) if legal)
state.apply_action(action)
mask = state.unified_legal_mask()
assert state.phase == "draw"
assert len(mask) == config.action_size
assert all(value is False for value in mask[:config.card_action_size])
assert any(mask[config.card_action_size:])
def test_random_fuzz_invariants() -> None:
config = LostCitiesConfig(n_colors=3, n_ranks=5, n_handshakes=1, hand_size=5)
bot = RandomBot(99)
for seed in range(1000):
state = GameState.new_game(config, seed=seed)
steps = 0
while not state.terminal:
mask = state.legal_mask()
assert any(mask)
action = bot.act(state)
state.apply_action(action)
steps += 1
assert steps < 1000
+110
View File
@@ -0,0 +1,110 @@
import pytest
from coolrl_lost_cities.games.classic.game import Card, GameState, IllegalMoveError, LostCitiesConfig, build_deck
def test_deck_generation_count() -> None:
config = LostCitiesConfig(n_colors=3, n_ranks=5, n_handshakes=1, hand_size=5)
assert len(build_deck(config)) == config.n_colors * (config.n_ranks + config.n_handshakes)
def test_initial_hands_remove_cards_from_deck() -> None:
config = LostCitiesConfig(seed=7)
state = GameState.new_game(config)
assert len(state.hands[0]) == config.hand_size
assert len(state.hands[1]) == config.hand_size
assert len(state.deck) == config.deck_size - 2 * config.hand_size
def test_play_must_be_ascending() -> None:
config = LostCitiesConfig()
state = GameState.empty(config)
state.hands[0] = [Card(0, 2)]
state.expeditions[0][0] = [Card(0, 4)]
assert state.legal_card_mask()[0] is False
def test_handshake_after_number_forbidden() -> None:
config = LostCitiesConfig()
state = GameState.empty(config)
state.hands[0] = [Card(1, 0)]
state.expeditions[0][1] = [Card(1, 1)]
assert state.legal_card_mask()[0] is False
def test_cannot_draw_just_discarded_color() -> None:
config = LostCitiesConfig()
state = GameState.empty(config)
state.hands[0] = [Card(2, 2)]
state.deck = [Card(0, 1)]
state.apply_action(1)
mask = state.legal_draw_mask()
assert mask[1 + 2] is False
def test_drawing_just_discarded_color_is_rejected() -> None:
config = LostCitiesConfig()
state = GameState.empty(config)
state.hands[0] = [Card(2, 2)]
state.deck = [Card(0, 1)]
state.apply_action(1)
with pytest.raises(IllegalMoveError):
state.apply_action(1 + 2)
def test_discarded_color_can_be_drawn_after_turn_advances() -> None:
config = LostCitiesConfig()
state = GameState.empty(config)
state.hands[0] = [Card(2, 2)]
state.hands[1] = [Card(0, 1)]
state.deck = [Card(1, 1), Card(1, 2)]
state.apply_action(1)
state.apply_action(0)
assert state.current_player == 1
state.apply_action(1)
assert state.phase == "draw"
assert state.legal_draw_mask()[1 + 2] is True
def test_discarded_card_is_removed_when_drawn_later() -> None:
config = LostCitiesConfig()
state = GameState.empty(config)
state.hands[0] = [Card(2, 2)]
state.hands[1] = [Card(0, 1)]
state.deck = [Card(1, 1), Card(1, 2)]
state.apply_action(1)
assert state.discards[2] == [Card(2, 2)]
state.apply_action(0)
assert state.current_player == 1
state.apply_action(1)
state.apply_action(1 + 2)
assert state.discards[2] == []
assert Card(2, 2) in state.hands[1]
def test_deck_exhaustion_ends_after_last_deck_draw() -> None:
config = LostCitiesConfig()
state = GameState.empty(config)
state.hands[0] = [Card(0, 1)]
state.deck = [Card(1, 1)]
state.apply_action(1)
state.apply_action(0)
assert state.terminal is True
assert len(state.deck) == 0
def test_card_phase_can_end_game_when_no_draw_sources_exist() -> None:
config = LostCitiesConfig(n_colors=3, n_ranks=5, n_handshakes=1, hand_size=5)
state = GameState.empty(config)
state.hands[0] = [Card(0, 1)]
state.hands[1] = [Card(1, 1)]
state.deck = []
state.apply_action(1)
assert state.phase == "draw"
assert state.terminal is True
+167
View File
@@ -0,0 +1,167 @@
import json
from pathlib import Path
import random
import subprocess
import coolrl_lost_cities.games.classic as classic
from coolrl_lost_cities.games.classic.game import GameState, LostCitiesConfig, build_deck
LOST_CITIES_DIR = Path(classic.__file__).resolve().parent
FIXTURE_DIR = LOST_CITIES_DIR / "fixtures"
RUST_CORE_DIR = LOST_CITIES_DIR / "rust_core"
def _run_probe(*args: str) -> dict:
result = subprocess.run(
["cargo", "run", "--quiet", "--bin", "lost_cities_probe", "--", *args],
cwd=RUST_CORE_DIR,
check=True,
text=True,
capture_output=True,
)
return json.loads(result.stdout)
def _python_trace(path: Path) -> dict:
fixture = json.loads(path.read_text())
config = LostCitiesConfig(**fixture["config"])
state = GameState.new_game_from_deck(fixture["initial_deck"], config)
steps = []
for step in fixture["steps"]:
action = step["action"]
if action is not None:
state.apply_unified_action(action)
state.validate_invariants()
snapshot = state.to_snapshot()
steps.append(
{
"action": action,
"phase": state.phase,
"current_player": state.current_player,
"turn_count": state.turn_count,
"terminal": state.terminal,
"pending_discarded_color": state.pending_discarded_color,
"score_diff_player0": state.score_diff(0),
"legal_mask": state.unified_legal_mask(),
"deck": snapshot["deck"],
"hands": snapshot["hands"],
"expeditions": snapshot["expeditions"],
"discards": snapshot["discards"],
}
)
return {"config": config.to_snapshot(), "steps": steps}
def test_rust_default_config_matches_python_default() -> None:
assert _run_probe("defaults") == LostCitiesConfig().to_snapshot()
def test_rust_fixture_trace_matches_python_core() -> None:
fixture_path = FIXTURE_DIR / "canonical_small.json"
assert _run_probe("trace", str(fixture_path)) == _python_trace(fixture_path)
def test_rust_randomized_fixture_traces_match_python_core(tmp_path: Path) -> None:
config = LostCitiesConfig(
n_colors=3,
n_ranks=5,
min_rank=2,
n_handshakes=1,
hand_size=5,
)
for seed in range(12):
deck = build_deck(config)
rng = random.Random(seed)
rng.shuffle(deck)
state = GameState.new_game_from_deck(deck, config)
steps = [{"action": None}]
for _ in range(64):
if state.terminal:
break
legal = [
index
for index, is_legal in enumerate(state.unified_legal_mask())
if is_legal
]
action = rng.choice(legal)
state.apply_unified_action(action)
steps.append({"action": action})
fixture_path = tmp_path / f"parity_{seed}.json"
fixture_path.write_text(
json.dumps(
{
"config": config.to_snapshot(),
"initial_deck": [card.to_snapshot() for card in deck],
"steps": steps,
}
)
)
assert _run_probe("trace", str(fixture_path)) == _python_trace(fixture_path)
def test_rust_engine_contract_is_checked_from_python() -> None:
result = _run_probe("engine")
assert result["duplicate_kind"] == "AlreadyExists"
assert result["missing_config_kind"] == "InvalidArgument"
assert result["empty_session_kind"] == "InvalidArgument"
assert result["unknown_session_kind"] == "NotFound"
assert result["invalid_observer_kind"] == "InvalidArgument"
assert result["invalid_observer_state_unchanged"] is True
assert result["invalid_observer_action_still_applies"] is True
assert result["phase_flow"][:2] == [
{
"state_version": 0,
"current_player": 0,
"observer_player": 0,
"phase": "card",
"terminal": False,
},
{
"state_version": 1,
"current_player": 0,
"observer_player": 0,
"phase": "draw",
"terminal": False,
},
]
assert result["phase_flow"][2]["state_version"] == 2
assert result["stale_kind"] == "FailedPrecondition"
assert result["end_session_counts"] == [1, 0]
assert result["off_turn_legal_empty"] is True
assert result["full_session_terminal_reward_matches"] is True
assert result["full_session_final_scores_match"] is True
assert result["terminal_reject_kind"] == "FailedPrecondition"
assert result["deterministic_match"] is True
def test_rust_grpc_contract_is_checked_from_python() -> None:
result = _run_probe("grpc")
assert result == {
"round_trip_phase": "card",
"opponent_legal_empty": True,
"stale_code": "FailedPrecondition",
"invalid_observer_code": "InvalidArgument",
"invalid_observer_state_unchanged": True,
"ended_session_code": "NotFound",
}
def test_rust_core_has_no_native_tests_left() -> None:
rust_files = [
path
for path in (RUST_CORE_DIR / "src").rglob("*.rs")
if "target" not in path.parts
]
for path in rust_files:
text = path.read_text()
assert "#[test]" not in text
assert "#[tokio::test" not in text
tests_dir = RUST_CORE_DIR / "tests"
assert not list(tests_dir.glob("*.rs"))
+35
View File
@@ -0,0 +1,35 @@
from coolrl_lost_cities.games.classic.game import Card, LostCitiesConfig, score_expedition
def test_empty_expedition_scores_zero() -> None:
assert score_expedition([], LostCitiesConfig()) == 0
def test_handshake_only_deepens_negative_score() -> None:
config = LostCitiesConfig(n_handshakes=3)
assert score_expedition([Card(0, 0), Card(0, 0)], config) == -60
def test_numbers_only_score() -> None:
config = LostCitiesConfig()
expedition = [Card(0, 1), Card(0, 3), Card(0, 5)]
assert score_expedition(expedition, config) == (2 + 4 + 6 - 20)
def test_two_handshakes_and_three_numbers() -> None:
config = LostCitiesConfig(n_handshakes=3)
expedition = [Card(0, 0), Card(0, 0), Card(0, 1), Card(0, 2), Card(0, 3)]
assert score_expedition(expedition, config) == (2 + 3 + 4 - 20) * 3
def test_bonus_threshold_adds_bonus() -> None:
config = LostCitiesConfig(n_ranks=9, n_handshakes=3, bonus_threshold=4, bonus_amount=20)
expedition = [Card(0, 1), Card(0, 2), Card(0, 3), Card(0, 4)]
assert score_expedition(expedition, config) == (2 + 3 + 4 + 5 - 20) + 20
def test_manual_multi_color_examples() -> None:
config = LostCitiesConfig(n_handshakes=3)
assert score_expedition([Card(1, 0), Card(1, 5)], config) == (6 - 20) * 2
assert score_expedition([Card(2, 4), Card(2, 5)], config) == 5 + 6 - 20
assert score_expedition([Card(0, 0), Card(0, 0), Card(0, 0)], config) == -80
Generated
+200
View File
@@ -0,0 +1,200 @@
version = 1
revision = 3
requires-python = ">=3.11"
[[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 = "coolrl-lost-cities"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "numpy" },
]
[package.dev-dependencies]
dev = [
{ name = "cython" },
{ name = "pytest" },
]
[package.metadata]
requires-dist = [{ name = "numpy", specifier = ">=1.26.0" }]
[package.metadata.requires-dev]
dev = [
{ name = "cython", specifier = ">=3.0" },
{ name = "pytest", specifier = ">=9.0.3" },
]
[[package]]
name = "cython"
version = "3.2.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/91/85/7574c9cd44b69a27210444b6650f6477f56c75fee1b70d7672d3e4166167/cython-3.2.4.tar.gz", hash = "sha256:84226ecd313b233da27dc2eb3601b4f222b8209c3a7216d8733b031da1dc64e6", size = 3280291, upload-time = "2026-01-04T14:14:14.473Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/85/cc/8f06145ec3efa121c8b1b67f06a640386ddacd77ee3e574da582a21b14ee/cython-3.2.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ff9af2134c05e3734064808db95b4dd7341a39af06e8945d05ea358e1741aaed", size = 2953769, upload-time = "2026-01-04T14:15:00.361Z" },
{ url = "https://files.pythonhosted.org/packages/55/b0/706cf830eddd831666208af1b3058c2e0758ae157590909c1f634b53bed9/cython-3.2.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:67922c9de058a0bfb72d2e75222c52d09395614108c68a76d9800f150296ddb3", size = 3243841, upload-time = "2026-01-04T14:15:02.066Z" },
{ url = "https://files.pythonhosted.org/packages/ac/25/58893afd4ef45f79e3d4db82742fa4ff874b936d67a83c92939053920ccd/cython-3.2.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b362819d155fff1482575e804e43e3a8825332d32baa15245f4642022664a3f4", size = 3378083, upload-time = "2026-01-04T14:15:04.248Z" },
{ url = "https://files.pythonhosted.org/packages/32/e4/424a004d7c0d8a4050c81846ebbd22272ececfa9a498cb340aa44fccbec2/cython-3.2.4-cp311-cp311-win_amd64.whl", hash = "sha256:1a64a112a34ec719b47c01395647e54fb4cf088a511613f9a3a5196694e8e382", size = 2769990, upload-time = "2026-01-04T14:15:06.53Z" },
{ url = "https://files.pythonhosted.org/packages/91/4d/1eb0c7c196a136b1926f4d7f0492a96c6fabd604d77e6cd43b56a3a16d83/cython-3.2.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:64d7f71be3dd6d6d4a4c575bb3a4674ea06d1e1e5e4cd1b9882a2bc40ed3c4c9", size = 2970064, upload-time = "2026-01-04T14:15:08.567Z" },
{ url = "https://files.pythonhosted.org/packages/03/1c/46e34b08bea19a1cdd1e938a4c123e6299241074642db9d81983cef95e9f/cython-3.2.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:869487ea41d004f8b92171f42271fbfadb1ec03bede3158705d16cd570d6b891", size = 3226757, upload-time = "2026-01-04T14:15:10.812Z" },
{ url = "https://files.pythonhosted.org/packages/12/33/3298a44d201c45bcf0d769659725ae70e9c6c42adf8032f6d89c8241098d/cython-3.2.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:55b6c44cd30821f0b25220ceba6fe636ede48981d2a41b9bbfe3c7902ce44ea7", size = 3388969, upload-time = "2026-01-04T14:15:12.45Z" },
{ url = "https://files.pythonhosted.org/packages/bb/f3/4275cd3ea0a4cf4606f9b92e7f8766478192010b95a7f516d1b7cf22cb10/cython-3.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:767b143704bdd08a563153448955935844e53b852e54afdc552b43902ed1e235", size = 2756457, upload-time = "2026-01-04T14:15:14.67Z" },
{ url = "https://files.pythonhosted.org/packages/18/b5/1cfca43b7d20a0fdb1eac67313d6bb6b18d18897f82dd0f17436bdd2ba7f/cython-3.2.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:28e8075087a59756f2d059273184b8b639fe0f16cf17470bd91c39921bc154e0", size = 2960506, upload-time = "2026-01-04T14:15:16.733Z" },
{ url = "https://files.pythonhosted.org/packages/71/bb/8f28c39c342621047fea349a82fac712a5e2b37546d2f737bbde48d5143d/cython-3.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:03893c88299a2c868bb741ba6513357acd104e7c42265809fd58dce1456a36fc", size = 3213148, upload-time = "2026-01-04T14:15:18.804Z" },
{ url = "https://files.pythonhosted.org/packages/7a/d2/16fa02f129ed2b627e88d9d9ebd5ade3eeb66392ae5ba85b259d2d52b047/cython-3.2.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f81eda419b5ada7b197bbc3c5f4494090e3884521ffd75a3876c93fbf66c9ca8", size = 3375764, upload-time = "2026-01-04T14:15:20.817Z" },
{ url = "https://files.pythonhosted.org/packages/91/3f/deb8f023a5c10c0649eb81332a58c180fad27c7533bb4aae138b5bc34d92/cython-3.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:83266c356c13c68ffe658b4905279c993d8a5337bb0160fa90c8a3e297ea9a2e", size = 2754238, upload-time = "2026-01-04T14:15:23.001Z" },
{ url = "https://files.pythonhosted.org/packages/ee/d7/3bda3efce0c5c6ce79cc21285dbe6f60369c20364e112f5a506ee8a1b067/cython-3.2.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d4b4fd5332ab093131fa6172e8362f16adef3eac3179fd24bbdc392531cb82fa", size = 2971496, upload-time = "2026-01-04T14:15:25.038Z" },
{ url = "https://files.pythonhosted.org/packages/89/ed/1021ffc80b9c4720b7ba869aea8422c82c84245ef117ebe47a556bdc00c3/cython-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e3b5ac54e95f034bc7fb07313996d27cbf71abc17b229b186c1540942d2dc28e", size = 3256146, upload-time = "2026-01-04T14:15:26.741Z" },
{ url = "https://files.pythonhosted.org/packages/0c/51/ca221ec7e94b3c5dc4138dcdcbd41178df1729c1e88c5dfb25f9d30ba3da/cython-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f43be4eaa6afd58ce20d970bb1657a3627c44e1760630b82aa256ba74b4acb", size = 3383458, upload-time = "2026-01-04T14:15:28.425Z" },
{ url = "https://files.pythonhosted.org/packages/79/2e/1388fc0243240cd54994bb74f26aaaf3b2e22f89d3a2cf8da06d75d46ca2/cython-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:983f9d2bb8a896e16fa68f2b37866ded35fa980195eefe62f764ddc5f9f5ef8e", size = 2791241, upload-time = "2026-01-04T14:15:30.448Z" },
{ url = "https://files.pythonhosted.org/packages/0a/8b/fd393f0923c82be4ec0db712fffb2ff0a7a131707b842c99bf24b549274d/cython-3.2.4-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:36bf3f5eb56d5281aafabecbaa6ed288bc11db87547bba4e1e52943ae6961ccf", size = 2875622, upload-time = "2026-01-04T14:15:39.749Z" },
{ url = "https://files.pythonhosted.org/packages/73/48/48530d9b9d64ec11dbe0dd3178a5fe1e0b27977c1054ecffb82be81e9b6a/cython-3.2.4-cp39-abi3-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6d5267f22b6451eb1e2e1b88f6f78a2c9c8733a6ddefd4520d3968d26b824581", size = 3210669, upload-time = "2026-01-04T14:15:41.911Z" },
{ url = "https://files.pythonhosted.org/packages/5e/91/4865fbfef1f6bb4f21d79c46104a53d1a3fa4348286237e15eafb26e0828/cython-3.2.4-cp39-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3b6e58f73a69230218d5381817850ce6d0da5bb7e87eb7d528c7027cbba40b06", size = 2856835, upload-time = "2026-01-04T14:15:43.815Z" },
{ url = "https://files.pythonhosted.org/packages/fa/39/60317957dbef179572398253f29d28f75f94ab82d6d39ea3237fb6c89268/cython-3.2.4-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e71efb20048358a6b8ec604a0532961c50c067b5e63e345e2e359fff72feaee8", size = 2994408, upload-time = "2026-01-04T14:15:45.422Z" },
{ url = "https://files.pythonhosted.org/packages/8d/30/7c24d9292650db4abebce98abc9b49c820d40fa7c87921c0a84c32f4efe7/cython-3.2.4-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:28b1e363b024c4b8dcf52ff68125e635cb9cb4b0ba997d628f25e32543a71103", size = 2891478, upload-time = "2026-01-04T14:15:47.394Z" },
{ url = "https://files.pythonhosted.org/packages/86/70/03dc3c962cde9da37a93cca8360e576f904d5f9beecfc9d70b1f820d2e5f/cython-3.2.4-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:31a90b4a2c47bb6d56baeb926948348ec968e932c1ae2c53239164e3e8880ccf", size = 3225663, upload-time = "2026-01-04T14:15:49.446Z" },
{ url = "https://files.pythonhosted.org/packages/b1/97/10b50c38313c37b1300325e2e53f48ea9a2c078a85c0c9572057135e31d5/cython-3.2.4-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e65e4773021f8dc8532010b4fbebe782c77f9a0817e93886e518c93bd6a44e9d", size = 3115628, upload-time = "2026-01-04T14:15:51.323Z" },
{ url = "https://files.pythonhosted.org/packages/8f/b1/d6a353c9b147848122a0db370863601fdf56de2d983b5c4a6a11e6ee3cd7/cython-3.2.4-cp39-abi3-win32.whl", hash = "sha256:2b1f12c0e4798293d2754e73cd6f35fa5bbdf072bdc14bc6fc442c059ef2d290", size = 2437463, upload-time = "2026-01-04T14:15:53.787Z" },
{ url = "https://files.pythonhosted.org/packages/2d/d8/319a1263b9c33b71343adfd407e5daffd453daef47ebc7b642820a8b68ed/cython-3.2.4-cp39-abi3-win_arm64.whl", hash = "sha256:3b8e62049afef9da931d55de82d8f46c9a147313b69d5ff6af6e9121d545ce7a", size = 2442754, upload-time = "2026-01-04T14:15:55.382Z" },
{ url = "https://files.pythonhosted.org/packages/ff/fa/d3c15189f7c52aaefbaea76fb012119b04b9013f4bf446cb4eb4c26c4e6b/cython-3.2.4-py3-none-any.whl", hash = "sha256:732fc93bc33ae4b14f6afaca663b916c2fdd5dcbfad7114e17fb2434eeaea45c", size = 1257078, upload-time = "2026-01-04T14:14:12.373Z" },
]
[[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 = "numpy"
version = "2.4.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d7/9f/b8cef5bffa569759033adda9481211426f12f53299629b410340795c2514/numpy-2.4.4.tar.gz", hash = "sha256:2d390634c5182175533585cc89f3608a4682ccb173cc9bb940b2881c8d6f8fa0", size = 20731587, upload-time = "2026-03-29T13:22:01.298Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/c6/4218570d8c8ecc9704b5157a3348e486e84ef4be0ed3e38218ab473c83d2/numpy-2.4.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f983334aea213c99992053ede6168500e5f086ce74fbc4acc3f2b00f5762e9db", size = 16976799, upload-time = "2026-03-29T13:18:15.438Z" },
{ url = "https://files.pythonhosted.org/packages/dd/92/b4d922c4a5f5dab9ed44e6153908a5c665b71acf183a83b93b690996e39b/numpy-2.4.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:72944b19f2324114e9dc86a159787333b77874143efcf89a5167ef83cfee8af0", size = 14971552, upload-time = "2026-03-29T13:18:18.606Z" },
{ url = "https://files.pythonhosted.org/packages/8a/dc/df98c095978fa6ee7b9a9387d1d58cbb3d232d0e69ad169a4ce784bde4fd/numpy-2.4.4-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:86b6f55f5a352b48d7fbfd2dbc3d5b780b2d79f4d3c121f33eb6efb22e9a2015", size = 5476566, upload-time = "2026-03-29T13:18:21.532Z" },
{ url = "https://files.pythonhosted.org/packages/28/34/b3fdcec6e725409223dd27356bdf5a3c2cc2282e428218ecc9cb7acc9763/numpy-2.4.4-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:ba1f4fc670ed79f876f70082eff4f9583c15fb9a4b89d6188412de4d18ae2f40", size = 6806482, upload-time = "2026-03-29T13:18:23.634Z" },
{ url = "https://files.pythonhosted.org/packages/68/62/63417c13aa35d57bee1337c67446761dc25ea6543130cf868eace6e8157b/numpy-2.4.4-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a87ec22c87be071b6bdbd27920b129b94f2fc964358ce38f3822635a3e2e03d", size = 15973376, upload-time = "2026-03-29T13:18:26.677Z" },
{ url = "https://files.pythonhosted.org/packages/cf/c5/9fcb7e0e69cef59cf10c746b84f7d58b08bc66a6b7d459783c5a4f6101a6/numpy-2.4.4-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df3775294accfdd75f32c74ae39fcba920c9a378a2fc18a12b6820aa8c1fb502", size = 16925137, upload-time = "2026-03-29T13:18:30.14Z" },
{ url = "https://files.pythonhosted.org/packages/7e/43/80020edacb3f84b9efdd1591120a4296462c23fd8db0dde1666f6ef66f13/numpy-2.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0d4e437e295f18ec29bc79daf55e8a47a9113df44d66f702f02a293d93a2d6dd", size = 17329414, upload-time = "2026-03-29T13:18:33.733Z" },
{ url = "https://files.pythonhosted.org/packages/fd/06/af0658593b18a5f73532d377188b964f239eb0894e664a6c12f484472f97/numpy-2.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6aa3236c78803afbcb255045fbef97a9e25a1f6c9888357d205ddc42f4d6eba5", size = 18658397, upload-time = "2026-03-29T13:18:37.511Z" },
{ url = "https://files.pythonhosted.org/packages/e6/ce/13a09ed65f5d0ce5c7dd0669250374c6e379910f97af2c08c57b0608eee4/numpy-2.4.4-cp311-cp311-win32.whl", hash = "sha256:30caa73029a225b2d40d9fae193e008e24b2026b7ee1a867b7ee8d96ca1a448e", size = 6239499, upload-time = "2026-03-29T13:18:40.372Z" },
{ url = "https://files.pythonhosted.org/packages/bd/63/05d193dbb4b5eec1eca73822d80da98b511f8328ad4ae3ca4caf0f4db91d/numpy-2.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:6bbe4eb67390b0a0265a2c25458f6b90a409d5d069f1041e6aff1e27e3d9a79e", size = 12614257, upload-time = "2026-03-29T13:18:42.95Z" },
{ url = "https://files.pythonhosted.org/packages/87/c5/8168052f080c26fa984c413305012be54741c9d0d74abd7fbeeccae3889f/numpy-2.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:fcfe2045fd2e8f3cb0ce9d4ba6dba6333b8fa05bb8a4939c908cd43322d14c7e", size = 10486775, upload-time = "2026-03-29T13:18:45.835Z" },
{ url = "https://files.pythonhosted.org/packages/28/05/32396bec30fb2263770ee910142f49c1476d08e8ad41abf8403806b520ce/numpy-2.4.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15716cfef24d3a9762e3acdf87e27f58dc823d1348f765bbea6bef8c639bfa1b", size = 16689272, upload-time = "2026-03-29T13:18:49.223Z" },
{ url = "https://files.pythonhosted.org/packages/c5/f3/a983d28637bfcd763a9c7aafdb6d5c0ebf3d487d1e1459ffdb57e2f01117/numpy-2.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:23cbfd4c17357c81021f21540da84ee282b9c8fba38a03b7b9d09ba6b951421e", size = 14699573, upload-time = "2026-03-29T13:18:52.629Z" },
{ url = "https://files.pythonhosted.org/packages/9b/fd/e5ecca1e78c05106d98028114f5c00d3eddb41207686b2b7de3e477b0e22/numpy-2.4.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:8b3b60bb7cba2c8c81837661c488637eee696f59a877788a396d33150c35d842", size = 5204782, upload-time = "2026-03-29T13:18:55.579Z" },
{ url = "https://files.pythonhosted.org/packages/de/2f/702a4594413c1a8632092beae8aba00f1d67947389369b3777aed783fdca/numpy-2.4.4-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:e4a010c27ff6f210ff4c6ef34394cd61470d01014439b192ec22552ee867f2a8", size = 6552038, upload-time = "2026-03-29T13:18:57.769Z" },
{ url = "https://files.pythonhosted.org/packages/7f/37/eed308a8f56cba4d1fdf467a4fc67ef4ff4bf1c888f5fc980481890104b1/numpy-2.4.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9e75681b59ddaa5e659898085ae0eaea229d054f2ac0c7e563a62205a700121", size = 15670666, upload-time = "2026-03-29T13:19:00.341Z" },
{ url = "https://files.pythonhosted.org/packages/0a/0d/0e3ecece05b7a7e87ab9fb587855548da437a061326fff64a223b6dcb78a/numpy-2.4.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:81f4a14bee47aec54f883e0cad2d73986640c1590eb9bfaaba7ad17394481e6e", size = 16645480, upload-time = "2026-03-29T13:19:03.63Z" },
{ url = "https://files.pythonhosted.org/packages/34/49/f2312c154b82a286758ee2f1743336d50651f8b5195db18cdb63675ff649/numpy-2.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:62d6b0f03b694173f9fcb1fb317f7222fd0b0b103e784c6549f5e53a27718c44", size = 17020036, upload-time = "2026-03-29T13:19:07.428Z" },
{ url = "https://files.pythonhosted.org/packages/7b/e9/736d17bd77f1b0ec4f9901aaec129c00d59f5d84d5e79bba540ef12c2330/numpy-2.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fbc356aae7adf9e6336d336b9c8111d390a05df88f1805573ebb0807bd06fd1d", size = 18368643, upload-time = "2026-03-29T13:19:10.775Z" },
{ url = "https://files.pythonhosted.org/packages/63/f6/d417977c5f519b17c8a5c3bc9e8304b0908b0e21136fe43bf628a1343914/numpy-2.4.4-cp312-cp312-win32.whl", hash = "sha256:0d35aea54ad1d420c812bfa0385c71cd7cc5bcf7c65fed95fc2cd02fe8c79827", size = 5961117, upload-time = "2026-03-29T13:19:13.464Z" },
{ url = "https://files.pythonhosted.org/packages/2d/5b/e1deebf88ff431b01b7406ca3583ab2bbb90972bbe1c568732e49c844f7e/numpy-2.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:b5f0362dc928a6ecd9db58868fca5e48485205e3855957bdedea308f8672ea4a", size = 12320584, upload-time = "2026-03-29T13:19:16.155Z" },
{ url = "https://files.pythonhosted.org/packages/58/89/e4e856ac82a68c3ed64486a544977d0e7bdd18b8da75b78a577ca31c4395/numpy-2.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:846300f379b5b12cc769334464656bc882e0735d27d9726568bc932fdc49d5ec", size = 10221450, upload-time = "2026-03-29T13:19:18.994Z" },
{ url = "https://files.pythonhosted.org/packages/14/1d/d0a583ce4fefcc3308806a749a536c201ed6b5ad6e1322e227ee4848979d/numpy-2.4.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:08f2e31ed5e6f04b118e49821397f12767934cfdd12a1ce86a058f91e004ee50", size = 16684933, upload-time = "2026-03-29T13:19:22.47Z" },
{ url = "https://files.pythonhosted.org/packages/c1/62/2b7a48fbb745d344742c0277f01286dead15f3f68e4f359fbfcf7b48f70f/numpy-2.4.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e823b8b6edc81e747526f70f71a9c0a07ac4e7ad13020aa736bb7c9d67196115", size = 14694532, upload-time = "2026-03-29T13:19:25.581Z" },
{ url = "https://files.pythonhosted.org/packages/e5/87/499737bfba066b4a3bebff24a8f1c5b2dee410b209bc6668c9be692580f0/numpy-2.4.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4a19d9dba1a76618dd86b164d608566f393f8ec6ac7c44f0cc879011c45e65af", size = 5199661, upload-time = "2026-03-29T13:19:28.31Z" },
{ url = "https://files.pythonhosted.org/packages/cd/da/464d551604320d1491bc345efed99b4b7034143a85787aab78d5691d5a0e/numpy-2.4.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d2a8490669bfe99a233298348acc2d824d496dee0e66e31b66a6022c2ad74a5c", size = 6547539, upload-time = "2026-03-29T13:19:30.97Z" },
{ url = "https://files.pythonhosted.org/packages/7d/90/8d23e3b0dafd024bf31bdec225b3bb5c2dbfa6912f8a53b8659f21216cbf/numpy-2.4.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45dbed2ab436a9e826e302fcdcbe9133f9b0006e5af7168afb8963a6520da103", size = 15668806, upload-time = "2026-03-29T13:19:33.887Z" },
{ url = "https://files.pythonhosted.org/packages/d1/73/a9d864e42a01896bb5974475438f16086be9ba1f0d19d0bb7a07427c4a8b/numpy-2.4.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c901b15172510173f5cb310eae652908340f8dede90fff9e3bf6c0d8dfd92f83", size = 16632682, upload-time = "2026-03-29T13:19:37.336Z" },
{ url = "https://files.pythonhosted.org/packages/34/fb/14570d65c3bde4e202a031210475ae9cde9b7686a2e7dc97ee67d2833b35/numpy-2.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:99d838547ace2c4aace6c4f76e879ddfe02bb58a80c1549928477862b7a6d6ed", size = 17019810, upload-time = "2026-03-29T13:19:40.963Z" },
{ url = "https://files.pythonhosted.org/packages/8a/77/2ba9d87081fd41f6d640c83f26fb7351e536b7ce6dd9061b6af5904e8e46/numpy-2.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0aec54fd785890ecca25a6003fd9a5aed47ad607bbac5cd64f836ad8666f4959", size = 18357394, upload-time = "2026-03-29T13:19:44.859Z" },
{ url = "https://files.pythonhosted.org/packages/a2/23/52666c9a41708b0853fa3b1a12c90da38c507a3074883823126d4e9d5b30/numpy-2.4.4-cp313-cp313-win32.whl", hash = "sha256:07077278157d02f65c43b1b26a3886bce886f95d20aabd11f87932750dfb14ed", size = 5959556, upload-time = "2026-03-29T13:19:47.661Z" },
{ url = "https://files.pythonhosted.org/packages/57/fb/48649b4971cde70d817cf97a2a2fdc0b4d8308569f1dd2f2611959d2e0cf/numpy-2.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:5c70f1cc1c4efbe316a572e2d8b9b9cc44e89b95f79ca3331553fbb63716e2bf", size = 12317311, upload-time = "2026-03-29T13:19:50.67Z" },
{ url = "https://files.pythonhosted.org/packages/ba/d8/11490cddd564eb4de97b4579ef6bfe6a736cc07e94c1598590ae25415e01/numpy-2.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:ef4059d6e5152fa1a39f888e344c73fdc926e1b2dd58c771d67b0acfbf2aa67d", size = 10222060, upload-time = "2026-03-29T13:19:54.229Z" },
{ url = "https://files.pythonhosted.org/packages/99/5d/dab4339177a905aad3e2221c915b35202f1ec30d750dd2e5e9d9a72b804b/numpy-2.4.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4bbc7f303d125971f60ec0aaad5e12c62d0d2c925f0ab1273debd0e4ba37aba5", size = 14822302, upload-time = "2026-03-29T13:19:57.585Z" },
{ url = "https://files.pythonhosted.org/packages/eb/e4/0564a65e7d3d97562ed6f9b0fd0fb0a6f559ee444092f105938b50043876/numpy-2.4.4-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:4d6d57903571f86180eb98f8f0c839fa9ebbfb031356d87f1361be91e433f5b7", size = 5327407, upload-time = "2026-03-29T13:20:00.601Z" },
{ url = "https://files.pythonhosted.org/packages/29/8d/35a3a6ce5ad371afa58b4700f1c820f8f279948cca32524e0a695b0ded83/numpy-2.4.4-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:4636de7fd195197b7535f231b5de9e4b36d2c440b6e566d2e4e4746e6af0ca93", size = 6647631, upload-time = "2026-03-29T13:20:02.855Z" },
{ url = "https://files.pythonhosted.org/packages/f4/da/477731acbd5a58a946c736edfdabb2ac5b34c3d08d1ba1a7b437fa0884df/numpy-2.4.4-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ad2e2ef14e0b04e544ea2fa0a36463f847f113d314aa02e5b402fdf910ef309e", size = 15727691, upload-time = "2026-03-29T13:20:06.004Z" },
{ url = "https://files.pythonhosted.org/packages/e6/db/338535d9b152beabeb511579598418ba0212ce77cf9718edd70262cc4370/numpy-2.4.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a285b3b96f951841799528cd1f4f01cd70e7e0204b4abebac9463eecfcf2a40", size = 16681241, upload-time = "2026-03-29T13:20:09.417Z" },
{ url = "https://files.pythonhosted.org/packages/e2/a9/ad248e8f58beb7a0219b413c9c7d8151c5d285f7f946c3e26695bdbbe2df/numpy-2.4.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f8474c4241bc18b750be2abea9d7a9ec84f46ef861dbacf86a4f6e043401f79e", size = 17085767, upload-time = "2026-03-29T13:20:13.126Z" },
{ url = "https://files.pythonhosted.org/packages/b5/1a/3b88ccd3694681356f70da841630e4725a7264d6a885c8d442a697e1146b/numpy-2.4.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4e874c976154687c1f71715b034739b45c7711bec81db01914770373d125e392", size = 18403169, upload-time = "2026-03-29T13:20:17.096Z" },
{ url = "https://files.pythonhosted.org/packages/c2/c9/fcfd5d0639222c6eac7f304829b04892ef51c96a75d479214d77e3ce6e33/numpy-2.4.4-cp313-cp313t-win32.whl", hash = "sha256:9c585a1790d5436a5374bac930dad6ed244c046ed91b2b2a3634eb2971d21008", size = 6083477, upload-time = "2026-03-29T13:20:20.195Z" },
{ url = "https://files.pythonhosted.org/packages/d5/e3/3938a61d1c538aaec8ed6fd6323f57b0c2d2d2219512434c5c878db76553/numpy-2.4.4-cp313-cp313t-win_amd64.whl", hash = "sha256:93e15038125dc1e5345d9b5b68aa7f996ec33b98118d18c6ca0d0b7d6198b7e8", size = 12457487, upload-time = "2026-03-29T13:20:22.946Z" },
{ url = "https://files.pythonhosted.org/packages/97/6a/7e345032cc60501721ef94e0e30b60f6b0bd601f9174ebd36389a2b86d40/numpy-2.4.4-cp313-cp313t-win_arm64.whl", hash = "sha256:0dfd3f9d3adbe2920b68b5cd3d51444e13a10792ec7154cd0a2f6e74d4ab3233", size = 10292002, upload-time = "2026-03-29T13:20:25.909Z" },
{ url = "https://files.pythonhosted.org/packages/6e/06/c54062f85f673dd5c04cbe2f14c3acb8c8b95e3384869bb8cc9bff8cb9df/numpy-2.4.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f169b9a863d34f5d11b8698ead99febeaa17a13ca044961aa8e2662a6c7766a0", size = 16684353, upload-time = "2026-03-29T13:20:29.504Z" },
{ url = "https://files.pythonhosted.org/packages/4c/39/8a320264a84404c74cc7e79715de85d6130fa07a0898f67fb5cd5bd79908/numpy-2.4.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2483e4584a1cb3092da4470b38866634bafb223cbcd551ee047633fd2584599a", size = 14704914, upload-time = "2026-03-29T13:20:33.547Z" },
{ url = "https://files.pythonhosted.org/packages/91/fb/287076b2614e1d1044235f50f03748f31fa287e3dbe6abeb35cdfa351eca/numpy-2.4.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:2d19e6e2095506d1736b7d80595e0f252d76b89f5e715c35e06e937679ea7d7a", size = 5210005, upload-time = "2026-03-29T13:20:36.45Z" },
{ url = "https://files.pythonhosted.org/packages/63/eb/fcc338595309910de6ecabfcef2419a9ce24399680bfb149421fa2df1280/numpy-2.4.4-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:6a246d5914aa1c820c9443ddcee9c02bec3e203b0c080349533fae17727dfd1b", size = 6544974, upload-time = "2026-03-29T13:20:39.014Z" },
{ url = "https://files.pythonhosted.org/packages/44/5d/e7e9044032a716cdfaa3fba27a8e874bf1c5f1912a1ddd4ed071bf8a14a6/numpy-2.4.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:989824e9faf85f96ec9c7761cd8d29c531ad857bfa1daa930cba85baaecf1a9a", size = 15684591, upload-time = "2026-03-29T13:20:42.146Z" },
{ url = "https://files.pythonhosted.org/packages/98/7c/21252050676612625449b4807d6b695b9ce8a7c9e1c197ee6216c8a65c7c/numpy-2.4.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:27a8d92cd10f1382a67d7cf4db7ce18341b66438bdd9f691d7b0e48d104c2a9d", size = 16637700, upload-time = "2026-03-29T13:20:46.204Z" },
{ url = "https://files.pythonhosted.org/packages/b1/29/56d2bbef9465db24ef25393383d761a1af4f446a1df9b8cded4fe3a5a5d7/numpy-2.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e44319a2953c738205bf3354537979eaa3998ed673395b964c1176083dd46252", size = 17035781, upload-time = "2026-03-29T13:20:50.242Z" },
{ url = "https://files.pythonhosted.org/packages/e3/2b/a35a6d7589d21f44cea7d0a98de5ddcbb3d421b2622a5c96b1edf18707c3/numpy-2.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e892aff75639bbef0d2a2cfd55535510df26ff92f63c92cd84ef8d4ba5a5557f", size = 18362959, upload-time = "2026-03-29T13:20:54.019Z" },
{ url = "https://files.pythonhosted.org/packages/64/c9/d52ec581f2390e0f5f85cbfd80fb83d965fc15e9f0e1aec2195faa142cde/numpy-2.4.4-cp314-cp314-win32.whl", hash = "sha256:1378871da56ca8943c2ba674530924bb8ca40cd228358a3b5f302ad60cf875fc", size = 6008768, upload-time = "2026-03-29T13:20:56.912Z" },
{ url = "https://files.pythonhosted.org/packages/fa/22/4cc31a62a6c7b74a8730e31a4274c5dc80e005751e277a2ce38e675e4923/numpy-2.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:715d1c092715954784bc79e1174fc2a90093dc4dc84ea15eb14dad8abdcdeb74", size = 12449181, upload-time = "2026-03-29T13:20:59.548Z" },
{ url = "https://files.pythonhosted.org/packages/70/2e/14cda6f4d8e396c612d1bf97f22958e92148801d7e4f110cabebdc0eef4b/numpy-2.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:2c194dd721e54ecad9ad387c1d35e63dce5c4450c6dc7dd5611283dda239aabb", size = 10496035, upload-time = "2026-03-29T13:21:02.524Z" },
{ url = "https://files.pythonhosted.org/packages/b1/e8/8fed8c8d848d7ecea092dc3469643f9d10bc3a134a815a3b033da1d2039b/numpy-2.4.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2aa0613a5177c264ff5921051a5719d20095ea586ca88cc802c5c218d1c67d3e", size = 14824958, upload-time = "2026-03-29T13:21:05.671Z" },
{ url = "https://files.pythonhosted.org/packages/05/1a/d8007a5138c179c2bf33ef44503e83d70434d2642877ee8fbb230e7c0548/numpy-2.4.4-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:42c16925aa5a02362f986765f9ebabf20de75cdefdca827d14315c568dcab113", size = 5330020, upload-time = "2026-03-29T13:21:08.635Z" },
{ url = "https://files.pythonhosted.org/packages/99/64/ffb99ac6ae93faf117bcbd5c7ba48a7f45364a33e8e458545d3633615dda/numpy-2.4.4-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:874f200b2a981c647340f841730fc3a2b54c9d940566a3c4149099591e2c4c3d", size = 6650758, upload-time = "2026-03-29T13:21:10.949Z" },
{ url = "https://files.pythonhosted.org/packages/6e/6e/795cc078b78a384052e73b2f6281ff7a700e9bf53bcce2ee579d4f6dd879/numpy-2.4.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9b39d38a9bd2ae1becd7eac1303d031c5c110ad31f2b319c6e7d98b135c934d", size = 15729948, upload-time = "2026-03-29T13:21:14.047Z" },
{ url = "https://files.pythonhosted.org/packages/5f/86/2acbda8cc2af5f3d7bfc791192863b9e3e19674da7b5e533fded124d1299/numpy-2.4.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b268594bccac7d7cf5844c7732e3f20c50921d94e36d7ec9b79e9857694b1b2f", size = 16679325, upload-time = "2026-03-29T13:21:17.561Z" },
{ url = "https://files.pythonhosted.org/packages/bc/59/cafd83018f4aa55e0ac6fa92aa066c0a1877b77a615ceff1711c260ffae8/numpy-2.4.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ac6b31e35612a26483e20750126d30d0941f949426974cace8e6b5c58a3657b0", size = 17084883, upload-time = "2026-03-29T13:21:21.106Z" },
{ url = "https://files.pythonhosted.org/packages/f0/85/a42548db84e65ece46ab2caea3d3f78b416a47af387fcbb47ec28e660dc2/numpy-2.4.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8e3ed142f2728df44263aaf5fb1f5b0b99f4070c553a0d7f033be65338329150", size = 18403474, upload-time = "2026-03-29T13:21:24.828Z" },
{ url = "https://files.pythonhosted.org/packages/ed/ad/483d9e262f4b831000062e5d8a45e342166ec8aaa1195264982bca267e62/numpy-2.4.4-cp314-cp314t-win32.whl", hash = "sha256:dddbbd259598d7240b18c9d87c56a9d2fb3b02fe266f49a7c101532e78c1d871", size = 6155500, upload-time = "2026-03-29T13:21:28.205Z" },
{ url = "https://files.pythonhosted.org/packages/c7/03/2fc4e14c7bd4ff2964b74ba90ecb8552540b6315f201df70f137faa5c589/numpy-2.4.4-cp314-cp314t-win_amd64.whl", hash = "sha256:a7164afb23be6e37ad90b2f10426149fd75aee07ca55653d2aa41e66c4ef697e", size = 12637755, upload-time = "2026-03-29T13:21:31.107Z" },
{ url = "https://files.pythonhosted.org/packages/58/78/548fb8e07b1a341746bfbecb32f2c268470f45fa028aacdbd10d9bc73aab/numpy-2.4.4-cp314-cp314t-win_arm64.whl", hash = "sha256:ba203255017337d39f89bdd58417f03c4426f12beed0440cfd933cb15f8669c7", size = 10566643, upload-time = "2026-03-29T13:21:34.339Z" },
{ url = "https://files.pythonhosted.org/packages/6b/33/8fae8f964a4f63ed528264ddf25d2b683d0b663e3cba26961eb838a7c1bd/numpy-2.4.4-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:58c8b5929fcb8287cbd6f0a3fae19c6e03a5c48402ae792962ac465224a629a4", size = 16854491, upload-time = "2026-03-29T13:21:38.03Z" },
{ url = "https://files.pythonhosted.org/packages/bc/d0/1aabee441380b981cf8cdda3ae7a46aa827d1b5a8cce84d14598bc94d6d9/numpy-2.4.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:eea7ac5d2dce4189771cedb559c738a71512768210dc4e4753b107a2048b3d0e", size = 14895830, upload-time = "2026-03-29T13:21:41.509Z" },
{ url = "https://files.pythonhosted.org/packages/a5/b8/aafb0d1065416894fccf4df6b49ef22b8db045187949545bced89c034b8e/numpy-2.4.4-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:51fc224f7ca4d92656d5a5eb315f12eb5fe2c97a66249aa7b5f562528a3be38c", size = 5400927, upload-time = "2026-03-29T13:21:44.747Z" },
{ url = "https://files.pythonhosted.org/packages/d6/77/063baa20b08b431038c7f9ff5435540c7b7265c78cf56012a483019ca72d/numpy-2.4.4-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:28a650663f7314afc3e6ec620f44f333c386aad9f6fc472030865dc0ebb26ee3", size = 6715557, upload-time = "2026-03-29T13:21:47.406Z" },
{ url = "https://files.pythonhosted.org/packages/c7/a8/379542d45a14f149444c5c4c4e7714707239ce9cc1de8c2803958889da14/numpy-2.4.4-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:19710a9ca9992d7174e9c52f643d4272dcd1558c5f7af7f6f8190f633bd651a7", size = 15804253, upload-time = "2026-03-29T13:21:50.753Z" },
{ url = "https://files.pythonhosted.org/packages/a2/c8/f0a45426d6d21e7ea3310a15cf90c43a14d9232c31a837702dba437f3373/numpy-2.4.4-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9b2aec6af35c113b05695ebb5749a787acd63cafc83086a05771d1e1cd1e555f", size = 16753552, upload-time = "2026-03-29T13:21:54.344Z" },
{ url = "https://files.pythonhosted.org/packages/04/74/f4c001f4714c3ad9ce037e18cf2b9c64871a84951eaa0baf683a9ca9301c/numpy-2.4.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f2cf083b324a467e1ab358c105f6cad5ea950f50524668a80c486ff1db24e119", size = 12509075, upload-time = "2026-03-29T13:21:57.644Z" },
]
[[package]]
name = "packaging"
version = "26.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
]
[[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.20.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
]
[[package]]
name = "pytest"
version = "9.0.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
]