Files
coorl-lost-cities/tests/games/classic/test_canonical_state.py
T

209 lines
6.5 KiB
Python

import json
import random
from pathlib import Path
import pytest
from coolrl_lost_cities.games.classic.game import Card, GameState, LostCitiesConfig
import coolrl_lost_cities.games.classic as classic
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_new_game_preserves_dealt_hand_order() -> None:
config = LostCitiesConfig(
n_colors=2,
n_ranks=3,
min_rank=1,
n_handshakes=0,
hand_size=2,
expedition_penalty=0,
bonus_threshold=99,
bonus_amount=0,
)
state = GameState.new_game_from_deck(
[
Card(0, 1),
Card(0, 2),
Card(0, 3),
Card(1, 1),
Card(1, 2),
Card(1, 3),
],
config,
)
assert state.hands[0] == [Card(1, 3), Card(1, 1)]
assert state.hands[1] == [Card(1, 2), Card(0, 3)]
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_apply_action_with_undo_restores_every_legal_action() -> None:
config = LostCitiesConfig(
n_colors=3,
n_ranks=5,
min_rank=2,
n_handshakes=1,
hand_size=5,
)
for seed in range(32):
state = GameState.new_game(config, seed=seed)
rng = random.Random(seed ^ 0xA11CE)
steps = 0
while not state.terminal:
legal = [index for index, is_legal in enumerate(state.unified_legal_mask()) if is_legal]
for unified_action in legal:
candidate = state.clone()
before = candidate.to_snapshot()
undo = candidate.apply_unified_action_with_undo(unified_action)
candidate.undo_action(undo)
assert candidate.to_snapshot() == before
candidate.validate_invariants()
state.apply_unified_action(rng.choice(legal))
steps += 1
assert steps < 1000
def test_apply_action_with_undo_matches_apply_action_result() -> None:
config = LostCitiesConfig(
n_colors=3,
n_ranks=5,
min_rank=2,
n_handshakes=1,
hand_size=5,
)
for seed in range(32):
state = GameState.new_game(config, seed=seed)
rng = random.Random(seed ^ 0xC0FFEE)
steps = 0
while not state.terminal:
legal = [index for index, is_legal in enumerate(state.unified_legal_mask()) if is_legal]
action = rng.choice(legal)
left = state.clone()
right = state.clone()
left.apply_unified_action(action)
right.apply_unified_action_with_undo(action)
assert right.to_snapshot() == left.to_snapshot()
state = left
steps += 1
assert steps < 1000
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)