213 lines
7.4 KiB
Python
213 lines
7.4 KiB
Python
from __future__ import annotations
|
|
|
|
import pytest
|
|
from coolrl_lost_cities.games.classic.game import GameState, classic_config
|
|
|
|
pytest.importorskip("pygame")
|
|
|
|
from coolrl_lost_cities.games.classic import pygame_table # noqa: E402
|
|
|
|
|
|
def test_argparser_defaults_to_final_candidate() -> None:
|
|
args = pygame_table.build_argparser().parse_args([])
|
|
|
|
assert args.checkpoint == str(pygame_table.DEFAULT_CHECKPOINT)
|
|
assert args.seed is None
|
|
assert args.width == 1600
|
|
assert args.height == 1000
|
|
assert args.offline is False
|
|
assert args.export_dir == "exports"
|
|
|
|
|
|
def test_argparser_accepts_overrides() -> None:
|
|
args = pygame_table.build_argparser().parse_args(
|
|
["--checkpoint", "/tmp/ckpt", "--seed", "3", "--offline"]
|
|
)
|
|
|
|
assert args.checkpoint == "/tmp/ckpt"
|
|
assert args.seed == 3
|
|
assert args.offline is True
|
|
|
|
|
|
def test_one_suit_per_classic_color() -> None:
|
|
config = classic_config()
|
|
assert len(pygame_table.SUITS) == config.n_colors
|
|
assert len(pygame_table.SUIT_PAINTERS) == config.n_colors
|
|
|
|
|
|
def test_card_key_distinguishes_wagers_from_numbers() -> None:
|
|
config = classic_config()
|
|
state = GameState.new_game(config, seed=0)
|
|
keys = [pygame_table.card_key(card, config.min_rank) for card in state.deck]
|
|
wagers = [key for key in keys if key[1] == 0]
|
|
numbers = [key for key in keys if key[1] != 0]
|
|
assert all(config.min_rank <= value <= config.max_rank for _, value in numbers)
|
|
assert all(0 <= color < config.n_colors for color, _ in keys)
|
|
assert wagers # a shuffled deck slice still contains wager cards
|
|
|
|
|
|
def test_layout_scales_between_window_sizes() -> None:
|
|
small = pygame_table.Layout(1024, 720, 5)
|
|
large = pygame_table.Layout(1920, 1200, 5)
|
|
|
|
for layout in (small, large):
|
|
assert layout.board_top < layout.discard_cy < layout.board_bottom
|
|
assert len(layout.col_x) == 5
|
|
slots = layout.hand_slots(8)
|
|
assert len(slots) == 8
|
|
assert slots[0][0] >= 0
|
|
assert slots[-1][0] + layout.hand_w <= layout.w
|
|
lane_center = (layout.col_x[0] + layout.col_x[-1] + layout.board_w) / 2
|
|
assert abs(lane_center - layout.w / 2) <= 1
|
|
assert large.hand_w >= small.hand_w
|
|
|
|
|
|
def _settle(app: pygame_table.TableApp) -> None:
|
|
now = pygame_table.pygame.time.get_ticks() + 10_000
|
|
app._assign_targets(now)
|
|
for sprite in app.sprites:
|
|
sprite.pos.update(sprite.target)
|
|
sprite.release_at = 0
|
|
sprite.pending_flip_at = None
|
|
|
|
|
|
def test_discard_draw_stays_public_and_survives_undo_redo() -> None:
|
|
app = pygame_table.TableApp(seed=5, offline=True, headless=True)
|
|
try:
|
|
app.input_locked_until = 0
|
|
# Human discards, draws from deck, then the rival takes that public card.
|
|
human_discard = next(i for i in app.state.unified_legal_actions() if i % 2 == 1)
|
|
discarded = app.state.hands[app.human_seat][human_discard // 2]
|
|
app.apply_unified(human_discard)
|
|
app.apply_unified(app.state.card_action_size)
|
|
|
|
rival_discard = next(
|
|
i
|
|
for i in app.state.unified_legal_actions()
|
|
if i % 2 == 1 and app.state.hands[app.ai_seat][i // 2].color != discarded.color
|
|
)
|
|
app.apply_unified(rival_discard)
|
|
draw_discard = app.state.card_action_size + 1 + discarded.color
|
|
assert app._legal(draw_discard)
|
|
app.apply_unified(draw_discard)
|
|
|
|
face = pygame_table.card_key(discarded, app.min_rank)
|
|
assert app.public_hand_counts[app.ai_seat][face] == 1
|
|
assert any(
|
|
sprite.public and (sprite.color, sprite.value) == face
|
|
for sprite in app.hand_zones[app.ai_seat]
|
|
)
|
|
|
|
before = app._copy_public_counts()
|
|
app.undo()
|
|
app.redo()
|
|
assert app.public_hand_counts == before
|
|
assert any(
|
|
sprite.public and sprite.face_up and (sprite.color, sprite.value) == face
|
|
for sprite in app.hand_zones[app.ai_seat]
|
|
)
|
|
finally:
|
|
app.opponent.shutdown()
|
|
pygame_table.pygame.quit()
|
|
|
|
|
|
def test_missing_checkpoint_is_visible_failure_not_heuristic_fallback(tmp_path) -> None:
|
|
opponent = pygame_table.Opponent(tmp_path / "missing", None, seed=1, offline=False)
|
|
try:
|
|
opponent.load_future.result(timeout=5)
|
|
assert opponent.policy is None
|
|
assert opponent.load_error is not None
|
|
assert opponent.label == "MODEL LOAD FAILED"
|
|
finally:
|
|
opponent.shutdown()
|
|
|
|
|
|
def test_offline_mode_explicitly_uses_heuristic() -> None:
|
|
opponent = pygame_table.Opponent(None, None, seed=1, offline=True)
|
|
try:
|
|
opponent.load_future.result(timeout=5)
|
|
assert opponent.policy is not None
|
|
assert opponent.load_error is None
|
|
assert "offline" in opponent.label
|
|
finally:
|
|
opponent.shutdown()
|
|
|
|
|
|
def test_finished_match_can_be_replayed_and_exported(tmp_path) -> None:
|
|
app = pygame_table.TableApp(
|
|
seed=29,
|
|
offline=True,
|
|
headless=True,
|
|
export_dir=tmp_path,
|
|
)
|
|
try:
|
|
while not app.state.terminal:
|
|
app.apply_unified(app.state.unified_legal_actions()[0])
|
|
|
|
final_index = len(app.match_steps) - 1
|
|
assert app.review_index == final_index
|
|
assert all(sprite.face_up for sprite in app.hand_zones[app.ai_seat])
|
|
|
|
app.review_step(-1)
|
|
assert app.review_index == final_index - 1
|
|
app.enter_review(0)
|
|
assert app.review_index == 0
|
|
assert app.state.turn_count == 0
|
|
assert all(sprite.face_up for sprite in app.hand_zones[app.ai_seat])
|
|
|
|
path = app.export_match()
|
|
restored = pygame_table.MatchRecord.read_jsonl(path)
|
|
assert restored.metadata["complete"] is True
|
|
assert len(restored.steps) == len(app.match_steps)
|
|
assert restored.steps[-1]["state"]["terminal"] is True
|
|
finally:
|
|
app.opponent.shutdown()
|
|
pygame_table.pygame.quit()
|
|
|
|
|
|
def test_undo_then_new_action_replaces_export_timeline() -> None:
|
|
app = pygame_table.TableApp(seed=37, offline=True, headless=True)
|
|
try:
|
|
first = app.state.unified_legal_actions()[0]
|
|
app.apply_unified(first)
|
|
app.apply_unified(app.state.unified_legal_actions()[0])
|
|
assert app.timeline_cursor == 2
|
|
|
|
app.undo()
|
|
assert app.timeline_cursor < len(app.match_steps) - 1
|
|
replacement = app.state.unified_legal_actions()[-1]
|
|
app.apply_unified(replacement)
|
|
|
|
record = app.current_match_record()
|
|
record.validate()
|
|
assert len(record.steps) == app.timeline_cursor + 1
|
|
assert record.steps[-1]["action_id"] == replacement
|
|
finally:
|
|
app.opponent.shutdown()
|
|
pygame_table.pygame.quit()
|
|
|
|
|
|
def test_menu_exports_current_match(tmp_path) -> None:
|
|
app = pygame_table.TableApp(
|
|
seed=43,
|
|
offline=True,
|
|
headless=True,
|
|
export_dir=tmp_path,
|
|
)
|
|
try:
|
|
app.draw()
|
|
app.on_click(app.menu_button_rect.center)
|
|
assert app.menu_open is True
|
|
app.draw()
|
|
export_rect = next(rect for action, rect in app.menu_items if action == "export")
|
|
app.on_click(export_rect.center)
|
|
|
|
exported = list(tmp_path.glob("lost-cities-match-*.jsonl"))
|
|
assert len(exported) == 1
|
|
restored = pygame_table.MatchRecord.read_jsonl(exported[0])
|
|
assert restored.metadata["complete"] is False
|
|
assert len(restored.steps) == 1
|
|
finally:
|
|
app.opponent.shutdown()
|
|
pygame_table.pygame.quit()
|