diff --git a/catanatron/catanatron/apply_action.py b/catanatron/catanatron/apply_action.py index 85672a1c..ad38e4a3 100644 --- a/catanatron/catanatron/apply_action.py +++ b/catanatron/catanatron/apply_action.py @@ -93,7 +93,7 @@ def apply_action( action_record = apply_buy_development_card(state, action, action_record) elif action.action_type == ActionType.ROLL: action_record = apply_roll(state, action, action_record) - elif action.action_type == ActionType.DISCARD: + elif action.action_type == ActionType.DISCARD_RESOURCE: action_record = apply_discard(state, action, action_record) elif action.action_type == ActionType.MOVE_ROBBER: action_record = apply_move_robber(state, action, action_record) @@ -266,17 +266,26 @@ def apply_roll(state: State, action: Action, action_record=None): action = Action(action.color, action.action_type, dices) if number == 7: - discarders = [ - player_num_resource_cards(state, color) > state.discard_limit + state.discard_counts = { + color: ( + player_num_resource_cards(state, color) // 2 + if player_num_resource_cards(state, color) > state.discard_limit + else 0 + ) for color in state.colors - ] - should_enter_discarding_sequence = any(discarders) + } + should_enter_discarding_sequence = any(state.discard_counts.values()) if should_enter_discarding_sequence: - state.current_player_index = discarders.index(True) + state.current_player_index = next( + i + for i, color in enumerate(state.colors) + if state.discard_counts[color] > 0 + ) state.current_prompt = ActionPrompt.DISCARD state.is_discarding = True else: + state.discard_counts = {color: 0 for color in state.colors} # state.current_player_index stays the same state.current_prompt = ActionPrompt.MOVE_ROBBER state.is_moving_knight = True @@ -295,33 +304,53 @@ def apply_roll(state: State, action: Action, action_record=None): return ActionRecord(action=action, result=dices) -def apply_discard(state: State, action: Action, action_record=None): +def normalize_discarded_cards(state: State, action: Action, action_record=None): + if action.value is not None: + if isinstance(action.value, (list, tuple)): + return list(action.value) + return [action.value] + + if action_record is not None and action_record.result is not None: + if isinstance(action_record.result, (list, tuple)): + return list(action_record.result) + return [action_record.result] + hand = player_deck_to_array(state, action.color) - num_to_discard = len(hand) // 2 - if action_record is None: - # TODO: Forcefully discard randomly so that decision tree doesnt explode in possibilities. - discarded = random.sample(hand, k=num_to_discard) - else: - discarded = action_record.result # for replay functionality + return [random.choice(hand)] + + +def apply_discard(state: State, action: Action, action_record=None): + discarded = normalize_discarded_cards(state, action, action_record) + remaining = state.discard_counts[action.color] + if len(discarded) > remaining: + raise ValueError("Trying to discard more cards than required") + to_discard = freqdeck_from_listdeck(discarded) player_freqdeck_subtract(state, action.color, to_discard) state.resource_freqdeck = freqdeck_add(state.resource_freqdeck, to_discard) - action = Action(action.color, action.action_type, discarded) - - # Advance turn - discarders_left = [ - player_num_resource_cards(state, color) > 7 for color in state.colors - ][state.current_player_index + 1 :] - if any(discarders_left): - to_skip = discarders_left.index(True) - state.current_player_index = state.current_player_index + 1 + to_skip + state.discard_counts[action.color] = remaining - len(discarded) + action_value = discarded[0] if len(discarded) == 1 else tuple(discarded) + action = Action(action.color, action.action_type, action_value) + + if state.discard_counts[action.color] > 0: + # state.current_player_index stays the same # state.current_prompt stays the same + pass else: - state.current_player_index = state.current_turn_index - state.current_prompt = ActionPrompt.MOVE_ROBBER - state.is_discarding = False - state.is_moving_knight = True + discarders_left = [state.discard_counts[color] > 0 for color in state.colors][ + state.current_player_index + 1 : + ] + if any(discarders_left): + to_skip = discarders_left.index(True) + state.current_player_index = state.current_player_index + 1 + to_skip + # state.current_prompt stays the same + else: + state.current_player_index = state.current_turn_index + state.current_prompt = ActionPrompt.MOVE_ROBBER + state.is_discarding = False + state.is_moving_knight = True + state.discard_counts = {color: 0 for color in state.colors} return ActionRecord(action=action, result=discarded) diff --git a/catanatron/catanatron/features.py b/catanatron/catanatron/features.py index 8aad0453..85cdd76f 100644 --- a/catanatron/catanatron/features.py +++ b/catanatron/catanatron/features.py @@ -486,7 +486,7 @@ def game_features(game: Game, p0_color: Color): features = { "BANK_DEV_CARDS": len(game.state.development_listdeck), "IS_MOVING_ROBBER": ActionType.MOVE_ROBBER in possibilities, - "IS_DISCARDING": ActionType.DISCARD in possibilities, + "IS_DISCARDING": ActionType.DISCARD_RESOURCE in possibilities, } for resource in RESOURCES: features[f"BANK_{resource}"] = freqdeck_count( diff --git a/catanatron/catanatron/gym/envs/action_space.py b/catanatron/catanatron/gym/envs/action_space.py index 68537c72..8a8c8761 100644 --- a/catanatron/catanatron/gym/envs/action_space.py +++ b/catanatron/catanatron/gym/envs/action_space.py @@ -17,10 +17,15 @@ def get_action_array( # We sort the actions to ensure a consistent ordering and reproducibility # without sorting, we couldn't get gym usages to be reproducible + def action_sort_key(action): + # Preserve historical DISCARD ordering so the rename does not reshuffle + # integer action ids for gym consumers. + return str(action).replace("DISCARD_RESOURCE", "DISCARD") + actions_array = sorted( [ (ActionType.ROLL, None), - (ActionType.DISCARD, None), + *[(ActionType.DISCARD_RESOURCE, resource) for resource in RESOURCES], *[ (ActionType.BUILD_ROAD, tuple(sorted(edge))) for edge in get_edges(catan_map.land_nodes) @@ -69,7 +74,7 @@ def get_action_array( ], (ActionType.END_TURN, None), ], - key=lambda x: str(x), + key=action_sort_key, ) return actions_array diff --git a/catanatron/catanatron/gym/envs/catanatron_env.py b/catanatron/catanatron/gym/envs/catanatron_env.py index 88712ee0..8aa2fbe8 100644 --- a/catanatron/catanatron/gym/envs/catanatron_env.py +++ b/catanatron/catanatron/gym/envs/catanatron_env.py @@ -329,9 +329,8 @@ def _is_done(self) -> bool: - Float * - IS_DISCARDING - - Whether current player must discard. For now, there is only 1 - discarding action (at random), since otherwise action space - would explode in size. + - Whether current player must discard. Discarding is represented + as one action per resource type currently held. - 1 - Boolean * - IS_MOVING_ROBBER diff --git a/catanatron/catanatron/json.py b/catanatron/catanatron/json.py index 9d9756d4..618f90be 100644 --- a/catanatron/catanatron/json.py +++ b/catanatron/catanatron/json.py @@ -30,6 +30,15 @@ def action_from_json(data) -> Action: if len(resources) not in [1, 2]: raise ValueError("Year of Plenty action must have 1 or 2 resources") action = Action(color, action_type, resources) + elif action_type == ActionType.DISCARD_RESOURCE: + value = data[2] + if isinstance(value, list): + if len(value) != 1: + raise ValueError( + "Discard action must have 1 resource when encoded as a list" + ) + value = value[0] + action = Action(color, action_type, value) elif action_type == ActionType.MOVE_ROBBER: coordinate, victim = data[2] coordinate = tuple(coordinate) diff --git a/catanatron/catanatron/models/actions.py b/catanatron/catanatron/models/actions.py index 5cd1b3c3..74a4d171 100644 --- a/catanatron/catanatron/models/actions.py +++ b/catanatron/catanatron/models/actions.py @@ -88,7 +88,7 @@ def generate_playable_actions(state: State) -> List[Action]: actions.extend(maritime_trade_possibilities(state, color)) return actions elif action_prompt == ActionPrompt.DISCARD: - return discard_possibilities(color) + return discard_possibilities(state, color) elif action_prompt == ActionPrompt.DECIDE_TRADE: actions = [Action(color, ActionType.REJECT_TRADE, state.current_trade)] @@ -278,24 +278,15 @@ def initial_road_possibilities(state, color) -> List[Action]: return [Action(color, ActionType.BUILD_ROAD, edge) for edge in buildable_edges] -def discard_possibilities(color) -> List[Action]: - return [Action(color, ActionType.DISCARD, None)] - # TODO: Be robust to high dimensionality of DISCARD - # hand = player.resource_deck.to_array() - # num_cards = player.resource_deck.num_cards() - # num_to_discard = num_cards // 2 - - # num_possibilities = ncr(num_cards, num_to_discard) - # if num_possibilities > 100: # if too many, just take first N - # return [Action(player, ActionType.DISCARD, hand[:num_to_discard])] - - # to_discard = itertools.combinations(hand, num_to_discard) - # return list( - # map( - # lambda combination: Action(player, ActionType.DISCARD, combination), - # to_discard, - # ) - # ) +def discard_possibilities(state: State, color) -> List[Action]: + if state.discard_counts[color] <= 0: + return [] + + return [ + Action(color, ActionType.DISCARD_RESOURCE, resource) + for resource in RESOURCES + if player_num_resource_cards(state, color, resource) > 0 + ] def ncr(n, r): diff --git a/catanatron/catanatron/models/enums.py b/catanatron/catanatron/models/enums.py index a129cd45..2940e84c 100644 --- a/catanatron/catanatron/models/enums.py +++ b/catanatron/catanatron/models/enums.py @@ -75,8 +75,7 @@ class ActionType(Enum): ROLL = "ROLL" # value is None MOVE_ROBBER = "MOVE_ROBBER" # value is (coordinate, Color|None). - # TODO: None for now to avoid complexity, but should be Resource[]. - DISCARD = "DISCARD" # value is None + DISCARD_RESOURCE = "DISCARD_RESOURCE" # value is Resource # Building/Buying BUILD_ROAD = "BUILD_ROAD" # value is edge_id @@ -132,7 +131,7 @@ def __repr__(self): The "result" field is polymorphic depending on the action_type. - ROLL: result is (int, int) 2 dice rolled -- DISCARD: result is List[Resource] discarded +- DISCARD_RESOURCE: result is List[Resource] discarded in this action - MOVE_ROBBER: result is card stolen (Resource|None) - BUY_DEVELOPMENT_CARD: result is card - ...for the rest, result is None since they are deterministic actions diff --git a/catanatron/catanatron/players/tree_search_utils.py b/catanatron/catanatron/players/tree_search_utils.py index 449e7bab..e04c0b0a 100644 --- a/catanatron/catanatron/players/tree_search_utils.py +++ b/catanatron/catanatron/players/tree_search_utils.py @@ -31,7 +31,7 @@ ActionType.PLAY_YEAR_OF_PLENTY, ActionType.PLAY_ROAD_BUILDING, ActionType.MARITIME_TRADE, - ActionType.DISCARD, # for simplicity... ok if reality is slightly different + ActionType.DISCARD_RESOURCE, # for simplicity... ok if reality is slightly different ActionType.PLAY_MONOPOLY, # for simplicity... we assume good card-counting and bank is visible... ] ) diff --git a/catanatron/catanatron/state.py b/catanatron/catanatron/state.py index 997313c3..6881af6b 100644 --- a/catanatron/catanatron/state.py +++ b/catanatron/catanatron/state.py @@ -1,7 +1,7 @@ -import random import pickle +import random from collections import defaultdict -from typing import Any, List, Sequence, Tuple, Dict +from typing import Any, Dict, List, Sequence, Tuple from catanatron.models.map import BASE_MAP_TEMPLATE, CatanMap, NumberPlacement from catanatron.models.board import Board @@ -76,6 +76,8 @@ class State: current_prompt (ActionPrompt): DEPRECATED. Not needed; use is_initial_build_phase, is_moving_knight, etc... instead. is_discarding (bool): If current player needs to discard. + discard_counts (Dict[Color, int]): Remaining number of cards each player + must discard in the current discard sequence. is_moving_knight (bool): If current player needs to move robber. is_road_building (bool): If current player needs to build free roads per Road Building dev card. @@ -131,6 +133,8 @@ def __init__( self.current_prompt = ActionPrompt.BUILD_INITIAL_SETTLEMENT self.is_initial_build_phase = True self.is_discarding = False + self.discard_counts: Dict[Color, int] = {color: 0 for color in self.colors} + self.discard_counts: Dict[Color, int] = {color: 0 for color in self.colors} self.is_moving_knight = False self.is_road_building = False self.free_roads_available = 0 @@ -182,6 +186,7 @@ def copy(self): state_copy.current_prompt = self.current_prompt state_copy.is_initial_build_phase = self.is_initial_build_phase state_copy.is_discarding = self.is_discarding + state_copy.discard_counts = self.discard_counts.copy() state_copy.is_moving_knight = self.is_moving_knight state_copy.is_road_building = self.is_road_building state_copy.free_roads_available = self.free_roads_available diff --git a/catanatron/catanatron/web/models.py b/catanatron/catanatron/web/models.py index 76f5d5f5..69b510a9 100644 --- a/catanatron/catanatron/web/models.py +++ b/catanatron/catanatron/web/models.py @@ -91,5 +91,4 @@ def get_game_state(game_id, state_index=None) -> Game | None: if result is None: abort(404) db.session.commit() - game = pickle.loads(result.pickle_data) # type: ignore - return game + return pickle.loads(result.pickle_data) # type: ignore diff --git a/tests/models/test_actions.py b/tests/models/test_actions.py index 0739e65b..b03c7416 100644 --- a/tests/models/test_actions.py +++ b/tests/models/test_actions.py @@ -1,5 +1,6 @@ from catanatron.state import State from catanatron.models.actions import ( + discard_possibilities, generate_playable_actions, monopoly_possibilities, year_of_plenty_possibilities, @@ -10,6 +11,7 @@ maritime_trade_possibilities, ) from catanatron.models.enums import ( + Action, BRICK, ORE, RESOURCES, @@ -55,6 +57,20 @@ def test_monopoly_possible_actions(): assert len(monopoly_possibilities(Color.RED)) == len(RESOURCES) +def test_discard_possibilities_are_per_resource(): + player = SimplePlayer(Color.RED) + state = State([player]) + state.discard_counts[player.color] = 2 + + player_deck_replenish(state, player.color, WHEAT, 2) + player_deck_replenish(state, player.color, BRICK, 1) + + assert discard_possibilities(state, player.color) == [ + Action(player.color, ActionType.DISCARD_RESOURCE, BRICK), + Action(player.color, ActionType.DISCARD_RESOURCE, WHEAT), + ] + + def test_road_possible_actions(): player = SimplePlayer(Color.RED) state = State([player]) @@ -175,13 +191,6 @@ def test_initial_placement_possibilities(): assert len(settlement_possibilities(state, Color.RED, True)) == 54 -# TODO: Forcing random selection to ease dimensionality. -# def test_discard_possibilities(): -# player = SimplePlayer(Color.RED) -# player_deck_replenish(state, player.color, Resource.WHEAT) -# assert len(discard_possibilities(player)) == 70 - - def test_4to1_maritime_trade_possibilities(): player = SimplePlayer(Color.RED) state = State([player]) diff --git a/tests/test_accumulators.py b/tests/test_accumulators.py index d3593c5c..946f6c6b 100644 --- a/tests/test_accumulators.py +++ b/tests/test_accumulators.py @@ -44,7 +44,7 @@ def after(self, game): discard_actions = [ (i, ar.action) for i, ar in enumerate(game.state.action_records) - if ar.action.action_type == ActionType.DISCARD + if ar.action.action_type == ActionType.DISCARD_RESOURCE ] for index, action in discard_actions: game_snapshot = accumulator.games[index] diff --git a/tests/test_game.py b/tests/test_game.py index 6e9888c0..09a5ffb1 100644 --- a/tests/test_game.py +++ b/tests/test_game.py @@ -122,7 +122,9 @@ def test_seven_cards_dont_trigger_discarding(fake_roll_dice): assert player_num_resource_cards(game.state, players[1].color) == 7 game.play_tick() # should be player 0 rolling. - assert not any(a.action_type == ActionType.DISCARD for a in game.playable_actions) + assert not any( + a.action_type == ActionType.DISCARD_RESOURCE for a in game.playable_actions + ) @patch("catanatron.apply_action.roll_dice") @@ -135,14 +137,22 @@ def test_rolling_a_seven_triggers_default_discard_limit(fake_roll_dice): until_nine = 9 - player_num_resource_cards(game.state, players[1].color) player_deck_replenish(game.state, players[1].color, WHEAT, until_nine) - assert player_num_resource_cards(game.state, players[1].color) == 9 + original_hand_size = player_num_resource_cards(game.state, players[1].color) + assert original_hand_size == 9 game.play_tick() # should be player 0 rolling. - assert len(game.playable_actions) == 1 - assert game.playable_actions == [Action(players[1].color, ActionType.DISCARD, None)] + assert game.state.current_color() == players[1].color + assert all( + a.action_type == ActionType.DISCARD_RESOURCE for a in game.playable_actions + ) + + for _ in range(original_hand_size // 2): + assert game.state.current_color() == players[1].color + assert game.state.current_prompt == ActionPrompt.DISCARD + game.play_tick() - game.play_tick() assert player_num_resource_cards(game.state, players[1].color) == 5 + assert game.state.current_prompt == ActionPrompt.MOVE_ROBBER @patch("catanatron.apply_action.roll_dice") @@ -177,34 +187,22 @@ def test_all_players_discard_as_needed(fake_roll_dice): # the following assumes, no matter who rolled 7, asking players # to discard, happens in original seating-order. - assert len(game.playable_actions) == 1 - assert game.playable_actions == [ - Action(ordered_players[0].color, ActionType.DISCARD, None) - ] + for ordered_player in ordered_players: + assert game.state.current_color() == ordered_player.color + discard_steps = 0 + while game.state.current_prompt == ActionPrompt.DISCARD and ( + game.state.current_color() == ordered_player.color + ): + assert all( + a.action_type == ActionType.DISCARD_RESOURCE + for a in game.playable_actions + ) + game.play_tick() + discard_steps += 1 + + assert discard_steps == 4 + assert player_num_resource_cards(game.state, ordered_player.color) == 5 - game.play_tick() # p0 discards, places p1 in line to discard - assert player_num_resource_cards(game.state, ordered_players[0].color) == 5 - assert len(game.playable_actions) == 1 - assert game.playable_actions == [ - Action(ordered_players[1].color, ActionType.DISCARD, None) - ] - - game.play_tick() - assert player_num_resource_cards(game.state, ordered_players[1].color) == 5 - assert len(game.playable_actions) == 1 - assert game.playable_actions == [ - Action(ordered_players[2].color, ActionType.DISCARD, None) - ] - - game.play_tick() - assert player_num_resource_cards(game.state, ordered_players[2].color) == 5 - assert len(game.playable_actions) == 1 - assert game.playable_actions == [ - Action(ordered_players[3].color, ActionType.DISCARD, None) - ] - - game.play_tick() # p3 discards, game goes back to p1 moving robber - assert player_num_resource_cards(game.state, ordered_players[3].color) == 5 assert game.state.is_moving_knight assert all(a.color == ordered_players[1].color for a in game.playable_actions) assert all(a.action_type == ActionType.MOVE_ROBBER for a in game.playable_actions) @@ -223,7 +221,9 @@ def test_discard_is_configurable(fake_roll_dice): assert player_num_resource_cards(game.state, players[1].color) == 9 game.play_tick() # should be p0 rolling. - assert game.playable_actions != [Action(players[1].color, ActionType.DISCARD, None)] + assert not any( + a.action_type == ActionType.DISCARD_RESOURCE for a in game.playable_actions + ) @patch("catanatron.apply_action.roll_dice") diff --git a/tests/test_gym.py b/tests/test_gym.py index 1ad732c8..2645c4ce 100644 --- a/tests/test_gym.py +++ b/tests/test_gym.py @@ -213,7 +213,7 @@ def test_action_space_conversion_roundtrip(): # RESOURCES = ['WOOD', 'BRICK', 'SHEEP', 'WHEAT', 'ORE'] test_actions = [ Action(Color.BLUE, ActionType.ROLL, None), - Action(Color.BLUE, ActionType.DISCARD, None), + Action(Color.BLUE, ActionType.DISCARD_RESOURCE, WHEAT), Action(Color.BLUE, ActionType.BUILD_SETTLEMENT, 10), Action(Color.BLUE, ActionType.BUILD_CITY, 5), Action(Color.BLUE, ActionType.BUILD_ROAD, (0, 1)), diff --git a/tests/test_json.py b/tests/test_json.py index f0aeaa5f..3a0e6aca 100644 --- a/tests/test_json.py +++ b/tests/test_json.py @@ -52,6 +52,14 @@ def test_action_from_json_play_year_of_plenty_one_resource(): assert action.value == (SHEEP,) +def test_action_from_json_discard(): + data = ["BLUE", "DISCARD_RESOURCE", WOOD] + action = action_from_json(data) + assert action.color == Color.BLUE + assert action.action_type == ActionType.DISCARD_RESOURCE + assert action.value == WOOD + + def test_action_from_json_play_year_of_plenty_invalid(): data = ["WHITE", "PLAY_YEAR_OF_PLENTY", [WOOD, BRICK, SHEEP]] with pytest.raises( diff --git a/tests/test_state.py b/tests/test_state.py index 79b41f6a..80c79a68 100644 --- a/tests/test_state.py +++ b/tests/test_state.py @@ -208,3 +208,31 @@ def test_sequence(): p0_color = state.colors[0] assert state.current_prompt == ActionPrompt.BUILD_INITIAL_SETTLEMENT apply_action(state, Action(p0_color, ActionType.BUILD_SETTLEMENT, 0)) + + +def test_discard_sequence_tracks_original_hand_size(): + players = [SimplePlayer(Color.RED), SimplePlayer(Color.BLUE)] + state = State(players) + color = players[0].color + current_player_index = state.colors.index(color) + turn_player_index = state.colors.index(players[1].color) + + state.current_turn_index = turn_player_index + state.current_player_index = current_player_index + state.current_prompt = ActionPrompt.DISCARD + state.is_discarding = True + state.discard_counts[color] = 4 + player_deck_replenish(state, color, BRICK, 1) + player_deck_replenish(state, color, WHEAT, 3) + + apply_action(state, Action(color, ActionType.DISCARD_RESOURCE, BRICK)) + assert state.current_color() == color + assert state.is_discarding + assert state.discard_counts[color] == 3 + + apply_action(state, Action(color, ActionType.DISCARD_RESOURCE, WHEAT)) + apply_action(state, Action(color, ActionType.DISCARD_RESOURCE, WHEAT)) + apply_action(state, Action(color, ActionType.DISCARD_RESOURCE, WHEAT)) + assert not state.is_discarding + assert state.is_moving_knight + assert state.current_player_index == turn_player_index diff --git a/tests/utils.py b/tests/utils.py index 531c6b96..592325fa 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -24,7 +24,7 @@ def build_initial_placements( def advance_to_play_turn(game: Game): game.execute(Action(game.state.current_color(), ActionType.ROLL, None)) while game.playable_actions[0].action_type in [ - ActionType.DISCARD, + ActionType.DISCARD_RESOURCE, ActionType.MOVE_ROBBER, ]: game.execute(game.playable_actions[0]) diff --git a/tests/web/test_api.py b/tests/web/test_api.py index f26f4d25..babbbd4b 100644 --- a/tests/web/test_api.py +++ b/tests/web/test_api.py @@ -140,6 +140,24 @@ def test_post_action_bot_turn(client): assert len(data_after["action_records"]) > len(data_before["action_records"]) +def test_repeated_bot_actions_advance_latest_state(client): + """Latest state should keep advancing across persisted bot turns.""" + post_response = client.post("/api/games", json={"players": ["RANDOM", "RANDOM"]}) + assert post_response.status_code == 200 + game_id = json.loads(post_response.data)["game_id"] + + latest_before = json.loads(client.get(f"/api/games/{game_id}/states/latest").data) + first_tick = json.loads(client.post(f"/api/games/{game_id}/actions", json={}).data) + second_tick = json.loads(client.post(f"/api/games/{game_id}/actions", json={}).data) + latest_after = json.loads(client.get(f"/api/games/{game_id}/states/latest").data) + + assert first_tick["state_index"] == latest_before["state_index"] + 1 + assert second_tick["state_index"] == first_tick["state_index"] + 1 + assert len(second_tick["action_records"]) == len(first_tick["action_records"]) + 1 + assert latest_after["state_index"] == second_tick["state_index"] + assert latest_after["action_records"] == second_tick["action_records"] + + 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/components/LeftDrawer.tsx b/ui/src/components/LeftDrawer.tsx index fb10fa27..f50bdade 100644 --- a/ui/src/components/LeftDrawer.tsx +++ b/ui/src/components/LeftDrawer.tsx @@ -6,7 +6,7 @@ import Drawer from "@mui/material/Drawer"; import Hidden from "./Hidden"; import PlayerStateBox from "./PlayerStateBox"; -import { humanizeActionRecord } from "../utils/promptUtils"; +import { humanizeAction, humanizeActionRecord } from "../utils/promptUtils"; import { store } from "../store"; import ACTIONS from "../actions"; import { playerKey } from "../utils/stateUtils"; @@ -35,16 +35,22 @@ function DrawerContent({ gameState }: { gameState: GameState }) { {playerSections}
{gameState.action_records - .slice() - .reverse() - .map((actionRecord, i) => ( -
- {humanizeActionRecord(gameState, actionRecord)} -
- ))} + ? gameState.action_records + .slice() + .reverse() + .map((actionRecord, i) => ( +
+ {humanizeActionRecord(gameState, actionRecord)} +
+ )) + : gameState.actions?.slice().reverse().map((action, i) => ( +
+ {humanizeAction(gameState, action)} +
+ ))}
); diff --git a/ui/src/components/ResourceSelector.tsx b/ui/src/components/ResourceSelector.tsx index 18c432a9..ae3a06b8 100644 --- a/ui/src/components/ResourceSelector.tsx +++ b/ui/src/components/ResourceSelector.tsx @@ -10,12 +10,14 @@ import { import "./ResourceSelector.scss"; import type { ResourceCard } from "../utils/api.types"; +type SelectorOption = ResourceCard | ResourceCard[]; + type ResourceSelectorProps = { open: boolean; onClose: () => void; - onSelect: (option: ResourceCard | ResourceCard[]) => void; - options: ResourceCard[][]; // this is only used when mode == "yearOfPlenty" - mode: "monopoly" | "yearOfPlenty"; + onSelect: (option: SelectorOption) => void; + options: SelectorOption[]; + mode: "discard" | "monopoly" | "yearOfPlenty"; }; const ResourceSelector = ({ @@ -25,23 +27,36 @@ const ResourceSelector = ({ onSelect, mode, }: ResourceSelectorProps) => { - const sortedOptions = React.useMemo(() => { - const resourceOrder: ResourceCard[] = [ - "WOOD", - "BRICK", - "SHEEP", - "WHEAT", - "ORE", - ]; + const resourceOrder: ResourceCard[] = [ + "WOOD", + "BRICK", + "SHEEP", + "WHEAT", + "ORE", + ]; + const isSingleResourceOption = ( + option: SelectorOption + ): option is ResourceCard => !Array.isArray(option); + const sortedOptions = React.useMemo(() => { if (mode === "monopoly") { return resourceOrder; } + if (mode === "discard") { + return options + .filter(isSingleResourceOption) + .sort((a, b) => resourceOrder.indexOf(a) - resourceOrder.indexOf(b)); + } - const hasDoubleOptions = options.some((option) => option.length === 2); + const yearOfPlentyOptions = options.filter( + (option): option is ResourceCard[] => Array.isArray(option) + ); + const hasDoubleOptions = yearOfPlentyOptions.some( + (option) => option.length === 2 + ); const filteredOptions = hasDoubleOptions - ? options.filter((option) => option.length === 2) - : options; + ? yearOfPlentyOptions.filter((option) => option.length === 2) + : yearOfPlentyOptions; return filteredOptions.sort((a: ResourceCard[], b: ResourceCard[]) => { const aFirstResource = a[0]; @@ -65,13 +80,11 @@ const ResourceSelector = ({ ); - const isMonopolyOption = ( - option: ResourceCard | ResourceCard[] - ): option is ResourceCard => mode === "monopoly" && !!option; - const optionToResourceSpan = (option: ResourceCard | ResourceCard[]) => { - if (isMonopolyOption(option)) { + const optionToResourceSpan = (option: SelectorOption) => { + if (isSingleResourceOption(option)) { return getResourceSpan(option); - } else if (option.length === 1) { + } + if (option.length === 1) { return ( <> {getResourceSpan(option[0])} @@ -98,7 +111,9 @@ const ResourceSelector = ({ fullWidth > - {mode === "monopoly" + {mode === "discard" + ? "Select Resource to Discard" + : mode === "monopoly" ? "Select Resource to Monopolize" : "Select Resources for Year of Plenty"} diff --git a/ui/src/components/Snackbar.tsx b/ui/src/components/Snackbar.tsx index 24bc64c1..cf454f46 100644 --- a/ui/src/components/Snackbar.tsx +++ b/ui/src/components/Snackbar.tsx @@ -1,7 +1,7 @@ import { IconButton } from "@mui/material"; import CloseIcon from "@mui/icons-material/Close"; import type { GameState } from "../utils/api.types"; -import { humanizeActionRecord } from "../utils/promptUtils"; +import { latestActionText } from "../utils/promptUtils"; // No types exported from notistack; type SnackbarKey = string | number; @@ -29,8 +29,13 @@ export function dispatchSnackbar( closeSnackbar: (key?: string | number) => void, gameState: GameState ) { + const message = latestActionText(gameState); + if (!message) { + return; + } + enqueueSnackbar( - humanizeActionRecord(gameState, gameState.action_records.slice(-1)[0]), + message, { action: snackbarActions(closeSnackbar), onClick: () => { diff --git a/ui/src/pages/ActionsToolbar.tsx b/ui/src/pages/ActionsToolbar.tsx index 9a7db134..fc58b8a9 100644 --- a/ui/src/pages/ActionsToolbar.tsx +++ b/ui/src/pages/ActionsToolbar.tsx @@ -36,6 +36,14 @@ import "./ActionsToolbar.scss"; import { useSnackbar } from "notistack"; import { dispatchSnackbar } from "../components/Snackbar"; +const RESOURCE_ORDER: ResourceCard[] = [ + "WOOD", + "BRICK", + "SHEEP", + "WHEAT", + "ORE", +]; + function PlayButtons() { const { gameId } = useParams(); if (!gameId) { @@ -78,9 +86,28 @@ function PlayButtons() { .map((action) => action[1]) ); const humanColor = getHumanColor(gameState); + const discardActionType = + gameState.current_playable_actions.find( + (action) => action[1] === "DISCARD" || action[1] === "DISCARD_RESOURCE" + )?.[1] ?? "DISCARD_RESOURCE"; const setIsPlayingMonopoly = useCallback(() => { dispatch({ type: ACTIONS.SET_IS_PLAYING_MONOPOLY }); }, [dispatch]); + const getValidDiscardOptions = useCallback(() => { + const discardOptions = gameState.current_playable_actions + .filter( + (action) => action[1] === "DISCARD" || action[1] === "DISCARD_RESOURCE" + ) + .map((action) => action[2] as ResourceCard); + if (discardOptions.length > 0) { + return discardOptions; + } + + // Fallback to the current hand if the discard actions are missing from the payload. + return RESOURCE_ORDER.filter( + (resource) => gameState.player_state[`${key}_${resource}_IN_HAND`] > 0 + ); + }, [gameState.current_playable_actions, gameState.player_state, key]); const getValidYearOfPlentyOptions = useCallback(() => { return gameState.current_playable_actions .filter((action) => action[1] === "PLAY_YEAR_OF_PLENTY") @@ -96,6 +123,12 @@ function PlayButtons() { "PLAY_MONOPOLY", selectedResources as ResourceCard, ]; + } else if (isDiscard) { + action = [ + humanColor, + discardActionType, + selectedResources as ResourceCard, + ]; } else if (isPlayingYearOfPlenty) { action = [ humanColor, @@ -118,6 +151,8 @@ function PlayButtons() { closeSnackbar, isPlayingMonopoly, isPlayingYearOfPlenty, + isDiscard, + discardActionType, ] ); const handleOpenResourceSelector = useCallback(() => { @@ -230,7 +265,6 @@ function PlayButtons() { dispatch({ type: ACTIONS.SET_IS_MOVING_ROBBER }); }, [dispatch]); const rollAction = carryOutAction([humanColor, "ROLL", null]); - const proceedAction = carryOutAction(); const endTurnAction = carryOutAction([humanColor, "END_TURN", null]); return ( <> @@ -265,7 +299,7 @@ function PlayButtons() { startIcon={} onClick={ isDiscard - ? proceedAction + ? handleOpenResourceSelector : isMoveRobber ? setIsMovingRobber : isPlayingYearOfPlenty || isPlayingMonopoly @@ -292,9 +326,17 @@ function PlayButtons() { dispatch({ type: ACTIONS.CANCEL_MONOPOLY }); dispatch({ type: ACTIONS.CANCEL_YEAR_OF_PLENTY }); }} - options={getValidYearOfPlentyOptions()} + options={ + isDiscard ? getValidDiscardOptions() : getValidYearOfPlentyOptions() + } onSelect={handleResourceSelection} - mode={isPlayingMonopoly ? "monopoly" : "yearOfPlenty"} + mode={ + isDiscard + ? "discard" + : isPlayingMonopoly + ? "monopoly" + : "yearOfPlenty" + } /> ); diff --git a/ui/src/utils/api.types.ts b/ui/src/utils/api.types.ts index 1ff6c4e6..7512122f 100644 --- a/ui/src/utils/api.types.ts +++ b/ui/src/utils/api.types.ts @@ -25,8 +25,12 @@ export type GameActionRecord = | [MaritimeTradeAction, null] | [EndTurnAction, null]; -export type RollGameAction = [Color, "ROLL", null]; -export type DiscardGameAction = [Color, "DISCARD", null]; +export type RollGameAction = [Color, "ROLL", [number, number] | null]; +export type DiscardGameAction = [ + Color, + "DISCARD" | "DISCARD_RESOURCE", + ResourceCard | null +]; export type BuyDevelopmentCardAction = [Color, "BUY_DEVELOPMENT_CARD", null]; export type BuildSettlementAction = [Color, "BUILD_SETTLEMENT", number]; export type BuildCityAction = [Color, "BUILD_CITY", number]; @@ -42,7 +46,7 @@ export type PlayYearOfPlentyAction = [ export type MoveRobberAction = [ Color, "MOVE_ROBBER", - [TileCoordinate, string?] + [TileCoordinate, string?, string?] ]; export type MaritimeTradeAction = [ Color, @@ -106,7 +110,8 @@ export type GameState = { winning_color?: Color; current_prompt: string; player_state: Record; - action_records: GameActionRecord[]; + action_records?: GameActionRecord[]; + actions?: GameAction[]; robber_coordinate: TileCoordinate; nodes: Array<{ id: number; diff --git a/ui/src/utils/promptUtils.test.ts b/ui/src/utils/promptUtils.test.ts index 2431ea76..f8ac5b5b 100644 --- a/ui/src/utils/promptUtils.test.ts +++ b/ui/src/utils/promptUtils.test.ts @@ -177,13 +177,13 @@ describe("humanizeAction", () => { ).toBe("BOT ROLLED A 7"); }); - test("DISCARD action", () => { + test("DISCARD_RESOURCE action", () => { expect( humanizeActionRecord(baseGameState, [ - ["ORANGE", "DISCARD", null], + ["ORANGE", "DISCARD_RESOURCE", "WHEAT"], ["WHEAT"], ]) - ).toBe("YOU DISCARDED"); + ).toBe("YOU DISCARDED WHEAT"); }); test("BUY_DEVELOPMENT_CARD action", () => { diff --git a/ui/src/utils/promptUtils.ts b/ui/src/utils/promptUtils.ts index ff7e37bb..47a4bf26 100644 --- a/ui/src/utils/promptUtils.ts +++ b/ui/src/utils/promptUtils.ts @@ -1,4 +1,5 @@ import type { + GameAction, Tile, PlacedTile, GameActionRecord, @@ -7,9 +8,75 @@ import type { BuildRoadAction, PlayYearOfPlentyAction, MoveRobberAction, + ResourceCard, } from "./api.types"; import type { GameState } from "./api.types"; +export function humanizeAction(gameState: GameState, action: GameAction) { + const botColors = gameState.bot_colors; + const player = botColors.includes(action[0]) ? "BOT" : "YOU"; + switch (action[1]) { + case "ROLL": + if (!action[2]) { + throw new Error("did not get Rolling outcomes back from Server! output"); + } + return `${player} ROLLED A ${action[2][0] + action[2][1]}`; + case "DISCARD": + case "DISCARD_RESOURCE": + return action[2] ? `${player} DISCARDED ${action[2]}` : `${player} DISCARDED`; + case "BUY_DEVELOPMENT_CARD": + return `${player} BOUGHT DEVELOPMENT CARD`; + case "BUILD_SETTLEMENT": + case "BUILD_CITY": { + const parts = action[1].split("_"); + const building = parts[parts.length - 1]; + const tileId = action[2]; + const tiles = gameState.adjacent_tiles[tileId]; + const tileString = tiles.map(getShortTileString).join("-"); + return `${player} BUILT ${building} ON ${tileString}`; + } + case "BUILD_ROAD": { + 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 intersection = a.filter((t) => b.includes(t)); + const tiles = intersection.map( + (tileId) => findTileById(gameState, tileId).tile + ); + const edgeString = tiles.map(getShortTileString).join("-"); + return `${player} BUILT ROAD ON ${edgeString}`; + } + case "PLAY_KNIGHT_CARD": + return `${player} PLAYED KNIGHT CARD`; + case "PLAY_ROAD_BUILDING": + return `${player} PLAYED ROAD BUILDING`; + case "PLAY_MONOPOLY": + return `${player} MONOPOLIZED ${action[2]}`; + case "PLAY_YEAR_OF_PLENTY": { + const firstResource = action[2][0]; + const secondResource = action[2][1]; + if (secondResource) { + return `${player} PLAYED YEAR OF PLENTY. CLAIMED ${firstResource} AND ${secondResource}`; + } + return `${player} PLAYED YEAR OF PLENTY. CLAIMED ${firstResource}`; + } + case "MOVE_ROBBER": { + const tile = findTileByCoordinate(gameState, action[2][0]); + const tileString = getTileString(tile); + const stolenResource = action[2][2] ? ` (STOLE ${action[2][2]})` : ""; + return `${player} ROBBED ${tileString}${stolenResource}`; + } + case "MARITIME_TRADE": { + const label = humanizeTradeAction(action as MaritimeTradeAction); + return `${player} TRADED ${label}`; + } + case "END_TURN": + return `${player} ENDED TURN`; + default: + throw new Error(`Unknown action type: ${action[1]}`); + } +} + export function humanizeActionRecord( gameState: GameState, actionRecord: GameActionRecord @@ -23,7 +90,10 @@ export function humanizeActionRecord( return `${player} ROLLED A ${action[0] + action[1]}`; } case "DISCARD": - return `${player} DISCARDED`; + case "DISCARD_RESOURCE": + return `${player} DISCARDED ${ + (actionRecord[1] as ResourceCard[])[0] + }`; case "BUY_DEVELOPMENT_CARD": return `${player} BOUGHT DEVELOPMENT CARD`; case "BUILD_SETTLEMENT": @@ -92,6 +162,20 @@ export function humanizeTradeAction(action: MaritimeTradeAction): string { return `${out.length} ${out[0]} => ${action[2][4]}`; } +export function latestActionText(gameState: GameState) { + const latestActionRecord = gameState.action_records?.slice(-1)[0]; + if (latestActionRecord) { + return humanizeActionRecord(gameState, latestActionRecord); + } + + const latestAction = gameState.actions?.slice(-1)[0]; + if (latestAction) { + return humanizeAction(gameState, latestAction); + } + + return ""; +} + export function findTileByCoordinate(gameState: GameState, coordinate: any) { for (const tile of Object.values(gameState.tiles)) { if (JSON.stringify(tile.coordinate) === JSON.stringify(coordinate)) {