"""Independent pure-Python Lost Cities rules reference. This module intentionally uses ordinary Python containers rather than the JAX state representation. It is deterministic under an explicit ``deck_order`` and the shared flat action encoding. """ from __future__ import annotations from bisect import insort from dataclasses import dataclass from random import Random N_PLAYERS = 2 N_COLORS = 5 CARDS_PER_COLOR = 12 N_CARDS = N_COLORS * CARDS_PER_COLOR HAND_SIZE = 8 INITIAL_DEAL = N_PLAYERS * HAND_SIZE MAX_STEPS = 400 N_ACTIONS = HAND_SIZE * 2 * 6 LOC_DECK = 0 LOC_P0_HAND = 1 LOC_P1_HAND = 2 LOC_P0_BOARD = 3 LOC_P1_BOARD = 4 LOC_DISCARD = 5 NO_CARD = -1 PLAY = 0 DISCARD = 1 DRAW_DECK = 0 @dataclass class RefState: deck_order: list[int] draw_ptr: int card_loc: list[int] hand_public: list[bool] hands: list[list[int]] board: list[list[list[int]]] col_top: list[list[int]] piles: list[list[int]] to_move: int just_discarded: int step_count: int done: bool def reset(seed: int | None = None) -> RefState: rng = Random(seed) order = list(range(N_CARDS)) rng.shuffle(order) return reset_from_order(order) def reset_from_order(deck_order: list[int] | tuple[int, ...]) -> RefState: order = [int(card) for card in deck_order] card_loc = [LOC_DECK] * N_CARDS p0_hand = sorted(order[:HAND_SIZE]) p1_hand = sorted(order[HAND_SIZE:INITIAL_DEAL]) for card in p0_hand: card_loc[card] = LOC_P0_HAND for card in p1_hand: card_loc[card] = LOC_P1_HAND return RefState( deck_order=order, draw_ptr=INITIAL_DEAL, card_loc=card_loc, hand_public=[False] * N_CARDS, hands=[p0_hand, p1_hand], board=[[[] for _ in range(N_COLORS)] for _ in range(N_PLAYERS)], col_top=[[0 for _ in range(N_COLORS)] for _ in range(N_PLAYERS)], piles=[[] for _ in range(N_COLORS)], to_move=0, just_discarded=NO_CARD, step_count=0, done=False, ) def clone_state(state: RefState) -> RefState: return RefState( deck_order=list(state.deck_order), draw_ptr=state.draw_ptr, card_loc=list(state.card_loc), hand_public=list(state.hand_public), hands=[list(hand) for hand in state.hands], board=[[list(col) for col in player] for player in state.board], col_top=[list(player) for player in state.col_top], piles=[list(pile) for pile in state.piles], to_move=state.to_move, just_discarded=state.just_discarded, step_count=state.step_count, done=state.done, ) def decode_action(action: int) -> tuple[int, int, int]: action = int(action) hand_slot = action // 12 rem = action % 12 place_type = rem // 6 draw_source = rem % 6 return hand_slot, place_type, draw_source def hand_cards(state: RefState, player: int | None = None) -> list[int]: if player is None: player = state.to_move return list(state.hands[player]) def legal_action_mask(state: RefState) -> list[bool]: bits = legal_action_bits(state) return [bool(bits & (1 << action)) for action in range(N_ACTIONS)] def legal_action_bits(state: RefState) -> int: bits = 0 if state.done: return bits player = state.to_move hand = state.hands[player] draw_bits = 1 for color, pile in enumerate(state.piles): if pile and pile[-1] != state.just_discarded: draw_bits |= 1 << (color + 1) for hand_slot, card in enumerate(hand[:HAND_SIZE]): base = hand_slot * 12 if can_play_card(state, player, card): bits |= draw_bits << base color = card_color(card) discard_bits = draw_bits & ~(1 << (color + 1)) bits |= discard_bits << (base + 6) return bits def nth_legal_action(bits: int, index: int) -> int: """Return the ``index``-th set action bit from low to high.""" remaining = int(index) action = 0 while bits: if bits & 1: if remaining == 0: return action remaining -= 1 action += 1 bits >>= 1 raise IndexError(index) def step( state: RefState, action: int, *, validate: bool = True ) -> tuple[RefState, list[float], bool]: if state.done or action < 0 or action >= N_ACTIONS: return clone_state(state), [0.0, 0.0], state.done if validate and not legal_action_mask(state)[action]: return clone_state(state), [0.0, 0.0], state.done next_state = clone_state(state) _, reward, done = step_in_place(next_state, action, validate=False) return next_state, reward, done def step_in_place( state: RefState, action: int, *, validate: bool = True ) -> tuple[RefState, list[float], bool]: if state.done or action < 0 or action >= N_ACTIONS: return state, [0.0, 0.0], state.done if validate and not legal_action_mask(state)[action]: return state, [0.0, 0.0], state.done player = state.to_move hand_slot, place_type, draw_source = decode_action(action) hand = state.hands[player] card = hand.pop(hand_slot) color = card_color(card) state.hand_public[card] = False if place_type == PLAY: state.board[player][color].append(card) if not is_handshake(card): state.col_top[player][color] = rank(card) state.card_loc[card] = LOC_P0_BOARD + player else: state.piles[color].append(card) state.card_loc[card] = LOC_DISCARD if draw_source == DRAW_DECK: drawn = state.deck_order[state.draw_ptr] state.draw_ptr += 1 public = False else: src = draw_source - 1 drawn = state.piles[src].pop() public = True insort(hand, drawn) state.card_loc[drawn] = LOC_P0_HAND + player state.hand_public[drawn] = public state.just_discarded = NO_CARD state.step_count += 1 state.done = (draw_source == DRAW_DECK and state.draw_ptr >= N_CARDS) or ( state.step_count >= MAX_STEPS ) state.to_move = 1 - player reward = board_score(state) if state.done else [0.0, 0.0] return state, reward, state.done def can_play_card(state: RefState, player: int, card: int) -> bool: top_rank = state.col_top[player][card_color(card)] if is_handshake(card): return top_rank == 0 return rank(card) > top_rank def board_score(state: RefState) -> list[float]: return [float(sum(score_column(column) for column in player)) for player in state.board] def score(state: RefState) -> list[float]: return board_score(state) def score_column(column: list[int]) -> int: if not column: return 0 handshakes = sum(1 for card in column if is_handshake(card)) rank_sum = sum(rank(card) for card in column if not is_handshake(card)) value = (rank_sum - 20) * (1 + handshakes) if len(column) >= 8: value += 20 return value def card_color(card: int) -> int: return int(card) // CARDS_PER_COLOR def card_slot(card: int) -> int: return int(card) % CARDS_PER_COLOR def is_handshake(card: int) -> bool: return card_slot(card) < 3 def rank(card: int) -> int: return card_slot(card) - 1 def _can_draw_after_place(state: RefState, card: int, place_type: int, draw_source: int) -> bool: if draw_source == DRAW_DECK: return True src = draw_source - 1 same_discard_pile = place_type == DISCARD and card_color(card) == src after_len = len(state.piles[src]) + int(same_discard_pile) if after_len == 0: return False after_top = card if same_discard_pile else state.piles[src][-1] just_discarded = card if place_type == DISCARD else state.just_discarded return after_top != just_discarded __all__ = [ "N_ACTIONS", "RefState", "board_score", "clone_state", "decode_action", "hand_cards", "legal_action_bits", "legal_action_mask", "nth_legal_action", "reset", "reset_from_order", "score", "step", "step_in_place", ]