로스트 시티 클래식 코어 이식
맥락: - 새 레포의 첫 범위를 RL 없는 Lost Cities classic 게임 구현으로 잡았다. - 기존 tier0-3 실험 축은 제거하고 classic 5-expedition 룰을 기본값으로 둔다. 변경: - games/classic 아래에 Cython 게임 엔진, env, bots, backend 경계, Rust core와 proto schema를 이식했다. - setuptools/Cython 빌드 설정과 package data, README, classic port notes를 추가했다. - 룰, 점수, 마스크, env, canonical state, bot, Rust parity 테스트를 새 경로로 가져왔다. 확인: - uv run pytest tests/games/classic - uv run lost-cities-classic
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
from coolrl_lost_cities.games.classic.bots import (
|
||||
LostCitiesBot,
|
||||
RandomBot,
|
||||
SafeHeuristicBot,
|
||||
play_game,
|
||||
)
|
||||
from coolrl_lost_cities.games.classic.game import Card, GameState, LostCitiesConfig
|
||||
from coolrl_lost_cities.games.classic.bots.heuristic import draw_from_discard_action
|
||||
|
||||
|
||||
def test_builtin_bots_implement_lost_cities_bot() -> None:
|
||||
assert isinstance(RandomBot(1), LostCitiesBot)
|
||||
assert isinstance(SafeHeuristicBot(), LostCitiesBot)
|
||||
|
||||
|
||||
def test_safe_heuristic_mirror_match_finishes() -> None:
|
||||
state = play_game(
|
||||
SafeHeuristicBot(),
|
||||
SafeHeuristicBot(),
|
||||
LostCitiesConfig(n_colors=3, n_ranks=5, n_handshakes=1, hand_size=5),
|
||||
seed=2000,
|
||||
max_steps=200,
|
||||
)
|
||||
assert state.terminal is True
|
||||
|
||||
|
||||
def test_safe_heuristic_opponent_value_ignores_hidden_hand() -> None:
|
||||
config = LostCitiesConfig(n_colors=2, n_ranks=8, hand_size=3)
|
||||
bot = SafeHeuristicBot()
|
||||
discard_card = Card(color=0, rank=6)
|
||||
|
||||
state_a = GameState.empty(config)
|
||||
state_a.expeditions[1][0] = [Card(color=0, rank=0), Card(color=0, rank=4)]
|
||||
state_a.discards[0] = [discard_card]
|
||||
state_a.hands[1] = [Card(color=0, rank=5)]
|
||||
|
||||
state_b = GameState.empty(config)
|
||||
state_b.expeditions[1][0] = [Card(color=0, rank=0), Card(color=0, rank=4)]
|
||||
state_b.discards[0] = [discard_card]
|
||||
state_b.hands[1] = [Card(color=0, rank=5), Card(color=0, rank=7), Card(color=0, rank=8)]
|
||||
|
||||
value_a = bot._card_value_for_opponent(
|
||||
state=state_a,
|
||||
opponent=1,
|
||||
card=discard_card,
|
||||
derived=bot._derived(state_a),
|
||||
)
|
||||
value_b = bot._card_value_for_opponent(
|
||||
state=state_b,
|
||||
opponent=1,
|
||||
card=discard_card,
|
||||
derived=bot._derived(state_b),
|
||||
)
|
||||
|
||||
assert value_a == value_b
|
||||
|
||||
|
||||
def test_safe_heuristic_started_expedition_value_ignores_invalid_lower_followup() -> None:
|
||||
config = LostCitiesConfig(n_colors=2, n_ranks=8, hand_size=3)
|
||||
bot = SafeHeuristicBot()
|
||||
high_card = Card(color=0, rank=8)
|
||||
|
||||
base_state = GameState.empty(config)
|
||||
base_state.expeditions[0][0] = [Card(color=0, rank=4)]
|
||||
base_state.hands[0] = [high_card]
|
||||
|
||||
lower_followup_state = GameState.empty(config)
|
||||
lower_followup_state.expeditions[0][0] = [Card(color=0, rank=4)]
|
||||
lower_followup_state.hands[0] = [Card(color=0, rank=5), high_card]
|
||||
|
||||
base_value = bot._started_expedition_play_value(
|
||||
state=base_state,
|
||||
player=0,
|
||||
card=high_card,
|
||||
derived=bot._derived(base_state),
|
||||
deck_left=config.deck_size,
|
||||
)
|
||||
lower_followup_value = bot._started_expedition_play_value(
|
||||
state=lower_followup_state,
|
||||
player=0,
|
||||
card=high_card,
|
||||
derived=bot._derived(lower_followup_state),
|
||||
deck_left=config.deck_size,
|
||||
)
|
||||
|
||||
assert lower_followup_value == base_value
|
||||
|
||||
|
||||
def test_safe_heuristic_draws_playable_discard_instead_of_deck() -> None:
|
||||
config = LostCitiesConfig(n_colors=2, n_ranks=8, hand_size=3)
|
||||
bot = SafeHeuristicBot()
|
||||
|
||||
state = GameState.empty(config)
|
||||
state.current_player = 0
|
||||
state.phase = "draw"
|
||||
state.expeditions[0][0] = [Card(color=0, rank=4)]
|
||||
state.discards[0] = [Card(color=0, rank=6)]
|
||||
state.deck = [Card(color=1, rank=8)]
|
||||
|
||||
assert bot._act_draw(state) == draw_from_discard_action(0)
|
||||
|
||||
|
||||
def test_safe_heuristic_can_draw_discard_to_deny_opponent_when_losing() -> None:
|
||||
config = LostCitiesConfig(n_colors=2, n_ranks=8, hand_size=4)
|
||||
bot = SafeHeuristicBot()
|
||||
|
||||
state = GameState.empty(config)
|
||||
state.current_player = 0
|
||||
state.phase = "draw"
|
||||
state.deck = [Card(color=1, rank=8), Card(color=1, rank=7)]
|
||||
state.hands[0] = [Card(color=0, rank=0), Card(color=0, rank=7)]
|
||||
state.expeditions[0][1] = [Card(color=1, rank=8)]
|
||||
state.expeditions[1][0] = [
|
||||
Card(color=0, rank=0),
|
||||
Card(color=0, rank=5),
|
||||
Card(color=0, rank=6),
|
||||
Card(color=0, rank=7),
|
||||
Card(color=0, rank=8),
|
||||
]
|
||||
state.discards[0] = [Card(color=0, rank=6)]
|
||||
|
||||
assert state.score_diff(0) < 0
|
||||
assert bot._act_draw(state) == draw_from_discard_action(0)
|
||||
|
||||
|
||||
def test_safe_heuristic_classic_self_play_opens_expeditions() -> None:
|
||||
state = GameState.new_game(LostCitiesConfig(), seed=1)
|
||||
bot = SafeHeuristicBot()
|
||||
player0_actions: list[int] = []
|
||||
|
||||
for _ in range(60):
|
||||
if state.terminal:
|
||||
break
|
||||
action = bot.act(state)
|
||||
unified = state.to_unified_action(action)
|
||||
if state.current_player == 0:
|
||||
player0_actions.append(unified)
|
||||
state.apply_unified_action(unified)
|
||||
|
||||
play_actions = [
|
||||
action
|
||||
for action in player0_actions
|
||||
if action < state.config.card_action_size and action % 2 == 0
|
||||
]
|
||||
|
||||
assert play_actions
|
||||
assert any(state.expeditions[0][color] for color in range(state.config.n_colors))
|
||||
|
||||
|
||||
def test_safe_heuristic_avoids_opening_weak_fifth_color() -> None:
|
||||
config = LostCitiesConfig(n_colors=5, n_ranks=8, hand_size=8)
|
||||
bot = SafeHeuristicBot()
|
||||
state = GameState.empty(config)
|
||||
state.current_player = 0
|
||||
state.phase = "card"
|
||||
|
||||
state.expeditions[0][0] = [Card(color=0, rank=4)]
|
||||
state.expeditions[0][1] = [Card(color=1, rank=4)]
|
||||
state.expeditions[0][2] = [Card(color=2, rank=5)]
|
||||
state.expeditions[0][3] = [Card(color=3, rank=6)]
|
||||
weak_open = Card(color=4, rank=4)
|
||||
state.hands[0] = [weak_open, Card(color=4, rank=7), Card(color=0, rank=6)]
|
||||
state.sort_hand(0)
|
||||
|
||||
assert bot._should_open_expedition(
|
||||
state=state,
|
||||
player=0,
|
||||
color=4,
|
||||
opening_card=weak_open,
|
||||
derived=bot._derived(state),
|
||||
deck_left=config.deck_size,
|
||||
) is False
|
||||
|
||||
|
||||
def test_safe_heuristic_prefers_followup_on_started_expedition() -> None:
|
||||
config = LostCitiesConfig(n_colors=3, n_ranks=8, hand_size=5)
|
||||
bot = SafeHeuristicBot()
|
||||
state = GameState.empty(config)
|
||||
state.current_player = 0
|
||||
state.phase = "card"
|
||||
state.expeditions[0][0] = [Card(color=0, rank=4)]
|
||||
state.hands[0] = [Card(color=0, rank=6), Card(color=1, rank=4), Card(color=1, rank=7)]
|
||||
state.sort_hand(0)
|
||||
|
||||
action = bot._act_card(state)
|
||||
chosen = state.hands[0][action // 2]
|
||||
|
||||
assert action % 2 == 0
|
||||
assert chosen.color == 0
|
||||
|
||||
|
||||
def test_safe_heuristic_avoids_unopened_discard_draw_after_four_opens() -> None:
|
||||
config = LostCitiesConfig(n_colors=5, n_ranks=8, hand_size=8)
|
||||
bot = SafeHeuristicBot()
|
||||
state = GameState.empty(config)
|
||||
state.current_player = 0
|
||||
state.phase = "draw"
|
||||
state.deck = [Card(color=0, rank=8), Card(color=1, rank=8)]
|
||||
state.expeditions[0][0] = [Card(color=0, rank=4)]
|
||||
state.expeditions[0][1] = [Card(color=1, rank=4)]
|
||||
state.expeditions[0][2] = [Card(color=2, rank=5)]
|
||||
state.expeditions[0][3] = [Card(color=3, rank=6)]
|
||||
state.hands[0] = [Card(color=4, rank=4), Card(color=4, rank=7)]
|
||||
state.discards[4] = [Card(color=4, rank=5)]
|
||||
|
||||
assert bot._act_draw(state) == 0
|
||||
@@ -0,0 +1,136 @@
|
||||
import json
|
||||
import random
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
import coolrl_lost_cities.games.classic as classic
|
||||
from coolrl_lost_cities.games.classic.game import Card, GameState, LostCitiesConfig
|
||||
|
||||
|
||||
FIXTURE_DIR = Path(classic.__file__).resolve().parent / "fixtures"
|
||||
|
||||
|
||||
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_new_game_from_deck_uses_explicit_internal_deck_order() -> None:
|
||||
config = _small_config()
|
||||
state = GameState.new_game_from_deck(
|
||||
[
|
||||
Card(0, 1),
|
||||
Card(0, 2),
|
||||
Card(1, 1),
|
||||
Card(1, 2),
|
||||
],
|
||||
config,
|
||||
)
|
||||
|
||||
assert state.hands == [[Card(1, 2)], [Card(1, 1)]]
|
||||
assert state.deck == [Card(0, 1), Card(0, 2)]
|
||||
state.validate_invariants()
|
||||
|
||||
|
||||
def test_snapshot_roundtrip_preserves_json_state() -> None:
|
||||
state = GameState.new_game(LostCitiesConfig(seed=5))
|
||||
first_action = next(
|
||||
index for index, legal in enumerate(state.unified_legal_mask()) if legal
|
||||
)
|
||||
state.apply_unified_action(first_action)
|
||||
second_action = next(
|
||||
index for index, legal in enumerate(state.unified_legal_mask()) if legal
|
||||
)
|
||||
state.apply_unified_action(second_action)
|
||||
|
||||
payload = json.loads(json.dumps(state.to_snapshot()))
|
||||
restored = GameState.from_snapshot(payload)
|
||||
|
||||
assert restored.to_snapshot() == state.to_snapshot()
|
||||
restored.validate_invariants()
|
||||
|
||||
|
||||
def test_validate_invariants_detects_card_loss() -> None:
|
||||
state = GameState.new_game(LostCitiesConfig(seed=7))
|
||||
state.deck.pop()
|
||||
|
||||
with pytest.raises(ValueError, match="card conservation"):
|
||||
state.validate_invariants()
|
||||
|
||||
|
||||
def test_validate_invariants_detects_bad_expedition_order() -> None:
|
||||
state = GameState.new_game(LostCitiesConfig(seed=8))
|
||||
card = state.deck.pop()
|
||||
state.expeditions[0][card.color].extend([Card(card.color, 2), Card(card.color, 1)])
|
||||
state.deck.extend([Card(card.color, 2), Card(card.color, 1)])
|
||||
|
||||
with pytest.raises(ValueError, match="strictly increasing"):
|
||||
state.validate_invariants()
|
||||
|
||||
|
||||
def test_canonical_small_fixture_matches_expected_trace() -> None:
|
||||
fixture = json.loads((FIXTURE_DIR / "canonical_small.json").read_text())
|
||||
config = LostCitiesConfig(**fixture["config"])
|
||||
state = GameState.new_game_from_deck(fixture["initial_deck"], config)
|
||||
|
||||
for step in fixture["steps"]:
|
||||
if step["action"] is not None:
|
||||
state.apply_unified_action(step["action"])
|
||||
assert state.phase == step["phase"]
|
||||
assert state.current_player == step["current_player"]
|
||||
assert state.turn_count == step["turn_count"]
|
||||
assert state.terminal is step["terminal"]
|
||||
assert state.score_diff(0) == step["score_diff_player0"]
|
||||
assert state.unified_legal_mask() == step["legal_mask"]
|
||||
state.validate_invariants()
|
||||
|
||||
|
||||
def test_random_games_preserve_python_core_invariants() -> None:
|
||||
config = LostCitiesConfig(
|
||||
n_colors=3,
|
||||
n_ranks=5,
|
||||
min_rank=2,
|
||||
n_handshakes=1,
|
||||
hand_size=5,
|
||||
)
|
||||
for seed in range(128):
|
||||
state = GameState.new_game(config, seed=seed)
|
||||
rng = random.Random(seed ^ 0x5EED)
|
||||
steps = 0
|
||||
while not state.terminal:
|
||||
state.validate_invariants()
|
||||
legal = [
|
||||
index
|
||||
for index, is_legal in enumerate(state.unified_legal_mask())
|
||||
if is_legal
|
||||
]
|
||||
state.apply_unified_action(rng.choice(legal))
|
||||
steps += 1
|
||||
assert steps < 1000
|
||||
state.validate_invariants()
|
||||
|
||||
|
||||
def test_same_seed_and_action_sequence_are_deterministic() -> None:
|
||||
config = LostCitiesConfig(seed=1234)
|
||||
left = GameState.new_game(config)
|
||||
right = GameState.new_game(config)
|
||||
|
||||
while True:
|
||||
assert left.to_snapshot() == right.to_snapshot()
|
||||
if left.terminal:
|
||||
break
|
||||
|
||||
action = next(
|
||||
index for index, is_legal in enumerate(left.unified_legal_mask()) if is_legal
|
||||
)
|
||||
left.apply_unified_action(action)
|
||||
right.apply_unified_action(action)
|
||||
@@ -0,0 +1,62 @@
|
||||
import numpy as np
|
||||
|
||||
from coolrl_lost_cities.games.classic.env import LostCitiesEnv
|
||||
from coolrl_lost_cities.games.classic.game import Card, GameState, LostCitiesConfig
|
||||
|
||||
|
||||
def test_env_observation_uses_fixed_unified_mask() -> None:
|
||||
config = LostCitiesConfig()
|
||||
env = LostCitiesEnv(config)
|
||||
|
||||
obs = env.reset()
|
||||
assert obs["legal_mask"].shape == (config.action_size,)
|
||||
assert env.phase == "card"
|
||||
|
||||
card_action = int(np.nonzero(obs["legal_mask"])[0][0])
|
||||
obs, _, _, _ = env.step(card_action)
|
||||
|
||||
assert env.phase == "draw"
|
||||
assert obs["legal_mask"].shape == (config.action_size,)
|
||||
assert np.all(obs["legal_mask"][:config.card_action_size] == 0)
|
||||
assert np.any(obs["legal_mask"][config.card_action_size:])
|
||||
|
||||
|
||||
def test_env_step_accepts_legacy_draw_action_ids() -> None:
|
||||
config = LostCitiesConfig()
|
||||
env = LostCitiesEnv(config)
|
||||
env.state = GameState.empty(config)
|
||||
env.state.hands[0] = [Card(0, 1)]
|
||||
env.state.hands[1] = [Card(1, 1)]
|
||||
env.state.deck = [Card(2, 1), Card(2, 2)]
|
||||
env.state.phase = "draw"
|
||||
|
||||
obs, reward, done, _ = env.step(0)
|
||||
|
||||
assert obs["legal_mask"].shape == (config.action_size,)
|
||||
assert reward == 0.0
|
||||
assert done is False
|
||||
assert env.current_player == 1
|
||||
assert env.phase == "card"
|
||||
|
||||
|
||||
def test_terminal_reward_is_relative_to_actor() -> None:
|
||||
config = LostCitiesConfig(
|
||||
n_colors=2,
|
||||
n_ranks=1,
|
||||
min_rank=1,
|
||||
n_handshakes=0,
|
||||
hand_size=1,
|
||||
expedition_penalty=0,
|
||||
bonus_threshold=99,
|
||||
)
|
||||
env = LostCitiesEnv(config)
|
||||
env.state = GameState.empty(config)
|
||||
env.state.current_player = 1
|
||||
env.state.phase = "draw"
|
||||
env.state.deck = [Card(1, 1)]
|
||||
env.state.expeditions[1][0] = [Card(0, 1)]
|
||||
|
||||
_, reward, done, _ = env.step(config.card_action_size)
|
||||
|
||||
assert done is True
|
||||
assert reward == 1.0
|
||||
@@ -0,0 +1,58 @@
|
||||
from coolrl_lost_cities.games.classic.bots import RandomBot
|
||||
from coolrl_lost_cities.games.classic.game import Card, GameState, LostCitiesConfig
|
||||
|
||||
|
||||
def test_legal_mask_has_action_in_nonterminal_phases() -> None:
|
||||
state = GameState.new_game(LostCitiesConfig(seed=3))
|
||||
while not state.terminal:
|
||||
assert any(state.legal_mask())
|
||||
action = RandomBot(11).act(state)
|
||||
state.apply_action(action)
|
||||
|
||||
|
||||
def test_empty_hand_slots_are_masked() -> None:
|
||||
state = GameState.empty(LostCitiesConfig())
|
||||
state.hands[0] = [Card(0, 1)]
|
||||
mask = state.legal_card_mask()
|
||||
assert mask[0] is True
|
||||
assert mask[1] is True
|
||||
assert all(value is False for value in mask[2:])
|
||||
|
||||
|
||||
def test_empty_discard_pile_draw_is_illegal() -> None:
|
||||
state = GameState.empty(LostCitiesConfig())
|
||||
state.phase = "draw"
|
||||
state.deck = [Card(0, 1)]
|
||||
mask = state.legal_draw_mask()
|
||||
assert mask[0] is True
|
||||
assert all(mask[1 + color] is False for color in range(state.config.n_colors))
|
||||
|
||||
|
||||
def test_unified_legal_mask_has_fixed_shape_across_phases() -> None:
|
||||
config = LostCitiesConfig()
|
||||
state = GameState.new_game(config, seed=1)
|
||||
assert len(state.unified_legal_mask()) == config.action_size
|
||||
|
||||
action = next(index for index, legal in enumerate(state.legal_mask()) if legal)
|
||||
state.apply_action(action)
|
||||
|
||||
mask = state.unified_legal_mask()
|
||||
assert state.phase == "draw"
|
||||
assert len(mask) == config.action_size
|
||||
assert all(value is False for value in mask[:config.card_action_size])
|
||||
assert any(mask[config.card_action_size:])
|
||||
|
||||
|
||||
def test_random_fuzz_invariants() -> None:
|
||||
config = LostCitiesConfig(n_colors=3, n_ranks=5, n_handshakes=1, hand_size=5)
|
||||
bot = RandomBot(99)
|
||||
for seed in range(1000):
|
||||
state = GameState.new_game(config, seed=seed)
|
||||
steps = 0
|
||||
while not state.terminal:
|
||||
mask = state.legal_mask()
|
||||
assert any(mask)
|
||||
action = bot.act(state)
|
||||
state.apply_action(action)
|
||||
steps += 1
|
||||
assert steps < 1000
|
||||
@@ -0,0 +1,110 @@
|
||||
import pytest
|
||||
|
||||
from coolrl_lost_cities.games.classic.game import Card, GameState, IllegalMoveError, LostCitiesConfig, build_deck
|
||||
|
||||
|
||||
def test_deck_generation_count() -> None:
|
||||
config = LostCitiesConfig(n_colors=3, n_ranks=5, n_handshakes=1, hand_size=5)
|
||||
assert len(build_deck(config)) == config.n_colors * (config.n_ranks + config.n_handshakes)
|
||||
|
||||
|
||||
def test_initial_hands_remove_cards_from_deck() -> None:
|
||||
config = LostCitiesConfig(seed=7)
|
||||
state = GameState.new_game(config)
|
||||
assert len(state.hands[0]) == config.hand_size
|
||||
assert len(state.hands[1]) == config.hand_size
|
||||
assert len(state.deck) == config.deck_size - 2 * config.hand_size
|
||||
|
||||
|
||||
def test_play_must_be_ascending() -> None:
|
||||
config = LostCitiesConfig()
|
||||
state = GameState.empty(config)
|
||||
state.hands[0] = [Card(0, 2)]
|
||||
state.expeditions[0][0] = [Card(0, 4)]
|
||||
assert state.legal_card_mask()[0] is False
|
||||
|
||||
|
||||
def test_handshake_after_number_forbidden() -> None:
|
||||
config = LostCitiesConfig()
|
||||
state = GameState.empty(config)
|
||||
state.hands[0] = [Card(1, 0)]
|
||||
state.expeditions[0][1] = [Card(1, 1)]
|
||||
assert state.legal_card_mask()[0] is False
|
||||
|
||||
|
||||
def test_cannot_draw_just_discarded_color() -> None:
|
||||
config = LostCitiesConfig()
|
||||
state = GameState.empty(config)
|
||||
state.hands[0] = [Card(2, 2)]
|
||||
state.deck = [Card(0, 1)]
|
||||
state.apply_action(1)
|
||||
mask = state.legal_draw_mask()
|
||||
assert mask[1 + 2] is False
|
||||
|
||||
|
||||
def test_drawing_just_discarded_color_is_rejected() -> None:
|
||||
config = LostCitiesConfig()
|
||||
state = GameState.empty(config)
|
||||
state.hands[0] = [Card(2, 2)]
|
||||
state.deck = [Card(0, 1)]
|
||||
|
||||
state.apply_action(1)
|
||||
|
||||
with pytest.raises(IllegalMoveError):
|
||||
state.apply_action(1 + 2)
|
||||
|
||||
|
||||
def test_discarded_color_can_be_drawn_after_turn_advances() -> None:
|
||||
config = LostCitiesConfig()
|
||||
state = GameState.empty(config)
|
||||
state.hands[0] = [Card(2, 2)]
|
||||
state.hands[1] = [Card(0, 1)]
|
||||
state.deck = [Card(1, 1), Card(1, 2)]
|
||||
state.apply_action(1)
|
||||
state.apply_action(0)
|
||||
assert state.current_player == 1
|
||||
state.apply_action(1)
|
||||
assert state.phase == "draw"
|
||||
assert state.legal_draw_mask()[1 + 2] is True
|
||||
|
||||
|
||||
def test_discarded_card_is_removed_when_drawn_later() -> None:
|
||||
config = LostCitiesConfig()
|
||||
state = GameState.empty(config)
|
||||
state.hands[0] = [Card(2, 2)]
|
||||
state.hands[1] = [Card(0, 1)]
|
||||
state.deck = [Card(1, 1), Card(1, 2)]
|
||||
|
||||
state.apply_action(1)
|
||||
assert state.discards[2] == [Card(2, 2)]
|
||||
|
||||
state.apply_action(0)
|
||||
assert state.current_player == 1
|
||||
|
||||
state.apply_action(1)
|
||||
state.apply_action(1 + 2)
|
||||
|
||||
assert state.discards[2] == []
|
||||
assert Card(2, 2) in state.hands[1]
|
||||
|
||||
|
||||
def test_deck_exhaustion_ends_after_last_deck_draw() -> None:
|
||||
config = LostCitiesConfig()
|
||||
state = GameState.empty(config)
|
||||
state.hands[0] = [Card(0, 1)]
|
||||
state.deck = [Card(1, 1)]
|
||||
state.apply_action(1)
|
||||
state.apply_action(0)
|
||||
assert state.terminal is True
|
||||
assert len(state.deck) == 0
|
||||
|
||||
|
||||
def test_card_phase_can_end_game_when_no_draw_sources_exist() -> None:
|
||||
config = LostCitiesConfig(n_colors=3, n_ranks=5, n_handshakes=1, hand_size=5)
|
||||
state = GameState.empty(config)
|
||||
state.hands[0] = [Card(0, 1)]
|
||||
state.hands[1] = [Card(1, 1)]
|
||||
state.deck = []
|
||||
state.apply_action(1)
|
||||
assert state.phase == "draw"
|
||||
assert state.terminal is True
|
||||
@@ -0,0 +1,167 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
import random
|
||||
import subprocess
|
||||
|
||||
import coolrl_lost_cities.games.classic as classic
|
||||
from coolrl_lost_cities.games.classic.game import GameState, LostCitiesConfig, build_deck
|
||||
|
||||
|
||||
LOST_CITIES_DIR = Path(classic.__file__).resolve().parent
|
||||
FIXTURE_DIR = LOST_CITIES_DIR / "fixtures"
|
||||
RUST_CORE_DIR = LOST_CITIES_DIR / "rust_core"
|
||||
|
||||
|
||||
def _run_probe(*args: str) -> dict:
|
||||
result = subprocess.run(
|
||||
["cargo", "run", "--quiet", "--bin", "lost_cities_probe", "--", *args],
|
||||
cwd=RUST_CORE_DIR,
|
||||
check=True,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
)
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def _python_trace(path: Path) -> dict:
|
||||
fixture = json.loads(path.read_text())
|
||||
config = LostCitiesConfig(**fixture["config"])
|
||||
state = GameState.new_game_from_deck(fixture["initial_deck"], config)
|
||||
steps = []
|
||||
for step in fixture["steps"]:
|
||||
action = step["action"]
|
||||
if action is not None:
|
||||
state.apply_unified_action(action)
|
||||
state.validate_invariants()
|
||||
snapshot = state.to_snapshot()
|
||||
steps.append(
|
||||
{
|
||||
"action": action,
|
||||
"phase": state.phase,
|
||||
"current_player": state.current_player,
|
||||
"turn_count": state.turn_count,
|
||||
"terminal": state.terminal,
|
||||
"pending_discarded_color": state.pending_discarded_color,
|
||||
"score_diff_player0": state.score_diff(0),
|
||||
"legal_mask": state.unified_legal_mask(),
|
||||
"deck": snapshot["deck"],
|
||||
"hands": snapshot["hands"],
|
||||
"expeditions": snapshot["expeditions"],
|
||||
"discards": snapshot["discards"],
|
||||
}
|
||||
)
|
||||
return {"config": config.to_snapshot(), "steps": steps}
|
||||
|
||||
|
||||
def test_rust_default_config_matches_python_default() -> None:
|
||||
assert _run_probe("defaults") == LostCitiesConfig().to_snapshot()
|
||||
|
||||
|
||||
def test_rust_fixture_trace_matches_python_core() -> None:
|
||||
fixture_path = FIXTURE_DIR / "canonical_small.json"
|
||||
assert _run_probe("trace", str(fixture_path)) == _python_trace(fixture_path)
|
||||
|
||||
|
||||
def test_rust_randomized_fixture_traces_match_python_core(tmp_path: Path) -> None:
|
||||
config = LostCitiesConfig(
|
||||
n_colors=3,
|
||||
n_ranks=5,
|
||||
min_rank=2,
|
||||
n_handshakes=1,
|
||||
hand_size=5,
|
||||
)
|
||||
|
||||
for seed in range(12):
|
||||
deck = build_deck(config)
|
||||
rng = random.Random(seed)
|
||||
rng.shuffle(deck)
|
||||
state = GameState.new_game_from_deck(deck, config)
|
||||
steps = [{"action": None}]
|
||||
|
||||
for _ in range(64):
|
||||
if state.terminal:
|
||||
break
|
||||
legal = [
|
||||
index
|
||||
for index, is_legal in enumerate(state.unified_legal_mask())
|
||||
if is_legal
|
||||
]
|
||||
action = rng.choice(legal)
|
||||
state.apply_unified_action(action)
|
||||
steps.append({"action": action})
|
||||
|
||||
fixture_path = tmp_path / f"parity_{seed}.json"
|
||||
fixture_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"config": config.to_snapshot(),
|
||||
"initial_deck": [card.to_snapshot() for card in deck],
|
||||
"steps": steps,
|
||||
}
|
||||
)
|
||||
)
|
||||
assert _run_probe("trace", str(fixture_path)) == _python_trace(fixture_path)
|
||||
|
||||
|
||||
def test_rust_engine_contract_is_checked_from_python() -> None:
|
||||
result = _run_probe("engine")
|
||||
|
||||
assert result["duplicate_kind"] == "AlreadyExists"
|
||||
assert result["missing_config_kind"] == "InvalidArgument"
|
||||
assert result["empty_session_kind"] == "InvalidArgument"
|
||||
assert result["unknown_session_kind"] == "NotFound"
|
||||
assert result["invalid_observer_kind"] == "InvalidArgument"
|
||||
assert result["invalid_observer_state_unchanged"] is True
|
||||
assert result["invalid_observer_action_still_applies"] is True
|
||||
assert result["phase_flow"][:2] == [
|
||||
{
|
||||
"state_version": 0,
|
||||
"current_player": 0,
|
||||
"observer_player": 0,
|
||||
"phase": "card",
|
||||
"terminal": False,
|
||||
},
|
||||
{
|
||||
"state_version": 1,
|
||||
"current_player": 0,
|
||||
"observer_player": 0,
|
||||
"phase": "draw",
|
||||
"terminal": False,
|
||||
},
|
||||
]
|
||||
assert result["phase_flow"][2]["state_version"] == 2
|
||||
assert result["stale_kind"] == "FailedPrecondition"
|
||||
assert result["end_session_counts"] == [1, 0]
|
||||
assert result["off_turn_legal_empty"] is True
|
||||
assert result["full_session_terminal_reward_matches"] is True
|
||||
assert result["full_session_final_scores_match"] is True
|
||||
assert result["terminal_reject_kind"] == "FailedPrecondition"
|
||||
assert result["deterministic_match"] is True
|
||||
|
||||
|
||||
def test_rust_grpc_contract_is_checked_from_python() -> None:
|
||||
result = _run_probe("grpc")
|
||||
|
||||
assert result == {
|
||||
"round_trip_phase": "card",
|
||||
"opponent_legal_empty": True,
|
||||
"stale_code": "FailedPrecondition",
|
||||
"invalid_observer_code": "InvalidArgument",
|
||||
"invalid_observer_state_unchanged": True,
|
||||
"ended_session_code": "NotFound",
|
||||
}
|
||||
|
||||
|
||||
def test_rust_core_has_no_native_tests_left() -> None:
|
||||
rust_files = [
|
||||
path
|
||||
for path in (RUST_CORE_DIR / "src").rglob("*.rs")
|
||||
if "target" not in path.parts
|
||||
]
|
||||
for path in rust_files:
|
||||
text = path.read_text()
|
||||
assert "#[test]" not in text
|
||||
assert "#[tokio::test" not in text
|
||||
|
||||
tests_dir = RUST_CORE_DIR / "tests"
|
||||
assert not list(tests_dir.glob("*.rs"))
|
||||
@@ -0,0 +1,35 @@
|
||||
from coolrl_lost_cities.games.classic.game import Card, LostCitiesConfig, score_expedition
|
||||
|
||||
|
||||
def test_empty_expedition_scores_zero() -> None:
|
||||
assert score_expedition([], LostCitiesConfig()) == 0
|
||||
|
||||
|
||||
def test_handshake_only_deepens_negative_score() -> None:
|
||||
config = LostCitiesConfig(n_handshakes=3)
|
||||
assert score_expedition([Card(0, 0), Card(0, 0)], config) == -60
|
||||
|
||||
|
||||
def test_numbers_only_score() -> None:
|
||||
config = LostCitiesConfig()
|
||||
expedition = [Card(0, 1), Card(0, 3), Card(0, 5)]
|
||||
assert score_expedition(expedition, config) == (2 + 4 + 6 - 20)
|
||||
|
||||
|
||||
def test_two_handshakes_and_three_numbers() -> None:
|
||||
config = LostCitiesConfig(n_handshakes=3)
|
||||
expedition = [Card(0, 0), Card(0, 0), Card(0, 1), Card(0, 2), Card(0, 3)]
|
||||
assert score_expedition(expedition, config) == (2 + 3 + 4 - 20) * 3
|
||||
|
||||
|
||||
def test_bonus_threshold_adds_bonus() -> None:
|
||||
config = LostCitiesConfig(n_ranks=9, n_handshakes=3, bonus_threshold=4, bonus_amount=20)
|
||||
expedition = [Card(0, 1), Card(0, 2), Card(0, 3), Card(0, 4)]
|
||||
assert score_expedition(expedition, config) == (2 + 3 + 4 + 5 - 20) + 20
|
||||
|
||||
|
||||
def test_manual_multi_color_examples() -> None:
|
||||
config = LostCitiesConfig(n_handshakes=3)
|
||||
assert score_expedition([Card(1, 0), Card(1, 5)], config) == (6 - 20) * 2
|
||||
assert score_expedition([Card(2, 4), Card(2, 5)], config) == 5 + 6 - 20
|
||||
assert score_expedition([Card(0, 0), Card(0, 0), Card(0, 0)], config) == -80
|
||||
Reference in New Issue
Block a user