From ae8478d67a834f6950d5eb7fc908916df0a4f917 Mon Sep 17 00:00:00 2001 From: Tazik Shahjahan Date: Thu, 23 Jul 2026 14:53:43 -0700 Subject: [PATCH 1/8] Give each game its own random.Random Game and State previously seeded the module-global random, so concurrent games in one process corrupted each other's streams. State now owns a Random(seed), shared by reference with state copies so simulations advance the same stream. Streams are identical to the previous global behavior for a given seed. --- catanatron/catanatron/apply_action.py | 9 +++++--- catanatron/catanatron/game.py | 4 +++- catanatron/catanatron/models/map.py | 13 +++++++---- catanatron/catanatron/models/player.py | 3 +-- catanatron/catanatron/players/mcts.py | 5 +++-- catanatron/catanatron/players/minimax.py | 5 ++--- catanatron/catanatron/players/playouts.py | 3 +-- catanatron/catanatron/players/search.py | 3 +-- catanatron/catanatron/players/value.py | 5 ++--- .../catanatron/players/weighted_random.py | 4 +--- catanatron/catanatron/state.py | 13 ++++++++--- catanatron/catanatron/state_functions.py | 3 +-- tests/test_gym.py | 22 ++++++++++++------- 13 files changed, 54 insertions(+), 38 deletions(-) diff --git a/catanatron/catanatron/apply_action.py b/catanatron/catanatron/apply_action.py index 729005781..59fc8089e 100644 --- a/catanatron/catanatron/apply_action.py +++ b/catanatron/catanatron/apply_action.py @@ -261,7 +261,9 @@ def apply_roll(state: State, action: Action, action_record=None): key = player_key(state, action.color) state.player_state[f"{key}_HAS_ROLLED"] = True - dices = action_record.result if action_record is not None else roll_dice() + dices = ( + action_record.result if action_record is not None else roll_dice(state.random) + ) number = dices[0] + dices[1] action = Action(action.color, action.action_type, dices) @@ -524,13 +526,14 @@ def apply_cancel_trade(state: State, action: Action): # ===== Helper Functions ===== -def roll_dice(): +def roll_dice(rng=None): """Yields two random numbers Returns: tuple[int, int]: 2-tuple of random numbers from 1 to 6 inclusive. """ - return (random.randint(1, 6), random.randint(1, 6)) + rng = rng if rng is not None else random + return (rng.randint(1, 6), rng.randint(1, 6)) def yield_resources(board: Board, resource_freqdeck, number): diff --git a/catanatron/catanatron/game.py b/catanatron/catanatron/game.py index d624cb87b..4f37d61f6 100644 --- a/catanatron/catanatron/game.py +++ b/catanatron/catanatron/game.py @@ -115,7 +115,7 @@ def __init__( """ if initialize: self.seed = seed if seed is not None else random.randrange(sys.maxsize) - random.seed(self.seed) + self.random = random.Random(self.seed) self.id = str(uuid.uuid4()) self.vps_to_win = vps_to_win @@ -126,6 +126,7 @@ def __init__( discard_limit=discard_limit, friendly_robber=friendly_robber, number_placement=number_placement, + rng=self.random, ) self.playable_actions = generate_playable_actions(self.state) @@ -220,6 +221,7 @@ def copy(self) -> "Game": """ game_copy = Game(players=[], initialize=False) game_copy.seed = self.seed + game_copy.random = self.random game_copy.id = self.id game_copy.vps_to_win = self.vps_to_win game_copy.friendly_robber = self.friendly_robber diff --git a/catanatron/catanatron/models/map.py b/catanatron/catanatron/models/map.py index cdb30a591..b945b31c1 100644 --- a/catanatron/catanatron/models/map.py +++ b/catanatron/catanatron/models/map.py @@ -207,8 +207,11 @@ def __init__( def from_template( map_template: MapTemplate, number_placement: NumberPlacement = "official_spiral", + rng=None, ): - tiles = initialize_tiles(map_template, number_placement=number_placement) + tiles = initialize_tiles( + map_template, number_placement=number_placement, rng=rng + ) return CatanMap.from_tiles(tiles) @@ -313,6 +316,7 @@ def initialize_tiles( shuffled_port_resources_param=None, shuffled_tile_resources_param=None, number_placement: NumberPlacement = "official_spiral", + rng=None, ) -> Dict[Coordinate, Tile]: """Initializes a new random board, based on the MapTemplate. @@ -329,13 +333,14 @@ def initialize_tiles( Returns: Dict[Coordinate, Tile]: Coordinate to initialized Tile mapping. """ - shuffled_port_resources = shuffled_port_resources_param or random.sample( + rng = rng if rng is not None else random + shuffled_port_resources = shuffled_port_resources_param or rng.sample( map_template.port_resources, len(map_template.port_resources) ) - shuffled_tile_resources = shuffled_tile_resources_param or random.sample( + shuffled_tile_resources = shuffled_tile_resources_param or rng.sample( map_template.tile_resources, len(map_template.tile_resources) ) - shuffled_numbers = shuffled_numbers_param or random.sample( + shuffled_numbers = shuffled_numbers_param or rng.sample( map_template.numbers, len(map_template.numbers) ) diff --git a/catanatron/catanatron/models/player.py b/catanatron/catanatron/models/player.py index 4950c1f29..b82903ff9 100644 --- a/catanatron/catanatron/models/player.py +++ b/catanatron/catanatron/models/player.py @@ -1,4 +1,3 @@ -import random import builtins from enum import Enum @@ -85,4 +84,4 @@ class RandomPlayer(Player): """Random AI player that selects an action randomly from the list of playable_actions""" def decide(self, game, playable_actions): - return random.choice(playable_actions) + return game.state.random.choice(playable_actions) diff --git a/catanatron/catanatron/players/mcts.py b/catanatron/catanatron/players/mcts.py index 131c29a31..cbbcf2632 100644 --- a/catanatron/catanatron/players/mcts.py +++ b/catanatron/catanatron/players/mcts.py @@ -1,7 +1,6 @@ import math import time from collections import defaultdict -import random from catanatron.game import Game from catanatron.models.player import Player @@ -99,7 +98,9 @@ def select(self): children = self.children[action] children_states = list(map(lambda c: c[0], children)) children_probas = list(map(lambda c: c[1], children)) - return random.choices(children_states, weights=children_probas, k=1)[0] + return self.game.state.random.choices( + children_states, weights=children_probas, k=1 + )[0] def choose_best_action(self): scores = [] diff --git a/catanatron/catanatron/players/minimax.py b/catanatron/catanatron/players/minimax.py index 94285f8ee..760631981 100644 --- a/catanatron/catanatron/players/minimax.py +++ b/catanatron/catanatron/players/minimax.py @@ -1,5 +1,4 @@ import time -import random from typing import Any from catanatron.game import Game @@ -57,8 +56,8 @@ def decide(self, game: Game, playable_actions): if len(actions) == 1: return actions[0] - if self.epsilon is not None and random.random() < self.epsilon: - return random.choice(playable_actions) + if self.epsilon is not None and game.state.random.random() < self.epsilon: + return game.state.random.choice(playable_actions) start = time.time() state_id = str(len(game.state.action_records)) diff --git a/catanatron/catanatron/players/playouts.py b/catanatron/catanatron/players/playouts.py index 5b738011e..5201d8b8d 100644 --- a/catanatron/catanatron/players/playouts.py +++ b/catanatron/catanatron/players/playouts.py @@ -1,5 +1,4 @@ import time -import random import multiprocessing from collections import Counter @@ -75,5 +74,5 @@ def run_playout(action_applied_game_copy): def decide_fn(self, game, playable_actions): - index = random.randrange(0, len(playable_actions)) + index = game.state.random.randrange(0, len(playable_actions)) return playable_actions[index] diff --git a/catanatron/catanatron/players/search.py b/catanatron/catanatron/players/search.py index 3f4c7e9e7..70d34e6e4 100644 --- a/catanatron/catanatron/players/search.py +++ b/catanatron/catanatron/players/search.py @@ -1,4 +1,3 @@ -import random from catanatron.state_functions import ( player_key, @@ -32,4 +31,4 @@ def decide(self, game: Game, playable_actions): best_value = value best_actions = [action] - return random.choice(best_actions) + return game.state.random.choice(best_actions) diff --git a/catanatron/catanatron/players/value.py b/catanatron/catanatron/players/value.py index 06f15511f..c1d05793e 100644 --- a/catanatron/catanatron/players/value.py +++ b/catanatron/catanatron/players/value.py @@ -1,4 +1,3 @@ -import random from catanatron.state_functions import ( get_longest_road_length, @@ -162,8 +161,8 @@ def decide(self, game, playable_actions): if len(playable_actions) == 1: return playable_actions[0] - if self.epsilon is not None and random.random() < self.epsilon: - return random.choice(playable_actions) + if self.epsilon is not None and game.state.random.random() < self.epsilon: + return game.state.random.choice(playable_actions) best_value = float("-inf") best_action = None diff --git a/catanatron/catanatron/players/weighted_random.py b/catanatron/catanatron/players/weighted_random.py index 3204e6c11..c35da8e75 100644 --- a/catanatron/catanatron/players/weighted_random.py +++ b/catanatron/catanatron/players/weighted_random.py @@ -1,5 +1,3 @@ -import random - from catanatron.models.player import Player from catanatron.models.actions import ActionType @@ -23,4 +21,4 @@ def decide(self, game, playable_actions): weight = WEIGHTS_BY_ACTION_TYPE.get(action.action_type, 1) bloated_actions.extend([action] * weight) - return random.choice(bloated_actions) + return game.state.random.choice(bloated_actions) diff --git a/catanatron/catanatron/state.py b/catanatron/catanatron/state.py index 93f6e52ab..d48d11da9 100644 --- a/catanatron/catanatron/state.py +++ b/catanatron/catanatron/state.py @@ -93,12 +93,17 @@ def __init__( friendly_robber=False, number_placement: NumberPlacement = "official_spiral", initialize=True, + rng=None, ): if initialize: - self.players = random.sample(players, len(players)) + self.random = rng if rng is not None else random.Random() + self.players = self.random.sample(players, len(players)) self.colors = tuple([player.color for player in self.players]) self.board = Board( - catan_map or CatanMap.from_template(BASE_MAP_TEMPLATE, number_placement) + catan_map + or CatanMap.from_template( + BASE_MAP_TEMPLATE, number_placement, rng=self.random + ) ) self.discard_limit = discard_limit self.friendly_robber = friendly_robber @@ -114,7 +119,7 @@ def __init__( self.resource_freqdeck = starting_resource_bank() self.development_listdeck = starting_devcard_bank() - random.shuffle(self.development_listdeck) + self.random.shuffle(self.development_listdeck) # Auxiliary attributes to implement game logic self.buildings_by_color: Dict[Color, Dict[Any, Any]] = { @@ -158,6 +163,8 @@ def copy(self): State: State copy. """ state_copy = State([], None, initialize=False) + # Shared by reference: simulations on copies must advance the same stream. + state_copy.random = self.random state_copy.players = self.players state_copy.discard_limit = self.discard_limit # immutable state_copy.friendly_robber = self.friendly_robber # immutable diff --git a/catanatron/catanatron/state_functions.py b/catanatron/catanatron/state_functions.py index ec05ed3b8..e5a444c0c 100644 --- a/catanatron/catanatron/state_functions.py +++ b/catanatron/catanatron/state_functions.py @@ -4,7 +4,6 @@ of the code decoupled from state representation. """ -import random from typing import Optional from catanatron.models.decks import ROAD_COST_FREQDECK, freqdeck_add @@ -321,7 +320,7 @@ def player_deck_replenish(state: State, color, resource, amount=1): def player_deck_random_select(state: State, color): deck_array = player_deck_to_array(state, color) - return random.choice(deck_array) + return state.random.choice(deck_array) def play_dev_card(state: State, color, dev_card): diff --git a/tests/test_gym.py b/tests/test_gym.py index ceab68934..663fd6da9 100644 --- a/tests/test_gym.py +++ b/tests/test_gym.py @@ -251,8 +251,7 @@ def test_action_space_conversion_roundtrip(): ), f"Action conversion failed: {action} -> {action_int} -> {recovered_action}" -def test_gym_reproducibility(): - # Play a game with the same seed, and ensure the game is the same +def _play_seeded_gym_game(seed): env = gymnasium.make( "catanatron/Catanatron-v0", config={ @@ -261,24 +260,31 @@ def test_gym_reproducibility(): ] }, ) - observation, info = env.reset(seed=123) - env.action_space.seed(123) + observation, info = env.reset(seed=seed) + rng = random.Random(seed) + game = env.unwrapped.game center_tile = game.state.board.map.land_tiles[(0, 0, 0)] assert center_tile.resource == ORE assert center_tile.number == 11 done = False - reward = 0 while not done: - action_mask = env.action_masks() + action_mask = env.unwrapped.action_masks() valid_indices = np.flatnonzero(action_mask) - action = random.choice(valid_indices) + action = rng.choice(list(valid_indices)) observation, reward, terminated, truncated, info = env.step(action) done = terminated or truncated game = env.unwrapped.game game_json = json.loads(json.dumps(game, cls=GameEncoder)) env.close() + return game_json + + +def test_gym_reproducibility(): + # The same seed and action sequence must yield the same game. + first = _play_seeded_gym_game(123) + second = _play_seeded_gym_game(123) - assert game_json["state_index"] == 125 + assert first == second From 27f500d4193bfc876fa0493b3a2017329aa8221f Mon Sep 17 00:00:00 2001 From: Tazik Shahjahan Date: Thu, 23 Jul 2026 16:05:28 -0700 Subject: [PATCH 2/8] Add regression test for per-game rng isolation --- tests/test_game.py | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/tests/test_game.py b/tests/test_game.py index a8a024492..90aac6be8 100644 --- a/tests/test_game.py +++ b/tests/test_game.py @@ -7,7 +7,7 @@ player_clean_turn, player_has_rolled, ) -from catanatron.game import Game, is_valid_trade +from catanatron.game import TURNS_LIMIT, Game, is_valid_trade from catanatron.apply_action import apply_action from catanatron.state_functions import ( player_key, @@ -654,3 +654,30 @@ def test_cannot_extend_road_past_enemy_settlement_at_endpoint(): f"Blue should not be able to build a road from enemy-settled node 10, " f"but found: {[e for e in blue_edges if 10 in e]}" ) + + +def test_seeded_games_are_isolated_from_each_other(): + # Play two games interleaved and check both match their solo runs. + def make_players(): + return [RandomPlayer(Color.RED), RandomPlayer(Color.BLUE)] + + def play_solo(seed): + game = Game(make_players(), seed=seed) + game.play() + return [str(a) for a in game.state.action_records] + + solo_a, solo_b = play_solo(7), play_solo(11) + + def ongoing(game): + return game.winning_color() is None and game.state.num_turns < TURNS_LIMIT + + game_a = Game(make_players(), seed=7) + game_b = Game(make_players(), seed=11) + while ongoing(game_a) or ongoing(game_b): + if ongoing(game_a): + game_a.play_tick() + if ongoing(game_b): + game_b.play_tick() + + assert [str(a) for a in game_a.state.action_records] == solo_a + assert [str(a) for a in game_b.state.action_records] == solo_b From 62f84c5a594f18ef91ddaeda2f3569e93ea77f5e Mon Sep 17 00:00:00 2001 From: Tazik Shahjahan Date: Thu, 23 Jul 2026 16:06:50 -0700 Subject: [PATCH 3/8] Make discard sequence test seating-independent The test assumed WHITE sits after RED, but State shuffles seating and the discard sequence advances by seat index without wrapping, so it failed for about half the seatings. Pick roles by seat index instead. --- tests/test_state.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/test_state.py b/tests/test_state.py index 2c169220e..e057a9a7b 100644 --- a/tests/test_state.py +++ b/tests/test_state.py @@ -245,10 +245,11 @@ def test_discard_sequence_advances_to_next_discarder(): SimplePlayer(Color.WHITE), ] state = State(players) - current_color = players[0].color - next_color = players[2].color - current_player_index = state.colors.index(current_color) - next_player_index = state.colors.index(next_color) + # Seating is shuffled and discards advance by seat, so pick roles by seat. + current_player_index = 0 + next_player_index = 2 + current_color = state.colors[current_player_index] + next_color = state.colors[next_player_index] state.current_turn_index = 1 state.current_player_index = current_player_index From 656ea01b2a27d1076c067bfea09eaa35443e8544 Mon Sep 17 00:00:00 2001 From: Tazik Shahjahan Date: Thu, 23 Jul 2026 16:18:06 -0700 Subject: [PATCH 4/8] Give multiprocessing playouts independent rngs --- catanatron/catanatron/players/playouts.py | 36 +++++++++++++++++++---- tests/test_playouts.py | 15 ++++++++++ 2 files changed, 45 insertions(+), 6 deletions(-) create mode 100644 tests/test_playouts.py diff --git a/catanatron/catanatron/players/playouts.py b/catanatron/catanatron/players/playouts.py index 5201d8b8d..0dfa52a8b 100644 --- a/catanatron/catanatron/players/playouts.py +++ b/catanatron/catanatron/players/playouts.py @@ -1,5 +1,6 @@ -import time import multiprocessing +import random +import time from collections import Counter from catanatron.game import Game @@ -54,19 +55,42 @@ def decide(self, game: Game, playable_actions): def run_playouts(action_applied_game_copy, num_playouts): start = time.time() - params = [] - for _ in range(num_playouts): - params.append(action_applied_game_copy) + params = [ + (action_applied_game_copy, seed) + for seed in _derive_playout_seeds(action_applied_game_copy, num_playouts) + ] if USE_MULTIPROCESSING: with multiprocessing.Pool(NUM_WORKERS) as p: - counter = Counter(p.map(run_playout, params)) + counter = Counter(p.map(_run_seeded_playout, params)) else: - counter = Counter(map(run_playout, params)) + counter = Counter(map(_run_seeded_playout, params)) duration = time.time() - start # print(f"{num_playouts} playouts took: {duration}. Results: {counter}") return counter +def _derive_playout_seeds(game, count): + """Derive distinct deterministic child seeds without advancing the game. + + Pool workers deserialize their inputs independently. Passing the same game + repeatedly would therefore restart every worker from the same RNG state + and turn supposedly random playouts into identical simulations. + """ + seed_rng = random.Random() + seed_rng.setstate(game.state.random.getstate()) + return [seed_rng.getrandbits(128) for _ in range(count)] + + +def _run_seeded_playout(params): + game, seed = params + game_copy = game.copy() + rng = random.Random(seed) + game_copy.random = rng + game_copy.state.random = rng + game_copy.play(decide_fn=decide_fn) + return game_copy.winning_color() + + def run_playout(action_applied_game_copy): game_copy = action_applied_game_copy.copy() game_copy.play(decide_fn=decide_fn) diff --git a/tests/test_playouts.py b/tests/test_playouts.py new file mode 100644 index 000000000..5d784c5fa --- /dev/null +++ b/tests/test_playouts.py @@ -0,0 +1,15 @@ +from catanatron import Color, Game, RandomPlayer +from catanatron.players.playouts import _derive_playout_seeds + + +def test_playout_seeds_are_distinct_deterministic_and_do_not_advance_game(): + players = [RandomPlayer(Color.RED), RandomPlayer(Color.BLUE)] + game = Game(players, seed=7) + original_rng_state = game.state.random.getstate() + + first = _derive_playout_seeds(game, 8) + second = _derive_playout_seeds(game, 8) + + assert game.state.random.getstate() == original_rng_state + assert first == second + assert len(set(first)) == len(first) From 89e9397e05ed02d56c911f90ca199a0e382c2eb4 Mon Sep 17 00:00:00 2001 From: Tazik Shahjahan Date: Thu, 23 Jul 2026 16:22:04 -0700 Subject: [PATCH 5/8] Test multiprocessing playout diversity --- tests/test_playouts.py | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/tests/test_playouts.py b/tests/test_playouts.py index 5d784c5fa..56f11e842 100644 --- a/tests/test_playouts.py +++ b/tests/test_playouts.py @@ -1,4 +1,7 @@ +import pickle + from catanatron import Color, Game, RandomPlayer +from catanatron.players import playouts from catanatron.players.playouts import _derive_playout_seeds @@ -13,3 +16,37 @@ def test_playout_seeds_are_distinct_deterministic_and_do_not_advance_game(): assert game.state.random.getstate() == original_rng_state assert first == second assert len(set(first)) == len(first) + + +class _SerializedPool: + """Run inline, but deserialize each input as a separate worker would.""" + + def __init__(self, *args, **kwargs): + pass + + def __enter__(self): + return self + + def __exit__(self, *args): + pass + + def map(self, fn, params): + return [fn(pickle.loads(pickle.dumps(param))) for param in params] + + +def test_multiprocessing_playouts_do_not_repeat_one_rng_stream(monkeypatch): + players = [ + RandomPlayer(Color.RED), + RandomPlayer(Color.BLUE), + RandomPlayer(Color.WHITE), + RandomPlayer(Color.ORANGE), + ] + game = Game(players, seed=7, vps_to_win=3) + original_rng_state = game.state.random.getstate() + monkeypatch.setattr(playouts.multiprocessing, "Pool", _SerializedPool) + + winners = playouts.run_playouts(game, 16) + + assert sum(winners.values()) == 16 + assert len(winners) > 1 + assert game.state.random.getstate() == original_rng_state From a6f084f277b30b3269f11b2de0064016a0ba8d02 Mon Sep 17 00:00:00 2001 From: Tazik Shahjahan Date: Wed, 29 Jul 2026 17:16:51 -0500 Subject: [PATCH 6/8] Test database persistence and GUI compatibility --- .github/workflows/build.yml | 18 +++++ catanatron/catanatron/game.py | 21 +++++ tests/test_json.py | 33 ++++++++ tests/web/test_api.py | 85 ++++++++++++++++++-- ui/src/App.test.tsx | 3 +- ui/src/pages/GameScreen.test.tsx | 131 +++++++++++++++++++++++++++++++ 6 files changed, 284 insertions(+), 7 deletions(-) create mode 100644 ui/src/pages/GameScreen.test.tsx diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 91b3d8f0e..de81ef2ce 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -24,6 +24,22 @@ jobs: build-python: runs-on: ubuntu-latest + services: + postgres: + image: postgres:18 + env: + POSTGRES_USER: catanatron + POSTGRES_PASSWORD: victorypoint + POSTGRES_DB: catanatron_test + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U catanatron -d catanatron_test" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + env: + CATANATRON_TEST_DATABASE_URL: postgresql://catanatron:victorypoint@127.0.0.1:5432/catanatron_test strategy: matrix: python-version: ["3.11", "3.12"] @@ -89,5 +105,7 @@ jobs: node-version: ${{ steps.node-version.outputs.node-version }} - run: npm ci working-directory: ./ui + - run: npm test -- --run + working-directory: ./ui - run: npm run build working-directory: ./ui diff --git a/catanatron/catanatron/game.py b/catanatron/catanatron/game.py index 4f37d61f6..2b12d8d25 100644 --- a/catanatron/catanatron/game.py +++ b/catanatron/catanatron/game.py @@ -130,6 +130,27 @@ def __init__( ) self.playable_actions = generate_playable_actions(self.state) + def __setstate__(self, attributes): + """Restore pickled games and normalize their per-game RNG. + + Legacy database rows have neither ``Game.random`` nor + ``State.random``. Their module-global RNG state was never persisted, + so the original stream cannot be resumed exactly. Seed a new per-game + stream from the stored game seed so legacy games remain playable; + subsequent saves preserve that stream. + """ + self.__dict__.update(attributes) + + state_rng = getattr(self.state, "random", None) + game_rng = getattr(self, "random", None) + rng = state_rng if state_rng is not None else game_rng + if rng is None: + rng = random.Random(self.seed) + + # Game and State intentionally share one stream by reference. + self.random = rng + self.state.random = rng + def play(self, accumulators=[], decide_fn=None): """Executes game until a player wins or exceeded TURNS_LIMIT. diff --git a/tests/test_json.py b/tests/test_json.py index 3a0e6acac..d4a754b43 100644 --- a/tests/test_json.py +++ b/tests/test_json.py @@ -28,6 +28,39 @@ def test_serialization(): assert isinstance(result["action_records"], list) +def test_serialization_matches_gui_contract(): + game = Game( + players=[ + SimplePlayer(Color.RED), + SimplePlayer(Color.BLUE), + ], + seed=123, + ) + + result = json.loads(json.dumps(game, cls=GameEncoder)) + + assert { + "tiles", + "adjacent_tiles", + "nodes", + "edges", + "action_records", + "player_state", + "colors", + "bot_colors", + "is_initial_build_phase", + "robber_coordinate", + "current_color", + "current_prompt", + "current_discard_count", + "current_playable_actions", + "longest_roads_by_player", + "winning_color", + "state_index", + } <= set(result) + assert "random" not in result + + def test_action_from_json_maritime_trade(): data = ["RED", "MARITIME_TRADE", [SHEEP, SHEEP, SHEEP, SHEEP, ORE]] action = action_from_json(data) diff --git a/tests/web/test_api.py b/tests/web/test_api.py index babbbd4b4..329e445b9 100644 --- a/tests/web/test_api.py +++ b/tests/web/test_api.py @@ -1,17 +1,29 @@ +import os + import pytest import json +from catanatron import Color, Game, RandomPlayer +from catanatron.json import GameEncoder from catanatron.web import create_app -from catanatron.web.models import db, GameState, get_game_state +from catanatron.web.models import ( + db, + GameState, + get_game_state, + upsert_game_state, +) @pytest.fixture def app(): """Create and configure a new app instance for each test.""" - # Setup an in-memory SQLite database for testing + database_url = os.environ.get( + "CATANATRON_TEST_DATABASE_URL", + "sqlite:///:memory:", + ) app = create_app( { "TESTING": True, - "SQLALCHEMY_DATABASE_URI": "sqlite:///:memory:", + "SQLALCHEMY_DATABASE_URI": database_url, "SECRET_KEY": "test", } ) @@ -21,9 +33,9 @@ def app(): yield app - # Teardown: drop all tables after each test (optional, if tests are isolated) - # with app.app_context(): - # db.drop_all() + with app.app_context(): + db.session.remove() + db.drop_all() @pytest.fixture @@ -101,6 +113,14 @@ def test_get_game_endpoint(client): assert data["is_initial_build_phase"] is True assert data["winning_color"] is None + # The API reserializes the pickled game; it must match the JSON snapshot + # stored alongside that pickle for GUI and replay consumers. + with client.application.app_context(): + stored_state = ( + db.session.query(GameState).filter_by(uuid=game_id, state_index=0).one() + ) + assert data == json.loads(stored_state.state) + def test_get_latest_game_endpoint(client): """Test retrieving the latest game state.""" @@ -158,6 +178,59 @@ def test_repeated_bot_actions_advance_latest_state(client): assert latest_after["action_records"] == second_tick["action_records"] +def test_database_roundtrips_preserve_rng_state_and_continuation(client): + """Persisted games must resume the same random stream after every load.""" + + def make_players(): + return [RandomPlayer(Color.RED), RandomPlayer(Color.BLUE)] + + persisted_game = Game(make_players(), seed=123) + uninterrupted_game = Game(make_players(), seed=123) + + with client.application.app_context(): + upsert_game_state(persisted_game) + + for _ in range(25): + restored_game = get_game_state(persisted_game.id) + + assert restored_game.random is restored_game.state.random + assert ( + restored_game.state.random.getstate() + == uninterrupted_game.state.random.getstate() + ) + + restored_game.play_tick() + uninterrupted_game.play_tick() + upsert_game_state(restored_game) + + restored_json = json.loads(json.dumps(restored_game, cls=GameEncoder)) + uninterrupted_json = json.loads(json.dumps(uninterrupted_game, cls=GameEncoder)) + + assert restored_json == uninterrupted_json + + +def test_legacy_database_game_without_rng_can_continue(client): + """Games persisted before per-game RNGs existed must remain playable.""" + game = Game( + [RandomPlayer(Color.RED), RandomPlayer(Color.BLUE)], + seed=123, + ) + del game.random + del game.state.random + + with client.application.app_context(): + upsert_game_state(game) + restored_game = get_game_state(game.id) + + assert restored_game.random is restored_game.state.random + restored_game.play_tick() + upsert_game_state(restored_game) + + latest_game = get_game_state(game.id) + + assert len(latest_game.state.action_records) == 1 + + def test_mcts_analysis_endpoint(client): """Test the MCTS analysis endpoint.""" post_response = client.post("/api/games", json={"players": ["RANDOM", "RANDOM"]}) diff --git a/ui/src/App.test.tsx b/ui/src/App.test.tsx index d4848fbd5..eb4417e9c 100644 --- a/ui/src/App.test.tsx +++ b/ui/src/App.test.tsx @@ -7,6 +7,7 @@ test('renders setup controls on the home page', () => { expect(getByText(/Map Template/i)).toBeInTheDocument(); expect(getByText(/Points to Win/i)).toBeInTheDocument(); expect(getByText(/Card Discard Limit/i)).toBeInTheDocument(); - expect(getByText(/At most one Human player/i)).toBeInTheDocument(); + expect(getByText(/Open hands. Random discard choice./i)).toBeInTheDocument(); + expect(getByText(/^Players$/i)).toBeInTheDocument(); expect(getByText(/^Start$/i)).toBeInTheDocument(); }); diff --git a/ui/src/pages/GameScreen.test.tsx b/ui/src/pages/GameScreen.test.tsx new file mode 100644 index 000000000..af4a1426c --- /dev/null +++ b/ui/src/pages/GameScreen.test.tsx @@ -0,0 +1,131 @@ +import { useContext } from "react"; +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { afterEach, beforeEach, expect, test, vi } from "vitest"; + +import { StateProvider, store } from "../store"; +import type { GameState } from "../utils/api.types"; +import { getState, postAction } from "../utils/apiClient"; +import { dispatchSnackbar } from "../components/Snackbar"; +import GameScreen from "./GameScreen"; + +const snackbar = vi.hoisted(() => ({ + enqueueSnackbar: vi.fn(), + closeSnackbar: vi.fn(), +})); + +vi.mock("../utils/apiClient", () => ({ + getState: vi.fn(), + postAction: vi.fn(), +})); +vi.mock("../components/Snackbar", () => ({ + dispatchSnackbar: vi.fn(), +})); +vi.mock("notistack", () => ({ + useSnackbar: () => snackbar, +})); +vi.mock("./ZoomableBoard", () => ({ + default: () =>
Board
, +})); +vi.mock("./ActionsToolbar", () => ({ + default: () =>
Actions
, +})); +vi.mock("../components/LeftDrawer", () => ({ + default: () =>
Players
, +})); +vi.mock("../components/RightDrawer", () => ({ + default: ({ children }: { children: React.ReactNode }) => ( + + ), +})); +vi.mock("../components/AnalysisBox", () => ({ + default: () =>
Analysis
, +})); + +const baseState: GameState = { + tiles: [], + adjacent_tiles: {}, + bot_colors: ["RED"], + colors: ["RED", "BLUE"], + current_color: "BLUE", + winning_color: undefined, + current_prompt: "PLAY_TURN", + player_state: {}, + action_records: [], + robber_coordinate: [0, 0, 0], + current_discard_count: 0, + nodes: [], + edges: [], + current_playable_actions: [], + is_initial_build_phase: false, + state_index: 12, +}; + +function StateIndexProbe() { + const { state } = useContext(store); + return {state.gameState?.state_index}; +} + +function renderGameScreen() { + return render( + + + + } + /> + + + + + ); +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +afterEach(() => { + cleanup(); +}); + +test("loads a persisted API state and renders the gameplay screen", async () => { + vi.mocked(getState).mockResolvedValue(baseState); + + renderGameScreen(); + + expect(await screen.findByTestId("board")).toBeInTheDocument(); + expect(screen.getByTestId("actions-toolbar")).toBeInTheDocument(); + expect(screen.getByTestId("left-drawer")).toBeInTheDocument(); + expect(screen.getByTestId("analysis-box")).toBeInTheDocument(); + expect(screen.getByTestId("state-index")).toHaveTextContent("12"); + expect(getState).toHaveBeenCalledWith("game-123", undefined); + expect(postAction).not.toHaveBeenCalled(); +}); + +test("advances a persisted bot turn and publishes the returned state", async () => { + const botTurn = { ...baseState, current_color: "RED" as const }; + const humanTurn = { + ...baseState, + state_index: 13, + action_records: [ + [["RED", "END_TURN", null], null], + ] as GameState["action_records"], + }; + vi.mocked(getState).mockResolvedValue(botTurn); + vi.mocked(postAction).mockResolvedValue(humanTurn); + + renderGameScreen(); + + await waitFor(() => { + expect(postAction).toHaveBeenCalledWith("game-123"); + }); + await waitFor( + () => { + expect(screen.getByTestId("state-index")).toHaveTextContent("13"); + }, + { timeout: 1000 } + ); + expect(dispatchSnackbar).toHaveBeenCalledOnce(); +}); From 1d2a760fe65bdddf5273cb113513e0e6fd619250 Mon Sep 17 00:00:00 2001 From: Tazik Shahjahan Date: Wed, 29 Jul 2026 18:11:50 -0500 Subject: [PATCH 7/8] Harden GUI serialization regression coverage --- catanatron/catanatron/players/search.py | 1 - catanatron/catanatron/players/value.py | 1 - tests/test_json.py | 12 ++ ui/src/components/AnalysisBox.tsx | 4 +- ui/src/pages/ActionsToolbar.tsx | 2 +- ui/src/pages/Board.test.tsx | 34 ++++++ ui/src/pages/GameScreen.test.tsx | 145 ++++++++++++++---------- ui/src/pages/ReplayScreen.test.tsx | 79 +++++++++++++ ui/src/pages/Tile.tsx | 8 +- ui/src/pages/ZoomableBoard.tsx | 2 +- ui/src/test/fixtures.ts | 111 ++++++++++++++++++ ui/src/utils/api.types.ts | 41 ++++--- ui/src/utils/promptUtils.ts | 8 +- 13 files changed, 358 insertions(+), 90 deletions(-) create mode 100644 ui/src/pages/Board.test.tsx create mode 100644 ui/src/pages/ReplayScreen.test.tsx create mode 100644 ui/src/test/fixtures.ts diff --git a/catanatron/catanatron/players/search.py b/catanatron/catanatron/players/search.py index 70d34e6e4..41735626a 100644 --- a/catanatron/catanatron/players/search.py +++ b/catanatron/catanatron/players/search.py @@ -1,4 +1,3 @@ - from catanatron.state_functions import ( player_key, ) diff --git a/catanatron/catanatron/players/value.py b/catanatron/catanatron/players/value.py index c1d05793e..fad9f9aee 100644 --- a/catanatron/catanatron/players/value.py +++ b/catanatron/catanatron/players/value.py @@ -1,4 +1,3 @@ - from catanatron.state_functions import ( get_longest_road_length, get_played_dev_cards, diff --git a/tests/test_json.py b/tests/test_json.py index d4a754b43..0d1262228 100644 --- a/tests/test_json.py +++ b/tests/test_json.py @@ -59,6 +59,18 @@ def test_serialization_matches_gui_contract(): "state_index", } <= set(result) assert "random" not in result + assert isinstance(result["tiles"], list) + assert isinstance(result["nodes"], dict) + assert isinstance(result["edges"], list) + assert result["winning_color"] is None + + tile_types = {placed_tile["tile"]["type"] for placed_tile in result["tiles"]} + assert {"RESOURCE_TILE", "DESERT", "PORT", "WATER"} <= tile_types + assert any( + placed_tile["tile"]["type"] == "PORT" + and placed_tile["tile"]["resource"] is None + for placed_tile in result["tiles"] + ) def test_action_from_json_maritime_trade(): diff --git a/ui/src/components/AnalysisBox.tsx b/ui/src/components/AnalysisBox.tsx index 53ffde4f0..a6ea42d3e 100644 --- a/ui/src/components/AnalysisBox.tsx +++ b/ui/src/components/AnalysisBox.tsx @@ -2,7 +2,7 @@ import { useContext, useState } from "react"; import { CircularProgress, Button } from "@mui/material"; import AssessmentIcon from "@mui/icons-material/Assessment"; import { type MCTSProbabilities, type StateIndex, getMctsAnalysis } from "../utils/apiClient"; -import { useParams } from "react-router"; +import { useParams } from "react-router-dom"; import "./AnalysisBox.scss"; import { store } from "../store"; @@ -83,4 +83,4 @@ export default function AnalysisBox( { stateIndex }: AnalysisBoxProps ) { )} ); -} \ No newline at end of file +} diff --git a/ui/src/pages/ActionsToolbar.tsx b/ui/src/pages/ActionsToolbar.tsx index d759e99fd..ddef7359b 100644 --- a/ui/src/pages/ActionsToolbar.tsx +++ b/ui/src/pages/ActionsToolbar.tsx @@ -19,7 +19,7 @@ import Paper from "@mui/material/Paper"; import Popper from "@mui/material/Popper"; import MenuList from "@mui/material/MenuList"; import SimCardIcon from "@mui/icons-material/SimCard"; -import { useParams } from "react-router"; +import { useParams } from "react-router-dom"; import Hidden from "../components/Hidden"; import Prompt from "../components/Prompt"; diff --git a/ui/src/pages/Board.test.tsx b/ui/src/pages/Board.test.tsx new file mode 100644 index 000000000..536f273ec --- /dev/null +++ b/ui/src/pages/Board.test.tsx @@ -0,0 +1,34 @@ +import { render, screen } from "@testing-library/react"; +import { expect, test, vi } from "vitest"; + +import { makeGameState } from "../test/fixtures"; +import Board from "./Board"; + +test("renders the nested tile, node, and edge shapes emitted by GameEncoder", () => { + const onNodeClick = vi.fn(); + const onEdgeClick = vi.fn(); + const onTileClick = vi.fn(); + const gameState = makeGameState(); + + const { container } = render( + onNodeClick} + buildOnEdgeClick={() => onEdgeClick} + handleTileClick={onTileClick} + replayMode={false} + gameState={gameState} + isMobile={false} + show + isMovingRobber={false} + /> + ); + + expect(container.querySelectorAll(".tile")).toHaveLength(3); + expect(container.querySelectorAll(".node")).toHaveLength(1); + expect(container.querySelectorAll(".edge")).toHaveLength(1); + expect(container.querySelectorAll(".robber")).toHaveLength(1); + expect(screen.getByText("11")).toBeInTheDocument(); + expect(screen.getByText("3:1")).toBeInTheDocument(); +}); diff --git a/ui/src/pages/GameScreen.test.tsx b/ui/src/pages/GameScreen.test.tsx index af4a1426c..51befe9ef 100644 --- a/ui/src/pages/GameScreen.test.tsx +++ b/ui/src/pages/GameScreen.test.tsx @@ -1,5 +1,6 @@ import { useContext } from "react"; -import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import { act, cleanup, render, screen } from "@testing-library/react"; +import { createTheme, ThemeProvider } from "@mui/material/styles"; import { MemoryRouter, Route, Routes } from "react-router-dom"; import { afterEach, beforeEach, expect, test, vi } from "vitest"; @@ -7,6 +8,7 @@ import { StateProvider, store } from "../store"; import type { GameState } from "../utils/api.types"; import { getState, postAction } from "../utils/apiClient"; import { dispatchSnackbar } from "../components/Snackbar"; +import { makeGameState } from "../test/fixtures"; import GameScreen from "./GameScreen"; const snackbar = vi.hoisted(() => ({ @@ -17,6 +19,7 @@ const snackbar = vi.hoisted(() => ({ vi.mock("../utils/apiClient", () => ({ getState: vi.fn(), postAction: vi.fn(), + getMctsAnalysis: vi.fn(), })); vi.mock("../components/Snackbar", () => ({ dispatchSnackbar: vi.fn(), @@ -27,58 +30,46 @@ vi.mock("notistack", () => ({ vi.mock("./ZoomableBoard", () => ({ default: () =>
Board
, })); -vi.mock("./ActionsToolbar", () => ({ - default: () =>
Actions
, -})); -vi.mock("../components/LeftDrawer", () => ({ - default: () =>
Players
, -})); -vi.mock("../components/RightDrawer", () => ({ - default: ({ children }: { children: React.ReactNode }) => ( - - ), -})); -vi.mock("../components/AnalysisBox", () => ({ - default: () =>
Analysis
, -})); -const baseState: GameState = { - tiles: [], - adjacent_tiles: {}, - bot_colors: ["RED"], - colors: ["RED", "BLUE"], - current_color: "BLUE", - winning_color: undefined, - current_prompt: "PLAY_TURN", - player_state: {}, - action_records: [], - robber_coordinate: [0, 0, 0], - current_discard_count: 0, - nodes: [], - edges: [], - current_playable_actions: [], - is_initial_build_phase: false, - state_index: 12, -}; +const theme = createTheme(); +const baseState = makeGameState(); + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} function StateIndexProbe() { const { state } = useContext(store); return {state.gameState?.state_index}; } -function renderGameScreen() { +function renderGameScreen({ + initialEntry = "/games/game-123", + routePath = "/games/:gameId", + replayMode = false, +}: { + initialEntry?: string; + routePath?: string; + replayMode?: boolean; +} = {}) { return render( - - - - } - /> - - - - + + + + + } + /> + + + + + ); } @@ -88,44 +79,74 @@ beforeEach(() => { afterEach(() => { cleanup(); + vi.useRealTimers(); }); -test("loads a persisted API state and renders the gameplay screen", async () => { +test("loads a persisted API state into the real gameplay controls", async () => { vi.mocked(getState).mockResolvedValue(baseState); renderGameScreen(); expect(await screen.findByTestId("board")).toBeInTheDocument(); - expect(screen.getByTestId("actions-toolbar")).toBeInTheDocument(); - expect(screen.getByTestId("left-drawer")).toBeInTheDocument(); - expect(screen.getByTestId("analysis-box")).toBeInTheDocument(); + expect( + screen.getByRole("heading", { name: "Catanatron" }) + ).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "ROLL" })).toBeEnabled(); + expect(screen.getAllByText("Win Probability Analysis").length).toBeGreaterThan( + 0 + ); + expect(screen.getAllByTitle("Victory Points").length).toBeGreaterThan(0); expect(screen.getByTestId("state-index")).toHaveTextContent("12"); expect(getState).toHaveBeenCalledWith("game-123", undefined); expect(postAction).not.toHaveBeenCalled(); }); test("advances a persisted bot turn and publishes the returned state", async () => { - const botTurn = { ...baseState, current_color: "RED" as const }; - const humanTurn = { - ...baseState, + const botTurn = makeGameState({ current_color: "RED" }); + const humanTurn: GameState = makeGameState({ state_index: 13, action_records: [ [["RED", "END_TURN", null], null], ] as GameState["action_records"], - }; + }); + const actionResponse = deferred(); vi.mocked(getState).mockResolvedValue(botTurn); - vi.mocked(postAction).mockResolvedValue(humanTurn); + vi.mocked(postAction).mockReturnValue(actionResponse.promise); renderGameScreen(); - await waitFor(() => { - expect(postAction).toHaveBeenCalledWith("game-123"); + expect(await screen.findByTestId("board")).toBeInTheDocument(); + expect(postAction).toHaveBeenCalledWith("game-123"); + + vi.useFakeTimers(); + await act(async () => { + actionResponse.resolve(humanTurn); + await actionResponse.promise; }); - await waitFor( - () => { - expect(screen.getByTestId("state-index")).toHaveTextContent("13"); - }, - { timeout: 1000 } - ); + + expect(screen.getByTestId("state-index")).toHaveTextContent("12"); + + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + }); + + expect(screen.getByTestId("state-index")).toHaveTextContent("13"); + expect(screen.getByRole("button", { name: "ROLL" })).toBeEnabled(); expect(dispatchSnackbar).toHaveBeenCalledOnce(); }); + +test("loads a historical state without advancing bots in replay mode", async () => { + vi.mocked(getState).mockResolvedValue(makeGameState({ state_index: 7 })); + + renderGameScreen({ + initialEntry: "/games/game-123/states/7", + routePath: "/games/:gameId/states/:stateIndex", + replayMode: true, + }); + + expect(await screen.findByTestId("board")).toBeInTheDocument(); + expect(screen.getByTestId("state-index")).toHaveTextContent("7"); + expect(getState).toHaveBeenCalledWith("game-123", "7"); + expect(postAction).not.toHaveBeenCalled(); + expect(screen.queryByRole("button", { name: "ROLL" })).not.toBeInTheDocument(); +}); diff --git a/ui/src/pages/ReplayScreen.test.tsx b/ui/src/pages/ReplayScreen.test.tsx new file mode 100644 index 000000000..1b63b4006 --- /dev/null +++ b/ui/src/pages/ReplayScreen.test.tsx @@ -0,0 +1,79 @@ +import { useContext } from "react"; +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { afterEach, beforeEach, expect, test, vi } from "vitest"; + +import { StateProvider, store } from "../store"; +import { makeGameState } from "../test/fixtures"; +import { getState } from "../utils/apiClient"; +import ReplayScreen from "./ReplayScreen"; + +vi.mock("../utils/apiClient", () => ({ + getState: vi.fn(), +})); +vi.mock("./ZoomableBoard", () => ({ + default: () =>
Board
, +})); +vi.mock("../components/LeftDrawer", () => ({ + default: () =>
Players
, +})); +vi.mock("../components/RightDrawer", () => ({ + default: ({ children }: { children: React.ReactNode }) => ( + + ), +})); +vi.mock("../components/AnalysisBox", () => ({ + default: () =>
Analysis
, +})); + +function StateIndexProbe() { + const { state } = useContext(store); + return {state.gameState?.state_index}; +} + +function renderReplayScreen() { + return render( + + + + } /> + + + + + ); +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getState).mockImplementation(async (_gameId, stateIndex) => { + const index = stateIndex === "latest" ? 12 : Number(stateIndex); + return makeGameState({ state_index: index }); + }); +}); + +afterEach(() => { + cleanup(); +}); + +test("loads the latest replay boundary and navigates persisted states", async () => { + const user = userEvent.setup(); + renderReplayScreen(); + + expect(await screen.findByTestId("board")).toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByText("Move: 0 / 12")).toBeInTheDocument(); + }); + expect(getState).toHaveBeenCalledWith("game-123", "latest"); + expect(getState).toHaveBeenCalledWith("game-123", 0); + expect(screen.getByTestId("state-index")).toHaveTextContent("0"); + + await user.click(screen.getByRole("button", { name: "Next Move" })); + + await waitFor(() => { + expect(getState).toHaveBeenCalledWith("game-123", 1); + expect(screen.getByTestId("state-index")).toHaveTextContent("1"); + expect(screen.getByText("Move: 1 / 12")).toBeInTheDocument(); + }); +}); diff --git a/ui/src/pages/Tile.tsx b/ui/src/pages/Tile.tsx index 920be79ad..b68ff09aa 100644 --- a/ui/src/pages/Tile.tsx +++ b/ui/src/pages/Tile.tsx @@ -103,12 +103,12 @@ const Port = ({ resource, style, }: { - resource: ResourceCard; + resource: ResourceCard | null; style: Partial; }) => { let ratio; let tile; - if (resource in RESOURCES) { + if (resource !== null && resource in RESOURCES) { ratio = "2:1"; tile = RESOURCES[resource]; } else { @@ -164,8 +164,8 @@ export default function Tile({ resourceTile = desertTile; } else if (tile.type === "PORT") { const { x, y } = calculatePortPosition(tile.direction, size); - contents = () - } + contents = ; + } return (
= {} +): GameState { + return { + tiles: [ + { + coordinate: [0, 0, 0], + tile: { + id: 0, + type: "RESOURCE_TILE", + resource: "WHEAT", + number: 11, + }, + }, + { + coordinate: [1, -1, 0], + tile: { + id: 19, + type: "PORT", + direction: "WEST", + resource: null, + }, + }, + { + coordinate: [2, -2, 0], + tile: { + type: "WATER", + }, + }, + ], + adjacent_tiles: { + "0": [ + { + id: 0, + type: "RESOURCE_TILE", + resource: "WHEAT", + number: 11, + }, + ], + }, + bot_colors: ["RED"], + colors: ["RED", "BLUE"], + current_color: "BLUE", + winning_color: null, + current_prompt: "PLAY_TURN", + player_state: { + ...makePlayerState(0), + ...makePlayerState(1), + P1_WOOD_IN_HAND: 2, + }, + action_records: [], + robber_coordinate: [0, 0, 0], + current_discard_count: 0, + nodes: { + "0": { + id: 0, + tile_coordinate: [0, 0, 0], + direction: "SOUTHWEST", + building: "SETTLEMENT", + color: "RED", + }, + }, + edges: [ + { + id: [0, 1], + color: "RED", + direction: "WEST", + tile_coordinate: [0, 0, 0], + }, + ], + current_playable_actions: [["BLUE", "ROLL", null]], + is_initial_build_phase: false, + longest_roads_by_player: { + RED: 1, + BLUE: 0, + }, + state_index: 12, + ...overrides, + }; +} diff --git a/ui/src/utils/api.types.ts b/ui/src/utils/api.types.ts index 8d70ea800..ce7334448 100644 --- a/ui/src/utils/api.types.ts +++ b/ui/src/utils/api.types.ts @@ -83,47 +83,56 @@ type DesertTile = { type: "DESERT"; }; +type WaterTile = { + type: "WATER"; +}; + type PortTile = { id: number; type: "PORT"; direction: Direction; - resource: ResourceCard; + resource: ResourceCard | null; }; -export type Tile = ResourceTile | DesertTile | PortTile; +export type Tile = ResourceTile | DesertTile | WaterTile | PortTile; export type PlacedTile = { coordinate: TileCoordinate; tile: Tile; }; +export type GameNode = { + id: number; + tile_coordinate: TileCoordinate; + direction: Direction; + building: Building | null; + color: Color | null; +}; + +export type GameEdge = { + id: [number, number]; + color: Color | null; + direction: Direction; + tile_coordinate: TileCoordinate; +}; + export type GameState = { tiles: PlacedTile[]; adjacent_tiles: Record; bot_colors: Color[]; colors: Color[]; current_color: Color; - winning_color?: Color; + winning_color: Color | null; current_prompt: string; player_state: Record; action_records: GameActionRecord[]; robber_coordinate: TileCoordinate; current_discard_count: number; - nodes: Array<{ - id: number; - tile_coordinate: TileCoordinate; - direction: Direction; - building: Building | null; - color: Color | null; - }>; - edges: Array<{ - id: [number, number]; - color: Color | null; - direction: Direction; - tile_coordinate: TileCoordinate; - }>; + nodes: Record; + edges: GameEdge[]; current_playable_actions: GameAction[]; is_initial_build_phase: boolean; + longest_roads_by_player: Partial>; edgeActions?: GameAction[]; nodeActions?: GameAction[]; state_index: number; diff --git a/ui/src/utils/promptUtils.ts b/ui/src/utils/promptUtils.ts index 52f2b4a3a..bb7bbd519 100644 --- a/ui/src/utils/promptUtils.ts +++ b/ui/src/utils/promptUtils.ts @@ -40,8 +40,12 @@ export function humanizeActionRecord( case "BUILD_ROAD": { const action = actionRecord[0] as BuildRoadAction; const edge = action[2]; - const a = gameState.adjacent_tiles[edge[0]].map((t) => t.id); - const b = gameState.adjacent_tiles[edge[1]].map((t) => t.id); + const a = gameState.adjacent_tiles[edge[0]].flatMap((tile) => + "id" in tile ? [tile.id] : [] + ); + const b = gameState.adjacent_tiles[edge[1]].flatMap((tile) => + "id" in tile ? [tile.id] : [] + ); const intersection = a.filter((t) => b.includes(t)); const tiles = intersection.map( (tileId) => findTileById(gameState, tileId).tile, From 426ed1ab540d5364a55ed53a72e25271b49ad0cb Mon Sep 17 00:00:00 2001 From: Tazik Shahjahan Date: Wed, 29 Jul 2026 18:51:09 -0500 Subject: [PATCH 8/8] Tighten GUI regression tests --- tests/test_json.py | 23 +++-------------------- ui/src/pages/Board.test.tsx | 11 ++++------- ui/src/pages/GameScreen.test.tsx | 10 +--------- 3 files changed, 8 insertions(+), 36 deletions(-) diff --git a/tests/test_json.py b/tests/test_json.py index 0d1262228..2fe1702f9 100644 --- a/tests/test_json.py +++ b/tests/test_json.py @@ -7,32 +7,13 @@ from catanatron.json import GameEncoder, action_from_json -def test_serialization(): +def test_serialization_matches_gui_contract(): game = Game( players=[ SimplePlayer(Color.RED), SimplePlayer(Color.BLUE), SimplePlayer(Color.WHITE), SimplePlayer(Color.ORANGE), - ] - ) - - string = json.dumps(game, cls=GameEncoder) - result = json.loads(string) - - # Loosely assert looks like expected - assert isinstance(result["robber_coordinate"], list) - assert isinstance(result["tiles"], list) - assert isinstance(result["edges"], list) - assert isinstance(result["nodes"], dict) - assert isinstance(result["action_records"], list) - - -def test_serialization_matches_gui_contract(): - game = Game( - players=[ - SimplePlayer(Color.RED), - SimplePlayer(Color.BLUE), ], seed=123, ) @@ -62,6 +43,8 @@ def test_serialization_matches_gui_contract(): assert isinstance(result["tiles"], list) assert isinstance(result["nodes"], dict) assert isinstance(result["edges"], list) + assert isinstance(result["action_records"], list) + assert isinstance(result["robber_coordinate"], list) assert result["winning_color"] is None tile_types = {placed_tile["tile"]["type"] for placed_tile in result["tiles"]} diff --git a/ui/src/pages/Board.test.tsx b/ui/src/pages/Board.test.tsx index 536f273ec..74c35daa0 100644 --- a/ui/src/pages/Board.test.tsx +++ b/ui/src/pages/Board.test.tsx @@ -1,22 +1,19 @@ import { render, screen } from "@testing-library/react"; -import { expect, test, vi } from "vitest"; +import { expect, test } from "vitest"; import { makeGameState } from "../test/fixtures"; import Board from "./Board"; test("renders the nested tile, node, and edge shapes emitted by GameEncoder", () => { - const onNodeClick = vi.fn(); - const onEdgeClick = vi.fn(); - const onTileClick = vi.fn(); const gameState = makeGameState(); const { container } = render( onNodeClick} - buildOnEdgeClick={() => onEdgeClick} - handleTileClick={onTileClick} + buildOnNodeClick={() => () => undefined} + buildOnEdgeClick={() => () => undefined} + handleTileClick={() => undefined} replayMode={false} gameState={gameState} isMobile={false} diff --git a/ui/src/pages/GameScreen.test.tsx b/ui/src/pages/GameScreen.test.tsx index 51befe9ef..35dc6cb64 100644 --- a/ui/src/pages/GameScreen.test.tsx +++ b/ui/src/pages/GameScreen.test.tsx @@ -88,13 +88,7 @@ test("loads a persisted API state into the real gameplay controls", async () => renderGameScreen(); expect(await screen.findByTestId("board")).toBeInTheDocument(); - expect( - screen.getByRole("heading", { name: "Catanatron" }) - ).toBeInTheDocument(); expect(screen.getByRole("button", { name: "ROLL" })).toBeEnabled(); - expect(screen.getAllByText("Win Probability Analysis").length).toBeGreaterThan( - 0 - ); expect(screen.getAllByTitle("Victory Points").length).toBeGreaterThan(0); expect(screen.getByTestId("state-index")).toHaveTextContent("12"); expect(getState).toHaveBeenCalledWith("game-123", undefined); @@ -124,10 +118,8 @@ test("advances a persisted bot turn and publishes the returned state", async () await actionResponse.promise; }); - expect(screen.getByTestId("state-index")).toHaveTextContent("12"); - await act(async () => { - await vi.advanceTimersByTimeAsync(300); + await vi.runAllTimersAsync(); }); expect(screen.getByTestId("state-index")).toHaveTextContent("13");