# coolrl-lost-cities Focused Lost Cities extraction from the legacy `coolrl` repository. The current implementation starts with the classic two-player card game: - classic 5-expedition rules by default - Python/Cython game engine - env wrapper - random, discard-only, and safe-heuristic bots - core rule, scoring, mask, env, canonical-state, bot, and GUI smoke tests Training code, Deep CFR, learned-policy evaluation, GUI, and web client are intentionally outside the first port. ## Development ```bash uv run pytest tests/games/classic uv run lost-cities-classic ``` For future GUI work, install the optional GUI dependencies: ```bash uv sync --extra gui ``` Run the classic pygame GUI: ```bash uv run lost-cities-classic-gui --mode pvc --bot safe-heuristic ``` The GUI uses the in-process Cython game engine. ## JAX Rules Engine `lost_cities_jax` is a standalone pure rules simulator for one two-player Lost Cities round. It does not contain neural networks, PPO, CFR, MCTS, match wrappers, bots, or rule variants. Public API: ```python from lost_cities_jax import ( OBS_DIM, N_ACTIONS, State, batched_legal_mask, batched_obs, batched_reset, batched_step, board_score, legal_action_mask, observation, reset, reset_from_order, score, step, ) ``` Core functions are pure JAX functions: - `reset(rng) -> State` - `reset_from_order(deck_order) -> State` - `legal_action_mask(state) -> bool[96]` - `step(state, action) -> (State, float32[2], bool)` - `score(state) -> float32[2]` - `board_score(state) -> float32[2]` - `observation(state, player) -> float32[454]` The batched exports are `jax.jit(jax.vmap(...))` wrappers. Illegal actions and done-state actions are defined as no-op transitions with zero reward; training code should still sample only from `legal_action_mask`. ### Rule Summary One round uses 60 cards: five colors, each with three handshakes and ranks 2 through 10. Each player starts with eight cards, then each ply must place one hand card to the matching expedition or discard pile and draw one card from the deck or a discard pile. A player may not draw the card they just discarded. Expedition numbers must be strictly increasing. Handshakes may be played only before any number in that color. The round ends immediately when the final deck card is drawn, or at `MAX_STEPS == 400`; forced termination is scored exactly like natural termination. Scoring per player/color: ```text empty column: 0 non-empty: (sum(number ranks) - 20) * (1 + handshake_count) bonus: +20 if total column length >= 8, not multiplied ``` ### Encodings Cards: | Field | Encoding | | --- | --- | | `card_id` | `color * 12 + slot` | | `color` | `0..4` | | `slot 0..2` | handshake | | `slot 3..11` | ranks `2..10`, with `rank = slot - 1` | Actions (`N_ACTIONS == 96`): ```text action_id = hand_slot * 12 + place_type * 6 + draw_source hand_slot = 0..7, current player's hand sorted by card_id place_type = 0 play, 1 discard draw_source = 0 deck, 1..5 discard pile color 0..4 ``` Observation (`OBS_DIM == 454`): - 60 cards x 7 one-hot channels: my hand, my board, opponent board, discard top, discard non-top, opponent public hand, unknown. - 34 scalar features: remaining deck `/44`, opponent unknown hand count `/8`, step count `/400`, current-player then opponent `col_top /10`, `col_hs /3`, `col_len /12`, and current board score difference `(player - opponent) /780`. ### Verification ```bash uv run pytest -q tests/lost_cities_jax uv run pytest -q uv run ruff check . ``` Large differential profiles: ```bash # CI profile: 100,000 random legal-policy games CI=1 uv run pytest -q tests/lost_cities_jax/test_differential.py # Full profile: 1,000,000 random legal-policy games uv run pytest -q tests/lost_cities_jax/test_differential.py --full ``` Throughput benchmark: ```bash flock -n .compute.lock uv run python benchmarks/throughput.py ``` Measured on 2026-07-04 with CPU JAX backend: ```text backend=cpu batch_size=8192 steps=256 elapsed_sec=4.944725 steps_per_sec=424119.01 ``` ### DECISIONS.md - Explicit `deck_order` dealing uses the first eight cards for player 0 and the next eight for player 1. The remaining cards are drawn from index 16. This is equivalent under a uniform shuffle and is fixed by tests. - After a legal terminal transition, `to_move` is advanced to the next player, but `done=True` makes all later steps complete no-ops. - Terminal reward is emitted only on the transition that reaches `done=True`. Done-state no-op steps return zero reward. - Observation scalar normalization is implementation-defined as documented above and locked by the exported `OBS_DIM`. ## Basic Usage ```python from coolrl_lost_cities.games.classic import GameState, build_bot, classic_config state = GameState.new_game(classic_config(seed=1)) bot = build_bot("random", seed=1) while not state.terminal: state.apply_action(bot.act(state)) print(state.total_score(0), state.total_score(1)) ``` See [classic port notes](docs/archive/classic-port-notes.md) for the current direction.