백엔드 제거하고 평가 기반 정리
This commit is contained in:
@@ -15,3 +15,6 @@ target/
|
|||||||
|
|
||||||
# Virtual environments
|
# Virtual environments
|
||||||
.venv
|
.venv
|
||||||
|
|
||||||
|
.pytest_cache
|
||||||
|
.ruff_cache
|
||||||
@@ -32,7 +32,7 @@ Run the classic pygame GUI:
|
|||||||
uv run lost-cities-classic-gui --mode pvc --bot safe-heuristic
|
uv run lost-cities-classic-gui --mode pvc --bot safe-heuristic
|
||||||
```
|
```
|
||||||
|
|
||||||
The GUI uses the in-process Python backend.
|
The GUI uses the in-process Cython game engine.
|
||||||
|
|
||||||
## Basic Usage
|
## Basic Usage
|
||||||
|
|
||||||
@@ -48,13 +48,4 @@ while not state.terminal:
|
|||||||
print(state.total_score(0), state.total_score(1))
|
print(state.total_score(0), state.total_score(1))
|
||||||
```
|
```
|
||||||
|
|
||||||
Backends use the same snapshot/apply/undo interface:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from coolrl_lost_cities.games.classic import build_backend, classic_config
|
|
||||||
|
|
||||||
backend = build_backend("python", classic_config(), seed=1)
|
|
||||||
snapshot = backend.snapshot()
|
|
||||||
```
|
|
||||||
|
|
||||||
See [classic port notes](docs/classic-port-notes.md) for the current direction.
|
See [classic port notes](docs/classic-port-notes.md) for the current direction.
|
||||||
|
|||||||
@@ -21,9 +21,11 @@ src/coolrl_lost_cities/
|
|||||||
games/
|
games/
|
||||||
classic/
|
classic/
|
||||||
game.pyx
|
game.pyx
|
||||||
|
reference.py
|
||||||
|
snapshots.py
|
||||||
|
evaluation.py
|
||||||
env.py
|
env.py
|
||||||
interfaces.py
|
interfaces.py
|
||||||
backends/
|
|
||||||
bots/
|
bots/
|
||||||
pygame_pvp.py
|
pygame_pvp.py
|
||||||
fixtures/
|
fixtures/
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ gui = [
|
|||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
lost-cities-classic = "coolrl_lost_cities.games.classic:main"
|
lost-cities-classic = "coolrl_lost_cities.games.classic:main"
|
||||||
|
lost-cities-eval = "coolrl_lost_cities.games.classic.evaluation:main"
|
||||||
lost-cities-classic-gui = "coolrl_lost_cities.games.classic.pygame_pvp:main"
|
lost-cities-classic-gui = "coolrl_lost_cities.games.classic.pygame_pvp:main"
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from .backends import build_backend
|
|
||||||
from .bots import (
|
from .bots import (
|
||||||
LostCitiesBot,
|
LostCitiesBot,
|
||||||
available_bot_names,
|
available_bot_names,
|
||||||
@@ -9,28 +8,42 @@ from .bots import (
|
|||||||
run_series,
|
run_series,
|
||||||
)
|
)
|
||||||
from .env import LostCitiesEnv
|
from .env import LostCitiesEnv
|
||||||
|
from .evaluation import (
|
||||||
|
GameResult,
|
||||||
|
MatchResult,
|
||||||
|
evaluate_bot,
|
||||||
|
make_bot_factory,
|
||||||
|
play_game_for_evaluation,
|
||||||
|
play_match,
|
||||||
|
)
|
||||||
from .game import (
|
from .game import (
|
||||||
GameState,
|
GameState,
|
||||||
IllegalMoveError,
|
IllegalMoveError,
|
||||||
LostCitiesConfig,
|
LostCitiesConfig,
|
||||||
classic_config,
|
classic_config,
|
||||||
)
|
)
|
||||||
from .interfaces import BackendName, LostCitiesBackend, Snapshot
|
from .interfaces import Snapshot
|
||||||
|
from .reference import ReferenceLostCitiesCard, ReferenceLostCitiesState
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"BackendName",
|
|
||||||
"GameState",
|
"GameState",
|
||||||
|
"GameResult",
|
||||||
"IllegalMoveError",
|
"IllegalMoveError",
|
||||||
"LostCitiesBackend",
|
|
||||||
"LostCitiesBot",
|
"LostCitiesBot",
|
||||||
"LostCitiesConfig",
|
"LostCitiesConfig",
|
||||||
"LostCitiesEnv",
|
"LostCitiesEnv",
|
||||||
|
"MatchResult",
|
||||||
|
"ReferenceLostCitiesCard",
|
||||||
|
"ReferenceLostCitiesState",
|
||||||
"Snapshot",
|
"Snapshot",
|
||||||
"available_bot_names",
|
"available_bot_names",
|
||||||
"build_backend",
|
|
||||||
"build_bot",
|
"build_bot",
|
||||||
"classic_config",
|
"classic_config",
|
||||||
|
"evaluate_bot",
|
||||||
|
"make_bot_factory",
|
||||||
"play_game",
|
"play_game",
|
||||||
|
"play_game_for_evaluation",
|
||||||
|
"play_match",
|
||||||
"run_series",
|
"run_series",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from .factory import build_backend
|
|
||||||
from .python import PythonLostCitiesBackend
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"PythonLostCitiesBackend",
|
|
||||||
"build_backend",
|
|
||||||
]
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from ..game import LostCitiesConfig
|
|
||||||
from ..interfaces import BackendName, LostCitiesBackend
|
|
||||||
from .python import PythonLostCitiesBackend
|
|
||||||
|
|
||||||
|
|
||||||
def build_backend(
|
|
||||||
backend: BackendName,
|
|
||||||
config: LostCitiesConfig,
|
|
||||||
seed: int | None,
|
|
||||||
) -> LostCitiesBackend:
|
|
||||||
if backend == "python":
|
|
||||||
return PythonLostCitiesBackend(config, seed)
|
|
||||||
raise ValueError(f"unknown backend: {backend}")
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -169,7 +169,7 @@ CARD phase에서는 DISCARD가 항상 가능하므로 합법 액션이 최소 1
|
|||||||
|
|
||||||
### 9.1 상태는 게임 인스턴스 단위로 유지
|
### 9.1 상태는 게임 인스턴스 단위로 유지
|
||||||
|
|
||||||
한 `GameState` 또는 backend 인스턴스가 한 게임을 나타낸다.
|
한 `GameState` 인스턴스가 한 게임을 나타낸다.
|
||||||
|
|
||||||
### 9.2 observation은 항상 특정 플레이어 관점
|
### 9.2 observation은 항상 특정 플레이어 관점
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,271 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from collections.abc import Callable
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from .bots import available_bot_names, build_bot
|
||||||
|
from .game import GameState, LostCitiesConfig, classic_config
|
||||||
|
from .interfaces import LostCitiesBot
|
||||||
|
|
||||||
|
BotFactory = Callable[[int | None], LostCitiesBot]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class GameResult:
|
||||||
|
score0: int
|
||||||
|
score1: int
|
||||||
|
score_diff0: int
|
||||||
|
steps: int
|
||||||
|
timed_out: bool
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class MatchResult:
|
||||||
|
games: int
|
||||||
|
wins0: int
|
||||||
|
wins1: int
|
||||||
|
draws: int
|
||||||
|
avg_score0: float
|
||||||
|
avg_score1: float
|
||||||
|
avg_score_diff0: float
|
||||||
|
avg_game_length: float
|
||||||
|
max_step_timeouts: int
|
||||||
|
elapsed_seconds: float
|
||||||
|
games_per_second: float
|
||||||
|
steps_per_second: float
|
||||||
|
|
||||||
|
@property
|
||||||
|
def win_rate0(self) -> float:
|
||||||
|
return self.wins0 / max(1, self.games)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def win_rate1(self) -> float:
|
||||||
|
return self.wins1 / max(1, self.games)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, float | int]:
|
||||||
|
return {
|
||||||
|
"games": self.games,
|
||||||
|
"wins0": self.wins0,
|
||||||
|
"wins1": self.wins1,
|
||||||
|
"draws": self.draws,
|
||||||
|
"win_rate0": self.win_rate0,
|
||||||
|
"win_rate1": self.win_rate1,
|
||||||
|
"avg_score0": self.avg_score0,
|
||||||
|
"avg_score1": self.avg_score1,
|
||||||
|
"avg_score_diff0": self.avg_score_diff0,
|
||||||
|
"avg_game_length": self.avg_game_length,
|
||||||
|
"max_step_timeouts": self.max_step_timeouts,
|
||||||
|
"elapsed_seconds": self.elapsed_seconds,
|
||||||
|
"games_per_second": self.games_per_second,
|
||||||
|
"steps_per_second": self.steps_per_second,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def make_bot_factory(name: str) -> BotFactory:
|
||||||
|
canonical = _canonical_bot_name(name)
|
||||||
|
|
||||||
|
def factory(seed: int | None = None) -> LostCitiesBot:
|
||||||
|
return build_bot(canonical, seed=seed)
|
||||||
|
|
||||||
|
return factory
|
||||||
|
|
||||||
|
|
||||||
|
def play_game_for_evaluation(
|
||||||
|
bot0: LostCitiesBot,
|
||||||
|
bot1: LostCitiesBot,
|
||||||
|
config: LostCitiesConfig,
|
||||||
|
*,
|
||||||
|
seed: int | None = None,
|
||||||
|
max_steps: int = 10_000,
|
||||||
|
) -> tuple[GameState, GameResult]:
|
||||||
|
if max_steps <= 0:
|
||||||
|
raise ValueError(f"max_steps must be positive, got {max_steps}")
|
||||||
|
state = GameState.new_game(config, seed=seed)
|
||||||
|
bots = [bot0, bot1]
|
||||||
|
steps = 0
|
||||||
|
for _ in range(max_steps):
|
||||||
|
if state.terminal:
|
||||||
|
break
|
||||||
|
action = bots[state.current_player].act(state)
|
||||||
|
state.apply_action(action)
|
||||||
|
steps += 1
|
||||||
|
timed_out = not state.terminal
|
||||||
|
if timed_out:
|
||||||
|
steps = max_steps
|
||||||
|
score0 = state.total_score(0)
|
||||||
|
score1 = state.total_score(1)
|
||||||
|
return (
|
||||||
|
state,
|
||||||
|
GameResult(
|
||||||
|
score0=score0,
|
||||||
|
score1=score1,
|
||||||
|
score_diff0=score0 - score1,
|
||||||
|
steps=steps,
|
||||||
|
timed_out=timed_out,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def play_match(
|
||||||
|
bot0_factory: BotFactory,
|
||||||
|
bot1_factory: BotFactory,
|
||||||
|
config: LostCitiesConfig,
|
||||||
|
*,
|
||||||
|
games: int,
|
||||||
|
seed: int = 1,
|
||||||
|
max_steps: int = 10_000,
|
||||||
|
alternate_seats: bool = True,
|
||||||
|
) -> MatchResult:
|
||||||
|
if games <= 0:
|
||||||
|
raise ValueError(f"games must be positive, got {games}")
|
||||||
|
|
||||||
|
score0: list[int] = []
|
||||||
|
score1: list[int] = []
|
||||||
|
diffs0: list[int] = []
|
||||||
|
lengths: list[int] = []
|
||||||
|
wins0 = wins1 = draws = timeouts = 0
|
||||||
|
|
||||||
|
started = time.perf_counter()
|
||||||
|
for index in range(games):
|
||||||
|
game_seed = seed + index
|
||||||
|
swap = alternate_seats and index % 2 == 1
|
||||||
|
if swap:
|
||||||
|
left = bot1_factory(game_seed * 2)
|
||||||
|
right = bot0_factory(game_seed * 2 + 1)
|
||||||
|
else:
|
||||||
|
left = bot0_factory(game_seed * 2)
|
||||||
|
right = bot1_factory(game_seed * 2 + 1)
|
||||||
|
|
||||||
|
_, result = play_game_for_evaluation(
|
||||||
|
left,
|
||||||
|
right,
|
||||||
|
config,
|
||||||
|
seed=game_seed,
|
||||||
|
max_steps=max_steps,
|
||||||
|
)
|
||||||
|
if swap:
|
||||||
|
bot0_score = result.score1
|
||||||
|
bot1_score = result.score0
|
||||||
|
diff0 = -result.score_diff0
|
||||||
|
else:
|
||||||
|
bot0_score = result.score0
|
||||||
|
bot1_score = result.score1
|
||||||
|
diff0 = result.score_diff0
|
||||||
|
|
||||||
|
score0.append(bot0_score)
|
||||||
|
score1.append(bot1_score)
|
||||||
|
diffs0.append(diff0)
|
||||||
|
lengths.append(result.steps)
|
||||||
|
timeouts += int(result.timed_out)
|
||||||
|
if diff0 > 0:
|
||||||
|
wins0 += 1
|
||||||
|
elif diff0 < 0:
|
||||||
|
wins1 += 1
|
||||||
|
else:
|
||||||
|
draws += 1
|
||||||
|
|
||||||
|
elapsed = time.perf_counter() - started
|
||||||
|
total_steps = sum(lengths)
|
||||||
|
return MatchResult(
|
||||||
|
games=games,
|
||||||
|
wins0=wins0,
|
||||||
|
wins1=wins1,
|
||||||
|
draws=draws,
|
||||||
|
avg_score0=_mean(score0),
|
||||||
|
avg_score1=_mean(score1),
|
||||||
|
avg_score_diff0=_mean(diffs0),
|
||||||
|
avg_game_length=_mean(lengths),
|
||||||
|
max_step_timeouts=timeouts,
|
||||||
|
elapsed_seconds=elapsed,
|
||||||
|
games_per_second=games / max(elapsed, 1.0e-12),
|
||||||
|
steps_per_second=total_steps / max(elapsed, 1.0e-12),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate_bot(
|
||||||
|
bot_factory: BotFactory,
|
||||||
|
opponent_factory: BotFactory,
|
||||||
|
config: LostCitiesConfig,
|
||||||
|
*,
|
||||||
|
games: int,
|
||||||
|
seed: int = 1,
|
||||||
|
max_steps: int = 10_000,
|
||||||
|
) -> MatchResult:
|
||||||
|
return play_match(
|
||||||
|
bot_factory,
|
||||||
|
opponent_factory,
|
||||||
|
config,
|
||||||
|
games=games,
|
||||||
|
seed=seed,
|
||||||
|
max_steps=max_steps,
|
||||||
|
alternate_seats=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> None:
|
||||||
|
parser = argparse.ArgumentParser(description="Evaluate Lost Cities classic bots.")
|
||||||
|
parser.add_argument("--bot0", default="safe-heuristic", choices=available_bot_names())
|
||||||
|
parser.add_argument("--bot1", default="random", choices=available_bot_names())
|
||||||
|
parser.add_argument("--games", type=int, default=100)
|
||||||
|
parser.add_argument("--seed", type=int, default=1)
|
||||||
|
parser.add_argument("--max-steps", type=int, default=10_000)
|
||||||
|
parser.add_argument("--no-alternate-seats", action="store_true")
|
||||||
|
parser.add_argument("--benchmark", action="store_true")
|
||||||
|
parser.add_argument("--json", action="store_true")
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
config = classic_config()
|
||||||
|
result = play_match(
|
||||||
|
make_bot_factory(args.bot0),
|
||||||
|
make_bot_factory(args.bot1),
|
||||||
|
config,
|
||||||
|
games=args.games,
|
||||||
|
seed=args.seed,
|
||||||
|
max_steps=args.max_steps,
|
||||||
|
alternate_seats=not args.no_alternate_seats,
|
||||||
|
)
|
||||||
|
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"bot0": args.bot0,
|
||||||
|
"bot1": args.bot1,
|
||||||
|
"benchmark": bool(args.benchmark),
|
||||||
|
**result.to_dict(),
|
||||||
|
}
|
||||||
|
if args.json:
|
||||||
|
print(json.dumps(payload, indent=2, sort_keys=True))
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"{args.bot0} vs {args.bot1}: {result.games} games")
|
||||||
|
print(
|
||||||
|
"wins/losses/draws: "
|
||||||
|
f"{result.wins0}/{result.wins1}/{result.draws} "
|
||||||
|
f"(win_rate0={result.win_rate0:.3f})"
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
f"avg_diff0={result.avg_score_diff0:.2f} "
|
||||||
|
f"avg_score0={result.avg_score0:.2f} "
|
||||||
|
f"avg_score1={result.avg_score1:.2f} "
|
||||||
|
f"avg_len={result.avg_game_length:.1f}"
|
||||||
|
)
|
||||||
|
if args.benchmark:
|
||||||
|
print(
|
||||||
|
f"elapsed={result.elapsed_seconds:.3f}s "
|
||||||
|
f"games/sec={result.games_per_second:.1f} "
|
||||||
|
f"steps/sec={result.steps_per_second:.1f}"
|
||||||
|
)
|
||||||
|
if result.max_step_timeouts:
|
||||||
|
print(f"max_step_timeouts={result.max_step_timeouts}")
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_bot_name(name: str) -> str:
|
||||||
|
return name.strip().lower().replace("_", "-")
|
||||||
|
|
||||||
|
|
||||||
|
def _mean(values: list[int]) -> float:
|
||||||
|
return float(np.mean(values)) if values else 0.0
|
||||||
@@ -1,44 +1,9 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from typing import Protocol, TypeAlias, runtime_checkable
|
||||||
from typing import Literal, Protocol, TypeAlias, runtime_checkable
|
|
||||||
|
|
||||||
from .game import Card, GameState, LostCitiesConfig, score_expedition
|
|
||||||
|
|
||||||
BackendName = Literal["python"]
|
|
||||||
|
|
||||||
|
|
||||||
@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)
|
|
||||||
|
|
||||||
|
from .game import GameState
|
||||||
|
from .snapshots import Snapshot
|
||||||
|
|
||||||
BotInput: TypeAlias = dict | GameState | Snapshot
|
BotInput: TypeAlias = dict | GameState | Snapshot
|
||||||
|
|
||||||
@@ -47,18 +12,3 @@ BotInput: TypeAlias = dict | GameState | Snapshot
|
|||||||
class LostCitiesBot(Protocol):
|
class LostCitiesBot(Protocol):
|
||||||
def act(self, obs_or_state: BotInput) -> int:
|
def act(self, obs_or_state: BotInput) -> int:
|
||||||
"""Choose an action id from the current state or observation."""
|
"""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: ...
|
|
||||||
|
|||||||
@@ -12,12 +12,10 @@ from datetime import datetime
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
|
|
||||||
from .backends.common import snapshot_summary
|
|
||||||
from .backends.python import PythonLostCitiesBackend
|
|
||||||
from .bots import DEFAULT_BOT, LostCitiesBot, available_bot_names, build_bot
|
from .bots import DEFAULT_BOT, LostCitiesBot, available_bot_names, build_bot
|
||||||
from .game import Card, GameState, LostCitiesConfig, classic_config
|
from .game import Card, GameState, LostCitiesConfig, classic_config
|
||||||
from .interfaces import LostCitiesBackend, Snapshot
|
|
||||||
from .resources import theme_path
|
from .resources import theme_path
|
||||||
|
from .snapshots import Snapshot, snapshot_from_state, snapshot_summary
|
||||||
|
|
||||||
LOGGER = logging.getLogger("coolrl_lost_cities.games.classic.pygame_pvp")
|
LOGGER = logging.getLogger("coolrl_lost_cities.games.classic.pygame_pvp")
|
||||||
ModeName = Literal["pvp", "pvc"]
|
ModeName = Literal["pvp", "pvc"]
|
||||||
@@ -106,17 +104,17 @@ def turn_identity_summary(identity: tuple[int, str, int] | None) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def undo_until_player_card_phase(
|
def undo_until_player_card_phase(
|
||||||
backend: LostCitiesBackend,
|
app: LostCitiesGuiApp,
|
||||||
*,
|
*,
|
||||||
player: int,
|
player: int,
|
||||||
) -> int:
|
) -> int:
|
||||||
undone = 0
|
undone = 0
|
||||||
while backend.can_undo():
|
while app.can_undo():
|
||||||
changed = backend.undo()
|
changed = app.undo_once()
|
||||||
if not changed:
|
if not changed:
|
||||||
break
|
break
|
||||||
undone += 1
|
undone += 1
|
||||||
snapshot = backend.snapshot()
|
snapshot = app.snapshot()
|
||||||
if snapshot.current_player == player and snapshot.phase == "card":
|
if snapshot.current_player == player and snapshot.phase == "card":
|
||||||
break
|
break
|
||||||
return undone
|
return undone
|
||||||
@@ -201,7 +199,8 @@ class LostCitiesGuiApp:
|
|||||||
self.next_computer_action_at_ms = 0
|
self.next_computer_action_at_ms = 0
|
||||||
self.config = classic_config()
|
self.config = classic_config()
|
||||||
self.computer_bot, self.computer_bot_label = self._build_computer_bot()
|
self.computer_bot, self.computer_bot_label = self._build_computer_bot()
|
||||||
self.backend: LostCitiesBackend = PythonLostCitiesBackend(self.config, self.seed)
|
self.state = GameState.new_game(self.config, seed=self.seed)
|
||||||
|
self.history: list[GameState] = []
|
||||||
self.ui_elements: list[Any] = []
|
self.ui_elements: list[Any] = []
|
||||||
self.hand_card_rects: dict[int, Any] = {}
|
self.hand_card_rects: dict[int, Any] = {}
|
||||||
self.board_targets: list[ActionTarget] = []
|
self.board_targets: list[ActionTarget] = []
|
||||||
@@ -243,6 +242,26 @@ class LostCitiesGuiApp:
|
|||||||
def _build_computer_bot(self) -> tuple[LostCitiesBot, str]:
|
def _build_computer_bot(self) -> tuple[LostCitiesBot, str]:
|
||||||
return build_bot(self.bot_name, seed=self._bot_seed()), self.bot_name
|
return build_bot(self.bot_name, seed=self._bot_seed()), self.bot_name
|
||||||
|
|
||||||
|
def snapshot(self) -> Snapshot:
|
||||||
|
return snapshot_from_state(self.state)
|
||||||
|
|
||||||
|
def can_undo(self) -> bool:
|
||||||
|
return bool(self.history)
|
||||||
|
|
||||||
|
def apply_state_action(self, action_id: int) -> None:
|
||||||
|
self.history.append(self.state.clone())
|
||||||
|
try:
|
||||||
|
self.state.apply_unified_action(action_id)
|
||||||
|
except Exception:
|
||||||
|
self.history.pop()
|
||||||
|
raise
|
||||||
|
|
||||||
|
def undo_once(self) -> bool:
|
||||||
|
if not self.history:
|
||||||
|
return False
|
||||||
|
self.state = self.history.pop()
|
||||||
|
return True
|
||||||
|
|
||||||
def _configure_ui_theme(self) -> None:
|
def _configure_ui_theme(self) -> None:
|
||||||
theme = json.loads(theme_path().read_text())
|
theme = json.loads(theme_path().read_text())
|
||||||
if self.font_path is not None:
|
if self.font_path is not None:
|
||||||
@@ -319,7 +338,7 @@ class LostCitiesGuiApp:
|
|||||||
self.handle_board_click(event.pos)
|
self.handle_board_click(event.pos)
|
||||||
|
|
||||||
def is_computer_turn(self, snapshot: Snapshot | None = None) -> bool:
|
def is_computer_turn(self, snapshot: Snapshot | None = None) -> bool:
|
||||||
snapshot = snapshot or self.backend.snapshot()
|
snapshot = snapshot or self.snapshot()
|
||||||
return (
|
return (
|
||||||
self.mode == "pvc"
|
self.mode == "pvc"
|
||||||
and not snapshot.terminal
|
and not snapshot.terminal
|
||||||
@@ -327,7 +346,7 @@ class LostCitiesGuiApp:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def maybe_apply_computer_action(self) -> None:
|
def maybe_apply_computer_action(self) -> None:
|
||||||
snapshot = self.backend.snapshot()
|
snapshot = self.snapshot()
|
||||||
if not self.is_computer_turn(snapshot):
|
if not self.is_computer_turn(snapshot):
|
||||||
self.next_computer_action_at_ms = 0
|
self.next_computer_action_at_ms = 0
|
||||||
return
|
return
|
||||||
@@ -362,7 +381,7 @@ class LostCitiesGuiApp:
|
|||||||
return snapshot
|
return snapshot
|
||||||
|
|
||||||
def handle_board_click(self, pos: tuple[int, int]) -> None:
|
def handle_board_click(self, pos: tuple[int, int]) -> None:
|
||||||
snapshot = self.backend.snapshot()
|
snapshot = self.snapshot()
|
||||||
if self.is_computer_turn(snapshot):
|
if self.is_computer_turn(snapshot):
|
||||||
LOGGER.debug(
|
LOGGER.debug(
|
||||||
"보드 클릭 무시: 컴퓨터 턴 위치=%s 상태={%s}", pos, snapshot_summary(snapshot)
|
"보드 클릭 무시: 컴퓨터 턴 위치=%s 상태={%s}", pos, snapshot_summary(snapshot)
|
||||||
@@ -412,13 +431,14 @@ class LostCitiesGuiApp:
|
|||||||
self.export_text = None
|
self.export_text = None
|
||||||
try:
|
try:
|
||||||
self.computer_bot, self.computer_bot_label = self._build_computer_bot()
|
self.computer_bot, self.computer_bot_label = self._build_computer_bot()
|
||||||
self.backend = PythonLostCitiesBackend(self.config, self.seed)
|
self.state = GameState.new_game(self.config, seed=self.seed)
|
||||||
|
self.history = []
|
||||||
self._reset_match_trace()
|
self._reset_match_trace()
|
||||||
self.error_text = None
|
self.error_text = None
|
||||||
LOGGER.debug(
|
LOGGER.debug(
|
||||||
"게임 초기화 완료: 시드=%s 상태={%s}",
|
"게임 초기화 완료: 시드=%s 상태={%s}",
|
||||||
self.seed,
|
self.seed,
|
||||||
snapshot_summary(self.backend.snapshot()),
|
snapshot_summary(self.snapshot()),
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self.error_text = str(exc)
|
self.error_text = str(exc)
|
||||||
@@ -427,10 +447,10 @@ class LostCitiesGuiApp:
|
|||||||
|
|
||||||
def apply_action(self, action_id: int, *, rebuild: bool = True) -> None:
|
def apply_action(self, action_id: int, *, rebuild: bool = True) -> None:
|
||||||
try:
|
try:
|
||||||
before = self.backend.snapshot()
|
before = self.snapshot()
|
||||||
LOGGER.debug("액션 적용 요청: 액션=%s 상태={%s}", action_id, snapshot_summary(before))
|
LOGGER.debug("액션 적용 요청: 액션=%s 상태={%s}", action_id, snapshot_summary(before))
|
||||||
self.backend.apply(action_id)
|
self.apply_state_action(action_id)
|
||||||
after = self.backend.snapshot()
|
after = self.snapshot()
|
||||||
self._append_match_trace_step(action_id=action_id, before=before, after=after)
|
self._append_match_trace_step(action_id=action_id, before=before, after=after)
|
||||||
self.selected_card_slot = None
|
self.selected_card_slot = None
|
||||||
self.hand_card_rects = {}
|
self.hand_card_rects = {}
|
||||||
@@ -450,14 +470,14 @@ class LostCitiesGuiApp:
|
|||||||
|
|
||||||
def undo(self) -> None:
|
def undo(self) -> None:
|
||||||
try:
|
try:
|
||||||
before = self.backend.snapshot()
|
before = self.snapshot()
|
||||||
if self.mode == "pvc":
|
if self.mode == "pvc":
|
||||||
undo_count = undo_until_player_card_phase(
|
undo_count = undo_until_player_card_phase(
|
||||||
self.backend,
|
self,
|
||||||
player=1 - self.computer_player,
|
player=1 - self.computer_player,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
undo_count = 1 if self.backend.undo() else 0
|
undo_count = 1 if self.undo_once() else 0
|
||||||
changed = undo_count > 0
|
changed = undo_count > 0
|
||||||
self.selected_card_slot = None
|
self.selected_card_slot = None
|
||||||
self.hand_card_rects = {}
|
self.hand_card_rects = {}
|
||||||
@@ -475,7 +495,7 @@ class LostCitiesGuiApp:
|
|||||||
changed,
|
changed,
|
||||||
undo_count,
|
undo_count,
|
||||||
snapshot_summary(before),
|
snapshot_summary(before),
|
||||||
snapshot_summary(self.backend.snapshot()),
|
snapshot_summary(self.snapshot()),
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self.error_text = str(exc)
|
self.error_text = str(exc)
|
||||||
@@ -483,7 +503,7 @@ class LostCitiesGuiApp:
|
|||||||
self.rebuild_ui()
|
self.rebuild_ui()
|
||||||
|
|
||||||
def _reset_match_trace(self) -> None:
|
def _reset_match_trace(self) -> None:
|
||||||
snapshot = self.backend.snapshot()
|
snapshot = self.snapshot()
|
||||||
self.match_trace = [
|
self.match_trace = [
|
||||||
self._trace_record(
|
self._trace_record(
|
||||||
step_index=0,
|
step_index=0,
|
||||||
@@ -530,7 +550,7 @@ class LostCitiesGuiApp:
|
|||||||
}
|
}
|
||||||
|
|
||||||
def export_match_trace(self) -> None:
|
def export_match_trace(self) -> None:
|
||||||
snapshot = self.backend.snapshot()
|
snapshot = self.snapshot()
|
||||||
if not snapshot.terminal:
|
if not snapshot.terminal:
|
||||||
self.error_text = "대국 종료 후 내보낼 수 있음"
|
self.error_text = "대국 종료 후 내보낼 수 있음"
|
||||||
self.rebuild_ui()
|
self.rebuild_ui()
|
||||||
@@ -549,7 +569,7 @@ class LostCitiesGuiApp:
|
|||||||
"variant": "classic",
|
"variant": "classic",
|
||||||
"mode": self.mode,
|
"mode": self.mode,
|
||||||
"bot": self._computer_bot_display_name() if self.mode == "pvc" else None,
|
"bot": self._computer_bot_display_name() if self.mode == "pvc" else None,
|
||||||
"backend": "python",
|
"engine": "cython",
|
||||||
"seed": self.seed,
|
"seed": self.seed,
|
||||||
"config": self.config.to_snapshot(),
|
"config": self.config.to_snapshot(),
|
||||||
"step_count": len(self.match_trace),
|
"step_count": len(self.match_trace),
|
||||||
@@ -624,14 +644,14 @@ class LostCitiesGuiApp:
|
|||||||
text="UNDO",
|
text="UNDO",
|
||||||
manager=self.manager,
|
manager=self.manager,
|
||||||
)
|
)
|
||||||
if not self.backend.can_undo():
|
if not self.can_undo():
|
||||||
self.undo_button.disable()
|
self.undo_button.disable()
|
||||||
self.export_button = pygame_gui.elements.UIButton(
|
self.export_button = pygame_gui.elements.UIButton(
|
||||||
relative_rect=pygame.Rect(width - 128, 17, 110, 46),
|
relative_rect=pygame.Rect(width - 128, 17, 110, 46),
|
||||||
text="EXPORT",
|
text="EXPORT",
|
||||||
manager=self.manager,
|
manager=self.manager,
|
||||||
)
|
)
|
||||||
if not self.backend.snapshot().terminal:
|
if not self.snapshot().terminal:
|
||||||
self.export_button.disable()
|
self.export_button.disable()
|
||||||
self.ui_elements.extend(
|
self.ui_elements.extend(
|
||||||
[
|
[
|
||||||
@@ -645,7 +665,7 @@ class LostCitiesGuiApp:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def draw(self) -> None:
|
def draw(self) -> None:
|
||||||
snapshot = self.backend.snapshot()
|
snapshot = self.snapshot()
|
||||||
self._sync_turn_identity(snapshot)
|
self._sync_turn_identity(snapshot)
|
||||||
self.hand_card_rects = {}
|
self.hand_card_rects = {}
|
||||||
self.board_targets = []
|
self.board_targets = []
|
||||||
@@ -834,7 +854,7 @@ class LostCitiesGuiApp:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _turn_flash_active(self, player: int) -> bool:
|
def _turn_flash_active(self, player: int) -> bool:
|
||||||
snapshot = self.backend.snapshot()
|
snapshot = self.snapshot()
|
||||||
return (
|
return (
|
||||||
snapshot.current_player == player
|
snapshot.current_player == player
|
||||||
and snapshot.phase == "card"
|
and snapshot.phase == "card"
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .game import LostCitiesConfig
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, order=True)
|
||||||
|
class ReferenceLostCitiesCard:
|
||||||
|
color: int
|
||||||
|
rank: int
|
||||||
|
|
||||||
|
|
||||||
|
class ReferenceLostCitiesState:
|
||||||
|
"""Pure Python reference implementation placeholder."""
|
||||||
|
|
||||||
|
def __init__(self, config: LostCitiesConfig | None = None, **_: Any) -> None:
|
||||||
|
self.config = config or LostCitiesConfig()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def new_game(
|
||||||
|
cls,
|
||||||
|
config: LostCitiesConfig | None = None,
|
||||||
|
*,
|
||||||
|
seed: int | None = None,
|
||||||
|
) -> ReferenceLostCitiesState:
|
||||||
|
raise NotImplementedError("pure Python reference engine is not implemented yet")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def new_game_from_deck(
|
||||||
|
cls,
|
||||||
|
deck: list[ReferenceLostCitiesCard],
|
||||||
|
config: LostCitiesConfig | None = None,
|
||||||
|
) -> ReferenceLostCitiesState:
|
||||||
|
raise NotImplementedError("pure Python reference engine is not implemented yet")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def empty(cls, config: LostCitiesConfig | None = None) -> ReferenceLostCitiesState:
|
||||||
|
raise NotImplementedError("pure Python reference engine is not implemented yet")
|
||||||
|
|
||||||
|
def to_snapshot(self) -> dict[str, Any]:
|
||||||
|
raise NotImplementedError("pure Python reference engine is not implemented yet")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_snapshot(
|
||||||
|
cls,
|
||||||
|
snapshot: dict[str, Any],
|
||||||
|
*,
|
||||||
|
validate: bool = True,
|
||||||
|
) -> ReferenceLostCitiesState:
|
||||||
|
raise NotImplementedError("pure Python reference engine is not implemented yet")
|
||||||
|
|
||||||
|
def legal_mask(self) -> list[bool]:
|
||||||
|
raise NotImplementedError("pure Python reference engine is not implemented yet")
|
||||||
|
|
||||||
|
def unified_legal_mask(self) -> list[bool]:
|
||||||
|
raise NotImplementedError("pure Python reference engine is not implemented yet")
|
||||||
|
|
||||||
|
def apply_action(self, action_id: int) -> None:
|
||||||
|
raise NotImplementedError("pure Python reference engine is not implemented yet")
|
||||||
|
|
||||||
|
def apply_unified_action(self, action_id: int) -> None:
|
||||||
|
raise NotImplementedError("pure Python reference engine is not implemented yet")
|
||||||
|
|
||||||
|
def total_score(self, player: int) -> int:
|
||||||
|
raise NotImplementedError("pure Python reference engine is not implemented yet")
|
||||||
|
|
||||||
|
def score_diff(self, player: int = 0) -> int:
|
||||||
|
raise NotImplementedError("pure Python reference engine is not implemented yet")
|
||||||
+43
-36
@@ -1,23 +1,40 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from ..game import Card, GameState, LostCitiesConfig
|
from .game import Card, GameState, LostCitiesConfig, score_expedition
|
||||||
from ..interfaces import Snapshot
|
|
||||||
|
|
||||||
|
|
||||||
def snapshot_summary(snapshot: Snapshot) -> str:
|
@dataclass
|
||||||
scores = [snapshot.total_score(0), snapshot.total_score(1)]
|
class Snapshot:
|
||||||
hand_sizes = [len(hand) for hand in snapshot.hands]
|
config: LostCitiesConfig
|
||||||
discard_sizes = [len(discard) for discard in snapshot.discards]
|
deck: list[Card]
|
||||||
phase = "카드" if snapshot.phase == "card" else "뽑기"
|
hands: list[list[Card]]
|
||||||
return (
|
expeditions: list[list[list[Card]]]
|
||||||
f"플레이어={snapshot.current_player} 단계={phase} "
|
discards: list[list[Card]]
|
||||||
f"턴={snapshot.turn_count} 종료={snapshot.terminal} "
|
current_player: int
|
||||||
f"덱={len(snapshot.deck)} 손패수={hand_sizes} 점수={scores} "
|
phase: str
|
||||||
f"직전버린색={snapshot.pending_discarded_color} "
|
pending_discarded_color: int | None
|
||||||
f"버린더미수={discard_sizes}"
|
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)
|
||||||
|
|
||||||
|
|
||||||
def snapshot_from_state(state: GameState) -> Snapshot:
|
def snapshot_from_state(state: GameState) -> Snapshot:
|
||||||
@@ -39,25 +56,15 @@ def snapshot_from_state(state: GameState) -> Snapshot:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def snapshot_from_trace(config_data: dict[str, Any], step: dict[str, Any]) -> Snapshot:
|
def snapshot_summary(snapshot: Snapshot) -> str:
|
||||||
config = LostCitiesConfig(**config_data)
|
scores = [snapshot.total_score(0), snapshot.total_score(1)]
|
||||||
return Snapshot(
|
hand_sizes = [len(hand) for hand in snapshot.hands]
|
||||||
config=config,
|
discard_sizes = [len(discard) for discard in snapshot.discards]
|
||||||
deck=cards_from_json(step["deck"]),
|
phase = "카드" if snapshot.phase == "card" else "뽑기"
|
||||||
hands=[cards_from_json(hand) for hand in step["hands"]],
|
return (
|
||||||
expeditions=[
|
f"플레이어={snapshot.current_player} 단계={phase} "
|
||||||
[cards_from_json(expedition) for expedition in player_expeditions]
|
f"턴={snapshot.turn_count} 종료={snapshot.terminal} "
|
||||||
for player_expeditions in step["expeditions"]
|
f"덱={len(snapshot.deck)} 손패수={hand_sizes} 점수={scores} "
|
||||||
],
|
f"직전버린색={snapshot.pending_discarded_color} "
|
||||||
discards=[cards_from_json(discard) for discard in step["discards"]],
|
f"버린더미수={discard_sizes}"
|
||||||
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,64 @@
|
|||||||
|
from coolrl_lost_cities.games.classic import (
|
||||||
|
LostCitiesConfig,
|
||||||
|
build_bot,
|
||||||
|
make_bot_factory,
|
||||||
|
play_game_for_evaluation,
|
||||||
|
play_match,
|
||||||
|
)
|
||||||
|
from coolrl_lost_cities.games.classic.evaluation import main
|
||||||
|
|
||||||
|
|
||||||
|
def test_play_game_for_evaluation_finishes_small_match() -> None:
|
||||||
|
config = LostCitiesConfig(n_colors=3, n_ranks=5, n_handshakes=1, hand_size=5)
|
||||||
|
|
||||||
|
state, result = play_game_for_evaluation(
|
||||||
|
build_bot("random", seed=1),
|
||||||
|
build_bot("passive-discard", seed=2),
|
||||||
|
config,
|
||||||
|
seed=3,
|
||||||
|
max_steps=200,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert state.terminal is True
|
||||||
|
assert result.timed_out is False
|
||||||
|
assert result.steps > 0
|
||||||
|
assert result.score_diff0 == result.score0 - result.score1
|
||||||
|
|
||||||
|
|
||||||
|
def test_play_match_alternates_seats_and_reports_rates() -> None:
|
||||||
|
config = LostCitiesConfig(n_colors=3, n_ranks=5, n_handshakes=1, hand_size=5)
|
||||||
|
|
||||||
|
result = play_match(
|
||||||
|
make_bot_factory("random"),
|
||||||
|
make_bot_factory("passive-discard"),
|
||||||
|
config,
|
||||||
|
games=4,
|
||||||
|
seed=10,
|
||||||
|
max_steps=200,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.games == 4
|
||||||
|
assert result.wins0 + result.wins1 + result.draws == 4
|
||||||
|
assert result.avg_game_length > 0.0
|
||||||
|
assert result.games_per_second > 0.0
|
||||||
|
assert result.steps_per_second > 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_evaluation_cli_smoke_json(capsys) -> None:
|
||||||
|
main(
|
||||||
|
[
|
||||||
|
"--bot0",
|
||||||
|
"random",
|
||||||
|
"--bot1",
|
||||||
|
"passive-discard",
|
||||||
|
"--games",
|
||||||
|
"2",
|
||||||
|
"--seed",
|
||||||
|
"20",
|
||||||
|
"--json",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert '"games": 2' in captured.out
|
||||||
|
assert '"win_rate0"' in captured.out
|
||||||
@@ -13,10 +13,26 @@ def test_classic_package_exports_common_game_api() -> None:
|
|||||||
assert state.config.deck_size == 60
|
assert state.config.deck_size == 60
|
||||||
|
|
||||||
|
|
||||||
def test_classic_package_exports_backend_alias() -> None:
|
def test_classic_package_exports_snapshot_alias() -> None:
|
||||||
backend = classic.build_backend("python", classic.classic_config(), seed=1)
|
state = classic.GameState.new_game(classic.classic_config(seed=1))
|
||||||
|
snapshot = classic.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(),
|
||||||
|
)
|
||||||
|
|
||||||
assert isinstance(backend.snapshot(), classic.Snapshot)
|
assert snapshot.score_diff(0) == state.score_diff(0)
|
||||||
|
|
||||||
|
|
||||||
def test_classic_package_exports_bot_registry_helpers() -> None:
|
def test_classic_package_exports_bot_registry_helpers() -> None:
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from coolrl_lost_cities.games.classic import (
|
||||||
|
LostCitiesConfig,
|
||||||
|
ReferenceLostCitiesCard,
|
||||||
|
ReferenceLostCitiesState,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_reference_card_is_plain_ordered_value() -> None:
|
||||||
|
assert ReferenceLostCitiesCard(0, 1) < ReferenceLostCitiesCard(1, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_reference_state_placeholder_is_explicitly_unimplemented() -> None:
|
||||||
|
with pytest.raises(NotImplementedError):
|
||||||
|
ReferenceLostCitiesState.new_game(LostCitiesConfig())
|
||||||
Reference in New Issue
Block a user