From 87634304754c16ffd84a899e455a45215244ba68 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 22:42:09 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B2=8C=EC=9E=84=20=EC=97=94=EC=A7=84?= =?UTF-8?q?=EC=9D=84=20game=20=EB=AA=A8=EB=93=88=EB=A1=9C=20=ED=86=B5?= =?UTF-8?q?=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 2 - setup.py | 4 - .../games/classic/__init__.py | 2 +- .../games/classic/bots/base.py | 2 +- .../games/classic/bots/heuristic.py | 3 +- .../games/classic/bots/passive.py | 2 +- .../games/classic/engines/__init__.py | 5 - .../games/classic/engines/cards.py | 31 - .../games/classic/engines/fast.pyx | 1151 ----------------- src/coolrl_lost_cities/games/classic/env.py | 3 +- .../games/classic/evaluation.py | 3 +- .../classic/{engines/fast.pxd => game.pxd} | 4 +- src/coolrl_lost_cities/games/classic/game.pyx | 1150 +++++++++++++++- .../games/classic/interfaces.py | 2 +- .../games/classic/pygame_pvp.py | 3 +- .../games/classic/snapshots.py | 3 +- ...test_fast_parity.py => test_game_state.py} | 174 ++- 17 files changed, 1242 insertions(+), 1302 deletions(-) delete mode 100644 src/coolrl_lost_cities/games/classic/engines/__init__.py delete mode 100644 src/coolrl_lost_cities/games/classic/engines/cards.py delete mode 100644 src/coolrl_lost_cities/games/classic/engines/fast.pyx rename src/coolrl_lost_cities/games/classic/{engines/fast.pxd => game.pxd} (98%) rename tests/games/classic/{engines/test_fast_parity.py => test_game_state.py} (65%) diff --git a/pyproject.toml b/pyproject.toml index f9fe393..605b1a3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,8 +43,6 @@ include = ["coolrl_lost_cities*"] "assets/*.json", "fixtures/*.json", "docs/*.md", -] -"coolrl_lost_cities.games.classic.engines" = [ "*.pxd", "*.pyx", ] diff --git a/setup.py b/setup.py index e0a138f..8aebd38 100644 --- a/setup.py +++ b/setup.py @@ -14,10 +14,6 @@ extensions = cythonize( "coolrl_lost_cities.games.classic.game", ["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, compiler_directives={ diff --git a/src/coolrl_lost_cities/games/classic/__init__.py b/src/coolrl_lost_cities/games/classic/__init__.py index 58db0ae..3dab106 100644 --- a/src/coolrl_lost_cities/games/classic/__init__.py +++ b/src/coolrl_lost_cities/games/classic/__init__.py @@ -5,7 +5,6 @@ from .bots import ( available_bot_names, build_bot, ) -from .engines import FastGameState as GameState from .env import LostCitiesEnv from .evaluation import ( GameResult, @@ -18,6 +17,7 @@ from .evaluation import ( play_match, ) from .game import ( + GameState, IllegalMoveError, LostCitiesConfig, classic_config, diff --git a/src/coolrl_lost_cities/games/classic/bots/base.py b/src/coolrl_lost_cities/games/classic/bots/base.py index 7efd22b..8b36c43 100644 --- a/src/coolrl_lost_cities/games/classic/bots/base.py +++ b/src/coolrl_lost_cities/games/classic/bots/base.py @@ -1,6 +1,6 @@ from __future__ import annotations -from ..engines import FastGameState as GameState +from ..game import GameState from ..interfaces import BotInput, Snapshot try: diff --git a/src/coolrl_lost_cities/games/classic/bots/heuristic.py b/src/coolrl_lost_cities/games/classic/bots/heuristic.py index 0df4af7..f1a41af 100644 --- a/src/coolrl_lost_cities/games/classic/bots/heuristic.py +++ b/src/coolrl_lost_cities/games/classic/bots/heuristic.py @@ -4,8 +4,7 @@ import logging from dataclasses import dataclass from functools import lru_cache -from ..engines import FastGameState as GameState -from ..game import Card, LostCitiesConfig +from ..game import Card, GameState, LostCitiesConfig from ..interfaces import BotInput, LostCitiesBot from .base import first_legal, legal_from_obs diff --git a/src/coolrl_lost_cities/games/classic/bots/passive.py b/src/coolrl_lost_cities/games/classic/bots/passive.py index ccda7bc..89dc565 100644 --- a/src/coolrl_lost_cities/games/classic/bots/passive.py +++ b/src/coolrl_lost_cities/games/classic/bots/passive.py @@ -1,6 +1,6 @@ from __future__ import annotations -from ..engines import FastGameState as GameState +from ..game import GameState from ..interfaces import BotInput, Snapshot from .base import first_legal, legal_from_obs diff --git a/src/coolrl_lost_cities/games/classic/engines/__init__.py b/src/coolrl_lost_cities/games/classic/engines/__init__.py deleted file mode 100644 index eeca280..0000000 --- a/src/coolrl_lost_cities/games/classic/engines/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from __future__ import annotations - -from .fast import FastGameState - -__all__ = ["FastGameState"] diff --git a/src/coolrl_lost_cities/games/classic/engines/cards.py b/src/coolrl_lost_cities/games/classic/engines/cards.py deleted file mode 100644 index 5ec5947..0000000 --- a/src/coolrl_lost_cities/games/classic/engines/cards.py +++ /dev/null @@ -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}") diff --git a/src/coolrl_lost_cities/games/classic/engines/fast.pyx b/src/coolrl_lost_cities/games/classic/engines/fast.pyx deleted file mode 100644 index 11c78d1..0000000 --- a/src/coolrl_lost_cities/games/classic/engines/fast.pyx +++ /dev/null @@ -1,1151 +0,0 @@ -# cython: language_level=3, boundscheck=False, wraparound=False, cdivision=True, initializedcheck=False -"""C-array based experimental Lost Cities classic engine.""" - -from collections import Counter -import random - -from libc.string cimport memcpy -from libc.stdlib cimport free, malloc, realloc - -from ..game import Card, IllegalMoveError, LostCitiesConfig, config_from_mapping - - -cdef inline int _phase_card(): - return 0 - - -cdef inline int _phase_draw(): - return 1 - - -cdef class FastGameState: - def __cinit__(self): - self.deck_cards = NULL - self.hand_cards = NULL - self.expedition_cards = NULL - self.expedition_lens = NULL - self.discard_cards = NULL - self.discard_lens = NULL - self.last_numeric_ranks = NULL - self.handshake_counts = NULL - self.numeric_sums = NULL - self.expedition_scores = NULL - self.undo_stack = NULL - - def __init__(self, config=None): - config = config or LostCitiesConfig() - config.validate() - self._configure(config) - - def __dealloc__(self): - if self.deck_cards != NULL: - free(self.deck_cards) - if self.hand_cards != NULL: - free(self.hand_cards) - if self.expedition_cards != NULL: - free(self.expedition_cards) - if self.expedition_lens != NULL: - free(self.expedition_lens) - if self.discard_cards != NULL: - free(self.discard_cards) - if self.discard_lens != NULL: - free(self.discard_lens) - if self.last_numeric_ranks != NULL: - free(self.last_numeric_ranks) - if self.handshake_counts != NULL: - free(self.handshake_counts) - if self.numeric_sums != NULL: - free(self.numeric_sums) - if self.expedition_scores != NULL: - free(self.expedition_scores) - if self.undo_stack != NULL: - free(self.undo_stack) - - cdef void _configure(self, object config) except *: - self.config = config - self.n_colors = int(config.n_colors) - self.n_ranks = int(config.n_ranks) - self.min_rank = int(config.min_rank) - self.n_handshakes = int(config.n_handshakes) - self.hand_size = int(config.hand_size) - self.expedition_penalty = int(config.expedition_penalty) - self.bonus_threshold = int(config.bonus_threshold) - self.bonus_amount = int(config.bonus_amount) - self.total_cards = int(config.deck_size) - self.cards_per_color = self.n_ranks + self.n_handshakes - self.stride = self.n_ranks + 1 - - self.deck_cards = malloc(self.total_cards * sizeof(int)) - self.hand_cards = malloc(2 * self.hand_size * sizeof(int)) - self.expedition_cards = malloc( - 2 * self.n_colors * self.cards_per_color * sizeof(int) - ) - self.expedition_lens = malloc(2 * self.n_colors * sizeof(int)) - self.discard_cards = malloc(self.n_colors * self.cards_per_color * sizeof(int)) - self.discard_lens = malloc(self.n_colors * sizeof(int)) - self.last_numeric_ranks = malloc(2 * self.n_colors * sizeof(int)) - self.handshake_counts = malloc(2 * self.n_colors * sizeof(int)) - self.numeric_sums = malloc(2 * self.n_colors * sizeof(int)) - self.expedition_scores = malloc(2 * self.n_colors * sizeof(int)) - self.undo_stack_capacity = 2 * self.total_cards + 16 - self.undo_stack = malloc( - self.undo_stack_capacity * sizeof(UndoRecord) - ) - if ( - self.deck_cards == NULL - or self.hand_cards == NULL - or self.expedition_cards == NULL - or self.expedition_lens == NULL - or self.discard_cards == NULL - or self.discard_lens == NULL - or self.last_numeric_ranks == NULL - or self.handshake_counts == NULL - or self.numeric_sums == NULL - or self.expedition_scores == NULL - or self.undo_stack == NULL - ): - raise MemoryError() - self._clear() - - cdef void _clear(self) noexcept: - cdef int i - self.deck_len = 0 - self.hand_lens[0] = 0 - self.hand_lens[1] = 0 - for i in range(2 * self.n_colors): - self.expedition_lens[i] = 0 - self.last_numeric_ranks[i] = 0 - self.handshake_counts[i] = 0 - self.numeric_sums[i] = 0 - self.expedition_scores[i] = 0 - for i in range(self.n_colors): - self.discard_lens[i] = 0 - self.total_scores[0] = 0 - self.total_scores[1] = 0 - self.undo_stack_len = 0 - self.current_player = 0 - self.phase_id = _phase_card() - self.pending_discarded_color = -1 - self.turn_count = 0 - self.terminal = False - - @classmethod - def empty(cls, config=None): - return cls(config or LostCitiesConfig()) - - @classmethod - def new_game(cls, config=None, *, seed=None): - config = config or LostCitiesConfig() - config.validate() - deck = _build_encoded_deck(config) - rng = random.Random(config.seed if seed is None else seed) - rng.shuffle(deck) - return cls.new_game_from_deck(deck, config) - - @classmethod - def new_game_from_deck(cls, deck, config=None): - config = config or LostCitiesConfig() - config.validate() - encoded = [_encode_card_snapshot(card, config) for card in deck] - if len(encoded) != int(config.deck_size): - raise ValueError( - f"deck length must be {config.deck_size}, got {len(encoded)}" - ) - if Counter(encoded) != Counter(_build_encoded_deck(config)): - raise ValueError("deck must contain exactly the cards defined by config") - - cdef int i - cdef int player - cdef FastGameState state = cls(config) - state.deck_len = len(encoded) - for i, card in enumerate(encoded): - state.deck_cards[i] = card - for _ in range(config.hand_size): - for player in range(2): - state.deck_len -= 1 - state.hand_cards[state._hand_index(player, state.hand_lens[player])] = state.deck_cards[ - state.deck_len - ] - state.hand_lens[player] += 1 - state.validate_invariants() - return state - - @classmethod - def from_snapshot(cls, snapshot, *, validate=True): - config = config_from_mapping(snapshot["config"]) - cdef FastGameState state = cls(config) - cdef int player - cdef int color - cdef int index - cdef list cards - - cards = [_encode_card_snapshot(card, config) for card in snapshot["deck"]] - if len(cards) > state.total_cards: - raise ValueError( - f"deck snapshot exceeds capacity {state.total_cards}: {len(cards)}" - ) - state.deck_len = len(cards) - for index, card in enumerate(cards): - state.deck_cards[index] = card - - for player in range(2): - cards = [ - _encode_card_snapshot(card, config) for card in snapshot["hands"][player] - ] - if len(cards) > state.hand_size: - raise ValueError( - f"hand {player} snapshot exceeds hand_size " - f"{state.hand_size}: {len(cards)}" - ) - state.hand_lens[player] = len(cards) - for index, card in enumerate(cards): - state.hand_cards[state._hand_index(player, index)] = card - - for player in range(2): - for color in range(state.n_colors): - cards = [ - _encode_card_snapshot(card, config) - for card in snapshot["expeditions"][player][color] - ] - if len(cards) > state.cards_per_color: - raise ValueError( - f"expedition {player}/{color} snapshot exceeds capacity " - f"{state.cards_per_color}: {len(cards)}" - ) - state.expedition_lens[state._expedition_len_index(player, color)] = len(cards) - for index, card in enumerate(cards): - state.expedition_cards[state._expedition_index(player, color, index)] = card - - for color in range(state.n_colors): - cards = [_encode_card_snapshot(card, config) for card in snapshot["discards"][color]] - if len(cards) > state.cards_per_color: - raise ValueError( - f"discard {color} snapshot exceeds capacity " - f"{state.cards_per_color}: {len(cards)}" - ) - state.discard_lens[color] = len(cards) - for index, card in enumerate(cards): - state.discard_cards[state._discard_index(color, index)] = card - - state.current_player = int(snapshot.get("current_player", 0)) - state.phase = snapshot.get("phase", "card") - pending = snapshot.get("pending_discarded_color") - state.pending_discarded_color = -1 if pending is None else int(pending) - state.turn_count = int(snapshot.get("turn_count", 0)) - state.terminal = bool(snapshot.get("terminal", False)) - state._recompute_score_caches() - if validate: - state.validate_invariants() - return state - - @property - def phase(self): - return "card" if self.phase_id == _phase_card() else "draw" - - @phase.setter - def phase(self, value): - if value == "card": - self.phase_id = _phase_card() - elif value == "draw": - self.phase_id = _phase_draw() - else: - raise ValueError(f"invalid phase: {value!r}") - - @property - def card_action_size(self): - return 2 * self.hand_size - - @property - def draw_action_size(self): - return 1 + self.n_colors - - @property - def action_size(self): - return self.card_action_size + self.draw_action_size - - @property - def deck(self): - return [self._card_obj(self.deck_cards[i]) for i in range(self.deck_len)] - - @property - def hands(self): - return [ - [ - self._card_obj(self.hand_cards[self._hand_index(player, i)]) - for i in range(self.hand_lens[player]) - ] - for player in range(2) - ] - - @property - def expeditions(self): - return [ - [ - [ - self._card_obj( - self.expedition_cards[ - self._expedition_index(player, color, i) - ] - ) - for i in range( - self.expedition_lens[ - self._expedition_len_index(player, color) - ] - ) - ] - for color in range(self.n_colors) - ] - for player in range(2) - ] - - @property - def discards(self): - return [ - [ - self._card_obj(self.discard_cards[self._discard_index(color, i)]) - for i in range(self.discard_lens[color]) - ] - for color in range(self.n_colors) - ] - - def to_snapshot(self): - return { - "config": self.config.to_snapshot(), - "deck": [self._card_snapshot(self.deck_cards[i]) for i in range(self.deck_len)], - "hands": [ - [ - self._card_snapshot(self.hand_cards[self._hand_index(player, i)]) - for i in range(self.hand_lens[player]) - ] - for player in range(2) - ], - "expeditions": [ - [ - [ - self._card_snapshot( - self.expedition_cards[self._expedition_index(player, color, i)] - ) - for i in range( - self.expedition_lens[ - self._expedition_len_index(player, color) - ] - ) - ] - for color in range(self.n_colors) - ] - for player in range(2) - ], - "discards": [ - [ - self._card_snapshot(self.discard_cards[self._discard_index(color, i)]) - for i in range(self.discard_lens[color]) - ] - for color in range(self.n_colors) - ], - "current_player": self.current_player, - "phase": self.phase, - "pending_discarded_color": ( - None if self.pending_discarded_color < 0 else self.pending_discarded_color - ), - "turn_count": self.turn_count, - "terminal": self.terminal, - } - - cpdef FastGameState clone(self): - cdef FastGameState other = FastGameState(self.config) - other.deck_len = self.deck_len - memcpy(other.deck_cards, self.deck_cards, self.deck_len * sizeof(int)) - memcpy(other.hand_cards, self.hand_cards, 2 * self.hand_size * sizeof(int)) - other.hand_lens[0] = self.hand_lens[0] - other.hand_lens[1] = self.hand_lens[1] - memcpy( - other.expedition_cards, - self.expedition_cards, - 2 * self.n_colors * self.cards_per_color * sizeof(int), - ) - memcpy( - other.expedition_lens, - self.expedition_lens, - 2 * self.n_colors * sizeof(int), - ) - memcpy( - other.discard_cards, - self.discard_cards, - self.n_colors * self.cards_per_color * sizeof(int), - ) - memcpy(other.discard_lens, self.discard_lens, self.n_colors * sizeof(int)) - memcpy( - other.last_numeric_ranks, - self.last_numeric_ranks, - 2 * self.n_colors * sizeof(int), - ) - memcpy( - other.handshake_counts, - self.handshake_counts, - 2 * self.n_colors * sizeof(int), - ) - memcpy(other.numeric_sums, self.numeric_sums, 2 * self.n_colors * sizeof(int)) - memcpy( - other.expedition_scores, - self.expedition_scores, - 2 * self.n_colors * sizeof(int), - ) - other.total_scores[0] = self.total_scores[0] - other.total_scores[1] = self.total_scores[1] - other.current_player = self.current_player - other.phase_id = self.phase_id - other.pending_discarded_color = self.pending_discarded_color - other.turn_count = self.turn_count - other.terminal = self.terminal - return other - - cpdef list legal_card_mask(self): - cdef list mask = [False] * (2 * self.hand_size) - cdef int slot - cdef int card - if self.terminal: - return mask - for slot in range(self.hand_lens[self.current_player]): - card = self.hand_cards[self._hand_index(self.current_player, slot)] - mask[2 * slot] = self._can_play_encoded_card_c(self.current_player, card) - mask[2 * slot + 1] = True - return mask - - cpdef list legal_draw_mask(self): - cdef list mask = [False] * (1 + self.n_colors) - cdef int color - if self.terminal: - return mask - mask[0] = self.deck_len > 0 - for color in range(self.n_colors): - mask[1 + color] = ( - self.discard_lens[color] > 0 - and (self.pending_discarded_color < 0 or color != self.pending_discarded_color) - ) - return mask - - cpdef list legal_mask(self): - if self.phase_id == _phase_card(): - return self.legal_card_mask() - return self.legal_draw_mask() - - cpdef list unified_legal_mask(self): - cdef list result - if self.phase_id == _phase_card(): - result = self.legal_card_mask() - result.extend([False] * (1 + self.n_colors)) - return result - result = [False] * (2 * self.hand_size) - result.extend(self.legal_draw_mask()) - return result - - cpdef list legal_actions(self): - cdef int* actions = malloc(self.action_size * sizeof(int)) - if actions == NULL: - raise MemoryError() - cdef int count - cdef int i - try: - count = self._legal_actions_c(actions) - return [actions[i] for i in range(count)] - finally: - free(actions) - - cpdef list unified_legal_actions(self): - cdef int* actions = malloc(self.action_size * sizeof(int)) - if actions == NULL: - raise MemoryError() - cdef int count - cdef int i - try: - count = self._unified_legal_actions_c(actions) - return [actions[i] for i in range(count)] - finally: - free(actions) - - cpdef int from_unified_action(self, int action_id): - cdef int card_action_size = 2 * self.hand_size - cdef int action_size = card_action_size + 1 + self.n_colors - if action_id < 0 or action_id >= action_size: - raise IllegalMoveError(f"action {action_id} is out of range") - if self.phase_id == _phase_card(): - if action_id >= card_action_size: - raise IllegalMoveError( - f"card action {action_id} is illegal during card phase" - ) - return action_id - if action_id < card_action_size: - raise IllegalMoveError( - f"card action {action_id} is illegal during draw phase" - ) - return action_id - card_action_size - - def to_unified_action(self, int action_id, phase=None): - cdef object p = self.phase if phase is None else phase - if p == "card": - if action_id < 0 or action_id >= 2 * self.hand_size: - raise IllegalMoveError(f"card action {action_id} is out of range") - return action_id - if action_id < 0 or action_id >= 1 + self.n_colors: - raise IllegalMoveError(f"draw action {action_id} is out of range") - return 2 * self.hand_size + action_id - - cpdef apply_action(self, int action_id): - if self.terminal: - raise IllegalMoveError("game is already terminal") - if not self._is_legal_action_c(action_id): - raise IllegalMoveError( - f"illegal action {action_id} in phase {self.phase} " - f"for player {self.current_player}" - ) - self._apply_action_unchecked_c(action_id) - - cpdef apply_unified_action(self, int action_id): - self.apply_action(self.from_unified_action(action_id)) - - cpdef object apply_action_with_undo(self, int action_id): - if self.terminal: - raise IllegalMoveError("game is already terminal") - if not self._is_legal_action_c(action_id): - raise IllegalMoveError( - f"illegal action {action_id} in phase {self.phase} " - f"for player {self.current_player}" - ) - cdef UndoRecord undo - self._apply_action_with_undo_c(action_id, &undo) - return self._undo_to_tuple(&undo) - - cpdef object apply_unified_action_with_undo(self, int action_id): - return self.apply_action_with_undo(self.from_unified_action(action_id)) - - cpdef undo_action(self, object undo): - cdef UndoRecord record - self._tuple_to_undo(undo, &record) - self._undo_action_c(&record) - - cpdef int push_action(self, int action_id): - if self.terminal: - raise IllegalMoveError("game is already terminal") - if not self._is_legal_action_c(action_id): - raise IllegalMoveError( - f"illegal action {action_id} in phase {self.phase} " - f"for player {self.current_player}" - ) - return self._push_action_c(action_id) - - cpdef int push_unified_action(self, int action_id): - return self.push_action(self.from_unified_action(action_id)) - - cpdef int pop_action(self): - if self.undo_stack_len <= 0: - raise ValueError("undo stack is empty") - return self._pop_action_c() - - cpdef bint can_play_encoded_card(self, int player, int card): - cdef int color = self._card_color(card) - cdef int rank = self._card_rank(card) - if color < 0 or color >= self.n_colors: - return False - if rank < 0 or rank > self.n_ranks: - return False - if rank == 0: - return self.last_numeric_ranks[self._expedition_len_index(player, color)] == 0 - return rank > self.last_numeric_ranks[self._expedition_len_index(player, color)] - - cpdef int last_numeric_rank(self, int player, int color): - return self.last_numeric_ranks[self._expedition_len_index(player, color)] - - def has_numeric(self, int player, int color): - return self.last_numeric_rank(player, color) > 0 - - def can_play_card(self, int player, object card): - return self.can_play_encoded_card(player, _encode_card_snapshot(card, self.config)) - - def hand_slots(self, player=None): - cdef int p = self.current_player if player is None else int(player) - cdef list hand = [] - cdef int i - for i in range(self.hand_lens[p]): - hand.append(self._card_obj(self.hand_cards[self._hand_index(p, i)])) - while len(hand) < self.hand_size: - hand.append(None) - return hand - - def sort_hands(self): - self.sort_hand(0) - self.sort_hand(1) - - def sort_hand(self, player=None): - cdef int p = self.current_player if player is None else int(player) - cdef int i - cdef int j - cdef int key - cdef int current - for i in range(1, self.hand_lens[p]): - key = self.hand_cards[self._hand_index(p, i)] - j = i - 1 - while j >= 0 and self.hand_cards[self._hand_index(p, j)] > key: - current = self.hand_cards[self._hand_index(p, j)] - self.hand_cards[self._hand_index(p, j + 1)] = current - j -= 1 - self.hand_cards[self._hand_index(p, j + 1)] = key - - cpdef object unified_legal_mask_np(self): - try: - import numpy as np - except ImportError as exc: # pragma: no cover - raise RuntimeError("numpy is required for unified_legal_mask_np") from exc - return np.asarray(self.unified_legal_mask(), dtype=bool) - - cpdef int expedition_score(self, int player, int color): - return self.expedition_scores[self._expedition_len_index(player, color)] - - cpdef int total_score(self, int player): - return self.total_scores[player] - - cpdef int score_diff(self, int player=0): - return self.total_score(player) - self.total_score(1 - player) - - def validate_invariants(self): - self.config.validate() - cdef int player - cdef int color - cdef int index - cdef int length - cdef int card - cdef int rank - cdef int last_rank - cdef bint seen_numeric - if self.current_player not in (0, 1): - raise ValueError("current_player must be 0 or 1") - if self.phase_id not in (_phase_card(), _phase_draw()): - raise ValueError("invalid phase") - if self.deck_len < 0 or self.deck_len > self.total_cards: - raise ValueError("deck length out of range") - if self.pending_discarded_color >= self.n_colors: - raise ValueError("pending_discarded_color is out of range") - if self.hand_lens[0] > self.hand_size or self.hand_lens[1] > self.hand_size: - raise ValueError("hand exceeds hand_size") - for color in range(self.n_colors): - if self.discard_lens[color] < 0 or self.discard_lens[color] > self.cards_per_color: - raise ValueError("discard length out of range") - for player in range(2): - if self.hand_lens[player] < 0: - raise ValueError("hand length out of range") - for color in range(self.n_colors): - length = self.expedition_lens[self._expedition_len_index(player, color)] - if length < 0 or length > self.cards_per_color: - raise ValueError("expedition length out of range") - seen_numeric = False - last_rank = 0 - for index in range(length): - card = self.expedition_cards[self._expedition_index(player, color, index)] - if self._card_color(card) != color: - raise ValueError("expedition contains wrong color") - rank = self._card_rank(card) - if rank < 0 or rank > self.n_ranks: - raise ValueError("card rank out of range") - if rank == 0: - if seen_numeric: - raise ValueError("expedition has handshake after number") - else: - seen_numeric = True - if rank <= last_rank: - raise ValueError("expedition is not strictly increasing") - last_rank = rank - if Counter(_all_cards_from_snapshot(self.to_snapshot())) != Counter( - _build_encoded_deck(self.config) - ): - raise ValueError("card conservation failed") - if self.phase_id == _phase_card() and self.pending_discarded_color >= 0: - raise ValueError("pending_discarded_color must be None during card phase") - if self.pending_discarded_color >= 0 and self.discard_lens[self.pending_discarded_color] == 0: - raise ValueError("pending discard color must have a discard pile card") - any_legal = any(self.unified_legal_mask()) - if self.terminal and any_legal: - raise ValueError("terminal state must have no legal actions") - if not self.terminal and not any_legal: - raise ValueError("non-terminal state must have at least one legal action") - - cdef bint _is_legal_action_c(self, int action_id) noexcept: - cdef int slot - cdef int color - if self.terminal: - return False - if self.phase_id == _phase_card(): - if action_id < 0 or action_id >= 2 * self.hand_size: - return False - slot = action_id // 2 - if slot >= self.hand_lens[self.current_player]: - return False - if action_id % 2 == 1: - return True - return self._can_play_encoded_card_c( - self.current_player, - self.hand_cards[self._hand_index(self.current_player, slot)], - ) - if action_id < 0 or action_id >= 1 + self.n_colors: - return False - if action_id == 0: - return self.deck_len > 0 - color = action_id - 1 - return ( - self.discard_lens[color] > 0 - and (self.pending_discarded_color < 0 or color != self.pending_discarded_color) - ) - - cdef int _legal_actions_c(self, int* out_actions) noexcept: - cdef int count = 0 - cdef int slot - cdef int color - cdef int card - if self.terminal: - return 0 - if self.phase_id == _phase_card(): - for slot in range(self.hand_lens[self.current_player]): - card = self.hand_cards[self._hand_index(self.current_player, slot)] - if self._can_play_encoded_card_c(self.current_player, card): - out_actions[count] = 2 * slot - count += 1 - out_actions[count] = 2 * slot + 1 - count += 1 - return count - if self.deck_len > 0: - out_actions[count] = 0 - count += 1 - for color in range(self.n_colors): - if ( - self.discard_lens[color] > 0 - and (self.pending_discarded_color < 0 or color != self.pending_discarded_color) - ): - out_actions[count] = 1 + color - count += 1 - return count - - cdef int _unified_legal_actions_c(self, int* out_actions) noexcept: - cdef int count = 0 - cdef int slot - cdef int color - cdef int card - cdef int card_action_size = 2 * self.hand_size - if self.terminal: - return 0 - if self.phase_id == _phase_card(): - for slot in range(self.hand_lens[self.current_player]): - card = self.hand_cards[self._hand_index(self.current_player, slot)] - if self._can_play_encoded_card_c(self.current_player, card): - out_actions[count] = 2 * slot - count += 1 - out_actions[count] = 2 * slot + 1 - count += 1 - return count - if self.deck_len > 0: - out_actions[count] = card_action_size - count += 1 - for color in range(self.n_colors): - if ( - self.discard_lens[color] > 0 - and (self.pending_discarded_color < 0 or color != self.pending_discarded_color) - ): - out_actions[count] = card_action_size + 1 + color - count += 1 - return count - - cdef bint _can_play_encoded_card_c(self, int player, int card) noexcept: - cdef int color = self._card_color(card) - cdef int rank = self._card_rank(card) - if color < 0 or color >= self.n_colors: - return False - if rank < 0 or rank > self.n_ranks: - return False - if rank == 0: - return self.last_numeric_ranks[self._expedition_len_index(player, color)] == 0 - return rank > self.last_numeric_ranks[self._expedition_len_index(player, color)] - - cdef void _fill_undo_c(self, int action_id, UndoRecord* undo) noexcept: - cdef int slot - cdef int card - cdef int color - cdef int cache_index - undo.phase_id = self.phase_id - undo.player = self.current_player - undo.action_id = action_id - undo.pending_before = self.pending_discarded_color - undo.terminal_before = self.terminal - undo.turn_count_before = self.turn_count - undo.slot = -1 - undo.play = 0 - undo.card = -1 - undo.color = -1 - undo.last_numeric_before = 0 - undo.handshake_count_before = 0 - undo.numeric_sum_before = 0 - undo.expedition_score_before = 0 - undo.total_score_before = self.total_scores[self.current_player] - if self.phase_id == _phase_card(): - slot = action_id // 2 - card = self.hand_cards[self._hand_index(self.current_player, slot)] - color = self._card_color(card) - cache_index = self._expedition_len_index(self.current_player, color) - undo.slot = slot - undo.play = action_id % 2 == 0 - undo.card = card - undo.color = color - undo.last_numeric_before = self.last_numeric_ranks[cache_index] - undo.handshake_count_before = self.handshake_counts[cache_index] - undo.numeric_sum_before = self.numeric_sums[cache_index] - undo.expedition_score_before = self.expedition_scores[cache_index] - elif action_id == 0: - undo.card = self.deck_cards[self.deck_len - 1] - else: - color = action_id - 1 - undo.color = color - undo.card = self.discard_cards[self._discard_index(color, self.discard_lens[color] - 1)] - - cdef void _apply_action_with_undo_c(self, int action_id, UndoRecord* undo) except *: - self._fill_undo_c(action_id, undo) - self._apply_action_unchecked_c(action_id) - - cdef void _apply_action_unchecked_c(self, int action_id) except *: - if self.phase_id == _phase_card(): - self._apply_card_action(action_id) - else: - self._apply_draw_action(action_id) - - cdef void _ensure_undo_capacity_c(self) except *: - cdef int new_capacity - cdef UndoRecord* grown - if self.undo_stack_len < self.undo_stack_capacity: - return - new_capacity = self.undo_stack_capacity * 2 - grown = realloc( - self.undo_stack, - new_capacity * sizeof(UndoRecord), - ) - if grown == NULL: - raise MemoryError() - self.undo_stack = grown - self.undo_stack_capacity = new_capacity - - cdef int _push_action_c(self, int action_id) except *: - self._ensure_undo_capacity_c() - self._apply_action_with_undo_c( - action_id, - &self.undo_stack[self.undo_stack_len], - ) - self.undo_stack_len += 1 - return self.undo_stack_len - - cdef int _pop_action_c(self) except *: - cdef int action_id - self.undo_stack_len -= 1 - action_id = self.undo_stack[self.undo_stack_len].action_id - self._undo_action_c(&self.undo_stack[self.undo_stack_len]) - return action_id - - cdef object _undo_to_tuple(self, UndoRecord* undo): - return ( - "card" if undo.phase_id == _phase_card() else "draw", - undo.player, - undo.action_id, - undo.pending_before, - undo.terminal_before, - undo.turn_count_before, - undo.slot, - undo.play, - undo.card, - undo.color, - undo.last_numeric_before, - undo.handshake_count_before, - undo.numeric_sum_before, - undo.expedition_score_before, - undo.total_score_before, - ) - - cdef void _tuple_to_undo(self, object data, UndoRecord* undo) except *: - cdef str phase = data[0] - if phase == "card": - undo.phase_id = _phase_card() - elif phase == "draw": - undo.phase_id = _phase_draw() - else: - raise ValueError(f"invalid undo phase: {phase!r}") - undo.player = data[1] - undo.action_id = data[2] - undo.pending_before = data[3] - undo.terminal_before = data[4] - undo.turn_count_before = data[5] - undo.slot = data[6] - undo.play = data[7] - undo.card = data[8] - undo.color = data[9] - undo.last_numeric_before = data[10] - undo.handshake_count_before = data[11] - undo.numeric_sum_before = data[12] - undo.expedition_score_before = data[13] - undo.total_score_before = data[14] - - cdef void _apply_card_action(self, int action_id) except *: - cdef int slot = action_id // 2 - cdef bint play = action_id % 2 == 0 - cdef int player = self.current_player - cdef int card = self.hand_cards[self._hand_index(player, slot)] - cdef int color = self._card_color(card) - cdef int rank = self._card_rank(card) - cdef int i - cdef int length_index - cdef int old_score - cdef int new_score - for i in range(slot, self.hand_lens[player] - 1): - self.hand_cards[self._hand_index(player, i)] = self.hand_cards[self._hand_index(player, i + 1)] - self.hand_lens[player] -= 1 - if play: - length_index = self._expedition_len_index(player, color) - old_score = self.expedition_scores[length_index] - self.expedition_cards[self._expedition_index(player, color, self.expedition_lens[length_index])] = card - self.expedition_lens[length_index] += 1 - if rank == 0: - self.handshake_counts[length_index] += 1 - else: - self.numeric_sums[length_index] += self.min_rank + rank - 1 - self.last_numeric_ranks[length_index] = rank - new_score = self._score_from_summary_c( - self.expedition_lens[length_index], - self.handshake_counts[length_index], - self.numeric_sums[length_index], - ) - self.expedition_scores[length_index] = new_score - self.total_scores[player] += new_score - old_score - else: - self.discard_cards[self._discard_index(color, self.discard_lens[color])] = card - self.discard_lens[color] += 1 - self.pending_discarded_color = color - self.phase_id = _phase_draw() - # Defensive terminal branch for externally constructed states where the - # deck was already empty before the card phase action. - if self.deck_len == 0 and not self._has_any_legal_draw(): - self.terminal = True - - cdef void _apply_draw_action(self, int action_id) except *: - cdef int player = self.current_player - cdef int card - cdef int color - if action_id == 0: - self.deck_len -= 1 - card = self.deck_cards[self.deck_len] - else: - color = action_id - 1 - self.discard_lens[color] -= 1 - card = self.discard_cards[self._discard_index(color, self.discard_lens[color])] - self.hand_cards[self._hand_index(player, self.hand_lens[player])] = card - self.hand_lens[player] += 1 - self.pending_discarded_color = -1 - self.turn_count += 1 - if self.deck_len == 0: - self.terminal = True - return - self.current_player = 1 - self.current_player - self.phase_id = _phase_card() - - cdef void _undo_action_c(self, UndoRecord* undo) except *: - if undo.phase_id == _phase_card(): - self._undo_card_action_c(undo) - elif undo.phase_id == _phase_draw(): - self._undo_draw_action_c(undo) - else: - raise ValueError("invalid undo phase") - - cdef void _undo_card_action_c(self, UndoRecord* undo) except *: - cdef int player = undo.player - cdef int pending_before = undo.pending_before - cdef bint terminal_before = undo.terminal_before - cdef int slot = undo.slot - cdef bint play = undo.play - cdef int card = undo.card - cdef int color = self._card_color(card) - cdef int moved - cdef int i - cdef int length_index - if play: - length_index = self._expedition_len_index(player, color) - self.expedition_lens[length_index] -= 1 - moved = self.expedition_cards[self._expedition_index(player, color, self.expedition_lens[length_index])] - self.last_numeric_ranks[length_index] = undo.last_numeric_before - self.handshake_counts[length_index] = undo.handshake_count_before - self.numeric_sums[length_index] = undo.numeric_sum_before - self.expedition_scores[length_index] = undo.expedition_score_before - self.total_scores[player] = undo.total_score_before - else: - self.discard_lens[color] -= 1 - moved = self.discard_cards[self._discard_index(color, self.discard_lens[color])] - if moved != card: - raise ValueError("undo card mismatch") - for i in range(self.hand_lens[player], slot, -1): - self.hand_cards[self._hand_index(player, i)] = self.hand_cards[self._hand_index(player, i - 1)] - self.hand_cards[self._hand_index(player, slot)] = card - self.hand_lens[player] += 1 - self.current_player = player - self.phase_id = _phase_card() - self.pending_discarded_color = pending_before - self.terminal = terminal_before - - cdef void _undo_draw_action_c(self, UndoRecord* undo) except *: - cdef int player = undo.player - cdef int action_id = undo.action_id - cdef int pending_before = undo.pending_before - cdef bint terminal_before = undo.terminal_before - cdef int turn_count_before = undo.turn_count_before - cdef int card = undo.card - cdef int moved - cdef int color - self.hand_lens[player] -= 1 - moved = self.hand_cards[self._hand_index(player, self.hand_lens[player])] - if moved != card: - raise ValueError("undo draw mismatch") - if action_id == 0: - self.deck_cards[self.deck_len] = card - self.deck_len += 1 - else: - color = action_id - 1 - self.discard_cards[self._discard_index(color, self.discard_lens[color])] = card - self.discard_lens[color] += 1 - self.current_player = player - self.phase_id = _phase_draw() - self.pending_discarded_color = pending_before - self.turn_count = turn_count_before - self.terminal = terminal_before - - cdef void _recompute_score_caches(self) noexcept: - cdef int i - cdef int player - cdef int color - cdef int cache_index - cdef int length - cdef int rank - cdef int card_index - for i in range(2 * self.n_colors): - self.last_numeric_ranks[i] = 0 - self.handshake_counts[i] = 0 - self.numeric_sums[i] = 0 - self.expedition_scores[i] = 0 - self.total_scores[0] = 0 - self.total_scores[1] = 0 - for player in range(2): - for color in range(self.n_colors): - cache_index = self._expedition_len_index(player, color) - length = self.expedition_lens[cache_index] - for card_index in range(length): - rank = self._card_rank( - self.expedition_cards[ - self._expedition_index(player, color, card_index) - ] - ) - if rank == 0: - self.handshake_counts[cache_index] += 1 - else: - self.numeric_sums[cache_index] += self.min_rank + rank - 1 - if rank > self.last_numeric_ranks[cache_index]: - self.last_numeric_ranks[cache_index] = rank - self.expedition_scores[cache_index] = self._score_from_summary_c( - length, - self.handshake_counts[cache_index], - self.numeric_sums[cache_index], - ) - self.total_scores[player] += self.expedition_scores[cache_index] - - cdef inline int _score_from_summary_c( - self, - int length, - int handshakes, - int numeric_sum, - ) noexcept: - cdef int score - if length == 0: - return 0 - score = (numeric_sum + self.expedition_penalty) * (handshakes + 1) - if length >= self.bonus_threshold: - score += self.bonus_amount - return score - - cdef bint _has_any_legal_draw(self) noexcept: - cdef int color - if self.deck_len > 0: - return True - for color in range(self.n_colors): - if ( - self.discard_lens[color] > 0 - and (self.pending_discarded_color < 0 or color != self.pending_discarded_color) - ): - return True - return False - - cdef inline int _hand_index(self, int player, int slot): - return player * self.hand_size + slot - - cdef inline int _expedition_len_index(self, int player, int color): - return player * self.n_colors + color - - cdef inline int _expedition_index(self, int player, int color, int index): - return (player * self.n_colors + color) * self.cards_per_color + index - - cdef inline int _discard_index(self, int color, int index): - return color * self.cards_per_color + index - - cdef inline int _encode_card(self, int color, int rank): - return color * self.stride + rank - - cdef inline int _card_color(self, int card): - return card // self.stride - - cdef inline int _card_rank(self, int card): - return card % self.stride - - cdef object _card_snapshot(self, int card): - return {"color": self._card_color(card), "rank": self._card_rank(card)} - - cdef object _card_obj(self, int card): - return Card(self._card_color(card), self._card_rank(card)) - - -def _build_encoded_deck(config): - deck = [] - stride = int(config.n_ranks) + 1 - for color in range(int(config.n_colors)): - for _ in range(int(config.n_handshakes)): - deck.append(color * stride) - for rank in range(1, int(config.n_ranks) + 1): - deck.append(color * stride + rank) - return deck - - -def _encode_card_snapshot(data, config): - stride = int(config.n_ranks) + 1 - if isinstance(data, int): - return int(data) - if isinstance(data, dict): - return int(data["color"]) * stride + int(data["rank"]) - if isinstance(data, (list, tuple)) and len(data) == 2: - return int(data[0]) * stride + int(data[1]) - color = getattr(data, "color", None) - rank = getattr(data, "rank", None) - if color is not None and rank is not None: - return int(color) * stride + int(rank) - raise ValueError(f"invalid card snapshot: {data!r}") - - -def _all_cards_from_snapshot(snapshot): - cards = [] - config = config_from_mapping(snapshot["config"]) - for card in snapshot["deck"]: - cards.append(_encode_card_snapshot(card, config)) - for hand in snapshot["hands"]: - for card in hand: - cards.append(_encode_card_snapshot(card, config)) - for player_expeditions in snapshot["expeditions"]: - for expedition in player_expeditions: - for card in expedition: - cards.append(_encode_card_snapshot(card, config)) - for discard in snapshot["discards"]: - for card in discard: - cards.append(_encode_card_snapshot(card, config)) - return cards diff --git a/src/coolrl_lost_cities/games/classic/env.py b/src/coolrl_lost_cities/games/classic/env.py index 67e337c..88e1b04 100644 --- a/src/coolrl_lost_cities/games/classic/env.py +++ b/src/coolrl_lost_cities/games/classic/env.py @@ -1,7 +1,6 @@ from __future__ import annotations -from .engines import FastGameState as GameState -from .game import IllegalMoveError, LostCitiesConfig +from .game import GameState, IllegalMoveError, LostCitiesConfig try: import numpy as np diff --git a/src/coolrl_lost_cities/games/classic/evaluation.py b/src/coolrl_lost_cities/games/classic/evaluation.py index 0f3ce1a..4a0acc6 100644 --- a/src/coolrl_lost_cities/games/classic/evaluation.py +++ b/src/coolrl_lost_cities/games/classic/evaluation.py @@ -10,8 +10,7 @@ from typing import Any import numpy as np from .bots import available_bot_names, build_bot -from .engines import FastGameState as GameState -from .game import LostCitiesConfig, classic_config +from .game import GameState, LostCitiesConfig, classic_config from .interfaces import LostCitiesBot BotFactory = Callable[[int | None], LostCitiesBot] diff --git a/src/coolrl_lost_cities/games/classic/engines/fast.pxd b/src/coolrl_lost_cities/games/classic/game.pxd similarity index 98% rename from src/coolrl_lost_cities/games/classic/engines/fast.pxd rename to src/coolrl_lost_cities/games/classic/game.pxd index ac8b8ea..0e48d76 100644 --- a/src/coolrl_lost_cities/games/classic/engines/fast.pxd +++ b/src/coolrl_lost_cities/games/classic/game.pxd @@ -16,7 +16,7 @@ ctypedef struct UndoRecord: int total_score_before -cdef class FastGameState: +cdef class GameState: cdef public object config cdef int n_colors cdef int n_ranks @@ -56,7 +56,7 @@ cdef class FastGameState: cdef void _configure(self, object config) except * cdef void _clear(self) noexcept - cpdef FastGameState clone(self) + cpdef GameState clone(self) cpdef list legal_card_mask(self) cpdef list legal_draw_mask(self) cpdef list legal_mask(self) diff --git a/src/coolrl_lost_cities/games/classic/game.pyx b/src/coolrl_lost_cities/games/classic/game.pyx index 9ca637e..1409b4f 100644 --- a/src/coolrl_lost_cities/games/classic/game.pyx +++ b/src/coolrl_lost_cities/games/classic/game.pyx @@ -1,12 +1,14 @@ # cython: language_level=3, boundscheck=False, wraparound=False, cdivision=True, initializedcheck=False -"""Core Lost Cities classic types. - -``GameState`` is provided by the C-array fast engine. -""" +"""Core Lost Cities classic types and C-array game state.""" +from collections import Counter from dataclasses import dataclass, fields +import random from typing import Any, Literal +from libc.string cimport memcpy +from libc.stdlib cimport free, malloc, realloc + cimport cython @@ -204,4 +206,1142 @@ cpdef int score_expedition(list expedition, config): return score -from .engines.fast import FastGameState as GameState +cdef inline int _phase_card(): + return 0 + + +cdef inline int _phase_draw(): + return 1 + + +cdef class GameState: + def __cinit__(self): + self.deck_cards = NULL + self.hand_cards = NULL + self.expedition_cards = NULL + self.expedition_lens = NULL + self.discard_cards = NULL + self.discard_lens = NULL + self.last_numeric_ranks = NULL + self.handshake_counts = NULL + self.numeric_sums = NULL + self.expedition_scores = NULL + self.undo_stack = NULL + + def __init__(self, config=None): + config = config or LostCitiesConfig() + config.validate() + self._configure(config) + + def __dealloc__(self): + if self.deck_cards != NULL: + free(self.deck_cards) + if self.hand_cards != NULL: + free(self.hand_cards) + if self.expedition_cards != NULL: + free(self.expedition_cards) + if self.expedition_lens != NULL: + free(self.expedition_lens) + if self.discard_cards != NULL: + free(self.discard_cards) + if self.discard_lens != NULL: + free(self.discard_lens) + if self.last_numeric_ranks != NULL: + free(self.last_numeric_ranks) + if self.handshake_counts != NULL: + free(self.handshake_counts) + if self.numeric_sums != NULL: + free(self.numeric_sums) + if self.expedition_scores != NULL: + free(self.expedition_scores) + if self.undo_stack != NULL: + free(self.undo_stack) + + cdef void _configure(self, object config) except *: + self.config = config + self.n_colors = int(config.n_colors) + self.n_ranks = int(config.n_ranks) + self.min_rank = int(config.min_rank) + self.n_handshakes = int(config.n_handshakes) + self.hand_size = int(config.hand_size) + self.expedition_penalty = int(config.expedition_penalty) + self.bonus_threshold = int(config.bonus_threshold) + self.bonus_amount = int(config.bonus_amount) + self.total_cards = int(config.deck_size) + self.cards_per_color = self.n_ranks + self.n_handshakes + self.stride = self.n_ranks + 1 + + self.deck_cards = malloc(self.total_cards * sizeof(int)) + self.hand_cards = malloc(2 * self.hand_size * sizeof(int)) + self.expedition_cards = malloc( + 2 * self.n_colors * self.cards_per_color * sizeof(int) + ) + self.expedition_lens = malloc(2 * self.n_colors * sizeof(int)) + self.discard_cards = malloc(self.n_colors * self.cards_per_color * sizeof(int)) + self.discard_lens = malloc(self.n_colors * sizeof(int)) + self.last_numeric_ranks = malloc(2 * self.n_colors * sizeof(int)) + self.handshake_counts = malloc(2 * self.n_colors * sizeof(int)) + self.numeric_sums = malloc(2 * self.n_colors * sizeof(int)) + self.expedition_scores = malloc(2 * self.n_colors * sizeof(int)) + self.undo_stack_capacity = 2 * self.total_cards + 16 + self.undo_stack = malloc( + self.undo_stack_capacity * sizeof(UndoRecord) + ) + if ( + self.deck_cards == NULL + or self.hand_cards == NULL + or self.expedition_cards == NULL + or self.expedition_lens == NULL + or self.discard_cards == NULL + or self.discard_lens == NULL + or self.last_numeric_ranks == NULL + or self.handshake_counts == NULL + or self.numeric_sums == NULL + or self.expedition_scores == NULL + or self.undo_stack == NULL + ): + raise MemoryError() + self._clear() + + cdef void _clear(self) noexcept: + cdef int i + self.deck_len = 0 + self.hand_lens[0] = 0 + self.hand_lens[1] = 0 + for i in range(2 * self.n_colors): + self.expedition_lens[i] = 0 + self.last_numeric_ranks[i] = 0 + self.handshake_counts[i] = 0 + self.numeric_sums[i] = 0 + self.expedition_scores[i] = 0 + for i in range(self.n_colors): + self.discard_lens[i] = 0 + self.total_scores[0] = 0 + self.total_scores[1] = 0 + self.undo_stack_len = 0 + self.current_player = 0 + self.phase_id = _phase_card() + self.pending_discarded_color = -1 + self.turn_count = 0 + self.terminal = False + + @classmethod + def empty(cls, config=None): + return cls(config or LostCitiesConfig()) + + @classmethod + def new_game(cls, config=None, *, seed=None): + config = config or LostCitiesConfig() + config.validate() + deck = _build_encoded_deck(config) + rng = random.Random(config.seed if seed is None else seed) + rng.shuffle(deck) + return cls.new_game_from_deck(deck, config) + + @classmethod + def new_game_from_deck(cls, deck, config=None): + config = config or LostCitiesConfig() + config.validate() + encoded = [_encode_card_snapshot(card, config) for card in deck] + if len(encoded) != int(config.deck_size): + raise ValueError( + f"deck length must be {config.deck_size}, got {len(encoded)}" + ) + if Counter(encoded) != Counter(_build_encoded_deck(config)): + raise ValueError("deck must contain exactly the cards defined by config") + + cdef int i + cdef int player + cdef GameState state = cls(config) + state.deck_len = len(encoded) + for i, card in enumerate(encoded): + state.deck_cards[i] = card + for _ in range(config.hand_size): + for player in range(2): + state.deck_len -= 1 + state.hand_cards[state._hand_index(player, state.hand_lens[player])] = state.deck_cards[ + state.deck_len + ] + state.hand_lens[player] += 1 + state.validate_invariants() + return state + + @classmethod + def from_snapshot(cls, snapshot, *, validate=True): + config = config_from_mapping(snapshot["config"]) + cdef GameState state = cls(config) + cdef int player + cdef int color + cdef int index + cdef list cards + + cards = [_encode_card_snapshot(card, config) for card in snapshot["deck"]] + if len(cards) > state.total_cards: + raise ValueError( + f"deck snapshot exceeds capacity {state.total_cards}: {len(cards)}" + ) + state.deck_len = len(cards) + for index, card in enumerate(cards): + state.deck_cards[index] = card + + for player in range(2): + cards = [ + _encode_card_snapshot(card, config) for card in snapshot["hands"][player] + ] + if len(cards) > state.hand_size: + raise ValueError( + f"hand {player} snapshot exceeds hand_size " + f"{state.hand_size}: {len(cards)}" + ) + state.hand_lens[player] = len(cards) + for index, card in enumerate(cards): + state.hand_cards[state._hand_index(player, index)] = card + + for player in range(2): + for color in range(state.n_colors): + cards = [ + _encode_card_snapshot(card, config) + for card in snapshot["expeditions"][player][color] + ] + if len(cards) > state.cards_per_color: + raise ValueError( + f"expedition {player}/{color} snapshot exceeds capacity " + f"{state.cards_per_color}: {len(cards)}" + ) + state.expedition_lens[state._expedition_len_index(player, color)] = len(cards) + for index, card in enumerate(cards): + state.expedition_cards[state._expedition_index(player, color, index)] = card + + for color in range(state.n_colors): + cards = [_encode_card_snapshot(card, config) for card in snapshot["discards"][color]] + if len(cards) > state.cards_per_color: + raise ValueError( + f"discard {color} snapshot exceeds capacity " + f"{state.cards_per_color}: {len(cards)}" + ) + state.discard_lens[color] = len(cards) + for index, card in enumerate(cards): + state.discard_cards[state._discard_index(color, index)] = card + + state.current_player = int(snapshot.get("current_player", 0)) + state.phase = snapshot.get("phase", "card") + pending = snapshot.get("pending_discarded_color") + state.pending_discarded_color = -1 if pending is None else int(pending) + state.turn_count = int(snapshot.get("turn_count", 0)) + state.terminal = bool(snapshot.get("terminal", False)) + state._recompute_score_caches() + if validate: + state.validate_invariants() + return state + + @property + def phase(self): + return "card" if self.phase_id == _phase_card() else "draw" + + @phase.setter + def phase(self, value): + if value == "card": + self.phase_id = _phase_card() + elif value == "draw": + self.phase_id = _phase_draw() + else: + raise ValueError(f"invalid phase: {value!r}") + + @property + def card_action_size(self): + return 2 * self.hand_size + + @property + def draw_action_size(self): + return 1 + self.n_colors + + @property + def action_size(self): + return self.card_action_size + self.draw_action_size + + @property + def deck(self): + return [self._card_obj(self.deck_cards[i]) for i in range(self.deck_len)] + + @property + def hands(self): + return [ + [ + self._card_obj(self.hand_cards[self._hand_index(player, i)]) + for i in range(self.hand_lens[player]) + ] + for player in range(2) + ] + + @property + def expeditions(self): + return [ + [ + [ + self._card_obj( + self.expedition_cards[ + self._expedition_index(player, color, i) + ] + ) + for i in range( + self.expedition_lens[ + self._expedition_len_index(player, color) + ] + ) + ] + for color in range(self.n_colors) + ] + for player in range(2) + ] + + @property + def discards(self): + return [ + [ + self._card_obj(self.discard_cards[self._discard_index(color, i)]) + for i in range(self.discard_lens[color]) + ] + for color in range(self.n_colors) + ] + + def to_snapshot(self): + return { + "config": self.config.to_snapshot(), + "deck": [self._card_snapshot(self.deck_cards[i]) for i in range(self.deck_len)], + "hands": [ + [ + self._card_snapshot(self.hand_cards[self._hand_index(player, i)]) + for i in range(self.hand_lens[player]) + ] + for player in range(2) + ], + "expeditions": [ + [ + [ + self._card_snapshot( + self.expedition_cards[self._expedition_index(player, color, i)] + ) + for i in range( + self.expedition_lens[ + self._expedition_len_index(player, color) + ] + ) + ] + for color in range(self.n_colors) + ] + for player in range(2) + ], + "discards": [ + [ + self._card_snapshot(self.discard_cards[self._discard_index(color, i)]) + for i in range(self.discard_lens[color]) + ] + for color in range(self.n_colors) + ], + "current_player": self.current_player, + "phase": self.phase, + "pending_discarded_color": ( + None if self.pending_discarded_color < 0 else self.pending_discarded_color + ), + "turn_count": self.turn_count, + "terminal": self.terminal, + } + + cpdef GameState clone(self): + cdef GameState other = GameState(self.config) + other.deck_len = self.deck_len + memcpy(other.deck_cards, self.deck_cards, self.deck_len * sizeof(int)) + memcpy(other.hand_cards, self.hand_cards, 2 * self.hand_size * sizeof(int)) + other.hand_lens[0] = self.hand_lens[0] + other.hand_lens[1] = self.hand_lens[1] + memcpy( + other.expedition_cards, + self.expedition_cards, + 2 * self.n_colors * self.cards_per_color * sizeof(int), + ) + memcpy( + other.expedition_lens, + self.expedition_lens, + 2 * self.n_colors * sizeof(int), + ) + memcpy( + other.discard_cards, + self.discard_cards, + self.n_colors * self.cards_per_color * sizeof(int), + ) + memcpy(other.discard_lens, self.discard_lens, self.n_colors * sizeof(int)) + memcpy( + other.last_numeric_ranks, + self.last_numeric_ranks, + 2 * self.n_colors * sizeof(int), + ) + memcpy( + other.handshake_counts, + self.handshake_counts, + 2 * self.n_colors * sizeof(int), + ) + memcpy(other.numeric_sums, self.numeric_sums, 2 * self.n_colors * sizeof(int)) + memcpy( + other.expedition_scores, + self.expedition_scores, + 2 * self.n_colors * sizeof(int), + ) + other.total_scores[0] = self.total_scores[0] + other.total_scores[1] = self.total_scores[1] + other.current_player = self.current_player + other.phase_id = self.phase_id + other.pending_discarded_color = self.pending_discarded_color + other.turn_count = self.turn_count + other.terminal = self.terminal + return other + + cpdef list legal_card_mask(self): + cdef list mask = [False] * (2 * self.hand_size) + cdef int slot + cdef int card + if self.terminal: + return mask + for slot in range(self.hand_lens[self.current_player]): + card = self.hand_cards[self._hand_index(self.current_player, slot)] + mask[2 * slot] = self._can_play_encoded_card_c(self.current_player, card) + mask[2 * slot + 1] = True + return mask + + cpdef list legal_draw_mask(self): + cdef list mask = [False] * (1 + self.n_colors) + cdef int color + if self.terminal: + return mask + mask[0] = self.deck_len > 0 + for color in range(self.n_colors): + mask[1 + color] = ( + self.discard_lens[color] > 0 + and (self.pending_discarded_color < 0 or color != self.pending_discarded_color) + ) + return mask + + cpdef list legal_mask(self): + if self.phase_id == _phase_card(): + return self.legal_card_mask() + return self.legal_draw_mask() + + cpdef list unified_legal_mask(self): + cdef list result + if self.phase_id == _phase_card(): + result = self.legal_card_mask() + result.extend([False] * (1 + self.n_colors)) + return result + result = [False] * (2 * self.hand_size) + result.extend(self.legal_draw_mask()) + return result + + cpdef list legal_actions(self): + cdef int* actions = malloc(self.action_size * sizeof(int)) + if actions == NULL: + raise MemoryError() + cdef int count + cdef int i + try: + count = self._legal_actions_c(actions) + return [actions[i] for i in range(count)] + finally: + free(actions) + + cpdef list unified_legal_actions(self): + cdef int* actions = malloc(self.action_size * sizeof(int)) + if actions == NULL: + raise MemoryError() + cdef int count + cdef int i + try: + count = self._unified_legal_actions_c(actions) + return [actions[i] for i in range(count)] + finally: + free(actions) + + cpdef int from_unified_action(self, int action_id): + cdef int card_action_size = 2 * self.hand_size + cdef int action_size = card_action_size + 1 + self.n_colors + if action_id < 0 or action_id >= action_size: + raise IllegalMoveError(f"action {action_id} is out of range") + if self.phase_id == _phase_card(): + if action_id >= card_action_size: + raise IllegalMoveError( + f"card action {action_id} is illegal during card phase" + ) + return action_id + if action_id < card_action_size: + raise IllegalMoveError( + f"card action {action_id} is illegal during draw phase" + ) + return action_id - card_action_size + + def to_unified_action(self, int action_id, phase=None): + cdef object p = self.phase if phase is None else phase + if p == "card": + if action_id < 0 or action_id >= 2 * self.hand_size: + raise IllegalMoveError(f"card action {action_id} is out of range") + return action_id + if action_id < 0 or action_id >= 1 + self.n_colors: + raise IllegalMoveError(f"draw action {action_id} is out of range") + return 2 * self.hand_size + action_id + + cpdef apply_action(self, int action_id): + if self.terminal: + raise IllegalMoveError("game is already terminal") + if not self._is_legal_action_c(action_id): + raise IllegalMoveError( + f"illegal action {action_id} in phase {self.phase} " + f"for player {self.current_player}" + ) + self._apply_action_unchecked_c(action_id) + + cpdef apply_unified_action(self, int action_id): + self.apply_action(self.from_unified_action(action_id)) + + cpdef object apply_action_with_undo(self, int action_id): + if self.terminal: + raise IllegalMoveError("game is already terminal") + if not self._is_legal_action_c(action_id): + raise IllegalMoveError( + f"illegal action {action_id} in phase {self.phase} " + f"for player {self.current_player}" + ) + cdef UndoRecord undo + self._apply_action_with_undo_c(action_id, &undo) + return self._undo_to_tuple(&undo) + + cpdef object apply_unified_action_with_undo(self, int action_id): + return self.apply_action_with_undo(self.from_unified_action(action_id)) + + cpdef undo_action(self, object undo): + cdef UndoRecord record + self._tuple_to_undo(undo, &record) + self._undo_action_c(&record) + + cpdef int push_action(self, int action_id): + if self.terminal: + raise IllegalMoveError("game is already terminal") + if not self._is_legal_action_c(action_id): + raise IllegalMoveError( + f"illegal action {action_id} in phase {self.phase} " + f"for player {self.current_player}" + ) + return self._push_action_c(action_id) + + cpdef int push_unified_action(self, int action_id): + return self.push_action(self.from_unified_action(action_id)) + + cpdef int pop_action(self): + if self.undo_stack_len <= 0: + raise ValueError("undo stack is empty") + return self._pop_action_c() + + cpdef bint can_play_encoded_card(self, int player, int card): + cdef int color = self._card_color(card) + cdef int rank = self._card_rank(card) + if color < 0 or color >= self.n_colors: + return False + if rank < 0 or rank > self.n_ranks: + return False + if rank == 0: + return self.last_numeric_ranks[self._expedition_len_index(player, color)] == 0 + return rank > self.last_numeric_ranks[self._expedition_len_index(player, color)] + + cpdef int last_numeric_rank(self, int player, int color): + return self.last_numeric_ranks[self._expedition_len_index(player, color)] + + def has_numeric(self, int player, int color): + return self.last_numeric_rank(player, color) > 0 + + def can_play_card(self, int player, object card): + return self.can_play_encoded_card(player, _encode_card_snapshot(card, self.config)) + + def hand_slots(self, player=None): + cdef int p = self.current_player if player is None else int(player) + cdef list hand = [] + cdef int i + for i in range(self.hand_lens[p]): + hand.append(self._card_obj(self.hand_cards[self._hand_index(p, i)])) + while len(hand) < self.hand_size: + hand.append(None) + return hand + + def sort_hands(self): + self.sort_hand(0) + self.sort_hand(1) + + def sort_hand(self, player=None): + cdef int p = self.current_player if player is None else int(player) + cdef int i + cdef int j + cdef int key + cdef int current + for i in range(1, self.hand_lens[p]): + key = self.hand_cards[self._hand_index(p, i)] + j = i - 1 + while j >= 0 and self.hand_cards[self._hand_index(p, j)] > key: + current = self.hand_cards[self._hand_index(p, j)] + self.hand_cards[self._hand_index(p, j + 1)] = current + j -= 1 + self.hand_cards[self._hand_index(p, j + 1)] = key + + cpdef object unified_legal_mask_np(self): + try: + import numpy as np + except ImportError as exc: # pragma: no cover + raise RuntimeError("numpy is required for unified_legal_mask_np") from exc + return np.asarray(self.unified_legal_mask(), dtype=bool) + + cpdef int expedition_score(self, int player, int color): + return self.expedition_scores[self._expedition_len_index(player, color)] + + cpdef int total_score(self, int player): + return self.total_scores[player] + + cpdef int score_diff(self, int player=0): + return self.total_score(player) - self.total_score(1 - player) + + def validate_invariants(self): + self.config.validate() + cdef int player + cdef int color + cdef int index + cdef int length + cdef int card + cdef int rank + cdef int last_rank + cdef bint seen_numeric + if self.current_player not in (0, 1): + raise ValueError("current_player must be 0 or 1") + if self.phase_id not in (_phase_card(), _phase_draw()): + raise ValueError("invalid phase") + if self.deck_len < 0 or self.deck_len > self.total_cards: + raise ValueError("deck length out of range") + if self.pending_discarded_color >= self.n_colors: + raise ValueError("pending_discarded_color is out of range") + if self.hand_lens[0] > self.hand_size or self.hand_lens[1] > self.hand_size: + raise ValueError("hand exceeds hand_size") + for color in range(self.n_colors): + if self.discard_lens[color] < 0 or self.discard_lens[color] > self.cards_per_color: + raise ValueError("discard length out of range") + for player in range(2): + if self.hand_lens[player] < 0: + raise ValueError("hand length out of range") + for color in range(self.n_colors): + length = self.expedition_lens[self._expedition_len_index(player, color)] + if length < 0 or length > self.cards_per_color: + raise ValueError("expedition length out of range") + seen_numeric = False + last_rank = 0 + for index in range(length): + card = self.expedition_cards[self._expedition_index(player, color, index)] + if self._card_color(card) != color: + raise ValueError("expedition contains wrong color") + rank = self._card_rank(card) + if rank < 0 or rank > self.n_ranks: + raise ValueError("card rank out of range") + if rank == 0: + if seen_numeric: + raise ValueError("expedition has handshake after number") + else: + seen_numeric = True + if rank <= last_rank: + raise ValueError("expedition is not strictly increasing") + last_rank = rank + if Counter(_all_cards_from_snapshot(self.to_snapshot())) != Counter( + _build_encoded_deck(self.config) + ): + raise ValueError("card conservation failed") + if self.phase_id == _phase_card() and self.pending_discarded_color >= 0: + raise ValueError("pending_discarded_color must be None during card phase") + if self.pending_discarded_color >= 0 and self.discard_lens[self.pending_discarded_color] == 0: + raise ValueError("pending discard color must have a discard pile card") + any_legal = any(self.unified_legal_mask()) + if self.terminal and any_legal: + raise ValueError("terminal state must have no legal actions") + if not self.terminal and not any_legal: + raise ValueError("non-terminal state must have at least one legal action") + + cdef bint _is_legal_action_c(self, int action_id) noexcept: + cdef int slot + cdef int color + if self.terminal: + return False + if self.phase_id == _phase_card(): + if action_id < 0 or action_id >= 2 * self.hand_size: + return False + slot = action_id // 2 + if slot >= self.hand_lens[self.current_player]: + return False + if action_id % 2 == 1: + return True + return self._can_play_encoded_card_c( + self.current_player, + self.hand_cards[self._hand_index(self.current_player, slot)], + ) + if action_id < 0 or action_id >= 1 + self.n_colors: + return False + if action_id == 0: + return self.deck_len > 0 + color = action_id - 1 + return ( + self.discard_lens[color] > 0 + and (self.pending_discarded_color < 0 or color != self.pending_discarded_color) + ) + + cdef int _legal_actions_c(self, int* out_actions) noexcept: + cdef int count = 0 + cdef int slot + cdef int color + cdef int card + if self.terminal: + return 0 + if self.phase_id == _phase_card(): + for slot in range(self.hand_lens[self.current_player]): + card = self.hand_cards[self._hand_index(self.current_player, slot)] + if self._can_play_encoded_card_c(self.current_player, card): + out_actions[count] = 2 * slot + count += 1 + out_actions[count] = 2 * slot + 1 + count += 1 + return count + if self.deck_len > 0: + out_actions[count] = 0 + count += 1 + for color in range(self.n_colors): + if ( + self.discard_lens[color] > 0 + and (self.pending_discarded_color < 0 or color != self.pending_discarded_color) + ): + out_actions[count] = 1 + color + count += 1 + return count + + cdef int _unified_legal_actions_c(self, int* out_actions) noexcept: + cdef int count = 0 + cdef int slot + cdef int color + cdef int card + cdef int card_action_size = 2 * self.hand_size + if self.terminal: + return 0 + if self.phase_id == _phase_card(): + for slot in range(self.hand_lens[self.current_player]): + card = self.hand_cards[self._hand_index(self.current_player, slot)] + if self._can_play_encoded_card_c(self.current_player, card): + out_actions[count] = 2 * slot + count += 1 + out_actions[count] = 2 * slot + 1 + count += 1 + return count + if self.deck_len > 0: + out_actions[count] = card_action_size + count += 1 + for color in range(self.n_colors): + if ( + self.discard_lens[color] > 0 + and (self.pending_discarded_color < 0 or color != self.pending_discarded_color) + ): + out_actions[count] = card_action_size + 1 + color + count += 1 + return count + + cdef bint _can_play_encoded_card_c(self, int player, int card) noexcept: + cdef int color = self._card_color(card) + cdef int rank = self._card_rank(card) + if color < 0 or color >= self.n_colors: + return False + if rank < 0 or rank > self.n_ranks: + return False + if rank == 0: + return self.last_numeric_ranks[self._expedition_len_index(player, color)] == 0 + return rank > self.last_numeric_ranks[self._expedition_len_index(player, color)] + + cdef void _fill_undo_c(self, int action_id, UndoRecord* undo) noexcept: + cdef int slot + cdef int card + cdef int color + cdef int cache_index + undo.phase_id = self.phase_id + undo.player = self.current_player + undo.action_id = action_id + undo.pending_before = self.pending_discarded_color + undo.terminal_before = self.terminal + undo.turn_count_before = self.turn_count + undo.slot = -1 + undo.play = 0 + undo.card = -1 + undo.color = -1 + undo.last_numeric_before = 0 + undo.handshake_count_before = 0 + undo.numeric_sum_before = 0 + undo.expedition_score_before = 0 + undo.total_score_before = self.total_scores[self.current_player] + if self.phase_id == _phase_card(): + slot = action_id // 2 + card = self.hand_cards[self._hand_index(self.current_player, slot)] + color = self._card_color(card) + cache_index = self._expedition_len_index(self.current_player, color) + undo.slot = slot + undo.play = action_id % 2 == 0 + undo.card = card + undo.color = color + undo.last_numeric_before = self.last_numeric_ranks[cache_index] + undo.handshake_count_before = self.handshake_counts[cache_index] + undo.numeric_sum_before = self.numeric_sums[cache_index] + undo.expedition_score_before = self.expedition_scores[cache_index] + elif action_id == 0: + undo.card = self.deck_cards[self.deck_len - 1] + else: + color = action_id - 1 + undo.color = color + undo.card = self.discard_cards[self._discard_index(color, self.discard_lens[color] - 1)] + + cdef void _apply_action_with_undo_c(self, int action_id, UndoRecord* undo) except *: + self._fill_undo_c(action_id, undo) + self._apply_action_unchecked_c(action_id) + + cdef void _apply_action_unchecked_c(self, int action_id) except *: + if self.phase_id == _phase_card(): + self._apply_card_action(action_id) + else: + self._apply_draw_action(action_id) + + cdef void _ensure_undo_capacity_c(self) except *: + cdef int new_capacity + cdef UndoRecord* grown + if self.undo_stack_len < self.undo_stack_capacity: + return + new_capacity = self.undo_stack_capacity * 2 + grown = realloc( + self.undo_stack, + new_capacity * sizeof(UndoRecord), + ) + if grown == NULL: + raise MemoryError() + self.undo_stack = grown + self.undo_stack_capacity = new_capacity + + cdef int _push_action_c(self, int action_id) except *: + self._ensure_undo_capacity_c() + self._apply_action_with_undo_c( + action_id, + &self.undo_stack[self.undo_stack_len], + ) + self.undo_stack_len += 1 + return self.undo_stack_len + + cdef int _pop_action_c(self) except *: + cdef int action_id + self.undo_stack_len -= 1 + action_id = self.undo_stack[self.undo_stack_len].action_id + self._undo_action_c(&self.undo_stack[self.undo_stack_len]) + return action_id + + cdef object _undo_to_tuple(self, UndoRecord* undo): + return ( + "card" if undo.phase_id == _phase_card() else "draw", + undo.player, + undo.action_id, + undo.pending_before, + undo.terminal_before, + undo.turn_count_before, + undo.slot, + undo.play, + undo.card, + undo.color, + undo.last_numeric_before, + undo.handshake_count_before, + undo.numeric_sum_before, + undo.expedition_score_before, + undo.total_score_before, + ) + + cdef void _tuple_to_undo(self, object data, UndoRecord* undo) except *: + cdef str phase = data[0] + if phase == "card": + undo.phase_id = _phase_card() + elif phase == "draw": + undo.phase_id = _phase_draw() + else: + raise ValueError(f"invalid undo phase: {phase!r}") + undo.player = data[1] + undo.action_id = data[2] + undo.pending_before = data[3] + undo.terminal_before = data[4] + undo.turn_count_before = data[5] + undo.slot = data[6] + undo.play = data[7] + undo.card = data[8] + undo.color = data[9] + undo.last_numeric_before = data[10] + undo.handshake_count_before = data[11] + undo.numeric_sum_before = data[12] + undo.expedition_score_before = data[13] + undo.total_score_before = data[14] + + cdef void _apply_card_action(self, int action_id) except *: + cdef int slot = action_id // 2 + cdef bint play = action_id % 2 == 0 + cdef int player = self.current_player + cdef int card = self.hand_cards[self._hand_index(player, slot)] + cdef int color = self._card_color(card) + cdef int rank = self._card_rank(card) + cdef int i + cdef int length_index + cdef int old_score + cdef int new_score + for i in range(slot, self.hand_lens[player] - 1): + self.hand_cards[self._hand_index(player, i)] = self.hand_cards[self._hand_index(player, i + 1)] + self.hand_lens[player] -= 1 + if play: + length_index = self._expedition_len_index(player, color) + old_score = self.expedition_scores[length_index] + self.expedition_cards[self._expedition_index(player, color, self.expedition_lens[length_index])] = card + self.expedition_lens[length_index] += 1 + if rank == 0: + self.handshake_counts[length_index] += 1 + else: + self.numeric_sums[length_index] += self.min_rank + rank - 1 + self.last_numeric_ranks[length_index] = rank + new_score = self._score_from_summary_c( + self.expedition_lens[length_index], + self.handshake_counts[length_index], + self.numeric_sums[length_index], + ) + self.expedition_scores[length_index] = new_score + self.total_scores[player] += new_score - old_score + else: + self.discard_cards[self._discard_index(color, self.discard_lens[color])] = card + self.discard_lens[color] += 1 + self.pending_discarded_color = color + self.phase_id = _phase_draw() + # Defensive terminal branch for externally constructed states where the + # deck was already empty before the card phase action. + if self.deck_len == 0 and not self._has_any_legal_draw(): + self.terminal = True + + cdef void _apply_draw_action(self, int action_id) except *: + cdef int player = self.current_player + cdef int card + cdef int color + if action_id == 0: + self.deck_len -= 1 + card = self.deck_cards[self.deck_len] + else: + color = action_id - 1 + self.discard_lens[color] -= 1 + card = self.discard_cards[self._discard_index(color, self.discard_lens[color])] + self.hand_cards[self._hand_index(player, self.hand_lens[player])] = card + self.hand_lens[player] += 1 + self.pending_discarded_color = -1 + self.turn_count += 1 + if self.deck_len == 0: + self.terminal = True + return + self.current_player = 1 - self.current_player + self.phase_id = _phase_card() + + cdef void _undo_action_c(self, UndoRecord* undo) except *: + if undo.phase_id == _phase_card(): + self._undo_card_action_c(undo) + elif undo.phase_id == _phase_draw(): + self._undo_draw_action_c(undo) + else: + raise ValueError("invalid undo phase") + + cdef void _undo_card_action_c(self, UndoRecord* undo) except *: + cdef int player = undo.player + cdef int pending_before = undo.pending_before + cdef bint terminal_before = undo.terminal_before + cdef int slot = undo.slot + cdef bint play = undo.play + cdef int card = undo.card + cdef int color = self._card_color(card) + cdef int moved + cdef int i + cdef int length_index + if play: + length_index = self._expedition_len_index(player, color) + self.expedition_lens[length_index] -= 1 + moved = self.expedition_cards[self._expedition_index(player, color, self.expedition_lens[length_index])] + self.last_numeric_ranks[length_index] = undo.last_numeric_before + self.handshake_counts[length_index] = undo.handshake_count_before + self.numeric_sums[length_index] = undo.numeric_sum_before + self.expedition_scores[length_index] = undo.expedition_score_before + self.total_scores[player] = undo.total_score_before + else: + self.discard_lens[color] -= 1 + moved = self.discard_cards[self._discard_index(color, self.discard_lens[color])] + if moved != card: + raise ValueError("undo card mismatch") + for i in range(self.hand_lens[player], slot, -1): + self.hand_cards[self._hand_index(player, i)] = self.hand_cards[self._hand_index(player, i - 1)] + self.hand_cards[self._hand_index(player, slot)] = card + self.hand_lens[player] += 1 + self.current_player = player + self.phase_id = _phase_card() + self.pending_discarded_color = pending_before + self.terminal = terminal_before + + cdef void _undo_draw_action_c(self, UndoRecord* undo) except *: + cdef int player = undo.player + cdef int action_id = undo.action_id + cdef int pending_before = undo.pending_before + cdef bint terminal_before = undo.terminal_before + cdef int turn_count_before = undo.turn_count_before + cdef int card = undo.card + cdef int moved + cdef int color + self.hand_lens[player] -= 1 + moved = self.hand_cards[self._hand_index(player, self.hand_lens[player])] + if moved != card: + raise ValueError("undo draw mismatch") + if action_id == 0: + self.deck_cards[self.deck_len] = card + self.deck_len += 1 + else: + color = action_id - 1 + self.discard_cards[self._discard_index(color, self.discard_lens[color])] = card + self.discard_lens[color] += 1 + self.current_player = player + self.phase_id = _phase_draw() + self.pending_discarded_color = pending_before + self.turn_count = turn_count_before + self.terminal = terminal_before + + cdef void _recompute_score_caches(self) noexcept: + cdef int i + cdef int player + cdef int color + cdef int cache_index + cdef int length + cdef int rank + cdef int card_index + for i in range(2 * self.n_colors): + self.last_numeric_ranks[i] = 0 + self.handshake_counts[i] = 0 + self.numeric_sums[i] = 0 + self.expedition_scores[i] = 0 + self.total_scores[0] = 0 + self.total_scores[1] = 0 + for player in range(2): + for color in range(self.n_colors): + cache_index = self._expedition_len_index(player, color) + length = self.expedition_lens[cache_index] + for card_index in range(length): + rank = self._card_rank( + self.expedition_cards[ + self._expedition_index(player, color, card_index) + ] + ) + if rank == 0: + self.handshake_counts[cache_index] += 1 + else: + self.numeric_sums[cache_index] += self.min_rank + rank - 1 + if rank > self.last_numeric_ranks[cache_index]: + self.last_numeric_ranks[cache_index] = rank + self.expedition_scores[cache_index] = self._score_from_summary_c( + length, + self.handshake_counts[cache_index], + self.numeric_sums[cache_index], + ) + self.total_scores[player] += self.expedition_scores[cache_index] + + cdef inline int _score_from_summary_c( + self, + int length, + int handshakes, + int numeric_sum, + ) noexcept: + cdef int score + if length == 0: + return 0 + score = (numeric_sum + self.expedition_penalty) * (handshakes + 1) + if length >= self.bonus_threshold: + score += self.bonus_amount + return score + + cdef bint _has_any_legal_draw(self) noexcept: + cdef int color + if self.deck_len > 0: + return True + for color in range(self.n_colors): + if ( + self.discard_lens[color] > 0 + and (self.pending_discarded_color < 0 or color != self.pending_discarded_color) + ): + return True + return False + + cdef inline int _hand_index(self, int player, int slot): + return player * self.hand_size + slot + + cdef inline int _expedition_len_index(self, int player, int color): + return player * self.n_colors + color + + cdef inline int _expedition_index(self, int player, int color, int index): + return (player * self.n_colors + color) * self.cards_per_color + index + + cdef inline int _discard_index(self, int color, int index): + return color * self.cards_per_color + index + + cdef inline int _encode_card(self, int color, int rank): + return color * self.stride + rank + + cdef inline int _card_color(self, int card): + return card // self.stride + + cdef inline int _card_rank(self, int card): + return card % self.stride + + cdef object _card_snapshot(self, int card): + return {"color": self._card_color(card), "rank": self._card_rank(card)} + + cdef object _card_obj(self, int card): + return Card(self._card_color(card), self._card_rank(card)) + + +def _build_encoded_deck(config): + deck = [] + stride = int(config.n_ranks) + 1 + for color in range(int(config.n_colors)): + for _ in range(int(config.n_handshakes)): + deck.append(color * stride) + for rank in range(1, int(config.n_ranks) + 1): + deck.append(color * stride + rank) + return deck + + +def _encode_card_snapshot(data, config): + stride = int(config.n_ranks) + 1 + if isinstance(data, int): + return int(data) + if isinstance(data, dict): + return int(data["color"]) * stride + int(data["rank"]) + if isinstance(data, (list, tuple)) and len(data) == 2: + return int(data[0]) * stride + int(data[1]) + color = getattr(data, "color", None) + rank = getattr(data, "rank", None) + if color is not None and rank is not None: + return int(color) * stride + int(rank) + raise ValueError(f"invalid card snapshot: {data!r}") + + +def _all_cards_from_snapshot(snapshot): + cards = [] + config = config_from_mapping(snapshot["config"]) + for card in snapshot["deck"]: + cards.append(_encode_card_snapshot(card, config)) + for hand in snapshot["hands"]: + for card in hand: + cards.append(_encode_card_snapshot(card, config)) + for player_expeditions in snapshot["expeditions"]: + for expedition in player_expeditions: + for card in expedition: + cards.append(_encode_card_snapshot(card, config)) + for discard in snapshot["discards"]: + for card in discard: + cards.append(_encode_card_snapshot(card, config)) + return cards diff --git a/src/coolrl_lost_cities/games/classic/interfaces.py b/src/coolrl_lost_cities/games/classic/interfaces.py index 13ac9f5..4d188d3 100644 --- a/src/coolrl_lost_cities/games/classic/interfaces.py +++ b/src/coolrl_lost_cities/games/classic/interfaces.py @@ -2,7 +2,7 @@ from __future__ import annotations from typing import Protocol, TypeAlias, runtime_checkable -from .engines import FastGameState as GameState +from .game import GameState from .snapshots import Snapshot BotInput: TypeAlias = dict | GameState | Snapshot diff --git a/src/coolrl_lost_cities/games/classic/pygame_pvp.py b/src/coolrl_lost_cities/games/classic/pygame_pvp.py index 6ec4114..d593062 100644 --- a/src/coolrl_lost_cities/games/classic/pygame_pvp.py +++ b/src/coolrl_lost_cities/games/classic/pygame_pvp.py @@ -13,8 +13,7 @@ from pathlib import Path from typing import Any, Literal from .bots import DEFAULT_BOT, LostCitiesBot, available_bot_names, build_bot -from .engines import FastGameState as GameState -from .game import Card, LostCitiesConfig, classic_config +from .game import Card, GameState, LostCitiesConfig, classic_config from .resources import theme_path from .snapshots import Snapshot, snapshot_from_state, snapshot_summary diff --git a/src/coolrl_lost_cities/games/classic/snapshots.py b/src/coolrl_lost_cities/games/classic/snapshots.py index 40d111c..9ba4fa0 100644 --- a/src/coolrl_lost_cities/games/classic/snapshots.py +++ b/src/coolrl_lost_cities/games/classic/snapshots.py @@ -2,8 +2,7 @@ from __future__ import annotations from dataclasses import dataclass -from .engines import FastGameState as GameState -from .game import Card, LostCitiesConfig, score_expedition +from .game import Card, GameState, LostCitiesConfig, score_expedition @dataclass diff --git a/tests/games/classic/engines/test_fast_parity.py b/tests/games/classic/test_game_state.py similarity index 65% rename from tests/games/classic/engines/test_fast_parity.py rename to tests/games/classic/test_game_state.py index 99596ff..6dc5e9b 100644 --- a/tests/games/classic/engines/test_fast_parity.py +++ b/tests/games/classic/test_game_state.py @@ -6,7 +6,6 @@ import pytest 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.engines import FastGameState 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() deck = build_deck(config) - assert GameState is FastGameState 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() - right.validate_invariants() + assert other.to_snapshot() == left.to_snapshot() + other.validate_invariants() -def test_fast_snapshot_roundtrip_preserves_snapshot() -> None: +def test_game_state_snapshot_roundtrip_preserves_snapshot() -> None: config = LostCitiesConfig(seed=11) 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() - restored = FastGameState.from_snapshot(right.to_snapshot()) - assert restored.to_snapshot() == right.to_snapshot() + assert other.to_snapshot() == left.to_snapshot() + restored = GameState.from_snapshot(other.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() state = GameState.new_game(config, seed=3) deck_snapshot = state.to_snapshot() deck_snapshot["deck"] = [_card(0, 1)] * (config.deck_size + 1) 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["hands"][0] = [_card(0, 1)] * (config.hand_size + 1) 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["expeditions"][0][0] = [_card(0, 1)] * ( config.n_ranks + config.n_handshakes + 1 ) 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["discards"][0] = [_card(0, 1)] * (config.n_ranks + config.n_handshakes + 1) 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() - snapshot = FastGameState.new_game(config, seed=4).to_snapshot() + snapshot = GameState.new_game(config, seed=4).to_snapshot() snapshot["deck"].extend( [ _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"): - 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( hands=[ [_card(0, 1)], @@ -135,29 +133,29 @@ def test_fast_pending_discard_sequence_is_deterministic() -> None: deck=[_card(2, 1), _card(3, 1)], ) left = GameState.from_snapshot(snapshot) - right = FastGameState.from_snapshot(snapshot) + other = GameState.from_snapshot(snapshot) left.apply_action(1) - right.apply_action(1) - assert right.to_snapshot() == left.to_snapshot() - assert right.legal_draw_mask() == left.legal_draw_mask() - assert right.legal_draw_mask()[1] is False + other.apply_action(1) + assert other.to_snapshot() == left.to_snapshot() + assert other.legal_draw_mask() == left.legal_draw_mask() + assert other.legal_draw_mask()[1] is False left.apply_action(0) - right.apply_action(0) + other.apply_action(0) left.apply_action(1) - right.apply_action(1) + other.apply_action(1) left.apply_action(0) - right.apply_action(0) + other.apply_action(0) left.apply_action(1) - right.apply_action(1) + other.apply_action(1) - assert right.to_snapshot() == left.to_snapshot() - assert right.legal_draw_mask() == left.legal_draw_mask() - assert right.legal_draw_mask()[1] is True + assert other.to_snapshot() == left.to_snapshot() + assert other.legal_draw_mask() == left.legal_draw_mask() + 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( deck=[_card(1, 1)], hands=[ @@ -170,15 +168,15 @@ def test_fast_terminal_edges_are_deterministic() -> None: for card in remaining_deck: last_draw_snapshot["discards"][card["color"]].append(card) 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) - right.apply_action(1) + other.apply_action(1) left.apply_action(0) - right.apply_action(0) + other.apply_action(0) - assert right.to_snapshot() == left.to_snapshot() - assert right.terminal is True + assert other.to_snapshot() == left.to_snapshot() + assert other.terminal is True defensive_snapshot = { "config": LostCitiesConfig().to_snapshot(), @@ -193,16 +191,16 @@ def test_fast_terminal_edges_are_deterministic() -> None: "terminal": 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) - right.apply_action(1) + other.apply_action(1) - assert right.to_snapshot() == left.to_snapshot() - assert right.terminal is True + assert other.to_snapshot() == left.to_snapshot() + 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( hands=[ [_card(0, 1)], @@ -214,9 +212,9 @@ def test_fast_last_numeric_legality_edges() -> None: ], ) left = GameState.from_snapshot(handshake_snapshot) - right = FastGameState.from_snapshot(handshake_snapshot) - assert right.legal_card_mask() == left.legal_card_mask() - assert right.legal_card_mask()[0] is True + other = GameState.from_snapshot(handshake_snapshot) + assert other.legal_card_mask() == left.legal_card_mask() + assert other.legal_card_mask()[0] is True numeric_snapshot = _snapshot( hands=[ @@ -229,14 +227,14 @@ def test_fast_last_numeric_legality_edges() -> None: ], ) left = GameState.from_snapshot(numeric_snapshot) - right = FastGameState.from_snapshot(numeric_snapshot) - assert right.legal_card_mask() == left.legal_card_mask() - assert right.legal_card_mask()[0] is False - assert right.legal_card_mask()[2] is False - assert right.legal_card_mask()[4] is True + other = GameState.from_snapshot(numeric_snapshot) + assert other.legal_card_mask() == left.legal_card_mask() + assert other.legal_card_mask()[0] is False + assert other.legal_card_mask()[2] is False + 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( hands=[ [_card(0, 7)], @@ -263,30 +261,30 @@ def test_fast_score_cache_and_undo_restore_snapshot() -> None: ], ) left = GameState.from_snapshot(snapshot) - right = FastGameState.from_snapshot(snapshot) - before = right.to_snapshot() + other = GameState.from_snapshot(snapshot) + before = other.to_snapshot() - assert right.expedition_score(0, 0) == left.expedition_score(0, 0) - assert right.total_score(0) == left.total_score(0) + assert other.expedition_score(0, 0) == left.expedition_score(0, 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) - assert right.to_snapshot() == left.to_snapshot() - assert right.expedition_score(0, 0) == left.expedition_score(0, 0) - assert right.total_score(0) == left.total_score(0) + assert other.to_snapshot() == left.to_snapshot() + assert other.expedition_score(0, 0) == left.expedition_score(0, 0) + assert other.total_score(0) == left.total_score(0) - right.undo_action(undo) - assert right.to_snapshot() == before - assert right.total_score(0) == GameState.from_snapshot(before).total_score(0) + other.undo_action(undo) + assert other.to_snapshot() == before + 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( hands=[[], [_card(1, 1)]], discards=[[_card(0, 1)], [], [], [], []], phase="draw", ) - state = FastGameState.from_snapshot(snapshot) + state = GameState.from_snapshot(snapshot) before = state.to_snapshot() 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 -def test_fast_random_action_sequence_is_deterministic() -> None: +def test_game_state_random_action_sequence_is_deterministic() -> None: config = LostCitiesConfig() for seed in range(48): 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) steps = 0 while True: - assert right.to_snapshot() == left.to_snapshot() - assert right.unified_legal_mask() == left.unified_legal_mask() - assert right.unified_legal_actions() == [ + assert other.to_snapshot() == left.to_snapshot() + assert other.unified_legal_mask() == left.unified_legal_mask() + assert other.unified_legal_actions() == [ 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: break legal = [index for index, is_legal in enumerate(left.unified_legal_mask()) if is_legal] action = rng.choice(legal) left.apply_unified_action(action) - right.apply_unified_action(action) + other.apply_unified_action(action) steps += 1 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() for seed in range(32): 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)] - right_bots = [RandomBot(seed=seed * 2), RandomBot(seed=seed * 2 + 1)] + other_bots = [RandomBot(seed=seed * 2), RandomBot(seed=seed * 2 + 1)] steps = 0 while True: - assert right.to_snapshot() == left.to_snapshot() + assert other.to_snapshot() == left.to_snapshot() if left.terminal: break player = left.current_player - assert right.current_player == player + assert other.current_player == player left_action = left_bots[player].act(left) - right_action = right_bots[player].act({"legal_mask": right.legal_mask()}) - assert right_action == left_action + other_action = other_bots[player].act({"legal_mask": other.legal_mask()}) + assert other_action == left_action left.apply_action(left_action) - right.apply_action(right_action) + other.apply_action(other_action) steps += 1 assert steps < 1000 - assert right.total_score(0) == left.total_score(0) - assert right.total_score(1) == left.total_score(1) - assert right.score_diff(0) == left.score_diff(0) + assert other.total_score(0) == left.total_score(0) + assert other.total_score(1) == left.total_score(1) + 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() for seed in range(32): - state = FastGameState.new_game(config, seed=seed) + state = GameState.new_game(config, seed=seed) rng = random.Random(seed ^ 0xFA57A11) steps = 0 @@ -373,10 +371,10 @@ def test_fast_apply_undo_restores_every_legal_action() -> None: 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() for seed in range(32): - state = FastGameState.new_game(config, seed=seed) + state = GameState.new_game(config, seed=seed) rng = random.Random(seed ^ 0x517ACC) before = state.to_snapshot() actions: list[int] = [] @@ -397,4 +395,4 @@ def test_fast_push_pop_action_restores_nested_sequence() -> None: assert state.to_snapshot() == before 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()