백엔드 제거하고 평가 기반 정리

This commit is contained in:
2026-05-06 20:39:21 +09:00
parent b578b38628
commit 1e5c5509e1
17 changed files with 560 additions and 212 deletions
+3
View File
@@ -15,3 +15,6 @@ target/
# Virtual environments # Virtual environments
.venv .venv
.pytest_cache
.ruff_cache
+1 -10
View File
@@ -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.
+3 -1
View File
@@ -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/
+1
View File
@@ -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")
@@ -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]
+64
View File
@@ -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
+19 -3
View File
@@ -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:
+16
View File
@@ -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())