diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 91b3d8f0..de81ef2c 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/apply_action.py b/catanatron/catanatron/apply_action.py index 72900578..59fc8089 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 d624cb87..2b12d8d2 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,9 +126,31 @@ 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) + 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. @@ -220,6 +242,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 cdb30a59..b945b31c 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 4950c1f2..b82903ff 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 131c29a3..cbbcf263 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 94285f8e..76063198 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 5b738011..0dfa52a8 100644 --- a/catanatron/catanatron/players/playouts.py +++ b/catanatron/catanatron/players/playouts.py @@ -1,6 +1,6 @@ -import time -import random import multiprocessing +import random +import time from collections import Counter from catanatron.game import Game @@ -55,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) @@ -75,5 +98,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 3f4c7e9e..41735626 100644 --- a/catanatron/catanatron/players/search.py +++ b/catanatron/catanatron/players/search.py @@ -1,5 +1,3 @@ -import random - from catanatron.state_functions import ( player_key, ) @@ -32,4 +30,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 06f15511..fad9f9ae 100644 --- a/catanatron/catanatron/players/value.py +++ b/catanatron/catanatron/players/value.py @@ -1,5 +1,3 @@ -import random - from catanatron.state_functions import ( get_longest_road_length, get_played_dev_cards, @@ -162,8 +160,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 3204e6c1..c35da8e7 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 93f6e52a..d48d11da 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 ec05ed3b..e5a444c0 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_game.py b/tests/test_game.py index a8a02449..90aac6be 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 diff --git a/tests/test_gym.py b/tests/test_gym.py index ceab6893..663fd6da 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 diff --git a/tests/test_json.py b/tests/test_json.py index 3a0e6aca..2fe1702f 100644 --- a/tests/test_json.py +++ b/tests/test_json.py @@ -7,25 +7,53 @@ 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), - ] + ], + seed=123, ) - string = json.dumps(game, cls=GameEncoder) - result = json.loads(string) - - # Loosely assert looks like expected - assert isinstance(result["robber_coordinate"], list) + 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 assert isinstance(result["tiles"], list) - assert isinstance(result["edges"], 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"]} + 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/tests/test_playouts.py b/tests/test_playouts.py new file mode 100644 index 00000000..56f11e84 --- /dev/null +++ b/tests/test_playouts.py @@ -0,0 +1,52 @@ +import pickle + +from catanatron import Color, Game, RandomPlayer +from catanatron.players import playouts +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) + + +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 diff --git a/tests/test_state.py b/tests/test_state.py index 2c169220..e057a9a7 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 diff --git a/tests/web/test_api.py b/tests/web/test_api.py index babbbd4b..329e445b 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 d4848fbd..eb4417e9 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/components/AnalysisBox.tsx b/ui/src/components/AnalysisBox.tsx index 53ffde4f..a6ea42d3 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 d759e99f..ddef7359 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 00000000..74c35daa --- /dev/null +++ b/ui/src/pages/Board.test.tsx @@ -0,0 +1,31 @@ +import { render, screen } from "@testing-library/react"; +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 gameState = makeGameState(); + + const { container } = render( + () => undefined} + buildOnEdgeClick={() => () => undefined} + handleTileClick={() => undefined} + 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 new file mode 100644 index 00000000..35dc6cb6 --- /dev/null +++ b/ui/src/pages/GameScreen.test.tsx @@ -0,0 +1,144 @@ +import { useContext } from "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"; + +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(() => ({ + enqueueSnackbar: vi.fn(), + closeSnackbar: vi.fn(), +})); + +vi.mock("../utils/apiClient", () => ({ + getState: vi.fn(), + postAction: vi.fn(), + getMctsAnalysis: vi.fn(), +})); +vi.mock("../components/Snackbar", () => ({ + dispatchSnackbar: vi.fn(), +})); +vi.mock("notistack", () => ({ + useSnackbar: () => snackbar, +})); +vi.mock("./ZoomableBoard", () => ({ + default: () =>
Board
, +})); + +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({ + initialEntry = "/games/game-123", + routePath = "/games/:gameId", + replayMode = false, +}: { + initialEntry?: string; + routePath?: string; + replayMode?: boolean; +} = {}) { + return render( + + + + + } + /> + + + + + + ); +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +afterEach(() => { + cleanup(); + vi.useRealTimers(); +}); + +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.getByRole("button", { name: "ROLL" })).toBeEnabled(); + 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 = 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).mockReturnValue(actionResponse.promise); + + renderGameScreen(); + + expect(await screen.findByTestId("board")).toBeInTheDocument(); + expect(postAction).toHaveBeenCalledWith("game-123"); + + vi.useFakeTimers(); + await act(async () => { + actionResponse.resolve(humanTurn); + await actionResponse.promise; + }); + + await act(async () => { + await vi.runAllTimersAsync(); + }); + + 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 00000000..1b63b400 --- /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 920be79a..b68ff09a 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 8d70ea80..ce733444 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 52f2b4a3..bb7bbd51 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,