From ec0e468fa17c066b09eef2e2622fb33b72ec3309 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 21:40:33 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B3=A0=EC=86=8D=20=EA=B2=8C=EC=9E=84=20?= =?UTF-8?q?=EC=97=94=EC=A7=84=20=EA=B3=A8=EA=B2=A9=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 +- pyproject.toml | 3 + setup.py | 6 +- .../games/classic/engines/__init__.py | 5 + .../games/classic/engines/cards.py | 31 + .../games/classic/engines/fast.pyx | 703 ++++++++++++++++++ src/coolrl_lost_cities/games/classic/game.pyx | 8 + .../games/classic/engines/test_fast_parity.py | 105 +++ 8 files changed, 862 insertions(+), 2 deletions(-) create mode 100644 src/coolrl_lost_cities/games/classic/engines/__init__.py create mode 100644 src/coolrl_lost_cities/games/classic/engines/cards.py create mode 100644 src/coolrl_lost_cities/games/classic/engines/fast.pyx create mode 100644 tests/games/classic/engines/test_fast_parity.py diff --git a/.gitignore b/.gitignore index 02fea38..4b2d686 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ wheels/ # Cython-generated sources src/coolrl_lost_cities/games/classic/game.c +src/coolrl_lost_cities/games/classic/engines/fast.c # Rust build output target/ @@ -17,4 +18,4 @@ target/ .venv .pytest_cache -.ruff_cache \ No newline at end of file +.ruff_cache diff --git a/pyproject.toml b/pyproject.toml index 884494c..91fd523 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,6 +44,9 @@ include = ["coolrl_lost_cities*"] "fixtures/*.json", "docs/*.md", ] +"coolrl_lost_cities.games.classic.engines" = [ + "*.pyx", +] [tool.ruff] line-length = 100 diff --git a/setup.py b/setup.py index 0b671ee..e0a138f 100644 --- a/setup.py +++ b/setup.py @@ -13,7 +13,11 @@ extensions = cythonize( Extension( "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/engines/__init__.py b/src/coolrl_lost_cities/games/classic/engines/__init__.py new file mode 100644 index 0000000..eeca280 --- /dev/null +++ b/src/coolrl_lost_cities/games/classic/engines/__init__.py @@ -0,0 +1,5 @@ +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 new file mode 100644 index 0000000..5ec5947 --- /dev/null +++ b/src/coolrl_lost_cities/games/classic/engines/cards.py @@ -0,0 +1,31 @@ +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 new file mode 100644 index 0000000..a6c533f --- /dev/null +++ b/src/coolrl_lost_cities/games/classic/engines/fast.pyx @@ -0,0 +1,703 @@ +# 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.stdlib cimport free, malloc + +from ..game import IllegalMoveError, LostCitiesConfig, config_from_mapping + + +cdef inline int _phase_card(): + return 0 + + +cdef inline int _phase_draw(): + return 1 + + +cdef class FastGameState: + cdef public object config + cdef int n_colors + cdef int n_ranks + cdef int min_rank + cdef int n_handshakes + cdef int hand_size + cdef int expedition_penalty + cdef int bonus_threshold + cdef int bonus_amount + cdef int total_cards + cdef int cards_per_color + cdef int stride + + cdef int* deck + cdef int deck_len + cdef int* hands + cdef int hand_lens[2] + cdef int* expeditions + cdef int* expedition_lens + cdef int* discards + cdef int* discard_lens + + cdef public int current_player + cdef int phase_id + cdef public int pending_discarded_color + cdef public int turn_count + cdef public bint terminal + + def __cinit__(self): + self.deck = NULL + self.hands = NULL + self.expeditions = NULL + self.expedition_lens = NULL + self.discards = NULL + self.discard_lens = NULL + + def __init__(self, config=None): + config = config or LostCitiesConfig() + config.validate() + self._configure(config) + + def __dealloc__(self): + if self.deck != NULL: + free(self.deck) + if self.hands != NULL: + free(self.hands) + if self.expeditions != NULL: + free(self.expeditions) + if self.expedition_lens != NULL: + free(self.expedition_lens) + if self.discards != NULL: + free(self.discards) + if self.discard_lens != NULL: + free(self.discard_lens) + + 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 = malloc(self.total_cards * sizeof(int)) + self.hands = malloc(2 * self.hand_size * sizeof(int)) + self.expeditions = malloc( + 2 * self.n_colors * self.cards_per_color * sizeof(int) + ) + self.expedition_lens = malloc(2 * self.n_colors * sizeof(int)) + self.discards = malloc(self.n_colors * self.cards_per_color * sizeof(int)) + self.discard_lens = malloc(self.n_colors * sizeof(int)) + if ( + self.deck == NULL + or self.hands == NULL + or self.expeditions == NULL + or self.expedition_lens == NULL + or self.discards == NULL + or self.discard_lens == 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 + for i in range(self.n_colors): + self.discard_lens[i] = 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 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[i] = card + for _ in range(config.hand_size): + for player in range(2): + state.deck_len -= 1 + state.hands[state._hand_index(player, state.hand_lens[player])] = state.deck[ + 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"]] + state.deck_len = len(cards) + for index, card in enumerate(cards): + state.deck[index] = card + + for player in range(2): + cards = [ + _encode_card_snapshot(card, config) for card in snapshot["hands"][player] + ] + state.hand_lens[player] = len(cards) + for index, card in enumerate(cards): + state.hands[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] + ] + state.expedition_lens[state._expedition_len_index(player, color)] = len(cards) + for index, card in enumerate(cards): + state.expeditions[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]] + state.discard_lens[color] = len(cards) + for index, card in enumerate(cards): + state.discards[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)) + 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 + + def to_snapshot(self): + return { + "config": self.config.to_snapshot(), + "deck": [self._card_snapshot(self.deck[i]) for i in range(self.deck_len)], + "hands": [ + [ + self._card_snapshot(self.hands[self._hand_index(player, i)]) + for i in range(self.hand_lens[player]) + ] + for player in range(2) + ], + "expeditions": [ + [ + [ + self._card_snapshot( + self.expeditions[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.discards[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) + cdef int i + other.deck_len = self.deck_len + for i in range(self.deck_len): + other.deck[i] = self.deck[i] + for i in range(2 * self.hand_size): + other.hands[i] = self.hands[i] + other.hand_lens[0] = self.hand_lens[0] + other.hand_lens[1] = self.hand_lens[1] + for i in range(2 * self.n_colors * self.cards_per_color): + other.expeditions[i] = self.expeditions[i] + for i in range(2 * self.n_colors): + other.expedition_lens[i] = self.expedition_lens[i] + for i in range(self.n_colors * self.cards_per_color): + other.discards[i] = self.discards[i] + for i in range(self.n_colors): + other.discard_lens[i] = self.discard_lens[i] + 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.hands[self._hand_index(self.current_player, slot)] + mask[2 * slot] = self.can_play_encoded_card(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 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") + cdef list mask = self.legal_mask() + if action_id < 0 or action_id >= len(mask) or not mask[action_id]: + raise IllegalMoveError( + f"illegal action {action_id} in phase {self.phase} " + f"for player {self.current_player}" + ) + if self.phase_id == _phase_card(): + self._apply_card_action(action_id) + else: + self._apply_draw_action(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") + cdef list mask = self.legal_mask() + if action_id < 0 or action_id >= len(mask) or not mask[action_id]: + raise IllegalMoveError( + f"illegal action {action_id} in phase {self.phase} " + f"for player {self.current_player}" + ) + cdef object undo + if self.phase_id == _phase_card(): + undo = self._card_action_undo(action_id) + self._apply_card_action(action_id) + else: + undo = self._draw_action_undo(action_id) + self._apply_draw_action(action_id) + return 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 str phase = undo[0] + if phase == "card": + self._undo_card_action(undo) + return + if phase == "draw": + self._undo_draw_action(undo) + return + raise ValueError(f"invalid undo phase: {phase!r}") + + 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) + cdef int last_numeric + if color < 0 or color >= self.n_colors: + return False + if rank < 0 or rank > self.n_ranks: + return False + last_numeric = self.last_numeric_rank(player, color) + if rank == 0: + return last_numeric == 0 + return rank > last_numeric + + cpdef int last_numeric_rank(self, int player, int color): + cdef int length = self.expedition_lens[self._expedition_len_index(player, color)] + cdef int i + cdef int rank + cdef int best = 0 + for i in range(length): + rank = self._card_rank(self.expeditions[self._expedition_index(player, color, i)]) + if rank > best: + best = rank + return best + + cpdef int expedition_score(self, int player, int color): + cdef int length = self.expedition_lens[self._expedition_len_index(player, color)] + cdef int handshakes = 0 + cdef int numeric_sum = 0 + cdef int i + cdef int rank + cdef int score + if length == 0: + return 0 + for i in range(length): + rank = self._card_rank(self.expeditions[self._expedition_index(player, color, i)]) + if rank == 0: + handshakes += 1 + else: + numeric_sum += self.min_rank + rank - 1 + score = (numeric_sum + self.expedition_penalty) * (handshakes + 1) + if length >= self.bonus_threshold: + score += self.bonus_amount + return score + + cpdef int total_score(self, int player): + cdef int total = 0 + cdef int color + for color in range(self.n_colors): + total += self.expedition_score(player, color) + return total + + 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() + 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.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") + 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 object _card_action_undo(self, int action_id): + cdef int slot = action_id // 2 + cdef bint play = action_id % 2 == 0 + cdef int card = self.hands[self._hand_index(self.current_player, slot)] + return ( + "card", + self.current_player, + action_id, + self.pending_discarded_color, + self.terminal, + slot, + play, + card, + ) + + cdef object _draw_action_undo(self, int action_id): + cdef int card + if action_id == 0: + card = self.deck[self.deck_len - 1] + else: + card = self.discards[self._discard_index(action_id - 1, self.discard_lens[action_id - 1] - 1)] + return ( + "draw", + self.current_player, + action_id, + self.pending_discarded_color, + self.terminal, + self.turn_count, + card, + ) + + 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.hands[self._hand_index(player, slot)] + cdef int color = self._card_color(card) + cdef int i + cdef int length_index + for i in range(slot, self.hand_lens[player] - 1): + self.hands[self._hand_index(player, i)] = self.hands[self._hand_index(player, i + 1)] + self.hand_lens[player] -= 1 + if play: + length_index = self._expedition_len_index(player, color) + self.expeditions[self._expedition_index(player, color, self.expedition_lens[length_index])] = card + self.expedition_lens[length_index] += 1 + else: + self.discards[self._discard_index(color, self.discard_lens[color])] = card + self.discard_lens[color] += 1 + self.pending_discarded_color = color + self.phase_id = _phase_draw() + 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[self.deck_len] + else: + color = action_id - 1 + self.discard_lens[color] -= 1 + card = self.discards[self._discard_index(color, self.discard_lens[color])] + self.hands[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_card_action(self, object undo) except *: + cdef int player = undo[1] + cdef int pending_before = undo[3] + cdef bint terminal_before = undo[4] + cdef int slot = undo[5] + cdef bint play = undo[6] + cdef int card = undo[7] + 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.expeditions[self._expedition_index(player, color, self.expedition_lens[length_index])] + else: + self.discard_lens[color] -= 1 + moved = self.discards[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.hands[self._hand_index(player, i)] = self.hands[self._hand_index(player, i - 1)] + self.hands[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(self, object undo) except *: + cdef int player = undo[1] + cdef int action_id = undo[2] + cdef int pending_before = undo[3] + cdef bint terminal_before = undo[4] + cdef int turn_count_before = undo[5] + cdef int card = undo[6] + cdef int moved + cdef int color + self.hand_lens[player] -= 1 + moved = self.hands[self._hand_index(player, self.hand_lens[player])] + if moved != card: + raise ValueError("undo draw mismatch") + if action_id == 0: + self.deck[self.deck_len] = card + self.deck_len += 1 + else: + color = action_id - 1 + self.discards[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 bint _has_any_legal_draw(self): + 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)} + + +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/game.pyx b/src/coolrl_lost_cities/games/classic/game.pyx index c69913f..8589977 100644 --- a/src/coolrl_lost_cities/games/classic/game.pyx +++ b/src/coolrl_lost_cities/games/classic/game.pyx @@ -1,4 +1,11 @@ # cython: language_level=3, boundscheck=False, wraparound=False, cdivision=True, initializedcheck=False +"""Deprecated compatibility rules engine for Lost Cities classic. + +This module remains the public engine while the replacement fast engine is +developed under ``coolrl_lost_cities.games.classic.engines``. New traversal, +simulation, and training work should target the fast engine once it exists. +""" + from collections import Counter from dataclasses import dataclass, fields import random @@ -10,6 +17,7 @@ cimport cython Phase = Literal["card", "draw"] +DEPRECATED_ENGINE = True class IllegalMoveError(ValueError): diff --git a/tests/games/classic/engines/test_fast_parity.py b/tests/games/classic/engines/test_fast_parity.py new file mode 100644 index 0000000..2422893 --- /dev/null +++ b/tests/games/classic/engines/test_fast_parity.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +import random + +from coolrl_lost_cities.games.classic.game import Card, GameState, LostCitiesConfig + +from coolrl_lost_cities.games.classic.engines import FastGameState + + +def _small_config() -> LostCitiesConfig: + return LostCitiesConfig( + n_colors=2, + n_ranks=2, + min_rank=1, + n_handshakes=0, + hand_size=1, + expedition_penalty=0, + bonus_threshold=99, + bonus_amount=0, + ) + + +def test_fast_new_game_from_deck_matches_game_state_snapshot() -> None: + config = _small_config() + deck = [ + Card(0, 1), + Card(0, 2), + Card(1, 1), + Card(1, 2), + ] + + classic = GameState.new_game_from_deck(deck, config) + fast = FastGameState.new_game_from_deck(deck, config) + + assert fast.to_snapshot() == classic.to_snapshot() + fast.validate_invariants() + + +def test_fast_snapshot_roundtrip_preserves_snapshot() -> None: + config = LostCitiesConfig(seed=11) + classic = GameState.new_game(config) + fast = FastGameState.from_snapshot(classic.to_snapshot()) + + assert fast.to_snapshot() == classic.to_snapshot() + restored = FastGameState.from_snapshot(fast.to_snapshot()) + assert restored.to_snapshot() == fast.to_snapshot() + + +def test_fast_random_action_sequence_matches_game_state() -> None: + config = LostCitiesConfig( + n_colors=3, + n_ranks=5, + min_rank=2, + n_handshakes=1, + hand_size=5, + ) + for seed in range(48): + classic = GameState.new_game(config, seed=seed) + fast = FastGameState.new_game(config, seed=seed) + rng = random.Random(seed ^ 0xF457) + steps = 0 + + while True: + assert fast.to_snapshot() == classic.to_snapshot() + assert fast.unified_legal_mask() == classic.unified_legal_mask() + assert fast.score_diff(0) == classic.score_diff(0) + if classic.terminal: + break + + legal = [ + index for index, is_legal in enumerate(classic.unified_legal_mask()) if is_legal + ] + action = rng.choice(legal) + classic.apply_unified_action(action) + fast.apply_unified_action(action) + steps += 1 + assert steps < 1000 + + +def test_fast_apply_undo_restores_every_legal_action() -> None: + config = LostCitiesConfig( + n_colors=3, + n_ranks=5, + min_rank=2, + n_handshakes=1, + hand_size=5, + ) + for seed in range(32): + state = FastGameState.new_game(config, seed=seed) + rng = random.Random(seed ^ 0xFA57A11) + steps = 0 + + while not state.terminal: + legal = [index for index, is_legal in enumerate(state.unified_legal_mask()) if is_legal] + for action in legal: + candidate = state.clone() + before = candidate.to_snapshot() + undo = candidate.apply_unified_action_with_undo(action) + candidate.undo_action(undo) + assert candidate.to_snapshot() == before + candidate.validate_invariants() + + state.apply_unified_action(rng.choice(legal)) + steps += 1 + assert steps < 1000