FastGameState를 기본 게임 상태로 전환
This commit is contained in:
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -13,7 +13,7 @@ def _card(color: int, rank: int) -> dict[str, int]:
|
||||
return {"color": color, "rank": rank}
|
||||
|
||||
|
||||
def _classic_snapshot(
|
||||
def _snapshot(
|
||||
*,
|
||||
deck: list[dict[str, int]] | None = None,
|
||||
hands: list[list[dict[str, int]]] | None = None,
|
||||
@@ -59,25 +59,26 @@ def _classic_snapshot(
|
||||
}
|
||||
|
||||
|
||||
def test_fast_new_game_from_deck_matches_game_state_snapshot() -> None:
|
||||
def test_public_game_state_alias_matches_fast_new_game_from_deck_snapshot() -> None:
|
||||
config = LostCitiesConfig()
|
||||
deck = build_deck(config)
|
||||
|
||||
classic = GameState.new_game_from_deck(deck, config)
|
||||
fast = FastGameState.new_game_from_deck(deck, config)
|
||||
assert GameState is FastGameState
|
||||
left = GameState.new_game_from_deck(deck, config)
|
||||
right = FastGameState.new_game_from_deck(deck, config)
|
||||
|
||||
assert fast.to_snapshot() == classic.to_snapshot()
|
||||
fast.validate_invariants()
|
||||
assert right.to_snapshot() == left.to_snapshot()
|
||||
right.validate_invariants()
|
||||
|
||||
|
||||
def test_fast_snapshot_roundtrip_preserves_snapshot() -> None:
|
||||
config = LostCitiesConfig(seed=11)
|
||||
classic = GameState.new_game(config)
|
||||
fast = FastGameState.from_snapshot(classic.to_snapshot())
|
||||
left = GameState.new_game(config)
|
||||
right = FastGameState.from_snapshot(left.to_snapshot())
|
||||
|
||||
assert fast.to_snapshot() == classic.to_snapshot()
|
||||
restored = FastGameState.from_snapshot(fast.to_snapshot())
|
||||
assert restored.to_snapshot() == fast.to_snapshot()
|
||||
assert right.to_snapshot() == left.to_snapshot()
|
||||
restored = FastGameState.from_snapshot(right.to_snapshot())
|
||||
assert restored.to_snapshot() == right.to_snapshot()
|
||||
|
||||
|
||||
def test_fast_from_snapshot_rejects_oversized_regions_before_write() -> None:
|
||||
@@ -125,39 +126,39 @@ def test_fast_validate_invariants_rejects_bad_expedition_order() -> None:
|
||||
FastGameState.from_snapshot(snapshot)
|
||||
|
||||
|
||||
def test_fast_pending_discard_matches_game_state() -> None:
|
||||
snapshot = _classic_snapshot(
|
||||
def test_fast_pending_discard_sequence_is_deterministic() -> None:
|
||||
snapshot = _snapshot(
|
||||
hands=[
|
||||
[_card(0, 1)],
|
||||
[_card(1, 1)],
|
||||
],
|
||||
deck=[_card(2, 1), _card(3, 1)],
|
||||
)
|
||||
classic = GameState.from_snapshot(snapshot)
|
||||
fast = FastGameState.from_snapshot(snapshot)
|
||||
left = GameState.from_snapshot(snapshot)
|
||||
right = FastGameState.from_snapshot(snapshot)
|
||||
|
||||
classic.apply_action(1)
|
||||
fast.apply_action(1)
|
||||
assert fast.to_snapshot() == classic.to_snapshot()
|
||||
assert fast.legal_draw_mask() == classic.legal_draw_mask()
|
||||
assert fast.legal_draw_mask()[1] is False
|
||||
left.apply_action(1)
|
||||
right.apply_action(1)
|
||||
assert right.to_snapshot() == left.to_snapshot()
|
||||
assert right.legal_draw_mask() == left.legal_draw_mask()
|
||||
assert right.legal_draw_mask()[1] is False
|
||||
|
||||
classic.apply_action(0)
|
||||
fast.apply_action(0)
|
||||
classic.apply_action(1)
|
||||
fast.apply_action(1)
|
||||
classic.apply_action(0)
|
||||
fast.apply_action(0)
|
||||
classic.apply_action(1)
|
||||
fast.apply_action(1)
|
||||
left.apply_action(0)
|
||||
right.apply_action(0)
|
||||
left.apply_action(1)
|
||||
right.apply_action(1)
|
||||
left.apply_action(0)
|
||||
right.apply_action(0)
|
||||
left.apply_action(1)
|
||||
right.apply_action(1)
|
||||
|
||||
assert fast.to_snapshot() == classic.to_snapshot()
|
||||
assert fast.legal_draw_mask() == classic.legal_draw_mask()
|
||||
assert fast.legal_draw_mask()[1] is True
|
||||
assert right.to_snapshot() == left.to_snapshot()
|
||||
assert right.legal_draw_mask() == left.legal_draw_mask()
|
||||
assert right.legal_draw_mask()[1] is True
|
||||
|
||||
|
||||
def test_fast_terminal_edges_match_game_state() -> None:
|
||||
last_draw_snapshot = _classic_snapshot(
|
||||
def test_fast_terminal_edges_are_deterministic() -> None:
|
||||
last_draw_snapshot = _snapshot(
|
||||
deck=[_card(1, 1)],
|
||||
hands=[
|
||||
[_card(0, 1)],
|
||||
@@ -168,16 +169,16 @@ def test_fast_terminal_edges_match_game_state() -> None:
|
||||
last_draw_snapshot["deck"] = [last_draw_snapshot["deck"][-1]]
|
||||
for card in remaining_deck:
|
||||
last_draw_snapshot["discards"][card["color"]].append(card)
|
||||
classic = GameState.from_snapshot(last_draw_snapshot)
|
||||
fast = FastGameState.from_snapshot(last_draw_snapshot)
|
||||
left = GameState.from_snapshot(last_draw_snapshot)
|
||||
right = FastGameState.from_snapshot(last_draw_snapshot)
|
||||
|
||||
classic.apply_action(1)
|
||||
fast.apply_action(1)
|
||||
classic.apply_action(0)
|
||||
fast.apply_action(0)
|
||||
left.apply_action(1)
|
||||
right.apply_action(1)
|
||||
left.apply_action(0)
|
||||
right.apply_action(0)
|
||||
|
||||
assert fast.to_snapshot() == classic.to_snapshot()
|
||||
assert fast.terminal is True
|
||||
assert right.to_snapshot() == left.to_snapshot()
|
||||
assert right.terminal is True
|
||||
|
||||
defensive_snapshot = {
|
||||
"config": LostCitiesConfig().to_snapshot(),
|
||||
@@ -191,18 +192,18 @@ def test_fast_terminal_edges_match_game_state() -> None:
|
||||
"turn_count": 0,
|
||||
"terminal": False,
|
||||
}
|
||||
classic = GameState.from_snapshot(defensive_snapshot, validate=False)
|
||||
fast = FastGameState.from_snapshot(defensive_snapshot, validate=False)
|
||||
left = GameState.from_snapshot(defensive_snapshot, validate=False)
|
||||
right = FastGameState.from_snapshot(defensive_snapshot, validate=False)
|
||||
|
||||
classic.apply_action(1)
|
||||
fast.apply_action(1)
|
||||
left.apply_action(1)
|
||||
right.apply_action(1)
|
||||
|
||||
assert fast.to_snapshot() == classic.to_snapshot()
|
||||
assert fast.terminal is True
|
||||
assert right.to_snapshot() == left.to_snapshot()
|
||||
assert right.terminal is True
|
||||
|
||||
|
||||
def test_fast_last_numeric_legality_matches_game_state() -> None:
|
||||
handshake_snapshot = _classic_snapshot(
|
||||
def test_fast_last_numeric_legality_edges() -> None:
|
||||
handshake_snapshot = _snapshot(
|
||||
hands=[
|
||||
[_card(0, 1)],
|
||||
[],
|
||||
@@ -212,12 +213,12 @@ def test_fast_last_numeric_legality_matches_game_state() -> None:
|
||||
[[], [], [], [], []],
|
||||
],
|
||||
)
|
||||
classic = GameState.from_snapshot(handshake_snapshot)
|
||||
fast = FastGameState.from_snapshot(handshake_snapshot)
|
||||
assert fast.legal_card_mask() == classic.legal_card_mask()
|
||||
assert fast.legal_card_mask()[0] is True
|
||||
left = GameState.from_snapshot(handshake_snapshot)
|
||||
right = FastGameState.from_snapshot(handshake_snapshot)
|
||||
assert right.legal_card_mask() == left.legal_card_mask()
|
||||
assert right.legal_card_mask()[0] is True
|
||||
|
||||
numeric_snapshot = _classic_snapshot(
|
||||
numeric_snapshot = _snapshot(
|
||||
hands=[
|
||||
[_card(0, 0), _card(0, 3), _card(0, 5)],
|
||||
[],
|
||||
@@ -227,16 +228,16 @@ def test_fast_last_numeric_legality_matches_game_state() -> None:
|
||||
[[], [], [], [], []],
|
||||
],
|
||||
)
|
||||
classic = GameState.from_snapshot(numeric_snapshot)
|
||||
fast = FastGameState.from_snapshot(numeric_snapshot)
|
||||
assert fast.legal_card_mask() == classic.legal_card_mask()
|
||||
assert fast.legal_card_mask()[0] is False
|
||||
assert fast.legal_card_mask()[2] is False
|
||||
assert fast.legal_card_mask()[4] is True
|
||||
left = GameState.from_snapshot(numeric_snapshot)
|
||||
right = FastGameState.from_snapshot(numeric_snapshot)
|
||||
assert right.legal_card_mask() == left.legal_card_mask()
|
||||
assert right.legal_card_mask()[0] is False
|
||||
assert right.legal_card_mask()[2] is False
|
||||
assert right.legal_card_mask()[4] is True
|
||||
|
||||
|
||||
def test_fast_score_cache_and_undo_match_game_state() -> None:
|
||||
snapshot = _classic_snapshot(
|
||||
def test_fast_score_cache_and_undo_restore_snapshot() -> None:
|
||||
snapshot = _snapshot(
|
||||
hands=[
|
||||
[_card(0, 7)],
|
||||
[],
|
||||
@@ -261,26 +262,26 @@ def test_fast_score_cache_and_undo_match_game_state() -> None:
|
||||
[[], [], [], [], []],
|
||||
],
|
||||
)
|
||||
classic = GameState.from_snapshot(snapshot)
|
||||
fast = FastGameState.from_snapshot(snapshot)
|
||||
before = fast.to_snapshot()
|
||||
left = GameState.from_snapshot(snapshot)
|
||||
right = FastGameState.from_snapshot(snapshot)
|
||||
before = right.to_snapshot()
|
||||
|
||||
assert fast.expedition_score(0, 0) == classic.expedition_score(0, 0)
|
||||
assert fast.total_score(0) == classic.total_score(0)
|
||||
assert right.expedition_score(0, 0) == left.expedition_score(0, 0)
|
||||
assert right.total_score(0) == left.total_score(0)
|
||||
|
||||
undo = fast.apply_action_with_undo(0)
|
||||
classic.apply_action(0)
|
||||
assert fast.to_snapshot() == classic.to_snapshot()
|
||||
assert fast.expedition_score(0, 0) == classic.expedition_score(0, 0)
|
||||
assert fast.total_score(0) == classic.total_score(0)
|
||||
undo = right.apply_action_with_undo(0)
|
||||
left.apply_action(0)
|
||||
assert right.to_snapshot() == left.to_snapshot()
|
||||
assert right.expedition_score(0, 0) == left.expedition_score(0, 0)
|
||||
assert right.total_score(0) == left.total_score(0)
|
||||
|
||||
fast.undo_action(undo)
|
||||
assert fast.to_snapshot() == before
|
||||
assert fast.total_score(0) == GameState.from_snapshot(before).total_score(0)
|
||||
right.undo_action(undo)
|
||||
assert right.to_snapshot() == before
|
||||
assert right.total_score(0) == GameState.from_snapshot(before).total_score(0)
|
||||
|
||||
|
||||
def test_fast_discard_draw_push_pop_restores_snapshot() -> None:
|
||||
snapshot = _classic_snapshot(
|
||||
snapshot = _snapshot(
|
||||
hands=[[], [_card(1, 1)]],
|
||||
discards=[[_card(0, 1)], [], [], [], []],
|
||||
phase="draw",
|
||||
@@ -294,62 +295,60 @@ def test_fast_discard_draw_push_pop_restores_snapshot() -> None:
|
||||
assert state.to_snapshot() == before
|
||||
|
||||
|
||||
def test_fast_random_action_sequence_matches_game_state() -> None:
|
||||
def test_fast_random_action_sequence_is_deterministic() -> None:
|
||||
config = LostCitiesConfig()
|
||||
for seed in range(48):
|
||||
classic = GameState.new_game(config, seed=seed)
|
||||
fast = FastGameState.new_game(config, seed=seed)
|
||||
left = GameState.new_game(config, seed=seed)
|
||||
right = FastGameState.new_game(config, seed=seed)
|
||||
rng = random.Random(seed ^ 0xF457)
|
||||
steps = 0
|
||||
|
||||
while True:
|
||||
assert fast.to_snapshot() == classic.to_snapshot()
|
||||
assert fast.unified_legal_mask() == classic.unified_legal_mask()
|
||||
assert fast.unified_legal_actions() == [
|
||||
index for index, is_legal in enumerate(classic.unified_legal_mask()) if is_legal
|
||||
assert right.to_snapshot() == left.to_snapshot()
|
||||
assert right.unified_legal_mask() == left.unified_legal_mask()
|
||||
assert right.unified_legal_actions() == [
|
||||
index for index, is_legal in enumerate(left.unified_legal_mask()) if is_legal
|
||||
]
|
||||
assert fast.score_diff(0) == classic.score_diff(0)
|
||||
if classic.terminal:
|
||||
assert right.score_diff(0) == left.score_diff(0)
|
||||
if left.terminal:
|
||||
break
|
||||
|
||||
legal = [
|
||||
index for index, is_legal in enumerate(classic.unified_legal_mask()) if is_legal
|
||||
]
|
||||
legal = [index for index, is_legal in enumerate(left.unified_legal_mask()) if is_legal]
|
||||
action = rng.choice(legal)
|
||||
classic.apply_unified_action(action)
|
||||
fast.apply_unified_action(action)
|
||||
left.apply_unified_action(action)
|
||||
right.apply_unified_action(action)
|
||||
steps += 1
|
||||
assert steps < 1000
|
||||
|
||||
|
||||
def test_fast_random_bot_self_play_matches_game_state() -> None:
|
||||
def test_fast_random_bot_self_play_is_deterministic() -> None:
|
||||
config = LostCitiesConfig()
|
||||
for seed in range(32):
|
||||
classic = GameState.new_game(config, seed=seed)
|
||||
fast = FastGameState.new_game(config, seed=seed)
|
||||
classic_bots = [RandomBot(seed=seed * 2), RandomBot(seed=seed * 2 + 1)]
|
||||
fast_bots = [RandomBot(seed=seed * 2), RandomBot(seed=seed * 2 + 1)]
|
||||
left = GameState.new_game(config, seed=seed)
|
||||
right = FastGameState.new_game(config, seed=seed)
|
||||
left_bots = [RandomBot(seed=seed * 2), RandomBot(seed=seed * 2 + 1)]
|
||||
right_bots = [RandomBot(seed=seed * 2), RandomBot(seed=seed * 2 + 1)]
|
||||
steps = 0
|
||||
|
||||
while True:
|
||||
assert fast.to_snapshot() == classic.to_snapshot()
|
||||
if classic.terminal:
|
||||
assert right.to_snapshot() == left.to_snapshot()
|
||||
if left.terminal:
|
||||
break
|
||||
|
||||
player = classic.current_player
|
||||
assert fast.current_player == player
|
||||
classic_action = classic_bots[player].act(classic)
|
||||
fast_action = fast_bots[player].act({"legal_mask": fast.legal_mask()})
|
||||
assert fast_action == classic_action
|
||||
player = left.current_player
|
||||
assert right.current_player == player
|
||||
left_action = left_bots[player].act(left)
|
||||
right_action = right_bots[player].act({"legal_mask": right.legal_mask()})
|
||||
assert right_action == left_action
|
||||
|
||||
classic.apply_action(classic_action)
|
||||
fast.apply_action(fast_action)
|
||||
left.apply_action(left_action)
|
||||
right.apply_action(right_action)
|
||||
steps += 1
|
||||
assert steps < 1000
|
||||
|
||||
assert fast.total_score(0) == classic.total_score(0)
|
||||
assert fast.total_score(1) == classic.total_score(1)
|
||||
assert fast.score_diff(0) == classic.score_diff(0)
|
||||
assert right.total_score(0) == left.total_score(0)
|
||||
assert right.total_score(1) == left.total_score(1)
|
||||
assert right.score_diff(0) == left.score_diff(0)
|
||||
|
||||
|
||||
def test_fast_apply_undo_restores_every_legal_action() -> None:
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from coolrl_lost_cities.games.classic.game import Card, GameState, LostCitiesConfig
|
||||
|
||||
|
||||
def make_state(
|
||||
config: LostCitiesConfig | None = None,
|
||||
*,
|
||||
deck: list[Card] | None = None,
|
||||
hands: list[list[Card]] | None = None,
|
||||
expeditions: list[list[list[Card]]] | None = None,
|
||||
discards: list[list[Card]] | None = None,
|
||||
current_player: int = 0,
|
||||
phase: str = "card",
|
||||
pending_discarded_color: int | None = None,
|
||||
turn_count: int = 0,
|
||||
terminal: bool = False,
|
||||
validate: bool = False,
|
||||
) -> GameState:
|
||||
config = config or LostCitiesConfig()
|
||||
return GameState.from_snapshot(
|
||||
{
|
||||
"config": config.to_snapshot(),
|
||||
"deck": deck or [],
|
||||
"hands": hands or [[], []],
|
||||
"expeditions": expeditions
|
||||
or [
|
||||
[[] for _ in range(config.n_colors)],
|
||||
[[] for _ in range(config.n_colors)],
|
||||
],
|
||||
"discards": discards or [[] for _ in range(config.n_colors)],
|
||||
"current_player": current_player,
|
||||
"phase": phase,
|
||||
"pending_discarded_color": pending_discarded_color,
|
||||
"turn_count": turn_count,
|
||||
"terminal": terminal,
|
||||
},
|
||||
validate=validate,
|
||||
)
|
||||
@@ -7,6 +7,14 @@ from coolrl_lost_cities.games.classic.bots import (
|
||||
)
|
||||
from coolrl_lost_cities.games.classic.bots.heuristic import draw_from_discard_action
|
||||
from coolrl_lost_cities.games.classic.evaluation import play_game_for_evaluation
|
||||
from tests.games.classic.helpers import make_state
|
||||
|
||||
|
||||
def _expeditions(config: LostCitiesConfig) -> list[list[list[Card]]]:
|
||||
return [
|
||||
[[] for _ in range(config.n_colors)],
|
||||
[[] for _ in range(config.n_colors)],
|
||||
]
|
||||
|
||||
|
||||
def test_builtin_bots_implement_lost_cities_bot() -> None:
|
||||
@@ -31,15 +39,26 @@ def test_safe_heuristic_opponent_value_ignores_hidden_hand() -> None:
|
||||
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)]
|
||||
expeditions_a = _expeditions(config)
|
||||
expeditions_a[1][0] = [Card(color=0, rank=0), Card(color=0, rank=4)]
|
||||
state_a = make_state(
|
||||
config,
|
||||
hands=[[], [Card(color=0, rank=5)]],
|
||||
expeditions=expeditions_a,
|
||||
discards=[[discard_card], []],
|
||||
)
|
||||
|
||||
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)]
|
||||
expeditions_b = _expeditions(config)
|
||||
expeditions_b[1][0] = [Card(color=0, rank=0), Card(color=0, rank=4)]
|
||||
state_b = make_state(
|
||||
config,
|
||||
hands=[
|
||||
[],
|
||||
[Card(color=0, rank=5), Card(color=0, rank=7), Card(color=0, rank=8)],
|
||||
],
|
||||
expeditions=expeditions_b,
|
||||
discards=[[discard_card], []],
|
||||
)
|
||||
|
||||
value_a = bot._card_value_for_opponent(
|
||||
state=state_a,
|
||||
@@ -62,13 +81,21 @@ def test_safe_heuristic_started_expedition_value_ignores_invalid_lower_followup(
|
||||
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]
|
||||
base_expeditions = _expeditions(config)
|
||||
base_expeditions[0][0] = [Card(color=0, rank=4)]
|
||||
base_state = make_state(
|
||||
config,
|
||||
hands=[[high_card], []],
|
||||
expeditions=base_expeditions,
|
||||
)
|
||||
|
||||
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]
|
||||
lower_expeditions = _expeditions(config)
|
||||
lower_expeditions[0][0] = [Card(color=0, rank=4)]
|
||||
lower_followup_state = make_state(
|
||||
config,
|
||||
hands=[[Card(color=0, rank=5), high_card], []],
|
||||
expeditions=lower_expeditions,
|
||||
)
|
||||
|
||||
base_value = bot._started_expedition_play_value(
|
||||
state=base_state,
|
||||
@@ -92,12 +119,15 @@ 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)]
|
||||
expeditions = _expeditions(config)
|
||||
expeditions[0][0] = [Card(color=0, rank=4)]
|
||||
state = make_state(
|
||||
config,
|
||||
deck=[Card(color=1, rank=8)],
|
||||
expeditions=expeditions,
|
||||
discards=[[Card(color=0, rank=6)], []],
|
||||
phase="draw",
|
||||
)
|
||||
|
||||
assert bot._act_draw(state) == draw_from_discard_action(0)
|
||||
|
||||
@@ -106,20 +136,23 @@ 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] = [
|
||||
expeditions = _expeditions(config)
|
||||
expeditions[0][1] = [Card(color=1, rank=8)]
|
||||
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)]
|
||||
state = make_state(
|
||||
config,
|
||||
deck=[Card(color=1, rank=8), Card(color=1, rank=7)],
|
||||
hands=[[Card(color=0, rank=0), Card(color=0, rank=7)], []],
|
||||
expeditions=expeditions,
|
||||
discards=[[Card(color=0, rank=6)], []],
|
||||
phase="draw",
|
||||
)
|
||||
|
||||
assert state.score_diff(0) < 0
|
||||
assert bot._act_draw(state) == draw_from_discard_action(0)
|
||||
@@ -152,16 +185,17 @@ def test_safe_heuristic_classic_self_play_opens_expeditions() -> None:
|
||||
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)]
|
||||
expeditions = _expeditions(config)
|
||||
expeditions[0][0] = [Card(color=0, rank=4)]
|
||||
expeditions[0][1] = [Card(color=1, rank=4)]
|
||||
expeditions[0][2] = [Card(color=2, rank=5)]
|
||||
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 = make_state(
|
||||
config,
|
||||
hands=[[weak_open, Card(color=4, rank=7), Card(color=0, rank=6)], []],
|
||||
expeditions=expeditions,
|
||||
)
|
||||
state.sort_hand(0)
|
||||
|
||||
assert (
|
||||
@@ -180,11 +214,16 @@ def test_safe_heuristic_avoids_opening_weak_fifth_color() -> None:
|
||||
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)]
|
||||
expeditions = _expeditions(config)
|
||||
expeditions[0][0] = [Card(color=0, rank=4)]
|
||||
state = make_state(
|
||||
config,
|
||||
hands=[
|
||||
[Card(color=0, rank=6), Card(color=1, rank=4), Card(color=1, rank=7)],
|
||||
[],
|
||||
],
|
||||
expeditions=expeditions,
|
||||
)
|
||||
state.sort_hand(0)
|
||||
|
||||
action = bot._act_card(state)
|
||||
@@ -197,15 +236,20 @@ def test_safe_heuristic_prefers_followup_on_started_expedition() -> None:
|
||||
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)]
|
||||
expeditions = _expeditions(config)
|
||||
expeditions[0][0] = [Card(color=0, rank=4)]
|
||||
expeditions[0][1] = [Card(color=1, rank=4)]
|
||||
expeditions[0][2] = [Card(color=2, rank=5)]
|
||||
expeditions[0][3] = [Card(color=3, rank=6)]
|
||||
discards = [[] for _ in range(config.n_colors)]
|
||||
discards[4] = [Card(color=4, rank=5)]
|
||||
state = make_state(
|
||||
config,
|
||||
deck=[Card(color=0, rank=8), Card(color=1, rank=8)],
|
||||
hands=[[Card(color=4, rank=4), Card(color=4, rank=7)], []],
|
||||
expeditions=expeditions,
|
||||
discards=discards,
|
||||
phase="draw",
|
||||
)
|
||||
|
||||
assert bot._act_draw(state) == 0
|
||||
|
||||
@@ -6,6 +6,7 @@ import pytest
|
||||
from coolrl_lost_cities.games.classic.game import Card, GameState, LostCitiesConfig
|
||||
|
||||
import coolrl_lost_cities.games.classic as classic
|
||||
from tests.games.classic.helpers import make_state
|
||||
|
||||
FIXTURE_DIR = Path(classic.__file__).resolve().parent / "fixtures"
|
||||
|
||||
@@ -85,17 +86,21 @@ def test_snapshot_roundtrip_preserves_json_state() -> None:
|
||||
|
||||
def test_validate_invariants_detects_card_loss() -> None:
|
||||
state = GameState.new_game(LostCitiesConfig(seed=7))
|
||||
state.deck.pop()
|
||||
snapshot = state.to_snapshot()
|
||||
snapshot["deck"].pop()
|
||||
broken = GameState.from_snapshot(snapshot, validate=False)
|
||||
|
||||
with pytest.raises(ValueError, match="card conservation"):
|
||||
state.validate_invariants()
|
||||
broken.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)])
|
||||
config = LostCitiesConfig(seed=8)
|
||||
state = make_state(
|
||||
config,
|
||||
deck=GameState.new_game(config).deck,
|
||||
expeditions=[[[Card(0, 2), Card(0, 1)], [], [], [], []], [[], [], [], [], []]],
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="strictly increasing"):
|
||||
state.validate_invariants()
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import numpy as np
|
||||
from coolrl_lost_cities.games.classic.game import Card, GameState, LostCitiesConfig
|
||||
from coolrl_lost_cities.games.classic.game import Card, LostCitiesConfig
|
||||
|
||||
from coolrl_lost_cities.games.classic.env import LostCitiesEnv
|
||||
from tests.games.classic.helpers import make_state
|
||||
|
||||
|
||||
def test_env_observation_uses_fixed_unified_mask() -> None:
|
||||
@@ -24,11 +25,12 @@ def test_env_observation_uses_fixed_unified_mask() -> None:
|
||||
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"
|
||||
env.state = make_state(
|
||||
config,
|
||||
deck=[Card(2, 1), Card(2, 2)],
|
||||
hands=[[Card(0, 1)], [Card(1, 1)]],
|
||||
phase="draw",
|
||||
)
|
||||
|
||||
obs, reward, done, _ = env.step(0)
|
||||
|
||||
@@ -50,11 +52,13 @@ def test_terminal_reward_is_relative_to_actor() -> None:
|
||||
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)]
|
||||
env.state = make_state(
|
||||
config,
|
||||
deck=[Card(1, 1)],
|
||||
expeditions=[[[], []], [[Card(0, 1)], []]],
|
||||
current_player=1,
|
||||
phase="draw",
|
||||
)
|
||||
|
||||
_, reward, done, _ = env.step(config.card_action_size)
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from coolrl_lost_cities.games.classic.game import Card, GameState, LostCitiesConfig
|
||||
|
||||
from coolrl_lost_cities.games.classic.bots import RandomBot
|
||||
from tests.games.classic.helpers import make_state
|
||||
|
||||
|
||||
def test_legal_mask_has_action_in_nonterminal_phases() -> None:
|
||||
@@ -12,8 +13,7 @@ def test_legal_mask_has_action_in_nonterminal_phases() -> None:
|
||||
|
||||
|
||||
def test_empty_hand_slots_are_masked() -> None:
|
||||
state = GameState.empty(LostCitiesConfig())
|
||||
state.hands[0] = [Card(0, 1)]
|
||||
state = make_state(hands=[[Card(0, 1)], []])
|
||||
mask = state.legal_card_mask()
|
||||
assert mask[0] is True
|
||||
assert mask[1] is True
|
||||
@@ -21,9 +21,7 @@ def test_empty_hand_slots_are_masked() -> None:
|
||||
|
||||
|
||||
def test_empty_discard_pile_draw_is_illegal() -> None:
|
||||
state = GameState.empty(LostCitiesConfig())
|
||||
state.phase = "draw"
|
||||
state.deck = [Card(0, 1)]
|
||||
state = make_state(deck=[Card(0, 1)], phase="draw")
|
||||
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))
|
||||
|
||||
@@ -7,6 +7,8 @@ from coolrl_lost_cities.games.classic.game import (
|
||||
build_deck,
|
||||
)
|
||||
|
||||
from tests.games.classic.helpers import make_state
|
||||
|
||||
|
||||
def test_deck_generation_count() -> None:
|
||||
config = LostCitiesConfig(n_colors=3, n_ranks=5, n_handshakes=1, hand_size=5)
|
||||
@@ -23,25 +25,27 @@ def test_initial_hands_remove_cards_from_deck() -> None:
|
||||
|
||||
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)]
|
||||
state = make_state(
|
||||
config,
|
||||
hands=[[Card(0, 2)], []],
|
||||
expeditions=[[[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)]
|
||||
state = make_state(
|
||||
config,
|
||||
hands=[[Card(1, 0)], []],
|
||||
expeditions=[[[], [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 = make_state(config, deck=[Card(0, 1)], hands=[[Card(2, 2)], []])
|
||||
state.apply_action(1)
|
||||
mask = state.legal_draw_mask()
|
||||
assert mask[1 + 2] is False
|
||||
@@ -49,9 +53,7 @@ def test_cannot_draw_just_discarded_color() -> None:
|
||||
|
||||
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 = make_state(config, deck=[Card(0, 1)], hands=[[Card(2, 2)], []])
|
||||
|
||||
state.apply_action(1)
|
||||
|
||||
@@ -61,10 +63,11 @@ def test_drawing_just_discarded_color_is_rejected() -> None:
|
||||
|
||||
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 = make_state(
|
||||
config,
|
||||
deck=[Card(1, 1), Card(1, 2)],
|
||||
hands=[[Card(2, 2)], [Card(0, 1)]],
|
||||
)
|
||||
state.apply_action(1)
|
||||
state.apply_action(0)
|
||||
assert state.current_player == 1
|
||||
@@ -75,10 +78,11 @@ def test_discarded_color_can_be_drawn_after_turn_advances() -> None:
|
||||
|
||||
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 = make_state(
|
||||
config,
|
||||
deck=[Card(1, 1), Card(1, 2)],
|
||||
hands=[[Card(2, 2)], [Card(0, 1)]],
|
||||
)
|
||||
|
||||
state.apply_action(1)
|
||||
assert state.discards[2] == [Card(2, 2)]
|
||||
@@ -95,9 +99,7 @@ def test_discarded_card_is_removed_when_drawn_later() -> None:
|
||||
|
||||
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 = make_state(config, deck=[Card(1, 1)], hands=[[Card(0, 1)], []])
|
||||
state.apply_action(1)
|
||||
state.apply_action(0)
|
||||
assert state.terminal is True
|
||||
@@ -106,10 +108,7 @@ def test_deck_exhaustion_ends_after_last_deck_draw() -> None:
|
||||
|
||||
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 = make_state(config, hands=[[Card(0, 1)], [Card(1, 1)]])
|
||||
state.apply_action(1)
|
||||
assert state.phase == "draw"
|
||||
assert state.terminal is True
|
||||
|
||||
Reference in New Issue
Block a user