Files
coorl-lost-cities/src/lost_cities_jax/match.py
T
coolguyandClaude Opus 4.8 7cd299dd1e Add the classic three-round match layer
Wraps the single-round engine rather than changing it: engine.py is the rules
oracle the TypeScript client is differential-tested against, and the round
itself is unchanged by the match. Only two rules live up here, both from the
Kosmos rulebook -- three rounds decided on the summed total, and "the player who
has more points begins" the next one. That is not alternating, and it is not
what reset_from_order hardcoded, so it takes a first_player argument now.

The rulebook says nothing about an exact tie, so the starter falls back to a
coin flip. A deterministic tie-break would give one seat a standing edge in
symmetric self-play and the agent would learn to steer for it. Round one needs
no special case: carry is (0, 0) there, so the tie branch already yields the
coin, which is exactly the rulebook's arbitrary "oldest player begins".

All randomness -- three deals and three coins -- is drawn in match_reset and
stored in the state, so match_step stays deterministic and needs no PRNG key
threaded through every rollout, eval, and gate body. It also makes a mirrored
match (same deals, seats swapped, same coins) a pure seat relabel, which the
antithetic pairing later depends on.

Tests cover the deck clock (44 deck draws a round, discard draws extend it),
carry banking each round exactly once, the start-player rule across all three
branches, a fair round-one coin, mirror symmetry, and that the running total
does not jump across a round boundary -- the last one matters because potential
shaping will be built on it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XBQKgvBbxbheiTF1AVy1Sh
2026-07-15 02:05:50 +09:00

140 lines
5.1 KiB
Python

"""Classic Lost Cities: three rounds, scores summed, highest total wins.
The single-round engine in ``engine.py`` is left alone -- it is the rules oracle
the TypeScript client is differential-tested against. This module wraps it.
Two rules only exist at the match level, both from the Kosmos rulebook:
- "If after three games you have the highest overall score, you win."
- "The player who has more points begins" the next game. Not alternating. The
rulebook says nothing about an exact tie, so we flip a coin: any deterministic
tie-break would hand one seat a standing edge in symmetric self-play, and the
agent would learn to steer for it.
All randomness is drawn in ``match_reset`` and stored in the state, so
``match_step`` stays deterministic and needs no PRNG key. That keeps the step
signature clean through every rollout/eval body, and it means a mirrored match
(same three deals, seats swapped, same coin flips) is just a seat relabel.
"""
from __future__ import annotations
from typing import NamedTuple
import jax
import jax.numpy as jnp
from lost_cities_jax.engine import board_score, legal_action_mask, reset_from_order, step
from lost_cities_jax.types import N_CARDS, State
N_ROUNDS = 3
class MatchState(NamedTuple):
round: State
"""The round being played right now."""
deck_orders: jax.Array
"""(N_ROUNDS, N_CARDS) -- every deal of the match, shuffled up front."""
coin_flips: jax.Array
"""(N_ROUNDS,) tie-breaking starters, used only when the scores are level."""
round_idx: jax.Array
carry: jax.Array
"""(2,) points banked by each player in the rounds already finished."""
done: jax.Array
def match_reset(rng: jax.Array) -> MatchState:
deck_key, coin_key = jax.random.split(rng)
deck_keys = jax.random.split(deck_key, N_ROUNDS)
deck_orders = jax.vmap(
lambda key: jax.random.permutation(key, jnp.arange(N_CARDS, dtype=jnp.int32), axis=0)
)(deck_keys).astype(jnp.int8)
coin_flips = jax.random.bernoulli(coin_key, 0.5, shape=(N_ROUNDS,)).astype(jnp.int8)
return match_reset_from(deck_orders, coin_flips)
def match_reset_from(deck_orders: jax.Array, coin_flips: jax.Array) -> MatchState:
"""Build a match from explicit deals and coin flips (mirrored eval, fixtures)."""
deck_orders = jnp.asarray(deck_orders, dtype=jnp.int8)
coin_flips = jnp.asarray(coin_flips, dtype=jnp.int8)
carry = jnp.zeros((2,), dtype=jnp.int32)
round_idx = jnp.asarray(0, dtype=jnp.int32)
return MatchState(
round=reset_from_order(deck_orders[0], first_player=starting_player(carry, 0, coin_flips)),
deck_orders=deck_orders,
coin_flips=coin_flips,
round_idx=round_idx,
carry=carry,
done=jnp.asarray(False),
)
def starting_player(carry: jax.Array, round_idx: jax.Array, coin_flips: jax.Array) -> jax.Array:
"""Whoever has banked more points leads; level scores fall back to the coin.
Round one needs no special case: ``carry`` is (0, 0) there, so the tie branch
already picks the coin flip, which is exactly the rulebook's arbitrary
"oldest player begins".
"""
lead = carry[0] - carry[1]
coin = coin_flips[round_idx].astype(jnp.int8)
ahead = jnp.where(lead > 0, jnp.int8(0), jnp.int8(1))
return jnp.where(lead == 0, coin, ahead)
def match_score(state: MatchState) -> jax.Array:
"""(2,) running totals: rounds already banked plus the board in play."""
return state.carry + board_score(state.round).astype(jnp.int32)
def match_legal_action_mask(state: MatchState) -> jax.Array:
return legal_action_mask(state.round) & ~state.done
def match_step(state: MatchState, action: jax.Array) -> tuple[MatchState, jax.Array, jax.Array]:
"""Play one ply. Rolls into the next round when the deck runs out.
Reward is zero until the third round ends, then it is each player's match
total. Intermediate rounds pay nothing: only the sum decides the match.
"""
played, _, _ = step(state.round, action)
round_over = played.done & ~state.done
is_final_round = state.round_idx >= N_ROUNDS - 1
advance = round_over & ~is_final_round
finished = round_over & is_final_round
banked = state.carry + board_score(played).astype(jnp.int32)
next_carry = jnp.where(advance, banked, state.carry)
next_round_idx = jnp.where(advance, state.round_idx + 1, state.round_idx)
# Dealt eagerly every ply; only kept on the plies that actually roll over.
fresh = reset_from_order(
state.deck_orders[next_round_idx],
first_player=starting_player(next_carry, next_round_idx, state.coin_flips),
)
next_round = jax.tree_util.tree_map(
lambda new, old: jnp.where(advance, new, old), fresh, played
)
next_state = MatchState(
round=next_round,
deck_orders=state.deck_orders,
coin_flips=state.coin_flips,
round_idx=next_round_idx,
carry=next_carry,
done=state.done | finished,
)
total = state.carry + board_score(played).astype(jnp.int32)
reward = jnp.where(finished, total.astype(jnp.float32), jnp.zeros((2,), dtype=jnp.float32))
return next_state, reward, next_state.done