로스트 시티 클래식 코어 이식

맥락:
- 새 레포의 첫 범위를 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:
2026-05-06 19:07:46 +09:00
commit 7df6e42904
46 changed files with 7531 additions and 0 deletions
+206
View File
@@ -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
+136
View File
@@ -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)
+62
View File
@@ -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
+58
View File
@@ -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
+110
View File
@@ -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
+167
View File
@@ -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"))
+35
View File
@@ -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