From 1e5c5509e11c67a13458e03f67d9be390a3acfc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=8B=9C=EC=9B=90?= Date: Wed, 6 May 2026 20:39:21 +0900 Subject: [PATCH] =?UTF-8?q?=EB=B0=B1=EC=97=94=EB=93=9C=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0=ED=95=98=EA=B3=A0=20=ED=8F=89=EA=B0=80=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 + README.md | 11 +- docs/classic-port-notes.md | 4 +- pyproject.toml | 1 + .../games/classic/__init__.py | 23 +- .../games/classic/backends/__init__.py | 9 - .../games/classic/backends/factory.py | 15 - .../games/classic/backends/python.py | 52 ---- .../games/classic/docs/lost_cities_spec.md | 2 +- .../games/classic/evaluation.py | 271 ++++++++++++++++++ .../games/classic/interfaces.py | 56 +--- .../games/classic/pygame_pvp.py | 74 +++-- .../games/classic/reference.py | 70 +++++ .../{backends/common.py => snapshots.py} | 79 ++--- tests/games/classic/test_evaluation.py | 64 +++++ tests/games/classic/test_public_api.py | 22 +- tests/games/classic/test_reference.py | 16 ++ 17 files changed, 560 insertions(+), 212 deletions(-) delete mode 100644 src/coolrl_lost_cities/games/classic/backends/__init__.py delete mode 100644 src/coolrl_lost_cities/games/classic/backends/factory.py delete mode 100644 src/coolrl_lost_cities/games/classic/backends/python.py create mode 100644 src/coolrl_lost_cities/games/classic/evaluation.py create mode 100644 src/coolrl_lost_cities/games/classic/reference.py rename src/coolrl_lost_cities/games/classic/{backends/common.py => snapshots.py} (56%) create mode 100644 tests/games/classic/test_evaluation.py create mode 100644 tests/games/classic/test_reference.py diff --git a/.gitignore b/.gitignore index b4ff4b8..02fea38 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,6 @@ target/ # Virtual environments .venv + +.pytest_cache +.ruff_cache \ No newline at end of file diff --git a/README.md b/README.md index 12974a9..65488d3 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ Run the classic pygame GUI: 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 @@ -48,13 +48,4 @@ while not state.terminal: 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. diff --git a/docs/classic-port-notes.md b/docs/classic-port-notes.md index 2b52c78..ad44973 100644 --- a/docs/classic-port-notes.md +++ b/docs/classic-port-notes.md @@ -21,9 +21,11 @@ src/coolrl_lost_cities/ games/ classic/ game.pyx + reference.py + snapshots.py + evaluation.py env.py interfaces.py - backends/ bots/ pygame_pvp.py fixtures/ diff --git a/pyproject.toml b/pyproject.toml index 05e3366..884494c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,7 @@ gui = [ [project.scripts] 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" [dependency-groups] diff --git a/src/coolrl_lost_cities/games/classic/__init__.py b/src/coolrl_lost_cities/games/classic/__init__.py index 984dd22..b0d5b1c 100644 --- a/src/coolrl_lost_cities/games/classic/__init__.py +++ b/src/coolrl_lost_cities/games/classic/__init__.py @@ -1,6 +1,5 @@ from __future__ import annotations -from .backends import build_backend from .bots import ( LostCitiesBot, available_bot_names, @@ -9,28 +8,42 @@ from .bots import ( run_series, ) from .env import LostCitiesEnv +from .evaluation import ( + GameResult, + MatchResult, + evaluate_bot, + make_bot_factory, + play_game_for_evaluation, + play_match, +) from .game import ( GameState, IllegalMoveError, LostCitiesConfig, classic_config, ) -from .interfaces import BackendName, LostCitiesBackend, Snapshot +from .interfaces import Snapshot +from .reference import ReferenceLostCitiesCard, ReferenceLostCitiesState __all__ = [ - "BackendName", "GameState", + "GameResult", "IllegalMoveError", - "LostCitiesBackend", "LostCitiesBot", "LostCitiesConfig", "LostCitiesEnv", + "MatchResult", + "ReferenceLostCitiesCard", + "ReferenceLostCitiesState", "Snapshot", "available_bot_names", - "build_backend", "build_bot", "classic_config", + "evaluate_bot", + "make_bot_factory", "play_game", + "play_game_for_evaluation", + "play_match", "run_series", ] diff --git a/src/coolrl_lost_cities/games/classic/backends/__init__.py b/src/coolrl_lost_cities/games/classic/backends/__init__.py deleted file mode 100644 index 6a70cee..0000000 --- a/src/coolrl_lost_cities/games/classic/backends/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -from __future__ import annotations - -from .factory import build_backend -from .python import PythonLostCitiesBackend - -__all__ = [ - "PythonLostCitiesBackend", - "build_backend", -] diff --git a/src/coolrl_lost_cities/games/classic/backends/factory.py b/src/coolrl_lost_cities/games/classic/backends/factory.py deleted file mode 100644 index 9c6c3df..0000000 --- a/src/coolrl_lost_cities/games/classic/backends/factory.py +++ /dev/null @@ -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}") diff --git a/src/coolrl_lost_cities/games/classic/backends/python.py b/src/coolrl_lost_cities/games/classic/backends/python.py deleted file mode 100644 index 7b0f475..0000000 --- a/src/coolrl_lost_cities/games/classic/backends/python.py +++ /dev/null @@ -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 diff --git a/src/coolrl_lost_cities/games/classic/docs/lost_cities_spec.md b/src/coolrl_lost_cities/games/classic/docs/lost_cities_spec.md index 2b7a0f8..c2bee07 100644 --- a/src/coolrl_lost_cities/games/classic/docs/lost_cities_spec.md +++ b/src/coolrl_lost_cities/games/classic/docs/lost_cities_spec.md @@ -169,7 +169,7 @@ CARD phase에서는 DISCARD가 항상 가능하므로 합법 액션이 최소 1 ### 9.1 상태는 게임 인스턴스 단위로 유지 -한 `GameState` 또는 backend 인스턴스가 한 게임을 나타낸다. +한 `GameState` 인스턴스가 한 게임을 나타낸다. ### 9.2 observation은 항상 특정 플레이어 관점 diff --git a/src/coolrl_lost_cities/games/classic/evaluation.py b/src/coolrl_lost_cities/games/classic/evaluation.py new file mode 100644 index 0000000..71ae124 --- /dev/null +++ b/src/coolrl_lost_cities/games/classic/evaluation.py @@ -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 diff --git a/src/coolrl_lost_cities/games/classic/interfaces.py b/src/coolrl_lost_cities/games/classic/interfaces.py index 3d5eb91..4d188d3 100644 --- a/src/coolrl_lost_cities/games/classic/interfaces.py +++ b/src/coolrl_lost_cities/games/classic/interfaces.py @@ -1,44 +1,9 @@ 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"] - - -@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 typing import Protocol, TypeAlias, runtime_checkable +from .game import GameState +from .snapshots import Snapshot BotInput: TypeAlias = dict | GameState | Snapshot @@ -47,18 +12,3 @@ BotInput: TypeAlias = dict | GameState | Snapshot class LostCitiesBot(Protocol): def act(self, obs_or_state: BotInput) -> int: """Choose an action id from the current state or observation.""" - - -@runtime_checkable -class LostCitiesBackend(Protocol): - name: BackendName - config: LostCitiesConfig - seed: int | None - - def snapshot(self) -> Snapshot: ... - - def apply(self, action_id: int) -> None: ... - - def can_undo(self) -> bool: ... - - def undo(self) -> bool: ... diff --git a/src/coolrl_lost_cities/games/classic/pygame_pvp.py b/src/coolrl_lost_cities/games/classic/pygame_pvp.py index 7e19b62..d593062 100644 --- a/src/coolrl_lost_cities/games/classic/pygame_pvp.py +++ b/src/coolrl_lost_cities/games/classic/pygame_pvp.py @@ -12,12 +12,10 @@ from datetime import datetime from pathlib import Path 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 .game import Card, GameState, LostCitiesConfig, classic_config -from .interfaces import LostCitiesBackend, Snapshot from .resources import theme_path +from .snapshots import Snapshot, snapshot_from_state, snapshot_summary LOGGER = logging.getLogger("coolrl_lost_cities.games.classic.pygame_pvp") 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( - backend: LostCitiesBackend, + app: LostCitiesGuiApp, *, player: int, ) -> int: undone = 0 - while backend.can_undo(): - changed = backend.undo() + while app.can_undo(): + changed = app.undo_once() if not changed: break undone += 1 - snapshot = backend.snapshot() + snapshot = app.snapshot() if snapshot.current_player == player and snapshot.phase == "card": break return undone @@ -201,7 +199,8 @@ class LostCitiesGuiApp: self.next_computer_action_at_ms = 0 self.config = classic_config() 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.hand_card_rects: dict[int, Any] = {} self.board_targets: list[ActionTarget] = [] @@ -243,6 +242,26 @@ class LostCitiesGuiApp: def _build_computer_bot(self) -> tuple[LostCitiesBot, str]: 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: theme = json.loads(theme_path().read_text()) if self.font_path is not None: @@ -319,7 +338,7 @@ class LostCitiesGuiApp: self.handle_board_click(event.pos) def is_computer_turn(self, snapshot: Snapshot | None = None) -> bool: - snapshot = snapshot or self.backend.snapshot() + snapshot = snapshot or self.snapshot() return ( self.mode == "pvc" and not snapshot.terminal @@ -327,7 +346,7 @@ class LostCitiesGuiApp: ) def maybe_apply_computer_action(self) -> None: - snapshot = self.backend.snapshot() + snapshot = self.snapshot() if not self.is_computer_turn(snapshot): self.next_computer_action_at_ms = 0 return @@ -362,7 +381,7 @@ class LostCitiesGuiApp: return snapshot def handle_board_click(self, pos: tuple[int, int]) -> None: - snapshot = self.backend.snapshot() + snapshot = self.snapshot() if self.is_computer_turn(snapshot): LOGGER.debug( "보드 클릭 무시: 컴퓨터 턴 위치=%s 상태={%s}", pos, snapshot_summary(snapshot) @@ -412,13 +431,14 @@ class LostCitiesGuiApp: self.export_text = None try: 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.error_text = None LOGGER.debug( "게임 초기화 완료: 시드=%s 상태={%s}", self.seed, - snapshot_summary(self.backend.snapshot()), + snapshot_summary(self.snapshot()), ) except Exception as exc: self.error_text = str(exc) @@ -427,10 +447,10 @@ class LostCitiesGuiApp: def apply_action(self, action_id: int, *, rebuild: bool = True) -> None: try: - before = self.backend.snapshot() + before = self.snapshot() LOGGER.debug("액션 적용 요청: 액션=%s 상태={%s}", action_id, snapshot_summary(before)) - self.backend.apply(action_id) - after = self.backend.snapshot() + self.apply_state_action(action_id) + after = self.snapshot() self._append_match_trace_step(action_id=action_id, before=before, after=after) self.selected_card_slot = None self.hand_card_rects = {} @@ -450,14 +470,14 @@ class LostCitiesGuiApp: def undo(self) -> None: try: - before = self.backend.snapshot() + before = self.snapshot() if self.mode == "pvc": undo_count = undo_until_player_card_phase( - self.backend, + self, player=1 - self.computer_player, ) else: - undo_count = 1 if self.backend.undo() else 0 + undo_count = 1 if self.undo_once() else 0 changed = undo_count > 0 self.selected_card_slot = None self.hand_card_rects = {} @@ -475,7 +495,7 @@ class LostCitiesGuiApp: changed, undo_count, snapshot_summary(before), - snapshot_summary(self.backend.snapshot()), + snapshot_summary(self.snapshot()), ) except Exception as exc: self.error_text = str(exc) @@ -483,7 +503,7 @@ class LostCitiesGuiApp: self.rebuild_ui() def _reset_match_trace(self) -> None: - snapshot = self.backend.snapshot() + snapshot = self.snapshot() self.match_trace = [ self._trace_record( step_index=0, @@ -530,7 +550,7 @@ class LostCitiesGuiApp: } def export_match_trace(self) -> None: - snapshot = self.backend.snapshot() + snapshot = self.snapshot() if not snapshot.terminal: self.error_text = "대국 종료 후 내보낼 수 있음" self.rebuild_ui() @@ -549,7 +569,7 @@ class LostCitiesGuiApp: "variant": "classic", "mode": self.mode, "bot": self._computer_bot_display_name() if self.mode == "pvc" else None, - "backend": "python", + "engine": "cython", "seed": self.seed, "config": self.config.to_snapshot(), "step_count": len(self.match_trace), @@ -624,14 +644,14 @@ class LostCitiesGuiApp: text="UNDO", manager=self.manager, ) - if not self.backend.can_undo(): + if not self.can_undo(): self.undo_button.disable() self.export_button = pygame_gui.elements.UIButton( relative_rect=pygame.Rect(width - 128, 17, 110, 46), text="EXPORT", manager=self.manager, ) - if not self.backend.snapshot().terminal: + if not self.snapshot().terminal: self.export_button.disable() self.ui_elements.extend( [ @@ -645,7 +665,7 @@ class LostCitiesGuiApp: ) def draw(self) -> None: - snapshot = self.backend.snapshot() + snapshot = self.snapshot() self._sync_turn_identity(snapshot) self.hand_card_rects = {} self.board_targets = [] @@ -834,7 +854,7 @@ class LostCitiesGuiApp: ) def _turn_flash_active(self, player: int) -> bool: - snapshot = self.backend.snapshot() + snapshot = self.snapshot() return ( snapshot.current_player == player and snapshot.phase == "card" diff --git a/src/coolrl_lost_cities/games/classic/reference.py b/src/coolrl_lost_cities/games/classic/reference.py new file mode 100644 index 0000000..e6f2f22 --- /dev/null +++ b/src/coolrl_lost_cities/games/classic/reference.py @@ -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") diff --git a/src/coolrl_lost_cities/games/classic/backends/common.py b/src/coolrl_lost_cities/games/classic/snapshots.py similarity index 56% rename from src/coolrl_lost_cities/games/classic/backends/common.py rename to src/coolrl_lost_cities/games/classic/snapshots.py index b6d7297..9ba4fa0 100644 --- a/src/coolrl_lost_cities/games/classic/backends/common.py +++ b/src/coolrl_lost_cities/games/classic/snapshots.py @@ -1,23 +1,40 @@ from __future__ import annotations -from typing import Any +from dataclasses import dataclass -from ..game import Card, GameState, LostCitiesConfig -from ..interfaces import Snapshot +from .game import Card, GameState, LostCitiesConfig, score_expedition -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}" - ) +@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) 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: - 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 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 cards_from_json(cards: list[dict[str, int]]) -> list[Card]: - return [Card.from_snapshot(card) for card in cards] diff --git a/tests/games/classic/test_evaluation.py b/tests/games/classic/test_evaluation.py new file mode 100644 index 0000000..98953da --- /dev/null +++ b/tests/games/classic/test_evaluation.py @@ -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 diff --git a/tests/games/classic/test_public_api.py b/tests/games/classic/test_public_api.py index dcb0ead..bf96f23 100644 --- a/tests/games/classic/test_public_api.py +++ b/tests/games/classic/test_public_api.py @@ -13,10 +13,26 @@ def test_classic_package_exports_common_game_api() -> None: assert state.config.deck_size == 60 -def test_classic_package_exports_backend_alias() -> None: - backend = classic.build_backend("python", classic.classic_config(), seed=1) +def test_classic_package_exports_snapshot_alias() -> None: + 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: diff --git a/tests/games/classic/test_reference.py b/tests/games/classic/test_reference.py new file mode 100644 index 0000000..700cdb0 --- /dev/null +++ b/tests/games/classic/test_reference.py @@ -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())