FastGameState를 기본 게임 상태로 전환

This commit is contained in:
2026-05-06 22:35:51 +09:00
parent 7e8c29478f
commit c1c50267b5
22 changed files with 477 additions and 908 deletions
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
+106 -107
View File
@@ -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:
+39
View File
@@ -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,
)
+96 -52
View File
@@ -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
+11 -6
View File
@@ -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()
+15 -11
View File
@@ -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)
+3 -5
View File
@@ -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))
+26 -27
View File
@@ -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