로스트 시티 클래식 코어 이식
맥락: - 새 레포의 첫 범위를 RL 없는 Lost Cities classic 게임 구현으로 잡았다. - 기존 tier0-3 실험 축은 제거하고 classic 5-expedition 룰을 기본값으로 둔다. 변경: - games/classic 아래에 Cython 게임 엔진, env, bots, backend 경계, Rust core와 proto schema를 이식했다. - setuptools/Cython 빌드 설정과 package data, README, classic port notes를 추가했다. - 룰, 점수, 마스크, env, canonical state, bot, Rust parity 테스트를 새 경로로 가져왔다. 확인: - uv run pytest tests/games/classic - uv run lost-cities-classic
This commit is contained in:
+17
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
3.11
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
__all__: list[str] = []
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .game import Card, GameState, IllegalMoveError, LostCitiesConfig, classic_config
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"Card",
|
||||||
|
"GameState",
|
||||||
|
"IllegalMoveError",
|
||||||
|
"LostCitiesConfig",
|
||||||
|
"classic_config",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
config = classic_config()
|
||||||
|
state = GameState.new_game(config)
|
||||||
|
print(f"Lost Cities classic: {config.deck_size} cards, player {state.current_player} to act")
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
{
|
||||||
|
"defaults": {
|
||||||
|
"font": {
|
||||||
|
"name": "b612",
|
||||||
|
"size": "16"
|
||||||
|
},
|
||||||
|
"colours": {
|
||||||
|
"normal_bg": "#050505",
|
||||||
|
"hovered_bg": "#101010",
|
||||||
|
"disabled_bg": "#050505",
|
||||||
|
"selected_bg": "#151515",
|
||||||
|
"active_bg": "#151515",
|
||||||
|
"dark_bg": "#000000",
|
||||||
|
"disabled_dark_bg": "#000000",
|
||||||
|
"normal_text": "#dddddd",
|
||||||
|
"hovered_text": "#ffffff",
|
||||||
|
"disabled_text": "#777777",
|
||||||
|
"selected_text": "#ffffff",
|
||||||
|
"active_text": "#ffffff",
|
||||||
|
"normal_text_shadow": "#00000000",
|
||||||
|
"hovered_text_shadow": "#00000000",
|
||||||
|
"disabled_text_shadow": "#00000000",
|
||||||
|
"selected_text_shadow": "#00000000",
|
||||||
|
"active_text_shadow": "#00000000",
|
||||||
|
"normal_border": "#3a3a40",
|
||||||
|
"hovered_border": "#d6d6dc",
|
||||||
|
"disabled_border": "#252529",
|
||||||
|
"selected_border": "#d6d6dc",
|
||||||
|
"active_border": "#d6d6dc",
|
||||||
|
"text_shadow": "#00000000"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"#pvp_control": {
|
||||||
|
"misc": {
|
||||||
|
"shape": "rectangle",
|
||||||
|
"border_width": "1",
|
||||||
|
"shadow_width": "0",
|
||||||
|
"shape_corner_radius": "0",
|
||||||
|
"text_horiz_alignment_padding": "12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"button": {
|
||||||
|
"prototype": "#pvp_control"
|
||||||
|
},
|
||||||
|
"drop_down_menu": {
|
||||||
|
"prototype": "#pvp_control",
|
||||||
|
"misc": {
|
||||||
|
"expand_direction": "down"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"drop_down_menu.button": {
|
||||||
|
"prototype": "#pvp_control"
|
||||||
|
},
|
||||||
|
"drop_down_menu.selection_list.button": {
|
||||||
|
"prototype": "#pvp_control"
|
||||||
|
},
|
||||||
|
"drop_down_menu.#expand_button": {
|
||||||
|
"prototype": "#pvp_control"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .factory import build_lost_cities_backend
|
||||||
|
from .python import PythonLostCitiesBackend
|
||||||
|
from .rust import RustLostCitiesBackend
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"PythonLostCitiesBackend",
|
||||||
|
"RustLostCitiesBackend",
|
||||||
|
"build_lost_cities_backend",
|
||||||
|
]
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from ..game import Card, GameState, LostCitiesConfig
|
||||||
|
from ..interfaces import Snapshot
|
||||||
|
|
||||||
|
|
||||||
|
def snapshot_summary(snapshot: Snapshot) -> str:
|
||||||
|
scores = [snapshot.total_score(0), snapshot.total_score(1)]
|
||||||
|
hand_sizes = [len(hand) for hand in snapshot.hands]
|
||||||
|
discard_sizes = [len(discard) for discard in snapshot.discards]
|
||||||
|
phase = "카드" if snapshot.phase == "card" else "뽑기"
|
||||||
|
return (
|
||||||
|
f"플레이어={snapshot.current_player} 단계={phase} "
|
||||||
|
f"턴={snapshot.turn_count} 종료={snapshot.terminal} "
|
||||||
|
f"덱={len(snapshot.deck)} 손패수={hand_sizes} 점수={scores} "
|
||||||
|
f"직전버린색={snapshot.pending_discarded_color} "
|
||||||
|
f"버린더미수={discard_sizes}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def snapshot_from_state(state: GameState) -> Snapshot:
|
||||||
|
return Snapshot(
|
||||||
|
config=state.config,
|
||||||
|
deck=list(state.deck),
|
||||||
|
hands=[list(hand) for hand in state.hands],
|
||||||
|
expeditions=[
|
||||||
|
[list(expedition) for expedition in player_expeditions]
|
||||||
|
for player_expeditions in state.expeditions
|
||||||
|
],
|
||||||
|
discards=[list(discard) for discard in state.discards],
|
||||||
|
current_player=state.current_player,
|
||||||
|
phase=state.phase,
|
||||||
|
pending_discarded_color=state.pending_discarded_color,
|
||||||
|
turn_count=state.turn_count,
|
||||||
|
terminal=state.terminal,
|
||||||
|
legal_mask=state.unified_legal_mask(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def snapshot_from_trace(config_data: dict[str, Any], step: dict[str, Any]) -> Snapshot:
|
||||||
|
config = LostCitiesConfig(**config_data)
|
||||||
|
return Snapshot(
|
||||||
|
config=config,
|
||||||
|
deck=cards_from_json(step["deck"]),
|
||||||
|
hands=[cards_from_json(hand) for hand in step["hands"]],
|
||||||
|
expeditions=[
|
||||||
|
[cards_from_json(expedition) for expedition in player_expeditions]
|
||||||
|
for player_expeditions in step["expeditions"]
|
||||||
|
],
|
||||||
|
discards=[cards_from_json(discard) for discard in step["discards"]],
|
||||||
|
current_player=int(step["current_player"]),
|
||||||
|
phase=str(step["phase"]),
|
||||||
|
pending_discarded_color=step.get("pending_discarded_color"),
|
||||||
|
turn_count=int(step["turn_count"]),
|
||||||
|
terminal=bool(step["terminal"]),
|
||||||
|
legal_mask=list(step["legal_mask"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def cards_from_json(cards: list[dict[str, int]]) -> list[Card]:
|
||||||
|
return [Card.from_snapshot(card) for card in cards]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from ..game import LostCitiesConfig
|
||||||
|
from ..interfaces import BackendName, LostCitiesBackend
|
||||||
|
from .python import PythonLostCitiesBackend
|
||||||
|
from .rust import RustLostCitiesBackend
|
||||||
|
|
||||||
|
|
||||||
|
def build_lost_cities_backend(
|
||||||
|
backend: BackendName,
|
||||||
|
config: LostCitiesConfig,
|
||||||
|
seed: int | None,
|
||||||
|
) -> LostCitiesBackend:
|
||||||
|
if backend == "python":
|
||||||
|
return PythonLostCitiesBackend(config, seed)
|
||||||
|
if backend == "rust":
|
||||||
|
return RustLostCitiesBackend(config, seed)
|
||||||
|
raise ValueError(f"unknown backend: {backend}")
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from ..game import GameState, LostCitiesConfig
|
||||||
|
from ..interfaces import BackendName, Snapshot
|
||||||
|
from .common import snapshot_from_state, snapshot_summary
|
||||||
|
|
||||||
|
LOGGER = logging.getLogger("coolrl_lost_cities.games.classic.backends.python")
|
||||||
|
|
||||||
|
|
||||||
|
class PythonLostCitiesBackend:
|
||||||
|
name: BackendName = "python"
|
||||||
|
|
||||||
|
def __init__(self, config: LostCitiesConfig, seed: int | None):
|
||||||
|
self.config = config
|
||||||
|
self.seed = seed
|
||||||
|
self.state = GameState.new_game(config, seed=seed)
|
||||||
|
self.history: list[GameState] = []
|
||||||
|
LOGGER.debug("파이썬 백엔드 초기화: %s", snapshot_summary(self.snapshot()))
|
||||||
|
|
||||||
|
def snapshot(self) -> Snapshot:
|
||||||
|
return snapshot_from_state(self.state)
|
||||||
|
|
||||||
|
def apply(self, action_id: int) -> None:
|
||||||
|
before = self.snapshot()
|
||||||
|
self.history.append(self.state.clone())
|
||||||
|
self.state.apply_unified_action(action_id)
|
||||||
|
LOGGER.debug(
|
||||||
|
"파이썬 액션 적용: 액션=%s 이전={%s} 이후={%s} 되돌리기깊이=%s",
|
||||||
|
action_id,
|
||||||
|
snapshot_summary(before),
|
||||||
|
snapshot_summary(self.snapshot()),
|
||||||
|
len(self.history),
|
||||||
|
)
|
||||||
|
|
||||||
|
def can_undo(self) -> bool:
|
||||||
|
return bool(self.history)
|
||||||
|
|
||||||
|
def undo(self) -> bool:
|
||||||
|
if not self.history:
|
||||||
|
LOGGER.debug("파이썬 되돌리기 무시: 기록이 비어 있음")
|
||||||
|
return False
|
||||||
|
before = self.snapshot()
|
||||||
|
self.state = self.history.pop()
|
||||||
|
LOGGER.debug(
|
||||||
|
"파이썬 되돌리기: 이전={%s} 이후={%s} 되돌리기깊이=%s",
|
||||||
|
snapshot_summary(before),
|
||||||
|
snapshot_summary(self.snapshot()),
|
||||||
|
len(self.history),
|
||||||
|
)
|
||||||
|
return True
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
import random
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
from ..game import Card, LostCitiesConfig, build_deck
|
||||||
|
from ..interfaces import BackendName, Snapshot
|
||||||
|
from .common import snapshot_from_trace, snapshot_summary
|
||||||
|
|
||||||
|
LOGGER = logging.getLogger("coolrl_lost_cities.games.classic.backends.rust")
|
||||||
|
|
||||||
|
|
||||||
|
class RustLostCitiesBackend:
|
||||||
|
name: BackendName = "rust"
|
||||||
|
|
||||||
|
def __init__(self, config: LostCitiesConfig, seed: int | None):
|
||||||
|
self.config = config
|
||||||
|
self.seed = seed
|
||||||
|
self.initial_deck = _shuffled_deck(config, seed)
|
||||||
|
self.actions: list[int] = []
|
||||||
|
self._snapshot = self._run_trace()
|
||||||
|
LOGGER.debug("러스트 백엔드 초기화: %s", snapshot_summary(self.snapshot()))
|
||||||
|
|
||||||
|
def snapshot(self) -> Snapshot:
|
||||||
|
return self._snapshot
|
||||||
|
|
||||||
|
def apply(self, action_id: int) -> None:
|
||||||
|
before = self.snapshot()
|
||||||
|
self.actions.append(action_id)
|
||||||
|
try:
|
||||||
|
self._snapshot = self._run_trace()
|
||||||
|
except Exception:
|
||||||
|
self.actions.pop()
|
||||||
|
raise
|
||||||
|
LOGGER.debug(
|
||||||
|
"러스트 액션 적용: 액션=%s 이전={%s} 이후={%s} 되돌리기깊이=%s",
|
||||||
|
action_id,
|
||||||
|
snapshot_summary(before),
|
||||||
|
snapshot_summary(self.snapshot()),
|
||||||
|
len(self.actions),
|
||||||
|
)
|
||||||
|
|
||||||
|
def can_undo(self) -> bool:
|
||||||
|
return bool(self.actions)
|
||||||
|
|
||||||
|
def undo(self) -> bool:
|
||||||
|
if not self.actions:
|
||||||
|
LOGGER.debug("러스트 되돌리기 무시: 액션 기록이 비어 있음")
|
||||||
|
return False
|
||||||
|
before = self.snapshot()
|
||||||
|
removed = self.actions.pop()
|
||||||
|
self._snapshot = self._run_trace()
|
||||||
|
LOGGER.debug(
|
||||||
|
"러스트 되돌리기: 제거한액션=%s 이전={%s} 이후={%s} 되돌리기깊이=%s",
|
||||||
|
removed,
|
||||||
|
snapshot_summary(before),
|
||||||
|
snapshot_summary(self.snapshot()),
|
||||||
|
len(self.actions),
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _run_trace(self) -> Snapshot:
|
||||||
|
fixture = {
|
||||||
|
"config": self.config.to_snapshot(),
|
||||||
|
"initial_deck": [card.to_snapshot() for card in self.initial_deck],
|
||||||
|
"steps": [{"action": None}]
|
||||||
|
+ [{"action": action} for action in self.actions],
|
||||||
|
}
|
||||||
|
rust_core = Path(__file__).resolve().parents[1] / "rust_core"
|
||||||
|
with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as handle:
|
||||||
|
json.dump(fixture, handle)
|
||||||
|
fixture_path = Path(handle.name)
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
[
|
||||||
|
"cargo",
|
||||||
|
"run",
|
||||||
|
"--quiet",
|
||||||
|
"--bin",
|
||||||
|
"lost_cities_probe",
|
||||||
|
"--",
|
||||||
|
"trace",
|
||||||
|
str(fixture_path),
|
||||||
|
],
|
||||||
|
cwd=rust_core,
|
||||||
|
check=True,
|
||||||
|
text=True,
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
except subprocess.CalledProcessError as exc:
|
||||||
|
message = exc.stderr.strip() or exc.stdout.strip() or str(exc)
|
||||||
|
raise RuntimeError(f"rust backend failed: {message}") from exc
|
||||||
|
finally:
|
||||||
|
fixture_path.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
trace = json.loads(result.stdout)
|
||||||
|
return snapshot_from_trace(trace["config"], trace["steps"][-1])
|
||||||
|
|
||||||
|
|
||||||
|
def _shuffled_deck(config: LostCitiesConfig, seed: int | None) -> list[Card]:
|
||||||
|
deck = build_deck(config)
|
||||||
|
rng = random.Random(config.seed if seed is None else seed)
|
||||||
|
rng.shuffle(deck)
|
||||||
|
return deck
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from ..interfaces import BotInput, LostCitiesBot
|
||||||
|
from .heuristic import SafeHeuristicBot
|
||||||
|
from .passive import PassiveDiscardBot
|
||||||
|
from .play import play_game, run_series
|
||||||
|
from .random import RandomBot
|
||||||
|
from .registry import DEFAULT_BOT, available_bot_names, build_bot
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"BotInput",
|
||||||
|
"DEFAULT_BOT",
|
||||||
|
"LostCitiesBot",
|
||||||
|
"PassiveDiscardBot",
|
||||||
|
"RandomBot",
|
||||||
|
"SafeHeuristicBot",
|
||||||
|
"available_bot_names",
|
||||||
|
"build_bot",
|
||||||
|
"play_game",
|
||||||
|
"run_series",
|
||||||
|
]
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from ..game import GameState
|
||||||
|
from ..interfaces import BotInput, Snapshot
|
||||||
|
|
||||||
|
try:
|
||||||
|
import numpy as np
|
||||||
|
except ImportError as exc: # pragma: no cover
|
||||||
|
raise RuntimeError("numpy is required for Lost Cities bots") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def legal_from_obs(obs_or_state: BotInput) -> np.ndarray:
|
||||||
|
if isinstance(obs_or_state, GameState):
|
||||||
|
return np.asarray(obs_or_state.legal_mask(), dtype=bool)
|
||||||
|
if isinstance(obs_or_state, Snapshot):
|
||||||
|
return np.asarray(obs_or_state.legal_mask, dtype=bool)
|
||||||
|
return np.asarray(obs_or_state["legal_mask"], dtype=bool)
|
||||||
|
|
||||||
|
|
||||||
|
def first_legal(legal: list[bool] | np.ndarray) -> int:
|
||||||
|
legal_indices = np.nonzero(np.asarray(legal, dtype=bool))[0]
|
||||||
|
if len(legal_indices) == 0:
|
||||||
|
raise RuntimeError("no legal action available")
|
||||||
|
return int(legal_indices[0])
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,54 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from ..game import GameState
|
||||||
|
from ..interfaces import BotInput, Snapshot
|
||||||
|
from .base import first_legal, legal_from_obs
|
||||||
|
|
||||||
|
|
||||||
|
class PassiveDiscardBot:
|
||||||
|
"""Baseline that avoids opening expeditions whenever discarding is legal."""
|
||||||
|
|
||||||
|
def act(self, obs_or_state: BotInput) -> int:
|
||||||
|
if isinstance(obs_or_state, GameState):
|
||||||
|
return self._act_phase_local(
|
||||||
|
obs_or_state.phase,
|
||||||
|
obs_or_state.legal_mask(),
|
||||||
|
obs_or_state.card_action_size,
|
||||||
|
)
|
||||||
|
if isinstance(obs_or_state, Snapshot):
|
||||||
|
return self._act_unified(
|
||||||
|
obs_or_state.phase,
|
||||||
|
obs_or_state.legal_mask,
|
||||||
|
obs_or_state.card_action_size,
|
||||||
|
)
|
||||||
|
legal = legal_from_obs(obs_or_state)
|
||||||
|
return first_legal(legal)
|
||||||
|
|
||||||
|
def _act_phase_local(
|
||||||
|
self,
|
||||||
|
phase: str,
|
||||||
|
legal: list[bool],
|
||||||
|
card_action_size: int,
|
||||||
|
) -> int:
|
||||||
|
if phase == "card":
|
||||||
|
for action in range(1, min(card_action_size, len(legal)), 2):
|
||||||
|
if legal[action]:
|
||||||
|
return action
|
||||||
|
if phase == "draw" and legal and legal[0]:
|
||||||
|
return 0
|
||||||
|
return first_legal(legal)
|
||||||
|
|
||||||
|
def _act_unified(
|
||||||
|
self,
|
||||||
|
phase: str,
|
||||||
|
legal: list[bool],
|
||||||
|
card_action_size: int,
|
||||||
|
) -> int:
|
||||||
|
if phase == "card":
|
||||||
|
for action in range(1, min(card_action_size, len(legal)), 2):
|
||||||
|
if legal[action]:
|
||||||
|
return action
|
||||||
|
deck_action = card_action_size
|
||||||
|
if phase == "draw" and deck_action < len(legal) and legal[deck_action]:
|
||||||
|
return deck_action
|
||||||
|
return first_legal(legal)
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import replace
|
||||||
|
|
||||||
|
from ..game import GameState, LostCitiesConfig
|
||||||
|
from ..interfaces import LostCitiesBot
|
||||||
|
|
||||||
|
try:
|
||||||
|
import numpy as np
|
||||||
|
except ImportError as exc: # pragma: no cover
|
||||||
|
raise RuntimeError("numpy is required for Lost Cities bots") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def play_game(
|
||||||
|
bot0: LostCitiesBot,
|
||||||
|
bot1: LostCitiesBot,
|
||||||
|
config: LostCitiesConfig,
|
||||||
|
*,
|
||||||
|
seed: int | None = None,
|
||||||
|
max_steps: int = 10_000,
|
||||||
|
) -> GameState:
|
||||||
|
game_config = replace(config, seed=seed) if seed is not None else config
|
||||||
|
state = GameState.new_game(game_config)
|
||||||
|
bots = [bot0, bot1]
|
||||||
|
for _ in range(max_steps):
|
||||||
|
if state.terminal:
|
||||||
|
return state
|
||||||
|
action = bots[state.current_player].act(state)
|
||||||
|
state.apply_action(action)
|
||||||
|
raise RuntimeError(f"game exceeded max_steps={max_steps}")
|
||||||
|
|
||||||
|
|
||||||
|
def run_series(
|
||||||
|
bot0: LostCitiesBot,
|
||||||
|
bot1: LostCitiesBot,
|
||||||
|
config: LostCitiesConfig,
|
||||||
|
*,
|
||||||
|
games: int = 100,
|
||||||
|
seed: int = 0,
|
||||||
|
) -> dict:
|
||||||
|
diffs: list[int] = []
|
||||||
|
wins0 = 0
|
||||||
|
wins1 = 0
|
||||||
|
draws = 0
|
||||||
|
for index in range(games):
|
||||||
|
state = play_game(bot0, bot1, config, seed=seed + index)
|
||||||
|
diff = state.score_diff(0)
|
||||||
|
diffs.append(diff)
|
||||||
|
if diff > 0:
|
||||||
|
wins0 += 1
|
||||||
|
elif diff < 0:
|
||||||
|
wins1 += 1
|
||||||
|
else:
|
||||||
|
draws += 1
|
||||||
|
return {
|
||||||
|
"games": games,
|
||||||
|
"avg_diff": float(np.mean(diffs)) if diffs else 0.0,
|
||||||
|
"wins0": wins0,
|
||||||
|
"wins1": wins1,
|
||||||
|
"draws": draws,
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from ..interfaces import BotInput, LostCitiesBot
|
||||||
|
from .base import legal_from_obs
|
||||||
|
|
||||||
|
try:
|
||||||
|
import numpy as np
|
||||||
|
except ImportError as exc: # pragma: no cover
|
||||||
|
raise RuntimeError("numpy is required for Lost Cities bots") from exc
|
||||||
|
|
||||||
|
|
||||||
|
class RandomBot(LostCitiesBot):
|
||||||
|
def __init__(self, seed: int | None = None):
|
||||||
|
self.rng = np.random.default_rng(seed)
|
||||||
|
|
||||||
|
def act(self, obs_or_state: BotInput) -> int:
|
||||||
|
legal = legal_from_obs(obs_or_state)
|
||||||
|
legal_indices = np.nonzero(legal)[0]
|
||||||
|
if len(legal_indices) == 0:
|
||||||
|
raise RuntimeError("no legal action available")
|
||||||
|
return int(self.rng.choice(legal_indices))
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
from ..interfaces import LostCitiesBot
|
||||||
|
from .heuristic import SafeHeuristicBot
|
||||||
|
from .passive import PassiveDiscardBot
|
||||||
|
from .random import RandomBot
|
||||||
|
|
||||||
|
BotName = str
|
||||||
|
DEFAULT_BOT: BotName = "random"
|
||||||
|
BotFactory = Callable[[int | None], LostCitiesBot]
|
||||||
|
|
||||||
|
BOT_REGISTRY: dict[BotName, BotFactory] = {
|
||||||
|
DEFAULT_BOT: RandomBot,
|
||||||
|
"passive-discard": lambda seed: PassiveDiscardBot(),
|
||||||
|
"safe-heuristic": lambda seed: SafeHeuristicBot(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def available_bot_names() -> list[BotName]:
|
||||||
|
return sorted(BOT_REGISTRY)
|
||||||
|
|
||||||
|
|
||||||
|
def build_bot(name: BotName, *, seed: int | None = None) -> LostCitiesBot:
|
||||||
|
try:
|
||||||
|
bot_factory = BOT_REGISTRY[name]
|
||||||
|
except KeyError as exc:
|
||||||
|
raise ValueError(f"unknown Lost Cities bot: {name}") from exc
|
||||||
|
return bot_factory(seed)
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
# Lost Cities — 게임 핵심 로직 명세서
|
||||||
|
|
||||||
|
본 문서는 외부 에이전트가 Lost Cities를 플레이하기 위해 알아야 할 모든 규칙과 상태 전이를 기술한다. 구현(Rust core)과 인터페이스(gRPC proto)는 이 명세를 준수해야 한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 개요
|
||||||
|
|
||||||
|
Lost Cities는 2인 불완전정보 카드게임이다. 각 플레이어는 자신의 손패로 색깔별 탐험대(expedition)를 꾸리고, 게임 종료 시점의 총점으로 승부를 가린다.
|
||||||
|
|
||||||
|
- 플레이어 수: 정확히 2 (인덱스 0과 1)
|
||||||
|
- 상태 종류: 완전히 관찰 가능(공개) + 부분 관찰 가능(자기 손패만)
|
||||||
|
- 결정성: config의 seed가 주어지면 초기 덱 셔플이 결정적
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 구성 요소
|
||||||
|
|
||||||
|
### 2.1 Config
|
||||||
|
|
||||||
|
게임은 다음 파라미터로 완전히 기술된다.
|
||||||
|
|
||||||
|
| 필드 | 의미 | 기본값 예시 |
|
||||||
|
|---|---|---|
|
||||||
|
| `n_colors` | 색깔(탐험대) 수 | 5 |
|
||||||
|
| `n_ranks` | 색깔당 숫자 카드 종류 수 | 9 |
|
||||||
|
| `min_rank` | 숫자 카드의 최소 face value | 2 |
|
||||||
|
| `n_handshakes` | 색깔당 handshake 카드 수 | 3 |
|
||||||
|
| `hand_size` | 각 플레이어의 손패 크기 | 8 |
|
||||||
|
| `expedition_penalty` | 탐험대 시작 비용 (음수) | -20 |
|
||||||
|
| `bonus_threshold` | 보너스 자격 카드 수 | 8 |
|
||||||
|
| `bonus_amount` | 보너스 점수 | 20 |
|
||||||
|
| `seed` | 덱 셔플 시드 (선택) | — |
|
||||||
|
|
||||||
|
제약: 모든 수치는 양수여야 하며, `deck_size >= 2 * hand_size`여야 한다.
|
||||||
|
|
||||||
|
### 2.2 Card
|
||||||
|
|
||||||
|
각 카드는 `(color, rank)` 쌍으로 식별된다.
|
||||||
|
|
||||||
|
- `color`: 0부터 `n_colors - 1`까지의 정수
|
||||||
|
- `rank`:
|
||||||
|
- `0`: handshake (투자) 카드
|
||||||
|
- `1`부터 `n_ranks`까지: 숫자 카드
|
||||||
|
- 숫자 카드의 face value: `min_rank + rank - 1`
|
||||||
|
- 예: `min_rank=2, rank=1` 이면 face value는 2
|
||||||
|
- 예: `min_rank=2, n_ranks=9` 이면 face value 범위는 2부터 10
|
||||||
|
- handshake의 face value는 정의상 0 (점수 계산에만 사용)
|
||||||
|
|
||||||
|
### 2.3 Deck
|
||||||
|
|
||||||
|
덱 구성: 각 색깔마다 `n_handshakes`장의 handshake와 rank 1부터 `n_ranks`까지 숫자 카드 각 1장씩.
|
||||||
|
|
||||||
|
총 덱 크기: `n_colors * (n_handshakes + n_ranks)`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 초기화
|
||||||
|
|
||||||
|
1. 덱을 config의 seed로 셔플한다.
|
||||||
|
2. 각 플레이어에게 `hand_size`만큼 카드를 딜한다. 딜 순서는 플레이어를 번갈아가며 1장씩.
|
||||||
|
3. 각 플레이어의 손패는 항상 정렬 상태로 유지된다 (key: color 오름차순, 같은 color 내에서 rank 오름차순).
|
||||||
|
4. 각 플레이어의 expedition은 색깔마다 빈 stack으로 초기화된다.
|
||||||
|
5. 각 색깔의 discard 더미는 빈 상태로 시작한다.
|
||||||
|
6. `current_player = 0`, `phase = CARD`, `pending_discarded_color = None`, `turn_count = 0`, `terminal = False`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 턴 구조
|
||||||
|
|
||||||
|
한 턴은 정확히 두 phase로 구성된다.
|
||||||
|
|
||||||
|
1. **CARD phase** — 손패에서 카드 한 장을 선택해 플레이(play)하거나 버린다(discard).
|
||||||
|
2. **DRAW phase** — 덱 맨 위 또는 비어 있지 않은 discard 더미 중 하나에서 카드를 손패로 받는다.
|
||||||
|
|
||||||
|
DRAW phase가 끝나면 `current_player`가 교체되고 `phase = CARD`로 돌아가며 `turn_count`가 1 증가한다.
|
||||||
|
|
||||||
|
단, 아래 6장(종료 조건)에 해당하면 턴 교체 없이 게임이 종료된다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 액션
|
||||||
|
|
||||||
|
### 5.1 CARD phase의 합법 액션
|
||||||
|
|
||||||
|
손패의 각 슬롯 `i`에 대해 다음 두 액션이 평가된다.
|
||||||
|
|
||||||
|
**PLAY_CARD(slot=i)**
|
||||||
|
|
||||||
|
- 대상 카드 `c = hand[i]`
|
||||||
|
- 해당 색깔의 expedition에서 "마지막 숫자 랭크"를 `last_numeric`이라 하자. expedition에 숫자 카드가 없으면 `last_numeric = 0`.
|
||||||
|
- `c`가 handshake이면: `last_numeric == 0`일 때만 합법
|
||||||
|
- `c`가 숫자 카드이면: `c.rank > last_numeric`일 때만 합법
|
||||||
|
- 합법이면 `c`를 해당 색깔 expedition stack에 push
|
||||||
|
|
||||||
|
**DISCARD_CARD(slot=i)**
|
||||||
|
|
||||||
|
- 항상 합법
|
||||||
|
- `c`를 `c.color`의 discard 더미 맨 위에 push
|
||||||
|
- `pending_discarded_color = c.color`로 기록
|
||||||
|
|
||||||
|
CARD phase에서는 DISCARD가 항상 가능하므로 합법 액션이 최소 1개 존재한다.
|
||||||
|
|
||||||
|
### 5.2 DRAW phase의 합법 액션
|
||||||
|
|
||||||
|
**DRAW_DECK**
|
||||||
|
|
||||||
|
- `len(deck) > 0`일 때만 합법
|
||||||
|
- 덱 맨 위 카드를 손패에 추가한 뒤 손패를 재정렬
|
||||||
|
|
||||||
|
**DRAW_DISCARD(color=k)**
|
||||||
|
|
||||||
|
- `len(discards[k]) > 0`이고 `k != pending_discarded_color`일 때만 합법
|
||||||
|
- `discards[k]` 맨 위 카드를 손패에 추가한 뒤 손패를 재정렬
|
||||||
|
|
||||||
|
### 5.3 pending_discarded_color
|
||||||
|
|
||||||
|
- CARD phase에서 `DISCARD_CARD`로 색깔 `k`를 버렸다면, 같은 턴의 DRAW phase에서 `DRAW_DISCARD(k)`는 금지된다.
|
||||||
|
- CARD phase에서 `PLAY_CARD`를 했다면 `pending_discarded_color`는 `None`.
|
||||||
|
- DRAW phase가 끝나면 `pending_discarded_color`는 다음 턴으로 넘어가기 전에 `None`으로 초기화된다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 종료 조건
|
||||||
|
|
||||||
|
게임은 다음 중 하나가 성립하는 즉시 종료된다.
|
||||||
|
|
||||||
|
1. DRAW phase에서 카드를 받은 직후 `len(deck) == 0`이 되는 경우. 이 경우 턴 교체 없이 종료.
|
||||||
|
2. 드물게: DRAW phase에서 합법 액션이 전혀 없는 경우 (덱과 모든 discard가 동시에 사용 불가). 극단적 config에서만 발생.
|
||||||
|
|
||||||
|
종료 시 `terminal = True`가 되고 이후 어떤 액션도 적용할 수 없다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 점수 계산
|
||||||
|
|
||||||
|
각 플레이어의 각 색깔 expedition에 대해:
|
||||||
|
|
||||||
|
- 카드가 하나도 없으면: 0점
|
||||||
|
- 카드가 있으면:
|
||||||
|
- `numeric_sum` = expedition 내 숫자 카드의 face value 합
|
||||||
|
- `handshakes` = expedition 내 handshake 카드 수
|
||||||
|
- `score = (numeric_sum + expedition_penalty) * (handshakes + 1)`
|
||||||
|
- `len(expedition) >= bonus_threshold`이면 `score += bonus_amount`
|
||||||
|
|
||||||
|
플레이어의 총점: 모든 색깔 expedition 점수의 합.
|
||||||
|
|
||||||
|
`score_diff(p) = total_score(p) - total_score(1 - p)`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 불변식
|
||||||
|
|
||||||
|
구현은 아래 불변식을 모든 상태 전이에서 유지해야 한다.
|
||||||
|
|
||||||
|
1. **카드 보존**: `len(deck) + Σ|hand[p]| + Σ|expedition[p][c]| + Σ|discard[c]|` 는 항상 `deck_size`와 같다.
|
||||||
|
2. **expedition monotonicity**: 각 expedition stack 내 숫자 카드는 bottom→top 방향으로 strictly increasing rank.
|
||||||
|
3. **handshake 위치**: expedition에 숫자 카드가 하나라도 들어가면 이후로 해당 expedition에 handshake를 더 추가할 수 없다.
|
||||||
|
4. **phase-scoped invariant**: `pending_discarded_color`는 `phase == DRAW`에서만 의미를 가진다.
|
||||||
|
5. **terminal 단조성**: `terminal`은 한 번 `True`가 되면 `False`로 되돌아가지 않는다.
|
||||||
|
6. **legal action 존재**: `terminal == False`이면 합법 액션 집합이 비어 있지 않다. `terminal == True`이면 공집합이다.
|
||||||
|
7. **손패 정렬**: 손패는 항상 `(color, rank)` 오름차순 정렬 상태.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 외부 에이전트를 위한 계약
|
||||||
|
|
||||||
|
gRPC 인터페이스로 플레이하는 에이전트가 알아야 할 계약.
|
||||||
|
|
||||||
|
### 9.1 상태는 세션 단위로 서버가 유지
|
||||||
|
|
||||||
|
클라이언트는 `session_id`로 게임을 식별한다. 한 세션 = 한 게임.
|
||||||
|
|
||||||
|
### 9.2 observation은 항상 특정 플레이어 관점
|
||||||
|
|
||||||
|
`observer_player`가 보는 정보:
|
||||||
|
|
||||||
|
- 자신의 손패 전체
|
||||||
|
- 자신과 상대의 expedition 전체 (공개 정보)
|
||||||
|
- 모든 discard 더미 전체 (공개 정보)
|
||||||
|
- 상대 손패 크기 (내용은 비공개)
|
||||||
|
- 남은 덱 크기 (내용은 비공개)
|
||||||
|
|
||||||
|
### 9.3 action id는 observation scoped
|
||||||
|
|
||||||
|
`Action.id`는 그 observation과 함께 반환된 것에 한해서만 유효하다. 상태가 한 번이라도 진행되면 이전 observation의 id는 재사용 불가.
|
||||||
|
|
||||||
|
이를 강제하기 위해 모든 observation에는 `state_version`(세션 내 단조증가)이 실리고, `ApplyAction`은 `expected_state_version`을 함께 받아 mismatch 시 거절한다.
|
||||||
|
|
||||||
|
### 9.4 합법 액션은 observation에 임베드되어 옴
|
||||||
|
|
||||||
|
별도 RPC 호출 없이 `observation.legal_actions` 안에서 전체 리스트와 마스크가 모두 제공된다. 외부 에이전트는 이 리스트에서 `id`만 골라 `ApplyAction`으로 되돌려주면 된다.
|
||||||
|
|
||||||
|
### 9.5 보상
|
||||||
|
|
||||||
|
`ApplyAction`의 `reward`는 observer 관점이며, terminal 전이에서만 nonzero이고 값은 observer의 `score_diff`. 비terminal 전이의 reward는 0.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 결정성과 재현
|
||||||
|
|
||||||
|
동일한 `(config, seed, action_sequence)`는 동일한 상태 전이와 최종 점수를 재생성한다. 로그/리플레이는 이 튜플을 저장하는 것으로 충분하다.
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .game import GameState, IllegalMoveError, LostCitiesConfig
|
||||||
|
|
||||||
|
try:
|
||||||
|
import numpy as np
|
||||||
|
except ImportError as exc: # pragma: no cover
|
||||||
|
raise RuntimeError("numpy is required for LostCitiesEnv") from exc
|
||||||
|
|
||||||
|
|
||||||
|
class LostCitiesEnv:
|
||||||
|
def __init__(self, config: LostCitiesConfig | None = None):
|
||||||
|
self.config = config or LostCitiesConfig()
|
||||||
|
self.state: GameState | None = None
|
||||||
|
|
||||||
|
def reset(self) -> dict:
|
||||||
|
self.state = GameState.new_game(self.config)
|
||||||
|
return self._obs()
|
||||||
|
|
||||||
|
def step(self, action_id: int) -> tuple[dict, float, bool, dict]:
|
||||||
|
if self.state is None:
|
||||||
|
self.reset()
|
||||||
|
assert self.state is not None
|
||||||
|
actor = self.state.current_player
|
||||||
|
self.state.apply_unified_action(self._normalize_action_id(action_id))
|
||||||
|
done = self.state.terminal
|
||||||
|
reward = float(self.state.score_diff(actor)) if done else 0.0
|
||||||
|
return self._obs(), reward, done, {}
|
||||||
|
|
||||||
|
def legal_actions(self) -> np.ndarray:
|
||||||
|
if self.state is None:
|
||||||
|
self.reset()
|
||||||
|
assert self.state is not None
|
||||||
|
return np.asarray(self.state.unified_legal_mask(), dtype=bool)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def current_player(self) -> int:
|
||||||
|
if self.state is None:
|
||||||
|
self.reset()
|
||||||
|
assert self.state is not None
|
||||||
|
return self.state.current_player
|
||||||
|
|
||||||
|
@property
|
||||||
|
def phase(self) -> str:
|
||||||
|
if self.state is None:
|
||||||
|
self.reset()
|
||||||
|
assert self.state is not None
|
||||||
|
return self.state.phase
|
||||||
|
|
||||||
|
def _obs(self) -> dict:
|
||||||
|
assert self.state is not None
|
||||||
|
return {
|
||||||
|
"spatial": np.zeros((0,), dtype=np.float32),
|
||||||
|
"scalar": np.zeros((0,), dtype=np.float32),
|
||||||
|
"legal_mask": np.asarray(self.state.unified_legal_mask(), dtype=bool),
|
||||||
|
"phase": 0 if self.state.phase == "card" else 1,
|
||||||
|
"player": self.state.current_player,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _normalize_action_id(self, action_id: int) -> int:
|
||||||
|
assert self.state is not None
|
||||||
|
unified_mask = self.state.unified_legal_mask()
|
||||||
|
if 0 <= action_id < len(unified_mask) and unified_mask[action_id]:
|
||||||
|
return action_id
|
||||||
|
|
||||||
|
local_mask = self.state.legal_mask()
|
||||||
|
if 0 <= action_id < len(local_mask) and local_mask[action_id]:
|
||||||
|
return self.state.to_unified_action(action_id)
|
||||||
|
|
||||||
|
raise IllegalMoveError(
|
||||||
|
f"illegal action {action_id} in phase {self.state.phase} "
|
||||||
|
f"for player {self.state.current_player}"
|
||||||
|
)
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
{
|
||||||
|
"name": "canonical-small-deterministic",
|
||||||
|
"config": {
|
||||||
|
"n_colors": 2,
|
||||||
|
"n_ranks": 2,
|
||||||
|
"min_rank": 1,
|
||||||
|
"n_handshakes": 0,
|
||||||
|
"hand_size": 1,
|
||||||
|
"expedition_penalty": 0,
|
||||||
|
"bonus_threshold": 99,
|
||||||
|
"bonus_amount": 0,
|
||||||
|
"seed": null
|
||||||
|
},
|
||||||
|
"initial_deck": [
|
||||||
|
{"color": 0, "rank": 1},
|
||||||
|
{"color": 0, "rank": 2},
|
||||||
|
{"color": 1, "rank": 1},
|
||||||
|
{"color": 1, "rank": 2}
|
||||||
|
],
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"action": null,
|
||||||
|
"phase": "card",
|
||||||
|
"current_player": 0,
|
||||||
|
"turn_count": 0,
|
||||||
|
"terminal": false,
|
||||||
|
"score_diff_player0": 0,
|
||||||
|
"legal_mask": [true, true, false, false, false]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"action": 1,
|
||||||
|
"phase": "draw",
|
||||||
|
"current_player": 0,
|
||||||
|
"turn_count": 0,
|
||||||
|
"terminal": false,
|
||||||
|
"score_diff_player0": 0,
|
||||||
|
"legal_mask": [false, false, true, false, false]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"action": 2,
|
||||||
|
"phase": "card",
|
||||||
|
"current_player": 1,
|
||||||
|
"turn_count": 1,
|
||||||
|
"terminal": false,
|
||||||
|
"score_diff_player0": 0,
|
||||||
|
"legal_mask": [true, true, false, false, false]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"action": 0,
|
||||||
|
"phase": "draw",
|
||||||
|
"current_player": 1,
|
||||||
|
"turn_count": 1,
|
||||||
|
"terminal": false,
|
||||||
|
"score_diff_player0": -1,
|
||||||
|
"legal_mask": [false, false, true, false, true]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"action": 4,
|
||||||
|
"phase": "card",
|
||||||
|
"current_player": 0,
|
||||||
|
"turn_count": 2,
|
||||||
|
"terminal": false,
|
||||||
|
"score_diff_player0": -1,
|
||||||
|
"legal_mask": [true, true, false, false, false]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"action": 0,
|
||||||
|
"phase": "draw",
|
||||||
|
"current_player": 0,
|
||||||
|
"turn_count": 2,
|
||||||
|
"terminal": false,
|
||||||
|
"score_diff_player0": 1,
|
||||||
|
"legal_mask": [false, false, true, false, false]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"action": 2,
|
||||||
|
"phase": "draw",
|
||||||
|
"current_player": 0,
|
||||||
|
"turn_count": 3,
|
||||||
|
"terminal": true,
|
||||||
|
"score_diff_player0": 1,
|
||||||
|
"legal_mask": [false, false, false, false, false]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,718 @@
|
|||||||
|
# cython: language_level=3, boundscheck=False, wraparound=False, cdivision=True, initializedcheck=False
|
||||||
|
from collections import Counter
|
||||||
|
from dataclasses import dataclass, fields
|
||||||
|
import random
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
cimport cython
|
||||||
|
|
||||||
|
|
||||||
|
Phase = Literal["card", "draw"]
|
||||||
|
|
||||||
|
|
||||||
|
class IllegalMoveError(ValueError):
|
||||||
|
"""Raised when an action id is not legal for the current state."""
|
||||||
|
|
||||||
|
|
||||||
|
@cython.freelist(256)
|
||||||
|
cdef class Card:
|
||||||
|
cdef readonly int color
|
||||||
|
cdef readonly int rank
|
||||||
|
|
||||||
|
def __cinit__(self, color, rank):
|
||||||
|
self.color = int(color)
|
||||||
|
self.rank = int(rank)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_handshake(self):
|
||||||
|
return self.rank == 0
|
||||||
|
|
||||||
|
cpdef int numeric_value(self, int min_rank):
|
||||||
|
if self.rank == 0:
|
||||||
|
return 0
|
||||||
|
return min_rank + self.rank - 1
|
||||||
|
|
||||||
|
def label(self, int min_rank):
|
||||||
|
if self.rank == 0:
|
||||||
|
return f"[{self.color}]H"
|
||||||
|
return f"[{self.color}]{self.numeric_value(min_rank)}"
|
||||||
|
|
||||||
|
def to_snapshot(self):
|
||||||
|
return {"color": self.color, "rank": self.rank}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_snapshot(cls, data):
|
||||||
|
if isinstance(data, Card):
|
||||||
|
return data
|
||||||
|
if isinstance(data, dict):
|
||||||
|
return cls(int(data["color"]), int(data["rank"]))
|
||||||
|
if isinstance(data, (list, tuple)) and len(data) == 2:
|
||||||
|
return cls(int(data[0]), int(data[1]))
|
||||||
|
raise ValueError(f"invalid card snapshot: {data!r}")
|
||||||
|
|
||||||
|
def __hash__(self):
|
||||||
|
return (self.color << 8) | self.rank
|
||||||
|
|
||||||
|
def __richcmp__(self, other, int op):
|
||||||
|
if not isinstance(other, Card):
|
||||||
|
return NotImplemented
|
||||||
|
cdef Card o = <Card>other
|
||||||
|
cdef bint eq = self.color == o.color and self.rank == o.rank
|
||||||
|
if op == 2: # ==
|
||||||
|
return eq
|
||||||
|
if op == 3: # !=
|
||||||
|
return not eq
|
||||||
|
cdef bint lt
|
||||||
|
if self.color != o.color:
|
||||||
|
lt = self.color < o.color
|
||||||
|
else:
|
||||||
|
lt = self.rank < o.rank
|
||||||
|
if op == 0: # <
|
||||||
|
return lt
|
||||||
|
if op == 1: # <=
|
||||||
|
return lt or eq
|
||||||
|
if op == 4: # >
|
||||||
|
return not lt and not eq
|
||||||
|
if op == 5: # >=
|
||||||
|
return not lt
|
||||||
|
return NotImplemented
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"Card(color={self.color}, rank={self.rank})"
|
||||||
|
|
||||||
|
def __reduce__(self):
|
||||||
|
return (Card, (self.color, self.rank))
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class LostCitiesConfig:
|
||||||
|
n_colors: int = 5
|
||||||
|
n_ranks: int = 9
|
||||||
|
min_rank: int = 2
|
||||||
|
n_handshakes: int = 3
|
||||||
|
hand_size: int = 8
|
||||||
|
expedition_penalty: int = -20
|
||||||
|
bonus_threshold: int = 8
|
||||||
|
bonus_amount: int = 20
|
||||||
|
seed: int | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def deck_size(self) -> int:
|
||||||
|
return self.n_colors * (self.n_ranks + self.n_handshakes)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def max_rank(self) -> int:
|
||||||
|
return self.min_rank + self.n_ranks - 1
|
||||||
|
|
||||||
|
@property
|
||||||
|
def card_action_size(self) -> int:
|
||||||
|
return 2 * self.hand_size
|
||||||
|
|
||||||
|
@property
|
||||||
|
def draw_action_size(self) -> int:
|
||||||
|
return 1 + self.n_colors
|
||||||
|
|
||||||
|
@property
|
||||||
|
def action_size(self) -> int:
|
||||||
|
return self.card_action_size + self.draw_action_size
|
||||||
|
|
||||||
|
def validate(self) -> None:
|
||||||
|
if self.n_colors <= 0:
|
||||||
|
raise ValueError("n_colors must be positive")
|
||||||
|
if self.n_ranks <= 0:
|
||||||
|
raise ValueError("n_ranks must be positive")
|
||||||
|
if self.min_rank <= 0:
|
||||||
|
raise ValueError("min_rank must be positive")
|
||||||
|
if self.n_handshakes < 0:
|
||||||
|
raise ValueError("n_handshakes cannot be negative")
|
||||||
|
if self.hand_size <= 0:
|
||||||
|
raise ValueError("hand_size must be positive")
|
||||||
|
if self.deck_size < 2 * self.hand_size:
|
||||||
|
raise ValueError("deck must contain at least both initial hands")
|
||||||
|
if self.bonus_threshold <= 0:
|
||||||
|
raise ValueError("bonus_threshold must be positive")
|
||||||
|
|
||||||
|
def to_snapshot(self) -> dict[str, Any]:
|
||||||
|
return {field.name: getattr(self, field.name) for field in fields(self)}
|
||||||
|
|
||||||
|
|
||||||
|
def classic_config(*, seed=None):
|
||||||
|
return LostCitiesConfig(seed=seed)
|
||||||
|
|
||||||
|
|
||||||
|
def config_from_mapping(data):
|
||||||
|
allowed = LostCitiesConfig.__dataclass_fields__.keys()
|
||||||
|
kwargs = {key: value for key, value in data.items() if key in allowed}
|
||||||
|
config = LostCitiesConfig(**kwargs)
|
||||||
|
config.validate()
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
def config_to_mapping(config):
|
||||||
|
return config.to_snapshot()
|
||||||
|
|
||||||
|
|
||||||
|
def load_config(path):
|
||||||
|
try:
|
||||||
|
import yaml
|
||||||
|
except ImportError as exc:
|
||||||
|
raise RuntimeError("pyyaml is required to load Lost Cities YAML configs") from exc
|
||||||
|
|
||||||
|
with open(path, "r", encoding="utf-8") as handle:
|
||||||
|
data = yaml.safe_load(handle) or {}
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise ValueError(f"expected mapping in config file: {path}")
|
||||||
|
return config_from_mapping(data)
|
||||||
|
|
||||||
|
|
||||||
|
def build_deck(config):
|
||||||
|
config.validate()
|
||||||
|
cdef list deck = []
|
||||||
|
cdef int color, rank
|
||||||
|
cdef int n_colors = config.n_colors
|
||||||
|
cdef int n_handshakes = config.n_handshakes
|
||||||
|
cdef int n_ranks = config.n_ranks
|
||||||
|
for color in range(n_colors):
|
||||||
|
for _ in range(n_handshakes):
|
||||||
|
deck.append(Card(color, 0))
|
||||||
|
for rank in range(1, n_ranks + 1):
|
||||||
|
deck.append(Card(color, rank))
|
||||||
|
return deck
|
||||||
|
|
||||||
|
|
||||||
|
def _card_counter(cards):
|
||||||
|
return Counter(cards)
|
||||||
|
|
||||||
|
|
||||||
|
def _cards_from_snapshot(data):
|
||||||
|
if not isinstance(data, list):
|
||||||
|
raise ValueError(f"expected card list snapshot, got {type(data).__name__}")
|
||||||
|
return [Card.from_snapshot(card) for card in data]
|
||||||
|
|
||||||
|
|
||||||
|
def _cards_to_snapshot(cards):
|
||||||
|
return [card.to_snapshot() for card in cards]
|
||||||
|
|
||||||
|
|
||||||
|
cdef class GameState:
|
||||||
|
cdef public object config
|
||||||
|
cdef public list deck
|
||||||
|
cdef public list hands
|
||||||
|
cdef public list expeditions
|
||||||
|
cdef public list discards
|
||||||
|
cdef public int current_player
|
||||||
|
cdef public str phase
|
||||||
|
cdef public object pending_discarded_color
|
||||||
|
cdef public int turn_count
|
||||||
|
cdef public bint terminal
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
config,
|
||||||
|
deck=None,
|
||||||
|
hands=None,
|
||||||
|
expeditions=None,
|
||||||
|
discards=None,
|
||||||
|
int current_player=0,
|
||||||
|
phase="card",
|
||||||
|
pending_discarded_color=None,
|
||||||
|
int turn_count=0,
|
||||||
|
bint terminal=False,
|
||||||
|
):
|
||||||
|
self.config = config
|
||||||
|
self.deck = list(deck) if deck is not None else []
|
||||||
|
self.hands = hands if hands is not None else [[], []]
|
||||||
|
self.expeditions = expeditions if expeditions is not None else [
|
||||||
|
[[] for _ in range(config.n_colors)],
|
||||||
|
[[] for _ in range(config.n_colors)],
|
||||||
|
]
|
||||||
|
self.discards = discards if discards is not None else [
|
||||||
|
[] for _ in range(config.n_colors)
|
||||||
|
]
|
||||||
|
self.current_player = current_player
|
||||||
|
self.phase = phase
|
||||||
|
self.pending_discarded_color = pending_discarded_color
|
||||||
|
self.turn_count = turn_count
|
||||||
|
self.terminal = terminal
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def new_game(cls, config=None, *, seed=None):
|
||||||
|
config = config or LostCitiesConfig()
|
||||||
|
config.validate()
|
||||||
|
rng = random.Random(config.seed if seed is None else seed)
|
||||||
|
deck = build_deck(config)
|
||||||
|
rng.shuffle(deck)
|
||||||
|
return cls.new_game_from_deck(deck, config)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def new_game_from_deck(cls, deck, config=None):
|
||||||
|
config = config or LostCitiesConfig()
|
||||||
|
config.validate()
|
||||||
|
cards = [Card.from_snapshot(card) for card in deck]
|
||||||
|
if _card_counter(cards) != _card_counter(build_deck(config)):
|
||||||
|
raise ValueError("deck must contain exactly the cards defined by config")
|
||||||
|
|
||||||
|
state = cls.empty(config)
|
||||||
|
state.deck = list(cards)
|
||||||
|
cdef int player
|
||||||
|
for _ in range(config.hand_size):
|
||||||
|
for player in range(2):
|
||||||
|
state.hands[player].append(state.deck.pop())
|
||||||
|
state.sort_hands()
|
||||||
|
state.validate_invariants()
|
||||||
|
return state
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def empty(cls, config=None):
|
||||||
|
config = config or LostCitiesConfig()
|
||||||
|
config.validate()
|
||||||
|
return cls(
|
||||||
|
config=config,
|
||||||
|
deck=[],
|
||||||
|
hands=[[], []],
|
||||||
|
expeditions=[
|
||||||
|
[[] for _ in range(config.n_colors)],
|
||||||
|
[[] for _ in range(config.n_colors)],
|
||||||
|
],
|
||||||
|
discards=[[] for _ in range(config.n_colors)],
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_snapshot(cls, snapshot, *, validate=True):
|
||||||
|
config = config_from_mapping(snapshot["config"])
|
||||||
|
phase = snapshot.get("phase", "card")
|
||||||
|
if phase not in ("card", "draw"):
|
||||||
|
raise ValueError(f"invalid phase: {phase!r}")
|
||||||
|
|
||||||
|
state = cls(
|
||||||
|
config=config,
|
||||||
|
deck=_cards_from_snapshot(snapshot["deck"]),
|
||||||
|
hands=[
|
||||||
|
_cards_from_snapshot(snapshot["hands"][0]),
|
||||||
|
_cards_from_snapshot(snapshot["hands"][1]),
|
||||||
|
],
|
||||||
|
expeditions=[
|
||||||
|
[
|
||||||
|
_cards_from_snapshot(color_cards)
|
||||||
|
for color_cards in snapshot["expeditions"][0]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
_cards_from_snapshot(color_cards)
|
||||||
|
for color_cards in snapshot["expeditions"][1]
|
||||||
|
],
|
||||||
|
],
|
||||||
|
discards=[
|
||||||
|
_cards_from_snapshot(color_cards)
|
||||||
|
for color_cards in snapshot["discards"]
|
||||||
|
],
|
||||||
|
current_player=int(snapshot.get("current_player", 0)),
|
||||||
|
phase=phase,
|
||||||
|
pending_discarded_color=snapshot.get("pending_discarded_color"),
|
||||||
|
turn_count=int(snapshot.get("turn_count", 0)),
|
||||||
|
terminal=bool(snapshot.get("terminal", False)),
|
||||||
|
)
|
||||||
|
if state.pending_discarded_color is not None:
|
||||||
|
state.pending_discarded_color = int(state.pending_discarded_color)
|
||||||
|
if validate:
|
||||||
|
state.validate_invariants()
|
||||||
|
return state
|
||||||
|
|
||||||
|
def to_snapshot(self):
|
||||||
|
return {
|
||||||
|
"config": self.config.to_snapshot(),
|
||||||
|
"deck": _cards_to_snapshot(self.deck),
|
||||||
|
"hands": [_cards_to_snapshot(hand) for hand in self.hands],
|
||||||
|
"expeditions": [
|
||||||
|
[_cards_to_snapshot(expedition) for expedition in player_expeditions]
|
||||||
|
for player_expeditions in self.expeditions
|
||||||
|
],
|
||||||
|
"discards": [_cards_to_snapshot(discard) for discard in self.discards],
|
||||||
|
"current_player": self.current_player,
|
||||||
|
"phase": self.phase,
|
||||||
|
"pending_discarded_color": self.pending_discarded_color,
|
||||||
|
"turn_count": self.turn_count,
|
||||||
|
"terminal": self.terminal,
|
||||||
|
}
|
||||||
|
|
||||||
|
cpdef GameState clone(self):
|
||||||
|
cdef GameState other = GameState.__new__(GameState)
|
||||||
|
other.config = self.config
|
||||||
|
other.deck = list(self.deck)
|
||||||
|
other.hands = [list(self.hands[0]), list(self.hands[1])]
|
||||||
|
other.expeditions = [
|
||||||
|
[list(exp) for exp in self.expeditions[0]],
|
||||||
|
[list(exp) for exp in self.expeditions[1]],
|
||||||
|
]
|
||||||
|
other.discards = [list(pile) for pile in self.discards]
|
||||||
|
other.current_player = self.current_player
|
||||||
|
other.phase = self.phase
|
||||||
|
other.pending_discarded_color = self.pending_discarded_color
|
||||||
|
other.turn_count = self.turn_count
|
||||||
|
other.terminal = self.terminal
|
||||||
|
return other
|
||||||
|
|
||||||
|
@property
|
||||||
|
def card_action_size(self):
|
||||||
|
return self.config.card_action_size
|
||||||
|
|
||||||
|
@property
|
||||||
|
def draw_action_size(self):
|
||||||
|
return self.config.draw_action_size
|
||||||
|
|
||||||
|
@property
|
||||||
|
def action_size(self):
|
||||||
|
return self.config.action_size
|
||||||
|
|
||||||
|
def sort_hands(self):
|
||||||
|
cdef int player
|
||||||
|
for player in range(2):
|
||||||
|
self.sort_hand(player)
|
||||||
|
|
||||||
|
def sort_hand(self, player=None):
|
||||||
|
cdef int p = self.current_player if player is None else int(player)
|
||||||
|
self.hands[p].sort(key=_card_sort_key)
|
||||||
|
|
||||||
|
def hand_slots(self, player=None):
|
||||||
|
cdef int p = self.current_player if player is None else int(player)
|
||||||
|
cdef list hand = self.hands[p]
|
||||||
|
cdef int hand_size = self.config.hand_size
|
||||||
|
cdef int n = len(hand)
|
||||||
|
cdef int i
|
||||||
|
cdef list out = []
|
||||||
|
for i in range(hand_size):
|
||||||
|
if i < n:
|
||||||
|
out.append(hand[i])
|
||||||
|
else:
|
||||||
|
out.append(None)
|
||||||
|
return out
|
||||||
|
|
||||||
|
cpdef int last_numeric_rank(self, int player, int color):
|
||||||
|
cdef list expedition = self.expeditions[player][color]
|
||||||
|
cdef int best = 0
|
||||||
|
cdef int n = len(expedition)
|
||||||
|
cdef int i
|
||||||
|
cdef Card card
|
||||||
|
for i in range(n):
|
||||||
|
card = <Card>expedition[i]
|
||||||
|
if card.rank == 0:
|
||||||
|
continue
|
||||||
|
if card.rank > best:
|
||||||
|
best = card.rank
|
||||||
|
return best
|
||||||
|
|
||||||
|
def has_numeric(self, int player, int color):
|
||||||
|
return self.last_numeric_rank(player, color) > 0
|
||||||
|
|
||||||
|
cpdef bint can_play_card(self, int player, Card card):
|
||||||
|
cdef int n_colors = self.config.n_colors
|
||||||
|
cdef int n_ranks = self.config.n_ranks
|
||||||
|
if card.color < 0 or card.color >= n_colors:
|
||||||
|
return False
|
||||||
|
if card.rank < 0 or card.rank > n_ranks:
|
||||||
|
return False
|
||||||
|
cdef int last_numeric = self.last_numeric_rank(player, card.color)
|
||||||
|
if card.rank == 0:
|
||||||
|
return last_numeric == 0
|
||||||
|
return card.rank > last_numeric
|
||||||
|
|
||||||
|
cpdef list legal_card_mask(self):
|
||||||
|
cdef int size = self.card_action_size
|
||||||
|
cdef list mask = [False] * size
|
||||||
|
if self.terminal:
|
||||||
|
return mask
|
||||||
|
cdef list hand = self.hands[self.current_player]
|
||||||
|
cdef int hand_size = self.config.hand_size
|
||||||
|
cdef int n = len(hand)
|
||||||
|
cdef int slot
|
||||||
|
cdef Card card
|
||||||
|
for slot in range(hand_size):
|
||||||
|
if slot >= n:
|
||||||
|
continue
|
||||||
|
card = <Card>hand[slot]
|
||||||
|
mask[2 * slot] = self.can_play_card(self.current_player, card)
|
||||||
|
mask[2 * slot + 1] = True
|
||||||
|
return mask
|
||||||
|
|
||||||
|
cpdef list legal_draw_mask(self):
|
||||||
|
cdef int size = self.draw_action_size
|
||||||
|
cdef list mask = [False] * size
|
||||||
|
if self.terminal:
|
||||||
|
return mask
|
||||||
|
mask[0] = len(self.deck) > 0
|
||||||
|
cdef int n_colors = self.config.n_colors
|
||||||
|
cdef int color
|
||||||
|
cdef object pending = self.pending_discarded_color
|
||||||
|
for color in range(n_colors):
|
||||||
|
mask[1 + color] = (
|
||||||
|
len(self.discards[color]) > 0
|
||||||
|
and (pending is None or color != pending)
|
||||||
|
)
|
||||||
|
return mask
|
||||||
|
|
||||||
|
cpdef list legal_mask(self):
|
||||||
|
if self.phase == "card":
|
||||||
|
return self.legal_card_mask()
|
||||||
|
return self.legal_draw_mask()
|
||||||
|
|
||||||
|
cpdef list unified_legal_mask(self):
|
||||||
|
cdef int draw_size = self.draw_action_size
|
||||||
|
cdef int card_size = self.card_action_size
|
||||||
|
cdef list result
|
||||||
|
if self.phase == "card":
|
||||||
|
result = self.legal_card_mask()
|
||||||
|
result.extend([False] * draw_size)
|
||||||
|
return result
|
||||||
|
result = [False] * card_size
|
||||||
|
result.extend(self.legal_draw_mask())
|
||||||
|
return result
|
||||||
|
|
||||||
|
cpdef object unified_legal_mask_np(self):
|
||||||
|
cdef int n_colors = self.config.n_colors
|
||||||
|
cdef int hand_size = self.config.hand_size
|
||||||
|
cdef int card_action_size = 2 * hand_size
|
||||||
|
cdef int draw_action_size = 1 + n_colors
|
||||||
|
cdef int total = card_action_size + draw_action_size
|
||||||
|
|
||||||
|
mask_arr = np.zeros(total, dtype=bool)
|
||||||
|
cdef unsigned char[::1] view = mask_arr.view(np.uint8)
|
||||||
|
if self.terminal:
|
||||||
|
return mask_arr
|
||||||
|
|
||||||
|
cdef int slot, n, color
|
||||||
|
cdef Card card
|
||||||
|
cdef list hand
|
||||||
|
cdef int p = self.current_player
|
||||||
|
cdef object pending
|
||||||
|
|
||||||
|
if self.phase == "card":
|
||||||
|
hand = self.hands[p]
|
||||||
|
n = len(hand)
|
||||||
|
for slot in range(hand_size):
|
||||||
|
if slot >= n:
|
||||||
|
continue
|
||||||
|
card = <Card>hand[slot]
|
||||||
|
if self.can_play_card(p, card):
|
||||||
|
view[2 * slot] = 1
|
||||||
|
view[2 * slot + 1] = 1
|
||||||
|
else:
|
||||||
|
pending = self.pending_discarded_color
|
||||||
|
if len(self.deck) > 0:
|
||||||
|
view[card_action_size] = 1
|
||||||
|
for color in range(n_colors):
|
||||||
|
if (
|
||||||
|
len(self.discards[color]) > 0
|
||||||
|
and (pending is None or color != pending)
|
||||||
|
):
|
||||||
|
view[card_action_size + 1 + color] = 1
|
||||||
|
return mask_arr
|
||||||
|
|
||||||
|
def to_unified_action(self, int action_id, phase=None):
|
||||||
|
cdef str p = self.phase if phase is None else phase
|
||||||
|
if p == "card":
|
||||||
|
if action_id < 0 or action_id >= self.card_action_size:
|
||||||
|
raise IllegalMoveError(f"card action {action_id} is out of range")
|
||||||
|
return action_id
|
||||||
|
if action_id < 0 or action_id >= self.draw_action_size:
|
||||||
|
raise IllegalMoveError(f"draw action {action_id} is out of range")
|
||||||
|
return self.card_action_size + action_id
|
||||||
|
|
||||||
|
cpdef int from_unified_action(self, int action_id):
|
||||||
|
if action_id < 0 or action_id >= self.action_size:
|
||||||
|
raise IllegalMoveError(f"action {action_id} is out of range")
|
||||||
|
if self.phase == "card":
|
||||||
|
if action_id >= self.card_action_size:
|
||||||
|
raise IllegalMoveError(
|
||||||
|
f"card action {action_id} is illegal during card phase"
|
||||||
|
)
|
||||||
|
return action_id
|
||||||
|
if action_id < self.card_action_size:
|
||||||
|
raise IllegalMoveError(
|
||||||
|
f"card action {action_id} is illegal during draw phase"
|
||||||
|
)
|
||||||
|
return action_id - self.card_action_size
|
||||||
|
|
||||||
|
cpdef apply_action(self, int action_id):
|
||||||
|
if self.terminal:
|
||||||
|
raise IllegalMoveError("game is already terminal")
|
||||||
|
cdef list mask = self.legal_mask()
|
||||||
|
if action_id < 0 or action_id >= len(mask) or not mask[action_id]:
|
||||||
|
raise IllegalMoveError(
|
||||||
|
f"illegal action {action_id} in phase {self.phase} "
|
||||||
|
f"for player {self.current_player}"
|
||||||
|
)
|
||||||
|
if self.phase == "card":
|
||||||
|
self._apply_card_action(action_id)
|
||||||
|
else:
|
||||||
|
self._apply_draw_action(action_id)
|
||||||
|
|
||||||
|
cpdef apply_unified_action(self, int action_id):
|
||||||
|
self.apply_action(self.from_unified_action(action_id))
|
||||||
|
|
||||||
|
cdef void _apply_card_action(self, int action_id) except *:
|
||||||
|
cdef int slot = action_id // 2
|
||||||
|
cdef bint play = action_id % 2 == 0
|
||||||
|
cdef Card card = <Card>self.hands[self.current_player].pop(slot)
|
||||||
|
if play:
|
||||||
|
self.expeditions[self.current_player][card.color].append(card)
|
||||||
|
else:
|
||||||
|
self.discards[card.color].append(card)
|
||||||
|
self.pending_discarded_color = card.color
|
||||||
|
self.phase = "draw"
|
||||||
|
cdef int n_colors = self.config.n_colors
|
||||||
|
cdef int color
|
||||||
|
cdef object pending = self.pending_discarded_color
|
||||||
|
cdef bint any_legal_draw = False
|
||||||
|
if len(self.deck) == 0:
|
||||||
|
for color in range(n_colors):
|
||||||
|
if len(self.discards[color]) > 0 and (pending is None or color != pending):
|
||||||
|
any_legal_draw = True
|
||||||
|
break
|
||||||
|
if not any_legal_draw:
|
||||||
|
self.terminal = True
|
||||||
|
|
||||||
|
cdef void _apply_draw_action(self, int action_id) except *:
|
||||||
|
cdef Card card
|
||||||
|
cdef int color
|
||||||
|
if action_id == 0:
|
||||||
|
card = <Card>self.deck.pop()
|
||||||
|
else:
|
||||||
|
color = action_id - 1
|
||||||
|
card = <Card>self.discards[color].pop()
|
||||||
|
self.hands[self.current_player].append(card)
|
||||||
|
self.sort_hand(self.current_player)
|
||||||
|
self.pending_discarded_color = None
|
||||||
|
self.turn_count += 1
|
||||||
|
if len(self.deck) == 0:
|
||||||
|
self.terminal = True
|
||||||
|
return
|
||||||
|
self.current_player = 1 - self.current_player
|
||||||
|
self.phase = "card"
|
||||||
|
|
||||||
|
cpdef int expedition_score(self, int player, int color):
|
||||||
|
return score_expedition(self.expeditions[player][color], self.config)
|
||||||
|
|
||||||
|
cpdef int total_score(self, int player):
|
||||||
|
cdef int total = 0
|
||||||
|
cdef int color
|
||||||
|
cdef int n_colors = self.config.n_colors
|
||||||
|
for color in range(n_colors):
|
||||||
|
total += score_expedition(self.expeditions[player][color], self.config)
|
||||||
|
return total
|
||||||
|
|
||||||
|
cpdef int score_diff(self, int player=0):
|
||||||
|
cdef int other = 1 - player
|
||||||
|
return self.total_score(player) - self.total_score(other)
|
||||||
|
|
||||||
|
def validate_invariants(self):
|
||||||
|
self.config.validate()
|
||||||
|
if self.current_player not in (0, 1):
|
||||||
|
raise ValueError("current_player must be 0 or 1")
|
||||||
|
if self.phase not in ("card", "draw"):
|
||||||
|
raise ValueError(f"invalid phase: {self.phase!r}")
|
||||||
|
if len(self.hands) != 2:
|
||||||
|
raise ValueError("hands must contain two players")
|
||||||
|
if len(self.expeditions) != 2:
|
||||||
|
raise ValueError("expeditions must contain two players")
|
||||||
|
if len(self.discards) != self.config.n_colors:
|
||||||
|
raise ValueError("discard pile count must match n_colors")
|
||||||
|
|
||||||
|
all_cards = []
|
||||||
|
all_cards.extend(self.deck)
|
||||||
|
for player, hand in enumerate(self.hands):
|
||||||
|
if len(hand) > self.config.hand_size:
|
||||||
|
raise ValueError(f"hand {player} exceeds hand_size")
|
||||||
|
if hand != sorted(hand, key=_card_sort_key):
|
||||||
|
raise ValueError(f"hand {player} is not sorted")
|
||||||
|
all_cards.extend(hand)
|
||||||
|
|
||||||
|
for player, expeditions in enumerate(self.expeditions):
|
||||||
|
if len(expeditions) != self.config.n_colors:
|
||||||
|
raise ValueError("expedition color count must match n_colors")
|
||||||
|
for color, expedition in enumerate(expeditions):
|
||||||
|
self._validate_expedition(player, color, expedition)
|
||||||
|
all_cards.extend(expedition)
|
||||||
|
for discard in self.discards:
|
||||||
|
all_cards.extend(discard)
|
||||||
|
|
||||||
|
for card in all_cards:
|
||||||
|
self._validate_card(card)
|
||||||
|
if _card_counter(all_cards) != _card_counter(build_deck(self.config)):
|
||||||
|
raise ValueError("card conservation failed")
|
||||||
|
|
||||||
|
if self.phase == "card" and self.pending_discarded_color is not None:
|
||||||
|
raise ValueError("pending_discarded_color must be None during card phase")
|
||||||
|
if self.pending_discarded_color is not None:
|
||||||
|
color = self.pending_discarded_color
|
||||||
|
if color < 0 or color >= self.config.n_colors:
|
||||||
|
raise ValueError("pending_discarded_color is out of range")
|
||||||
|
if not self.discards[color]:
|
||||||
|
raise ValueError("pending discard color must have a discard pile card")
|
||||||
|
|
||||||
|
any_legal = any(self.unified_legal_mask())
|
||||||
|
if self.terminal and any_legal:
|
||||||
|
raise ValueError("terminal state must have no legal actions")
|
||||||
|
if not self.terminal and not any_legal:
|
||||||
|
raise ValueError("non-terminal state must have at least one legal action")
|
||||||
|
|
||||||
|
def _validate_card(self, Card card):
|
||||||
|
if card.color < 0 or card.color >= self.config.n_colors:
|
||||||
|
raise ValueError(f"card color out of range: {card}")
|
||||||
|
if card.rank < 0 or card.rank > self.config.n_ranks:
|
||||||
|
raise ValueError(f"card rank out of range: {card}")
|
||||||
|
|
||||||
|
def _validate_expedition(self, int player, int color, list expedition):
|
||||||
|
cdef bint seen_numeric = False
|
||||||
|
cdef int last_numeric = 0
|
||||||
|
cdef Card card
|
||||||
|
for card in expedition:
|
||||||
|
if card.color != color:
|
||||||
|
raise ValueError(
|
||||||
|
f"player {player} expedition {color} contains wrong color"
|
||||||
|
)
|
||||||
|
if card.rank == 0:
|
||||||
|
if seen_numeric:
|
||||||
|
raise ValueError(
|
||||||
|
f"player {player} expedition {color} has handshake after number"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
seen_numeric = True
|
||||||
|
if card.rank <= last_numeric:
|
||||||
|
raise ValueError(
|
||||||
|
f"player {player} expedition {color} is not strictly increasing"
|
||||||
|
)
|
||||||
|
last_numeric = card.rank
|
||||||
|
|
||||||
|
def __reduce__(self):
|
||||||
|
# support pickle via snapshot round-trip
|
||||||
|
return (_rebuild_game_state, (self.to_snapshot(),))
|
||||||
|
|
||||||
|
|
||||||
|
def _rebuild_game_state(snapshot):
|
||||||
|
return GameState.from_snapshot(snapshot, validate=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _card_sort_key(Card card):
|
||||||
|
return (card.color, card.rank)
|
||||||
|
|
||||||
|
|
||||||
|
cpdef int score_expedition(list expedition, config):
|
||||||
|
cdef int n = len(expedition)
|
||||||
|
if n == 0:
|
||||||
|
return 0
|
||||||
|
cdef int min_rank = config.min_rank
|
||||||
|
cdef int handshakes = 0
|
||||||
|
cdef int numeric_sum = 0
|
||||||
|
cdef int i
|
||||||
|
cdef Card card
|
||||||
|
for i in range(n):
|
||||||
|
card = <Card>expedition[i]
|
||||||
|
if card.rank == 0:
|
||||||
|
handshakes += 1
|
||||||
|
else:
|
||||||
|
numeric_sum += min_rank + card.rank - 1
|
||||||
|
cdef int score = (numeric_sum + config.expedition_penalty) * (handshakes + 1)
|
||||||
|
if n >= config.bonus_threshold:
|
||||||
|
score += config.bonus_amount
|
||||||
|
return score
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Literal, Protocol, TypeAlias, runtime_checkable
|
||||||
|
|
||||||
|
from .game import Card, GameState, LostCitiesConfig, score_expedition
|
||||||
|
|
||||||
|
BackendName = Literal["python", "rust"]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Snapshot:
|
||||||
|
config: LostCitiesConfig
|
||||||
|
deck: list[Card]
|
||||||
|
hands: list[list[Card]]
|
||||||
|
expeditions: list[list[list[Card]]]
|
||||||
|
discards: list[list[Card]]
|
||||||
|
current_player: int
|
||||||
|
phase: str
|
||||||
|
pending_discarded_color: int | None
|
||||||
|
turn_count: int
|
||||||
|
terminal: bool
|
||||||
|
legal_mask: list[bool]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def card_action_size(self) -> int:
|
||||||
|
return self.config.card_action_size
|
||||||
|
|
||||||
|
@property
|
||||||
|
def draw_action_size(self) -> int:
|
||||||
|
return self.config.draw_action_size
|
||||||
|
|
||||||
|
def expedition_score(self, player: int, color: int) -> int:
|
||||||
|
return score_expedition(self.expeditions[player][color], self.config)
|
||||||
|
|
||||||
|
def total_score(self, player: int) -> int:
|
||||||
|
return sum(
|
||||||
|
self.expedition_score(player, color)
|
||||||
|
for color in range(self.config.n_colors)
|
||||||
|
)
|
||||||
|
|
||||||
|
def score_diff(self, player: int = 0) -> int:
|
||||||
|
return self.total_score(player) - self.total_score(1 - player)
|
||||||
|
|
||||||
|
|
||||||
|
BotInput: TypeAlias = dict | GameState | Snapshot
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class LostCitiesBot(Protocol):
|
||||||
|
def act(self, obs_or_state: BotInput) -> int:
|
||||||
|
"""Choose an action id from the current state or observation."""
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class LostCitiesBackend(Protocol):
|
||||||
|
name: BackendName
|
||||||
|
config: LostCitiesConfig
|
||||||
|
seed: int | None
|
||||||
|
|
||||||
|
def snapshot(self) -> Snapshot: ...
|
||||||
|
|
||||||
|
def apply(self, action_id: int) -> None: ...
|
||||||
|
|
||||||
|
def can_undo(self) -> bool: ...
|
||||||
|
|
||||||
|
def undo(self) -> bool: ...
|
||||||
+1367
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
|||||||
|
[package]
|
||||||
|
name = "lost-cities-core"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "lost_cities_core"
|
||||||
|
path = "src/lib.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
prost = "0.13"
|
||||||
|
prost-types = "0.13"
|
||||||
|
rand = { version = "0.8", features = ["std", "std_rng"] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
tokio = { version = "1", features = ["net", "rt-multi-thread"] }
|
||||||
|
tokio-stream = { version = "0.1", features = ["net"] }
|
||||||
|
tonic = { version = "0.12", features = ["transport"] }
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
protoc-bin-vendored = "3"
|
||||||
|
tonic-build = "0.12"
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let protoc = protoc_bin_vendored::protoc_bin_path().expect("vendored protoc");
|
||||||
|
std::env::set_var("PROTOC", protoc);
|
||||||
|
|
||||||
|
let proto_dir = PathBuf::from("../schemas");
|
||||||
|
let proto_file = proto_dir.join("lost_cities.proto");
|
||||||
|
|
||||||
|
println!("cargo:rerun-if-changed={}", proto_file.display());
|
||||||
|
|
||||||
|
tonic_build::configure()
|
||||||
|
.compile_protos(&[proto_file], &[proto_dir])
|
||||||
|
.expect("compile lost_cities proto");
|
||||||
|
}
|
||||||
@@ -0,0 +1,779 @@
|
|||||||
|
use std::env;
|
||||||
|
use std::error::Error;
|
||||||
|
use std::fs;
|
||||||
|
use std::io;
|
||||||
|
|
||||||
|
use lost_cities_core::proto::{
|
||||||
|
self, lost_cities_client::LostCitiesClient, lost_cities_server::LostCitiesServer,
|
||||||
|
};
|
||||||
|
use lost_cities_core::{
|
||||||
|
Card, Config, EngineErrorKind, GameState, LostCitiesEngine, LostCitiesGrpcService, Phase,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tokio::net::TcpListener;
|
||||||
|
use tokio_stream::wrappers::TcpListenerStream;
|
||||||
|
use tonic::transport::{Channel, Endpoint, Server};
|
||||||
|
|
||||||
|
type ProbeResult<T> = Result<T, Box<dyn Error>>;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)]
|
||||||
|
struct CardJson {
|
||||||
|
color: u32,
|
||||||
|
rank: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
|
||||||
|
struct ConfigJson {
|
||||||
|
n_colors: usize,
|
||||||
|
n_ranks: u32,
|
||||||
|
min_rank: u32,
|
||||||
|
n_handshakes: u32,
|
||||||
|
hand_size: usize,
|
||||||
|
expedition_penalty: i32,
|
||||||
|
bonus_threshold: usize,
|
||||||
|
bonus_amount: i32,
|
||||||
|
seed: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct FixtureInput {
|
||||||
|
config: ConfigJson,
|
||||||
|
initial_deck: Vec<CardJson>,
|
||||||
|
steps: Vec<FixtureStepInput>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct FixtureStepInput {
|
||||||
|
action: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct TraceOutput {
|
||||||
|
config: ConfigJson,
|
||||||
|
steps: Vec<StateTraceStep>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct StateTraceStep {
|
||||||
|
action: Option<u32>,
|
||||||
|
phase: &'static str,
|
||||||
|
current_player: usize,
|
||||||
|
turn_count: u32,
|
||||||
|
terminal: bool,
|
||||||
|
pending_discarded_color: Option<u32>,
|
||||||
|
score_diff_player0: i32,
|
||||||
|
legal_mask: Vec<bool>,
|
||||||
|
deck: Vec<CardJson>,
|
||||||
|
hands: Vec<Vec<CardJson>>,
|
||||||
|
expeditions: Vec<Vec<Vec<CardJson>>>,
|
||||||
|
discards: Vec<Vec<CardJson>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct ObservationMini {
|
||||||
|
state_version: u64,
|
||||||
|
current_player: u32,
|
||||||
|
observer_player: u32,
|
||||||
|
phase: String,
|
||||||
|
terminal: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, PartialEq)]
|
||||||
|
struct ObservationSummary {
|
||||||
|
state_version: u64,
|
||||||
|
current_player: u32,
|
||||||
|
observer_player: u32,
|
||||||
|
phase: String,
|
||||||
|
hand: Vec<CardJson>,
|
||||||
|
opponent_hand_size: u32,
|
||||||
|
deck_size: u32,
|
||||||
|
discards: Vec<Vec<CardJson>>,
|
||||||
|
my_expeditions: Vec<Vec<CardJson>>,
|
||||||
|
opponent_expeditions: Vec<Vec<CardJson>>,
|
||||||
|
legal_mask: Vec<bool>,
|
||||||
|
terminal: bool,
|
||||||
|
my_score: i32,
|
||||||
|
opponent_score: i32,
|
||||||
|
score_diff: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct EngineProbeOutput {
|
||||||
|
duplicate_kind: String,
|
||||||
|
missing_config_kind: String,
|
||||||
|
empty_session_kind: String,
|
||||||
|
unknown_session_kind: String,
|
||||||
|
invalid_observer_kind: String,
|
||||||
|
invalid_observer_state_unchanged: bool,
|
||||||
|
invalid_observer_action_still_applies: bool,
|
||||||
|
phase_flow: Vec<ObservationMini>,
|
||||||
|
stale_kind: String,
|
||||||
|
end_session_counts: Vec<usize>,
|
||||||
|
off_turn_legal_empty: bool,
|
||||||
|
full_session_terminal_reward_matches: bool,
|
||||||
|
full_session_final_scores_match: bool,
|
||||||
|
terminal_reject_kind: String,
|
||||||
|
deterministic_match: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct GrpcProbeOutput {
|
||||||
|
round_trip_phase: String,
|
||||||
|
opponent_legal_empty: bool,
|
||||||
|
stale_code: String,
|
||||||
|
invalid_observer_code: String,
|
||||||
|
invalid_observer_state_unchanged: bool,
|
||||||
|
ended_session_code: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() -> ProbeResult<()> {
|
||||||
|
let mut args = env::args().skip(1);
|
||||||
|
let command = args
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| io::Error::other("expected command"))?;
|
||||||
|
match command.as_str() {
|
||||||
|
"defaults" => print_json(&ConfigJson::from_config(&Config::default()))?,
|
||||||
|
"trace" => {
|
||||||
|
let path = args
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| io::Error::other("expected fixture path"))?;
|
||||||
|
print_json(&run_trace(&path)?)?;
|
||||||
|
}
|
||||||
|
"engine" => print_json(&run_engine_probe()?)?,
|
||||||
|
"grpc" => {
|
||||||
|
let runtime = tokio::runtime::Builder::new_multi_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()?;
|
||||||
|
print_json(&runtime.block_on(run_grpc_probe())?)?;
|
||||||
|
}
|
||||||
|
_ => return Err(io::Error::other(format!("unknown command: {command}")).into()),
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_json<T: Serialize>(value: &T) -> ProbeResult<()> {
|
||||||
|
println!("{}", serde_json::to_string_pretty(value)?);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_trace(path: &str) -> ProbeResult<TraceOutput> {
|
||||||
|
let fixture: FixtureInput = serde_json::from_str(&fs::read_to_string(path)?)?;
|
||||||
|
let config = fixture.config.to_config();
|
||||||
|
let deck = fixture
|
||||||
|
.initial_deck
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.map(Card::from)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let mut state = GameState::new_game_from_deck(config, deck)?;
|
||||||
|
let mut steps = Vec::with_capacity(fixture.steps.len());
|
||||||
|
|
||||||
|
for step in fixture.steps {
|
||||||
|
if let Some(action) = step.action {
|
||||||
|
state.apply_unified_action(action)?;
|
||||||
|
}
|
||||||
|
state.validate_invariants().map_err(io::Error::other)?;
|
||||||
|
steps.push(StateTraceStep::from_state(step.action, &state));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(TraceOutput {
|
||||||
|
config: ConfigJson::from_config(&state.config),
|
||||||
|
steps,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_engine_probe() -> ProbeResult<EngineProbeOutput> {
|
||||||
|
let duplicate_kind = {
|
||||||
|
let mut engine = LostCitiesEngine::new();
|
||||||
|
engine.new_game(new_game_request("dup", 7, true))?;
|
||||||
|
kind_name(
|
||||||
|
engine
|
||||||
|
.new_game(new_game_request("dup", 7, true))
|
||||||
|
.expect_err("duplicate session must fail")
|
||||||
|
.kind(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
let missing_config_kind = {
|
||||||
|
let mut engine = LostCitiesEngine::new();
|
||||||
|
kind_name(
|
||||||
|
engine
|
||||||
|
.new_game(new_game_request("missing-config", 7, false))
|
||||||
|
.expect_err("missing config must fail")
|
||||||
|
.kind(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
let empty_session_kind = {
|
||||||
|
let mut engine = LostCitiesEngine::new();
|
||||||
|
kind_name(
|
||||||
|
engine
|
||||||
|
.new_game(new_game_request(" ", 7, true))
|
||||||
|
.expect_err("empty session id must fail")
|
||||||
|
.kind(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
let unknown_session_kind = {
|
||||||
|
let engine = LostCitiesEngine::new();
|
||||||
|
kind_name(
|
||||||
|
engine
|
||||||
|
.get_observation(proto::SessionRef {
|
||||||
|
session_id: "unknown".to_string(),
|
||||||
|
observer_player: None,
|
||||||
|
})
|
||||||
|
.expect_err("unknown session must fail")
|
||||||
|
.kind(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
let (
|
||||||
|
invalid_observer_kind,
|
||||||
|
invalid_observer_state_unchanged,
|
||||||
|
invalid_observer_action_still_applies,
|
||||||
|
) = {
|
||||||
|
let mut engine = LostCitiesEngine::new();
|
||||||
|
let observation = engine.new_game(new_game_request("bad-observer", 7, true))?;
|
||||||
|
let action_id = first_action(&observation)?;
|
||||||
|
let err = engine
|
||||||
|
.apply_action(proto::ApplyActionRequest {
|
||||||
|
session_id: "bad-observer".to_string(),
|
||||||
|
action_id,
|
||||||
|
expected_state_version: observation.state_version,
|
||||||
|
observer_player: Some(2),
|
||||||
|
})
|
||||||
|
.expect_err("invalid observer must fail");
|
||||||
|
let after_error = engine.get_observation(proto::SessionRef {
|
||||||
|
session_id: "bad-observer".to_string(),
|
||||||
|
observer_player: Some(0),
|
||||||
|
})?;
|
||||||
|
let state_unchanged = after_error.state_version == observation.state_version
|
||||||
|
&& after_error.phase == observation.phase
|
||||||
|
&& after_error.current_player == observation.current_player;
|
||||||
|
let still_applies = engine
|
||||||
|
.apply_action(proto::ApplyActionRequest {
|
||||||
|
session_id: "bad-observer".to_string(),
|
||||||
|
action_id,
|
||||||
|
expected_state_version: observation.state_version,
|
||||||
|
observer_player: Some(0),
|
||||||
|
})
|
||||||
|
.is_ok();
|
||||||
|
(kind_name(err.kind()), state_unchanged, still_applies)
|
||||||
|
};
|
||||||
|
|
||||||
|
let phase_flow = {
|
||||||
|
let mut engine = LostCitiesEngine::new();
|
||||||
|
let observation = engine.new_game(new_game_request("phase-flow", 11, true))?;
|
||||||
|
let first = engine
|
||||||
|
.apply_action(proto::ApplyActionRequest {
|
||||||
|
session_id: "phase-flow".to_string(),
|
||||||
|
action_id: first_action(&observation)?,
|
||||||
|
expected_state_version: observation.state_version,
|
||||||
|
observer_player: None,
|
||||||
|
})?
|
||||||
|
.observation
|
||||||
|
.ok_or_else(|| io::Error::other("missing first observation"))?;
|
||||||
|
let second = engine
|
||||||
|
.apply_action(proto::ApplyActionRequest {
|
||||||
|
session_id: "phase-flow".to_string(),
|
||||||
|
action_id: first_action(&first)?,
|
||||||
|
expected_state_version: first.state_version,
|
||||||
|
observer_player: None,
|
||||||
|
})?
|
||||||
|
.observation
|
||||||
|
.ok_or_else(|| io::Error::other("missing second observation"))?;
|
||||||
|
vec![
|
||||||
|
ObservationMini::from_observation(&observation),
|
||||||
|
ObservationMini::from_observation(&first),
|
||||||
|
ObservationMini::from_observation(&second),
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
let stale_kind = {
|
||||||
|
let mut engine = LostCitiesEngine::new();
|
||||||
|
let observation = engine.new_game(new_game_request("stale", 3, true))?;
|
||||||
|
let action_id = first_action(&observation)?;
|
||||||
|
engine.apply_action(proto::ApplyActionRequest {
|
||||||
|
session_id: "stale".to_string(),
|
||||||
|
action_id,
|
||||||
|
expected_state_version: observation.state_version,
|
||||||
|
observer_player: None,
|
||||||
|
})?;
|
||||||
|
kind_name(
|
||||||
|
engine
|
||||||
|
.apply_action(proto::ApplyActionRequest {
|
||||||
|
session_id: "stale".to_string(),
|
||||||
|
action_id,
|
||||||
|
expected_state_version: observation.state_version,
|
||||||
|
observer_player: None,
|
||||||
|
})
|
||||||
|
.expect_err("stale version must fail")
|
||||||
|
.kind(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
let end_session_counts = {
|
||||||
|
let mut engine = LostCitiesEngine::new();
|
||||||
|
engine.new_game(new_game_request("cleanup", 5, true))?;
|
||||||
|
let before = engine.session_count();
|
||||||
|
engine.end_session(session_ref("cleanup", None))?;
|
||||||
|
engine.end_session(session_ref("cleanup", None))?;
|
||||||
|
vec![before, engine.session_count()]
|
||||||
|
};
|
||||||
|
|
||||||
|
let off_turn_legal_empty = {
|
||||||
|
let mut engine = LostCitiesEngine::new();
|
||||||
|
let config = small_config(9);
|
||||||
|
engine.new_game(proto::NewGameRequest {
|
||||||
|
session_id: "hidden".to_string(),
|
||||||
|
config: Some(config),
|
||||||
|
})?;
|
||||||
|
let hidden = engine.get_observation(session_ref("hidden", Some(1)))?;
|
||||||
|
hidden
|
||||||
|
.legal_actions
|
||||||
|
.as_ref()
|
||||||
|
.map(|legal| legal.actions.is_empty() && legal.mask.iter().all(|value| !value))
|
||||||
|
.unwrap_or(false)
|
||||||
|
};
|
||||||
|
|
||||||
|
let (
|
||||||
|
full_session_terminal_reward_matches,
|
||||||
|
full_session_final_scores_match,
|
||||||
|
terminal_reject_kind,
|
||||||
|
) = run_full_session_probe()?;
|
||||||
|
|
||||||
|
let deterministic_match = run_deterministic_probe()?;
|
||||||
|
|
||||||
|
Ok(EngineProbeOutput {
|
||||||
|
duplicate_kind,
|
||||||
|
missing_config_kind,
|
||||||
|
empty_session_kind,
|
||||||
|
unknown_session_kind,
|
||||||
|
invalid_observer_kind,
|
||||||
|
invalid_observer_state_unchanged,
|
||||||
|
invalid_observer_action_still_applies,
|
||||||
|
phase_flow,
|
||||||
|
stale_kind,
|
||||||
|
end_session_counts,
|
||||||
|
off_turn_legal_empty,
|
||||||
|
full_session_terminal_reward_matches,
|
||||||
|
full_session_final_scores_match,
|
||||||
|
terminal_reject_kind,
|
||||||
|
deterministic_match,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_grpc_probe() -> ProbeResult<GrpcProbeOutput> {
|
||||||
|
let (mut client, server) = spawn_client().await?;
|
||||||
|
let observation = client
|
||||||
|
.new_game(new_game_request("grpc-round-trip", 13, true))
|
||||||
|
.await?
|
||||||
|
.into_inner();
|
||||||
|
let opponent_view = client
|
||||||
|
.get_observation(session_ref("grpc-round-trip", Some(1)))
|
||||||
|
.await?
|
||||||
|
.into_inner();
|
||||||
|
let round_trip_phase = phase_name_proto(observation.phase).to_string();
|
||||||
|
let opponent_legal_empty = opponent_view
|
||||||
|
.legal_actions
|
||||||
|
.map(|legal| legal.actions.is_empty())
|
||||||
|
.unwrap_or(false);
|
||||||
|
server.abort();
|
||||||
|
|
||||||
|
let (mut client, server) = spawn_client().await?;
|
||||||
|
let observation = client
|
||||||
|
.new_game(new_game_request("grpc-stale", 21, true))
|
||||||
|
.await?
|
||||||
|
.into_inner();
|
||||||
|
let action_id = first_action(&observation)?;
|
||||||
|
client
|
||||||
|
.apply_action(proto::ApplyActionRequest {
|
||||||
|
session_id: "grpc-stale".to_string(),
|
||||||
|
action_id,
|
||||||
|
expected_state_version: observation.state_version,
|
||||||
|
observer_player: None,
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
let stale_code = format!(
|
||||||
|
"{:?}",
|
||||||
|
client
|
||||||
|
.apply_action(proto::ApplyActionRequest {
|
||||||
|
session_id: "grpc-stale".to_string(),
|
||||||
|
action_id,
|
||||||
|
expected_state_version: observation.state_version,
|
||||||
|
observer_player: None,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect_err("stale state_version must fail")
|
||||||
|
.code()
|
||||||
|
);
|
||||||
|
server.abort();
|
||||||
|
|
||||||
|
let (mut client, server) = spawn_client().await?;
|
||||||
|
let observation = client
|
||||||
|
.new_game(new_game_request("grpc-bad-observer", 31, true))
|
||||||
|
.await?
|
||||||
|
.into_inner();
|
||||||
|
let action_id = first_action(&observation)?;
|
||||||
|
let invalid_observer_code = format!(
|
||||||
|
"{:?}",
|
||||||
|
client
|
||||||
|
.apply_action(proto::ApplyActionRequest {
|
||||||
|
session_id: "grpc-bad-observer".to_string(),
|
||||||
|
action_id,
|
||||||
|
expected_state_version: observation.state_version,
|
||||||
|
observer_player: Some(2),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect_err("invalid observer must fail")
|
||||||
|
.code()
|
||||||
|
);
|
||||||
|
let after_error = client
|
||||||
|
.get_observation(session_ref("grpc-bad-observer", Some(0)))
|
||||||
|
.await?
|
||||||
|
.into_inner();
|
||||||
|
let invalid_observer_state_unchanged = after_error.state_version == observation.state_version
|
||||||
|
&& after_error.phase == observation.phase
|
||||||
|
&& after_error.current_player == observation.current_player;
|
||||||
|
server.abort();
|
||||||
|
|
||||||
|
let (mut client, server) = spawn_client().await?;
|
||||||
|
client
|
||||||
|
.new_game(new_game_request("grpc-end-session", 41, true))
|
||||||
|
.await?;
|
||||||
|
client
|
||||||
|
.end_session(session_ref("grpc-end-session", None))
|
||||||
|
.await?;
|
||||||
|
client
|
||||||
|
.end_session(session_ref("grpc-end-session", None))
|
||||||
|
.await?;
|
||||||
|
let ended_session_code = format!(
|
||||||
|
"{:?}",
|
||||||
|
client
|
||||||
|
.get_observation(session_ref("grpc-end-session", None))
|
||||||
|
.await
|
||||||
|
.expect_err("ended session should not be readable")
|
||||||
|
.code()
|
||||||
|
);
|
||||||
|
server.abort();
|
||||||
|
|
||||||
|
Ok(GrpcProbeOutput {
|
||||||
|
round_trip_phase,
|
||||||
|
opponent_legal_empty,
|
||||||
|
stale_code,
|
||||||
|
invalid_observer_code,
|
||||||
|
invalid_observer_state_unchanged,
|
||||||
|
ended_session_code,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_full_session_probe() -> ProbeResult<(bool, bool, String)> {
|
||||||
|
let mut engine = LostCitiesEngine::new();
|
||||||
|
let mut observation = engine.new_game(new_game_request("loop", 17, true))?;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let action_id = first_action(&observation)?;
|
||||||
|
let step = engine.apply_action(proto::ApplyActionRequest {
|
||||||
|
session_id: "loop".to_string(),
|
||||||
|
action_id,
|
||||||
|
expected_state_version: observation.state_version,
|
||||||
|
observer_player: None,
|
||||||
|
})?;
|
||||||
|
let next_observation = step
|
||||||
|
.observation
|
||||||
|
.ok_or_else(|| io::Error::other("missing loop observation"))?;
|
||||||
|
|
||||||
|
if step.terminal {
|
||||||
|
let observer = next_observation.observer_player;
|
||||||
|
let other = 1 - observer;
|
||||||
|
let reward_matches = step.reward as i32 == next_observation.score_diff;
|
||||||
|
let scores_match = step.final_scores.len() == 2
|
||||||
|
&& step.final_scores.get(&observer).copied() == Some(next_observation.my_score)
|
||||||
|
&& step.final_scores.get(&other).copied() == Some(next_observation.opponent_score);
|
||||||
|
let terminal_reject_kind = kind_name(
|
||||||
|
engine
|
||||||
|
.apply_action(proto::ApplyActionRequest {
|
||||||
|
session_id: "loop".to_string(),
|
||||||
|
action_id: 0,
|
||||||
|
expected_state_version: next_observation.state_version,
|
||||||
|
observer_player: None,
|
||||||
|
})
|
||||||
|
.expect_err("terminal game must reject further actions")
|
||||||
|
.kind(),
|
||||||
|
);
|
||||||
|
return Ok((reward_matches, scores_match, terminal_reject_kind));
|
||||||
|
}
|
||||||
|
|
||||||
|
observation = next_observation;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_deterministic_probe() -> ProbeResult<bool> {
|
||||||
|
let config = small_config(29);
|
||||||
|
let mut left = LostCitiesEngine::new();
|
||||||
|
let mut right = LostCitiesEngine::new();
|
||||||
|
let mut left_observation = left.new_game(proto::NewGameRequest {
|
||||||
|
session_id: "det-left".to_string(),
|
||||||
|
config: Some(config.clone()),
|
||||||
|
})?;
|
||||||
|
let mut right_observation = right.new_game(proto::NewGameRequest {
|
||||||
|
session_id: "det-right".to_string(),
|
||||||
|
config: Some(config),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
if observation_summary(&left_observation) != observation_summary(&right_observation) {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
if left_observation.terminal {
|
||||||
|
return Ok(right_observation.terminal);
|
||||||
|
}
|
||||||
|
|
||||||
|
let action_id = first_action(&left_observation)?;
|
||||||
|
let left_step = left.apply_action(proto::ApplyActionRequest {
|
||||||
|
session_id: "det-left".to_string(),
|
||||||
|
action_id,
|
||||||
|
expected_state_version: left_observation.state_version,
|
||||||
|
observer_player: None,
|
||||||
|
})?;
|
||||||
|
let right_step = right.apply_action(proto::ApplyActionRequest {
|
||||||
|
session_id: "det-right".to_string(),
|
||||||
|
action_id,
|
||||||
|
expected_state_version: right_observation.state_version,
|
||||||
|
observer_player: None,
|
||||||
|
})?;
|
||||||
|
if left_step.terminal != right_step.terminal
|
||||||
|
|| left_step.final_scores != right_step.final_scores
|
||||||
|
|| left_step.reward != right_step.reward
|
||||||
|
{
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
left_observation = left_step
|
||||||
|
.observation
|
||||||
|
.ok_or_else(|| io::Error::other("missing left observation"))?;
|
||||||
|
right_observation = right_step
|
||||||
|
.observation
|
||||||
|
.ok_or_else(|| io::Error::other("missing right observation"))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn spawn_client() -> ProbeResult<(LostCitiesClient<Channel>, tokio::task::JoinHandle<()>)> {
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").await?;
|
||||||
|
let addr = listener.local_addr()?;
|
||||||
|
let incoming = TcpListenerStream::new(listener);
|
||||||
|
|
||||||
|
let server = tokio::spawn(async move {
|
||||||
|
Server::builder()
|
||||||
|
.add_service(LostCitiesServer::new(LostCitiesGrpcService::default()))
|
||||||
|
.serve_with_incoming(incoming)
|
||||||
|
.await
|
||||||
|
.expect("gRPC server should run");
|
||||||
|
});
|
||||||
|
|
||||||
|
let endpoint = Endpoint::from_shared(format!("http://{}", addr))?;
|
||||||
|
let client = LostCitiesClient::new(endpoint.connect().await?);
|
||||||
|
Ok((client, server))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn observation_summary(observation: &proto::GameObservation) -> ObservationSummary {
|
||||||
|
ObservationSummary {
|
||||||
|
state_version: observation.state_version,
|
||||||
|
current_player: observation.current_player,
|
||||||
|
observer_player: observation.observer_player,
|
||||||
|
phase: phase_name_proto(observation.phase).to_string(),
|
||||||
|
hand: observation.hand.iter().map(CardJson::from_proto).collect(),
|
||||||
|
opponent_hand_size: observation.opponent_hand_size,
|
||||||
|
deck_size: observation.deck_size,
|
||||||
|
discards: observation
|
||||||
|
.discards
|
||||||
|
.iter()
|
||||||
|
.map(|discard| discard.cards.iter().map(CardJson::from_proto).collect())
|
||||||
|
.collect(),
|
||||||
|
my_expeditions: observation
|
||||||
|
.my_expeditions
|
||||||
|
.iter()
|
||||||
|
.map(|expedition| expedition.cards.iter().map(CardJson::from_proto).collect())
|
||||||
|
.collect(),
|
||||||
|
opponent_expeditions: observation
|
||||||
|
.opponent_expeditions
|
||||||
|
.iter()
|
||||||
|
.map(|expedition| expedition.cards.iter().map(CardJson::from_proto).collect())
|
||||||
|
.collect(),
|
||||||
|
legal_mask: observation
|
||||||
|
.legal_actions
|
||||||
|
.as_ref()
|
||||||
|
.map(|legal| legal.mask.clone())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
terminal: observation.terminal,
|
||||||
|
my_score: observation.my_score,
|
||||||
|
opponent_score: observation.opponent_score,
|
||||||
|
score_diff: observation.score_diff,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn first_action(observation: &proto::GameObservation) -> ProbeResult<u32> {
|
||||||
|
observation
|
||||||
|
.legal_actions
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|actions| actions.actions.first())
|
||||||
|
.map(|action| action.id)
|
||||||
|
.ok_or_else(|| io::Error::other("observation has no legal action").into())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn small_config(seed: u64) -> proto::GameConfig {
|
||||||
|
proto::GameConfig {
|
||||||
|
n_colors: 2,
|
||||||
|
n_ranks: 2,
|
||||||
|
min_rank: 1,
|
||||||
|
n_handshakes: 0,
|
||||||
|
hand_size: 1,
|
||||||
|
expedition_penalty: 0,
|
||||||
|
bonus_threshold: 99,
|
||||||
|
bonus_amount: 0,
|
||||||
|
seed: Some(seed),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn new_game_request(session_id: &str, seed: u64, include_config: bool) -> proto::NewGameRequest {
|
||||||
|
proto::NewGameRequest {
|
||||||
|
session_id: session_id.to_string(),
|
||||||
|
config: include_config.then(|| small_config(seed)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn session_ref(session_id: &str, observer_player: Option<u32>) -> proto::SessionRef {
|
||||||
|
proto::SessionRef {
|
||||||
|
session_id: session_id.to_string(),
|
||||||
|
observer_player,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn kind_name(kind: EngineErrorKind) -> String {
|
||||||
|
match kind {
|
||||||
|
EngineErrorKind::AlreadyExists => "AlreadyExists",
|
||||||
|
EngineErrorKind::NotFound => "NotFound",
|
||||||
|
EngineErrorKind::FailedPrecondition => "FailedPrecondition",
|
||||||
|
EngineErrorKind::InvalidArgument => "InvalidArgument",
|
||||||
|
}
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn phase_name_state(phase: Phase) -> &'static str {
|
||||||
|
match phase {
|
||||||
|
Phase::Card => "card",
|
||||||
|
Phase::Draw => "draw",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn phase_name_proto(phase: i32) -> &'static str {
|
||||||
|
match proto::Phase::try_from(phase) {
|
||||||
|
Ok(proto::Phase::Card) => "card",
|
||||||
|
Ok(proto::Phase::Draw) => "draw",
|
||||||
|
_ => "unspecified",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ConfigJson {
|
||||||
|
fn to_config(&self) -> Config {
|
||||||
|
Config {
|
||||||
|
n_colors: self.n_colors,
|
||||||
|
n_ranks: self.n_ranks,
|
||||||
|
min_rank: self.min_rank,
|
||||||
|
n_handshakes: self.n_handshakes,
|
||||||
|
hand_size: self.hand_size,
|
||||||
|
expedition_penalty: self.expedition_penalty,
|
||||||
|
bonus_threshold: self.bonus_threshold,
|
||||||
|
bonus_amount: self.bonus_amount,
|
||||||
|
seed: self.seed,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_config(config: &Config) -> Self {
|
||||||
|
Self {
|
||||||
|
n_colors: config.n_colors,
|
||||||
|
n_ranks: config.n_ranks,
|
||||||
|
min_rank: config.min_rank,
|
||||||
|
n_handshakes: config.n_handshakes,
|
||||||
|
hand_size: config.hand_size,
|
||||||
|
expedition_penalty: config.expedition_penalty,
|
||||||
|
bonus_threshold: config.bonus_threshold,
|
||||||
|
bonus_amount: config.bonus_amount,
|
||||||
|
seed: config.seed,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<CardJson> for Card {
|
||||||
|
fn from(value: CardJson) -> Self {
|
||||||
|
Self {
|
||||||
|
color: value.color,
|
||||||
|
rank: value.rank,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Card> for CardJson {
|
||||||
|
fn from(value: Card) -> Self {
|
||||||
|
Self {
|
||||||
|
color: value.color,
|
||||||
|
rank: value.rank,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CardJson {
|
||||||
|
fn from_proto(value: &proto::Card) -> Self {
|
||||||
|
Self {
|
||||||
|
color: value.color,
|
||||||
|
rank: value.rank,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StateTraceStep {
|
||||||
|
fn from_state(action: Option<u32>, state: &GameState) -> Self {
|
||||||
|
Self {
|
||||||
|
action,
|
||||||
|
phase: phase_name_state(state.phase),
|
||||||
|
current_player: state.current_player,
|
||||||
|
turn_count: state.turn_count,
|
||||||
|
terminal: state.terminal,
|
||||||
|
pending_discarded_color: state.pending_discarded_color,
|
||||||
|
score_diff_player0: state.score_diff(0),
|
||||||
|
legal_mask: state.legal_unified_mask(),
|
||||||
|
deck: state.deck.iter().copied().map(CardJson::from).collect(),
|
||||||
|
hands: state
|
||||||
|
.hands
|
||||||
|
.iter()
|
||||||
|
.map(|hand| hand.iter().copied().map(CardJson::from).collect())
|
||||||
|
.collect(),
|
||||||
|
expeditions: state
|
||||||
|
.expeditions
|
||||||
|
.iter()
|
||||||
|
.map(|expeditions| {
|
||||||
|
expeditions
|
||||||
|
.iter()
|
||||||
|
.map(|expedition| expedition.iter().copied().map(CardJson::from).collect())
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
discards: state
|
||||||
|
.discards
|
||||||
|
.iter()
|
||||||
|
.map(|discard| discard.iter().copied().map(CardJson::from).collect())
|
||||||
|
.collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ObservationMini {
|
||||||
|
fn from_observation(observation: &proto::GameObservation) -> Self {
|
||||||
|
Self {
|
||||||
|
state_version: observation.state_version,
|
||||||
|
current_player: observation.current_player,
|
||||||
|
observer_player: observation.observer_player,
|
||||||
|
phase: phase_name_proto(observation.phase).to_string(),
|
||||||
|
terminal: observation.terminal,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
use crate::error::EngineError;
|
||||||
|
use crate::proto;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct Config {
|
||||||
|
pub n_colors: usize,
|
||||||
|
pub n_ranks: u32,
|
||||||
|
pub min_rank: u32,
|
||||||
|
pub n_handshakes: u32,
|
||||||
|
pub hand_size: usize,
|
||||||
|
pub expedition_penalty: i32,
|
||||||
|
pub bonus_threshold: usize,
|
||||||
|
pub bonus_amount: i32,
|
||||||
|
pub seed: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Config {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
n_colors: 5,
|
||||||
|
n_ranks: 9,
|
||||||
|
min_rank: 2,
|
||||||
|
n_handshakes: 3,
|
||||||
|
hand_size: 8,
|
||||||
|
expedition_penalty: -20,
|
||||||
|
bonus_threshold: 8,
|
||||||
|
bonus_amount: 20,
|
||||||
|
seed: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
pub fn validate(&self) -> Result<(), EngineError> {
|
||||||
|
if self.n_colors == 0 {
|
||||||
|
return Err(EngineError::invalid_argument("n_colors must be positive"));
|
||||||
|
}
|
||||||
|
if self.n_ranks == 0 {
|
||||||
|
return Err(EngineError::invalid_argument("n_ranks must be positive"));
|
||||||
|
}
|
||||||
|
if self.min_rank == 0 {
|
||||||
|
return Err(EngineError::invalid_argument("min_rank must be positive"));
|
||||||
|
}
|
||||||
|
if self.hand_size == 0 {
|
||||||
|
return Err(EngineError::invalid_argument("hand_size must be positive"));
|
||||||
|
}
|
||||||
|
if self.bonus_threshold == 0 {
|
||||||
|
return Err(EngineError::invalid_argument(
|
||||||
|
"bonus_threshold must be positive",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if self.deck_size() < 2 * self.hand_size {
|
||||||
|
return Err(EngineError::invalid_argument(
|
||||||
|
"deck must contain at least both initial hands",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_seed(mut self, seed: Option<u64>) -> Self {
|
||||||
|
self.seed = seed;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn deck_size(&self) -> usize {
|
||||||
|
self.n_colors * (self.n_ranks as usize + self.n_handshakes as usize)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn card_action_size(&self) -> usize {
|
||||||
|
self.hand_size * 2
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn draw_action_size(&self) -> usize {
|
||||||
|
1 + self.n_colors
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn action_space_size(&self) -> usize {
|
||||||
|
self.card_action_size() + self.draw_action_size()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<&proto::GameConfig> for Config {
|
||||||
|
type Error = EngineError;
|
||||||
|
|
||||||
|
fn try_from(value: &proto::GameConfig) -> Result<Self, Self::Error> {
|
||||||
|
let n_colors = usize::try_from(value.n_colors)
|
||||||
|
.map_err(|_| EngineError::invalid_argument("n_colors is out of range"))?;
|
||||||
|
let hand_size = usize::try_from(value.hand_size)
|
||||||
|
.map_err(|_| EngineError::invalid_argument("hand_size is out of range"))?;
|
||||||
|
let bonus_threshold = usize::try_from(value.bonus_threshold)
|
||||||
|
.map_err(|_| EngineError::invalid_argument("bonus_threshold is out of range"))?;
|
||||||
|
|
||||||
|
let config = Self {
|
||||||
|
n_colors,
|
||||||
|
n_ranks: value.n_ranks,
|
||||||
|
min_rank: value.min_rank,
|
||||||
|
n_handshakes: value.n_handshakes,
|
||||||
|
hand_size,
|
||||||
|
expedition_penalty: value.expedition_penalty,
|
||||||
|
bonus_threshold,
|
||||||
|
bonus_amount: value.bonus_amount,
|
||||||
|
seed: value.seed,
|
||||||
|
};
|
||||||
|
config.validate()?;
|
||||||
|
Ok(config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<proto::GameConfig> for Config {
|
||||||
|
type Error = EngineError;
|
||||||
|
|
||||||
|
fn try_from(value: proto::GameConfig) -> Result<Self, Self::Error> {
|
||||||
|
Self::try_from(&value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&Config> for proto::GameConfig {
|
||||||
|
fn from(value: &Config) -> Self {
|
||||||
|
Self {
|
||||||
|
n_colors: value.n_colors as u32,
|
||||||
|
n_ranks: value.n_ranks,
|
||||||
|
min_rank: value.min_rank,
|
||||||
|
n_handshakes: value.n_handshakes,
|
||||||
|
hand_size: value.hand_size as u32,
|
||||||
|
expedition_penalty: value.expedition_penalty,
|
||||||
|
bonus_threshold: value.bonus_threshold as u32,
|
||||||
|
bonus_amount: value.bonus_amount,
|
||||||
|
seed: value.seed,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use crate::config::Config;
|
||||||
|
use crate::error::EngineError;
|
||||||
|
use crate::proto;
|
||||||
|
use crate::state::{GameState, Phase};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct SessionState {
|
||||||
|
game: GameState,
|
||||||
|
state_version: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SessionState {
|
||||||
|
fn observation(&self, session_id: &str, observer: usize) -> proto::GameObservation {
|
||||||
|
let game = &self.game;
|
||||||
|
let can_act = !game.terminal && observer == game.current_player;
|
||||||
|
let my_score = game.total_score(observer);
|
||||||
|
let opponent_score = game.total_score(1 - observer);
|
||||||
|
|
||||||
|
proto::GameObservation {
|
||||||
|
session_id: session_id.to_string(),
|
||||||
|
config: Some((&game.config).into()),
|
||||||
|
state_version: self.state_version,
|
||||||
|
observer_player: observer as u32,
|
||||||
|
current_player: game.current_player as u32,
|
||||||
|
phase: game.phase.to_proto(),
|
||||||
|
hand: game.hands[observer]
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.map(|card| card.to_proto(&game.config))
|
||||||
|
.collect(),
|
||||||
|
opponent_hand_size: game.hands[1 - observer].len() as u32,
|
||||||
|
my_expeditions: Self::build_expeditions(observer, game),
|
||||||
|
opponent_expeditions: Self::build_expeditions(1 - observer, game),
|
||||||
|
discards: Self::build_discards(game),
|
||||||
|
deck_size: game.deck.len() as u32,
|
||||||
|
pending_discarded_color: if game.phase == Phase::Draw {
|
||||||
|
game.pending_discarded_color
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
},
|
||||||
|
legal_actions: Some(game.build_legal_action_set(self.state_version, can_act)),
|
||||||
|
turn_count: game.turn_count,
|
||||||
|
terminal: game.terminal,
|
||||||
|
my_score,
|
||||||
|
opponent_score,
|
||||||
|
score_diff: my_score - opponent_score,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn final_scores(&self) -> HashMap<u32, i32> {
|
||||||
|
HashMap::from([(0, self.game.total_score(0)), (1, self.game.total_score(1))])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_expeditions(player: usize, game: &GameState) -> Vec<proto::Expedition> {
|
||||||
|
game.expeditions[player]
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(color, cards)| proto::Expedition {
|
||||||
|
color: color as u32,
|
||||||
|
cards: cards
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.map(|card| card.to_proto(&game.config))
|
||||||
|
.collect(),
|
||||||
|
current_score: crate::score_expedition(cards, &game.config),
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_discards(game: &GameState) -> Vec<proto::DiscardPile> {
|
||||||
|
game.discards
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(color, cards)| proto::DiscardPile {
|
||||||
|
color: color as u32,
|
||||||
|
cards: cards
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.map(|card| card.to_proto(&game.config))
|
||||||
|
.collect(),
|
||||||
|
size: cards.len() as u32,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct LostCitiesEngine {
|
||||||
|
sessions: HashMap<String, SessionState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LostCitiesEngine {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn session_count(&self) -> usize {
|
||||||
|
self.sessions.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn new_game(
|
||||||
|
&mut self,
|
||||||
|
request: proto::NewGameRequest,
|
||||||
|
) -> Result<proto::GameObservation, EngineError> {
|
||||||
|
let session_id = request.session_id;
|
||||||
|
Self::validate_session_id(&session_id)?;
|
||||||
|
if self.sessions.contains_key(&session_id) {
|
||||||
|
return Err(EngineError::already_exists(format!(
|
||||||
|
"session {} already exists",
|
||||||
|
session_id
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let config_proto = request
|
||||||
|
.config
|
||||||
|
.ok_or_else(|| EngineError::invalid_argument("config is required"))?;
|
||||||
|
let config = Config::try_from(config_proto)?;
|
||||||
|
let session = SessionState {
|
||||||
|
game: GameState::new_game(config)?,
|
||||||
|
state_version: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
let observation = session.observation(&session_id, 0);
|
||||||
|
self.sessions.insert(session_id, session);
|
||||||
|
Ok(observation)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_observation(
|
||||||
|
&self,
|
||||||
|
request: proto::SessionRef,
|
||||||
|
) -> Result<proto::GameObservation, EngineError> {
|
||||||
|
let session_id = request.session_id;
|
||||||
|
Self::validate_session_id(&session_id)?;
|
||||||
|
let session = self
|
||||||
|
.sessions
|
||||||
|
.get(&session_id)
|
||||||
|
.ok_or_else(|| EngineError::not_found(format!("unknown session {}", session_id)))?;
|
||||||
|
let observer = Self::requested_or_default_observer(
|
||||||
|
request.observer_player,
|
||||||
|
session.game.current_player,
|
||||||
|
)?;
|
||||||
|
Ok(session.observation(&session_id, observer))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn apply_action(
|
||||||
|
&mut self,
|
||||||
|
request: proto::ApplyActionRequest,
|
||||||
|
) -> Result<proto::StepResult, EngineError> {
|
||||||
|
let session_id = request.session_id;
|
||||||
|
Self::validate_session_id(&session_id)?;
|
||||||
|
let requested_observer = request
|
||||||
|
.observer_player
|
||||||
|
.map(Self::validate_observer)
|
||||||
|
.transpose()?;
|
||||||
|
let session = self
|
||||||
|
.sessions
|
||||||
|
.get_mut(&session_id)
|
||||||
|
.ok_or_else(|| EngineError::not_found(format!("unknown session {}", session_id)))?;
|
||||||
|
|
||||||
|
if session.game.terminal {
|
||||||
|
return Err(EngineError::failed_precondition("game is already terminal"));
|
||||||
|
}
|
||||||
|
if request.expected_state_version != session.state_version {
|
||||||
|
return Err(EngineError::failed_precondition(format!(
|
||||||
|
"expected state_version {}, got {}",
|
||||||
|
session.state_version, request.expected_state_version
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
session.game.apply_unified_action(request.action_id)?;
|
||||||
|
session.state_version += 1;
|
||||||
|
|
||||||
|
let observer = requested_observer.unwrap_or(session.game.current_player);
|
||||||
|
let observation = session.observation(&session_id, observer);
|
||||||
|
let terminal = session.game.terminal;
|
||||||
|
let reward = if terminal {
|
||||||
|
f64::from(observation.score_diff)
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
let final_scores = if terminal {
|
||||||
|
session.final_scores()
|
||||||
|
} else {
|
||||||
|
HashMap::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(proto::StepResult {
|
||||||
|
observation: Some(observation),
|
||||||
|
reward,
|
||||||
|
terminal,
|
||||||
|
final_scores,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn end_session(&mut self, request: proto::SessionRef) -> Result<(), EngineError> {
|
||||||
|
Self::validate_session_id(&request.session_id)?;
|
||||||
|
self.sessions.remove(&request.session_id);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_session_id(session_id: &str) -> Result<(), EngineError> {
|
||||||
|
if session_id.trim().is_empty() {
|
||||||
|
return Err(EngineError::invalid_argument(
|
||||||
|
"session_id must not be empty",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn requested_or_default_observer(
|
||||||
|
observer_player: Option<u32>,
|
||||||
|
default_player: usize,
|
||||||
|
) -> Result<usize, EngineError> {
|
||||||
|
observer_player
|
||||||
|
.map(Self::validate_observer)
|
||||||
|
.transpose()
|
||||||
|
.map(|observer| observer.unwrap_or(default_player))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_observer(observer: u32) -> Result<usize, EngineError> {
|
||||||
|
match observer {
|
||||||
|
0 => Ok(0),
|
||||||
|
1 => Ok(1),
|
||||||
|
_ => Err(EngineError::invalid_argument(
|
||||||
|
"observer_player must be 0 or 1",
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
use std::error::Error;
|
||||||
|
use std::fmt::{self, Display, Formatter};
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum EngineErrorKind {
|
||||||
|
AlreadyExists,
|
||||||
|
NotFound,
|
||||||
|
FailedPrecondition,
|
||||||
|
InvalidArgument,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct EngineError {
|
||||||
|
kind: EngineErrorKind,
|
||||||
|
message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EngineError {
|
||||||
|
pub fn new(kind: EngineErrorKind, message: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
kind,
|
||||||
|
message: message.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn kind(&self) -> EngineErrorKind {
|
||||||
|
self.kind
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn message(&self) -> &str {
|
||||||
|
&self.message
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn already_exists(message: impl Into<String>) -> Self {
|
||||||
|
Self::new(EngineErrorKind::AlreadyExists, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn not_found(message: impl Into<String>) -> Self {
|
||||||
|
Self::new(EngineErrorKind::NotFound, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn failed_precondition(message: impl Into<String>) -> Self {
|
||||||
|
Self::new(EngineErrorKind::FailedPrecondition, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn invalid_argument(message: impl Into<String>) -> Self {
|
||||||
|
Self::new(EngineErrorKind::InvalidArgument, message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for EngineError {
|
||||||
|
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
|
||||||
|
write!(f, "{:?}: {}", self.kind, self.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Error for EngineError {}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
pub mod proto {
|
||||||
|
tonic::include_proto!("lost_cities.v1");
|
||||||
|
}
|
||||||
|
|
||||||
|
mod config;
|
||||||
|
mod engine;
|
||||||
|
mod error;
|
||||||
|
mod service;
|
||||||
|
mod state;
|
||||||
|
|
||||||
|
pub use config::Config;
|
||||||
|
pub use engine::LostCitiesEngine;
|
||||||
|
pub use error::{EngineError, EngineErrorKind};
|
||||||
|
pub use service::LostCitiesGrpcService;
|
||||||
|
pub use state::{score_expedition, Card, GameState, Phase};
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use tonic::{Request, Response, Status};
|
||||||
|
|
||||||
|
use crate::error::{EngineError, EngineErrorKind};
|
||||||
|
use crate::proto;
|
||||||
|
use crate::LostCitiesEngine;
|
||||||
|
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
pub struct LostCitiesGrpcService {
|
||||||
|
engine: Arc<Mutex<LostCitiesEngine>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LostCitiesGrpcService {
|
||||||
|
pub fn new(engine: LostCitiesEngine) -> Self {
|
||||||
|
Self {
|
||||||
|
engine: Arc::new(Mutex::new(engine)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tonic::async_trait]
|
||||||
|
impl proto::lost_cities_server::LostCities for LostCitiesGrpcService {
|
||||||
|
async fn new_game(
|
||||||
|
&self,
|
||||||
|
request: Request<proto::NewGameRequest>,
|
||||||
|
) -> Result<Response<proto::GameObservation>, Status> {
|
||||||
|
let mut engine = self.engine.lock().map_err(|_| poisoned_engine_status())?;
|
||||||
|
let observation = engine
|
||||||
|
.new_game(request.into_inner())
|
||||||
|
.map_err(map_engine_error)?;
|
||||||
|
Ok(Response::new(observation))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_observation(
|
||||||
|
&self,
|
||||||
|
request: Request<proto::SessionRef>,
|
||||||
|
) -> Result<Response<proto::GameObservation>, Status> {
|
||||||
|
let engine = self.engine.lock().map_err(|_| poisoned_engine_status())?;
|
||||||
|
let observation = engine
|
||||||
|
.get_observation(request.into_inner())
|
||||||
|
.map_err(map_engine_error)?;
|
||||||
|
Ok(Response::new(observation))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn apply_action(
|
||||||
|
&self,
|
||||||
|
request: Request<proto::ApplyActionRequest>,
|
||||||
|
) -> Result<Response<proto::StepResult>, Status> {
|
||||||
|
let mut engine = self.engine.lock().map_err(|_| poisoned_engine_status())?;
|
||||||
|
let result = engine
|
||||||
|
.apply_action(request.into_inner())
|
||||||
|
.map_err(map_engine_error)?;
|
||||||
|
Ok(Response::new(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn end_session(
|
||||||
|
&self,
|
||||||
|
request: Request<proto::SessionRef>,
|
||||||
|
) -> Result<Response<()>, Status> {
|
||||||
|
let mut engine = self.engine.lock().map_err(|_| poisoned_engine_status())?;
|
||||||
|
engine
|
||||||
|
.end_session(request.into_inner())
|
||||||
|
.map_err(map_engine_error)?;
|
||||||
|
Ok(Response::new(()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn poisoned_engine_status() -> Status {
|
||||||
|
Status::internal("lost cities engine mutex poisoned")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_engine_error(error: EngineError) -> Status {
|
||||||
|
match error.kind() {
|
||||||
|
EngineErrorKind::AlreadyExists => Status::already_exists(error.message().to_string()),
|
||||||
|
EngineErrorKind::NotFound => Status::not_found(error.message().to_string()),
|
||||||
|
EngineErrorKind::FailedPrecondition => {
|
||||||
|
Status::failed_precondition(error.message().to_string())
|
||||||
|
}
|
||||||
|
EngineErrorKind::InvalidArgument => Status::invalid_argument(error.message().to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,550 @@
|
|||||||
|
use crate::config::Config;
|
||||||
|
use crate::error::EngineError;
|
||||||
|
use crate::proto;
|
||||||
|
use rand::rngs::StdRng;
|
||||||
|
use rand::seq::SliceRandom;
|
||||||
|
use rand::SeedableRng;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
|
pub struct Card {
|
||||||
|
pub color: u32,
|
||||||
|
pub rank: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Card {
|
||||||
|
pub fn is_handshake(self) -> bool {
|
||||||
|
self.rank == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn numeric_value(self, min_rank: u32) -> u32 {
|
||||||
|
if self.is_handshake() {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
min_rank + self.rank - 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn label(self, min_rank: u32) -> String {
|
||||||
|
if self.is_handshake() {
|
||||||
|
format!("[{}]H", self.color)
|
||||||
|
} else {
|
||||||
|
format!("[{}]{}", self.color, self.numeric_value(min_rank))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn to_proto(self, config: &Config) -> proto::Card {
|
||||||
|
proto::Card {
|
||||||
|
color: self.color,
|
||||||
|
rank: self.rank,
|
||||||
|
numeric_value: self.numeric_value(config.min_rank),
|
||||||
|
label: self.label(config.min_rank),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum Phase {
|
||||||
|
Card,
|
||||||
|
Draw,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Phase {
|
||||||
|
pub fn to_proto(self) -> i32 {
|
||||||
|
match self {
|
||||||
|
Self::Card => proto::Phase::Card as i32,
|
||||||
|
Self::Draw => proto::Phase::Draw as i32,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct GameState {
|
||||||
|
pub config: Config,
|
||||||
|
pub deck: Vec<Card>,
|
||||||
|
pub hands: [Vec<Card>; 2],
|
||||||
|
pub expeditions: [Vec<Vec<Card>>; 2],
|
||||||
|
pub discards: Vec<Vec<Card>>,
|
||||||
|
pub current_player: usize,
|
||||||
|
pub phase: Phase,
|
||||||
|
pub pending_discarded_color: Option<u32>,
|
||||||
|
pub turn_count: u32,
|
||||||
|
pub terminal: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GameState {
|
||||||
|
pub fn new_game(config: Config) -> Result<Self, EngineError> {
|
||||||
|
config.validate()?;
|
||||||
|
let mut deck = build_deck(&config);
|
||||||
|
match config.seed {
|
||||||
|
Some(seed) => {
|
||||||
|
let mut rng = StdRng::seed_from_u64(seed);
|
||||||
|
deck.shuffle(&mut rng);
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
let mut rng = rand::thread_rng();
|
||||||
|
deck.shuffle(&mut rng);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Self::new_game_from_deck(config, deck)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn new_game_from_deck(config: Config, deck: Vec<Card>) -> Result<Self, EngineError> {
|
||||||
|
config.validate()?;
|
||||||
|
let mut expected = build_deck(&config);
|
||||||
|
let mut actual = deck.clone();
|
||||||
|
expected.sort();
|
||||||
|
actual.sort();
|
||||||
|
if actual != expected {
|
||||||
|
return Err(EngineError::invalid_argument(
|
||||||
|
"deck must contain exactly the cards defined by config",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut state = Self::empty(config)?;
|
||||||
|
state.deck = deck;
|
||||||
|
for _ in 0..state.config.hand_size {
|
||||||
|
for player in 0..2 {
|
||||||
|
let card = state
|
||||||
|
.deck
|
||||||
|
.pop()
|
||||||
|
.expect("validated deck must contain both initial hands");
|
||||||
|
state.hands[player].push(card);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.sort_hands();
|
||||||
|
state
|
||||||
|
.validate_invariants()
|
||||||
|
.map_err(EngineError::invalid_argument)?;
|
||||||
|
Ok(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn empty(config: Config) -> Result<Self, EngineError> {
|
||||||
|
config.validate()?;
|
||||||
|
let n_colors = config.n_colors;
|
||||||
|
Ok(Self {
|
||||||
|
config,
|
||||||
|
deck: Vec::new(),
|
||||||
|
hands: [Vec::new(), Vec::new()],
|
||||||
|
expeditions: std::array::from_fn(|_| vec![Vec::new(); n_colors]),
|
||||||
|
discards: vec![Vec::new(); n_colors],
|
||||||
|
current_player: 0,
|
||||||
|
phase: Phase::Card,
|
||||||
|
pending_discarded_color: None,
|
||||||
|
turn_count: 0,
|
||||||
|
terminal: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sort_hands(&mut self) {
|
||||||
|
self.sort_hand(0);
|
||||||
|
self.sort_hand(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sort_hand(&mut self, player: usize) {
|
||||||
|
self.hands[player].sort_by_key(|card| (card.color, card.rank));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn last_numeric_rank(&self, player: usize, color: usize) -> u32 {
|
||||||
|
self.expeditions[player][color]
|
||||||
|
.iter()
|
||||||
|
.filter(|card| !card.is_handshake())
|
||||||
|
.map(|card| card.rank)
|
||||||
|
.max()
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn can_play_card(&self, player: usize, card: Card) -> bool {
|
||||||
|
let color = match usize::try_from(card.color) {
|
||||||
|
Ok(value) if value < self.config.n_colors => value,
|
||||||
|
_ => return false,
|
||||||
|
};
|
||||||
|
if card.rank > self.config.n_ranks {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let last_numeric = self.last_numeric_rank(player, color);
|
||||||
|
if card.is_handshake() {
|
||||||
|
last_numeric == 0
|
||||||
|
} else {
|
||||||
|
card.rank > last_numeric
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn legal_card_mask_phase(&self) -> Vec<bool> {
|
||||||
|
let mut mask = vec![false; self.config.card_action_size()];
|
||||||
|
if self.terminal {
|
||||||
|
return mask;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (slot, card) in self.hands[self.current_player].iter().copied().enumerate() {
|
||||||
|
let play_id = Self::play_action_id(slot);
|
||||||
|
mask[play_id] = self.can_play_card(self.current_player, card);
|
||||||
|
mask[Self::discard_action_id(slot)] = true;
|
||||||
|
}
|
||||||
|
mask
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn legal_draw_mask_phase(&self) -> Vec<bool> {
|
||||||
|
let mut mask = vec![false; self.config.draw_action_size()];
|
||||||
|
if self.terminal {
|
||||||
|
return mask;
|
||||||
|
}
|
||||||
|
|
||||||
|
mask[0] = !self.deck.is_empty();
|
||||||
|
for color in 0..self.config.n_colors {
|
||||||
|
let pending = self.pending_discarded_color == Some(color as u32);
|
||||||
|
mask[1 + color] = !self.discards[color].is_empty() && !pending;
|
||||||
|
}
|
||||||
|
mask
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn legal_unified_mask(&self) -> Vec<bool> {
|
||||||
|
if self.terminal {
|
||||||
|
return vec![false; self.config.action_space_size()];
|
||||||
|
}
|
||||||
|
match self.phase {
|
||||||
|
Phase::Card => {
|
||||||
|
let mut mask = self.legal_card_mask_phase();
|
||||||
|
mask.resize(self.config.action_space_size(), false);
|
||||||
|
mask
|
||||||
|
}
|
||||||
|
Phase::Draw => {
|
||||||
|
let mut mask = vec![false; self.config.card_action_size()];
|
||||||
|
mask.extend(self.legal_draw_mask_phase());
|
||||||
|
mask
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn apply_unified_action(&mut self, action_id: u32) -> Result<(), EngineError> {
|
||||||
|
if self.terminal {
|
||||||
|
return Err(EngineError::failed_precondition("game is already terminal"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let action_index = usize::try_from(action_id)
|
||||||
|
.map_err(|_| EngineError::failed_precondition("action_id is out of range"))?;
|
||||||
|
let mask = self.legal_unified_mask();
|
||||||
|
if action_index >= mask.len() || !mask[action_index] {
|
||||||
|
return Err(EngineError::failed_precondition(format!(
|
||||||
|
"illegal action {} in phase {:?} for player {}",
|
||||||
|
action_id, self.phase, self.current_player
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
match self.phase {
|
||||||
|
Phase::Card => self.apply_card_action(action_index),
|
||||||
|
Phase::Draw => self.apply_draw_action(action_index),
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn total_score(&self, player: usize) -> i32 {
|
||||||
|
self.expeditions[player]
|
||||||
|
.iter()
|
||||||
|
.map(|expedition| score_expedition(expedition, &self.config))
|
||||||
|
.sum()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn score_diff(&self, player: usize) -> i32 {
|
||||||
|
self.total_score(player) - self.total_score(1 - player)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate_invariants(&self) -> Result<(), String> {
|
||||||
|
self.config.validate().map_err(|err| err.to_string())?;
|
||||||
|
if self.current_player > 1 {
|
||||||
|
return Err("current_player must be 0 or 1".to_string());
|
||||||
|
}
|
||||||
|
if self.discards.len() != self.config.n_colors {
|
||||||
|
return Err("discard pile count must match n_colors".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut all_cards = Vec::new();
|
||||||
|
all_cards.extend(self.deck.iter().copied());
|
||||||
|
for (player_index, hand) in self.hands.iter().enumerate() {
|
||||||
|
if hand.len() > self.config.hand_size {
|
||||||
|
return Err(format!("hand {} exceeds hand_size", player_index));
|
||||||
|
}
|
||||||
|
if !hand.windows(2).all(|pair| pair[0] <= pair[1]) {
|
||||||
|
return Err(format!("hand {} is not sorted", player_index));
|
||||||
|
}
|
||||||
|
all_cards.extend(hand.iter().copied());
|
||||||
|
}
|
||||||
|
|
||||||
|
for (player_index, expeditions) in self.expeditions.iter().enumerate() {
|
||||||
|
if expeditions.len() != self.config.n_colors {
|
||||||
|
return Err("expedition color count must match n_colors".to_string());
|
||||||
|
}
|
||||||
|
for (color, expedition) in expeditions.iter().enumerate() {
|
||||||
|
let mut seen_numeric = false;
|
||||||
|
let mut last_numeric = 0;
|
||||||
|
for card in expedition {
|
||||||
|
if card.color as usize != color {
|
||||||
|
return Err(format!(
|
||||||
|
"player {} expedition {} contains wrong color",
|
||||||
|
player_index, color
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if card.is_handshake() {
|
||||||
|
if seen_numeric {
|
||||||
|
return Err(format!(
|
||||||
|
"player {} expedition {} has handshake after numeric",
|
||||||
|
player_index, color
|
||||||
|
));
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen_numeric = true;
|
||||||
|
if card.rank <= last_numeric {
|
||||||
|
return Err(format!(
|
||||||
|
"player {} expedition {} is not strictly increasing",
|
||||||
|
player_index, color
|
||||||
|
));
|
||||||
|
}
|
||||||
|
last_numeric = card.rank;
|
||||||
|
}
|
||||||
|
all_cards.extend(expedition.iter().copied());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for discard in &self.discards {
|
||||||
|
all_cards.extend(discard.iter().copied());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut expected = build_deck(&self.config);
|
||||||
|
expected.sort();
|
||||||
|
all_cards.sort();
|
||||||
|
if all_cards != expected {
|
||||||
|
return Err("card conservation failed".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.phase == Phase::Card && self.pending_discarded_color.is_some() {
|
||||||
|
return Err("pending_discarded_color must be None during card phase".to_string());
|
||||||
|
}
|
||||||
|
if let Some(color) = self.pending_discarded_color {
|
||||||
|
let color = color as usize;
|
||||||
|
if color >= self.config.n_colors {
|
||||||
|
return Err("pending_discarded_color is out of range".to_string());
|
||||||
|
}
|
||||||
|
if self.discards[color].is_empty() {
|
||||||
|
return Err("pending discard color must have a discard pile card".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let any_legal = self.legal_unified_mask().into_iter().any(|value| value);
|
||||||
|
if self.terminal && any_legal {
|
||||||
|
return Err("terminal state must have no legal actions".to_string());
|
||||||
|
}
|
||||||
|
if !self.terminal && !any_legal {
|
||||||
|
return Err("non-terminal state must have at least one legal action".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn build_legal_action_set(
|
||||||
|
&self,
|
||||||
|
state_version: u64,
|
||||||
|
include_actions: bool,
|
||||||
|
) -> proto::LegalActionSet {
|
||||||
|
if self.terminal || !include_actions {
|
||||||
|
return self.empty_legal_action_set(state_version);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mask = self.legal_unified_mask();
|
||||||
|
let actions = match self.phase {
|
||||||
|
Phase::Card => self.build_card_actions(&mask),
|
||||||
|
Phase::Draw => self.build_draw_actions(&mask),
|
||||||
|
};
|
||||||
|
|
||||||
|
proto::LegalActionSet {
|
||||||
|
state_version,
|
||||||
|
actions,
|
||||||
|
mask,
|
||||||
|
action_space_size: self.config.action_space_size() as u32,
|
||||||
|
phase: self.phase.to_proto(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_card_action(&mut self, action_index: usize) {
|
||||||
|
let slot = action_index / 2;
|
||||||
|
let play = action_index.is_multiple_of(2);
|
||||||
|
let card = self.hands[self.current_player].remove(slot);
|
||||||
|
let color = card.color as usize;
|
||||||
|
|
||||||
|
if play {
|
||||||
|
self.expeditions[self.current_player][color].push(card);
|
||||||
|
self.pending_discarded_color = None;
|
||||||
|
} else {
|
||||||
|
self.discards[color].push(card);
|
||||||
|
self.pending_discarded_color = Some(card.color);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.phase = Phase::Draw;
|
||||||
|
if self.deck.is_empty() && !self.has_draw_source() {
|
||||||
|
self.terminal = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_draw_action(&mut self, action_index: usize) {
|
||||||
|
let draw_index = action_index - self.draw_action_offset();
|
||||||
|
let card = if draw_index == 0 {
|
||||||
|
self.deck.pop().expect("legal deck draw")
|
||||||
|
} else {
|
||||||
|
let color = draw_index - 1;
|
||||||
|
self.discards[color].pop().expect("legal discard draw")
|
||||||
|
};
|
||||||
|
|
||||||
|
self.hands[self.current_player].push(card);
|
||||||
|
self.sort_hand(self.current_player);
|
||||||
|
self.pending_discarded_color = None;
|
||||||
|
self.turn_count += 1;
|
||||||
|
|
||||||
|
if self.deck.is_empty() {
|
||||||
|
self.terminal = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.current_player = 1 - self.current_player;
|
||||||
|
self.phase = Phase::Card;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_card_actions(&self, mask: &[bool]) -> Vec<proto::Action> {
|
||||||
|
let mut actions = Vec::new();
|
||||||
|
for (slot, card) in self.hands[self.current_player].iter().copied().enumerate() {
|
||||||
|
let play_id = Self::play_action_id(slot);
|
||||||
|
if mask[play_id] {
|
||||||
|
actions.push(self.card_action(
|
||||||
|
play_id,
|
||||||
|
proto::ActionKind::PlayCard,
|
||||||
|
slot,
|
||||||
|
card,
|
||||||
|
"Play",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let discard_id = Self::discard_action_id(slot);
|
||||||
|
if mask[discard_id] {
|
||||||
|
actions.push(self.card_action(
|
||||||
|
discard_id,
|
||||||
|
proto::ActionKind::DiscardCard,
|
||||||
|
slot,
|
||||||
|
card,
|
||||||
|
"Discard",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
actions
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_draw_actions(&self, mask: &[bool]) -> Vec<proto::Action> {
|
||||||
|
let mut actions = Vec::new();
|
||||||
|
let deck_draw_id = self.draw_action_offset();
|
||||||
|
if mask[deck_draw_id] {
|
||||||
|
actions.push(self.draw_action(deck_draw_id, None));
|
||||||
|
}
|
||||||
|
|
||||||
|
for color in 0..self.config.n_colors {
|
||||||
|
let action_id = self.discard_draw_action_id(color);
|
||||||
|
if mask[action_id] {
|
||||||
|
actions.push(self.draw_action(action_id, Some(color)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
actions
|
||||||
|
}
|
||||||
|
|
||||||
|
fn empty_legal_action_set(&self, state_version: u64) -> proto::LegalActionSet {
|
||||||
|
proto::LegalActionSet {
|
||||||
|
state_version,
|
||||||
|
actions: Vec::new(),
|
||||||
|
mask: vec![false; self.config.action_space_size()],
|
||||||
|
action_space_size: self.config.action_space_size() as u32,
|
||||||
|
phase: self.phase.to_proto(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_draw_source(&self) -> bool {
|
||||||
|
self.legal_draw_mask_phase().into_iter().any(|value| value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_action_offset(&self) -> usize {
|
||||||
|
self.config.card_action_size()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn play_action_id(slot: usize) -> usize {
|
||||||
|
slot * 2
|
||||||
|
}
|
||||||
|
|
||||||
|
fn discard_action_id(slot: usize) -> usize {
|
||||||
|
Self::play_action_id(slot) + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
fn discard_draw_action_id(&self, color: usize) -> usize {
|
||||||
|
self.draw_action_offset() + 1 + color
|
||||||
|
}
|
||||||
|
|
||||||
|
fn card_action(
|
||||||
|
&self,
|
||||||
|
id: usize,
|
||||||
|
kind: proto::ActionKind,
|
||||||
|
slot: usize,
|
||||||
|
card: Card,
|
||||||
|
verb: &str,
|
||||||
|
) -> proto::Action {
|
||||||
|
proto::Action {
|
||||||
|
id: id as u32,
|
||||||
|
kind: kind as i32,
|
||||||
|
hand_slot: slot as u32,
|
||||||
|
card: Some(card.to_proto(&self.config)),
|
||||||
|
discard_color: 0,
|
||||||
|
description: format!("{verb} {}", card.label(self.config.min_rank)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_action(&self, id: usize, discard_color: Option<usize>) -> proto::Action {
|
||||||
|
let (kind, discard_color, description) = match discard_color {
|
||||||
|
Some(color) => (
|
||||||
|
proto::ActionKind::DrawDiscard,
|
||||||
|
color as u32,
|
||||||
|
format!("Draw discard {color}"),
|
||||||
|
),
|
||||||
|
None => (proto::ActionKind::DrawDeck, 0, "Draw deck".to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
proto::Action {
|
||||||
|
id: id as u32,
|
||||||
|
kind: kind as i32,
|
||||||
|
hand_slot: 0,
|
||||||
|
card: None,
|
||||||
|
discard_color,
|
||||||
|
description,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_deck(config: &Config) -> Vec<Card> {
|
||||||
|
let mut deck = Vec::with_capacity(config.deck_size());
|
||||||
|
for color in 0..config.n_colors as u32 {
|
||||||
|
for _ in 0..config.n_handshakes {
|
||||||
|
deck.push(Card { color, rank: 0 });
|
||||||
|
}
|
||||||
|
for rank in 1..=config.n_ranks {
|
||||||
|
deck.push(Card { color, rank });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
deck
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn score_expedition(expedition: &[Card], config: &Config) -> i32 {
|
||||||
|
if expedition.is_empty() {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let handshakes = expedition.iter().filter(|card| card.is_handshake()).count() as i32;
|
||||||
|
let numeric_sum = expedition
|
||||||
|
.iter()
|
||||||
|
.map(|card| card.numeric_value(config.min_rank) as i32)
|
||||||
|
.sum::<i32>();
|
||||||
|
let mut score = (numeric_sum + config.expedition_penalty) * (handshakes + 1);
|
||||||
|
if expedition.len() >= config.bonus_threshold {
|
||||||
|
score += config.bonus_amount;
|
||||||
|
}
|
||||||
|
score
|
||||||
|
}
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
syntax = "proto3";
|
||||||
|
package lost_cities.v1;
|
||||||
|
|
||||||
|
import "google/protobuf/empty.proto";
|
||||||
|
|
||||||
|
// See lost_cities_spec.md for the full game rules.
|
||||||
|
// This proto is the wire contract for external agents to play Lost Cities
|
||||||
|
// against a trusted orchestrator. RL training uses in-process PyO3 bindings
|
||||||
|
// instead and does not go through this service.
|
||||||
|
|
||||||
|
// =============================================================
|
||||||
|
// Core value types
|
||||||
|
// =============================================================
|
||||||
|
|
||||||
|
// A single card. Rank 0 is a handshake (investment) card.
|
||||||
|
// Rank 1..n_ranks are numeric cards whose printed face value is
|
||||||
|
// min_rank + rank - 1.
|
||||||
|
message Card {
|
||||||
|
uint32 color = 1;
|
||||||
|
uint32 rank = 2;
|
||||||
|
uint32 numeric_value = 3; // 0 for handshake, else min_rank + rank - 1
|
||||||
|
string label = 4; // debug-friendly e.g. "[2]H", "[0]5"
|
||||||
|
// non-authoritative, do not parse
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Phase {
|
||||||
|
PHASE_UNSPECIFIED = 0;
|
||||||
|
PHASE_CARD = 1; // the current player must play or discard a card
|
||||||
|
PHASE_DRAW = 2; // the current player must draw from deck or a discard pile
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ActionKind {
|
||||||
|
ACTION_KIND_UNSPECIFIED = 0;
|
||||||
|
ACTION_KIND_PLAY_CARD = 1;
|
||||||
|
ACTION_KIND_DISCARD_CARD = 2;
|
||||||
|
ACTION_KIND_DRAW_DECK = 3;
|
||||||
|
ACTION_KIND_DRAW_DISCARD = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A resolved legal action.
|
||||||
|
//
|
||||||
|
// `id` is an opaque integer handle valid only within the observation it
|
||||||
|
// was returned with. Clients pass it back via ApplyAction together with
|
||||||
|
// the observation's state_version.
|
||||||
|
//
|
||||||
|
// `kind` plus typed payload fields let semantic agents (LLM, rule-based)
|
||||||
|
// reason without decoding ids. `description` is a human-readable label
|
||||||
|
// such as "Play yellow 5" and must not be parsed by the agent.
|
||||||
|
message Action {
|
||||||
|
uint32 id = 1;
|
||||||
|
ActionKind kind = 2;
|
||||||
|
|
||||||
|
// Set for PLAY_CARD and DISCARD_CARD.
|
||||||
|
uint32 hand_slot = 3;
|
||||||
|
Card card = 4;
|
||||||
|
|
||||||
|
// Set for DRAW_DISCARD.
|
||||||
|
uint32 discard_color = 5;
|
||||||
|
|
||||||
|
string description = 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================
|
||||||
|
// Configuration
|
||||||
|
// =============================================================
|
||||||
|
|
||||||
|
message GameConfig {
|
||||||
|
uint32 n_colors = 1;
|
||||||
|
uint32 n_ranks = 2;
|
||||||
|
uint32 min_rank = 3;
|
||||||
|
uint32 n_handshakes = 4;
|
||||||
|
uint32 hand_size = 5;
|
||||||
|
int32 expedition_penalty = 6; // typically negative, e.g. -20
|
||||||
|
uint32 bonus_threshold = 7;
|
||||||
|
int32 bonus_amount = 8;
|
||||||
|
optional uint64 seed = 9; // absent = nondeterministic shuffle
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================
|
||||||
|
// Piles and expeditions
|
||||||
|
// =============================================================
|
||||||
|
|
||||||
|
message Expedition {
|
||||||
|
uint32 color = 1;
|
||||||
|
repeated Card cards = 2; // bottom to top, push-only during game
|
||||||
|
int32 current_score = 3; // derived from the game rules
|
||||||
|
}
|
||||||
|
|
||||||
|
message DiscardPile {
|
||||||
|
uint32 color = 1;
|
||||||
|
repeated Card cards = 2; // bottom to top; last element is drawable top
|
||||||
|
uint32 size = 3; // = cards.size(); convenience
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================
|
||||||
|
// Legal actions
|
||||||
|
// =============================================================
|
||||||
|
|
||||||
|
// The set of legal actions at a specific state.
|
||||||
|
//
|
||||||
|
// Invariants:
|
||||||
|
// - actions are sorted by id ascending
|
||||||
|
// - for every action a in actions: mask[a.id] == true
|
||||||
|
// - mask.size() == action_space_size
|
||||||
|
// - all ids are < action_space_size
|
||||||
|
// - state_version matches the observation this set was produced for
|
||||||
|
message LegalActionSet {
|
||||||
|
uint64 state_version = 1;
|
||||||
|
repeated Action actions = 2;
|
||||||
|
repeated bool mask = 3;
|
||||||
|
uint32 action_space_size = 4;
|
||||||
|
Phase phase = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================
|
||||||
|
// Observation
|
||||||
|
// =============================================================
|
||||||
|
|
||||||
|
// Player-perspective snapshot. `observer_player` identifies whose view
|
||||||
|
// this is. The observer sees their own hand; the opponent's hand is
|
||||||
|
// represented only by its size.
|
||||||
|
message GameObservation {
|
||||||
|
string session_id = 1;
|
||||||
|
GameConfig config = 2;
|
||||||
|
|
||||||
|
// Monotonically increasing per session. Required for ApplyAction.
|
||||||
|
uint64 state_version = 3;
|
||||||
|
|
||||||
|
uint32 observer_player = 4;
|
||||||
|
uint32 current_player = 5;
|
||||||
|
Phase phase = 6;
|
||||||
|
|
||||||
|
repeated Card hand = 7; // observer's hand, sorted
|
||||||
|
uint32 opponent_hand_size = 8;
|
||||||
|
|
||||||
|
repeated Expedition my_expeditions = 9;
|
||||||
|
repeated Expedition opponent_expeditions = 10;
|
||||||
|
repeated DiscardPile discards = 11;
|
||||||
|
|
||||||
|
uint32 deck_size = 12;
|
||||||
|
|
||||||
|
// Color just discarded during the current turn's card phase.
|
||||||
|
// The current player's draw phase may not re-draw that color
|
||||||
|
// from the discard pile. Cleared at turn boundary.
|
||||||
|
optional uint32 pending_discarded_color = 13;
|
||||||
|
|
||||||
|
// Legal actions for the player to act. Empty when terminal.
|
||||||
|
// Embedded to avoid a round-trip on the common observe->select->apply loop.
|
||||||
|
LegalActionSet legal_actions = 14;
|
||||||
|
|
||||||
|
uint32 turn_count = 15;
|
||||||
|
bool terminal = 16;
|
||||||
|
|
||||||
|
// Scores are from the observer's perspective.
|
||||||
|
int32 my_score = 17;
|
||||||
|
int32 opponent_score = 18;
|
||||||
|
int32 score_diff = 19; // my_score - opponent_score
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================
|
||||||
|
// Requests and responses
|
||||||
|
// =============================================================
|
||||||
|
|
||||||
|
message NewGameRequest {
|
||||||
|
string session_id = 1;
|
||||||
|
GameConfig config = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message SessionRef {
|
||||||
|
string session_id = 1;
|
||||||
|
// Defaults to current_player when absent.
|
||||||
|
optional uint32 observer_player = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ApplyActionRequest {
|
||||||
|
string session_id = 1;
|
||||||
|
uint32 action_id = 2;
|
||||||
|
|
||||||
|
// Must match the observation the action was selected from.
|
||||||
|
// Mismatch yields FAILED_PRECONDITION; no state change occurs.
|
||||||
|
// This makes ApplyAction safely retryable.
|
||||||
|
uint64 expected_state_version = 3;
|
||||||
|
|
||||||
|
// Perspective for the returned observation. Defaults to current_player
|
||||||
|
// after the action is applied.
|
||||||
|
optional uint32 observer_player = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message StepResult {
|
||||||
|
GameObservation observation = 1;
|
||||||
|
|
||||||
|
// From the observation's observer_player perspective.
|
||||||
|
// Sparse: nonzero only on the terminal transition, equal to score_diff.
|
||||||
|
double reward = 2;
|
||||||
|
|
||||||
|
bool terminal = 3;
|
||||||
|
|
||||||
|
// Populated on terminal transitions. Keyed by player index.
|
||||||
|
map<uint32, int32> final_scores = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================
|
||||||
|
// Service
|
||||||
|
// =============================================================
|
||||||
|
|
||||||
|
service LostCities {
|
||||||
|
// Start a new game. session_id must be unique; duplicates return
|
||||||
|
// ALREADY_EXISTS. The returned observation is from player 0's perspective.
|
||||||
|
rpc NewGame(NewGameRequest) returns (GameObservation);
|
||||||
|
|
||||||
|
// Re-read the current state without advancing. Useful after reconnects
|
||||||
|
// or for a second client joining an existing session.
|
||||||
|
rpc GetObservation(SessionRef) returns (GameObservation);
|
||||||
|
|
||||||
|
// Apply one action. Fails with:
|
||||||
|
// NOT_FOUND session_id unknown
|
||||||
|
// FAILED_PRECONDITION expected_state_version mismatch,
|
||||||
|
// action_id not legal in current state,
|
||||||
|
// or game already terminal
|
||||||
|
// INVALID_ARGUMENT malformed request
|
||||||
|
rpc ApplyAction(ApplyActionRequest) returns (StepResult);
|
||||||
|
|
||||||
|
// Release server-side session state. Idempotent.
|
||||||
|
rpc EndSession(SessionRef) returns (google.protobuf.Empty);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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"))
|
||||||
@@ -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
|
||||||
@@ -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" },
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user