게임 엔진을 game 모듈로 통합

This commit is contained in:
2026-05-06 22:42:09 +09:00
parent c1c50267b5
commit 8763430475
17 changed files with 1242 additions and 1302 deletions
-2
View File
@@ -43,8 +43,6 @@ include = ["coolrl_lost_cities*"]
"assets/*.json", "assets/*.json",
"fixtures/*.json", "fixtures/*.json",
"docs/*.md", "docs/*.md",
]
"coolrl_lost_cities.games.classic.engines" = [
"*.pxd", "*.pxd",
"*.pyx", "*.pyx",
] ]
-4
View File
@@ -14,10 +14,6 @@ extensions = cythonize(
"coolrl_lost_cities.games.classic.game", "coolrl_lost_cities.games.classic.game",
["src/coolrl_lost_cities/games/classic/game.pyx"], ["src/coolrl_lost_cities/games/classic/game.pyx"],
), ),
Extension(
"coolrl_lost_cities.games.classic.engines.fast",
["src/coolrl_lost_cities/games/classic/engines/fast.pyx"],
),
], ],
language_level=3, language_level=3,
compiler_directives={ compiler_directives={
@@ -5,7 +5,6 @@ from .bots import (
available_bot_names, available_bot_names,
build_bot, build_bot,
) )
from .engines import FastGameState as GameState
from .env import LostCitiesEnv from .env import LostCitiesEnv
from .evaluation import ( from .evaluation import (
GameResult, GameResult,
@@ -18,6 +17,7 @@ from .evaluation import (
play_match, play_match,
) )
from .game import ( from .game import (
GameState,
IllegalMoveError, IllegalMoveError,
LostCitiesConfig, LostCitiesConfig,
classic_config, classic_config,
@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from ..engines import FastGameState as GameState from ..game import GameState
from ..interfaces import BotInput, Snapshot from ..interfaces import BotInput, Snapshot
try: try:
@@ -4,8 +4,7 @@ import logging
from dataclasses import dataclass from dataclasses import dataclass
from functools import lru_cache from functools import lru_cache
from ..engines import FastGameState as GameState from ..game import Card, GameState, LostCitiesConfig
from ..game import Card, LostCitiesConfig
from ..interfaces import BotInput, LostCitiesBot from ..interfaces import BotInput, LostCitiesBot
from .base import first_legal, legal_from_obs from .base import first_legal, legal_from_obs
@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from ..engines import FastGameState as GameState from ..game import GameState
from ..interfaces import BotInput, Snapshot from ..interfaces import BotInput, Snapshot
from .base import first_legal, legal_from_obs from .base import first_legal, legal_from_obs
@@ -1,5 +0,0 @@
from __future__ import annotations
from .fast import FastGameState
__all__ = ["FastGameState"]
@@ -1,31 +0,0 @@
from __future__ import annotations
from typing import Any
def encode_card(color: int, rank: int, n_ranks: int) -> int:
return int(color) * (int(n_ranks) + 1) + int(rank)
def decode_card(card: int, n_ranks: int) -> tuple[int, int]:
stride = int(n_ranks) + 1
return int(card) // stride, int(card) % stride
def card_to_snapshot(card: int, n_ranks: int) -> dict[str, int]:
color, rank = decode_card(card, n_ranks)
return {"color": color, "rank": rank}
def encode_card_snapshot(data: Any, n_ranks: int) -> int:
if isinstance(data, int):
return data
if isinstance(data, dict):
return encode_card(int(data["color"]), int(data["rank"]), n_ranks)
if isinstance(data, (list, tuple)) and len(data) == 2:
return encode_card(int(data[0]), int(data[1]), n_ranks)
color = getattr(data, "color", None)
rank = getattr(data, "rank", None)
if color is not None and rank is not None:
return encode_card(int(color), int(rank), n_ranks)
raise ValueError(f"invalid card snapshot: {data!r}")
File diff suppressed because it is too large Load Diff
+1 -2
View File
@@ -1,7 +1,6 @@
from __future__ import annotations from __future__ import annotations
from .engines import FastGameState as GameState from .game import GameState, IllegalMoveError, LostCitiesConfig
from .game import IllegalMoveError, LostCitiesConfig
try: try:
import numpy as np import numpy as np
@@ -10,8 +10,7 @@ from typing import Any
import numpy as np import numpy as np
from .bots import available_bot_names, build_bot from .bots import available_bot_names, build_bot
from .engines import FastGameState as GameState from .game import GameState, LostCitiesConfig, classic_config
from .game import LostCitiesConfig, classic_config
from .interfaces import LostCitiesBot from .interfaces import LostCitiesBot
BotFactory = Callable[[int | None], LostCitiesBot] BotFactory = Callable[[int | None], LostCitiesBot]
@@ -16,7 +16,7 @@ ctypedef struct UndoRecord:
int total_score_before int total_score_before
cdef class FastGameState: cdef class GameState:
cdef public object config cdef public object config
cdef int n_colors cdef int n_colors
cdef int n_ranks cdef int n_ranks
@@ -56,7 +56,7 @@ cdef class FastGameState:
cdef void _configure(self, object config) except * cdef void _configure(self, object config) except *
cdef void _clear(self) noexcept cdef void _clear(self) noexcept
cpdef FastGameState clone(self) cpdef GameState clone(self)
cpdef list legal_card_mask(self) cpdef list legal_card_mask(self)
cpdef list legal_draw_mask(self) cpdef list legal_draw_mask(self)
cpdef list legal_mask(self) cpdef list legal_mask(self)
File diff suppressed because it is too large Load Diff
@@ -2,7 +2,7 @@ from __future__ import annotations
from typing import Protocol, TypeAlias, runtime_checkable from typing import Protocol, TypeAlias, runtime_checkable
from .engines import FastGameState as GameState from .game import GameState
from .snapshots import Snapshot from .snapshots import Snapshot
BotInput: TypeAlias = dict | GameState | Snapshot BotInput: TypeAlias = dict | GameState | Snapshot
@@ -13,8 +13,7 @@ from pathlib import Path
from typing import Any, Literal from typing import Any, Literal
from .bots import DEFAULT_BOT, LostCitiesBot, available_bot_names, build_bot from .bots import DEFAULT_BOT, LostCitiesBot, available_bot_names, build_bot
from .engines import FastGameState as GameState from .game import Card, GameState, LostCitiesConfig, classic_config
from .game import Card, LostCitiesConfig, classic_config
from .resources import theme_path from .resources import theme_path
from .snapshots import Snapshot, snapshot_from_state, snapshot_summary from .snapshots import Snapshot, snapshot_from_state, snapshot_summary
@@ -2,8 +2,7 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from .engines import FastGameState as GameState from .game import Card, GameState, LostCitiesConfig, score_expedition
from .game import Card, LostCitiesConfig, score_expedition
@dataclass @dataclass
@@ -6,7 +6,6 @@ import pytest
from coolrl_lost_cities.games.classic.game import GameState, LostCitiesConfig, build_deck from coolrl_lost_cities.games.classic.game import GameState, LostCitiesConfig, build_deck
from coolrl_lost_cities.games.classic.bots import RandomBot from coolrl_lost_cities.games.classic.bots import RandomBot
from coolrl_lost_cities.games.classic.engines import FastGameState
def _card(color: int, rank: int) -> dict[str, int]: def _card(color: int, rank: int) -> dict[str, int]:
@@ -59,58 +58,57 @@ def _snapshot(
} }
def test_public_game_state_alias_matches_fast_new_game_from_deck_snapshot() -> None: def test_public_game_state_alias_matches_game_state_new_game_from_deck_snapshot() -> None:
config = LostCitiesConfig() config = LostCitiesConfig()
deck = build_deck(config) deck = build_deck(config)
assert GameState is FastGameState
left = GameState.new_game_from_deck(deck, config) left = GameState.new_game_from_deck(deck, config)
right = FastGameState.new_game_from_deck(deck, config) other = GameState.new_game_from_deck(deck, config)
assert right.to_snapshot() == left.to_snapshot() assert other.to_snapshot() == left.to_snapshot()
right.validate_invariants() other.validate_invariants()
def test_fast_snapshot_roundtrip_preserves_snapshot() -> None: def test_game_state_snapshot_roundtrip_preserves_snapshot() -> None:
config = LostCitiesConfig(seed=11) config = LostCitiesConfig(seed=11)
left = GameState.new_game(config) left = GameState.new_game(config)
right = FastGameState.from_snapshot(left.to_snapshot()) other = GameState.from_snapshot(left.to_snapshot())
assert right.to_snapshot() == left.to_snapshot() assert other.to_snapshot() == left.to_snapshot()
restored = FastGameState.from_snapshot(right.to_snapshot()) restored = GameState.from_snapshot(other.to_snapshot())
assert restored.to_snapshot() == right.to_snapshot() assert restored.to_snapshot() == other.to_snapshot()
def test_fast_from_snapshot_rejects_oversized_regions_before_write() -> None: def test_game_state_from_snapshot_rejects_oversized_regions_before_write() -> None:
config = LostCitiesConfig() config = LostCitiesConfig()
state = GameState.new_game(config, seed=3) state = GameState.new_game(config, seed=3)
deck_snapshot = state.to_snapshot() deck_snapshot = state.to_snapshot()
deck_snapshot["deck"] = [_card(0, 1)] * (config.deck_size + 1) deck_snapshot["deck"] = [_card(0, 1)] * (config.deck_size + 1)
with pytest.raises(ValueError, match="deck snapshot exceeds capacity"): with pytest.raises(ValueError, match="deck snapshot exceeds capacity"):
FastGameState.from_snapshot(deck_snapshot) GameState.from_snapshot(deck_snapshot)
hand_snapshot = state.to_snapshot() hand_snapshot = state.to_snapshot()
hand_snapshot["hands"][0] = [_card(0, 1)] * (config.hand_size + 1) hand_snapshot["hands"][0] = [_card(0, 1)] * (config.hand_size + 1)
with pytest.raises(ValueError, match="hand 0 snapshot exceeds hand_size"): with pytest.raises(ValueError, match="hand 0 snapshot exceeds hand_size"):
FastGameState.from_snapshot(hand_snapshot) GameState.from_snapshot(hand_snapshot)
expedition_snapshot = state.to_snapshot() expedition_snapshot = state.to_snapshot()
expedition_snapshot["expeditions"][0][0] = [_card(0, 1)] * ( expedition_snapshot["expeditions"][0][0] = [_card(0, 1)] * (
config.n_ranks + config.n_handshakes + 1 config.n_ranks + config.n_handshakes + 1
) )
with pytest.raises(ValueError, match="expedition 0/0 snapshot exceeds capacity"): with pytest.raises(ValueError, match="expedition 0/0 snapshot exceeds capacity"):
FastGameState.from_snapshot(expedition_snapshot) GameState.from_snapshot(expedition_snapshot)
discard_snapshot = state.to_snapshot() discard_snapshot = state.to_snapshot()
discard_snapshot["discards"][0] = [_card(0, 1)] * (config.n_ranks + config.n_handshakes + 1) discard_snapshot["discards"][0] = [_card(0, 1)] * (config.n_ranks + config.n_handshakes + 1)
with pytest.raises(ValueError, match="discard 0 snapshot exceeds capacity"): with pytest.raises(ValueError, match="discard 0 snapshot exceeds capacity"):
FastGameState.from_snapshot(discard_snapshot) GameState.from_snapshot(discard_snapshot)
def test_fast_validate_invariants_rejects_bad_expedition_order() -> None: def test_game_state_validate_invariants_rejects_bad_expedition_order() -> None:
config = LostCitiesConfig() config = LostCitiesConfig()
snapshot = FastGameState.new_game(config, seed=4).to_snapshot() snapshot = GameState.new_game(config, seed=4).to_snapshot()
snapshot["deck"].extend( snapshot["deck"].extend(
[ [
_card(0, 2), _card(0, 2),
@@ -123,10 +121,10 @@ def test_fast_validate_invariants_rejects_bad_expedition_order() -> None:
] ]
with pytest.raises(ValueError, match="expedition is not strictly increasing"): with pytest.raises(ValueError, match="expedition is not strictly increasing"):
FastGameState.from_snapshot(snapshot) GameState.from_snapshot(snapshot)
def test_fast_pending_discard_sequence_is_deterministic() -> None: def test_game_state_pending_discard_sequence_is_deterministic() -> None:
snapshot = _snapshot( snapshot = _snapshot(
hands=[ hands=[
[_card(0, 1)], [_card(0, 1)],
@@ -135,29 +133,29 @@ def test_fast_pending_discard_sequence_is_deterministic() -> None:
deck=[_card(2, 1), _card(3, 1)], deck=[_card(2, 1), _card(3, 1)],
) )
left = GameState.from_snapshot(snapshot) left = GameState.from_snapshot(snapshot)
right = FastGameState.from_snapshot(snapshot) other = GameState.from_snapshot(snapshot)
left.apply_action(1) left.apply_action(1)
right.apply_action(1) other.apply_action(1)
assert right.to_snapshot() == left.to_snapshot() assert other.to_snapshot() == left.to_snapshot()
assert right.legal_draw_mask() == left.legal_draw_mask() assert other.legal_draw_mask() == left.legal_draw_mask()
assert right.legal_draw_mask()[1] is False assert other.legal_draw_mask()[1] is False
left.apply_action(0) left.apply_action(0)
right.apply_action(0) other.apply_action(0)
left.apply_action(1) left.apply_action(1)
right.apply_action(1) other.apply_action(1)
left.apply_action(0) left.apply_action(0)
right.apply_action(0) other.apply_action(0)
left.apply_action(1) left.apply_action(1)
right.apply_action(1) other.apply_action(1)
assert right.to_snapshot() == left.to_snapshot() assert other.to_snapshot() == left.to_snapshot()
assert right.legal_draw_mask() == left.legal_draw_mask() assert other.legal_draw_mask() == left.legal_draw_mask()
assert right.legal_draw_mask()[1] is True assert other.legal_draw_mask()[1] is True
def test_fast_terminal_edges_are_deterministic() -> None: def test_game_state_terminal_edges_are_deterministic() -> None:
last_draw_snapshot = _snapshot( last_draw_snapshot = _snapshot(
deck=[_card(1, 1)], deck=[_card(1, 1)],
hands=[ hands=[
@@ -170,15 +168,15 @@ def test_fast_terminal_edges_are_deterministic() -> None:
for card in remaining_deck: for card in remaining_deck:
last_draw_snapshot["discards"][card["color"]].append(card) last_draw_snapshot["discards"][card["color"]].append(card)
left = GameState.from_snapshot(last_draw_snapshot) left = GameState.from_snapshot(last_draw_snapshot)
right = FastGameState.from_snapshot(last_draw_snapshot) other = GameState.from_snapshot(last_draw_snapshot)
left.apply_action(1) left.apply_action(1)
right.apply_action(1) other.apply_action(1)
left.apply_action(0) left.apply_action(0)
right.apply_action(0) other.apply_action(0)
assert right.to_snapshot() == left.to_snapshot() assert other.to_snapshot() == left.to_snapshot()
assert right.terminal is True assert other.terminal is True
defensive_snapshot = { defensive_snapshot = {
"config": LostCitiesConfig().to_snapshot(), "config": LostCitiesConfig().to_snapshot(),
@@ -193,16 +191,16 @@ def test_fast_terminal_edges_are_deterministic() -> None:
"terminal": False, "terminal": False,
} }
left = GameState.from_snapshot(defensive_snapshot, validate=False) left = GameState.from_snapshot(defensive_snapshot, validate=False)
right = FastGameState.from_snapshot(defensive_snapshot, validate=False) other = GameState.from_snapshot(defensive_snapshot, validate=False)
left.apply_action(1) left.apply_action(1)
right.apply_action(1) other.apply_action(1)
assert right.to_snapshot() == left.to_snapshot() assert other.to_snapshot() == left.to_snapshot()
assert right.terminal is True assert other.terminal is True
def test_fast_last_numeric_legality_edges() -> None: def test_game_state_last_numeric_legality_edges() -> None:
handshake_snapshot = _snapshot( handshake_snapshot = _snapshot(
hands=[ hands=[
[_card(0, 1)], [_card(0, 1)],
@@ -214,9 +212,9 @@ def test_fast_last_numeric_legality_edges() -> None:
], ],
) )
left = GameState.from_snapshot(handshake_snapshot) left = GameState.from_snapshot(handshake_snapshot)
right = FastGameState.from_snapshot(handshake_snapshot) other = GameState.from_snapshot(handshake_snapshot)
assert right.legal_card_mask() == left.legal_card_mask() assert other.legal_card_mask() == left.legal_card_mask()
assert right.legal_card_mask()[0] is True assert other.legal_card_mask()[0] is True
numeric_snapshot = _snapshot( numeric_snapshot = _snapshot(
hands=[ hands=[
@@ -229,14 +227,14 @@ def test_fast_last_numeric_legality_edges() -> None:
], ],
) )
left = GameState.from_snapshot(numeric_snapshot) left = GameState.from_snapshot(numeric_snapshot)
right = FastGameState.from_snapshot(numeric_snapshot) other = GameState.from_snapshot(numeric_snapshot)
assert right.legal_card_mask() == left.legal_card_mask() assert other.legal_card_mask() == left.legal_card_mask()
assert right.legal_card_mask()[0] is False assert other.legal_card_mask()[0] is False
assert right.legal_card_mask()[2] is False assert other.legal_card_mask()[2] is False
assert right.legal_card_mask()[4] is True assert other.legal_card_mask()[4] is True
def test_fast_score_cache_and_undo_restore_snapshot() -> None: def test_game_state_score_cache_and_undo_restore_snapshot() -> None:
snapshot = _snapshot( snapshot = _snapshot(
hands=[ hands=[
[_card(0, 7)], [_card(0, 7)],
@@ -263,30 +261,30 @@ def test_fast_score_cache_and_undo_restore_snapshot() -> None:
], ],
) )
left = GameState.from_snapshot(snapshot) left = GameState.from_snapshot(snapshot)
right = FastGameState.from_snapshot(snapshot) other = GameState.from_snapshot(snapshot)
before = right.to_snapshot() before = other.to_snapshot()
assert right.expedition_score(0, 0) == left.expedition_score(0, 0) assert other.expedition_score(0, 0) == left.expedition_score(0, 0)
assert right.total_score(0) == left.total_score(0) assert other.total_score(0) == left.total_score(0)
undo = right.apply_action_with_undo(0) undo = other.apply_action_with_undo(0)
left.apply_action(0) left.apply_action(0)
assert right.to_snapshot() == left.to_snapshot() assert other.to_snapshot() == left.to_snapshot()
assert right.expedition_score(0, 0) == left.expedition_score(0, 0) assert other.expedition_score(0, 0) == left.expedition_score(0, 0)
assert right.total_score(0) == left.total_score(0) assert other.total_score(0) == left.total_score(0)
right.undo_action(undo) other.undo_action(undo)
assert right.to_snapshot() == before assert other.to_snapshot() == before
assert right.total_score(0) == GameState.from_snapshot(before).total_score(0) assert other.total_score(0) == GameState.from_snapshot(before).total_score(0)
def test_fast_discard_draw_push_pop_restores_snapshot() -> None: def test_game_state_discard_draw_push_pop_restores_snapshot() -> None:
snapshot = _snapshot( snapshot = _snapshot(
hands=[[], [_card(1, 1)]], hands=[[], [_card(1, 1)]],
discards=[[_card(0, 1)], [], [], [], []], discards=[[_card(0, 1)], [], [], [], []],
phase="draw", phase="draw",
) )
state = FastGameState.from_snapshot(snapshot) state = GameState.from_snapshot(snapshot)
before = state.to_snapshot() before = state.to_snapshot()
assert state.push_action(1) == 1 assert state.push_action(1) == 1
@@ -295,66 +293,66 @@ def test_fast_discard_draw_push_pop_restores_snapshot() -> None:
assert state.to_snapshot() == before assert state.to_snapshot() == before
def test_fast_random_action_sequence_is_deterministic() -> None: def test_game_state_random_action_sequence_is_deterministic() -> None:
config = LostCitiesConfig() config = LostCitiesConfig()
for seed in range(48): for seed in range(48):
left = GameState.new_game(config, seed=seed) left = GameState.new_game(config, seed=seed)
right = FastGameState.new_game(config, seed=seed) other = GameState.new_game(config, seed=seed)
rng = random.Random(seed ^ 0xF457) rng = random.Random(seed ^ 0xF457)
steps = 0 steps = 0
while True: while True:
assert right.to_snapshot() == left.to_snapshot() assert other.to_snapshot() == left.to_snapshot()
assert right.unified_legal_mask() == left.unified_legal_mask() assert other.unified_legal_mask() == left.unified_legal_mask()
assert right.unified_legal_actions() == [ assert other.unified_legal_actions() == [
index for index, is_legal in enumerate(left.unified_legal_mask()) if is_legal index for index, is_legal in enumerate(left.unified_legal_mask()) if is_legal
] ]
assert right.score_diff(0) == left.score_diff(0) assert other.score_diff(0) == left.score_diff(0)
if left.terminal: if left.terminal:
break break
legal = [index for index, is_legal in enumerate(left.unified_legal_mask()) if is_legal] legal = [index for index, is_legal in enumerate(left.unified_legal_mask()) if is_legal]
action = rng.choice(legal) action = rng.choice(legal)
left.apply_unified_action(action) left.apply_unified_action(action)
right.apply_unified_action(action) other.apply_unified_action(action)
steps += 1 steps += 1
assert steps < 1000 assert steps < 1000
def test_fast_random_bot_self_play_is_deterministic() -> None: def test_game_state_random_bot_self_play_is_deterministic() -> None:
config = LostCitiesConfig() config = LostCitiesConfig()
for seed in range(32): for seed in range(32):
left = GameState.new_game(config, seed=seed) left = GameState.new_game(config, seed=seed)
right = FastGameState.new_game(config, seed=seed) other = GameState.new_game(config, seed=seed)
left_bots = [RandomBot(seed=seed * 2), RandomBot(seed=seed * 2 + 1)] left_bots = [RandomBot(seed=seed * 2), RandomBot(seed=seed * 2 + 1)]
right_bots = [RandomBot(seed=seed * 2), RandomBot(seed=seed * 2 + 1)] other_bots = [RandomBot(seed=seed * 2), RandomBot(seed=seed * 2 + 1)]
steps = 0 steps = 0
while True: while True:
assert right.to_snapshot() == left.to_snapshot() assert other.to_snapshot() == left.to_snapshot()
if left.terminal: if left.terminal:
break break
player = left.current_player player = left.current_player
assert right.current_player == player assert other.current_player == player
left_action = left_bots[player].act(left) left_action = left_bots[player].act(left)
right_action = right_bots[player].act({"legal_mask": right.legal_mask()}) other_action = other_bots[player].act({"legal_mask": other.legal_mask()})
assert right_action == left_action assert other_action == left_action
left.apply_action(left_action) left.apply_action(left_action)
right.apply_action(right_action) other.apply_action(other_action)
steps += 1 steps += 1
assert steps < 1000 assert steps < 1000
assert right.total_score(0) == left.total_score(0) assert other.total_score(0) == left.total_score(0)
assert right.total_score(1) == left.total_score(1) assert other.total_score(1) == left.total_score(1)
assert right.score_diff(0) == left.score_diff(0) assert other.score_diff(0) == left.score_diff(0)
def test_fast_apply_undo_restores_every_legal_action() -> None: def test_game_state_apply_undo_restores_every_legal_action() -> None:
config = LostCitiesConfig() config = LostCitiesConfig()
for seed in range(32): for seed in range(32):
state = FastGameState.new_game(config, seed=seed) state = GameState.new_game(config, seed=seed)
rng = random.Random(seed ^ 0xFA57A11) rng = random.Random(seed ^ 0xFA57A11)
steps = 0 steps = 0
@@ -373,10 +371,10 @@ def test_fast_apply_undo_restores_every_legal_action() -> None:
assert steps < 1000 assert steps < 1000
def test_fast_push_pop_action_restores_nested_sequence() -> None: def test_game_state_push_pop_action_restores_nested_sequence() -> None:
config = LostCitiesConfig() config = LostCitiesConfig()
for seed in range(32): for seed in range(32):
state = FastGameState.new_game(config, seed=seed) state = GameState.new_game(config, seed=seed)
rng = random.Random(seed ^ 0x517ACC) rng = random.Random(seed ^ 0x517ACC)
before = state.to_snapshot() before = state.to_snapshot()
actions: list[int] = [] actions: list[int] = []
@@ -397,4 +395,4 @@ def test_fast_push_pop_action_restores_nested_sequence() -> None:
assert state.to_snapshot() == before assert state.to_snapshot() == before
with pytest.raises(ValueError, match="undo stack is empty"): with pytest.raises(ValueError, match="undo stack is empty"):
FastGameState.new_game(config, seed=1).pop_action() GameState.new_game(config, seed=1).pop_action()