Add polished Lost Cities table GUI

This commit is contained in:
2026-07-12 14:58:59 +09:00
parent 3b86f81bf6
commit 7e481ca064
6 changed files with 1893 additions and 6 deletions
@@ -1,5 +1,7 @@
from __future__ import annotations
from collections import Counter
import numpy as np
from coolrl_lost_cities.games.classic.game import GameState, classic_config
@@ -58,3 +60,20 @@ def test_jax_policy_splits_atomic_action_across_classic_phases(monkeypatch) -> N
assert state.phase == "draw"
assert policy.act(state) == expected_draw
assert policy.pending_draw is None
def test_snapshot_conversion_marks_known_discard_draw_in_public_hand() -> None:
state = GameState.new_game(classic_config(), seed=19)
snapshot = snapshot_from_state(state)
card = snapshot.hands[1][0]
face = (card.color, 0 if card.is_handshake else card.numeric_value(2))
converted, _ = snapshot_to_jax_state(
snapshot,
[Counter(), Counter({face: 1})],
)
public = np.asarray(converted.hand_public, dtype=bool)
locations = np.asarray(converted.card_loc)
assert public.sum() == 1
assert np.all(locations[public] == 2)
+130
View File
@@ -0,0 +1,130 @@
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
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
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()