# cython: language_level=3, boundscheck=False, wraparound=False, cdivision=True, initializedcheck=False """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 Phase = Literal["card", "draw"] class IllegalMoveError(ValueError): """Raised when an action id is not legal for the current state.""" @cython.freelist(256) cdef class Card: cdef readonly int color cdef readonly int rank def __cinit__(self, color, rank): self.color = int(color) self.rank = int(rank) @property def is_handshake(self): return self.rank == 0 cpdef int numeric_value(self, int min_rank): if self.rank == 0: return 0 return min_rank + self.rank - 1 def label(self, int min_rank): if self.rank == 0: return f"[{self.color}]H" return f"[{self.color}]{self.numeric_value(min_rank)}" def to_snapshot(self): return {"color": self.color, "rank": self.rank} @classmethod def from_snapshot(cls, data): if isinstance(data, Card): return data if isinstance(data, dict): return cls(int(data["color"]), int(data["rank"])) if isinstance(data, (list, tuple)) and len(data) == 2: return cls(int(data[0]), int(data[1])) raise ValueError(f"invalid card snapshot: {data!r}") def __hash__(self): return (self.color << 8) | self.rank def __richcmp__(self, other, int op): if not isinstance(other, Card): return NotImplemented cdef Card o = other cdef bint eq = self.color == o.color and self.rank == o.rank if op == 2: # == return eq if op == 3: # != return not eq cdef bint lt if self.color != o.color: lt = self.color < o.color else: lt = self.rank < o.rank if op == 0: # < return lt if op == 1: # <= return lt or eq if op == 4: # > return not lt and not eq if op == 5: # >= return not lt return NotImplemented def __repr__(self): return f"Card(color={self.color}, rank={self.rank})" def __reduce__(self): return (Card, (self.color, self.rank)) @dataclass(frozen=True) class LostCitiesConfig: n_colors: int = 5 n_ranks: int = 9 min_rank: int = 2 n_handshakes: int = 3 hand_size: int = 8 expedition_penalty: int = -20 bonus_threshold: int = 8 bonus_amount: int = 20 seed: int | None = None @property def deck_size(self) -> int: return self.n_colors * (self.n_ranks + self.n_handshakes) @property def max_rank(self) -> int: return self.min_rank + self.n_ranks - 1 @property def card_action_size(self) -> int: return 2 * self.hand_size @property def draw_action_size(self) -> int: return 1 + self.n_colors @property def action_size(self) -> int: return self.card_action_size + self.draw_action_size def validate(self) -> None: if self.n_colors <= 0: raise ValueError("n_colors must be positive") if self.n_ranks <= 0: raise ValueError("n_ranks must be positive") if self.min_rank <= 0: raise ValueError("min_rank must be positive") if self.n_handshakes < 0: raise ValueError("n_handshakes cannot be negative") if self.hand_size <= 0: raise ValueError("hand_size must be positive") if self.deck_size < 2 * self.hand_size: raise ValueError("deck must contain at least both initial hands") if self.bonus_threshold <= 0: raise ValueError("bonus_threshold must be positive") def to_snapshot(self) -> dict[str, Any]: return {field.name: getattr(self, field.name) for field in fields(self)} def classic_config(*, seed=None): return LostCitiesConfig(seed=seed) def config_from_mapping(data): allowed = LostCitiesConfig.__dataclass_fields__.keys() kwargs = {key: value for key, value in data.items() if key in allowed} config = LostCitiesConfig(**kwargs) config.validate() return config def config_to_mapping(config): return config.to_snapshot() def load_config(path): try: import yaml except ImportError as exc: raise RuntimeError("pyyaml is required to load Lost Cities YAML configs") from exc with open(path, "r", encoding="utf-8") as handle: data = yaml.safe_load(handle) or {} if not isinstance(data, dict): raise ValueError(f"expected mapping in config file: {path}") return config_from_mapping(data) def build_deck(config): config.validate() cdef list deck = [] cdef int color, rank cdef int n_colors = config.n_colors cdef int n_handshakes = config.n_handshakes cdef int n_ranks = config.n_ranks for color in range(n_colors): for _ in range(n_handshakes): deck.append(Card(color, 0)) for rank in range(1, n_ranks + 1): deck.append(Card(color, rank)) return deck cpdef int score_expedition(list expedition, config): cdef int n = len(expedition) if n == 0: return 0 cdef int min_rank = config.min_rank cdef int handshakes = 0 cdef int numeric_sum = 0 cdef int i cdef Card card for i in range(n): card = expedition[i] if card.rank == 0: handshakes += 1 else: numeric_sum += min_rank + card.rank - 1 cdef int score = (numeric_sum + config.expedition_penalty) * (handshakes + 1) if n >= config.bonus_threshold: score += config.bonus_amount return score 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 swap_deck_cards(self, int left, int right): self._swap_deck_cards_c(left, right) 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 void _swap_deck_cards_c(self, int left, int right) except *: cdef int tmp if left < 0 or left >= self.deck_len: raise IndexError(f"deck index out of range: {left}") if right < 0 or right >= self.deck_len: raise IndexError(f"deck index out of range: {right}") if left == right: return tmp = self.deck_cards[left] self.deck_cards[left] = self.deck_cards[right] self.deck_cards[right] = tmp 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