From 7df6e4290413065b86d32a31ea71d87aab1ccef6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=8B=9C=EC=9B=90?= Date: Wed, 6 May 2026 19:07:46 +0900 Subject: [PATCH] =?UTF-8?q?=EB=A1=9C=EC=8A=A4=ED=8A=B8=20=EC=8B=9C?= =?UTF-8?q?=ED=8B=B0=20=ED=81=B4=EB=9E=98=EC=8B=9D=20=EC=BD=94=EC=96=B4=20?= =?UTF-8?q?=EC=9D=B4=EC=8B=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 맥락: - 새 레포의 첫 범위를 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 --- .gitignore | 17 + .python-version | 1 + README.md | 24 + docs/classic-port-notes.md | 82 + pyproject.toml | 42 + setup.py | 30 + src/coolrl_lost_cities/__init__.py | 3 + src/coolrl_lost_cities/games/__init__.py | 0 .../games/classic/__init__.py | 17 + .../classic/assets/pygame_pvp_theme.json | 60 + .../games/classic/backends/__init__.py | 11 + .../games/classic/backends/common.py | 63 + .../games/classic/backends/factory.py | 18 + .../games/classic/backends/python.py | 52 + .../games/classic/backends/rust.py | 108 ++ .../games/classic/bots/__init__.py | 21 + .../games/classic/bots/base.py | 24 + .../games/classic/bots/heuristic.py | 1199 +++++++++++++++ .../games/classic/bots/passive.py | 54 + .../games/classic/bots/play.py | 61 + .../games/classic/bots/random.py | 21 + .../games/classic/bots/registry.py | 30 + .../games/classic/docs/lost_cities_spec.md | 202 +++ src/coolrl_lost_cities/games/classic/env.py | 73 + .../classic/fixtures/canonical_small.json | 85 + src/coolrl_lost_cities/games/classic/game.pyx | 718 +++++++++ .../games/classic/interfaces.py | 67 + .../games/classic/rust_core/Cargo.lock | 1367 +++++++++++++++++ .../games/classic/rust_core/Cargo.toml | 22 + .../games/classic/rust_core/build.rs | 15 + .../rust_core/src/bin/lost_cities_probe.rs | 779 ++++++++++ .../games/classic/rust_core/src/config.rs | 131 ++ .../games/classic/rust_core/src/engine.rs | 231 +++ .../games/classic/rust_core/src/error.rs | 57 + .../games/classic/rust_core/src/lib.rs | 15 + .../games/classic/rust_core/src/service.rs | 82 + .../games/classic/rust_core/src/state.rs | 550 +++++++ .../games/classic/schemas/lost_cities.proto | 225 +++ tests/games/classic/test_bots.py | 206 +++ tests/games/classic/test_canonical_state.py | 136 ++ tests/games/classic/test_env.py | 62 + tests/games/classic/test_masks.py | 58 + tests/games/classic/test_rules.py | 110 ++ tests/games/classic/test_rust_parity.py | 167 ++ tests/games/classic/test_scoring.py | 35 + uv.lock | 200 +++ 46 files changed, 7531 insertions(+) create mode 100644 .gitignore create mode 100644 .python-version create mode 100644 README.md create mode 100644 docs/classic-port-notes.md create mode 100644 pyproject.toml create mode 100644 setup.py create mode 100644 src/coolrl_lost_cities/__init__.py create mode 100644 src/coolrl_lost_cities/games/__init__.py create mode 100644 src/coolrl_lost_cities/games/classic/__init__.py create mode 100644 src/coolrl_lost_cities/games/classic/assets/pygame_pvp_theme.json create mode 100644 src/coolrl_lost_cities/games/classic/backends/__init__.py create mode 100644 src/coolrl_lost_cities/games/classic/backends/common.py create mode 100644 src/coolrl_lost_cities/games/classic/backends/factory.py create mode 100644 src/coolrl_lost_cities/games/classic/backends/python.py create mode 100644 src/coolrl_lost_cities/games/classic/backends/rust.py create mode 100644 src/coolrl_lost_cities/games/classic/bots/__init__.py create mode 100644 src/coolrl_lost_cities/games/classic/bots/base.py create mode 100644 src/coolrl_lost_cities/games/classic/bots/heuristic.py create mode 100644 src/coolrl_lost_cities/games/classic/bots/passive.py create mode 100644 src/coolrl_lost_cities/games/classic/bots/play.py create mode 100644 src/coolrl_lost_cities/games/classic/bots/random.py create mode 100644 src/coolrl_lost_cities/games/classic/bots/registry.py create mode 100644 src/coolrl_lost_cities/games/classic/docs/lost_cities_spec.md create mode 100644 src/coolrl_lost_cities/games/classic/env.py create mode 100644 src/coolrl_lost_cities/games/classic/fixtures/canonical_small.json create mode 100644 src/coolrl_lost_cities/games/classic/game.pyx create mode 100644 src/coolrl_lost_cities/games/classic/interfaces.py create mode 100644 src/coolrl_lost_cities/games/classic/rust_core/Cargo.lock create mode 100644 src/coolrl_lost_cities/games/classic/rust_core/Cargo.toml create mode 100644 src/coolrl_lost_cities/games/classic/rust_core/build.rs create mode 100644 src/coolrl_lost_cities/games/classic/rust_core/src/bin/lost_cities_probe.rs create mode 100644 src/coolrl_lost_cities/games/classic/rust_core/src/config.rs create mode 100644 src/coolrl_lost_cities/games/classic/rust_core/src/engine.rs create mode 100644 src/coolrl_lost_cities/games/classic/rust_core/src/error.rs create mode 100644 src/coolrl_lost_cities/games/classic/rust_core/src/lib.rs create mode 100644 src/coolrl_lost_cities/games/classic/rust_core/src/service.rs create mode 100644 src/coolrl_lost_cities/games/classic/rust_core/src/state.rs create mode 100644 src/coolrl_lost_cities/games/classic/schemas/lost_cities.proto create mode 100644 tests/games/classic/test_bots.py create mode 100644 tests/games/classic/test_canonical_state.py create mode 100644 tests/games/classic/test_env.py create mode 100644 tests/games/classic/test_masks.py create mode 100644 tests/games/classic/test_rules.py create mode 100644 tests/games/classic/test_rust_parity.py create mode 100644 tests/games/classic/test_scoring.py create mode 100644 uv.lock diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b4ff4b8 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..2c07333 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.11 diff --git a/README.md b/README.md new file mode 100644 index 0000000..b554ac9 --- /dev/null +++ b/README.md @@ -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. diff --git a/docs/classic-port-notes.md b/docs/classic-port-notes.md new file mode 100644 index 0000000..7247fad --- /dev/null +++ b/docs/classic-port-notes.md @@ -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. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..4523017 --- /dev/null +++ b/pyproject.toml @@ -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", +] diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..ee216a7 --- /dev/null +++ b/setup.py @@ -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) diff --git a/src/coolrl_lost_cities/__init__.py b/src/coolrl_lost_cities/__init__.py new file mode 100644 index 0000000..bdec2fc --- /dev/null +++ b/src/coolrl_lost_cities/__init__.py @@ -0,0 +1,3 @@ +from __future__ import annotations + +__all__: list[str] = [] diff --git a/src/coolrl_lost_cities/games/__init__.py b/src/coolrl_lost_cities/games/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/coolrl_lost_cities/games/classic/__init__.py b/src/coolrl_lost_cities/games/classic/__init__.py new file mode 100644 index 0000000..5418e6a --- /dev/null +++ b/src/coolrl_lost_cities/games/classic/__init__.py @@ -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") diff --git a/src/coolrl_lost_cities/games/classic/assets/pygame_pvp_theme.json b/src/coolrl_lost_cities/games/classic/assets/pygame_pvp_theme.json new file mode 100644 index 0000000..29a45bd --- /dev/null +++ b/src/coolrl_lost_cities/games/classic/assets/pygame_pvp_theme.json @@ -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" + } +} diff --git a/src/coolrl_lost_cities/games/classic/backends/__init__.py b/src/coolrl_lost_cities/games/classic/backends/__init__.py new file mode 100644 index 0000000..da19037 --- /dev/null +++ b/src/coolrl_lost_cities/games/classic/backends/__init__.py @@ -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", +] diff --git a/src/coolrl_lost_cities/games/classic/backends/common.py b/src/coolrl_lost_cities/games/classic/backends/common.py new file mode 100644 index 0000000..b6d7297 --- /dev/null +++ b/src/coolrl_lost_cities/games/classic/backends/common.py @@ -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] diff --git a/src/coolrl_lost_cities/games/classic/backends/factory.py b/src/coolrl_lost_cities/games/classic/backends/factory.py new file mode 100644 index 0000000..f31ffc0 --- /dev/null +++ b/src/coolrl_lost_cities/games/classic/backends/factory.py @@ -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}") diff --git a/src/coolrl_lost_cities/games/classic/backends/python.py b/src/coolrl_lost_cities/games/classic/backends/python.py new file mode 100644 index 0000000..7b0f475 --- /dev/null +++ b/src/coolrl_lost_cities/games/classic/backends/python.py @@ -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 diff --git a/src/coolrl_lost_cities/games/classic/backends/rust.py b/src/coolrl_lost_cities/games/classic/backends/rust.py new file mode 100644 index 0000000..a2f7ec2 --- /dev/null +++ b/src/coolrl_lost_cities/games/classic/backends/rust.py @@ -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 diff --git a/src/coolrl_lost_cities/games/classic/bots/__init__.py b/src/coolrl_lost_cities/games/classic/bots/__init__.py new file mode 100644 index 0000000..8d4d901 --- /dev/null +++ b/src/coolrl_lost_cities/games/classic/bots/__init__.py @@ -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", +] diff --git a/src/coolrl_lost_cities/games/classic/bots/base.py b/src/coolrl_lost_cities/games/classic/bots/base.py new file mode 100644 index 0000000..af96884 --- /dev/null +++ b/src/coolrl_lost_cities/games/classic/bots/base.py @@ -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]) diff --git a/src/coolrl_lost_cities/games/classic/bots/heuristic.py b/src/coolrl_lost_cities/games/classic/bots/heuristic.py new file mode 100644 index 0000000..fac8095 --- /dev/null +++ b/src/coolrl_lost_cities/games/classic/bots/heuristic.py @@ -0,0 +1,1199 @@ +from __future__ import annotations + +from dataclasses import dataclass +from functools import lru_cache +import logging + +from ..game import Card, GameState, LostCitiesConfig +from ..interfaces import BotInput, LostCitiesBot +from .base import first_legal, 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 + + +PLAY_OR_DISCARD_ACTIONS_PER_SLOT = 2 +DRAW_FROM_DECK_ACTION = 0 + + +def play_action(slot: int) -> int: + return PLAY_OR_DISCARD_ACTIONS_PER_SLOT * slot + + +def discard_action(slot: int) -> int: + return PLAY_OR_DISCARD_ACTIONS_PER_SLOT * slot + 1 + + +def draw_from_discard_action(color: int) -> int: + return 1 + color + + +LOGGER = logging.getLogger("coolrl_lost_cities.games.classic.bots.safe_heuristic") + + +@dataclass(frozen=True) +class SafeHeuristicParams: + # Expedition opening. + open_target_ratio: float = 0.50 + open_min_card_ratio: float = 0.40 + + # Handshake / investment behavior. + handshake_target_multiplier: float = 1.15 + handshake_min_card_ratio: float = 0.34 + + # Game phase thresholds. + late_deck_ratio: float = 0.20 + mid_deck_ratio: float = 0.35 + + # Evaluation weights. + commitment_weight: float = 1.00 + gift_penalty_weight: float = 1.00 + discard_safety_bonus: float = 6.00 + unusable_discard_bonus: float = 20.00 + + # Draw preferences. + deck_draw_early_value: float = 2.00 + deck_draw_mid_value: float = 1.00 + deck_draw_late_value: float = -1.00 + deny_opponent_weight: float = 0.40 + winning_deck_bonus: float = 0.75 + losing_deck_penalty: float = 1.25 + losing_visible_draw_bonus: float = 1.50 + speculative_visible_draw_bonus: float = 1.50 + dead_visible_draw_penalty: float = 2.00 + unopened_draw_penalty_three_open: float = 10.00 + unopened_draw_penalty_four_open: float = 20.00 + strong_deny_threshold: float = 10.00 + + # General behavior. + late_open_block_ratio: float = 0.20 + low_card_sequence_bonus: float = 5.00 + started_expedition_play_bonus: float = 4.00 + started_expedition_followup_bonus: float = 3.00 + + +@dataclass(frozen=True) +class DerivedHeuristicConfig: + middle_rank: int + max_color_sum: int + break_even_sum: int + open_target_sum: float + min_open_cards: int + min_handshake_numeric_cards: int + late_deck_threshold: int + mid_deck_threshold: int + late_open_block_threshold: int + bonus_possible: bool + max_expedition_cards: int + + +@lru_cache(maxsize=64) +def derive_heuristic_config( + config: LostCitiesConfig, + params: SafeHeuristicParams, +) -> DerivedHeuristicConfig: + max_color_sum = sum( + config.min_rank + rank - 1 + for rank in range(1, config.n_ranks + 1) + ) + break_even_sum = -config.expedition_penalty + + # In small tiers, break-even can be impossible. Do not set impossible targets. + open_target_sum = min( + 0.8 * float(break_even_sum), + params.open_target_ratio * float(max_color_sum), + ) + + min_open_cards = max( + 1, + min( + config.hand_size, + round(config.hand_size * params.open_min_card_ratio), + ), + ) + + min_handshake_numeric_cards = max( + 1, + min( + config.hand_size, + round(config.hand_size * params.handshake_min_card_ratio), + ), + ) + + late_deck_threshold = max(1, round(config.deck_size * params.late_deck_ratio)) + mid_deck_threshold = max( + late_deck_threshold + 1, + round(config.deck_size * params.mid_deck_ratio), + ) + late_open_block_threshold = max( + 1, + round(config.deck_size * params.late_open_block_ratio), + ) + + max_expedition_cards = config.n_handshakes + config.n_ranks + + return DerivedHeuristicConfig( + middle_rank=(config.n_ranks + 1) // 2, + max_color_sum=max_color_sum, + break_even_sum=break_even_sum, + open_target_sum=open_target_sum, + min_open_cards=min_open_cards, + min_handshake_numeric_cards=min_handshake_numeric_cards, + late_deck_threshold=late_deck_threshold, + mid_deck_threshold=mid_deck_threshold, + late_open_block_threshold=late_open_block_threshold, + bonus_possible=max_expedition_cards >= config.bonus_threshold, + max_expedition_cards=max_expedition_cards, + ) + + +class SafeHeuristicBot(LostCitiesBot): + def __init__(self, params: SafeHeuristicParams | None = None): + self.params = params or SafeHeuristicParams() + + def act(self, obs_or_state: BotInput) -> int: + if not isinstance(obs_or_state, GameState): + LOGGER.debug( + "SafeHeuristicBot fallback to first legal: input_type=%s", + type(obs_or_state).__name__, + ) + return first_legal(legal_from_obs(obs_or_state)) + + LOGGER.debug( + "SafeHeuristicBot heuristic path: player=%s phase=%s turn=%s", + obs_or_state.current_player, + obs_or_state.phase, + obs_or_state.turn_count, + ) + if obs_or_state.phase == "card": + return self._act_card(obs_or_state) + + return self._act_draw(obs_or_state) + + def _act_card(self, state: GameState) -> int: + player = state.current_player + hand = state.hands[player] + legal = state.legal_card_mask() + derived = self._derived(state) + deck_left = len(state.deck) + + handshake_action = self._best_handshake_play( + state=state, + player=player, + hand=hand, + legal=legal, + derived=derived, + deck_left=deck_left, + ) + if handshake_action is not None: + return handshake_action + + play_action_id = self._best_number_play( + state=state, + player=player, + hand=hand, + legal=legal, + derived=derived, + deck_left=deck_left, + ) + if play_action_id is not None: + return play_action_id + + if all(not expedition for expedition in state.expeditions[player]): + forced_open_action = self._best_forced_open( + state=state, + player=player, + hand=hand, + legal=legal, + derived=derived, + deck_left=deck_left, + ) + if forced_open_action is not None: + return forced_open_action + + discard_action_id = self._best_discard( + state=state, + player=player, + hand=hand, + legal=legal, + derived=derived, + ) + if discard_action_id is not None: + return discard_action_id + + return first_legal(legal) + + def _best_handshake_play( + self, + *, + state: GameState, + player: int, + hand: list[Card], + legal: list[bool] | np.ndarray, + derived: DerivedHeuristicConfig, + deck_left: int, + ) -> int | None: + if state.config.n_handshakes <= 0: + return None + + candidates: list[tuple[float, int]] = [] + + for slot, card in enumerate(hand): + action = play_action(slot) + if not legal[action] or not card.is_handshake: + continue + + color = card.color + expedition = state.expeditions[player][color] + + # Investment cards should only be played before any number. + if any(not played.is_handshake for played in expedition): + continue + + playable_numbers = [ + other + for other_slot, other in enumerate(hand) + if ( + other_slot != slot + and other.color == color + and not other.is_handshake + and state.can_play_card(player, other) + ) + ] + + number_count = len(playable_numbers) + number_sum = sum(self._num(state, other) for other in playable_numbers) + + if number_count < derived.min_handshake_numeric_cards: + continue + + # Handshakes need a stronger support than normal opening. + required_sum = ( + derived.open_target_sum * self.params.handshake_target_multiplier + ) + if number_sum < required_sum: + continue + + if deck_left <= derived.late_open_block_threshold: + continue + + value = 0.0 + value += number_sum + value += 2.0 * number_count + value += self._bonus_potential( + state=state, + player=player, + color=color, + extra_cards=0, + derived=derived, + committed_cards=1, + exclude_card=card, + ) + value -= self._late_penalty(derived, deck_left) + + candidates.append((value, action)) + + if not candidates: + return None + + return max(candidates)[1] + + def _best_number_play( + self, + *, + state: GameState, + player: int, + hand: list[Card], + legal: list[bool] | np.ndarray, + derived: DerivedHeuristicConfig, + deck_left: int, + ) -> int | None: + candidates: list[tuple[float, int]] = [] + + for slot, card in enumerate(hand): + action = play_action(slot) + if not legal[action] or card.is_handshake: + continue + + color = card.color + expedition_started = len(state.expeditions[player][color]) > 0 + + if expedition_started: + value = self._started_expedition_play_value( + state=state, + player=player, + card=card, + derived=derived, + deck_left=deck_left, + ) + candidates.append((value, action)) + continue + + if self._should_open_expedition( + state=state, + player=player, + color=color, + opening_card=card, + derived=derived, + deck_left=deck_left, + ): + value = self._open_expedition_value( + state=state, + player=player, + color=color, + opening_card=card, + derived=derived, + deck_left=deck_left, + ) + candidates.append((value, action)) + + if not candidates: + return None + + return max(candidates)[1] + + def _started_expedition_play_value( + self, + *, + state: GameState, + player: int, + card: Card, + derived: DerivedHeuristicConfig, + deck_left: int, + ) -> float: + color = card.color + expedition = state.expeditions[player][color] + numeric_value = self._num(state, card) + + current_sum = sum( + self._num(state, played) + for played in expedition + if not played.is_handshake + ) + + followups = [ + followup + for followup in state.hands[player] + if ( + followup is not card + and followup.color == color + and not followup.is_handshake + and followup.rank > card.rank + ) + ] + + projected_sum = current_sum + numeric_value + sum( + self._num(state, followup) for followup in followups + ) + + value = 0.0 + value += self.params.started_expedition_play_bonus + value += self.params.started_expedition_followup_bonus + + # Early/mid game: preserve sequencing by playing lower legal cards first. + value += float(state.config.max_rank + 1 - numeric_value) + + # Late game: cash out larger cards more aggressively. + if deck_left <= derived.late_deck_threshold: + value += 2.0 * numeric_value + elif deck_left <= derived.mid_deck_threshold: + value += 0.8 * numeric_value + + # Avoid extending hopeless expeditions unless the game is late. + if projected_sum < derived.open_target_sum: + value -= 6.0 + + # If investments are already committed, numbers become more urgent. + handshakes = sum(1 for played in expedition if played.is_handshake) + value += 3.0 * handshakes + + value += self._bonus_potential( + state=state, + player=player, + color=color, + extra_cards=0, + derived=derived, + committed_cards=1, + exclude_card=card, + ) + + return value + + def _should_open_expedition( + self, + *, + state: GameState, + player: int, + color: int, + opening_card: Card, + derived: DerivedHeuristicConfig, + deck_left: int, + ) -> bool: + if deck_left <= derived.late_open_block_threshold: + return False + + return self._opening_plan_value( + state=state, + player=player, + color=color, + opening_card=opening_card, + derived=derived, + deck_left=deck_left, + ) > 0.0 + + def _opening_plan_value( + self, + *, + state: GameState, + player: int, + color: int, + opening_card: Card, + derived: DerivedHeuristicConfig, + deck_left: int, + ) -> float: + numbers = [ + card + for card in state.hands[player] + if ( + card.color == color + and not card.is_handshake + and card.rank >= opening_card.rank + ) + ] + handshakes = [ + card + for card in state.hands[player] + if card.color == color and card.is_handshake + ] + opened_colors = sum(1 for expedition in state.expeditions[player] if expedition) + number_sum = sum(self._num(state, card) for card in numbers) + high_cards = [card for card in numbers if card.rank >= derived.middle_rank] + high_count = len(high_cards) + opening_value = self._num(state, opening_card) + new_color_penalty = self._new_color_open_penalty(opened_colors) + + strong_open = ( + len(numbers) >= derived.min_open_cards + and number_sum >= derived.open_target_sum + and (high_cards or number_sum >= 0.85 * derived.max_color_sum) + ) + speculative_open = ( + opened_colors <= 2 + and + len(numbers) >= 2 + and number_sum >= 0.65 * derived.open_target_sum + and bool(high_cards) + ) + single_late_open = ( + deck_left <= derived.mid_deck_threshold + and len(numbers) >= 1 + and opening_value >= 8 + ) + exceptional_open = ( + len(numbers) >= derived.min_open_cards + 1 + and number_sum >= max(float(derived.break_even_sum), derived.open_target_sum * 1.4) + and high_count >= 2 + and deck_left > derived.mid_deck_threshold + ) + + if opened_colors == 3: + speculative_open = False + if opened_colors >= 4: + strong_open = False + speculative_open = False + single_late_open = False + + if strong_open: + return ( + 6.0 + + 0.25 * number_sum + + 0.8 * len(numbers) + + 0.5 * len(handshakes) + - new_color_penalty + ) + if speculative_open: + return ( + 3.0 + + 0.18 * number_sum + + 0.7 * len(numbers) + + 0.4 * len(handshakes) + - new_color_penalty + ) + if opened_colors == 3: + return 0.0 + if exceptional_open: + return ( + 10.0 + + 0.3 * number_sum + + 1.0 * len(numbers) + + 0.7 * high_count + - new_color_penalty + ) + if single_late_open: + return 1.5 + 0.2 * opening_value - new_color_penalty + + return 0.0 + + def _open_expedition_value( + self, + *, + state: GameState, + player: int, + color: int, + opening_card: Card, + derived: DerivedHeuristicConfig, + deck_left: int, + ) -> float: + numbers = [ + card + for card in state.hands[player] + if ( + card.color == color + and not card.is_handshake + and card.rank >= opening_card.rank + ) + ] + handshakes = [ + card + for card in state.hands[player] + if card.color == color and card.is_handshake + ] + + number_sum = sum(self._num(state, card) for card in numbers) + + value = 0.0 + value += number_sum + value += 2.0 * len(numbers) + value += 1.5 * len(handshakes) + value += self._opening_plan_value( + state=state, + player=player, + color=color, + opening_card=opening_card, + derived=derived, + deck_left=deck_left, + ) + + # Opening an expedition accepts the penalty. + value += state.config.expedition_penalty + + # Low opening cards preserve sequencing. + value += max(0.0, float(derived.middle_rank - opening_card.rank)) + + value += self._bonus_potential( + state=state, + player=player, + color=color, + extra_cards=0, + derived=derived, + committed_cards=1, + exclude_card=opening_card, + ) + + value -= self._late_penalty(derived, deck_left) + + return value + + def _best_forced_open( + self, + *, + state: GameState, + player: int, + hand: list[Card], + legal: list[bool] | np.ndarray, + derived: DerivedHeuristicConfig, + deck_left: int, + ) -> int | None: + candidates: list[tuple[float, int]] = [] + + for slot, card in enumerate(hand): + action = play_action(slot) + if not legal[action] or card.is_handshake: + continue + if state.expeditions[player][card.color]: + continue + + opening_value = self._opening_plan_value( + state=state, + player=player, + color=card.color, + opening_card=card, + derived=derived, + deck_left=deck_left, + ) + color_numbers = [ + other + for other in hand + if other.color == card.color and not other.is_handshake + ] + number_sum = sum(self._num(state, other) for other in color_numbers) + + if ( + opening_value <= 0.0 + and len(color_numbers) < 2 + and number_sum < 0.5 * derived.open_target_sum + and deck_left > derived.mid_deck_threshold + ): + continue + + forced_value = opening_value + forced_value += 0.2 * number_sum + forced_value += float(state.config.max_rank + 1 - self._num(state, card)) + candidates.append((forced_value, action)) + + if not candidates: + return None + + return max(candidates)[1] + + def _best_discard( + self, + *, + state: GameState, + player: int, + hand: list[Card], + legal: list[bool] | np.ndarray, + derived: DerivedHeuristicConfig, + ) -> int | None: + candidates: list[tuple[float, int]] = [] + opponent = 1 - player + + for slot, card in enumerate(hand): + action = discard_action(slot) + if not legal[action]: + continue + + my_value = self._card_value_for_me( + state=state, + player=player, + card=card, + derived=derived, + ) + opponent_value = self._card_value_for_opponent( + state=state, + opponent=opponent, + card=card, + derived=derived, + ) + + # Higher means better to discard. + score = 0.0 + score -= my_value + score -= self.params.gift_penalty_weight * opponent_value + + if not state.can_play_card(player, card): + score += self.params.unusable_discard_bonus + + if not state.can_play_card(opponent, card): + score += self.params.discard_safety_bonus + + # Handshakes are swingy. Prefer not discarding them unless they are dead. + if card.is_handshake and state.can_play_card(player, card): + score -= 4.0 + + candidates.append((score, action)) + + if not candidates: + return None + + return max(candidates)[1] + + def _act_draw(self, state: GameState) -> int: + legal = state.legal_draw_mask() + player = state.current_player + derived = self._derived(state) + + candidates: list[tuple[float, int, int]] = [] + + if legal[DRAW_FROM_DECK_ACTION]: + candidates.append( + ( + self._deck_draw_value(state, derived), + 1, + DRAW_FROM_DECK_ACTION, + ) + ) + + for color in range(state.config.n_colors): + action = draw_from_discard_action(color) + if not legal[action] or not state.discards[color]: + continue + + card = state.discards[color][-1] + value = self._visible_draw_value( + state=state, + player=player, + card=card, + derived=derived, + ) + candidates.append((value, 0, action)) + + if candidates: + return max(candidates)[2] + + return first_legal(legal) + + def _visible_draw_value( + self, + *, + state: GameState, + player: int, + card: Card, + derived: DerivedHeuristicConfig, + ) -> float: + color = card.color + opponent = 1 - player + opened_colors = sum(1 for expedition in state.expeditions[player] if expedition) + is_unopened_color = not state.expeditions[player][color] + commitment = self._color_commitment( + state=state, + player=player, + color=color, + derived=derived, + ) + opponent_value = self._card_value_for_opponent( + state=state, + opponent=opponent, + card=card, + derived=derived, + ) + score_diff = state.score_diff(player) + + value = self.params.deny_opponent_weight * opponent_value + if score_diff <= 0: + value += self.params.losing_visible_draw_bonus + + exceptional_support = False + if is_unopened_color: + if opened_colors >= 4: + value -= self.params.unopened_draw_penalty_four_open + elif opened_colors >= 3: + value -= self.params.unopened_draw_penalty_three_open + + if card.is_handshake: + if state.has_numeric(player, color): + return value - self.params.dead_visible_draw_penalty + if not state.expeditions[player][color]: + playable_numbers = [ + other + for other in state.hands[player] + if ( + other.color == color + and not other.is_handshake + and state.can_play_card(player, other) + ) + ] + number_sum = sum(self._num(state, other) for other in playable_numbers) + required_sum = ( + derived.open_target_sum * self.params.handshake_target_multiplier + ) + if ( + len(playable_numbers) < derived.min_handshake_numeric_cards + or number_sum < required_sum + ): + support = self._visible_open_support_value( + state=state, + player=player, + card=card, + derived=derived, + ) + exceptional_support = support >= 6.0 + if ( + is_unopened_color + and opened_colors >= 4 + and not exceptional_support + and opponent_value < self.params.strong_deny_threshold + and score_diff > -15 + ): + return -8.0 + return value + support - 0.5 + return value + 6.0 + commitment + + immediate_playable = state.can_play_card(player, card) + if immediate_playable: + value += float(self._num(state, card)) + value += 0.7 * commitment + + if state.expeditions[player][color]: + value += 5.0 + else: + support = self._visible_open_support_value( + state=state, + player=player, + card=card, + derived=derived, + ) + exceptional_support = support >= 6.0 + value += support + else: + value -= self.params.dead_visible_draw_penalty + if not state.expeditions[player][color]: + support = self._visible_open_support_value( + state=state, + player=player, + card=card, + derived=derived, + ) + exceptional_support = support >= 6.0 + value += support + + if ( + is_unopened_color + and opened_colors >= 4 + and not exceptional_support + and opponent_value < self.params.strong_deny_threshold + and score_diff > -15 + ): + return -8.0 + + value += self._bonus_potential( + state=state, + player=player, + color=color, + extra_cards=1, + derived=derived, + ) + + return value + + def _visible_open_support_value( + self, + *, + state: GameState, + player: int, + card: Card, + derived: DerivedHeuristicConfig, + ) -> float: + color = card.color + opened_colors = sum(1 for expedition in state.expeditions[player] if expedition) + same_color_numbers = [ + other + for other in state.hands[player] + if other.color == color and not other.is_handshake + ] + same_color_handshakes = [ + other + for other in state.hands[player] + if other.color == color and other.is_handshake + ] + future_numbers = [ + other + for other in same_color_numbers + if other is not card and other.rank >= card.rank + ] + + value = 0.0 + value += 0.8 * len(future_numbers) + value += 1.0 * len(same_color_handshakes) + + if card.rank <= derived.middle_rank: + value += self.params.speculative_visible_draw_bonus + + if self._visible_number_can_help_open( + state=state, + player=player, + card=card, + derived=derived, + ): + value += 4.0 + elif opened_colors <= 2 and (future_numbers or same_color_handshakes): + value += self.params.speculative_visible_draw_bonus + + if opened_colors <= 2: + value += 0.25 * self._opening_plan_value( + state=state, + player=player, + color=color, + opening_card=card, + derived=derived, + deck_left=len(state.deck), + ) + elif opened_colors == 3: + value += 0.1 * max( + 0.0, + self._opening_plan_value( + state=state, + player=player, + color=color, + opening_card=card, + derived=derived, + deck_left=len(state.deck), + ), + ) + + return value + + def _visible_number_can_help_open( + self, + *, + state: GameState, + player: int, + card: Card, + derived: DerivedHeuristicConfig, + ) -> bool: + numbers = [ + other + for other in state.hands[player] + if ( + other.color == card.color + and not other.is_handshake + and other.rank >= card.rank + ) + ] + numbers.append(card) + + if len(numbers) < derived.min_open_cards: + return False + + number_sum = sum(self._num(state, other) for other in numbers) + if number_sum < derived.open_target_sum: + return False + + return any(other.rank >= derived.middle_rank for other in numbers) + + def _deck_draw_value( + self, + state: GameState, + derived: DerivedHeuristicConfig, + ) -> float: + deck_left = len(state.deck) + score_diff = state.score_diff(state.current_player) + + if deck_left > derived.mid_deck_threshold: + value = self.params.deck_draw_early_value + elif deck_left > derived.late_deck_threshold: + value = self.params.deck_draw_mid_value + else: + value = self.params.deck_draw_late_value + + if score_diff > 0: + value += self.params.winning_deck_bonus + else: + value -= self.params.losing_deck_penalty + + return value + + def _card_value_for_me( + self, + *, + state: GameState, + player: int, + card: Card, + derived: DerivedHeuristicConfig, + ) -> float: + if not state.can_play_card(player, card): + return 0.0 + + commitment = self._color_commitment( + state=state, + player=player, + color=card.color, + derived=derived, + ) + + if card.is_handshake: + return 7.0 + 1.2 * commitment + + numeric_value = self._num(state, card) + + value = 0.0 + value += 0.8 * numeric_value + value += self.params.commitment_weight * commitment + if state.expeditions[player][card.color]: + value += self.params.started_expedition_play_bonus + value += self.params.started_expedition_followup_bonus + + # Low playable cards are valuable when we are committed to that color. + if commitment >= 6.0 and card.rank <= derived.middle_rank: + value += self.params.low_card_sequence_bonus + + return value + + def _new_color_open_penalty(self, opened_colors: int) -> float: + if opened_colors <= 1: + return 0.0 + if opened_colors == 2: + return 6.0 + if opened_colors == 3: + return 14.0 + return 28.0 + + def _card_value_for_opponent( + self, + *, + state: GameState, + opponent: int, + card: Card, + derived: DerivedHeuristicConfig, + ) -> float: + if not state.can_play_card(opponent, card): + return 0.0 + + interest = self._public_color_commitment_for_opponent( + state=state, + opponent=opponent, + color=card.color, + derived=derived, + ) + + if card.is_handshake: + return 8.0 + 1.5 * interest + + numeric_value = self._num(state, card) + return numeric_value * (0.4 + 0.25 * interest) + + def _color_commitment( + self, + *, + state: GameState, + player: int, + color: int, + derived: DerivedHeuristicConfig, + ) -> float: + expedition = state.expeditions[player][color] + hand = state.hands[player] + + value = 0.0 + + if expedition: + value += 5.0 + + for card in expedition: + if card.is_handshake: + value += 2.0 + else: + value += 0.25 * self._num(state, card) + + playable_cards = [ + card + for card in hand + if card.color == color and state.can_play_card(player, card) + ] + + playable_numbers = [card for card in playable_cards if not card.is_handshake] + playable_handshakes = [card for card in playable_cards if card.is_handshake] + + value += 1.2 * len(playable_numbers) + value += 1.5 * len(playable_handshakes) + value += 0.15 * sum(self._num(state, card) for card in playable_numbers) + + # Bonus chance means the color is strategically more interesting. + value += 0.05 * self._bonus_potential( + state=state, + player=player, + color=color, + extra_cards=0, + derived=derived, + ) + + return value + + def _public_color_commitment_for_opponent( + self, + *, + state: GameState, + opponent: int, + color: int, + derived: DerivedHeuristicConfig, + ) -> float: + expedition = state.expeditions[opponent][color] + discard = state.discards[color] + + value = 0.0 + + if expedition: + value += 5.0 + + handshake_count = sum(1 for card in expedition if card.is_handshake) + value += 2.0 * handshake_count + + numeric_cards = [card for card in expedition if not card.is_handshake] + value += 0.25 * sum(self._num(state, card) for card in numeric_cards) + + if numeric_cards: + value += 0.4 * self._num(state, numeric_cards[-1]) + + if discard: + top_card = discard[-1] + if state.can_play_card(opponent, top_card): + if top_card.is_handshake: + value += 1.5 + else: + value += 1.0 + 0.1 * self._num(state, top_card) + + if derived.bonus_possible: + expedition_len = len(expedition) + if expedition_len + 1 >= state.config.bonus_threshold: + value += 0.2 * float(state.config.bonus_amount) + + return value + + def _playable_followup_numbers( + self, + state: GameState, + player: int, + color: int, + ) -> list[Card]: + return [ + card + for card in state.hands[player] + if ( + card.color == color + and not card.is_handshake + and state.can_play_card(player, card) + ) + ] + + def _bonus_potential( + self, + *, + state: GameState, + player: int, + color: int, + extra_cards: int, + derived: DerivedHeuristicConfig, + committed_cards: int = 0, + exclude_card: Card | None = None, + ) -> float: + if not derived.bonus_possible: + return 0.0 + + expedition_len = len(state.expeditions[player][color]) + committed_cards + need = state.config.bonus_threshold - expedition_len + + if need <= 0: + return float(state.config.bonus_amount) + + playable_count = sum( + 1 + for card in state.hands[player] + if ( + card is not exclude_card + and card.color == color + and state.can_play_card(player, card) + ) + ) + + if playable_count + extra_cards >= need: + return 0.4 * float(state.config.bonus_amount) + + return 0.0 + + def _late_penalty( + self, + derived: DerivedHeuristicConfig, + deck_left: int, + ) -> float: + if deck_left <= derived.late_deck_threshold: + return 15.0 + if deck_left <= derived.mid_deck_threshold: + return 8.0 + return 0.0 + + def _num(self, state: GameState, card: Card) -> int: + return card.numeric_value(state.config.min_rank) + + def _derived(self, state: GameState) -> DerivedHeuristicConfig: + return derive_heuristic_config(state.config, self.params) diff --git a/src/coolrl_lost_cities/games/classic/bots/passive.py b/src/coolrl_lost_cities/games/classic/bots/passive.py new file mode 100644 index 0000000..14529be --- /dev/null +++ b/src/coolrl_lost_cities/games/classic/bots/passive.py @@ -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) diff --git a/src/coolrl_lost_cities/games/classic/bots/play.py b/src/coolrl_lost_cities/games/classic/bots/play.py new file mode 100644 index 0000000..29dcda6 --- /dev/null +++ b/src/coolrl_lost_cities/games/classic/bots/play.py @@ -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, + } diff --git a/src/coolrl_lost_cities/games/classic/bots/random.py b/src/coolrl_lost_cities/games/classic/bots/random.py new file mode 100644 index 0000000..887d6ba --- /dev/null +++ b/src/coolrl_lost_cities/games/classic/bots/random.py @@ -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)) diff --git a/src/coolrl_lost_cities/games/classic/bots/registry.py b/src/coolrl_lost_cities/games/classic/bots/registry.py new file mode 100644 index 0000000..3976873 --- /dev/null +++ b/src/coolrl_lost_cities/games/classic/bots/registry.py @@ -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) diff --git a/src/coolrl_lost_cities/games/classic/docs/lost_cities_spec.md b/src/coolrl_lost_cities/games/classic/docs/lost_cities_spec.md new file mode 100644 index 0000000..6ccbfd8 --- /dev/null +++ b/src/coolrl_lost_cities/games/classic/docs/lost_cities_spec.md @@ -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)`는 동일한 상태 전이와 최종 점수를 재생성한다. 로그/리플레이는 이 튜플을 저장하는 것으로 충분하다. diff --git a/src/coolrl_lost_cities/games/classic/env.py b/src/coolrl_lost_cities/games/classic/env.py new file mode 100644 index 0000000..88e1b04 --- /dev/null +++ b/src/coolrl_lost_cities/games/classic/env.py @@ -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}" + ) diff --git a/src/coolrl_lost_cities/games/classic/fixtures/canonical_small.json b/src/coolrl_lost_cities/games/classic/fixtures/canonical_small.json new file mode 100644 index 0000000..b426e21 --- /dev/null +++ b/src/coolrl_lost_cities/games/classic/fixtures/canonical_small.json @@ -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] + } + ] +} diff --git a/src/coolrl_lost_cities/games/classic/game.pyx b/src/coolrl_lost_cities/games/classic/game.pyx new file mode 100644 index 0000000..714f0e6 --- /dev/null +++ b/src/coolrl_lost_cities/games/classic/game.pyx @@ -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 = 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 = 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 = 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 = 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 = 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 = self.deck.pop() + else: + color = action_id - 1 + 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 = 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 diff --git a/src/coolrl_lost_cities/games/classic/interfaces.py b/src/coolrl_lost_cities/games/classic/interfaces.py new file mode 100644 index 0000000..9f0ee68 --- /dev/null +++ b/src/coolrl_lost_cities/games/classic/interfaces.py @@ -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: ... diff --git a/src/coolrl_lost_cities/games/classic/rust_core/Cargo.lock b/src/coolrl_lost_cities/games/classic/rust_core/Cargo.lock new file mode 100644 index 0000000..48a1e9d --- /dev/null +++ b/src/coolrl_lost_cities/games/classic/rust_core/Cargo.lock @@ -0,0 +1,1367 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "sync_wrapper", + "tower 0.5.3", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.14.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "libc", + "pin-project-lite", + "socket2 0.6.3", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.0", + "serde", + "serde_core", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.185" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lost-cities-core" +version = "0.1.0" +dependencies = [ + "prost", + "prost-types", + "protoc-bin-vendored", + "rand", + "serde", + "serde_json", + "tokio", + "tokio-stream", + "tonic", + "tonic-build", +] + +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "multimap" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "petgraph" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" +dependencies = [ + "fixedbitset", + "indexmap 2.14.0", +] + +[[package]] +name = "pin-project" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prost" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-build" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf" +dependencies = [ + "heck", + "itertools", + "log", + "multimap", + "once_cell", + "petgraph", + "prettyplease", + "prost", + "prost-types", + "regex", + "syn", + "tempfile", +] + +[[package]] +name = "prost-derive" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "prost-types" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16" +dependencies = [ + "prost", +] + +[[package]] +name = "protoc-bin-vendored" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1c381df33c98266b5f08186583660090a4ffa0889e76c7e9a5e175f645a67fa" +dependencies = [ + "protoc-bin-vendored-linux-aarch_64", + "protoc-bin-vendored-linux-ppcle_64", + "protoc-bin-vendored-linux-s390_64", + "protoc-bin-vendored-linux-x86_32", + "protoc-bin-vendored-linux-x86_64", + "protoc-bin-vendored-macos-aarch_64", + "protoc-bin-vendored-macos-x86_64", + "protoc-bin-vendored-win32", +] + +[[package]] +name = "protoc-bin-vendored-linux-aarch_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c350df4d49b5b9e3ca79f7e646fde2377b199e13cfa87320308397e1f37e1a4c" + +[[package]] +name = "protoc-bin-vendored-linux-ppcle_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a55a63e6c7244f19b5c6393f025017eb5d793fd5467823a099740a7a4222440c" + +[[package]] +name = "protoc-bin-vendored-linux-s390_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dba5565db4288e935d5330a07c264a4ee8e4a5b4a4e6f4e83fad824cc32f3b0" + +[[package]] +name = "protoc-bin-vendored-linux-x86_32" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8854774b24ee28b7868cd71dccaae8e02a2365e67a4a87a6cd11ee6cdbdf9cf5" + +[[package]] +name = "protoc-bin-vendored-linux-x86_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b38b07546580df720fa464ce124c4b03630a6fb83e05c336fea2a241df7e5d78" + +[[package]] +name = "protoc-bin-vendored-macos-aarch_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89278a9926ce312e51f1d999fee8825d324d603213344a9a706daa009f1d8092" + +[[package]] +name = "protoc-bin-vendored-macos-x86_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81745feda7ccfb9471d7a4de888f0652e806d5795b61480605d4943176299756" + +[[package]] +name = "protoc-bin-vendored-win32" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95067976aca6421a523e491fce939a3e65249bac4b977adee0ee9771568e8aa3" + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio" +version = "1.52.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2 0.6.3", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tonic" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877c5b330756d856ffcc4553ab34a5684481ade925ecc54bcd1bf02b1d0d4d52" +dependencies = [ + "async-stream", + "async-trait", + "axum", + "base64", + "bytes", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "prost", + "socket2 0.5.10", + "tokio", + "tokio-stream", + "tower 0.4.13", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tonic-build" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9557ce109ea773b399c9b9e5dca39294110b74f1f342cb347a80d1fce8c26a11" +dependencies = [ + "prettyplease", + "proc-macro2", + "prost-build", + "prost-types", + "quote", + "syn", +] + +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "indexmap 1.9.3", + "pin-project", + "pin-project-lite", + "rand", + "slab", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap 2.14.0", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap 2.14.0", + "semver", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap 2.14.0", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap 2.14.0", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap 2.14.0", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/src/coolrl_lost_cities/games/classic/rust_core/Cargo.toml b/src/coolrl_lost_cities/games/classic/rust_core/Cargo.toml new file mode 100644 index 0000000..6a0ce81 --- /dev/null +++ b/src/coolrl_lost_cities/games/classic/rust_core/Cargo.toml @@ -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" diff --git a/src/coolrl_lost_cities/games/classic/rust_core/build.rs b/src/coolrl_lost_cities/games/classic/rust_core/build.rs new file mode 100644 index 0000000..48b2d3e --- /dev/null +++ b/src/coolrl_lost_cities/games/classic/rust_core/build.rs @@ -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"); +} diff --git a/src/coolrl_lost_cities/games/classic/rust_core/src/bin/lost_cities_probe.rs b/src/coolrl_lost_cities/games/classic/rust_core/src/bin/lost_cities_probe.rs new file mode 100644 index 0000000..c7e16fd --- /dev/null +++ b/src/coolrl_lost_cities/games/classic/rust_core/src/bin/lost_cities_probe.rs @@ -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 = Result>; + +#[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, +} + +#[derive(Debug, Deserialize)] +struct FixtureInput { + config: ConfigJson, + initial_deck: Vec, + steps: Vec, +} + +#[derive(Debug, Deserialize)] +struct FixtureStepInput { + action: Option, +} + +#[derive(Debug, Serialize)] +struct TraceOutput { + config: ConfigJson, + steps: Vec, +} + +#[derive(Debug, Serialize)] +struct StateTraceStep { + action: Option, + phase: &'static str, + current_player: usize, + turn_count: u32, + terminal: bool, + pending_discarded_color: Option, + score_diff_player0: i32, + legal_mask: Vec, + deck: Vec, + hands: Vec>, + expeditions: Vec>>, + discards: Vec>, +} + +#[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, + opponent_hand_size: u32, + deck_size: u32, + discards: Vec>, + my_expeditions: Vec>, + opponent_expeditions: Vec>, + legal_mask: Vec, + 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, + stale_kind: String, + end_session_counts: Vec, + 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(value: &T) -> ProbeResult<()> { + println!("{}", serde_json::to_string_pretty(value)?); + Ok(()) +} + +fn run_trace(path: &str) -> ProbeResult { + 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::>(); + 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 { + 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 { + 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 { + 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, 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 { + 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) -> 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 for Card { + fn from(value: CardJson) -> Self { + Self { + color: value.color, + rank: value.rank, + } + } +} + +impl From 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, 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, + } + } +} diff --git a/src/coolrl_lost_cities/games/classic/rust_core/src/config.rs b/src/coolrl_lost_cities/games/classic/rust_core/src/config.rs new file mode 100644 index 0000000..0c492a6 --- /dev/null +++ b/src/coolrl_lost_cities/games/classic/rust_core/src/config.rs @@ -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, +} + +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) -> 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 { + 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 for Config { + type Error = EngineError; + + fn try_from(value: proto::GameConfig) -> Result { + 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, + } + } +} diff --git a/src/coolrl_lost_cities/games/classic/rust_core/src/engine.rs b/src/coolrl_lost_cities/games/classic/rust_core/src/engine.rs new file mode 100644 index 0000000..c9cfb99 --- /dev/null +++ b/src/coolrl_lost_cities/games/classic/rust_core/src/engine.rs @@ -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 { + HashMap::from([(0, self.game.total_score(0)), (1, self.game.total_score(1))]) + } + + fn build_expeditions(player: usize, game: &GameState) -> Vec { + 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 { + 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, +} + +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 { + 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 { + 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 { + 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, + default_player: usize, + ) -> Result { + observer_player + .map(Self::validate_observer) + .transpose() + .map(|observer| observer.unwrap_or(default_player)) + } + + fn validate_observer(observer: u32) -> Result { + match observer { + 0 => Ok(0), + 1 => Ok(1), + _ => Err(EngineError::invalid_argument( + "observer_player must be 0 or 1", + )), + } + } +} diff --git a/src/coolrl_lost_cities/games/classic/rust_core/src/error.rs b/src/coolrl_lost_cities/games/classic/rust_core/src/error.rs new file mode 100644 index 0000000..6eed7f9 --- /dev/null +++ b/src/coolrl_lost_cities/games/classic/rust_core/src/error.rs @@ -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) -> 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) -> Self { + Self::new(EngineErrorKind::AlreadyExists, message) + } + + pub(crate) fn not_found(message: impl Into) -> Self { + Self::new(EngineErrorKind::NotFound, message) + } + + pub(crate) fn failed_precondition(message: impl Into) -> Self { + Self::new(EngineErrorKind::FailedPrecondition, message) + } + + pub(crate) fn invalid_argument(message: impl Into) -> 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 {} diff --git a/src/coolrl_lost_cities/games/classic/rust_core/src/lib.rs b/src/coolrl_lost_cities/games/classic/rust_core/src/lib.rs new file mode 100644 index 0000000..d0b8fb7 --- /dev/null +++ b/src/coolrl_lost_cities/games/classic/rust_core/src/lib.rs @@ -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}; diff --git a/src/coolrl_lost_cities/games/classic/rust_core/src/service.rs b/src/coolrl_lost_cities/games/classic/rust_core/src/service.rs new file mode 100644 index 0000000..380af45 --- /dev/null +++ b/src/coolrl_lost_cities/games/classic/rust_core/src/service.rs @@ -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>, +} + +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, + ) -> Result, 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, + ) -> Result, 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, + ) -> Result, 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, + ) -> Result, 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()), + } +} diff --git a/src/coolrl_lost_cities/games/classic/rust_core/src/state.rs b/src/coolrl_lost_cities/games/classic/rust_core/src/state.rs new file mode 100644 index 0000000..48b637d --- /dev/null +++ b/src/coolrl_lost_cities/games/classic/rust_core/src/state.rs @@ -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, + pub hands: [Vec; 2], + pub expeditions: [Vec>; 2], + pub discards: Vec>, + pub current_player: usize, + pub phase: Phase, + pub pending_discarded_color: Option, + pub turn_count: u32, + pub terminal: bool, +} + +impl GameState { + pub fn new_game(config: Config) -> Result { + 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) -> Result { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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) -> 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 { + 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::(); + let mut score = (numeric_sum + config.expedition_penalty) * (handshakes + 1); + if expedition.len() >= config.bonus_threshold { + score += config.bonus_amount; + } + score +} diff --git a/src/coolrl_lost_cities/games/classic/schemas/lost_cities.proto b/src/coolrl_lost_cities/games/classic/schemas/lost_cities.proto new file mode 100644 index 0000000..b869ab6 --- /dev/null +++ b/src/coolrl_lost_cities/games/classic/schemas/lost_cities.proto @@ -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 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); +} diff --git a/tests/games/classic/test_bots.py b/tests/games/classic/test_bots.py new file mode 100644 index 0000000..0cbd008 --- /dev/null +++ b/tests/games/classic/test_bots.py @@ -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 diff --git a/tests/games/classic/test_canonical_state.py b/tests/games/classic/test_canonical_state.py new file mode 100644 index 0000000..3861569 --- /dev/null +++ b/tests/games/classic/test_canonical_state.py @@ -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) diff --git a/tests/games/classic/test_env.py b/tests/games/classic/test_env.py new file mode 100644 index 0000000..4fe5e53 --- /dev/null +++ b/tests/games/classic/test_env.py @@ -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 diff --git a/tests/games/classic/test_masks.py b/tests/games/classic/test_masks.py new file mode 100644 index 0000000..4253f46 --- /dev/null +++ b/tests/games/classic/test_masks.py @@ -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 diff --git a/tests/games/classic/test_rules.py b/tests/games/classic/test_rules.py new file mode 100644 index 0000000..e0fc6ad --- /dev/null +++ b/tests/games/classic/test_rules.py @@ -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 diff --git a/tests/games/classic/test_rust_parity.py b/tests/games/classic/test_rust_parity.py new file mode 100644 index 0000000..bc6fead --- /dev/null +++ b/tests/games/classic/test_rust_parity.py @@ -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")) diff --git a/tests/games/classic/test_scoring.py b/tests/games/classic/test_scoring.py new file mode 100644 index 0000000..f109bc8 --- /dev/null +++ b/tests/games/classic/test_scoring.py @@ -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 diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..6a06b77 --- /dev/null +++ b/uv.lock @@ -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" }, +]