백엔드 제거하고 평가 기반 정리
This commit is contained in:
@@ -15,3 +15,6 @@ target/
|
||||
|
||||
# Virtual environments
|
||||
.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
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
@@ -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 상태는 게임 인스턴스 단위로 유지
|
||||
|
||||
한 `GameState` 또는 backend 인스턴스가 한 게임을 나타낸다.
|
||||
한 `GameState` 인스턴스가 한 게임을 나타낸다.
|
||||
|
||||
### 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 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: ...
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 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]
|
||||
@@ -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
|
||||
|
||||
|
||||
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:
|
||||
|
||||
@@ -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