diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1bf64f059..eaedf96d6 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -88,3 +88,41 @@ jobs: working-directory: ./ui - run: npm run build working-directory: ./ui + + build-rust: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Set up Rust + uses: actions-rs/toolchain@v1 + with: + toolchain: stable + profile: minimal + override: true + + - name: Run `cargo fmt` + run: cargo fmt -- --check + working-directory: catanatron_rust + - name: Run `cargo clippy` + run: cargo clippy -- -D warnings + working-directory: catanatron_rust + + - name: Run `cargo run` + run: cargo run + working-directory: catanatron_rust + - name: Run `cargo run --release` + run: cargo run --release + working-directory: catanatron_rust + + - name: Run `cargo test` + run: cargo test --verbose + working-directory: catanatron_rust + - name: Run `cargo test --release` + run: cargo test --release --verbose + working-directory: catanatron_rust + + - name: Run `cargo bench` + run: cargo bench + working-directory: catanatron_rust diff --git a/catanatron_core/catanatron/game.py b/catanatron_core/catanatron/game.py index 01bdf7e7c..6140779bc 100644 --- a/catanatron_core/catanatron/game.py +++ b/catanatron_core/catanatron/game.py @@ -10,7 +10,7 @@ from catanatron.models.enums import Action, ActionPrompt, ActionType from catanatron.state import State, apply_action from catanatron.state_functions import player_key, player_has_rolled -from catanatron.models.map import CatanMap +from catanatron.models.map_instance import MapInstance from catanatron.models.player import Color, Player # To timeout RandomRobots from getting stuck... @@ -92,7 +92,7 @@ def __init__( seed: Optional[int] = None, discard_limit: int = 7, vps_to_win: int = 10, - catan_map: Optional[CatanMap] = None, + map_instance: Optional[MapInstance] = None, initialize: bool = True, ): """Creates a game (doesn't run it). @@ -102,7 +102,7 @@ def __init__( seed (int, optional): Random seed to use (for reproducing games). Defaults to None. discard_limit (int, optional): Discard limit to use. Defaults to 7. vps_to_win (int, optional): Victory Points needed to win. Defaults to 10. - catan_map (CatanMap, optional): Map to use. Defaults to None. + map_instance (MapInstance, optional): Map to use. Defaults to None. initialize (bool, optional): Whether to initialize. Defaults to True. """ if initialize: @@ -111,7 +111,7 @@ def __init__( self.id = str(uuid.uuid4()) self.vps_to_win = vps_to_win - self.state = State(players, catan_map, discard_limit=discard_limit) + self.state = State(players, map_instance, discard_limit=discard_limit) def play(self, accumulators=[], decide_fn=None): """Executes game until a player wins or exceeded TURNS_LIMIT. diff --git a/catanatron_core/catanatron/json.py b/catanatron_core/catanatron/json.py index a04055f5a..0ec342cc9 100644 --- a/catanatron_core/catanatron/json.py +++ b/catanatron_core/catanatron/json.py @@ -5,7 +5,7 @@ import json from enum import Enum -from catanatron.models.map import Water, Port, LandTile +from catanatron.models.map_instance import Water, Port, LandTile from catanatron.game import Game from catanatron.models.player import Color from catanatron.models.enums import RESOURCES, Action, ActionType diff --git a/catanatron_core/catanatron/models/actions.py b/catanatron_core/catanatron/models/actions.py index 336750432..1580121b4 100644 --- a/catanatron_core/catanatron/models/actions.py +++ b/catanatron_core/catanatron/models/actions.py @@ -2,6 +2,7 @@ Move-generation functions (these return a list of actions that can be taken by current player). Main function is generate_playable_actions. """ + import operator as op from functools import reduce from typing import Any, Dict, List, Set, Tuple, Union @@ -51,39 +52,7 @@ def generate_playable_actions(state) -> List[Action]: elif action_prompt == ActionPrompt.MOVE_ROBBER: return robber_possibilities(state, color) elif action_prompt == ActionPrompt.PLAY_TURN: - if state.is_road_building: - return road_building_possibilities(state, color, False) - actions = [] - # Allow playing dev cards before and after rolling - if player_can_play_dev(state, color, "YEAR_OF_PLENTY"): - actions.extend(year_of_plenty_possibilities(color, state.resource_freqdeck)) - if player_can_play_dev(state, color, "MONOPOLY"): - actions.extend(monopoly_possibilities(color)) - if player_can_play_dev(state, color, "KNIGHT"): - actions.append(Action(color, ActionType.PLAY_KNIGHT_CARD, None)) - if ( - player_can_play_dev(state, color, "ROAD_BUILDING") - and len(road_building_possibilities(state, color, False)) > 0 - ): - actions.append(Action(color, ActionType.PLAY_ROAD_BUILDING, None)) - if not player_has_rolled(state, color): - actions.append(Action(color, ActionType.ROLL, None)) - else: - actions.append(Action(color, ActionType.END_TURN, None)) - actions.extend(road_building_possibilities(state, color)) - actions.extend(settlement_possibilities(state, color)) - actions.extend(city_possibilities(state, color)) - - can_buy_dev_card = ( - player_can_afford_dev_card(state, color) - and len(state.development_listdeck) > 0 - ) - if can_buy_dev_card: - actions.append(Action(color, ActionType.BUY_DEVELOPMENT_CARD, None)) - - # Trade - actions.extend(maritime_trade_possibilities(state, color)) - return actions + return play_turn_possibilities(state, color) elif action_prompt == ActionPrompt.DISCARD: return discard_possibilities(color) elif action_prompt == ActionPrompt.DECIDE_TRADE: @@ -236,6 +205,42 @@ def robber_possibilities(state, color) -> List[Action]: return actions +def play_turn_possibilities(state, color) -> List[Action]: + if state.is_road_building: + return road_building_possibilities(state, color, False) + actions = [] + # Allow playing dev cards before and after rolling + if player_can_play_dev(state, color, "YEAR_OF_PLENTY"): + actions.extend(year_of_plenty_possibilities(color, state.resource_freqdeck)) + if player_can_play_dev(state, color, "MONOPOLY"): + actions.extend(monopoly_possibilities(color)) + if player_can_play_dev(state, color, "KNIGHT"): + actions.append(Action(color, ActionType.PLAY_KNIGHT_CARD, None)) + if ( + player_can_play_dev(state, color, "ROAD_BUILDING") + and len(road_building_possibilities(state, color, False)) > 0 + ): + actions.append(Action(color, ActionType.PLAY_ROAD_BUILDING, None)) + if not player_has_rolled(state, color): + actions.append(Action(color, ActionType.ROLL, None)) + else: + actions.append(Action(color, ActionType.END_TURN, None)) + actions.extend(road_building_possibilities(state, color)) + actions.extend(settlement_possibilities(state, color)) + actions.extend(city_possibilities(state, color)) + + can_buy_dev_card = ( + player_can_afford_dev_card(state, color) + and len(state.development_listdeck) > 0 + ) + if can_buy_dev_card: + actions.append(Action(color, ActionType.BUY_DEVELOPMENT_CARD, None)) + + # Trade + actions.extend(maritime_trade_possibilities(state, color)) + return actions + + def initial_road_possibilities(state, color) -> List[Action]: # Must be connected to last settlement last_settlement_node_id = state.buildings_by_color[color][SETTLEMENT][-1] diff --git a/catanatron_core/catanatron/models/board.py b/catanatron_core/catanatron/models/board.py index ff2d97d17..d8164b724 100644 --- a/catanatron_core/catanatron/models/board.py +++ b/catanatron_core/catanatron/models/board.py @@ -7,19 +7,19 @@ import networkx as nx # type: ignore from catanatron.models.player import Color -from catanatron.models.map import ( +from catanatron.models.map_instance import ( BASE_MAP_TEMPLATE, MINI_MAP_TEMPLATE, NUM_NODES, - CatanMap, + MapInstance, NodeId, ) from catanatron.models.enums import FastBuildingType, SETTLEMENT, CITY # Used to find relationships between nodes and edges -base_map = CatanMap.from_template(BASE_MAP_TEMPLATE) -mini_map = CatanMap.from_template(MINI_MAP_TEMPLATE) +base_map = MapInstance.from_template(BASE_MAP_TEMPLATE) +mini_map = MapInstance.from_template(MINI_MAP_TEMPLATE) STATIC_GRAPH = nx.Graph() for tile in base_map.tiles.values(): STATIC_GRAPH.add_nodes_from(tile.nodes.values()) @@ -54,12 +54,12 @@ class Board: robber_coordinate (Coordinate): Coordinate where robber is. """ - def __init__(self, catan_map=None, initialize=True): + def __init__(self, map_instance=None, initialize=True): self.buildable_subgraph: Any = None self.buildable_edges_cache = {} self.player_port_resources_cache = {} if initialize: - self.map: CatanMap = catan_map or CatanMap.from_template( + self.map: MapInstance = map_instance or MapInstance.from_template( BASE_MAP_TEMPLATE ) # Static State (no need to copy) diff --git a/catanatron_core/catanatron/models/map.py b/catanatron_core/catanatron/models/map_instance.py similarity index 71% rename from catanatron_core/catanatron/models/map.py rename to catanatron_core/catanatron/models/map_instance.py index 1c4dc0b3e..18721c10e 100644 --- a/catanatron_core/catanatron/models/map.py +++ b/catanatron_core/catanatron/models/map_instance.py @@ -1,10 +1,21 @@ import typing -from dataclasses import dataclass import random from collections import Counter, defaultdict -from typing import Dict, FrozenSet, List, Literal, Mapping, Set, Tuple, Type, Union +from typing import Dict, FrozenSet, List, Literal, Set, Union from catanatron.models.coordinate_system import Direction, add, UNIT_VECTORS +from catanatron.models.map_template import ( + Coordinate, + Tile, + LandTile, + NodeId, + Port, + Water, + MapTemplate, + EdgeId, + BASE_MAP_TEMPLATE, + MINI_MAP_TEMPLATE, +) from catanatron.models.enums import ( FastResource, WOOD, @@ -16,183 +27,13 @@ NodeRef, ) +# TODO: Deprecate in favor of having users specify map type first (BASE, TOURNAMENT, MINI) NUM_NODES = 54 NUM_EDGES = 72 NUM_TILES = 19 -EdgeId = Tuple[int, int] -NodeId = int -Coordinate = Tuple[int, int, int] - - -@dataclass -class LandTile: - id: int - resource: Union[FastResource, None] # None means desert tile - number: Union[int, None] # None if desert - nodes: Dict[NodeRef, NodeId] # node_ref => node_id - edges: Dict[EdgeRef, EdgeId] # edge_ref => edge - - # The id is unique among the tiles, so we can use it as the hash. - def __hash__(self): - return self.id - - -@dataclass -class Port: - id: int - resource: Union[FastResource, None] # None means desert tile - direction: Direction - nodes: Dict[NodeRef, NodeId] # node_ref => node_id - edges: Dict[EdgeRef, EdgeId] # edge_ref => edge - - # The id is unique among the tiles, so we can use it as the hash. - def __hash__(self): - return self.id - - -@dataclass(frozen=True) -class Water: - nodes: Dict[NodeRef, int] - edges: Dict[EdgeRef, EdgeId] - - -Tile = Union[LandTile, Port, Water] - - -@dataclass(frozen=True) -class MapTemplate: - numbers: List[int] - port_resources: List[Union[FastResource, None]] - tile_resources: List[Union[FastResource, None]] - topology: Mapping[ - Coordinate, Union[Type[LandTile], Type[Water], Tuple[Type[Port], Direction]] - ] - - -# Small 7-tile map, no ports. -MINI_MAP_TEMPLATE = MapTemplate( - [3, 4, 5, 6, 8, 9, 10], - [], - [WOOD, None, BRICK, SHEEP, WHEAT, WHEAT, ORE], - { - # center - (0, 0, 0): LandTile, - # first layer - (1, -1, 0): LandTile, - (0, -1, 1): LandTile, - (-1, 0, 1): LandTile, - (-1, 1, 0): LandTile, - (0, 1, -1): LandTile, - (1, 0, -1): LandTile, - # second layer - (2, -2, 0): Water, - (1, -2, 1): Water, - (0, -2, 2): Water, - (-1, -1, 2): Water, - (-2, 0, 2): Water, - (-2, 1, 1): Water, - (-2, 2, 0): Water, - (-1, 2, -1): Water, - (0, 2, -2): Water, - (1, 1, -2): Water, - (2, 0, -2): Water, - (2, -1, -1): Water, - }, -) - -"""Standard 4-player map""" -BASE_MAP_TEMPLATE = MapTemplate( - [2, 3, 3, 4, 4, 5, 5, 6, 6, 8, 8, 9, 9, 10, 10, 11, 11, 12], - [ - # These are 2:1 ports - WOOD, - BRICK, - SHEEP, - WHEAT, - ORE, - # These represet 3:1 ports - None, - None, - None, - None, - ], - [ - # Four wood tiles - WOOD, - WOOD, - WOOD, - WOOD, - # Three brick tiles - BRICK, - BRICK, - BRICK, - # Four sheep tiles - SHEEP, - SHEEP, - SHEEP, - SHEEP, - # Four wheat tiles - WHEAT, - WHEAT, - WHEAT, - WHEAT, - # Three ore tiles - ORE, - ORE, - ORE, - # One desert - None, - ], - # 3 layers, where last layer is water - { - # center - (0, 0, 0): LandTile, - # first layer - (1, -1, 0): LandTile, - (0, -1, 1): LandTile, - (-1, 0, 1): LandTile, - (-1, 1, 0): LandTile, - (0, 1, -1): LandTile, - (1, 0, -1): LandTile, - # second layer - (2, -2, 0): LandTile, - (1, -2, 1): LandTile, - (0, -2, 2): LandTile, - (-1, -1, 2): LandTile, - (-2, 0, 2): LandTile, - (-2, 1, 1): LandTile, - (-2, 2, 0): LandTile, - (-1, 2, -1): LandTile, - (0, 2, -2): LandTile, - (1, 1, -2): LandTile, - (2, 0, -2): LandTile, - (2, -1, -1): LandTile, - # third (water) layer - (3, -3, 0): (Port, Direction.WEST), - (2, -3, 1): Water, - (1, -3, 2): (Port, Direction.NORTHWEST), - (0, -3, 3): Water, - (-1, -2, 3): (Port, Direction.NORTHWEST), - (-2, -1, 3): Water, - (-3, 0, 3): (Port, Direction.NORTHEAST), - (-3, 1, 2): Water, - (-3, 2, 1): (Port, Direction.EAST), - (-3, 3, 0): Water, - (-2, 3, -1): (Port, Direction.EAST), - (-1, 3, -2): Water, - (0, 3, -3): (Port, Direction.SOUTHEAST), - (1, 2, -3): Water, - (2, 1, -3): (Port, Direction.SOUTHWEST), - (3, 0, -3): Water, - (3, -1, -2): (Port, Direction.SOUTHWEST), - (3, -2, -1): Water, - }, -) - - -class CatanMap: +class MapInstance: """Represents a randomly initialized map.""" def __init__( @@ -219,11 +60,11 @@ def __init__( def from_template(map_template: MapTemplate): tiles = initialize_tiles(map_template) - return CatanMap.from_tiles(tiles) + return MapInstance.from_tiles(tiles) @staticmethod def from_tiles(tiles: Dict[Coordinate, Tile]): - self = CatanMap() + self = MapInstance() self.tiles = tiles self.land_tiles = { @@ -239,9 +80,11 @@ def from_tiles(tiles: Dict[Coordinate, Tile]): # TODO: Rename to self.node_to_tiles self.adjacent_tiles = init_adjacent_tiles(self.land_tiles) self.node_production = init_node_production(self.adjacent_tiles) + # TODO: Deprecate in favor of using self.land_tiles or some static enumeration over Coordinates self.tiles_by_id = { t.id: t for t in self.tiles.values() if isinstance(t, LandTile) } + # TODO: Deprecate in favor of using self.land_tiles or some static enumeration over Coordinates self.ports_by_id = {p.id: p for p in self.tiles.values() if isinstance(p, Port)} return self @@ -513,13 +356,13 @@ def get_edge_nodes(edge_ref): None, ], ) -TOURNAMENT_MAP = CatanMap.from_tiles(TOURNAMENT_MAP_TILES) +TOURNAMENT_MAP = MapInstance.from_tiles(TOURNAMENT_MAP_TILES) def build_map(map_type: Literal["BASE", "TOURNAMENT", "MINI"]): if map_type == "TOURNAMENT": return TOURNAMENT_MAP # this assumes map is read-only data struct elif map_type == "MINI": - return CatanMap.from_template(MINI_MAP_TEMPLATE) + return MapInstance.from_template(MINI_MAP_TEMPLATE) else: - return CatanMap.from_template(BASE_MAP_TEMPLATE) + return MapInstance.from_template(BASE_MAP_TEMPLATE) diff --git a/catanatron_core/catanatron/models/map_template.py b/catanatron_core/catanatron/models/map_template.py new file mode 100644 index 000000000..a00fd651c --- /dev/null +++ b/catanatron_core/catanatron/models/map_template.py @@ -0,0 +1,185 @@ +from dataclasses import dataclass +from typing import Dict, List, Mapping, Tuple, Type, Union + +from catanatron.models.coordinate_system import Direction +from catanatron.models.enums import ( + FastResource, + WOOD, + BRICK, + SHEEP, + WHEAT, + ORE, + EdgeRef, + NodeRef, +) + + +EdgeId = Tuple[int, int] +NodeId = int +Coordinate = Tuple[int, int, int] + + +@dataclass +class LandTile: + id: int + resource: Union[FastResource, None] # None means desert tile + number: Union[int, None] # None if desert + nodes: Dict[NodeRef, NodeId] # node_ref => node_id + edges: Dict[EdgeRef, EdgeId] # edge_ref => edge + + # The id is unique among the tiles, so we can use it as the hash. + def __hash__(self): + return self.id + + +@dataclass +class Port: + id: int + resource: Union[FastResource, None] # None means desert tile + direction: Direction + nodes: Dict[NodeRef, NodeId] # node_ref => node_id + edges: Dict[EdgeRef, EdgeId] # edge_ref => edge + + # The id is unique among the tiles, so we can use it as the hash. + def __hash__(self): + return self.id + + +@dataclass(frozen=True) +class Water: + nodes: Dict[NodeRef, int] + edges: Dict[EdgeRef, EdgeId] + + +Tile = Union[LandTile, Port, Water] + + +@dataclass(frozen=True) +class MapTemplate: + numbers: List[int] + port_resources: List[Union[FastResource, None]] + tile_resources: List[Union[FastResource, None]] + topology: Mapping[ + Coordinate, Union[Type[LandTile], Type[Water], Tuple[Type[Port], Direction]] + ] + + +# Small 7-tile map, no ports. +MINI_MAP_TEMPLATE = MapTemplate( + [3, 4, 5, 6, 8, 9, 10], + [], + [WOOD, None, BRICK, SHEEP, WHEAT, WHEAT, ORE], + { + # center + (0, 0, 0): LandTile, + # first layer + (1, -1, 0): LandTile, + (0, -1, 1): LandTile, + (-1, 0, 1): LandTile, + (-1, 1, 0): LandTile, + (0, 1, -1): LandTile, + (1, 0, -1): LandTile, + # second layer + (2, -2, 0): Water, + (1, -2, 1): Water, + (0, -2, 2): Water, + (-1, -1, 2): Water, + (-2, 0, 2): Water, + (-2, 1, 1): Water, + (-2, 2, 0): Water, + (-1, 2, -1): Water, + (0, 2, -2): Water, + (1, 1, -2): Water, + (2, 0, -2): Water, + (2, -1, -1): Water, + }, +) + +"""Standard 4-player map""" +BASE_MAP_TEMPLATE = MapTemplate( + [2, 3, 3, 4, 4, 5, 5, 6, 6, 8, 8, 9, 9, 10, 10, 11, 11, 12], + [ + # These are 2:1 ports + WOOD, + BRICK, + SHEEP, + WHEAT, + ORE, + # These represet 3:1 ports + None, + None, + None, + None, + ], + [ + # Four wood tiles + WOOD, + WOOD, + WOOD, + WOOD, + # Three brick tiles + BRICK, + BRICK, + BRICK, + # Four sheep tiles + SHEEP, + SHEEP, + SHEEP, + SHEEP, + # Four wheat tiles + WHEAT, + WHEAT, + WHEAT, + WHEAT, + # Three ore tiles + ORE, + ORE, + ORE, + # One desert + None, + ], + # 3 layers, where last layer is water + { + # center + (0, 0, 0): LandTile, + # first layer + (1, -1, 0): LandTile, + (0, -1, 1): LandTile, + (-1, 0, 1): LandTile, + (-1, 1, 0): LandTile, + (0, 1, -1): LandTile, + (1, 0, -1): LandTile, + # second layer + (2, -2, 0): LandTile, + (1, -2, 1): LandTile, + (0, -2, 2): LandTile, + (-1, -1, 2): LandTile, + (-2, 0, 2): LandTile, + (-2, 1, 1): LandTile, + (-2, 2, 0): LandTile, + (-1, 2, -1): LandTile, + (0, 2, -2): LandTile, + (1, 1, -2): LandTile, + (2, 0, -2): LandTile, + (2, -1, -1): LandTile, + # third (water) layer + (3, -3, 0): (Port, Direction.WEST), + (2, -3, 1): Water, + (1, -3, 2): (Port, Direction.NORTHWEST), + (0, -3, 3): Water, + (-1, -2, 3): (Port, Direction.NORTHWEST), + (-2, -1, 3): Water, + (-3, 0, 3): (Port, Direction.NORTHEAST), + (-3, 1, 2): Water, + (-3, 2, 1): (Port, Direction.EAST), + (-3, 3, 0): Water, + (-2, 3, -1): (Port, Direction.EAST), + (-1, 3, -2): Water, + (0, 3, -3): (Port, Direction.SOUTHEAST), + (1, 2, -3): Water, + (2, 1, -3): (Port, Direction.SOUTHWEST), + (3, 0, -3): Water, + (3, -1, -2): (Port, Direction.SOUTHWEST), + (3, -2, -1): Water, + }, +) diff --git a/catanatron_core/catanatron/state.py b/catanatron_core/catanatron/state.py index b6fabef74..7b0f28e97 100644 --- a/catanatron_core/catanatron/state.py +++ b/catanatron_core/catanatron/state.py @@ -7,7 +7,7 @@ from collections import defaultdict from typing import Any, List, Tuple, Dict, Iterable -from catanatron.models.map import BASE_MAP_TEMPLATE, CatanMap +from catanatron.models.map_instance import BASE_MAP_TEMPLATE, MapInstance from catanatron.models.board import Board from catanatron.models.enums import ( DEVELOPMENT_CARDS, @@ -66,14 +66,14 @@ # Create Player State blueprint PLAYER_INITIAL_STATE = { "VICTORY_POINTS": 0, - "ROADS_AVAILABLE": 15, - "SETTLEMENTS_AVAILABLE": 5, - "CITIES_AVAILABLE": 4, + # de-normalized features (for performance and ease of creating feature vectors) "HAS_ROAD": False, "HAS_ARMY": False, "HAS_ROLLED": False, "HAS_PLAYED_DEVELOPMENT_CARD_IN_TURN": False, - # de-normalized features (for performance since we think they are good features) + "ROADS_AVAILABLE": 15, + "SETTLEMENTS_AVAILABLE": 5, + "CITIES_AVAILABLE": 4, "ACTUAL_VICTORY_POINTS": 0, "LONGEST_ROAD_LENGTH": 0, "KNIGHT_OWNED_AT_START": False, @@ -119,6 +119,7 @@ class State: current_turn_index (int): index per colors array of player whose turn is it. current_prompt (ActionPrompt): DEPRECATED. Not needed; use is_initial_build_phase, is_moving_knight, etc... instead. + is_initial_build_phase (bool): If current player is building initial settlements is_discarding (bool): If current player needs to discard. 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 @@ -131,14 +132,16 @@ class State: def __init__( self, players: List[Player], - catan_map=None, + map_instance=None, discard_limit=7, initialize=True, ): if initialize: self.players = 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)) + self.board = Board( + map_instance or MapInstance.from_template(BASE_MAP_TEMPLATE) + ) self.discard_limit = discard_limit # feature-ready dictionary diff --git a/catanatron_core/catanatron/test_rust_game.py b/catanatron_core/catanatron/test_rust_game.py new file mode 100644 index 000000000..73829c340 --- /dev/null +++ b/catanatron_core/catanatron/test_rust_game.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python +""" +Simple script to run a Catan game and display the winner. +""" +import time + + +def main(): + try: + from catanatron_rust import Game + + # Create and play the game + game = Game(4) + print(f"Created game with {game.get_num_players()} players") + print("Game configuration:") + print("- Victory points to win: 10") + print("- Maximum ticks: 10000") + + print("\nStarting game simulation...") + start_time = time.time() + game.play() + end_time = time.time() + elapsed_time = end_time - start_time + + # Get and display the winner + winner = game.get_winner() + + print("\n" + "=" * 50) + print(f"Game completed in {elapsed_time:.2f} seconds") + + if winner is not None: + print(f"WINNER: Player {winner} reached 10 victory points!") + else: + print("NO WINNER: Game reached 10000 ticks without a winner") + print("This happens when no player reaches 10 victory points") + print("within the maximum number of turns.") + print("=" * 50) + + except ImportError as e: + print(f"Failed to import catanatron_rust: {e}") + return 1 + + return 0 + + +if __name__ == "__main__": + main() diff --git a/catanatron_experimental/catanatron_experimental/benchmarks/benchmark_deck_agasint_rust.py b/catanatron_experimental/catanatron_experimental/benchmarks/benchmark_deck_agasint_rust.py new file mode 100644 index 000000000..651949fdc --- /dev/null +++ b/catanatron_experimental/catanatron_experimental/benchmarks/benchmark_deck_agasint_rust.py @@ -0,0 +1,32 @@ +""" +This is the Python equivalent of one of the Rust benchmarks. +It shows Rust implementation can be ~600x faster. +""" + +import time + +from catanatron.models.decks import * + + +print("Starting benchmark of Deck operations...") + +# Get starting time +start = time.time() + +# Run benchmark loop for 1,000,000 iterations +for _ in range(1_000_000): + deck = starting_resource_bank() + if freqdeck_can_draw(deck, 2, WOOD): + freqdeck_draw(deck, 2, WOOD) + freqdeck_replenish( + deck, 1, WOOD + ) # Replenish after drawing to keep the count consistent + freqdeck_replenish( + deck, 1, WOOD + ) # Replenish after drawing to keep the count consistent + +# Get duration +duration = time.time() - start + +# Print the result +print(f"Time taken for 1,000,000 can_draw operations: {duration:.4f} seconds") diff --git a/catanatron_experimental/catanatron_experimental/benchmarks/benchmark_game_copy.py b/catanatron_experimental/catanatron_experimental/benchmarks/benchmark_game_copy.py index f4c356c00..78d9b7a45 100644 --- a/catanatron_experimental/catanatron_experimental/benchmarks/benchmark_game_copy.py +++ b/catanatron_experimental/catanatron_experimental/benchmarks/benchmark_game_copy.py @@ -38,7 +38,7 @@ board['connected_components'] = game.state.board.connected_components.copy() state_copy = dict() -state_copy['colors'] = game.state.colors.copy() +state_copy['colors'] = game.state.colors state_copy['board'] = board state_copy['actions'] = game.state.actions.copy() state_copy['resource_freqdeck'] = game.state.resource_freqdeck.copy() diff --git a/catanatron_experimental/catanatron_experimental/machine_learning/players/tree_search_utils.py b/catanatron_experimental/catanatron_experimental/machine_learning/players/tree_search_utils.py index 1142a0411..f3c1f2222 100644 --- a/catanatron_experimental/catanatron_experimental/machine_learning/players/tree_search_utils.py +++ b/catanatron_experimental/catanatron_experimental/machine_learning/players/tree_search_utils.py @@ -1,7 +1,7 @@ import math from collections import defaultdict -from catanatron.models.map import number_probability +from catanatron.models.map_instance import number_probability from catanatron.models.enums import ( DEVELOPMENT_CARDS, RESOURCES, diff --git a/catanatron_experimental/catanatron_experimental/play.py b/catanatron_experimental/catanatron_experimental/play.py index 1d796d652..c49a8d755 100644 --- a/catanatron_experimental/catanatron_experimental/play.py +++ b/catanatron_experimental/catanatron_experimental/play.py @@ -15,7 +15,7 @@ from catanatron.game import Game from catanatron.models.player import Color -from catanatron.models.map import build_map +from catanatron.models.map_instance import build_map from catanatron.state_functions import get_actual_victory_points # try to suppress TF output before any potentially tf-importing modules @@ -203,7 +203,7 @@ class OutputOptions: class GameConfigOptions: discard_limit: int = 7 vps_to_win: int = 10 - catan_map: Literal["BASE", "TOURNAMENT", "MINI"] = "BASE" + map_instance: Literal["BASE", "TOURNAMENT", "MINI"] = "BASE" COLOR_TO_RICH_STYLE = { @@ -234,12 +234,12 @@ def play_batch_core(num_games, players, game_config, accumulators=[]): for _ in range(num_games): for player in players: player.reset_state() - catan_map = build_map(game_config.catan_map) + map_instance = build_map(game_config.map_instance) game = Game( players, discard_limit=game_config.discard_limit, vps_to_win=game_config.vps_to_win, - catan_map=catan_map, + map_instance=map_instance, ) game.play(accumulators) yield game diff --git a/catanatron_gym/catanatron_gym/board_tensor_features.py b/catanatron_gym/catanatron_gym/board_tensor_features.py index aabdb4cf7..5de9d4bdc 100644 --- a/catanatron_gym/catanatron_gym/board_tensor_features.py +++ b/catanatron_gym/catanatron_gym/board_tensor_features.py @@ -12,7 +12,7 @@ ) from catanatron.models.coordinate_system import offset_to_cube from catanatron.models.board import STATIC_GRAPH -from catanatron.models.map import number_probability +from catanatron.models.map_instance import number_probability from catanatron_gym.features import get_feature_ordering, iter_players # These assume 4 players diff --git a/catanatron_gym/catanatron_gym/envs/catanatron_env.py b/catanatron_gym/catanatron_gym/envs/catanatron_env.py index a5c76d03d..7fd662e00 100644 --- a/catanatron_gym/catanatron_gym/envs/catanatron_env.py +++ b/catanatron_gym/catanatron_gym/envs/catanatron_env.py @@ -4,7 +4,8 @@ from catanatron.game import Game, TURNS_LIMIT from catanatron.models.player import Color, Player, RandomPlayer -from catanatron.models.map import BASE_MAP_TEMPLATE, NUM_NODES, LandTile, build_map +from catanatron.models.map_template import BASE_MAP_TEMPLATE, LandTile +from catanatron.models.map_instance import NUM_NODES, build_map from catanatron.models.enums import RESOURCES, Action, ActionType from catanatron.models.board import get_edges from catanatron_gym.features import ( @@ -223,13 +224,13 @@ def reset( ): super().reset(seed=seed) - catan_map = build_map(self.map_type) + map_instance = build_map(self.map_type) for player in self.players: player.reset_state() self.game = Game( players=self.players, seed=seed, - catan_map=catan_map, + map_instance=map_instance, vps_to_win=self.vps_to_win, ) self.invalid_actions_count = 0 diff --git a/catanatron_gym/catanatron_gym/features.py b/catanatron_gym/catanatron_gym/features.py index 1d6f5b3a1..8a289ac12 100644 --- a/catanatron_gym/catanatron_gym/features.py +++ b/catanatron_gym/catanatron_gym/features.py @@ -12,7 +12,7 @@ player_num_resource_cards, ) from catanatron.models.board import STATIC_GRAPH, get_edges, get_node_distances -from catanatron.models.map import NUM_TILES, CatanMap, build_map +from catanatron.models.map_instance import NUM_TILES, MapInstance, build_map from catanatron.models.player import Color, SimplePlayer from catanatron.models.enums import ( DEVELOPMENT_CARDS, @@ -24,7 +24,7 @@ VICTORY_POINT, ) from catanatron.game import Game -from catanatron.models.map import number_probability +from catanatron.models.map_instance import number_probability # ===== Helpers @@ -126,12 +126,12 @@ def resource_hand_features(game: Game, p0_color: Color): @functools.lru_cache(NUM_TILES * 2) # one for each robber, and acount for Minimap -def map_tile_features(catan_map: CatanMap, robber_coordinate): +def map_tile_features(map_instance: MapInstance, robber_coordinate): # Returns list of functions that take a game and output a feature. # build features like tile0_is_wood, tile0_is_wheat, ..., tile0_proba, tile0_hasrobber features = {} - for tile_id, tile in catan_map.tiles_by_id.items(): + for tile_id, tile in map_instance.tiles_by_id.items(): for resource in RESOURCES: features[f"TILE{tile_id}_IS_{resource}"] = tile.resource == resource features[f"TILE{tile_id}_IS_DESERT"] = tile.resource == None @@ -139,7 +139,7 @@ def map_tile_features(catan_map: CatanMap, robber_coordinate): 0 if tile.resource is None else number_probability(tile.number) ) features[f"TILE{tile_id}_HAS_ROBBER"] = ( - catan_map.tiles[robber_coordinate] == tile + map_instance.tiles[robber_coordinate] == tile ) return features @@ -151,9 +151,9 @@ def tile_features(game: Game, p0_color: Color): @functools.lru_cache(1) -def map_port_features(catan_map): +def map_port_features(map_instance): features = {} - for port_id, port in catan_map.ports_by_id.items(): + for port_id, port in map_instance.ports_by_id.items(): for resource in RESOURCES: features[f"PORT{port_id}_IS_{resource}"] = port.resource == resource features[f"PORT{port_id}_IS_THREE_TO_ONE"] = port.resource is None @@ -166,13 +166,13 @@ def port_features(game, p0_color): @functools.lru_cache(4) -def initialize_graph_features_template(num_players, catan_map: CatanMap): +def initialize_graph_features_template(num_players, map_instance: MapInstance): features = {} for i in range(num_players): - for node_id in range(len(catan_map.land_nodes)): + for node_id in range(len(map_instance.land_nodes)): for building in [SETTLEMENT, CITY]: features[f"NODE{node_id}_P{i}_{building}"] = False - for edge in get_edges(catan_map.land_nodes): + for edge in get_edges(map_instance.land_nodes): features[f"EDGE{edge}_P{i}_ROAD"] = False return features @@ -239,8 +239,8 @@ def production_features(game: Game, p0_color: Color): @functools.lru_cache(maxsize=1000) -def get_node_production(catan_map, node_id, resource): - tiles = catan_map.adjacent_tiles[node_id] +def get_node_production(map_instance, node_id, resource): + tiles = map_instance.adjacent_tiles[node_id] return sum([number_probability(t.number) for t in tiles if t.resource == resource]) @@ -369,10 +369,10 @@ def reachability_features(game: Game, p0_color: Color, levels=REACHABLE_FEATURES @functools.lru_cache(maxsize=1000) -def count_production(nodes, catan_map): +def count_production(nodes, map_instance): production = Counter() for node_id in nodes: - production += catan_map.node_production[node_id] + production += map_instance.node_production[node_id] return production @@ -534,6 +534,6 @@ def get_feature_ordering( SimplePlayer(Color.ORANGE), ] players = players[:num_players] - game = Game(players, catan_map=build_map(map_type)) + game = Game(players, map_instance=build_map(map_type)) sample = create_sample(game, players[0].color) return sorted(sample.keys()) diff --git a/catanatron_rust/Cargo.lock b/catanatron_rust/Cargo.lock new file mode 100644 index 000000000..66381aee2 --- /dev/null +++ b/catanatron_rust/Cargo.lock @@ -0,0 +1,941 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + +[[package]] +name = "anstream" +version = "0.6.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bec1de6f59aedf83baf9ff929c98f2ad654b97c9510f4e70cf6f661d49fd5b1" + +[[package]] +name = "anstyle-parse" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3534e77181a9cc07539ad51f2141fe32f6c3ffd4df76db8ad92346b003ae4e" +dependencies = [ + "anstyle", + "once_cell", + "windows-sys 0.59.0", +] + +[[package]] +name = "autocfg" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" + +[[package]] +name = "bitflags" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f68f53c83ab957f72c32642f3868eec03eb974d1fb82e453128456482613d36" + +[[package]] +name = "bumpalo" +version = "3.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + +[[package]] +name = "catanatron_rust" +version = "0.1.0" +dependencies = [ + "criterion", + "env_logger", + "log", + "pyo3", + "rand", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + +[[package]] +name = "clap" +version = "4.5.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97f376d85a664d5837dbae44bf546e6477a679ff6610010f17276f686d867e8" +dependencies = [ + "clap_builder", +] + +[[package]] +name = "clap_builder" +version = "4.5.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19bc80abd44e4bed93ca373a0704ccbd1b710dc5749406201bb018272808dc54" +dependencies = [ + "anstyle", + "clap_lex", +] + +[[package]] +name = "clap_lex" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1462739cb27611015575c0c11df5df7601141071f07518d56fcc1be504cbec97" + +[[package]] +name = "colorchoice" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" + +[[package]] +name = "criterion" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f" +dependencies = [ + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "is-terminal", + "itertools", + "num-traits", + "once_cell", + "oorandom", + "plotters", + "rayon", + "regex", + "serde", + "serde_derive", + "serde_json", + "tinytemplate", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" +dependencies = [ + "cast", + "itertools", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613f8cc01fe9cf1a3eb3d7f488fd2fa8388403e97039e2f73692932e291a770d" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22ec99545bb0ed0ea7bb9b8e1e9122ea386ff8a48c0922e43f36d45ab09e0e80" + +[[package]] +name = "crunchy" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a81dae078cea95a014a339291cec439d2f232ebe854a9d672b796c6afafa9b7" + +[[package]] +name = "either" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" + +[[package]] +name = "env_filter" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "186e05a59d4c50738528153b83b0b0194d3a29507dfec16eccd4b342903397d0" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcaee3d8e3cfc3fd92428d477bc97fc29ec8716d180c0d74c643bb26166660e0" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "humantime", + "log", +] + +[[package]] +name = "getrandom" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "half" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dd08c532ae367adf81c312a4580bc67f1d0fe8bc9c460520283f4c0ff277888" +dependencies = [ + "cfg-if", + "crunchy", +] + +[[package]] +name = "hermit-abi" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbf6a919d6cf397374f7dfeeea91d974c7c0a7221d0d0f4f20d859d329e53fcc" + +[[package]] +name = "humantime" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" + +[[package]] +name = "indoc" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa799dd5ed20a7e349f3b4639aa80d74549c81716d9ec4f994c9b5815598306" + +[[package]] +name = "is-terminal" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "261f68e344040fbd0edea105bef17c66edf46f984ddb1115b775ce31be948f4b" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" + +[[package]] +name = "js-sys" +version = "0.3.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a88f1bda2bd75b0452a14784937d796722fdebfe50df998aeb3f0b7603019a9" +dependencies = [ + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.159" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "561d97a539a36e26a9a5fad1ea11a3039a67714694aaa379433e580854bc3dc5" + +[[package]] +name = "lock_api" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30bde2b3dc3671ae49d8e2e9f044c7c005836e7a023ee57cffa25ab82764bb9e" + +[[package]] +name = "memchr" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" + +[[package]] +name = "oorandom" +version = "11.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b410bbe7e14ab526a0e86877eb47c6996a2bd7746f027ba551028c925390e4e9" + +[[package]] +name = "parking_lot" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-targets", +] + +[[package]] +name = "plotters" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +dependencies = [ + "num-traits", + "plotters-backend", + "plotters-svg", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-backend" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" + +[[package]] +name = "plotters-svg" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +dependencies = [ + "plotters-backend", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77957b295656769bb8ad2b6a6b09d897d94f05c41b069aede1fcdaa675eaea04" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.87" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3e4daa0dcf6feba26f985457cdf104d4b4256fc5a09547140f3631bb076b19a" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pyo3" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e681a6cfdc4adcc93b4d3cf993749a4552018ee0a9b65fc0ccfad74352c72a38" +dependencies = [ + "cfg-if", + "indoc", + "libc", + "memoffset", + "parking_lot", + "pyo3-build-config", + "pyo3-ffi", + "pyo3-macros", + "unindent", +] + +[[package]] +name = "pyo3-build-config" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "076c73d0bc438f7a4ef6fdd0c3bb4732149136abd952b110ac93e4edb13a6ba5" +dependencies = [ + "once_cell", + "target-lexicon", +] + +[[package]] +name = "pyo3-ffi" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e53cee42e77ebe256066ba8aa77eff722b3bb91f3419177cf4cd0f304d3284d9" +dependencies = [ + "libc", + "pyo3-build-config", +] + +[[package]] +name = "pyo3-macros" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfeb4c99597e136528c6dd7d5e3de5434d1ceaf487436a3f03b2d56b6fc9efd1" +dependencies = [ + "proc-macro2", + "pyo3-macros-backend", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "pyo3-macros-backend" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "947dc12175c254889edc0c02e399476c2f652b4b9ebd123aa655c224de259536" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "quote" +version = "1.0.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "rayon" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "redox_syscall" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b568323e98e49e2a0899dcee453dd679fae22d69adf9b11dd508d1549b7e2f" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38200e5ee88914975b69f657f0801b6f6dccafd44fd9326302a4aaeecfacb1d8" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368758f23274712b504848e9d5a6f010445cc8b87a7cdb4d7cbee666c1288da3" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" + +[[package]] +name = "ryu" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.210" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8e3592472072e6e22e0a54d5904d9febf8508f65fb8552499a1abc7d1078c3a" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.210" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "243902eda00fad750862fc144cea25caca5e20d615af0a81bee94ca738f1df1f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.79", +] + +[[package]] +name = "serde_json" +version = "1.0.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ff5456707a1de34e7e37f2a6fd3d3f808c318259cbd01ab6377795054b483d8" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "smallvec" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fcf8323ef1faaee30a44a340193b1ac6814fd9b7b4e88e9d4519a3e4abe1cfd" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89132cd0bf050864e1d38dc3bbc07a0eb8e7530af26344d3d2bbbef83499f590" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "unicode-ident" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe" + +[[package]] +name = "unindent" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1766d682d402817b5ac4490b3c3002d91dfa0d22812f341609f97b08757359c" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "wasm-bindgen" +version = "0.2.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "128d1e363af62632b8eb57219c8fd7877144af57558fb2ef0368d0087bddeb2e" +dependencies = [ + "cfg-if", + "once_cell", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb6dd4d3ca0ddffd1dd1c9c04f94b868c37ff5fac97c30b97cff2d74fce3a358" +dependencies = [ + "bumpalo", + "log", + "once_cell", + "proc-macro2", + "quote", + "syn 2.0.79", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e79384be7f8f5a9dd5d7167216f022090cf1f9ec128e6e6a482a2cb5c5422c56" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26c6ab57572f7a24a4985830b120de1594465e5d500f24afe89e16b4e833ef68" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.79", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65fc09f10666a9f147042251e0dda9c18f166ff7de300607007e96bdebc1068d" + +[[package]] +name = "web-sys" +version = "0.3.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6488b90108c040df0fe62fa815cbdee25124641df01814dd7282749234c6112" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winapi-util" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "zerocopy" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" +dependencies = [ + "byteorder", + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.79", +] diff --git a/catanatron_rust/Cargo.toml b/catanatron_rust/Cargo.toml new file mode 100644 index 000000000..e22dc33f3 --- /dev/null +++ b/catanatron_rust/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "catanatron_rust" +version = "0.1.0" +edition = "2021" + +[lib] +name = "catanatron_rust" +path = "src/lib.rs" +crate-type = ["cdylib", "rlib"] + +[[bin]] +name = "catanatron_rust" +path = "src/main.rs" + +[dependencies] +rand = "0.8" +pyo3 = { version = "0.19", features = ["extension-module"] } +log = "0.4.26" +env_logger = "0.11.6" + +[dev-dependencies] +criterion = { version = "0.5", features = ["html_reports"] } + +[[bench]] +name = "copy_benchmark" +harness = false diff --git a/catanatron_rust/README.md b/catanatron_rust/README.md new file mode 100644 index 000000000..e3a812519 --- /dev/null +++ b/catanatron_rust/README.md @@ -0,0 +1,102 @@ +# catanatron_rust + +This is a rewrite of Catanatron Core in Rust. + +## Usage + +``` +cargo run +cargo test +cargo bench +``` + +### Benchmarking + +To debug speed + +``` +CARGO_PROFILE_RELEASE_DEBUG=true cargo flamegraph --release --root +cargo flamegraph --dev --root +``` + +## Performance + +We will make sure MoveGeneration is the one that only creates +valid moves. That way, applying moves don't need to validate. + +### State as Vector + +Small experiments (see copy_benchmark) showed its +faster to copy a small vector than a larger array. And cloning +a higher abstracted / complex object like a struct would take +a lot more! So going with a compact Vector to represent it. + +## Actions as BitFields + +Reading GYM actions, that are 289, we prob need at least 9 bits. +To represent Actions as a bitfield number, we would need: + +- 2 bits for the color (4 colors). Or 3 bits for up to 8 colors. +- 5 bits for type of action (18 types) + its reasonable to say that the numbers in the trade freqdecks are 0-4, so we can use 3 bits for that. + biggest value is that of CONFIRM_TRADE, which is 11 3-bit numbers, so 33 bits. +- 33 bits for the value of the action. +- if we were to remove trading, we would have 5 bits for the value of the action. + because of we would need to represent just MAX of: + - 6 bits: 2 numbers of up to 6 (for the dice roll), which is 3 bits each. So 6 bits. + - 11 bits: tiles id (up to 19), which is 5 bits (+ 3 bits of color + 3 bits of resource) (MOVE_ROBBER) + - 7 bits: BUILD_X (up to 72 edges), which is 7 bits (BUILD_ROAD) or 6 bits (BUILD_SETTLEMENT) + +## BitBoard + +We need to support the following operations: + +- Action Generation: + - Yielding Resources: + - Need to tiles for a given number + - Check if robber in given tile + - Get buildings (color and multiplier) around tile + - Road Building: Valid Buildable Edges + - Settlement Posibilities: Buildable Node Ids + - Robber Possibilities: + - Which are land tiles not robber. +- Move Application + - Build a settlement + - Build a road + - Build a city + - Move Robber +- Queries + - Adjacent Tiles to a Node (for yielding resources in initial building phase) + - Edges given a Node Id + - Is friendly road (edgeId, color) + - Is Enemy Node (nodeId, color) + - Expandable Nodes (for ) + +Additional Responsabilities + +- Incrementally Mantain Longest Road (Color + Count) +- Buildable Subgraph for quick Answering Buildable Edges +- Connected Components (this is to, when plowing happens, be able to count by just max(len) pieces after updating data structure). Otherwise we would have to re-BFS all roads. + +# TODO: + +- Build tests for move_generation.rs +- Build tests for initial_build_phase +- Ensure Victory Points are counted correctly + + - Count Largest Army + - Count Longest Road + +- Implement move_application for Roll +- Implement move_application for MoveRobber +- Implement move_application for Discard +- Implement move_application for EndTurn + +- Implement move_application for BuildCity + +- Implement move_application for Buy DevelopmentCard +- Implement move_application for Play Knight +- Implement move_application for Play YOP +- Implement move_application for Play Mono +- Implement move_application for Play RB +- Implement move_application for MaritimeTrade diff --git a/catanatron_rust/benches/copy_benchmark.rs b/catanatron_rust/benches/copy_benchmark.rs new file mode 100644 index 000000000..08d4c265f --- /dev/null +++ b/catanatron_rust/benches/copy_benchmark.rs @@ -0,0 +1,72 @@ +use catanatron_rust::decks::{DevCardDeck, ResourceDeck}; +use criterion::{black_box, criterion_group, criterion_main, Criterion}; + +fn copy_vector_benchmark(c: &mut Criterion) { + // Initialize a vector of length 600 + let vector = vec![0u8; 600]; + + c.bench_function("Clone 600-length vector", |b| { + let mut iteration_number = 0; + b.iter(|| { + let mut copied_vector = vector.clone(); + let index = iteration_number % 600; + let random_value = (iteration_number % 256) as u8; + copied_vector[index] = random_value; + black_box(copied_vector); + + iteration_number += 1; + }) + }); +} + +fn copy_array_benchmark(c: &mut Criterion) { + // Initialize an array of length 1200 + let array = [0u8; 600]; + + c.bench_function("Copy 600-length array", |b| { + let mut iteration_number = 0; + b.iter(|| { + let mut copied_array = array; + let index = iteration_number % 600; + let random_value = (iteration_number % 256) as u8; + copied_array[index] = random_value; + black_box(copied_array); + + iteration_number += 1; + }) + }); +} + +#[derive(Debug, Clone)] +pub struct State { + pub bank_resources: ResourceDeck, + pub bank_development_cards: DevCardDeck, +} + +impl State { + pub fn initialize_state() -> State { + State { + bank_resources: ResourceDeck::starting_resource_bank(), + bank_development_cards: DevCardDeck::starting_deck(), + } + } +} + +fn copy_struct_benchmark(c: &mut Criterion) { + let state = State::initialize_state(); + + c.bench_function("Clone Struct1", |b| { + b.iter(|| { + let copied_struct1 = black_box(state.clone()); + black_box(copied_struct1); + }) + }); +} + +criterion_group!( + benches, + copy_vector_benchmark, + copy_array_benchmark, + copy_struct_benchmark +); +criterion_main!(benches); diff --git a/catanatron_rust/pyproject.toml b/catanatron_rust/pyproject.toml new file mode 100644 index 000000000..cc4779a4c --- /dev/null +++ b/catanatron_rust/pyproject.toml @@ -0,0 +1,13 @@ +[build-system] +requires = ["maturin>=1.0,<2.0"] +build-backend = "maturin" + +[project] +name = "catanatron_rust" +version = "0.1.0" +requires-python = ">=3.7" +classifiers = [ + "Programming Language :: Rust", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy", +] \ No newline at end of file diff --git a/catanatron_rust/src/deck_slices.rs b/catanatron_rust/src/deck_slices.rs new file mode 100644 index 000000000..10f92d723 --- /dev/null +++ b/catanatron_rust/src/deck_slices.rs @@ -0,0 +1,30 @@ +pub type FreqDeck = [u8; 5]; + +pub const SETTLEMENT_COST: FreqDeck = [1, 1, 1, 1, 0]; +pub const ROAD_COST: FreqDeck = [1, 1, 0, 0, 0]; +pub const CITY_COST: FreqDeck = [0, 0, 0, 2, 3]; +pub const DEVCARD_COST: FreqDeck = [0, 0, 1, 1, 1]; + +pub fn freqdeck_sub(freqdeck: &mut [u8], cost: FreqDeck) { + freqdeck[0] -= cost[0]; + freqdeck[1] -= cost[1]; + freqdeck[2] -= cost[2]; + freqdeck[3] -= cost[3]; + freqdeck[4] -= cost[4]; +} + +pub fn freqdeck_add(freqdeck: &mut [u8], cost: FreqDeck) { + freqdeck[0] += cost[0]; + freqdeck[1] += cost[1]; + freqdeck[2] += cost[2]; + freqdeck[3] += cost[3]; + freqdeck[4] += cost[4]; +} + +pub fn freqdeck_contains(freqdeck: &[u8], cost: FreqDeck) -> bool { + freqdeck[0] >= cost[0] + && freqdeck[1] >= cost[1] + && freqdeck[2] >= cost[2] + && freqdeck[3] >= cost[3] + && freqdeck[4] >= cost[4] +} diff --git a/catanatron_rust/src/decks.rs b/catanatron_rust/src/decks.rs new file mode 100644 index 000000000..258ba2639 --- /dev/null +++ b/catanatron_rust/src/decks.rs @@ -0,0 +1,385 @@ +use crate::enums::{DevCard, Resource}; + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct ResourceDeck { + freqdeck: u32, // Store resource counts using 32 bits. Each resource gets 6 bits. +} + +impl Default for ResourceDeck { + fn default() -> Self { + Self::new() + } +} + +impl ResourceDeck { + /// Static constructor: Constructs a deck with all resources set to 0. + pub fn new() -> Self { + ResourceDeck { freqdeck: 0 } + } + + // Read operations ===== + /// Counts the number of a specific resource in the deck. + pub fn count(&self, card: Resource) -> u32 { + let shift = ResourceDeck::resource_shift(card); + (self.freqdeck >> shift) & 0b111111 // Extract 6 bits (max count 63) + } + + /// Checks if the deck can draw a specific number of a given resource. + pub fn can_draw(&self, amount: u32, card: Resource) -> bool { + let count = self.count(card); + count >= amount + } + + // Write operations ===== + /// Draws a specific number of a given resource from the deck. + pub fn draw(&mut self, amount: u32, card: Resource) { + let count = self.count(card); + let new_count = count.saturating_sub(amount); // Avoid negative values + self.set_resource_count(card, new_count); + } + + /// Replenishes the deck with a specific number of a given resource. + pub fn replenish(&mut self, amount: u32, card: Resource) { + let count = self.count(card); + let new_count = count.saturating_add(amount); // Avoid overflow + self.set_resource_count(card, new_count); + } + + /// Adds two frequency decks together element-wise. + pub fn add(&self, other: &ResourceDeck) -> ResourceDeck { + let mut result = ResourceDeck::new(); + for card in [ + Resource::Wood, + Resource::Brick, + Resource::Sheep, + Resource::Wheat, + Resource::Ore, + ] { + let total = self.count(card) + other.count(card); + result.set_resource_count(card, total); + } + result + } + + /// Subtracts one frequency deck from another element-wise. + pub fn subtract(&self, other: &ResourceDeck) -> ResourceDeck { + let mut result = ResourceDeck::new(); + for card in [ + Resource::Wood, + Resource::Brick, + Resource::Sheep, + Resource::Wheat, + Resource::Ore, + ] { + let diff = self.count(card).saturating_sub(other.count(card)); + result.set_resource_count(card, diff); + } + result + } + + /// Checks if one frequency deck contains all the resources of another deck. + pub fn contains(&self, other: &ResourceDeck) -> bool { + for card in [ + Resource::Wood, + Resource::Brick, + Resource::Sheep, + Resource::Wheat, + Resource::Ore, + ] { + if self.count(card) < other.count(card) { + return false; + } + } + true + } + + /// Creates a frequency deck from a list of resources. + pub fn from_listdeck(listdeck: &[Resource]) -> ResourceDeck { + let mut deck = ResourceDeck::new(); + for &resource in listdeck { + deck.replenish(1, resource); + } + deck + } + + /// Helper function to get the number of bits to shift for a given resource. + fn resource_shift(card: Resource) -> u32 { + match card { + Resource::Wood => 0, // First 6 bits + Resource::Brick => 6, // Next 6 bits + Resource::Sheep => 12, // Next 6 bits + Resource::Wheat => 18, // Next 6 bits + Resource::Ore => 24, // Next 6 bits + } + } + + /// Sets the count of the specified resource using bitwise operations. + fn set_resource_count(&mut self, card: Resource, count: u32) { + let shift = ResourceDeck::resource_shift(card); + self.freqdeck &= !(0b111111 << shift); // Clear the bits for the resource + self.freqdeck |= (count & 0b111111) << shift; // Set the new count + } + + /// Static constructor: Constructs a deck with [19, 19, 19, 19, 19] resources. + pub fn starting_resource_bank() -> Self { + ResourceDeck::from_counts([19, 19, 19, 19, 19]) + } + + /// Constructs a deck from a list of resource counts + /// The input array must have 5 elements, corresponding to [Wood, Brick, Sheep, Wheat, Ore]. + pub fn from_counts(counts: [u32; 5]) -> Self { + let mut freqdeck = 0; + + // Assign each resource count to its corresponding bitfield using bitwise shifts. + for (i, &count) in counts.iter().enumerate() { + // Ensure each count fits within 6 bits (maximum value of 63) + let count = count & 0b111111; // Mask to fit within 6 bits + freqdeck |= count << (i * 6); // Shift the count to its correct position in the bitfield + } + + ResourceDeck { freqdeck } + } + + pub fn total_cards(&self) -> u32 { + let mut total = 0; + + // Iterate over all resources (Wood, Brick, Sheep, Wheat, Ore) + for card in [ + Resource::Wood, + Resource::Brick, + Resource::Sheep, + Resource::Wheat, + Resource::Ore, + ] { + total += self.count(card); // Sum each resource count + } + + total + } +} + +pub fn starting_dev_listdeck() -> [u8; 25] { + let mut listdeck: [u8; 25] = [0; 25]; + listdeck[0..14].copy_from_slice(&[DevCard::Knight as u8; 14]); + listdeck[14..16].copy_from_slice(&[DevCard::YearOfPlenty as u8; 2]); + listdeck[16..18].copy_from_slice(&[DevCard::RoadBuilding as u8; 2]); + listdeck[18..20].copy_from_slice(&[DevCard::Monopoly as u8; 2]); + listdeck[20..25].copy_from_slice(&[DevCard::VictoryPoint as u8; 5]); + listdeck +} + +#[derive(Debug, Clone)] +pub struct DevCardDeck { + pub listdeck: Vec, +} + +impl Default for DevCardDeck { + fn default() -> Self { + Self::new() + } +} + +impl DevCardDeck { + /// Static constructor: Constructs a deck with all development cards set to 0. + pub fn new() -> Self { + DevCardDeck { + listdeck: Vec::new(), + } + } + + pub fn starting_deck() -> Self { + let mut listdeck = Vec::new(); + listdeck.extend(vec![DevCard::Knight; 14]); + listdeck.extend(vec![DevCard::YearOfPlenty; 2]); + listdeck.extend(vec![DevCard::RoadBuilding; 2]); + listdeck.extend(vec![DevCard::Monopoly; 2]); + listdeck.extend(vec![DevCard::VictoryPoint; 5]); + DevCardDeck { listdeck } + } + + /// Draws a specific number of a given development card from the deck. + pub fn draw(&mut self, amount: u32, card: DevCard) { + let mut count = amount; + let mut i = 0; + while count > 0 && i < self.listdeck.len() { + if self.listdeck[i] == card { + self.listdeck.remove(i); + count -= 1; + } else { + i += 1; + } + } + } + + /// Replenishes the deck with a specific number of a given development card. + pub fn replenish(&mut self, amount: u32, card: DevCard) { + for _ in 0..amount { + self.listdeck.push(card); + } + } + + /// Counts the number of a specific development card in the deck. + pub fn count(&self, card: DevCard) -> u32 { + self.listdeck.iter().filter(|&&c| c == card).count() as u32 + } + + /// Returns the total number of development cards in the deck. + pub fn total_cards(&self) -> u32 { + self.listdeck.len() as u32 + } + + /// Creates a development card deck from a list of development cards. + pub fn from_listdeck(listdeck: &[DevCard]) -> DevCardDeck { + let mut deck = DevCardDeck::new(); + for &card in listdeck { + deck.replenish(1, card); + } + deck + } + + /// Checks if the deck can draw a specific number of a given development card. + pub fn can_draw(&self, amount: u32, card: DevCard) -> bool { + let count = self.count(card); + count >= amount + } + + /// Adds two development card decks together element-wise. + pub fn add(&self, other: &DevCardDeck) -> DevCardDeck { + let mut result = DevCardDeck::new(); + for card in [ + DevCard::Knight, + DevCard::VictoryPoint, + DevCard::RoadBuilding, + DevCard::YearOfPlenty, + DevCard::Monopoly, + ] { + let total = self.count(card) + other.count(card); + result.replenish(total, card); + } + result + } + + /// Subtracts one development card deck from another + pub fn subtract(&self, other: &DevCardDeck) -> DevCardDeck { + let mut result = DevCardDeck::new(); + for card in [ + DevCard::Knight, + DevCard::VictoryPoint, + DevCard::RoadBuilding, + DevCard::YearOfPlenty, + DevCard::Monopoly, + ] { + let diff = self.count(card).saturating_sub(other.count(card)); + result.replenish(diff, card); + } + result + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_resource_freqdeck_init() { + // Test that the starting resource bank is initialized correctly + let deck = ResourceDeck::starting_resource_bank(); + assert_eq!(deck.count(Resource::Wood), 19); + } + + #[test] + fn test_resource_freqdeck_can_draw() { + // Test the `can_draw` function + let deck = ResourceDeck::starting_resource_bank(); + assert!(deck.can_draw(10, Resource::Brick)); // Should be able to draw 10 bricks + assert!(!deck.can_draw(20, Resource::Brick)); // Should not be able to draw 20 bricks + } + + #[test] + fn test_resource_freqdeck_integration() { + let mut deck = ResourceDeck::starting_resource_bank(); + + // Test the initial count and total of all resources + assert_eq!(deck.count(Resource::Wood), 19); + assert_eq!(deck.count(Resource::Brick), 19); + assert_eq!(deck.count(Resource::Sheep), 19); + assert_eq!(deck.count(Resource::Wheat), 19); + assert_eq!(deck.count(Resource::Ore), 19); + + // Test drawing from the deck + assert!(deck.can_draw(10, Resource::Wheat)); + deck.draw(10, Resource::Wheat); + assert_eq!(deck.count(Resource::Wheat), 9); // After drawing 10, there should be 9 Wheat + assert_eq!(deck.total_cards(), 19 * 5 - 10); // Total sum of resources should be 85 + + // Test replenishing the deck + deck.replenish(5, Resource::Wheat); + assert_eq!(deck.count(Resource::Wheat), 14); // After replenishing 5, there should be 14 Wheat + assert_eq!(deck.total_cards(), 19 * 5 - 10 + 5); // Total sum of resources should be 90 + } + + #[test] + fn test_can_add() { + let mut a = ResourceDeck::new(); + let mut b = ResourceDeck::new(); + + a.replenish(10, Resource::Ore); + b.replenish(1, Resource::Ore); + + assert_eq!(a.count(Resource::Ore), 10); + assert_eq!(b.count(Resource::Ore), 1); + + b = b.add(&a); + assert_eq!(a.count(Resource::Ore), 10); + assert_eq!(b.count(Resource::Ore), 11); + } + + #[test] + fn test_can_subtract() { + let mut a = ResourceDeck::new(); + let mut b = ResourceDeck::new(); + + a.replenish(13, Resource::Sheep); + b.replenish(4, Resource::Sheep); + + assert_eq!(a.count(Resource::Sheep), 13); + assert_eq!(b.count(Resource::Sheep), 4); + + b.replenish(11, Resource::Sheep); // now has 15 + b = b.subtract(&a); + assert_eq!(a.count(Resource::Sheep), 13); + assert_eq!(b.count(Resource::Sheep), 2); + } + + #[test] + fn test_from_array() { + let a = ResourceDeck::from_listdeck(&[Resource::Brick, Resource::Brick, Resource::Wood]); + assert_eq!(a.total_cards(), 3); + assert_eq!(a.count(Resource::Brick), 2); + assert_eq!(a.count(Resource::Wood), 1); + } + + #[test] + fn test_devcard_freqdeck_init() { + let deck = DevCardDeck::starting_deck(); + assert_eq!(deck.count(DevCard::Knight), 14); + assert_eq!(deck.count(DevCard::VictoryPoint), 5); + assert_eq!(deck.total_cards(), 25); + } + + #[test] + fn test_devcard_can_draw() { + let deck = DevCardDeck::starting_deck(); + assert!(deck.can_draw(10, DevCard::Knight)); + assert!(!deck.can_draw(15, DevCard::Knight)); + } + + #[test] + fn test_devcard_draw() { + let mut deck = DevCardDeck::starting_deck(); + assert_eq!(deck.count(DevCard::Knight), 14); + deck.draw(5, DevCard::Knight); + assert_eq!(deck.count(DevCard::Knight), 9); + assert_eq!(deck.total_cards(), 25 - 5); + } +} diff --git a/catanatron_rust/src/enums.rs b/catanatron_rust/src/enums.rs new file mode 100644 index 000000000..8ccdc9b35 --- /dev/null +++ b/catanatron_rust/src/enums.rs @@ -0,0 +1,159 @@ +use crate::{ + deck_slices::FreqDeck, + map_instance::{EdgeId, NodeId}, + map_template::Coordinate, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Color { + Red = 0, + Blue = 1, + Orange = 2, + White = 3, +} + +pub const COLORS: [Color; 4] = [Color::Red, Color::Blue, Color::Orange, Color::White]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Resource { + Wood, + Brick, + Sheep, + Wheat, + Ore, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum DevCard { + Knight, + YearOfPlenty, + Monopoly, + RoadBuilding, + VictoryPoint, +} + +#[derive(Debug, Clone, Copy)] +pub enum BuildingType { + Settlement, + City, + Road, +} + +#[derive(Debug, Clone, Copy)] +pub enum NodeRef { + North, + Northeast, + Southeast, + South, + Southwest, + Northwest, +} + +#[derive(Debug, Clone, Copy)] +pub enum EdgeRef { + East, + Southeast, + Southwest, + West, + Northwest, + Northeast, +} + +#[derive(Debug, Clone)] +pub enum ActionPrompt { + BuildInitialSettlement, + BuildInitialRoad, + PlayTurn, + Discard, + MoveRobber, + DecideTrade, + DecideAcceptees, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum Action { + Roll { + color: u8, + dice_opt: Option<(u8, u8)>, + }, + MoveRobber { + color: u8, + coordinate: Coordinate, + victim_opt: Option, + }, + Discard { + color: u8, + }, + BuildRoad { + color: u8, + edge_id: EdgeId, + }, + BuildSettlement { + color: u8, + node_id: NodeId, + }, + BuildCity { + color: u8, + node_id: NodeId, + }, + BuyDevelopmentCard { + color: u8, + }, + PlayKnight { + color: u8, + }, + PlayYearOfPlenty { + color: u8, + resources: (u8, Option), + }, + PlayMonopoly { + color: u8, + resource: u8, + }, + PlayRoadBuilding { + color: u8, + }, + MaritimeTrade { + color: u8, + give: u8, + take: u8, + ratio: u8, + }, + OfferTrade { + color: u8, + trade: (FreqDeck, FreqDeck), + }, + AcceptTrade { + color: u8, + trade: (FreqDeck, FreqDeck), + }, + RejectTrade { + color: u8, + }, + ConfirmTrade { + color: u8, + trade: (FreqDeck, FreqDeck, u8), + }, + CancelTrade { + color: u8, + }, + EndTurn { + color: u8, + }, +} + +#[derive(Debug, Clone)] +pub enum MapType { + Mini, + Base, + Tournament, +} + +#[derive(Debug, Clone)] +pub struct GameConfiguration { + pub discard_limit: u8, + pub vps_to_win: u8, + pub map_type: MapType, + pub num_players: u8, + pub max_ticks: u32, +} diff --git a/catanatron_rust/src/game.rs b/catanatron_rust/src/game.rs new file mode 100644 index 000000000..07ced0470 --- /dev/null +++ b/catanatron_rust/src/game.rs @@ -0,0 +1,318 @@ +use log::{debug, info}; +use std::collections::HashMap; +use std::rc::Rc; + +use crate::enums::{Action, GameConfiguration, MapType}; +use crate::global_state::GlobalState; +use crate::map_instance::MapInstance; +use crate::players::{Player, RandomPlayer}; +use crate::state::State; +use pyo3::prelude::*; + +pub fn play_game( + global_state: GlobalState, + config: GameConfiguration, + players: HashMap>, +) -> Option { + debug!( + "play_game: config={:?}, players={:?}", + config, + players.keys().collect::>() + ); + + let map_instance = MapInstance::new( + &global_state.base_map_template, + &global_state.dice_probas, + 0, + ); + let rc_config = Rc::new(config); + info!("Playing game with configuration: {:?}", rc_config); + let mut state = State::new(rc_config.clone(), Rc::new(map_instance)); + + debug!( + "State initialized: current_tick_seat={}, current_color={}", + state.get_current_tick_seat(), + state.get_current_color() + ); + + info!("Seat order: {:?}", state.get_seating_order()); + let mut num_ticks = 0; + while state.winner().is_none() && num_ticks < rc_config.max_ticks { + debug!("Tick {:?} =====", num_ticks); + play_tick(&players, &mut state); + num_ticks += 1; + } + state.winner() +} + +fn play_tick(players: &HashMap>, state: &mut State) -> Action { + let current_color = state.get_current_color(); + debug!( + "play_tick: current_color={}, players={:?}, action_prompt={:?}", + current_color, + players.keys().collect::>(), + state.get_action_prompt() + ); + + let current_player = match players.get(¤t_color) { + Some(player) => player, + None => { + debug!( + "ERROR: No player found for color {}. Available players: {:?}", + current_color, + players.keys().collect::>() + ); + panic!("No player found for color {}", current_color); + } + }; + + let playable_actions = state.generate_playable_actions(); + debug!( + "Player {:?} has {:?} playable actions", + current_color, playable_actions + ); + let action = current_player.decide(state, &playable_actions); + debug!( + "Player {:?} decided to play action {:?}", + current_color, action + ); + + state.apply_action(action); + action +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + enums::{Action, ActionPrompt, MapType}, + players::RandomPlayer, + }; + + fn setup_game( + num_players: u8, + ) -> (GlobalState, GameConfiguration, HashMap>) { + let global_state = GlobalState::new(); + let config = GameConfiguration { + discard_limit: 7, + vps_to_win: 10, + map_type: MapType::Base, + num_players, + max_ticks: 8, // TODO: Change! + }; + let mut players: HashMap> = HashMap::new(); + players.insert(0, Box::new(RandomPlayer {})); + players.insert(1, Box::new(RandomPlayer {})); + players.insert(2, Box::new(RandomPlayer {})); + players.insert(3, Box::new(RandomPlayer {})); + (global_state, config, players) + } + + #[test] + fn test_game_creation() { + let (global_state, config, players) = setup_game(4); + + let result = play_game(global_state, config, players); + assert_eq!(result, None); + } + + #[test] + fn test_initial_build_phase_four_player() { + let (global_state, config, players) = setup_game(4); + let map_instance = MapInstance::new( + &global_state.base_map_template, + &global_state.dice_probas, + 0, + ); + let rc_config = Rc::new(config); + let mut state = State::new(rc_config.clone(), Rc::new(map_instance)); + + let seating_order = state.get_seating_order(); + let first_player = seating_order[0]; + let second_player = seating_order[1]; + let third_player = seating_order[2]; + let fourth_player = seating_order[3]; + + // first player settlement + let playable_actions = state.generate_playable_actions(); + assert_eq!(playable_actions.len(), 54); + assert_all_build_settlements(playable_actions, first_player); + play_tick(&players, &mut state); + + // first player road + let playable_actions = state.generate_playable_actions(); + assert!(playable_actions.len() >= 2); + assert_all_build_roads(playable_actions, first_player); + assert!(state.is_initial_build_phase()); + play_tick(&players, &mut state); + + // second player settlement: assert at 50-51 actions and all are build settlement + let playable_actions = state.generate_playable_actions(); + assert!(playable_actions.len() >= 50 && playable_actions.len() <= 51); + assert_all_build_settlements(playable_actions, second_player); + play_tick(&players, &mut state); + + // second player road: assert at least 2 actions and all are build road + let playable_actions = state.generate_playable_actions(); + assert!(playable_actions.len() >= 2); + assert_all_build_roads(playable_actions, second_player); + play_tick(&players, &mut state); + + play_tick(&players, &mut state); // third player settlement + + // third player road + let playable_actions = state.generate_playable_actions(); + assert_all_build_roads(playable_actions, third_player); + play_tick(&players, &mut state); + + play_tick(&players, &mut state); // fourth player settlement + play_tick(&players, &mut state); // fourth player road + + // fourth player settlement 2 + assert!(state.is_initial_build_phase()); + let playable_actions = state.generate_playable_actions(); + assert_all_build_settlements(playable_actions, fourth_player); + play_tick(&players, &mut state); + + play_tick(&players, &mut state); // fourth player road + play_tick(&players, &mut state); // third player settlement 2 + play_tick(&players, &mut state); // third player road + let second_player_second_settlement_action = play_tick(&players, &mut state); + let second_player_second_node_id; + if let Action::BuildSettlement { + color: player, + node_id, + } = second_player_second_settlement_action + { + assert_eq!(player, second_player); + second_player_second_node_id = node_id; + } else { + panic!("Expected Action::BuildSettlement"); + } + debug!("{}", second_player_second_node_id); + + // second player road 2 + let playable_actions = state.generate_playable_actions(); + assert_all_build_roads(playable_actions.clone(), second_player); + // assert playable_actions are connected to the last settlement + assert!(playable_actions.iter().all(|e| { + if let Action::BuildRoad { edge_id, .. } = e { + second_player_second_node_id == edge_id.0 + || second_player_second_node_id == edge_id.1 + } else { + false + } + })); + play_tick(&players, &mut state); + + play_tick(&players, &mut state); // first player settlement 2 + play_tick(&players, &mut state); // first player road + + // Assert that the initial build phase is over and its the first player's turn + assert!(!state.is_initial_build_phase()); + assert_eq!(state.get_current_color(), first_player); + assert!(matches!(state.get_action_prompt(), ActionPrompt::PlayTurn)); + + // TODO: Assert players have money of their second house + } + + #[test] + fn test_initial_build_phase_two_player() { + let (global_state, config, players) = setup_game(2); + let map_instance = MapInstance::new( + &global_state.base_map_template, + &global_state.dice_probas, + 0, + ); + let rc_config = Rc::new(config); + let mut state = State::new(rc_config.clone(), Rc::new(map_instance)); + + let seating_order = state.get_seating_order(); + let first_player = seating_order[0]; + + for _ in 0..8 { + assert!(state.is_initial_build_phase()); + play_tick(&players, &mut state); + } + + // Assert that the initial build phase is over and its the first player's turn + assert!(!state.is_initial_build_phase()); + assert_eq!(state.get_current_color(), first_player); + assert!(matches!(state.get_action_prompt(), ActionPrompt::PlayTurn)); + } + + fn assert_all_build_settlements(playable_actions: Vec, player: u8) { + assert!( + playable_actions.iter().all(|e| { + if let Action::BuildSettlement { color, .. } = e { + *color == player + } else { + false + } + }), + "Expected all actions to be BuildSettlement for player {:?}", + player + ); + } + + fn assert_all_build_roads(playable_actions: Vec, player: u8) { + assert!( + playable_actions.iter().all(|e| { + if let Action::BuildRoad { color, .. } = e { + *color == player + } else { + false + } + }), + "Expected all actions to be BuildRoad for player {:?}", + player + ); + } +} + +#[pyclass(unsendable)] +pub struct Game { + num_players: usize, + config: GameConfiguration, + winner: Option, +} + +#[pymethods] +impl Game { + #[new] + fn new(num_players: usize) -> Self { + let config = GameConfiguration { + discard_limit: 7, + vps_to_win: 10, + map_type: MapType::Base, + num_players: num_players as u8, + max_ticks: 10000, + }; + Game { + num_players, + config, + winner: None, + } + } + + fn play(&mut self) { + let global_state = GlobalState::new(); + let mut players = HashMap::new(); + for i in 0..self.num_players { + players.insert(i as u8, Box::new(RandomPlayer {}) as Box); + } + self.winner = play_game(global_state, self.config.clone(), players); + match self.winner { + Some(winner) => info!("Game completed - Player {} won!", winner), + None => info!("Game ended without a winner"), + } + } + + fn get_num_players(&self) -> usize { + self.num_players + } + + fn get_winner(&self) -> Option { + self.winner + } +} diff --git a/catanatron_rust/src/global_state.rs b/catanatron_rust/src/global_state.rs new file mode 100644 index 000000000..78ee73690 --- /dev/null +++ b/catanatron_rust/src/global_state.rs @@ -0,0 +1,187 @@ +use std::collections::HashMap; + +use crate::{ + enums::Resource, + map_template::{MapTemplate, TileSlot}, + ordered_hashmap::OrderedHashMap, +}; + +#[derive(Debug)] +pub struct GlobalState { + pub mini_map_template: MapTemplate, + pub base_map_template: MapTemplate, + pub dice_probas: HashMap, +} + +fn build_dice_probas() -> HashMap { + let mut probas: HashMap = HashMap::new(); + + // Iterate over two dice rolls + for i in 1..=6 { + for j in 1..=6 { + let sum = i + j; + let counter = probas.entry(sum).or_insert(0.0); + *counter += 1.0 / 36.0; + } + } + + probas +} + +impl Default for GlobalState { + fn default() -> Self { + Self::new() + } +} + +impl GlobalState { + pub fn new() -> Self { + // Mini Map Template + let mut topology = OrderedHashMap::new(); + // center + topology.insert((0, 0, 0), TileSlot::Land); + // first layer + topology.insert((1, -1, 0), TileSlot::Land); + topology.insert((0, -1, 1), TileSlot::Land); + topology.insert((-1, 0, 1), TileSlot::Land); + topology.insert((-1, 1, 0), TileSlot::Land); + topology.insert((0, 1, -1), TileSlot::Land); + topology.insert((1, 0, -1), TileSlot::Land); + // second layer + topology.insert((2, -2, 0), TileSlot::Water); + topology.insert((1, -2, 1), TileSlot::Water); + topology.insert((0, -2, 2), TileSlot::Water); + topology.insert((-1, -1, 2), TileSlot::Water); + topology.insert((-2, 0, 2), TileSlot::Water); + topology.insert((-2, 1, 1), TileSlot::Water); + topology.insert((-2, 2, 0), TileSlot::Water); + topology.insert((-1, 2, -1), TileSlot::Water); + topology.insert((0, 2, -2), TileSlot::Water); + topology.insert((1, 1, -2), TileSlot::Water); + topology.insert((2, 0, -2), TileSlot::Water); + topology.insert((2, -1, -1), TileSlot::Water); + let mini_map_template = MapTemplate { + numbers: vec![3, 4, 5, 6, 8, 9, 10], + ports: vec![], + tiles: vec![ + Some(Resource::Wood), + None, + Some(Resource::Brick), + Some(Resource::Sheep), + Some(Resource::Wheat), + Some(Resource::Wheat), + Some(Resource::Ore), + ], + topology, + }; + // Base Map Template + let mut topology = OrderedHashMap::new(); + // center + topology.insert((0, 0, 0), TileSlot::Land); + // first layer + topology.insert((1, -1, 0), TileSlot::Land); + topology.insert((0, -1, 1), TileSlot::Land); + topology.insert((-1, 0, 1), TileSlot::Land); + topology.insert((-1, 1, 0), TileSlot::Land); + topology.insert((0, 1, -1), TileSlot::Land); + topology.insert((1, 0, -1), TileSlot::Land); + // second layer + topology.insert((2, -2, 0), TileSlot::Land); + topology.insert((1, -2, 1), TileSlot::Land); + topology.insert((0, -2, 2), TileSlot::Land); + topology.insert((-1, -1, 2), TileSlot::Land); + topology.insert((-2, 0, 2), TileSlot::Land); + topology.insert((-2, 1, 1), TileSlot::Land); + topology.insert((-2, 2, 0), TileSlot::Land); + topology.insert((-1, 2, -1), TileSlot::Land); + topology.insert((0, 2, -2), TileSlot::Land); + topology.insert((1, 1, -2), TileSlot::Land); + topology.insert((2, 0, -2), TileSlot::Land); + topology.insert((2, -1, -1), TileSlot::Land); + // third layer + topology.insert((3, -3, 0), TileSlot::WPort); + topology.insert((2, -3, 1), TileSlot::Water); + topology.insert((1, -3, 2), TileSlot::NWPort); + topology.insert((0, -3, 3), TileSlot::Water); + topology.insert((-1, -2, 3), TileSlot::NWPort); + topology.insert((-2, -1, 3), TileSlot::Water); + topology.insert((-3, 0, 3), TileSlot::NEPort); + topology.insert((-3, 1, 2), TileSlot::Water); + topology.insert((-3, 2, 1), TileSlot::EPort); + topology.insert((-3, 3, 0), TileSlot::Water); + topology.insert((-2, 3, -1), TileSlot::EPort); + topology.insert((-1, 3, -2), TileSlot::Water); + topology.insert((0, 3, -3), TileSlot::SEPort); + topology.insert((1, 2, -3), TileSlot::Water); + topology.insert((2, 1, -3), TileSlot::SWPort); + topology.insert((3, 0, -3), TileSlot::Water); + topology.insert((3, -1, -2), TileSlot::SWPort); + topology.insert((3, -2, -1), TileSlot::Water); + + let base_map_template = MapTemplate { + numbers: vec![2, 3, 3, 4, 4, 5, 5, 6, 6, 8, 8, 9, 9, 10, 10, 11, 11, 12], + ports: vec![ + // These are 2:1 ports + Some(Resource::Wood), + Some(Resource::Brick), + Some(Resource::Sheep), + Some(Resource::Wheat), + Some(Resource::Ore), + // These represet 3:1 ports + None, + None, + None, + None, + ], + tiles: vec![ + // Four wood tiles + Some(Resource::Wood), + Some(Resource::Wood), + Some(Resource::Wood), + Some(Resource::Wood), + // Three brick tiles + Some(Resource::Brick), + Some(Resource::Brick), + Some(Resource::Brick), + // Four sheep tiles + Some(Resource::Sheep), + Some(Resource::Sheep), + Some(Resource::Sheep), + Some(Resource::Sheep), + // Four wheat tiles + Some(Resource::Wheat), + Some(Resource::Wheat), + Some(Resource::Wheat), + Some(Resource::Wheat), + // Three ore tiles + Some(Resource::Ore), + Some(Resource::Ore), + Some(Resource::Ore), + // One desert + None, + ], + topology, + }; + + let dice_probas = build_dice_probas(); + + Self { + mini_map_template, + base_map_template, + dice_probas, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_global_state() { + let global_state = GlobalState::new(); + assert_eq!(global_state.mini_map_template.numbers.len(), 7); + assert_eq!(global_state.base_map_template.numbers.len(), 18); + assert_eq!(global_state.mini_map_template.topology.len(), 19); + } +} diff --git a/catanatron_rust/src/lib.rs b/catanatron_rust/src/lib.rs new file mode 100644 index 000000000..589a8c45e --- /dev/null +++ b/catanatron_rust/src/lib.rs @@ -0,0 +1,28 @@ +use log::info; +use pyo3::prelude::*; +use std::sync::Once; + +pub mod deck_slices; +pub mod decks; +pub mod enums; +pub mod game; +pub mod global_state; +pub mod map_instance; +pub mod map_template; +mod ordered_hashmap; +pub mod players; +pub mod state; +pub mod state_vector; + +// Ensure logger is initialized only once for Python module +static PYTHON_LOGGER_INIT: Once = Once::new(); + +#[pymodule] +fn catanatron_rust(_py: Python, m: &PyModule) -> PyResult<()> { + PYTHON_LOGGER_INIT.call_once(|| { + env_logger::init(); + info!("Initialized catanatron_rust logging"); + }); + m.add_class::()?; + Ok(()) +} diff --git a/catanatron_rust/src/main.rs b/catanatron_rust/src/main.rs new file mode 100644 index 000000000..a4ff4a285 --- /dev/null +++ b/catanatron_rust/src/main.rs @@ -0,0 +1,94 @@ +use catanatron_rust::decks; +use catanatron_rust::enums::{Color, GameConfiguration, MapType, Resource, COLORS}; +use catanatron_rust::game::play_game; +use catanatron_rust::global_state; +use catanatron_rust::map_instance::MapInstance; +use catanatron_rust::players::{Player, RandomPlayer}; +use catanatron_rust::state_vector; +use log::{debug, info}; +use std::collections::HashMap; +use std::time::Instant; + +fn main() { + env_logger::init(); + + // Benchmark deck operations + info!("Starting benchmark of Deck operations..."); + let start = Instant::now(); + let mut deck = decks::ResourceDeck::starting_resource_bank(); + for _ in 0..1_000_000 { + if deck.can_draw(2, Resource::Wood) { + deck.draw(2, Resource::Wood); + deck.replenish(1, Resource::Wood); // Replenish after drawing to keep the count consistent + deck.replenish(1, Resource::Wood); // Replenish after drawing to keep the count consistent + } + } + let duration = start.elapsed(); + info!("Time taken for 1,000,000 deck operations: {:?}", duration); + info!("Total cards in deck: {}", deck.total_cards()); + + // Benchmark copy operations + info!("Starting benchmark of Deck operations..."); + let start = Instant::now(); + let vector = vec![0u8; 600]; + let mut copied_vector = vector.clone(); + for i in 0..1_000_000 { + let index = i % 600; + copied_vector[index] = index as u8; + } + let duration = start.elapsed(); + info!("Time taken for 1,000,000 copy operations: {:?}", duration); + debug!("Copy Results: {:?}, {:?}", vector, copied_vector); + + // Benchmark array copy operations + info!("Starting benchmark of Array operations..."); + let start = Instant::now(); + let array = [0u8; 1200]; + let mut copied_array = array; + for i in 0..1_000_000 { + let index = i % 1200; + copied_array[index] = index as u8; + } + let duration = start.elapsed(); + info!("Time taken for 1,000,000 array operations: {:?}", duration); + debug!("Copy Results: {:?}, {:?}", array, copied_array); + + let global_state = global_state::GlobalState::new(); + debug!("Global State: {:?}", global_state); + + let size = state_vector::get_state_array_size(2); + debug!("Vector length: {}", size); + + let vector = state_vector::initialize_state(4); + debug!("Vector: {:?}", vector); + + let map_instance = MapInstance::new( + &global_state.base_map_template, + &global_state.dice_probas, + 0, + ); + debug!("Map Instance Tiles: {:?}", map_instance.get_tile((0, 0, 0))); + debug!( + "Map Instance Land Tiles: {:?}", + map_instance.get_land_tile((1, 0, -1)) + ); + + debug!("Colors slice: {:?}", state_vector::seating_order_slice(4)); + debug!("Colors {:?}", COLORS); + + let config = GameConfiguration { + discard_limit: 7, + vps_to_win: 10, + map_type: MapType::Base, + num_players: 4, + max_ticks: 10000, + }; + let mut players: HashMap> = HashMap::new(); + players.insert(Color::Red as u8, Box::new(RandomPlayer {})); + players.insert(Color::Blue as u8, Box::new(RandomPlayer {})); + players.insert(Color::Orange as u8, Box::new(RandomPlayer {})); + players.insert(Color::White as u8, Box::new(RandomPlayer {})); + + let result = play_game(global_state, config, players); + info!("Game result: {:?}", result); +} diff --git a/catanatron_rust/src/map_instance.rs b/catanatron_rust/src/map_instance.rs new file mode 100644 index 000000000..d6bafe6ff --- /dev/null +++ b/catanatron_rust/src/map_instance.rs @@ -0,0 +1,698 @@ +use rand::rngs::StdRng; +use rand::{seq::SliceRandom, SeedableRng}; +use std::collections::{HashMap, HashSet}; +use std::hash::Hash; + +use crate::enums::Resource; +use crate::map_template::{add_coordinates, Coordinate, MapTemplate, TileSlot}; + +pub type NodeId = u8; +pub type EdgeId = (NodeId, NodeId); + +#[derive(Debug, Clone, Copy, Hash, Eq, PartialEq)] +pub enum NodeRef { + North, + NorthEast, + SouthEast, + South, + SouthWest, + NorthWest, +} + +const NODE_REFS: [NodeRef; 6] = [ + NodeRef::North, + NodeRef::NorthEast, + NodeRef::SouthEast, + NodeRef::South, + NodeRef::SouthWest, + NodeRef::NorthWest, +]; + +#[derive(Debug, Clone, Copy, Hash, Eq, PartialEq)] +pub enum EdgeRef { + East, + SouthEast, + SouthWest, + West, + NorthWest, + NorthEast, +} + +const EDGE_REFS: [EdgeRef; 6] = [ + EdgeRef::East, + EdgeRef::SouthEast, + EdgeRef::SouthWest, + EdgeRef::West, + EdgeRef::NorthWest, + EdgeRef::NorthEast, +]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Direction { + East, + SouthEast, + SouthWest, + West, + NorthWest, + NorthEast, +} + +const DIRECTIONS: [Direction; 6] = [ + Direction::East, + Direction::SouthEast, + Direction::SouthWest, + Direction::West, + Direction::NorthWest, + Direction::NorthEast, +]; + +fn get_unit_vector(direction: Direction) -> (i8, i8, i8) { + match direction { + Direction::NorthEast => (1, 0, -1), + Direction::SouthWest => (-1, 0, 1), + Direction::NorthWest => (0, 1, -1), + Direction::SouthEast => (0, -1, 1), + Direction::East => (1, -1, 0), + Direction::West => (-1, 1, 0), + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct Hexagon { + pub(crate) nodes: HashMap, + pub(crate) edges: HashMap, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct LandTile { + pub(crate) id: u8, + pub(crate) hexagon: Hexagon, + pub(crate) resource: Option, + pub(crate) number: Option, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct PortTile { + pub(crate) id: u8, + pub(crate) hexagon: Hexagon, + pub(crate) resource: Option, + pub(crate) direction: Direction, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct WaterTile { + pub(crate) hexagon: Hexagon, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum Tile { + Land(LandTile), + Port(PortTile), + Water(WaterTile), +} + +#[derive(Debug)] +pub struct MapInstance { + tiles: HashMap, + land_tiles: HashMap, + port_nodes: HashMap>, + adjacent_land_tiles: HashMap>, + node_production: HashMap>, + + // TODO: Since this doesn't change per map_instance but per template, move + // these to MapTemplate. + // Decided to not use Petgraph for now, since needs seem to be: + // - Lookup node neighbors of a node + // - Lookup edges of a node + // - Lookup list of land edges + // - Pairwise distances between nodes + // - Shortest path between nodes + // - BFS capabilities + // all which doesn't sound too bad to implement. + land_nodes: HashSet, + + // TODO: Track valid edges for building roads. + #[allow(dead_code)] + land_edges: HashSet, + node_neighbors: HashMap>, + edge_neighbors: HashMap>, +} + +impl MapInstance { + pub fn get_tiles(&self) -> &HashMap { + &self.tiles + } + + pub fn get_land_tiles(&self) -> &HashMap { + &self.land_tiles + } + + pub fn land_nodes(&self) -> &HashSet { + &self.land_nodes + } + + pub fn get_port_nodes(&self) -> &HashMap> { + &self.port_nodes + } + + pub fn get_node_production(&self, node_id: NodeId) -> Option<&HashMap> { + self.node_production.get(&node_id) + } + + pub fn get_all_node_production(&self) -> &HashMap> { + &self.node_production + } + + pub fn get_tile(&self, coordinate: Coordinate) -> Option<&Tile> { + self.tiles.get(&coordinate) + } + + pub fn get_land_tile(&self, coordinate: Coordinate) -> Option<&LandTile> { + self.land_tiles.get(&coordinate) + } + + pub fn get_neighbor_nodes(&self, node_id: NodeId) -> Vec { + self.node_neighbors.get(&node_id).unwrap().clone() + } + + pub fn get_neighbor_edges(&self, node_id: NodeId) -> Vec { + self.edge_neighbors.get(&node_id).unwrap().clone() + } + + pub fn get_adjacent_tiles(&self, node_id: NodeId) -> Option<&Vec> { + self.adjacent_land_tiles.get(&node_id) + } + + pub fn get_tiles_by_number(&self, number: u8) -> Vec<&LandTile> { + self.land_tiles + .values() + .filter(|&tile| tile.number == Some(number)) + .collect() + } +} + +impl MapInstance { + pub fn new(map_template: &MapTemplate, dice_probas: &HashMap, seed: u64) -> Self { + let tiles = Self::initialize_tiles(map_template, seed); + Self::from_tiles(tiles, dice_probas) + } + + fn initialize_tiles(map_template: &MapTemplate, seed: u64) -> HashMap { + let mut rng = StdRng::seed_from_u64(seed); + + // Shuffle the numbers, tiles, and ports + let mut shuffled_numbers = map_template.numbers.clone(); + shuffled_numbers.shuffle(&mut rng); + let mut shuffled_tiles = map_template.tiles.clone(); + shuffled_tiles.shuffle(&mut rng); + let mut shuffled_ports = map_template.ports.clone(); + shuffled_ports.shuffle(&mut rng); + + // Build the Hexagons by iterating over map_template.topology + let mut hexagons: HashMap = HashMap::new(); + let mut tiles: HashMap = HashMap::new(); + let mut autoinc = 0; + let mut tile_autoinc = 0; + let mut port_autoinc = 0; + + for (&coordinate, &tile_slot) in map_template.topology.iter() { + let (nodes, edges, new_autoinc) = get_nodes_edges(&hexagons, coordinate, autoinc); + autoinc = new_autoinc; + let hexagon = Hexagon { nodes, edges }; + + if tile_slot == TileSlot::Land { + let resource = shuffled_tiles.pop().unwrap(); + if resource.is_none() { + let land_tile = LandTile { + id: tile_autoinc, + hexagon: hexagon.clone(), + resource, + number: None, + }; + tiles.insert(coordinate, Tile::Land(land_tile)); + } else { + let number = shuffled_numbers.pop().unwrap(); + let land_tile = LandTile { + id: tile_autoinc, + hexagon: hexagon.clone(), + resource, + number: Some(number), + }; + tiles.insert(coordinate, Tile::Land(land_tile)); + } + tile_autoinc += 1; + } else if tile_slot == TileSlot::Water { + let water_tile = WaterTile { + hexagon: hexagon.clone(), + }; + tiles.insert(coordinate, Tile::Water(water_tile)); + } else { + let direction = match tile_slot { + TileSlot::NWPort => Direction::NorthWest, + TileSlot::NEPort => Direction::NorthEast, + TileSlot::EPort => Direction::East, + TileSlot::SEPort => Direction::SouthEast, + TileSlot::SWPort => Direction::SouthWest, + TileSlot::WPort => Direction::West, + _ => panic!("Invalid port tile slot"), + }; + let resource = shuffled_ports.pop().unwrap(); + let port_tile = PortTile { + id: port_autoinc, + hexagon: hexagon.clone(), + resource, + direction, + }; + tiles.insert(coordinate, Tile::Port(port_tile)); + port_autoinc += 1; + } + + hexagons.insert(coordinate, hexagon); + } + tiles + } + + fn from_tiles(tiles: HashMap, dice_probas: &HashMap) -> Self { + let mut land_tiles: HashMap = HashMap::new(); + let mut port_nodes: HashMap> = HashMap::new(); + let mut adjacent_land_tiles: HashMap> = HashMap::new(); + let mut node_production: HashMap> = HashMap::new(); + + let mut land_nodes: HashSet = HashSet::new(); + let mut land_edges: HashSet = HashSet::new(); + let mut node_neighbors: HashMap> = HashMap::new(); + let mut edge_neighbors: HashMap> = HashMap::new(); + + for (&coordinate, tile) in tiles.iter() { + if let Tile::Land(land_tile) = tile { + land_tiles.insert(coordinate, land_tile.clone()); + let is_desert = land_tile.resource.is_none(); + land_tile.hexagon.nodes.values().for_each(|&node_id| { + land_nodes.insert(node_id); + adjacent_land_tiles + .entry(node_id) + .or_default() + .push(land_tile.clone()); + + // maybe add this tile's production to the node's production + let production = node_production.entry(node_id).or_default(); + if is_desert { + return; + } + let resource = land_tile.resource.unwrap(); + let number = land_tile.number.unwrap(); + let proba = dice_probas.get(&number).unwrap(); + production.entry(resource).or_insert(0.0); + *production.get_mut(&resource).unwrap() += proba; + }); + + land_tile.hexagon.edges.values().for_each(|&edge_id| { + land_edges.insert(edge_id); + node_neighbors.entry(edge_id.0).or_default().push(edge_id.1); + node_neighbors.entry(edge_id.1).or_default().push(edge_id.0); + + // Only insert edge into edge_neighbors if not already present + { + let edges_for_node_0 = edge_neighbors.entry(edge_id.0).or_default(); + if !edges_for_node_0.contains(&edge_id) { + edges_for_node_0.push(edge_id); + } + let edges_for_node_1 = edge_neighbors.entry(edge_id.1).or_default(); + if !edges_for_node_1.contains(&edge_id) { + edges_for_node_1.push(edge_id); + } + } + }); + } else if let Tile::Port(port_tile) = tile { + let (a_noderef, b_noderef) = get_noderefs_from_port_direction(port_tile.direction); + port_nodes.insert( + *port_tile.hexagon.nodes.get(&a_noderef).unwrap(), + port_tile.resource, + ); + port_nodes.insert( + *port_tile.hexagon.nodes.get(&b_noderef).unwrap(), + port_tile.resource, + ); + } + } + + Self { + tiles, + land_tiles, + port_nodes, + adjacent_land_tiles, + node_production, + + land_nodes, + land_edges, + node_neighbors, + edge_neighbors, + } + } +} + +fn get_noderefs_from_port_direction(direction: Direction) -> (NodeRef, NodeRef) { + match direction { + Direction::East => (NodeRef::NorthEast, NodeRef::SouthEast), + Direction::SouthEast => (NodeRef::SouthEast, NodeRef::South), + Direction::SouthWest => (NodeRef::South, NodeRef::SouthWest), + Direction::West => (NodeRef::SouthWest, NodeRef::NorthWest), + Direction::NorthWest => (NodeRef::NorthWest, NodeRef::North), + Direction::NorthEast => (NodeRef::North, NodeRef::NorthEast), + } +} + +fn get_nodes_edges( + hexagons: &HashMap, + coordinate: Coordinate, + mut node_autoinc: NodeId, +) -> (HashMap, HashMap, NodeId) { + let mut nodes = HashMap::new(); + let mut edges = HashMap::new(); + + // Insert Pre-existing Nodes and Edges + let neighbor_hexagons: Vec<(Direction, Coordinate)> = DIRECTIONS + .iter() + .map(|&direction| { + let unit_vector = get_unit_vector(direction); + (direction, add_coordinates(coordinate, unit_vector)) + }) + .collect::>(); + for (neighbor_direction, neighbor_coordinate) in neighbor_hexagons { + if hexagons.contains_key(&neighbor_coordinate) { + let neighbor_hexagon = hexagons.get(&neighbor_coordinate).unwrap(); + + if neighbor_direction == Direction::East { + nodes.insert( + NodeRef::NorthEast, + *neighbor_hexagon.nodes.get(&NodeRef::NorthWest).unwrap(), + ); + nodes.insert( + NodeRef::SouthEast, + *neighbor_hexagon.nodes.get(&NodeRef::SouthWest).unwrap(), + ); + edges.insert( + EdgeRef::East, + *neighbor_hexagon.edges.get(&EdgeRef::West).unwrap(), + ); + } else if neighbor_direction == Direction::SouthEast { + nodes.insert( + NodeRef::South, + *neighbor_hexagon.nodes.get(&NodeRef::NorthWest).unwrap(), + ); + nodes.insert( + NodeRef::SouthEast, + *neighbor_hexagon.nodes.get(&NodeRef::North).unwrap(), + ); + edges.insert( + EdgeRef::SouthEast, + *neighbor_hexagon.edges.get(&EdgeRef::NorthWest).unwrap(), + ); + } else if neighbor_direction == Direction::SouthWest { + nodes.insert( + NodeRef::South, + *neighbor_hexagon.nodes.get(&NodeRef::NorthEast).unwrap(), + ); + nodes.insert( + NodeRef::SouthWest, + *neighbor_hexagon.nodes.get(&NodeRef::North).unwrap(), + ); + edges.insert( + EdgeRef::SouthWest, + *neighbor_hexagon.edges.get(&EdgeRef::NorthEast).unwrap(), + ); + } else if neighbor_direction == Direction::West { + nodes.insert( + NodeRef::NorthWest, + *neighbor_hexagon.nodes.get(&NodeRef::NorthEast).unwrap(), + ); + nodes.insert( + NodeRef::SouthWest, + *neighbor_hexagon.nodes.get(&NodeRef::SouthEast).unwrap(), + ); + edges.insert( + EdgeRef::West, + *neighbor_hexagon.edges.get(&EdgeRef::East).unwrap(), + ); + } else if neighbor_direction == Direction::NorthWest { + nodes.insert( + NodeRef::North, + *neighbor_hexagon.nodes.get(&NodeRef::SouthEast).unwrap(), + ); + nodes.insert( + NodeRef::NorthWest, + *neighbor_hexagon.nodes.get(&NodeRef::South).unwrap(), + ); + edges.insert( + EdgeRef::NorthWest, + *neighbor_hexagon.edges.get(&EdgeRef::SouthEast).unwrap(), + ); + } else if neighbor_direction == Direction::NorthEast { + nodes.insert( + NodeRef::North, + *neighbor_hexagon.nodes.get(&NodeRef::SouthWest).unwrap(), + ); + nodes.insert( + NodeRef::NorthEast, + *neighbor_hexagon.nodes.get(&NodeRef::South).unwrap(), + ); + edges.insert( + EdgeRef::NorthEast, + *neighbor_hexagon.edges.get(&EdgeRef::SouthWest).unwrap(), + ); + } else { + panic!("Something went wrong"); + } + } + } + + // Insert New Nodes and Edges + for noderef in NODE_REFS { + if let std::collections::hash_map::Entry::Vacant(e) = nodes.entry(noderef) { + e.insert(node_autoinc); + node_autoinc += 1; + } + } + for edgeref in EDGE_REFS { + edges.entry(edgeref).or_insert_with(|| { + let (a_noderef, b_noderef) = get_noderefs(edgeref); + let edge_nodes = ( + *nodes.get(&a_noderef).unwrap(), + *nodes.get(&b_noderef).unwrap(), + ); + edge_nodes + }); + } + + (nodes, edges, node_autoinc) +} + +fn get_noderefs(edgeref: EdgeRef) -> (NodeRef, NodeRef) { + match edgeref { + EdgeRef::East => (NodeRef::NorthEast, NodeRef::SouthEast), + EdgeRef::SouthEast => (NodeRef::SouthEast, NodeRef::South), + EdgeRef::SouthWest => (NodeRef::South, NodeRef::SouthWest), + EdgeRef::West => (NodeRef::SouthWest, NodeRef::NorthWest), + EdgeRef::NorthWest => (NodeRef::NorthWest, NodeRef::North), + EdgeRef::NorthEast => (NodeRef::North, NodeRef::NorthEast), + } +} + +#[cfg(test)] +mod tests { + use crate::global_state::GlobalState; + + use super::*; + + #[test] + fn test_get_nodes_edges() { + let autoinc = 0; + let (nodes, edges, autoinc) = get_nodes_edges(&HashMap::new(), (0, 0, 0), autoinc); + assert!(nodes.len() == 6); + assert!(edges.len() == 6); + assert_eq!(autoinc, 6); + } + + #[test] + fn test_get_nodes_and_edges_for_east_attachment() { + let mut tiles = HashMap::new(); + let autoinc = 0; + let (nodes1, edges1, autoinc1) = get_nodes_edges(&tiles, (0, 0, 0), autoinc); + + tiles.insert( + (0, 0, 0), + Hexagon { + nodes: nodes1, + edges: edges1, + }, + ); + let (nodes2, edges2, autoinc2) = get_nodes_edges(&tiles, (1, -1, 0), autoinc1); + assert_eq!(nodes2.len(), 6); + assert_eq!(nodes2.get(&NodeRef::SouthEast), Some(&8)); + assert_eq!(nodes2.values().max(), Some(&9)); + assert_eq!(edges2.len(), 6); + assert_eq!(edges2.get(&EdgeRef::East), Some(&(7, 8))); + assert_eq!(autoinc2, 10); + } + + // TODO: Test production at a node that has two of the same tiles next to each other. + // See https://github.com/bcollazo/catanatron/issues/263. + + fn assert_node_value( + map_instance: &MapInstance, + coordinates: Coordinate, + node_ref: NodeRef, + expected_value: u8, + ) { + assert_eq!( + map_instance + .land_tiles + .get(&coordinates) + .unwrap() + .hexagon + .nodes + .get(&node_ref), + Some(&expected_value) + ); + } + + fn assert_land_tile( + map_instance: &MapInstance, + coordinates: Coordinate, + resource: Option, + number: Option, + ) { + let land_tile = map_instance.get_land_tile(coordinates).unwrap(); + assert_eq!(land_tile.resource, resource); + assert_eq!(land_tile.number, number); + } + + #[test] + fn test_map_mini() { + let global_state = GlobalState::new(); + let map_instance = MapInstance::new( + &global_state.mini_map_template, + &global_state.dice_probas, + 0, + ); + + assert_eq!(map_instance.tiles.len(), 19); + assert_eq!(map_instance.land_tiles.len(), 7); + assert_eq!(map_instance.land_nodes.len(), 24); + assert_eq!(map_instance.port_nodes.len(), 0); + assert_eq!(map_instance.adjacent_land_tiles.len(), 24); + assert_eq!(map_instance.node_production.len(), 24); + + // Test adjacent_tiles + let adjacent_tiles = map_instance.adjacent_land_tiles.get(&0).unwrap(); + assert_eq!(adjacent_tiles.len(), 3); + assert_land_tile(&map_instance, (0, 0, 0), Some(Resource::Ore), Some(9)); + assert_land_tile(&map_instance, (1, 0, -1), Some(Resource::Wheat), Some(4)); + assert_land_tile(&map_instance, (0, 1, -1), Some(Resource::Brick), Some(5)); + // Assert there is a 9 ore in adjacent_tiles + assert!(adjacent_tiles + .iter() + .any(|tile| { tile.resource == Some(Resource::Ore) && tile.number == Some(9) })); + // Spot-check two more nodes + assert_eq!(map_instance.adjacent_land_tiles.get(&14).unwrap().len(), 1); + assert_eq!(map_instance.adjacent_land_tiles.get(&16).unwrap().len(), 2); + + // Test node production + let node_production = map_instance.node_production.get(&0).unwrap(); + assert_eq!( + node_production.get(&Resource::Ore), + Some(&0.1111111111111111) + ); + assert_eq!( + node_production.get(&Resource::Wheat), + Some(&0.08333333333333333) + ); + assert_eq!( + node_production.get(&Resource::Brick), + Some(&0.1111111111111111) + ); + + // Spot-check several node ids + assert_node_value(&map_instance, (0, 0, 0), NodeRef::North, 0); + assert_node_value(&map_instance, (0, 0, 0), NodeRef::NorthEast, 1); + assert_node_value(&map_instance, (1, -1, 0), NodeRef::SouthEast, 8); + assert_node_value(&map_instance, (1, 0, -1), NodeRef::North, 22); + assert_node_value(&map_instance, (-1, 0, 1), NodeRef::South, 13); + } + + #[test] + fn test_map_instance() { + let global_state = GlobalState::new(); + let map_instance = MapInstance::new( + &global_state.base_map_template, + &global_state.dice_probas, + 0, + ); + + assert_eq!(map_instance.tiles.len(), 37); + assert_eq!(map_instance.land_tiles.len(), 19); + assert_eq!(map_instance.land_nodes.len(), 54); + assert_eq!(map_instance.port_nodes.len(), 18); + assert_eq!(map_instance.adjacent_land_tiles.len(), 54); + + // Assert tile at 0,0,0 is Land with right resource and number + assert_eq!( + map_instance.tiles.get(&(0, 0, 0)), + Some(&Tile::Land(LandTile { + id: 0, + hexagon: Hexagon { + nodes: HashMap::from([ + (NodeRef::North, 0), + (NodeRef::NorthEast, 1), + (NodeRef::SouthEast, 2), + (NodeRef::South, 3), + (NodeRef::SouthWest, 4), + (NodeRef::NorthWest, 5) + ]), + edges: HashMap::from([ + (EdgeRef::East, (1, 2)), + (EdgeRef::SouthEast, (2, 3)), + (EdgeRef::SouthWest, (3, 4)), + (EdgeRef::West, (4, 5)), + (EdgeRef::NorthWest, (5, 0)), + (EdgeRef::NorthEast, (0, 1)) + ]) + }, + resource: Some(Resource::Wood), + number: Some(10) + })) + ); + assert_eq!( + map_instance.land_tiles.get(&(1, -1, 0)), + Some(&LandTile { + id: 1, + hexagon: Hexagon { + nodes: HashMap::from([ + (NodeRef::North, 6), + (NodeRef::NorthEast, 7), + (NodeRef::SouthEast, 8), + (NodeRef::South, 9), + (NodeRef::SouthWest, 2), + (NodeRef::NorthWest, 1) + ]), + edges: HashMap::from([ + (EdgeRef::East, (7, 8)), + (EdgeRef::SouthEast, (8, 9)), + (EdgeRef::SouthWest, (9, 2)), + (EdgeRef::West, (1, 2)), + (EdgeRef::NorthWest, (1, 6)), + (EdgeRef::NorthEast, (6, 7)) + ]) + }, + resource: Some(Resource::Ore), + number: Some(9) + }) + ); + + // Spot-check several node ids + assert_node_value(&map_instance, (-1, 1, 0), NodeRef::SouthWest, 17); + assert_node_value(&map_instance, (1, 0, -1), NodeRef::NorthEast, 23); + assert_node_value(&map_instance, (-1, 2, -1), NodeRef::North, 43); + assert_node_value(&map_instance, (0, -2, 2), NodeRef::NorthWest, 11); + } +} diff --git a/catanatron_rust/src/map_template.rs b/catanatron_rust/src/map_template.rs new file mode 100644 index 000000000..1ca4f6398 --- /dev/null +++ b/catanatron_rust/src/map_template.rs @@ -0,0 +1,32 @@ +use crate::{enums::Resource, ordered_hashmap::OrderedHashMap}; + +// Define a new struct to wrap the tuple +pub type Coordinate = (i8, i8, i8); + +// Method to add two coordinates +pub fn add_coordinates(a: Coordinate, b: Coordinate) -> Coordinate { + (a.0 + b.0, a.1 + b.1, a.2 + b.2) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum TileSlot { + Land, + Water, + NWPort, + NEPort, + EPort, + SEPort, + SWPort, + WPort, +} + +#[derive(Debug)] +pub struct MapTemplate { + pub(crate) numbers: Vec, + pub(crate) ports: Vec>, + pub(crate) tiles: Vec>, + + // Ordered, so that when map is built, we keep the same node-id, edge-id, and tile-id. + // that original catanatron uses. + pub(crate) topology: OrderedHashMap, +} diff --git a/catanatron_rust/src/ordered_hashmap.rs b/catanatron_rust/src/ordered_hashmap.rs new file mode 100644 index 000000000..325eb99ab --- /dev/null +++ b/catanatron_rust/src/ordered_hashmap.rs @@ -0,0 +1,34 @@ +use std::collections::HashMap; + +#[derive(Debug)] +pub struct OrderedHashMap { + map: HashMap, + keys: Vec, +} + +impl OrderedHashMap { + pub fn new() -> Self { + OrderedHashMap { + map: HashMap::new(), + keys: Vec::new(), + } + } + + pub fn insert(&mut self, key: K, value: V) { + if !self.map.contains_key(&key) { + self.keys.push(key.clone()); + } + self.map.insert(key, value); + } + + pub fn iter(&self) -> impl Iterator { + self.keys + .iter() + .filter_map(move |key| self.map.get(key).map(|value| (key, value))) + } + + #[cfg(test)] + pub fn len(&self) -> usize { + self.keys.len() + } +} diff --git a/catanatron_rust/src/players/mod.rs b/catanatron_rust/src/players/mod.rs new file mode 100644 index 000000000..d116d275d --- /dev/null +++ b/catanatron_rust/src/players/mod.rs @@ -0,0 +1,10 @@ +mod random_player; + +use crate::enums::Action; +use crate::state::State; + +pub trait Player { + fn decide(&self, state: &State, playable_actions: &[Action]) -> Action; +} + +pub use random_player::RandomPlayer; diff --git a/catanatron_rust/src/players/random_player.rs b/catanatron_rust/src/players/random_player.rs new file mode 100644 index 000000000..1d9d39b3e --- /dev/null +++ b/catanatron_rust/src/players/random_player.rs @@ -0,0 +1,16 @@ +use rand::prelude::*; + +use super::Player; +use crate::enums::Action; +use crate::state::State; + +pub struct RandomPlayer {} + +impl Player for RandomPlayer { + fn decide(&self, _state: &State, playable_actions: &[Action]) -> Action { + let mut rng = rand::thread_rng(); + *playable_actions + .choose(&mut rng) + .expect("There should always be at least one playable action") + } +} diff --git a/catanatron_rust/src/state.rs b/catanatron_rust/src/state.rs new file mode 100644 index 000000000..e74d6028b --- /dev/null +++ b/catanatron_rust/src/state.rs @@ -0,0 +1,579 @@ +use log::debug; +use std::{ + collections::{HashMap, HashSet}, + rc::Rc, +}; + +use crate::{ + enums::{ActionPrompt, GameConfiguration, MapType}, + global_state::GlobalState, + map_instance::{EdgeId, MapInstance, NodeId}, + state_vector::{ + actual_victory_points_index, initialize_state, player_devhand_slice, player_hand_slice, + player_played_devhand_slice, seating_order_slice, StateVector, BANK_RESOURCE_SLICE, + CURRENT_TICK_SEAT_INDEX, FREE_ROADS_AVAILABLE_INDEX, HAS_PLAYED_DEV_CARD, HAS_ROLLED_INDEX, + IS_DISCARDING_INDEX, IS_INITIAL_BUILD_PHASE_INDEX, IS_MOVING_ROBBER_INDEX, + ROBBER_TILE_INDEX, + }, +}; + +#[derive(Debug, Copy, Clone, PartialEq)] +pub enum Building { + Settlement(u8, NodeId), // Color, NodeId + City(u8, NodeId), // Color, NodeId +} + +#[derive(Debug)] +pub struct State { + // These two are immutable + config: Rc, + map_instance: Rc, + + // This is mutable + vector: StateVector, + + // These are caches for speeding up game state calculations + board_buildable_ids: HashSet, + buildings: HashMap, + buildings_by_color: HashMap>, // Color -> Buildings + roads: HashMap, // (Node1, Node2) -> Color + roads_by_color: Vec, // Color -> Count + connected_components: HashMap>>, + longest_road_color: Option, + longest_road_length: u8, + largest_army_color: Option, + largest_army_count: u8, +} + +mod move_application; +mod move_generation; + +impl State { + pub fn new(config: Rc, map_instance: Rc) -> Self { + debug!( + "State::new: config={:?}, num_players={}", + config, config.num_players + ); + + let vector = initialize_state(config.num_players); + debug!( + "State::new: vector initialized, length={}, seating_order={:?}", + vector.len(), + &vector[seating_order_slice(config.num_players as usize)] + ); + + let board_buildable_ids = map_instance.land_nodes().clone(); + let buildings = HashMap::new(); + let buildings_by_color = HashMap::new(); + let roads = HashMap::new(); + let roads_by_color = vec![0; config.num_players as usize]; + let mut connected_components = HashMap::new(); + for color in 0..config.num_players { + connected_components.insert(color, Vec::new()); + } + let longest_road_color = None; + let longest_road_length = 0; + let largest_army_color = None; + let largest_army_count = 0; + + Self { + config, + map_instance, + vector, + board_buildable_ids, + buildings, + buildings_by_color, + roads, + roads_by_color, + connected_components, + longest_road_color, + longest_road_length, + largest_army_color, + largest_army_count, + } + } + + pub fn new_base() -> Self { + let global_state = GlobalState::new(); + let config = GameConfiguration { + discard_limit: 7, + vps_to_win: 10, + map_type: MapType::Base, + num_players: 4, + max_ticks: 10, + }; + let map_instance = MapInstance::new( + &global_state.base_map_template, + &global_state.dice_probas, + 0, + ); + State::new(Rc::new(config), Rc::new(map_instance)) + } + + fn get_num_players(&self) -> u8 { + self.config.num_players + } + + // ===== Getters ===== + pub fn is_initial_build_phase(&self) -> bool { + self.vector[IS_INITIAL_BUILD_PHASE_INDEX] == 1 + } + + pub fn is_moving_robber(&self) -> bool { + self.vector[IS_MOVING_ROBBER_INDEX] == 1 + } + + pub fn is_discarding(&self) -> bool { + self.vector[IS_DISCARDING_INDEX] == 1 + } + + fn is_road_building(&self) -> bool { + self.vector[IS_INITIAL_BUILD_PHASE_INDEX] == 1 + && self.vector[FREE_ROADS_AVAILABLE_INDEX] == 1 + } + + /// Returns a slice of Colors in the order of seating + /// e.g. [2, 1, 0, 3] if Orange goes first, then Blue, then Red, and then White + pub fn get_seating_order(&self) -> &[u8] { + let slice = seating_order_slice(self.config.num_players as usize); + debug!( + "get_seating_order: num_players={}, slice={:?}, vector.len()={}", + self.config.num_players, + slice, + self.vector.len() + ); + + &self.vector[slice] + } + + pub fn get_current_tick_seat(&self) -> u8 { + self.vector[CURRENT_TICK_SEAT_INDEX] + } + + pub fn get_current_color(&self) -> u8 { + let seating_order = self.get_seating_order(); + let current_tick_seat = self.get_current_tick_seat(); + debug!( + "get_current_color: seating_order={:?}, current_tick_seat={}, seating_order.len()={}", + seating_order, + current_tick_seat, + seating_order.len() + ); + + if current_tick_seat as usize >= seating_order.len() { + debug!( + "ERROR: current_tick_seat {} is out of bounds for seating_order of length {}", + current_tick_seat, + seating_order.len() + ); + // Return a default value to avoid panic + return 0; + } + + seating_order[current_tick_seat as usize] + } + + pub fn current_player_rolled(&self) -> bool { + self.vector[HAS_ROLLED_INDEX] == 1 + } + + pub fn can_play_dev(&self, dev_card: u8) -> bool { + let color = self.get_current_color(); + let dev_card_index = dev_card as usize; + let has_one = + self.vector[player_devhand_slice(self.config.num_players, color)][dev_card_index] > 0; + let has_played_in_turn = self.vector[HAS_PLAYED_DEV_CARD] == 1; + has_one && !has_played_in_turn + } + + pub fn get_action_prompt(&self) -> ActionPrompt { + if self.is_initial_build_phase() { + let num_things_built = self.buildings.len() + self.roads.len() / 2; + if num_things_built == 4 * self.config.num_players as usize { + return ActionPrompt::PlayTurn; + } else if num_things_built % 2 == 0 { + return ActionPrompt::BuildInitialSettlement; + } else { + return ActionPrompt::BuildInitialRoad; + } + } else if self.is_moving_robber() { + return ActionPrompt::MoveRobber; + } else if self.is_discarding() { + return ActionPrompt::Discard; + } // TODO: Implement Trading Prompts (DecideTrade, DecideAcceptees) + ActionPrompt::PlayTurn + } + + // TODO: Maybe move to mutations(?) + pub fn get_mut_player_hand(&mut self, color: u8) -> &mut [u8] { + &mut self.vector[player_hand_slice(self.config.num_players, color)] + } + + pub fn get_player_hand(&self, color: u8) -> &[u8] { + &self.vector[player_hand_slice(self.config.num_players, color)] + } + + pub fn get_mut_player_devhand(&mut self, color: u8) -> &mut [u8] { + &mut self.vector[player_devhand_slice(self.config.num_players, color)] + } + + pub fn get_player_devhand(&self, color: u8) -> &[u8] { + &self.vector[player_devhand_slice(self.config.num_players, color)] + } + + pub fn winner(&self) -> Option { + let current_color = self.get_current_color(); + + let actual_victory_points = self.get_actual_victory_points(current_color); + if actual_victory_points >= self.config.vps_to_win { + return Some(current_color); + } + None + } + + pub fn get_actual_victory_points(&self, color: u8) -> u8 { + self.vector[actual_victory_points_index(self.config.num_players, color)] + } + + // ===== Board Getters ===== + pub fn get_cities(&self, color: u8) -> Vec { + let buildings = self.buildings_by_color.get(&color); + match buildings { + Some(buildings) => buildings + .iter() + .filter(|building| matches!(building, Building::City(_, _))) + .cloned() + .collect(), + None => vec![], + } + } + + pub fn get_settlements(&self, color: u8) -> Vec { + let buildings = self.buildings_by_color.get(&color); + match buildings { + Some(buildings) => buildings + .iter() + .filter(|building| matches!(building, Building::Settlement(_, _))) + .cloned() + .collect(), + None => vec![], + } + } + + // TODO: Potentially cache this implementation + pub fn board_buildable_edges(&self, color: u8) -> Vec { + let color_components = self.connected_components.get(&color).unwrap(); + let expandable_nodes: Vec = color_components + .iter() + .flat_map(|component| component.iter()) + .cloned() + .collect(); + + let mut buildable = HashSet::new(); + for node in expandable_nodes { + for edge in self.map_instance.get_neighbor_edges(node) { + if !self.roads.contains_key(&edge) { + let sorted_edge = (edge.0.min(edge.1), edge.0.max(edge.1)); + buildable.insert(sorted_edge); + } + } + } + buildable.into_iter().collect() + } + + pub fn buildable_node_ids(&self, color: u8) -> Vec { + let road_subgraphs = match self.connected_components.get(&color) { + Some(components) => components, + None => &vec![], + }; + + let mut road_connected_nodes: HashSet = HashSet::new(); + for component in road_subgraphs { + road_connected_nodes.extend(component); + } + + road_connected_nodes + .intersection(&self.board_buildable_ids) + .copied() + .collect() + } + + fn get_connected_component_index(&self, color: u8, a: u8) -> Option { + let components = self.connected_components.get(&color).unwrap(); + for (i, component) in components.iter().enumerate() { + if component.contains(&a) { + return Some(i); + } + } + None + } + + fn is_enemy_node(&self, color: u8, a: u8) -> bool { + let node_color = self.get_node_color(a); + match node_color { + None => false, + Some(node_color) => node_color != color, + } + } + + fn get_node_color(&self, a: u8) -> Option { + match self.buildings.get(&a) { + Some(Building::Settlement(color, _)) => Some(*color), + Some(Building::City(color, _)) => Some(*color), + None => None, + } + } + + fn edge_contains(&self, edge: EdgeId, a: u8) -> bool { + let (node1, node2) = edge; + node1 == a || node2 == a + } + + fn dfs_longest_path( + &self, + node: NodeId, + parent: Option, + connected_set: &HashSet, + color: u8, + current_path: &mut Vec, + best_path: &mut Vec, + ) { + // If current_path is longer than what we have, store it + if current_path.len() > best_path.len() { + *best_path = current_path.clone(); + } + + for &neighbor in &self.map_instance.get_neighbor_nodes(node) { + // Must be in the connected component + if !connected_set.contains(&neighbor) { + continue; + } + let edge = (node.min(neighbor), node.max(neighbor)); + + // Avoid going back to parent + if parent == Some(neighbor) { + continue; + } + // Skip roads not owned by us + if self.roads.get(&edge) != Some(&color) { + continue; + } + // Acyclic check + if current_path.contains(&edge) { + continue; + } + + // Move forward + current_path.push(edge); + self.dfs_longest_path( + neighbor, + Some(node), + connected_set, + color, + current_path, + best_path, + ); + current_path.pop(); + } + } + + pub fn longest_acyclic_path( + &self, + connected_node_set: &HashSet, + color: u8, + ) -> Vec { + if connected_node_set.is_empty() { + return vec![]; + } + + let mut overall_best_path = Vec::new(); + + for &start_node in connected_node_set { + let mut current_path = Vec::new(); + let mut best_path = Vec::new(); + + self.dfs_longest_path( + start_node, + None, + connected_node_set, + color, + &mut current_path, + &mut best_path, + ); + if best_path.len() > overall_best_path.len() { + overall_best_path = best_path; + } + } + overall_best_path + } + + pub fn add_dev_card(&mut self, color: u8, card_idx: usize) { + self.vector[player_devhand_slice(self.config.num_players, color)][card_idx] += 1; + } + + pub fn get_dev_card_count(&self, color: u8, card_idx: usize) -> u8 { + self.vector[player_devhand_slice(self.config.num_players, color)][card_idx] + } + + pub fn get_played_dev_card_count(&self, color: u8, card_idx: usize) -> u8 { + self.vector[player_played_devhand_slice(self.config.num_players, color)][card_idx] + } + + pub fn add_played_dev_card(&mut self, color: u8, card_idx: usize) { + self.vector[player_played_devhand_slice(self.config.num_players, color)][card_idx] += 1; + } + + pub fn remove_dev_card(&mut self, color: u8, card_idx: usize) { + self.vector[player_devhand_slice(self.config.num_players, color)][card_idx] -= 1; + } + + pub fn set_has_played_dev_card(&mut self) { + self.vector[HAS_PLAYED_DEV_CARD] = 1; + } + + pub fn set_is_moving_robber(&mut self) { + self.vector[IS_MOVING_ROBBER_INDEX] = 1; + } + + pub fn clear_is_moving_robber(&mut self) { + self.vector[IS_MOVING_ROBBER_INDEX] = 0; + } + + pub fn bank_has_resource(&self, resource: u8) -> bool { + self.vector[BANK_RESOURCE_SLICE][resource as usize] > 0 + } + + pub fn from_bank_to_player(&mut self, color: u8, resource: u8) { + let resource_idx = resource as usize; + self.vector[BANK_RESOURCE_SLICE][resource_idx] -= 1; + self.get_mut_player_hand(color)[resource_idx] += 1; + } + + pub fn from_player_to_bank(&mut self, color: u8, resource: u8, amount: u8) { + let resource_idx = resource as usize; + self.get_mut_player_hand(color)[resource_idx] -= amount; + self.vector[BANK_RESOURCE_SLICE][resource_idx] += amount; + } + + pub fn get_player_resource_count(&self, color: u8, resource: u8) -> u8 { + self.get_player_hand(color)[resource as usize] + } + + pub fn from_player_to_player( + &mut self, + from_color: u8, + to_color: u8, + resource: u8, + amount: u8, + ) { + let resource_idx = resource as usize; + self.get_mut_player_hand(from_color)[resource_idx] -= amount; + self.get_mut_player_hand(to_color)[resource_idx] += amount; + } + + pub fn get_robber_tile(&self) -> u8 { + self.vector[ROBBER_TILE_INDEX] + } + + pub fn set_robber_tile(&mut self, tile_id: u8) { + self.vector[ROBBER_TILE_INDEX] = tile_id; + } + + pub fn get_bank_resources(&self) -> &[u8] { + &self.vector[BANK_RESOURCE_SLICE] + } + + pub fn set_bank_resource(&mut self, resource_index: usize, count: u8) { + self.vector[BANK_RESOURCE_SLICE.start + resource_index] = count; + } + + /// Calculates effective production (considering robber) for a player + pub fn get_effective_production(&self, color: u8) -> Vec { + self.get_player_production_internal(color, true) + } + + /// Calculates total production (ignoring robber) for a player + pub fn get_total_production(&self, color: u8) -> Vec { + self.get_player_production_internal(color, false) + } + + fn get_player_production_internal(&self, color: u8, consider_robber: bool) -> Vec { + let mut production = vec![0.0; 5]; // One for each resource + let robber_tile = if consider_robber { + Some(self.get_robber_tile()) + } else { + None + }; + + // Get all buildings for this player + if let Some(buildings) = self.buildings_by_color.get(&color) { + for building in buildings { + let (node_id, multiplier) = match building { + Building::Settlement(_, node) => (*node, 1.0), + Building::City(_, node) => (*node, 2.0), + }; + + // Skip if robber is blocking this node + if let Some(robber_id) = robber_tile { + if let Some(adjacent_tiles) = self.map_instance.get_adjacent_tiles(node_id) { + if adjacent_tiles.iter().any(|tile| tile.id == robber_id) { + continue; + } + } + } + + // Get production for this node + if let Some(node_prod) = self.map_instance.get_node_production(node_id) { + for (resource, prob) in node_prod { + production[*resource as usize] += prob * multiplier; + } + } + } + } + + production + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_state_creation() { + let state = State::new_base(); + + assert_eq!(state.longest_road_color, None); + } + + #[test] + fn test_initial_build_phase() { + let state = State::new_base(); + + assert!(state.is_initial_build_phase()); + assert!(!state.is_moving_robber()); + assert!(!state.is_discarding()); + } + + #[test] + fn test_longest_acyclic_path() { + let mut state = State::new_base(); + let color = 0; + + state.roads.insert((0, 1), color); + state.roads.insert((1, 2), color); + state.roads.insert((2, 3), color); + state.roads.insert((3, 4), color); + state.roads.insert((4, 5), color); + state.roads.insert((0, 5), color); + state.roads.insert((0, 20), color); + state.roads.insert((20, 19), color); + state.roads.insert((20, 22), color); + state.roads.insert((22, 23), color); + state.roads.insert((6, 23), color); + + let all_nodes = HashSet::from([0, 1, 2, 3, 4, 5, 19, 20, 22, 23, 6]); + let path = state.longest_acyclic_path(&all_nodes, color); + assert_eq!(path.len(), 10); + } +} diff --git a/catanatron_rust/src/state/move_application.rs b/catanatron_rust/src/state/move_application.rs new file mode 100644 index 000000000..9ac9c411f --- /dev/null +++ b/catanatron_rust/src/state/move_application.rs @@ -0,0 +1,1756 @@ +use log::debug; +use std::collections::{HashMap, HashSet}; + +use rand::Rng; + +use crate::{ + deck_slices::*, + enums::{Action, DevCard}, + map_instance::{EdgeId, NodeId}, + state::Building, + state_vector::*, +}; + +use super::State; + +impl State { + pub fn apply_action(&mut self, action: Action) { + debug!("Applying action: {:?}", action); + debug!("Current state: current_color={}, action_prompt={:?}, is_initial_build_phase={}, buildings={}, roads={}", + self.get_current_color(), + self.get_action_prompt(), + self.is_initial_build_phase(), + self.buildings.len(), + self.roads.len() / 2 + ); + + match action { + Action::BuildSettlement { color, node_id } => { + debug!( + "Building settlement for player {} at node {}", + color, node_id + ); + let (new_owner, new_length) = self.build_settlement(color, node_id); + debug!( + "Settlement built. New longest road: owner={:?}, length={}", + new_owner, new_length + ); + self.maintain_longest_road(new_owner, new_length); + } + Action::BuildRoad { color, edge_id } => { + debug!( + "Building road for player {} at edge ({}, {})", + color, edge_id.0, edge_id.1 + ); + let (new_owner, new_length) = self.build_road(color, edge_id); + debug!( + "Road built. New longest road: owner={:?}, length={}", + new_owner, new_length + ); + self.maintain_longest_road(new_owner, new_length); + } + Action::BuildCity { color, node_id } => { + self.build_city(color, node_id); + } + Action::BuyDevelopmentCard { color } => { + self.buy_development_card(color); + } + Action::Roll { color, dice_opt } => { + self.roll_dice(color, dice_opt); + } + Action::Discard { color } => { + self.discard(color); + } + Action::MoveRobber { + color, + coordinate, + victim_opt, + } => { + self.move_robber(color, coordinate, victim_opt); + } + Action::PlayKnight { color } => { + self.play_knight(color); + self.maintain_largest_army(); + } + Action::PlayYearOfPlenty { color, resources } => { + self.play_year_of_plenty(color, resources); + } + Action::PlayMonopoly { color, resource } => { + self.play_monopoly(color, resource); + } + Action::PlayRoadBuilding { color } => { + self.play_road_building(color); + } + Action::MaritimeTrade { + color, + give, + take, + ratio, + } => { + self.maritime_trade(color, give, take, ratio); + } + Action::EndTurn { color } => { + self.end_turn(color); + } + _ => { + panic!("Action not implemented: {:?}", action); + } + } + + debug!("Finished applying action: {:?}", action); + debug!("Updated state: current_color={}, action_prompt={:?}, is_initial_build_phase={}, buildings={}, roads={}", + self.get_current_color(), + self.get_action_prompt(), + self.is_initial_build_phase(), + self.buildings.len(), + self.roads.len() / 2 + ); + } + + pub fn add_victory_points(&mut self, color: u8, points: u8) { + let n = self.get_num_players(); + self.vector[actual_victory_points_index(n, color)] += points; + } + + pub fn sub_victory_points(&mut self, color: u8, points: u8) { + let n = self.get_num_players(); + self.vector[actual_victory_points_index(n, color)] -= points; + } + + pub fn advance_turn(&mut self, step_size: i8) { + // We add an extra num_players to ensure next_index is positive (u8) + let num_players = self.get_num_players() as i8; + let next_index = + ((self.get_current_tick_seat() as i8 + step_size + num_players) % num_players) as u8; + + self.vector[CURRENT_TURN_SEAT_INDEX] = next_index; + self.vector[CURRENT_TICK_SEAT_INDEX] = next_index; + } + + pub fn build_settlement(&mut self, placing_color: u8, node_id: u8) -> (Option, u8) { + self.buildings + .insert(node_id, Building::Settlement(placing_color, node_id)); + self.buildings_by_color + .entry(placing_color) + .or_default() + .push(Building::Settlement(placing_color, node_id)); + + let is_free = self.is_initial_build_phase(); + if !is_free { + freqdeck_sub(self.get_mut_player_hand(placing_color), SETTLEMENT_COST); + freqdeck_add(&mut self.vector[BANK_RESOURCE_SLICE], SETTLEMENT_COST); + } + + self.add_victory_points(placing_color, 1); + + let mut road_lengths: HashMap = HashMap::new(); + + if is_free { + let owned_buildings = self.buildings_by_color.get(&placing_color).unwrap(); + let owned_settlements = owned_buildings + .iter() + .filter(|b| matches!(b, Building::Settlement(_, _))) + .count(); + + // If second house, yield resources + if owned_settlements == 2 { + let adjacent_tiles = self.map_instance.get_adjacent_tiles(node_id); + if let Some(adjacent_tiles) = adjacent_tiles { + let mut total_resources = [0; 5]; + for tile in adjacent_tiles { + if let Some(resource) = tile.resource { + total_resources[resource as usize] += 1; + } + } + + let bank = &mut self.vector[BANK_RESOURCE_SLICE]; + freqdeck_sub(bank, total_resources); + + let hand = self.get_mut_player_hand(placing_color); + freqdeck_add(hand, total_resources); + } + } + // Maintain caches and longest road ===== + // - connected_components + let component = HashSet::from([node_id]); + self.connected_components + .entry(placing_color) + .or_default() + .push(component); + } else { + // Mantain connected_components + // Mantain longest_road_color and longest_road_length + let mut plowed_edges_by_color: HashMap> = HashMap::new(); + for edge in self.map_instance.get_neighbor_edges(node_id) { + if let Some(&road_color) = self.roads.get(&edge) { + plowed_edges_by_color + .entry(road_color) + .or_default() + .push(edge); + } + } + + for (plowed_color, plowed_edges) in plowed_edges_by_color { + if plowed_edges.len() != 2 || plowed_color == placing_color { + continue; // Skip if no bisection/plow + } + + if let Some(plowed_component_idx) = + self.get_connected_component_index(plowed_color, node_id) + { + let outer_nodes: Vec = plowed_edges + .iter() + .map(|&edge| if edge.0 == node_id { edge.1 } else { edge.0 }) + .collect(); + + // First remove the bisected component + let road_components = self.connected_components.get_mut(&plowed_color).unwrap(); + road_components.remove(plowed_component_idx); + + let mut new_components = Vec::new(); + for outer_node in outer_nodes { + let new_component = self.dfs_walk(outer_node, plowed_color); + if !new_component.is_empty() { + new_components.push(new_component); + } + } + + let road_components = self.connected_components.get_mut(&plowed_color).unwrap(); + road_components.extend(new_components); + } + + // Insert the longest road length for all colors if a road was plowed + for (&color, components) in &self.connected_components { + let max_length = components + .iter() + .map(|component| self.longest_acyclic_path(component, color).len()) + .max() + .unwrap_or(0); + road_lengths.insert(color, max_length as u8); + } + } + } + // - board_buildable_ids + self.board_buildable_ids.remove(&node_id); + for neighbor_id in self.map_instance.get_neighbor_nodes(node_id) { + self.board_buildable_ids.remove(&neighbor_id); + } + + // Determine new longest road holder + let (new_road_color, new_road_length) = if road_lengths.is_empty() { + // If no road lengths affected, just return the previous longest road + (self.longest_road_color, self.longest_road_length) + } else { + let max_entry = road_lengths + .iter() + .filter(|(_, &len)| len >= 5) + .max_by_key(|(_, &len)| len); + + match max_entry { + Some((&color, &length)) => (Some(color), length), + None => (None, 0), // No player has >= 5 roads + } + }; + (new_road_color, new_road_length) + } + + fn build_road(&mut self, placing_color: u8, edge_id: EdgeId) -> (Option, u8) { + let inverted_edge = (edge_id.1, edge_id.0); + self.roads.insert(edge_id, placing_color); + self.roads.insert(inverted_edge, placing_color); + self.roads_by_color[placing_color as usize] += 1; + + let is_initial_build_phase = self.is_initial_build_phase(); + let is_free = is_initial_build_phase || self.is_road_building(); + if !is_free { + freqdeck_sub(self.get_mut_player_hand(placing_color), ROAD_COST); + freqdeck_add(&mut self.vector[BANK_RESOURCE_SLICE], ROAD_COST); + } + + if is_initial_build_phase { + let num_settlements = self.buildings.len(); + let num_players = self.config.num_players as usize; + let going_forward = num_settlements < num_players; + let at_midpoint = num_settlements == num_players; + + if going_forward { + self.advance_turn(1); + } else if at_midpoint { + // do nothing, generate prompt should take care + } else if num_settlements == 2 * num_players { + // just change prompt without advancing turn (since last to place is first to roll) + self.vector[IS_INITIAL_BUILD_PHASE_INDEX] = 0; + } else { + self.advance_turn(-1); + } + } + + // Maintain caches and longest road ===== + // Extend or merge components + let (a, b) = edge_id; + let a_index = self.get_connected_component_index(placing_color, a); + let b_index = self.get_connected_component_index(placing_color, b); + + // Make sure the connected_components for this color exists + self.connected_components.entry(placing_color).or_default(); + + // Update connected components based on the new road + let affected_component = + self.update_connected_components(placing_color, a, b, a_index, b_index); + + let prev_road_color = self.longest_road_color; + + // Calculate length for affected component + let path_length = self + .longest_acyclic_path(&affected_component, placing_color) + .len() as u8; + + let (new_road_color, new_road_length) = + if path_length >= 5 && path_length > self.longest_road_length { + (Some(placing_color), path_length) + } else { + (prev_road_color, self.longest_road_length) + }; + (new_road_color, new_road_length) + } + + /// Updates the road network when a new road is built + /// + /// This method maintains the connected components for a player's road network: + /// - Merges components when a road connects two previously separate networks + /// - Extends an existing component when a road connects to it + /// - Creates a new component for isolated roads + /// + /// The function also handles enemy settlements that would block connections. + /// + /// Returns the affected component that contains the new road. + fn update_connected_components( + &mut self, + placing_color: u8, + a: NodeId, + b: NodeId, + a_index: Option, + b_index: Option, + ) -> HashSet { + // Pre-compute node validity before mutable borrow + let a_valid = !self.is_enemy_node(placing_color, a); + let b_valid = !self.is_enemy_node(placing_color, b); + + // Get the components list for this color, creating it if it doesn't exist + let components = self.connected_components.entry(placing_color).or_default(); + + // Case 1: Both nodes are in components + if let (Some(a_idx), Some(b_idx)) = (a_index, b_index) { + if a_idx == b_idx { + // Both in same component - no change needed + return components[a_idx].clone(); + } + + // Merge components - always merge into the component with smaller index + // to minimize shifts in the vector + let (keep_idx, remove_idx) = if a_idx < b_idx { + (a_idx, b_idx) + } else { + (b_idx, a_idx) + }; + + let removed = components.remove(remove_idx); + components[keep_idx].extend(removed); + return components[keep_idx].clone(); + } + + // Case 2: Only one node is in a component - extend that component + if let Some(idx) = a_index.or(b_index) { + let component = &mut components[idx]; + + // Add the node that isn't in a component if it's valid + let new_node = if a_index.is_some() { b } else { a }; + let is_valid = if a_index.is_some() { b_valid } else { a_valid }; + + if is_valid { + component.insert(new_node); + } + + return component.clone(); + } + + // Case 3: Neither node is in a component - create a new one with valid nodes + let mut new_component = HashSet::new(); + if a_valid { + new_component.insert(a); + } + if b_valid { + new_component.insert(b); + } + + if !new_component.is_empty() { + components.push(new_component.clone()); + } + + new_component + } + + fn build_city(&mut self, color: u8, node_id: u8) { + self.buildings + .insert(node_id, Building::City(color, node_id)); + let buildings = self.buildings_by_color.entry(color).or_default(); + if let Some(pos) = buildings.iter().position(|b| { + if let Building::Settlement(_, n) = b { + *n == node_id + } else { + false + } + }) { + buildings.remove(pos); + } + freqdeck_sub(self.get_mut_player_hand(color), CITY_COST); + freqdeck_add(&mut self.vector[BANK_RESOURCE_SLICE], CITY_COST); + self.add_victory_points(color, 1); + } + + fn buy_development_card(&mut self, color: u8) -> Option { + // Get next card from deck + if let Some(card) = take_next_dev_card(&mut self.vector) { + // Pay for the card + freqdeck_sub(self.get_mut_player_hand(color), DEVCARD_COST); + freqdeck_add(&mut self.vector[BANK_RESOURCE_SLICE], DEVCARD_COST); + + let dev_card = match card { + 0 => DevCard::Knight, + 1 => DevCard::YearOfPlenty, + 2 => DevCard::Monopoly, + 3 => DevCard::RoadBuilding, + 4 => DevCard::VictoryPoint, + _ => panic!("Invalid dev card index"), + }; + + match dev_card { + DevCard::VictoryPoint => { + self.add_victory_points(color, 1); + } + _ => { + let dev_hand = + &mut self.vector[player_devhand_slice(self.config.num_players, color)]; + dev_hand[card as usize] += 1; + } + } + + Some(dev_card) + } else { + None + } + } + + fn roll_dice(&mut self, color: u8, dice_opt: Option<(u8, u8)>) { + self.vector[HAS_ROLLED_INDEX] = 1; + let (die1, die2) = dice_opt.unwrap_or_else(|| { + let mut rng = rand::thread_rng(); + (rng.gen_range(1..=6), rng.gen_range(1..=6)) + }); + let total = die1 + die2; + + if total == 7 { + self.handle_roll_seven(color); + } else { + self.distribute_roll_yields(total); + self.vector[CURRENT_TICK_SEAT_INDEX] = color; + } + } + + fn handle_roll_seven(&mut self, color: u8) { + // Check who needs to discard + let discarders: Vec = (0..self.get_num_players()) + .map(|c| { + let player_hand = self.get_player_hand(c); + let total_cards: u8 = player_hand.iter().sum(); + total_cards > self.config.discard_limit + }) + .collect(); + + let should_enter_discard_phase = discarders.iter().any(|&x| x); + if should_enter_discard_phase { + if let Some(first_discarder) = discarders.iter().position(|&x| x) { + self.vector[CURRENT_TICK_SEAT_INDEX] = first_discarder as u8; + self.vector[IS_DISCARDING_INDEX] = 1; + } + } else { + self.vector[IS_MOVING_ROBBER_INDEX] = 1; + self.vector[CURRENT_TICK_SEAT_INDEX] = color; + } + } + + // Returns Vec of (color, resource_index, amount) tuples for what each player should receive + fn collect_roll_yields(&self, roll: u8) -> Vec<(u8, usize, u8)> { + let mut all_yields = Vec::new(); + let matching_tiles = self.map_instance.get_tiles_by_number(roll); + + for tile in matching_tiles { + // Skip robber tile + if self.get_robber_tile() == tile.id { + continue; + } + + if let Some(resource) = tile.resource { + let resource_idx = resource as usize; + // Collect all yields for this tile + for &node_id in tile.hexagon.nodes.values() { + if let Some(building) = self.buildings.get(&node_id) { + match building { + Building::Settlement(owner_color, _) => { + all_yields.push((*owner_color, resource_idx, 1)); + } + Building::City(owner_color, _) => { + all_yields.push((*owner_color, resource_idx, 2)); + } + } + } + } + } + } + all_yields + } + + fn distribute_roll_yields(&mut self, roll: u8) { + let yields = self.collect_roll_yields(roll); + if yields.is_empty() { + return; + } + + debug!("Roll {} yields: {:?}", roll, yields); + + // Calculate total needed by resource type + let mut resource_needs = [0u8; 5]; + for (_, resource_idx, amount) in &yields { + resource_needs[*resource_idx] += amount; + } + + // Check what can be allocated from bank + let bank = &self.vector[BANK_RESOURCE_SLICE]; + debug!( + "Current bank: [{}, {}, {}, {}, {}]", + bank[0], bank[1], bank[2], bank[3], bank[4] + ); + debug!("Total resource needs: {:?}", resource_needs); + + // For each resource type, determine if multiple players need it + let mut resource_recipients = [Vec::new(), Vec::new(), Vec::new(), Vec::new(), Vec::new()]; + for (color, resource_idx, _) in &yields { + if !resource_recipients[*resource_idx].contains(color) { + resource_recipients[*resource_idx].push(*color); + } + } + + // Determine which resources can be distributed + let mut can_distribute = [true; 5]; + for i in 0..5 { + if bank[i] < resource_needs[i] { + // Resource is insufficient + if resource_recipients[i].len() > 1 { + // Multiple players need this resource - no one gets it + debug!( + "Resource {}: insufficient for multiple recipients ({}), no one gets it", + i, + resource_recipients[i].len() + ); + can_distribute[i] = false; + } else { + debug!("Resource {}: insufficient for single recipient, will distribute what's available", i); + // Single player - they get what's available (handled during distribution) + } + } + } + + // Make a copy of bank resources to track what's distributed + let mut remaining = [0u8; 5]; + remaining.copy_from_slice(&bank[..5]); + + // Distribute resources according to the rules + for (owner_color, resource_idx, amount) in yields { + if !can_distribute[resource_idx] { + // Skip resources that can't be distributed + continue; + } + + // Calculate how much to give (either full amount or what's available) + let available = remaining[resource_idx].min(amount); + if available > 0 { + // Update tracking of what's left + remaining[resource_idx] -= available; + + // Update actual game state + self.vector[BANK_RESOURCE_SLICE][resource_idx] -= available; + self.get_mut_player_hand(owner_color)[resource_idx] += available; + + debug!( + "Distributed {} of resource {} to player {}", + available, resource_idx, owner_color + ); + } + } + + debug!( + "Bank after distribution: {:?}", + &self.vector[BANK_RESOURCE_SLICE] + ); + } + + /* + * TODO: For now, we're not letting players choose what to discard, to avoid + * the combinatorial explosion of possibilities. Instead, we'll just + * force discards in a way that maximizes resource diversity. + */ + fn discard(&mut self, color: u8) { + let mut remaining_hand = self.get_player_hand(color).to_vec(); + let total_cards: u8 = remaining_hand.iter().sum(); + let mut to_discard = total_cards - (total_cards / 2); + let mut discarded = [0u8; 5]; + + while to_discard > 0 { + // Find highest frequency resources + let max_count = *remaining_hand.iter().max().unwrap(); + let max_indices: Vec<_> = (0..5).filter(|&i| remaining_hand[i] == max_count).collect(); + + // Take one card from each highest frequency resource + for &i in &max_indices { + if to_discard > 0 { + remaining_hand[i] -= 1; + discarded[i] += 1; + to_discard -= 1; + } + } + } + + freqdeck_sub(self.get_mut_player_hand(color), discarded); + freqdeck_add(&mut self.vector[BANK_RESOURCE_SLICE], discarded); + self.vector[IS_DISCARDING_INDEX] = 0; + // TODO: Advance turn; handle discarders left and pass turn to original roller + } + + fn move_robber(&mut self, color: u8, coordinate: (i8, i8, i8), victim_opt: Option) { + self.set_robber_tile(self.map_instance.get_land_tile(coordinate).unwrap().id); + + if let Some(victim) = victim_opt { + let total_cards: u8 = self.get_player_hand(victim).iter().sum(); + + if total_cards > 0 { + // Randomly select card to steal + let mut rng = rand::thread_rng(); + let selected_idx = rng.gen_range(0..total_cards); + + let mut cumsum = 0; + let mut stolen_resource_idx = 0; + for (i, &count) in self.get_player_hand(victim).iter().enumerate() { + cumsum += count; + if selected_idx < cumsum { + stolen_resource_idx = i; + break; + } + } + + let mut stolen_freqdeck = [0; 5]; + stolen_freqdeck[stolen_resource_idx] = 1; + freqdeck_sub(self.get_mut_player_hand(victim), stolen_freqdeck); + freqdeck_add(self.get_mut_player_hand(color), stolen_freqdeck); + } + } + self.vector[IS_MOVING_ROBBER_INDEX] = 0; + } + + fn maintain_longest_road(&mut self, new_owner: Option, new_length: u8) { + let prev_owner = self.longest_road_color; + self.longest_road_color = new_owner; + self.longest_road_length = new_length; + + if new_owner == prev_owner { + return; + } + + if let Some(prev_owner) = prev_owner { + self.sub_victory_points(prev_owner, 2); + } + + if let Some(new_owner) = new_owner { + self.add_victory_points(new_owner, 2); + } + } + + fn dfs_walk(&self, start_node: NodeId, color: u8) -> HashSet { + let mut agenda = vec![start_node]; + let mut visited = HashSet::new(); + + while let Some(node) = agenda.pop() { + if visited.contains(&node) { + continue; + } + visited.insert(node); + + if self.is_enemy_node(color, node) { + continue; + } + + for neighbor in self.map_instance.get_neighbor_nodes(node) { + let edge = (node.min(neighbor), node.max(neighbor)); + if self.roads.get(&edge) == Some(&color) { + agenda.push(neighbor); + } + } + } + visited + } + + fn play_knight(&mut self, color: u8) { + // Mark card as played + self.remove_dev_card(color, DevCard::Knight as usize); + self.add_played_dev_card(color, DevCard::Knight as usize); + self.set_has_played_dev_card(); + + // Set state to move robber + self.set_is_moving_robber(); + } + + fn maintain_largest_army(&mut self) { + let prev_owner = self.largest_army_color; + let prev_count = self.largest_army_count; + + // Find player with most knights (if any have 3 or more) + let mut max_knights = 0; + let mut max_knights_color = None; + + for color in 0..self.get_num_players() { + let knights = self.get_played_dev_card_count(color, DevCard::Knight as usize); + if knights >= 3 && knights > max_knights { + max_knights = knights; + max_knights_color = Some(color); + } + } + + // Case where playerB meets playerA's largest army -> no change + if max_knights == prev_count { + return; + } + + self.largest_army_color = max_knights_color; + self.largest_army_count = max_knights; + + // If playerA retains largest army -> no VP changes + if max_knights_color == prev_owner { + return; + } + + if let Some(prev_owner) = prev_owner { + self.sub_victory_points(prev_owner, 2); + } + + if let Some(new_owner) = max_knights_color { + self.add_victory_points(new_owner, 2); + } + } + + fn play_year_of_plenty(&mut self, color: u8, resources: (u8, Option)) { + // Assume move_generation has already checked that player has year of plenty card + // and that bank has enough resources + self.remove_dev_card(color, DevCard::YearOfPlenty as usize); + self.add_played_dev_card(color, DevCard::YearOfPlenty as usize); + self.set_has_played_dev_card(); + + // Give first resource to player + self.from_bank_to_player(color, resources.0); + + // Give second resource if specified + if let Some(resource2) = resources.1 { + self.from_bank_to_player(color, resource2); + } + } + + fn play_monopoly(&mut self, color: u8, resource: u8) { + // Assume move_generation has already checked that player has monopoly card. + self.remove_dev_card(color, DevCard::Monopoly as usize); + self.add_played_dev_card(color, DevCard::Monopoly as usize); + self.set_has_played_dev_card(); + + // Steal all resources of type from other players + for victim_color in 0..self.get_num_players() { + if victim_color != color { + let amount = self.get_player_resource_count(victim_color, resource); + if amount > 0 { + self.from_player_to_player(victim_color, color, resource, amount); + } + } + } + } + + fn play_road_building(&mut self, color: u8) { + // Assume move_generation has already checked that player has road building card. + self.remove_dev_card(color, DevCard::RoadBuilding as usize); + self.add_played_dev_card(color, DevCard::RoadBuilding as usize); + self.set_has_played_dev_card(); + + // Set state for free roads + self.vector[IS_BUILDING_ROAD_INDEX] = 1; + self.vector[FREE_ROADS_AVAILABLE_INDEX] = 2; + } + + fn maritime_trade(&mut self, color: u8, give: u8, take: u8, ratio: u8) { + // Assume move_generation has already checked that player has enough resources + // to give and that bank has enough resources to take + self.from_player_to_bank(color, give, ratio); + self.from_bank_to_player(color, take); + } + + fn end_turn(&mut self, _color: u8) { + self.vector[HAS_PLAYED_DEV_CARD] = 0; + self.vector[HAS_ROLLED_INDEX] = 0; + + self.advance_turn(1); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_build_settlement_initial_build_phase() { + let mut state = State::new_base(); + let color = state.get_current_color(); + assert_eq!(state.buildings.get(&0), None); + assert_eq!(state.board_buildable_ids.len(), 54); + assert_eq!(state.get_actual_victory_points(color), 0); + + let node_id = 0; + state.build_settlement(color, node_id); + + assert_eq!( + state.buildings.get(&node_id), + Some(&Building::Settlement(color, node_id)) + ); + assert_eq!(state.board_buildable_ids.len(), 50); + assert_eq!(state.get_actual_victory_points(color), 1); + } + + #[test] + fn test_build_settlement_spends_resources() { + let mut state = State::new_base(); + let color = state.get_current_color(); + assert_eq!(state.buildings.get(&0), None); + assert_eq!(state.board_buildable_ids.len(), 54); + assert_eq!(state.get_actual_victory_points(color), 0); + + // Exit initial build phase + state.vector[IS_INITIAL_BUILD_PHASE_INDEX] = 0; + + freqdeck_add(state.get_mut_player_hand(color), SETTLEMENT_COST); + let hand_before = state.get_player_hand(color).to_vec(); + + let node_id = 0; + state.build_settlement(color, node_id); + + assert_eq!( + state.buildings.get(&node_id), + Some(&Building::Settlement(color, node_id)) + ); + assert_eq!(state.board_buildable_ids.len(), 50); + assert_eq!(state.get_actual_victory_points(color), 1); + + let hand_after = state.get_player_hand(color); + for i in 0..5 { + assert_eq!(hand_after[i], hand_before[i] - SETTLEMENT_COST[i]); + } + } + + #[test] + fn test_roll_seven_triggers_discard() { + let mut state = State::new_base(); + let color = state.get_current_color(); + + { + let hand = state.get_mut_player_hand(color); + hand[0] = 8; // Give 8 wood cards + } + + state.roll_dice(color, Some((4, 3))); + + assert_eq!(state.vector[HAS_ROLLED_INDEX], 1); + assert_eq!(state.vector[IS_DISCARDING_INDEX], 1); + assert_eq!(state.vector[CURRENT_TICK_SEAT_INDEX], color); + assert_eq!(state.vector[IS_MOVING_ROBBER_INDEX], 0); + } + + #[test] + fn test_roll_seven_no_discard_needed() { + let mut state = State::new_base(); + let color = state.get_current_color(); + + state.roll_dice(color, Some((4, 3))); + + assert_eq!(state.vector[HAS_ROLLED_INDEX], 1); + assert_eq!(state.vector[IS_DISCARDING_INDEX], 0); + assert_eq!(state.vector[CURRENT_TICK_SEAT_INDEX], color); + assert_eq!(state.vector[IS_MOVING_ROBBER_INDEX], 1); + } + + #[test] + fn test_roll_tracks_has_rolled() { + let mut state = State::new_base(); + let color = state.get_current_color(); + + assert_eq!(state.vector[HAS_ROLLED_INDEX], 0); + state.roll_dice(color, Some((2, 3))); + assert_eq!(state.vector[HAS_ROLLED_INDEX], 1); + } + + #[test] + fn test_second_settlement_yields_resources() { + let mut state = State::new_base(); + let color = state.get_current_color(); + let first_node = 0; + let bank_before = state.vector[BANK_RESOURCE_SLICE].to_vec(); + let hand_before = state.get_player_hand(color).to_vec(); + + state.build_settlement(color, first_node); + + assert_eq!(state.get_player_hand(color), hand_before); + assert_eq!(state.vector[BANK_RESOURCE_SLICE], bank_before); + + let second_node = 3; + let bank_before = state.vector[BANK_RESOURCE_SLICE].to_vec(); + let hand_before = state.get_player_hand(color).to_vec(); + + state.build_settlement(color, second_node); + + assert_ne!(state.get_player_hand(color), hand_before); + assert_ne!(state.vector[BANK_RESOURCE_SLICE], bank_before); + + for i in 0..5 { + let bank_diff = bank_before[i] - state.vector[BANK_RESOURCE_SLICE][i]; + let hand_diff = state.get_player_hand(color)[i] - hand_before[i]; + assert_eq!(bank_diff, hand_diff); + } + } + + #[test] + fn test_settlement_cuts_longest_road() { + let mut state = State::new_base(); + let color1 = 1; + let color2 = 2; + + // give color1 6 consecutive roads + state.apply_action(Action::BuildSettlement { + color: color1, + node_id: 0, + }); + for edge in [(0, 1), (1, 2), (2, 3), (3, 4), (4, 5), (5, 16)] { + state.apply_action(Action::BuildRoad { + color: color1, + edge_id: edge, + }); + } + + assert_eq!(state.longest_road_color, Some(color1)); + assert_eq!(state.get_actual_victory_points(color1), 3); + assert_eq!(state.get_actual_victory_points(color2), 0); + + // Give color2 a settlement at node 4 to bisect color1's Longest Road + state.vector[IS_INITIAL_BUILD_PHASE_INDEX] = 0; + freqdeck_add(state.get_mut_player_hand(color2), SETTLEMENT_COST); + state.apply_action(Action::BuildSettlement { + color: color2, + node_id: 4, + }); + + assert_eq!(state.longest_road_color, None); + assert_eq!(state.get_actual_victory_points(color1), 1); + assert_eq!(state.get_actual_victory_points(color2), 1); + } + + #[test] + fn test_build_road_maintains_connected_components() { + let mut state = State::new_base(); + let color1 = 1; + + state.build_settlement(color1, 0); + state.build_road(color1, (0, 1)); + + let components = state.connected_components.get(&color1).unwrap(); + assert_eq!(components.len(), 1); + assert_eq!(components[0], HashSet::from([0, 1])); + + state.build_road(color1, (1, 2)); + + let components = state.connected_components.get(&color1).unwrap(); + assert_eq!(components.len(), 1); + assert_eq!(components[0], HashSet::from([0, 1, 2])); + + state.build_settlement(color1, 4); + state.build_road(color1, (3, 4)); + + let components = state.connected_components.get(&color1).unwrap(); + assert_eq!(components.len(), 2); + assert_eq!(components[0], HashSet::from([0, 1, 2])); + assert_eq!(components[1], HashSet::from([3, 4])); + + state.build_road(color1, (2, 3)); + + let components = state.connected_components.get(&color1).unwrap(); + assert_eq!(components.len(), 1); + assert_eq!(components[0], HashSet::from([0, 1, 2, 3, 4])); + } + + #[test] + fn test_settlement_cuts_longest_road_and_transfers() { + let mut state = State::new_base(); + let color1 = 1; + let color2 = 2; + + // give color1 6 consecutive roads + state.apply_action(Action::BuildSettlement { + color: color1, + node_id: 0, + }); + for edge in [(0, 1), (1, 2), (2, 3), (3, 4), (4, 5), (5, 16)] { + state.apply_action(Action::BuildRoad { + color: color1, + edge_id: edge, + }); + } + // Give color2 5 consecutive roads with potential to bisect/plow color1's road + state.apply_action(Action::BuildSettlement { + color: color2, + node_id: 11, + }); + for edge in [(11, 12), (12, 13), (13, 14), (14, 15), (4, 15)] { + state.apply_action(Action::BuildRoad { + color: color2, + edge_id: edge, + }); + } + + assert_eq!(state.longest_road_color, Some(color1)); + assert_eq!(state.get_actual_victory_points(color1), 3); + assert_eq!(state.get_actual_victory_points(color2), 1); + + // Give color2 a settlement at node 4 to bisect color1's Longest Road + state.vector[IS_INITIAL_BUILD_PHASE_INDEX] = 0; + freqdeck_add(state.get_mut_player_hand(color2), SETTLEMENT_COST); + state.apply_action(Action::BuildSettlement { + color: color2, + node_id: 4, + }); + + assert_eq!(state.longest_road_color, Some(color2)); + assert_eq!(state.get_actual_victory_points(color1), 1); + assert_eq!(state.get_actual_victory_points(color2), 4); + } + + #[test] + fn test_extend_own_longest_road() { + let mut state = State::new_base(); + let color1 = 1; + + state.apply_action(Action::BuildSettlement { + color: color1, + node_id: 0, + }); + for edge in [(0, 1), (1, 2), (2, 3), (3, 4), (4, 5)] { + state.apply_action(Action::BuildRoad { + color: color1, + edge_id: edge, + }); + } + + assert_eq!(state.longest_road_color, Some(color1)); + assert_eq!(state.longest_road_length, 5); + assert_eq!(state.get_actual_victory_points(color1), 3); + + state.apply_action(Action::BuildRoad { + color: color1, + edge_id: (5, 16), + }); + + assert_eq!(state.longest_road_color, Some(color1)); + assert_eq!(state.longest_road_length, 6); + assert_eq!(state.get_actual_victory_points(color1), 3); + } + + #[test] + fn test_bisection_counts_remaining_components() { + let mut state = State::new_base(); + let color1 = 1; + let color2 = 2; + + state.apply_action(Action::BuildSettlement { + color: color1, + node_id: 0, + }); + for edge in [(0, 1), (1, 2), (2, 3), (3, 4), (4, 5), (5, 16)] { + state.apply_action(Action::BuildRoad { + color: color1, + edge_id: edge, + }); + } + + assert_eq!(state.longest_road_color, Some(color1)); + assert_eq!(state.longest_road_length, 6); + assert_eq!(state.get_actual_victory_points(color1), 3); + + state.vector[IS_INITIAL_BUILD_PHASE_INDEX] = 0; + freqdeck_add(state.get_mut_player_hand(color2), SETTLEMENT_COST); + state.apply_action(Action::BuildSettlement { + color: color2, + node_id: 5, + }); + + assert_eq!(state.longest_road_color, Some(color1)); + assert_eq!(state.longest_road_length, 5); + assert_eq!(state.connected_components.get(&color1).unwrap().len(), 2); + assert_eq!(state.get_actual_victory_points(color1), 3); + assert_eq!(state.get_actual_victory_points(color2), 1); + } + + #[test] + fn test_buy_development_cards() { + let mut state = State::new_base(); + let color = state.get_current_color(); + let mut cards_drawn = 0; + + while cards_drawn < 26 { + freqdeck_add(state.get_mut_player_hand(color), DEVCARD_COST); + let initial_hand: [u8; 5] = state.get_player_hand(color).try_into().unwrap(); + let initial_devhand = state.get_player_devhand(color).to_vec(); + let initial_bank = state.vector[BANK_RESOURCE_SLICE].to_vec(); + let initial_vps = state.get_actual_victory_points(color); + + let drawn_card = state.buy_development_card(color); + cards_drawn += 1; + + println!("Cards Drawn: {}, Drawn card: {:?}", cards_drawn, drawn_card); + + if cards_drawn < 26 { + let hand_after = state.get_player_hand(color); + let bank_after = &state.vector[BANK_RESOURCE_SLICE]; + for i in 0..5 { + assert_eq!(hand_after[i], initial_hand[i] - DEVCARD_COST[i]); + assert_eq!(bank_after[i], initial_bank[i] + DEVCARD_COST[i]); + } + let devhand_after = state.get_player_devhand(color); + + if drawn_card == Some(DevCard::VictoryPoint) { + // VP added, devhand not incremented + assert_eq!(state.get_actual_victory_points(color), initial_vps + 1); + assert_eq!( + devhand_after[drawn_card.unwrap() as usize], + initial_devhand[drawn_card.unwrap() as usize] + ); + } else { + // VP not added, devhand incremented + assert_eq!(state.get_actual_victory_points(color), initial_vps); + assert_eq!( + devhand_after[drawn_card.unwrap() as usize], + initial_devhand[drawn_card.unwrap() as usize] + 1 + ); + } + } else { + // 26th card should not be drawn + assert!(drawn_card.is_none()); + assert_eq!(state.get_player_hand(color), initial_hand); + assert_eq!(&state.vector[BANK_RESOURCE_SLICE], initial_bank); + } + } + } + + #[test] + fn test_roll_yields_resources() { + let mut state = State::new_base(); + let color = state.get_current_color(); + + state.build_settlement(color, 0); + + let adjacent_tiles = state.map_instance.get_adjacent_tiles(0).unwrap(); + + let mut chosen_roll = None; + let mut expected_resource_yields = [0; 5]; + + for tile in adjacent_tiles.iter() { + if let (Some(number), Some(resource)) = (tile.number, tile.resource) { + // First valid number we find will be our roll + // Don't pick robber tile + if tile.id != state.get_robber_tile() { + if chosen_roll.is_none() { + chosen_roll = Some(number); + } + + if Some(number) == chosen_roll { + expected_resource_yields[resource as usize] += 1; + } + } + } + } + + let initial_bank = state.vector[BANK_RESOURCE_SLICE].to_vec(); + let initial_hand = state.get_player_hand(color).to_vec(); + // Roll numbers should sum to chosen_roll + let roll_numbers = (chosen_roll.unwrap() / 2, (chosen_roll.unwrap() + 1) / 2); + + state.apply_action(Action::Roll { + color, + dice_opt: Some(roll_numbers), + }); + + for resource_idx in 0..5 { + assert_eq!( + state.vector[BANK_RESOURCE_SLICE][resource_idx], + initial_bank[resource_idx] - expected_resource_yields[resource_idx], + "Bank should have {} fewer resource of {:?}", + expected_resource_yields[resource_idx], + resource_idx + ); + assert_eq!( + state.get_player_hand(color)[resource_idx], + initial_hand[resource_idx] + expected_resource_yields[resource_idx], + "Player should have {} more resource of {:?}", + expected_resource_yields[resource_idx], + resource_idx + ) + } + } + + #[test] + fn test_roll_city_yields_double() { + let mut state = State::new_base(); + let color = state.get_current_color(); + + freqdeck_add(state.get_mut_player_hand(color), CITY_COST); + state.build_settlement(color, 0); + state.build_city(color, 0); + + let adjacent_tiles = state.map_instance.get_adjacent_tiles(0).unwrap(); + + let mut chosen_roll = None; + let mut expected_resource_yields = [0; 5]; + + for tile in adjacent_tiles.iter() { + if let (Some(number), Some(resource)) = (tile.number, tile.resource) { + // Don't pick robber tile + if tile.id != state.get_robber_tile() { + if chosen_roll.is_none() { + chosen_roll = Some(number); + } + + if Some(number) == chosen_roll { + expected_resource_yields[resource as usize] += 2; + } + } + } + } + + let initial_bank = state.vector[BANK_RESOURCE_SLICE].to_vec(); + let initial_hand = state.get_player_hand(color).to_vec(); + // Roll numbers should sum to chosen_roll + let roll_numbers = (chosen_roll.unwrap() / 2, (chosen_roll.unwrap() + 1) / 2); + + state.apply_action(Action::Roll { + color, + dice_opt: Some(roll_numbers), + }); + + for resource_idx in 0..5 { + assert_eq!( + state.vector[BANK_RESOURCE_SLICE][resource_idx], + initial_bank[resource_idx] - expected_resource_yields[resource_idx], + "Bank should have {} fewer resource of {:?}", + expected_resource_yields[resource_idx], + resource_idx + ); + assert_eq!( + state.get_player_hand(color)[resource_idx], + initial_hand[resource_idx] + expected_resource_yields[resource_idx], + "Player should have {} more resource of {:?}", + expected_resource_yields[resource_idx], + resource_idx + ); + } + } + + #[test] + fn test_roll_single_player_partial_payment_when_insufficient_bank() { + let mut state = State::new_base(); + let color = state.get_current_color(); + + let node_id = 0; + state.build_settlement(color, node_id); + freqdeck_add(state.get_mut_player_hand(color), CITY_COST); + state.build_city(color, node_id); + + let adjacent_tiles = state.map_instance.get_adjacent_tiles(node_id).unwrap(); + + let mut chosen_roll = None; + let mut chosen_resource = None; + + for tile in adjacent_tiles.iter() { + if let (Some(number), Some(resource)) = (tile.number, tile.resource) { + if tile.id != state.get_robber_tile() && chosen_roll.is_none() { + chosen_roll = Some(number); + chosen_resource = Some(resource); + } + } + } + assert!(chosen_roll.is_some(), "Should find at least one valid tile"); + + for i in 0..5 { + state.vector[BANK_RESOURCE_SLICE][i] = 1; + } + let hand_before = state.get_player_hand(color).to_vec(); + + let roll = chosen_roll.unwrap(); + let roll_numbers = (roll / 2, (roll + 1) / 2); + state.roll_dice(color, Some(roll_numbers)); + + let chosen_resource_idx = chosen_resource.unwrap() as usize; + assert_eq!(state.vector[BANK_RESOURCE_SLICE][chosen_resource_idx], 0); + + assert_eq!( + state.get_player_hand(color)[chosen_resource_idx], + hand_before[chosen_resource_idx] + 1 + ); + assert_eq!(state.vector[BANK_RESOURCE_SLICE][chosen_resource_idx], 0) + } + + #[test] + fn test_roll_multiple_player_no_payment_when_insufficient_bank() { + let mut state = State::new_base(); + let color1 = 1; + let color2 = 2; + + let (resource, number, node1, node2) = { + let tile = state + .map_instance + .get_land_tiles() + .values() + .find(|tile| { + tile.resource.is_some() && // Not a desert + tile.id != state.get_robber_tile() // Not under robber + }) + .expect("Should be at least one valid tile"); + + let node_ids: Vec<_> = tile.hexagon.nodes.values().take(2).copied().collect(); + + ( + tile.resource.unwrap(), + tile.number.unwrap(), + node_ids[0], + node_ids[1], + ) + }; + + // Place two opposing cities on a shared tile with expected yields + state.build_settlement(color1, node1); + state.build_settlement(color2, node2); + freqdeck_add(state.get_mut_player_hand(color1), CITY_COST); + freqdeck_add(state.get_mut_player_hand(color2), CITY_COST); + state.build_city(color1, node1); + state.build_city(color2, node2); + + // Set bank to have only 1 of the needed resource + let resource_idx = resource as usize; + state.vector[BANK_RESOURCE_SLICE][resource_idx] = 1; + + let bank_before = state.vector[BANK_RESOURCE_SLICE][resource_idx]; + let hand1_before = state.get_player_hand(color1)[resource_idx]; + let hand2_before = state.get_player_hand(color2)[resource_idx]; + + // Roll the shared tile's number + let roll_numbers = (number / 2, (number + 1) / 2); + state.roll_dice(color1, Some(roll_numbers)); + + assert_eq!( + state.vector[BANK_RESOURCE_SLICE][resource_idx], bank_before, + "Bank should be unchanged" + ); + // Neither player should get any resources + assert_eq!( + state.get_player_hand(color1)[resource_idx], + hand1_before, + "Player 1 should not receive resources" + ); + assert_eq!( + state.get_player_hand(color2)[resource_idx], + hand2_before, + "Player 2 should not receive resources" + ); + } + + #[test] + fn test_discard() { + let mut state = State::new_base(); + let color = state.get_current_color(); + + // Give the player a known distribution of 17 cards + freqdeck_add(state.get_mut_player_hand(color), [3, 9, 1, 3, 1]); + + let bank_before = state.vector[BANK_RESOURCE_SLICE].to_vec(); + + state.discard(color); + + // After discarding, the player should have half => 17 / 2 = 8. + let total_after: u8 = state.get_player_hand(color).iter().sum(); + assert_eq!(total_after, 8, "Player should have exactly 8 cards left."); + + // Verify discard phase ended + assert_eq!( + state.vector[IS_DISCARDING_INDEX], 0, + "Discard phase should end." + ); + + // The bank should have received exactly 6 more cards in total + let bank_after = &state.vector[BANK_RESOURCE_SLICE]; + let mut total_discarded = 0; + for i in 0..5 { + total_discarded += bank_after[i] - bank_before[i]; + } + assert_eq!( + total_discarded, 9, + "Exactly 9 cards should have been added to the bank." + ); + + // Check the specific distribution after discard + let final_player_hand = state.get_player_hand(color); + assert_eq!( + final_player_hand, + &[2, 2, 1, 2, 1], + "Discard logic should spread discards across highest-frequency resources first." + ); + } + + #[test] + fn test_play_knight() { + let mut state = State::new_base(); + let color = state.get_current_color(); + + state.add_dev_card(color, DevCard::Knight as usize); + assert_eq!(state.get_dev_card_count(color, DevCard::Knight as usize), 1); + assert_eq!( + state.get_played_dev_card_count(color, DevCard::Knight as usize), + 0 + ); + assert_eq!(state.vector[HAS_PLAYED_DEV_CARD], 0); + assert_eq!(state.vector[IS_MOVING_ROBBER_INDEX], 0); + + state.play_knight(color); + + assert_eq!(state.get_dev_card_count(color, DevCard::Knight as usize), 0); + assert_eq!( + state.get_played_dev_card_count(color, DevCard::Knight as usize), + 1 + ); + assert_eq!(state.vector[HAS_PLAYED_DEV_CARD], 1); + assert_eq!(state.vector[IS_MOVING_ROBBER_INDEX], 1); + } + + #[test] + fn test_play_knight_largest_army() { + let mut state = State::new_base(); + let color1 = 1; + let color2 = 2; + + // Give first player 3 knight cards + for _ in 0..3 { + state.add_dev_card(color1, DevCard::Knight as usize); + } + + // Play knights and verify largest army + for i in 0..3 { + state.vector[HAS_PLAYED_DEV_CARD] = 0; // Reset for each turn + state.apply_action(Action::PlayKnight { color: color1 }); + + // Verify knight was removed and marked as played + assert_eq!( + state.get_dev_card_count(color1, DevCard::Knight as usize), + 2 - i + ); + assert_eq!( + state.get_played_dev_card_count(color1, DevCard::Knight as usize), + i + 1 + ); + assert_eq!(state.vector[HAS_PLAYED_DEV_CARD], 1); + assert_eq!(state.vector[IS_MOVING_ROBBER_INDEX], 1); + + // Check largest army status + if i == 2 { + assert_eq!(state.largest_army_color, Some(color1)); + assert_eq!(state.largest_army_count, 3); + assert_eq!(state.get_actual_victory_points(color1), 2); + assert_eq!(state.get_actual_victory_points(color2), 0); + } else { + assert_eq!(state.largest_army_color, None); + assert_eq!(state.largest_army_count, 0); + assert_eq!(state.get_actual_victory_points(color1), 0); + assert_eq!(state.get_actual_victory_points(color2), 0); + } + } + + // Now give second player 4 knight cards and have them take largest army + for _ in 0..4 { + state.add_dev_card(color2, DevCard::Knight as usize); + } + + // Play knights with second player + for i in 0..4 { + state.vector[HAS_PLAYED_DEV_CARD] = 0; // Reset for each turn + state.apply_action(Action::PlayKnight { color: color2 }); + + // Verify knight was removed and marked as played + assert_eq!( + state.get_dev_card_count(color2, DevCard::Knight as usize), + 3 - i + ); + assert_eq!( + state.get_played_dev_card_count(color2, DevCard::Knight as usize), + i + 1 + ); + + // Check largest army status + if i == 3 { + // After 4th knight, should take largest army + assert_eq!(state.largest_army_color, Some(color2)); + assert_eq!(state.largest_army_count, 4); + assert_eq!(state.get_actual_victory_points(color1), 0); // Lost 2 VPs + assert_eq!(state.get_actual_victory_points(color2), 2); // Gained 2 VPs + } else { + // Still held by first player + assert_eq!(state.largest_army_color, Some(color1)); + assert_eq!(state.largest_army_count, 3); + assert_eq!(state.get_actual_victory_points(color1), 2); + assert_eq!(state.get_actual_victory_points(color2), 0); + } + } + } + + #[test] + fn test_play_year_of_plenty() { + let mut state = State::new_base(); + let color = state.get_current_color(); + + // Give player a year of plenty card + state.add_dev_card(color, DevCard::YearOfPlenty as usize); + + let bank_before = state.vector[BANK_RESOURCE_SLICE].to_vec(); + let hand_before = state.get_player_hand(color).to_vec(); + + // Play year of plenty for wood and brick + state.play_year_of_plenty(color, (0, Some(1))); + + // Verify card was removed from hand + assert_eq!( + state.get_dev_card_count(color, DevCard::YearOfPlenty as usize), + 0 + ); + + // Verify card was marked as played + assert_eq!( + state.get_played_dev_card_count(color, DevCard::YearOfPlenty as usize), + 1 + ); + assert_eq!(state.vector[HAS_PLAYED_DEV_CARD], 1); + + // Verify resources were transferred + assert_eq!(state.vector[BANK_RESOURCE_SLICE][0], bank_before[0] - 1); + assert_eq!(state.vector[BANK_RESOURCE_SLICE][1], bank_before[1] - 1); + assert_eq!(state.get_player_hand(color)[0], hand_before[0] + 1); + assert_eq!(state.get_player_hand(color)[1], hand_before[1] + 1); + } + + #[test] + fn test_play_monopoly() { + let mut state = State::new_base(); + let monopolist_color = state.get_current_color(); + + // Give player a monopoly card + state.add_dev_card(monopolist_color, DevCard::Monopoly as usize); + + // Give other players some wood + for other_color in 0..state.get_num_players() { + if other_color != monopolist_color { + state.get_mut_player_hand(other_color)[0] = 3; + } + } + + let initial_wood = state.get_player_hand(monopolist_color)[0]; + let expected_stolen = 3 * (state.get_num_players() - 1) as u8; // 3 wood from each other player + + // Play monopoly on wood (resource index 0) + state.play_monopoly(monopolist_color, 0); + + assert_eq!( + state.get_dev_card_count(monopolist_color, DevCard::Monopoly as usize), + 0 + ); + assert_eq!( + state.get_played_dev_card_count(monopolist_color, DevCard::Monopoly as usize), + 1 + ); + assert_eq!(state.vector[HAS_PLAYED_DEV_CARD], 1); + assert_eq!( + state.get_player_hand(monopolist_color)[0], + initial_wood + expected_stolen + ); + + // Verify other players lost their wood + for other_color in 0..state.get_num_players() { + if other_color != monopolist_color { + assert_eq!(state.get_player_hand(other_color)[0], 0); + } + } + } + + #[test] + fn test_play_road_building() { + let mut state = State::new_base(); + let color = state.get_current_color(); + + // Give player a road building card + state.add_dev_card(color, DevCard::RoadBuilding as usize); + assert_eq!( + state.get_dev_card_count(color, DevCard::RoadBuilding as usize), + 1 + ); + assert_eq!( + state.get_played_dev_card_count(color, DevCard::RoadBuilding as usize), + 0 + ); + assert_eq!(state.vector[HAS_PLAYED_DEV_CARD], 0); + + // Play road building card + state.play_road_building(color); + + // Verify card was removed from hand + assert_eq!( + state.get_dev_card_count(color, DevCard::RoadBuilding as usize), + 0 + ); + + // Verify card was marked as played + assert_eq!( + state.get_played_dev_card_count(color, DevCard::RoadBuilding as usize), + 1 + ); + assert_eq!(state.vector[HAS_PLAYED_DEV_CARD], 1); + + // Verify state was set for free roads + assert_eq!(state.vector[IS_BUILDING_ROAD_INDEX], 1); + assert_eq!(state.vector[FREE_ROADS_AVAILABLE_INDEX], 2); + } + + #[test] + fn test_maritime_trade_basic_rate() { + let mut state = State::new_base(); + let color = state.get_current_color(); + + state.get_mut_player_hand(color)[0] = 4; // 4 wood + + let initial_bank_brick = state.vector[BANK_RESOURCE_SLICE][1]; + + state.apply_action(Action::MaritimeTrade { + color, + give: 0, + take: 1, + ratio: 4, + }); + + assert_eq!(state.get_player_hand(color)[0], 0); + assert_eq!(state.get_player_hand(color)[1], 1); + assert_eq!(state.vector[BANK_RESOURCE_SLICE][0], 19 + 4); + assert_eq!(state.vector[BANK_RESOURCE_SLICE][1], initial_bank_brick - 1); + } + + #[test] + fn test_end_turn() { + let mut state = State::new_base(); + let starting_color = state.get_current_color(); + let seating_order = state.get_seating_order().to_vec(); + + state.vector[HAS_PLAYED_DEV_CARD] = 1; + state.vector[HAS_ROLLED_INDEX] = 1; + state.apply_action(Action::EndTurn { + color: starting_color, + }); + + assert_eq!(state.vector[HAS_PLAYED_DEV_CARD], 0); + assert_eq!(state.vector[HAS_ROLLED_INDEX], 0); + + assert_eq!(state.get_current_color(), seating_order[1]); + + for _ in 0..(state.get_num_players() - 1) { + state.apply_action(Action::EndTurn { + color: state.get_current_color(), + }); + } + + assert_eq!(state.get_current_color(), starting_color); + } + + #[test] + fn test_update_connected_components() { + // Create a test state + let mut state = State::new_base(); + let color = 0; // Red player + + // Create two separate components + let mut comp1 = HashSet::new(); + comp1.insert(0); + comp1.insert(1); + + let mut comp2 = HashSet::new(); + comp2.insert(3); + comp2.insert(4); + + state.connected_components.insert(color, vec![comp1, comp2]); + + // Add roads to connect the components + state.roads.insert((1, 2), color); + state.roads.insert((2, 3), color); + + // First update: Connect node 1 to node 2 + // Node 1 is in component index 0, node 2 is not in any component + let _updated_component = state.update_connected_components(color, 1, 2, Some(0), None); + + // Verify node 2 was added to the first component + let components = state.connected_components.get(&color).unwrap(); + assert_eq!(components.len(), 2, "Should still have two components"); + assert!( + components[0].contains(&2), + "First component should now contain node 2" + ); + + // Second update: Connect node 2 to node 3 + // After the first update, node 2 is now in component index 0 + // Node 3 is in component index 1 + let _updated_component = state.update_connected_components(color, 2, 3, Some(0), Some(1)); + + // Verify that the components were merged + let components = state.connected_components.get(&color).unwrap(); + assert_eq!(components.len(), 1, "Components should be merged into one"); + + // Verify the merged component contains all nodes + let merged = &components[0]; + assert!( + merged.contains(&0), + "Merged component should contain node 0" + ); + assert!( + merged.contains(&1), + "Merged component should contain node 1" + ); + assert!( + merged.contains(&2), + "Merged component should contain node 2" + ); + assert!( + merged.contains(&3), + "Merged component should contain node 3" + ); + assert!( + merged.contains(&4), + "Merged component should contain node 4" + ); + } +} diff --git a/catanatron_rust/src/state/move_generation.rs b/catanatron_rust/src/state/move_generation.rs new file mode 100644 index 000000000..f4259c9a5 --- /dev/null +++ b/catanatron_rust/src/state/move_generation.rs @@ -0,0 +1,737 @@ +use crate::deck_slices::{freqdeck_contains, CITY_COST, ROAD_COST, SETTLEMENT_COST}; +use crate::enums::{Action, ActionPrompt, DevCard}; +use crate::state::State; +use crate::state_vector::get_free_roads_available; +use std::collections::HashSet; + +use super::Building; + +const TOTAL_ROADS_PER_PLAYER: u8 = 15; +const TOTAL_CITIES_PER_PLAYER: u8 = 4; + +impl State { + pub fn generate_playable_actions(&self) -> Vec { + let current_color = self.get_current_color(); + let action_prompt = self.get_action_prompt(); + match action_prompt { + ActionPrompt::BuildInitialSettlement => { + self.settlement_possibilities(current_color, true) + } + ActionPrompt::BuildInitialRoad => self.initial_road_possibilities(current_color), + ActionPrompt::MoveRobber => self.robber_possibilities(current_color), + ActionPrompt::PlayTurn => self.play_turn_possibilities(current_color), + ActionPrompt::Discard => self.discard_possibilities(current_color), + ActionPrompt::DecideTrade => todo!("generate_playable_actions for Decide trade"), + ActionPrompt::DecideAcceptees => { + todo!("generate_playable_actions for Decide acceptees") + } + } + } + + pub fn settlement_possibilities(&self, color: u8, is_initial_build_phase: bool) -> Vec { + if is_initial_build_phase { + self.board_buildable_ids + .iter() + .map(|node_id| Action::BuildSettlement { + color, + node_id: *node_id, + }) + .collect() + } else { + let has_resources = freqdeck_contains(self.get_player_hand(color), SETTLEMENT_COST); + let settlements_used = self.get_settlements(color).len(); + let has_settlements_available = settlements_used < 5; + + if has_resources && has_settlements_available { + self.buildable_node_ids(color) + .into_iter() + .map(|node_id| Action::BuildSettlement { color, node_id }) + .collect() + } else { + vec![] + } + } + } + + pub fn initial_road_possibilities(&self, color: u8) -> Vec { + let last_settlement_building = self.buildings_by_color[&color].last().unwrap(); + let last_node_id = match last_settlement_building { + Building::Settlement(_, node_id) => *node_id, + _ => panic!("Invalid building type"), + }; + + self.board_buildable_edges(color) + .iter() + .filter(|edge_id| self.edge_contains(**edge_id, last_node_id)) + .map(|edge_id| Action::BuildRoad { + color, + edge_id: *edge_id, + }) + .collect() + } + + pub fn road_possibilities(&self, color: u8, is_free: bool) -> Vec { + let has_roads_available = TOTAL_ROADS_PER_PLAYER - self.roads_by_color[color as usize]; + if has_roads_available == 0 { + return vec![]; + } + + if is_free || freqdeck_contains(self.get_player_hand(color), ROAD_COST) { + self.board_buildable_edges(color) + .iter() + .map(|edge_id| Action::BuildRoad { + color, + edge_id: *edge_id, + }) + .collect() + } else { + vec![] + } + } + + pub fn city_possibilities(&self, color: u8) -> Vec { + let has_money = freqdeck_contains(self.get_player_hand(color), CITY_COST); + if !has_money { + return vec![]; + } + + let has_cities_available = self.get_cities(color).len() < TOTAL_CITIES_PER_PLAYER as usize; + if !has_cities_available { + return vec![]; + } + + self.get_settlements(color) + .iter() + .map(|building| match building { + Building::Settlement(color, node_id) => Action::BuildCity { + color: *color, + node_id: *node_id, + }, + _ => panic!("Invalid building type"), + }) + .collect() + } + + pub fn year_of_plenty_possibilities(&self, color: u8) -> Vec { + let bank_resources = self.get_bank_resources(); + let mut actions = Vec::new(); + + // First try all same-resource pairs + for (resource, &count) in bank_resources.iter().enumerate() { + if count >= 2 { + actions.push(Action::PlayYearOfPlenty { + color, + resources: (resource as u8, Some(resource as u8)), + }); + } + } + + // Then try different-resource pairs + for (resource1, &count1) in bank_resources.iter().enumerate() { + if count1 > 0 { + for (resource2, &count2) in bank_resources.iter().enumerate().skip(resource1 + 1) { + if count2 > 0 { + actions.push(Action::PlayYearOfPlenty { + color, + resources: (resource1 as u8, Some(resource2 as u8)), + }); + } + } + } + } + + // If no two-resource actions possible, try single resources + if actions.is_empty() { + for (resource, &count) in bank_resources.iter().enumerate() { + if count > 0 { + actions.push(Action::PlayYearOfPlenty { + color, + resources: (resource as u8, None), + }); + break; // Only need first available resource + } + } + } + + actions + } + + pub fn play_turn_possibilities(&self, color: u8) -> Vec { + if self.is_road_building() { + if get_free_roads_available(&self.vector) == 0 { + return vec![Action::EndTurn { color }]; // Always provide EndTurn + } + return self.road_possibilities(color, true); + } else if !self.current_player_rolled() { + let mut actions = vec![Action::Roll { + color, + dice_opt: None, + }]; + if self.can_play_dev(DevCard::Knight as u8) { + actions.push(Action::PlayKnight { color }); + } + return actions; + } + + let mut actions = vec![Action::EndTurn { color }]; + + // Add all possible actions + actions.extend(self.settlement_possibilities(color, false)); + actions.extend(self.road_possibilities(color, false)); + actions.extend(self.city_possibilities(color)); + + if self.can_play_dev(DevCard::Knight as u8) { + actions.push(Action::PlayKnight { color }); + } + if self.can_play_dev(DevCard::YearOfPlenty as u8) { + actions.extend(self.year_of_plenty_possibilities(color)); + } + if self.can_play_dev(DevCard::Monopoly as u8) { + for resource in 0..5 { + actions.push(Action::PlayMonopoly { color, resource }); + } + } + if self.can_play_dev(DevCard::RoadBuilding as u8) { + // TODO: What if user has no roads left? or is completely blocked? + actions.push(Action::PlayRoadBuilding { color }); + } + + // Add maritime trade possibilities + actions.extend(self.maritime_trade_possibilities(color)); + + // TODO: Domestic trading is temporarily disabled to reduce the state space explosion + // This simplification allows us to first build a superhuman AI player without + // the complexity of domestic trading. + + actions + } + + fn calculate_port_rates(&self, color: u8) -> [u8; 5] { + let mut port_rates = [4; 5]; // Default 4:1 rate for all resources + + let Some(player_buildings) = self.buildings_by_color.get(&color) else { + return port_rates; + }; + + // For each player building, check if it's on a port and update rates + for building in player_buildings { + let node_id = match building { + Building::Settlement(_, id) | Building::City(_, id) => id, + }; + + if let Some(&port_resource) = self.map_instance.get_port_nodes().get(node_id) { + match port_resource { + Some(resource) => port_rates[resource as usize] = 2, + None => port_rates + .iter_mut() + .for_each(|rate| *rate = (*rate).min(3)), + } + } + } + + port_rates + } + + pub fn maritime_trade_possibilities(&self, color: u8) -> Vec { + let hand = self.get_player_hand(color); + let bank = self.get_bank_resources(); + let port_rates = self.calculate_port_rates(color); + + hand.iter() + .enumerate() + .flat_map(|(give_idx, &give_count)| { + let rate = port_rates[give_idx]; + if give_count >= rate { + (0..5) + .filter(|&take_idx| { + // Ensure bank has enough resources and it's a different resource + take_idx != give_idx && bank[take_idx] > 0 + }) + .map(|take_idx| Action::MaritimeTrade { + color, + give: give_idx as u8, + take: take_idx as u8, + ratio: rate, + }) + .collect::>() + } else { + Vec::new() + } + }) + .collect() + } + + pub fn robber_possibilities(&self, color: u8) -> Vec { + let mut actions = vec![]; + let current_robber_tile = self.get_robber_tile(); + + for (coordinate, tile) in self.map_instance.get_land_tiles() { + // Skip current robber location + if tile.id == current_robber_tile { + continue; + } + + // Find players to steal from at this tile + let mut victims = HashSet::new(); + for node_id in tile.hexagon.nodes.values() { + if let Some(building) = self.buildings.get(node_id) { + match building { + Building::Settlement(victim_color, _) | Building::City(victim_color, _) => { + // Can't steal from yourself and victim must have resources + if *victim_color != color + && self.get_player_hand(*victim_color).iter().sum::() > 0 + { + victims.insert(*victim_color); + } + } + } + } + } + + if victims.is_empty() { + actions.push(Action::MoveRobber { + color, + coordinate: *coordinate, + victim_opt: None, + }); + } else { + for victim in victims { + actions.push(Action::MoveRobber { + color, + coordinate: *coordinate, + victim_opt: Some(victim), + }); + } + } + } + + actions + } + + pub fn discard_possibilities(&self, color: u8) -> Vec { + let hand = self.get_player_hand(color); + let total_cards: u8 = hand.iter().sum(); + + // If player has 7 or fewer cards, they can just end their turn + if total_cards <= 7 { + return vec![Action::EndTurn { color }]; + } + + // For now, just generate a single discard action + // This is to prevent state space explosion + vec![Action::Discard { color }] + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::enums::Resource; + + fn find_port_node_by_type(state: &State, resource: Option) -> Option { + state + .map_instance + .get_port_nodes() + .iter() + .find_map(|(&node_id, &port_resource)| { + if port_resource == resource { + Some(node_id) + } else { + None + } + }) + } + + #[test] + fn test_move_generation() { + let state = State::new_base(); + let actions = state.generate_playable_actions(); + assert_eq!(actions.len(), 54); + assert!(matches!(actions[0], Action::BuildSettlement { .. })); + } + + #[test] + fn test_settlement_possibilities() { + let mut state = State::new_base(); + let color = state.get_current_color(); + + // Test initial build phase + let initial_build_phase_actions = state.settlement_possibilities(color, true); + assert_eq!(initial_build_phase_actions.len(), 54); + + // Give player resources + let hand = state.get_mut_player_hand(color); + for resource in hand.iter_mut() { + *resource = 5; + } + + let action = Action::BuildSettlement { color, node_id: 0 }; + state.apply_action(action); + + let action = Action::BuildRoad { + color, + edge_id: (0, 1), + }; + state.apply_action(action); + + let actions = state.settlement_possibilities(0, false); + assert_eq!(actions.len(), 0); + + let action = Action::BuildRoad { + color, + edge_id: (1, 2), + }; + state.apply_action(action); + + // Should be able to build at node 2 + let actions = state.settlement_possibilities(color, false); + assert_eq!(actions.len(), 1); + assert!(actions.iter().any(|action| { + matches!(action, Action::BuildSettlement { color: c, node_id } if *c == color && *node_id == 2) + })); + } + + #[test] + fn test_initial_settlement_possibilities() { + let state = State::new_base(); + let curr_color = state.get_current_color(); + let actions = state.settlement_possibilities(curr_color, true); + assert_eq!(actions.len(), 54); // All nodes should be buildable + + for action in actions { + match action { + Action::BuildSettlement { color, .. } => assert_eq!(color, curr_color), + _ => panic!("Expected BuildSettlement action"), + } + } + } + + #[test] + fn test_initial_road_possibilities() { + let mut state = State::new_base(); + let curr_color = state.get_current_color(); + + state.build_settlement(curr_color, 0); + + let actions = state.initial_road_possibilities(curr_color); + assert_eq!(actions.len(), 3); + + for action in actions { + match action { + Action::BuildRoad { color, edge_id } => { + assert_eq!(color, curr_color); + assert!(edge_id.0 == 0 || edge_id.1 == 0); + } + _ => panic!("Expected BuildRoad action"), + } + } + } + + #[test] + fn test_settlement_blocks_neighbors() { + let mut state = State::new_base(); + let curr_color = state.get_current_color(); + + state.build_settlement(curr_color, 0); + + let actions = state.settlement_possibilities(curr_color, true); + let neighbors = state.map_instance.get_neighbor_nodes(0); + + for action in actions { + match action { + Action::BuildSettlement { color: _, node_id } => { + assert_ne!(node_id, 0); + assert!(!neighbors.contains(&node_id)); + } + _ => panic!("Expected BuildSettlement action"), + } + } + } + + #[test] + fn test_play_turn_initial_possibilities() { + let mut state = State::new_base(); + + assert!(state.is_initial_build_phase()); + assert!(matches!( + state.get_action_prompt(), + ActionPrompt::BuildInitialSettlement + )); + + let actions = state.generate_playable_actions(); + match &actions[0] { + Action::BuildSettlement { .. } => (), + _ => panic!("Expected BuildSettlement action to be first action"), + } + state.apply_action(actions[0]); + + assert!(matches!( + state.get_action_prompt(), + ActionPrompt::BuildInitialRoad + )); + } + + #[test] + fn test_robber_possibilities() { + let mut state = State::new_base(); + let color1 = 1; + let color2 = 2; + + state.build_settlement(color2, 0); + + // Give resources to color2 + let hand = state.get_mut_player_hand(color2); + hand[1] = 1; // Use Brick's index (1) directly + + let actions = state.robber_possibilities(color1); + + // Should be able to move robber to any land tile except current location + let num_land_tiles = state.map_instance.get_land_tiles().len(); + assert_eq!(actions.len(), num_land_tiles - 1); + + // Count how many actions involve stealing + let steal_actions_count = actions.iter().filter(|action| { + matches!(action, Action::MoveRobber { victim_opt: Some(v), .. } if *v == color2) + }).count(); + + // Node 0 is connected to 3 tiles, but one might be the robber's current location + assert!( + steal_actions_count == 2 || steal_actions_count == 3, + "Should have 2 or 3 tiles where stealing is possible" + ); + + // Verify can't steal from player with no resources + let hand = state.get_mut_player_hand(color2); + hand[1] = 0; // Clear Brick resource + let actions = state.robber_possibilities(color1); + assert_eq!(actions.len(), num_land_tiles - 1); // All tiles except current + + // Verify no stealing actions when victim has no resources + let steal_actions_count = actions + .iter() + .filter(|action| { + matches!( + action, + Action::MoveRobber { + victim_opt: Some(_), + .. + } + ) + }) + .count(); + assert_eq!( + steal_actions_count, 0, + "Should have no stealing actions when victim has no resources" + ); + } + + #[test] + fn test_robber_cant_stay_in_place() { + let state = State::new_base(); + let color = state.get_current_color(); + let actions = state.robber_possibilities(color); + + // Get current robber tile + let current_robber_tile = state.get_robber_tile(); + + // Verify no action tries to move robber to current location + assert!(actions.iter().all(|action| { + if let Action::MoveRobber { coordinate, .. } = action { + state.map_instance.get_land_tile(*coordinate).unwrap().id != current_robber_tile + } else { + false + } + })); + } + + #[test] + fn test_year_of_plenty_possibilities() { + let mut state = State::new_base(); + let color = state.get_current_color(); + + // Give player a Year of Plenty card + state.add_dev_card(color, DevCard::YearOfPlenty as usize); + + // Test with full bank - should have 15 actions: + // - 5 same-resource actions (Wood+Wood, Brick+Brick, etc.) + // - 10 different-resource combinations (5 choose 2) + let actions = state.year_of_plenty_possibilities(color); + assert_eq!(actions.len(), 15); + + // Test with empty bank - should have 0 actions + for i in 0..5 { + // 5 resource types + state.set_bank_resource(i, 0); + } + let actions = state.year_of_plenty_possibilities(color); + assert_eq!(actions.len(), 0); + + // Test with only 2 ORE available + state.set_bank_resource(4, 2); + let actions = state.year_of_plenty_possibilities(color); + assert_eq!(actions.len(), 1); // Can only take ORE+ORE + assert!(matches!( + actions[0], + Action::PlayYearOfPlenty { + resources: (4, Some(4)), + .. + } + )); + + // Test with only 1 WHEAT available + for i in 0..5 { + state.set_bank_resource(i, 0); + } + state.set_bank_resource(3, 1); + let actions = state.year_of_plenty_possibilities(color); + assert_eq!(actions.len(), 1); // Can take just one WHEAT when that's all that's available + assert!(matches!( + actions[0], + Action::PlayYearOfPlenty { + resources: (3, None), + .. + } + )); + } + + #[test] + fn test_discard_possibilities() { + let mut state = State::new_base(); + let color = state.get_current_color(); + + // Give player 8 cards (above discard limit) + { + let hand = state.get_mut_player_hand(color); + hand[0] = 8; // Give 8 wood cards + } + + let actions = state.discard_possibilities(color); + assert_eq!(actions.len(), 1); + assert!(matches!(actions[0], Action::Discard { color: c } if c == color)); + + // Test with 7 cards (at discard limit) + { + let hand = state.get_mut_player_hand(color); + hand[0] = 7; + } + let actions = state.discard_possibilities(color); + assert_eq!(actions.len(), 1); + assert!(matches!(actions[0], Action::EndTurn { color: c } if c == color)); + } + + #[test] + fn test_maritime_trade_possibilities() { + let mut state = State::new_base(); + let color = state.get_current_color(); + + // Give player 4 wood (enough for 4:1 trade) + { + let hand = state.get_mut_player_hand(color); + hand[0] = 4; + } + + let actions = state.maritime_trade_possibilities(color); + + // Should be able to trade 4 wood for any other resource + assert_eq!(actions.len(), 4); // Can trade for brick, sheep, wheat, ore + assert!(actions.iter().all(|action| matches!( + action, + Action::MaritimeTrade { + color: c, + give, + take, + ratio + } if *c == color && *give == 0 && *ratio == 4 && *take != 0 + ))); + + // Test with insufficient resources + { + let hand = state.get_mut_player_hand(color); + hand[0] = 3; + } + let actions = state.maritime_trade_possibilities(color); + assert_eq!(actions.len(), 0); + } + + #[test] + fn test_maritime_trade_with_empty_bank() { + let mut state = State::new_base(); + let color = state.get_current_color(); + + // Give player resources for trading + let hand = state.get_mut_player_hand(color); + hand[0] = 4; // Give 4 wood for 4:1 trade + + // Empty the bank + for i in 0..5 { + state.set_bank_resource(i, 0); + } + + let actions = state.maritime_trade_possibilities(color); + assert_eq!( + actions.len(), + 0, + "Should not be able to trade with empty bank" + ); + } + + #[test] + fn test_maritime_trade_with_two_to_one_port() { + let mut state = State::new_base(); + let color = state.get_current_color(); + + // Find a 2:1 wood port node + let wood_port_node = find_port_node_by_type(&state, Some(Resource::Wood)).unwrap(); + + // Build settlement at wood port + state.build_settlement(color, wood_port_node); + + // Give player 2 wood (enough for 2:1 trade) + let hand = state.get_mut_player_hand(color); + hand[0] = 2; + + let actions = state.maritime_trade_possibilities(color); + assert!( + actions.iter().any(|action| matches!( + action, + Action::MaritimeTrade { + color: c, + give, + take, + ratio + } if *c == color && *give == 0 && *ratio == 2 && *take != 0 + )), + "Should be able to use 2:1 wood port" + ); + } + + #[test] + fn test_maritime_trade_with_three_to_one_port() { + let mut state = State::new_base(); + let color = state.get_current_color(); + + // Find a 3:1 port node + let port_node = find_port_node_by_type(&state, None).unwrap(); + + // Build settlement at 3:1 port + state.build_settlement(color, port_node); + + // Give player 3 brick (enough for 3:1 trade) + let hand = state.get_mut_player_hand(color); + hand[1] = 3; // Give 3 brick + + let actions = state.maritime_trade_possibilities(color); + assert!( + actions.iter().any(|action| matches!( + action, + Action::MaritimeTrade { + color: c, + give, + take, + ratio + } if *c == color && *give == 1 && *ratio == 3 && *take != 0 + )), + "Should be able to use 3:1 port for brick" + ); + } +} diff --git a/catanatron_rust/src/state_vector.rs b/catanatron_rust/src/state_vector.rs new file mode 100644 index 000000000..1482a7ec0 --- /dev/null +++ b/catanatron_rust/src/state_vector.rs @@ -0,0 +1,295 @@ +use crate::decks::starting_dev_listdeck; +use rand::seq::SliceRandom; + +use crate::enums::COLORS; + +pub type StateVector = Vec; + +// Board dimensions (BASE_MAP) +pub const NUM_NODES: usize = 54; +pub const NUM_EDGES: usize = 72; +pub const NUM_TILES: usize = 19; +pub const NUM_PORTS: usize = 9; + +// Bank indices +pub const BANK_RESOURCE_SLICE: std::ops::Range = 0..5; +pub const DEV_BANK_START_INDEX: usize = 5; +pub const DEV_BANK_END_INDEX: usize = 30; +pub const DEV_BANK_PTR_INDEX: usize = 30; + +// Game control indices +pub const CURRENT_TICK_SEAT_INDEX: usize = 31; +pub const CURRENT_TURN_SEAT_INDEX: usize = 32; +pub const IS_INITIAL_BUILD_PHASE_INDEX: usize = 33; +pub const HAS_PLAYED_DEV_CARD: usize = 34; +pub const HAS_ROLLED_INDEX: usize = 35; +pub const IS_DISCARDING_INDEX: usize = 36; +pub const IS_MOVING_ROBBER_INDEX: usize = 37; +pub const IS_BUILDING_ROAD_INDEX: usize = 38; +pub const FREE_ROADS_AVAILABLE_INDEX: usize = 39; + +// Extra state indices +pub const LONGEST_ROAD_PLAYER_INDEX: usize = 40; +pub const LARGEST_ARMY_PLAYER_INDEX: usize = 41; +pub const ROBBER_TILE_INDEX: usize = 42; + +// Player state indices and sizes +pub const PLAYER_STATE_START_INDEX: usize = 268; +pub const PLAYER_STATE_SIZE: usize = 15; // Size of each player's state block +pub const PLAYER_VP_OFFSET: usize = 0; +pub const PLAYER_RESOURCES_OFFSET: usize = 1; +pub const PLAYER_RESOURCES_SIZE: usize = 5; +pub const PLAYER_DEVCARDS_OFFSET: usize = 6; +pub const PLAYER_DEVCARDS_SIZE: usize = 5; +pub const PLAYER_PLAYED_DEVCARDS_OFFSET: usize = 11; +pub const PLAYER_PLAYED_DEVCARDS_SIZE: usize = 4; + +// Resource constants +pub const MAX_RESOURCE_COUNT: u8 = 19; +pub const MAX_DEV_CARDS: usize = 25; +pub const MAX_VICTORY_POINTS: u8 = 12; +pub const NUM_RESOURCES: usize = 5; +pub const FREE_ROADS_MAX: u8 = 2; + +/// This is in theory not needed since we use a vector and we can +/// .push() to it. But since we made it, leaving in here in case +/// we want to switch to an array implementation and it serves +/// as documentation of the state vector. +pub fn get_state_array_size(num_players: usize) -> usize { + // TODO: Is configuration part of state? + // TODO: Hardcoded for BASE_MAP + let n = num_players; + + log::debug!("get_state_array_size: num_players={}, n={}", num_players, n); + + let mut size: usize = 0; + // Trying to have as most fixed-size vector first as possible + // so that we can understand/debug all configurations similarly. + // Bank + size += NUM_RESOURCES; // Bank Resources + size += MAX_DEV_CARDS; // Bank Development Cards + + // Game Controls + size += 1; // Current_Player_Index (Player Index < n) + size += 1; // Current_Turn_Index (Player Index < n) + size += 1; // Is_Initial_Build_Phase (Boolean) + size += 1; // Has_Played_Development_Card (Boolean) + size += 1; // Has_Rolled (Boolean) + size += 1; // Is_Discarding (Boolean) + size += 1; // Is_Moving_Robber (Boolean) + size += 1; // Is_Building_Road (Boolean) + size += 1; // Free_Roads_Available (Number <= 2) + + // Extra (these are needed to make game Markovian (i.e. memoryless)) + // Note: (Largest_Army_Size and Longest_Road_Size are captured by player (_Played) and board state) + size += 1; // Longest_Road_Player_Index (Player Index < n) + size += 1; // Largest_Army_Player_Index (Player Index < n) + size += 1; // Robber_Tile (Tile Index < num_tiles) + + // Board (dynamically sized based on map template; 228 for BASE_MAP) + size += NUM_TILES; // Tile resources + size += NUM_TILES; // Tile numbers + size += NUM_EDGES; // Edge owners + size += NUM_NODES; // Node owners + size += NUM_NODES; // Node building types + size += NUM_PORTS; // Port resources + + // Players state + size += n; // Color seating order + size += (1 + PLAYER_RESOURCES_SIZE + PLAYER_DEVCARDS_SIZE + PLAYER_PLAYED_DEVCARDS_SIZE) * n; + + size +} + +pub fn bank_resource_index(resource: u8) -> usize { + if resource > 4 { + panic!("Invalid resource index"); + } + resource as usize +} + +pub fn seating_order_slice(num_players: usize) -> std::ops::Range { + let slice = PLAYER_STATE_START_INDEX..PLAYER_STATE_START_INDEX + num_players; + log::debug!( + "seating_order_slice: num_players={}, PLAYER_STATE_START_INDEX={}, slice={:?}", + num_players, + PLAYER_STATE_START_INDEX, + slice + ); + slice +} + +pub fn actual_victory_points_index(num_players: u8, color: u8) -> usize { + PLAYER_STATE_START_INDEX + num_players as usize + color as usize * PLAYER_STATE_SIZE +} + +pub fn player_hand_slice(num_players: u8, color: u8) -> std::ops::Range { + let start = PLAYER_STATE_START_INDEX + + num_players as usize + + PLAYER_RESOURCES_OFFSET + + (color as usize * PLAYER_STATE_SIZE); + start..start + PLAYER_RESOURCES_SIZE +} + +pub fn player_devhand_slice(num_players: u8, color: u8) -> std::ops::Range { + let start = PLAYER_STATE_START_INDEX + + num_players as usize + + PLAYER_DEVCARDS_OFFSET + + (color as usize * PLAYER_STATE_SIZE); + start..start + PLAYER_DEVCARDS_SIZE +} + +pub fn player_played_devhand_slice(num_players: u8, color: u8) -> std::ops::Range { + let start = PLAYER_STATE_START_INDEX + + num_players as usize + + PLAYER_PLAYED_DEVCARDS_OFFSET + + (color as usize * PLAYER_STATE_SIZE); + start..start + PLAYER_PLAYED_DEVCARDS_SIZE +} + +pub fn get_free_roads_available(vector: &StateVector) -> u8 { + vector[FREE_ROADS_AVAILABLE_INDEX] +} + +// TODO: I'm not sure if it makes more sense to have this in state.rs? +pub fn take_next_dev_card(vector: &mut StateVector) -> Option { + let ptr = vector[DEV_BANK_PTR_INDEX] as usize; + if ptr >= MAX_DEV_CARDS { + return None; + } + let card = vector[DEV_BANK_START_INDEX + ptr]; + vector[DEV_BANK_PTR_INDEX] += 1; + Some(card) +} + +/// This is a compact representation of the omnipotent state of the game. +/// Fairly close to a bitboard, but not quite. Its a vector of integers. +/// +/// To create a feature-vector from the perspective of a player, +/// remember to mask hidden information and hot-encode as needed. +/// +/// TODO: Is it better to have it already in hot-encoded rep? +/// For now going with compact representation, to allow MCTS rollouts +/// to be faster (without needing to hot-encode). If any workload +/// needs to create samples (e.g. collect RL trajectories), then +/// they'll have to pay the performance of hot-encoding this into +/// a tensor separately. +/// +/// TODO: This is not the only Data Structure to do rollouts. +/// We recommend additional caches and aux data structures for +/// faster rollouts. This one is compact optimized for copying. +/// TODO: Accept a seed for deterministic tests +pub fn initialize_state(num_players: u8) -> Vec { + log::debug!( + "initialize_state: num_players={}, PLAYER_STATE_START_INDEX={}", + num_players, + PLAYER_STATE_START_INDEX + ); + + let n = num_players as usize; + let size = get_state_array_size(n); + + log::debug!("initialize_state: size={}, n={}", size, n); + + let mut vector = vec![0; size]; + + // Initialize Bank Resources + for i in BANK_RESOURCE_SLICE { + vector[i] = MAX_RESOURCE_COUNT; + } + + // Initialize Bank Development Cards + // TODO: Shuffle + let mut listdeck = starting_dev_listdeck(); + listdeck.shuffle(&mut rand::thread_rng()); + vector[DEV_BANK_START_INDEX..DEV_BANK_END_INDEX].copy_from_slice(&listdeck); + vector[DEV_BANK_PTR_INDEX] = 0; + + // Initialize Game Controls + vector[CURRENT_TICK_SEAT_INDEX] = 0; + vector[CURRENT_TURN_SEAT_INDEX] = 0; + vector[IS_INITIAL_BUILD_PHASE_INDEX] = 1; + vector[HAS_PLAYED_DEV_CARD] = 0; + vector[HAS_ROLLED_INDEX] = 0; + vector[IS_DISCARDING_INDEX] = 0; + vector[IS_MOVING_ROBBER_INDEX] = 0; + vector[IS_BUILDING_ROAD_INDEX] = 0; + vector[FREE_ROADS_AVAILABLE_INDEX] = 0; // Initially no free roads available (road building dev card) + + // Initialize Extra State + vector[LONGEST_ROAD_PLAYER_INDEX] = u8::MAX; + vector[LARGEST_ARMY_PLAYER_INDEX] = u8::MAX; + vector[ROBBER_TILE_INDEX] = 0; + + // Initialize Players + let mut player_state_start = PLAYER_STATE_START_INDEX; + + // Shuffle player indices + let mut color_seating_order = COLORS[0..n].iter().map(|&x| x as u8).collect::>(); + color_seating_order.shuffle(&mut rand::thread_rng()); + vector[player_state_start..player_state_start + n].copy_from_slice(&color_seating_order); + player_state_start += n; + + for _ in 0..num_players { + // Player_Victory_Points (Number <= 12). i is in order of COLORS + vector[player_state_start] = 0; // victory points + + // Player__In_Hand (Number <= 19) + vector[player_state_start + 1] = 0; // wood + vector[player_state_start + 2] = 0; // brick + vector[player_state_start + 3] = 0; // sheep + vector[player_state_start + 4] = 0; // wheat + vector[player_state_start + 5] = 0; // ore + + // Player__In_Hand (Number <= 25) + vector[player_state_start + 6] = 0; // knight + vector[player_state_start + 7] = 0; // year of plenty + vector[player_state_start + 8] = 0; // monopoly + vector[player_state_start + 9] = 0; // road building + vector[player_state_start + 10] = 0; // victory point + + // Player__Played (Number <= 14) + vector[player_state_start + 11] = 0; // knight played + vector[player_state_start + 12] = 0; // year of plenty played + vector[player_state_start + 13] = 0; // monopoly played + vector[player_state_start + 14] = 0; // road building played + player_state_start += PLAYER_STATE_SIZE; + } + + vector +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::enums::Color; + + #[test] + fn test_initialize_state_vector() { + let n: usize = 2; + let result = get_state_array_size(n); + assert_eq!(result, 301); + } + + #[test] + fn test_initialize_state() { + let state = initialize_state(2); + assert_eq!(state.len(), 301); + } + + #[test] + fn test_colors_slice() { + let result = seating_order_slice(4); + assert_eq!(result, 268..272); + } + + #[test] + fn test_indexing() { + let num_players = 2; + let result = actual_victory_points_index(num_players, Color::Red as u8); + assert_eq!(result, 270); + + let result = actual_victory_points_index(num_players, Color::Blue as u8); + assert_eq!(result, 270 + 15); + } +} diff --git a/catanatron_rust/tests/integration_test.rs b/catanatron_rust/tests/integration_test.rs new file mode 100644 index 000000000..309fa06f4 --- /dev/null +++ b/catanatron_rust/tests/integration_test.rs @@ -0,0 +1,18 @@ +use catanatron_rust::decks; +use catanatron_rust::enums::Resource; +use catanatron_rust::state_vector; + +#[test] +fn test_integration() { + let mut deck = decks::ResourceDeck::starting_resource_bank(); + if deck.can_draw(2, Resource::Wood) { + deck.draw(2, Resource::Wood); + deck.replenish(1, Resource::Wood); // Replenish after drawing to keep the count consistent + deck.replenish(1, Resource::Wood); // Replenish after drawing to keep the count consistent + } + assert_eq!(deck.total_cards(), 95); + + let vector = state_vector::initialize_state(2); + let size = state_vector::get_state_array_size(2); + assert_eq!(size, vector.len()); +} diff --git a/tests/models/test_board.py b/tests/models/test_board.py index c212df89e..791dc6647 100644 --- a/tests/models/test_board.py +++ b/tests/models/test_board.py @@ -1,6 +1,6 @@ import pytest -from catanatron.models.map import MINI_MAP_TEMPLATE, CatanMap +from catanatron.models.map_instance import MINI_MAP_TEMPLATE, MapInstance from catanatron.models.enums import RESOURCES from catanatron.models.board import Board, get_node_distances from catanatron.models.player import Color @@ -98,7 +98,7 @@ def test_buildable_nodes(): def test_buildable_nodes_in_mini_map(): - board = Board(catan_map=CatanMap.from_template(MINI_MAP_TEMPLATE)) + board = Board(map_instance=MapInstance.from_template(MINI_MAP_TEMPLATE)) nodes = board.buildable_node_ids(Color.RED) assert len(nodes) == 0 nodes = board.buildable_node_ids(Color.RED, initial_build_phase=True) @@ -156,7 +156,7 @@ def test_buildable_edges_simple(): def test_buildable_edges_in_mini(): - board = Board(catan_map=CatanMap.from_template(MINI_MAP_TEMPLATE)) + board = Board(map_instance=MapInstance.from_template(MINI_MAP_TEMPLATE)) board.build_settlement(Color.RED, 19, initial_build_phase=True) buildable = board.buildable_edges(Color.RED) assert len(buildable) == 2 diff --git a/tests/models/test_map.py b/tests/models/test_map.py index b6892381e..72c1cee99 100644 --- a/tests/models/test_map.py +++ b/tests/models/test_map.py @@ -1,9 +1,10 @@ from catanatron import WOOD, BRICK -from catanatron.models.map import ( +from catanatron.models.map_instance import ( BASE_MAP_TEMPLATE, MINI_MAP_TEMPLATE, - CatanMap, + MapInstance, LandTile, + NodeRef, get_nodes_and_edges, get_node_counter_production, DICE_PROBAS, @@ -24,7 +25,8 @@ def test_node_production_of_same_resource_adjacent_tile(): def test_mini_map_can_be_created(): - mini = CatanMap.from_template(MINI_MAP_TEMPLATE) + mini = MapInstance.from_template(MINI_MAP_TEMPLATE) + assert mini.tiles[(0, 0, 0)].nodes[NodeRef.NORTH] == 0 assert len(mini.land_tiles) == 7 assert len(mini.land_nodes) == 24 assert len(mini.tiles_by_id) == 7 @@ -39,9 +41,9 @@ def test_mini_map_can_be_created(): def test_base_map_can_be_created(): - catan_map = CatanMap.from_template(BASE_MAP_TEMPLATE) - assert len(catan_map.land_tiles) == 19 - assert len(catan_map.node_production) == 54 + map_instance = MapInstance.from_template(BASE_MAP_TEMPLATE) + assert len(map_instance.land_tiles) == 19 + assert len(map_instance.node_production) == 54 def test_get_nodes_and_edges_on_empty_board(): diff --git a/tests/test_machine_learning.py b/tests/test_machine_learning.py index 9c1f91805..8f1d6a46a 100644 --- a/tests/test_machine_learning.py +++ b/tests/test_machine_learning.py @@ -7,15 +7,17 @@ from catanatron.state import player_deck_replenish from catanatron.models.enums import ORE, Action, ActionType, WHEAT, NodeRef from catanatron.models.board import Board, get_edges -from catanatron.models.map import ( +from catanatron.models.map_template import ( BASE_MAP_TEMPLATE, MINI_MAP_TEMPLATE, +) +from catanatron.models.map_instance import ( NUM_EDGES, NUM_NODES, - CatanMap, + MapInstance, ) from catanatron.game import Game -from catanatron.models.map import number_probability +from catanatron.models.map_instance import number_probability from catanatron.models.player import SimplePlayer, Color from catanatron_gym.features import ( create_sample, @@ -126,8 +128,8 @@ def test_reachability_features(): # We do this here to allow Game.__init__ evolve freely. random.seed(123) random.sample(players, len(players)) - catan_map = CatanMap.from_template(BASE_MAP_TEMPLATE) - game = Game(players, seed=123, catan_map=catan_map) + map_instance = MapInstance.from_template(BASE_MAP_TEMPLATE) + game = Game(players, seed=123, map_instance=map_instance) p0_color = game.state.colors[0] game.execute(Action(p0_color, ActionType.BUILD_SETTLEMENT, 5)) @@ -197,7 +199,7 @@ def test_tile_features_in_mini(): SimplePlayer(Color.RED), SimplePlayer(Color.BLUE), ] - game = Game(players, catan_map=CatanMap.from_template(MINI_MAP_TEMPLATE)) + game = Game(players, map_instance=MapInstance.from_template(MINI_MAP_TEMPLATE)) features = tile_features(game, players[0].color) haystack = "".join(features.keys()) @@ -209,7 +211,7 @@ def test_port_features_in_mini(): SimplePlayer(Color.RED), SimplePlayer(Color.BLUE), ] - game = Game(players, catan_map=CatanMap.from_template(MINI_MAP_TEMPLATE)) + game = Game(players, map_instance=MapInstance.from_template(MINI_MAP_TEMPLATE)) features = port_features(game, players[0].color) assert len(features) == 0 @@ -247,7 +249,7 @@ def test_graph_features_in_mini(): SimplePlayer(Color.RED), SimplePlayer(Color.BLUE), ] - game = Game(players, catan_map=CatanMap.from_template(MINI_MAP_TEMPLATE)) + game = Game(players, map_instance=MapInstance.from_template(MINI_MAP_TEMPLATE)) p0_color = game.state.colors[0] game.execute(Action(p0_color, ActionType.BUILD_SETTLEMENT, 3)) game.execute(Action(p0_color, ActionType.BUILD_ROAD, (2, 3))) @@ -378,8 +380,8 @@ def test_resource_proba_planes(): # We do this here to allow Game.__init__ evolve freely. random.seed(123) random.sample(players, len(players)) - catan_map = CatanMap.from_template(BASE_MAP_TEMPLATE) - game = Game(players, seed=123, catan_map=catan_map) + map_instance = MapInstance.from_template(BASE_MAP_TEMPLATE) + game = Game(players, seed=123, map_instance=map_instance) tensor = create_board_tensor(game, players[0].color) assert tensor[0][0][0] == 0