Skip to content

Commit

Permalink
Merge d2b62d1 into 4176da8
Browse files Browse the repository at this point in the history
  • Loading branch information
bcollazo committed Feb 5, 2022
2 parents 4176da8 + d2b62d1 commit cf0b483
Show file tree
Hide file tree
Showing 85 changed files with 825 additions and 15,067 deletions.
107 changes: 81 additions & 26 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,29 +3,26 @@
[![Coverage Status](https://coveralls.io/repos/github/bcollazo/catanatron/badge.svg?branch=master)](https://coveralls.io/github/bcollazo/catanatron?branch=master)
[![Documentation Status](https://readthedocs.org/projects/catanatron/badge/?version=latest)](https://catanatron.readthedocs.io/en/latest/?badge=latest)
[![Join the chat at https://gitter.im/bcollazo-catanatron/community](https://badges.gitter.im/bcollazo-catanatron/community.svg)](https://gitter.im/bcollazo-catanatron/community?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge)
[![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/bcollazo/catanatron/blob/master/catanatron_experimental/catanatron_experimental/notebooks/Overview.ipynb)
[![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/bcollazo/catanatron/blob/master/catanatron_experimental/catanatron_experimental/Overview.ipynb)

Settlers of Catan Bot Simulator. Test out bot strategies at scale (thousands of games per minutes). The goal of this project is to find the strongest Settlers of Catan bot possible.
Settlers of Catan Bot and Bot Simulator. Test out bot strategies at scale (thousands of games per minutes). The goal of this project is to find the strongest Settlers of Catan bot possible.

See the motivation of the project here: [5 Ways NOT to Build a Catan AI](https://medium.com/@bcollazo2010/5-ways-not-to-build-a-catan-ai-e01bc491af17).

<p align="left">
<img src="https://raw.githubusercontent.com/bcollazo/catanatron/master/docs/source/_static/cli.gif">
</p>

## Getting Started
## Installation

The main usage is to clone this repo, install dependencies, and run simulations/experiments
so as to find stronger bot implementations and strategies.

1. Clone the repository
Clone this repository and install dependencies. This will include the Catanatron bot implementation and the `catanatron-play` simulator.

```
git clone https://github.com/bcollazo/catanatron
git clone git@github.com:bcollazo/catanatron.git
cd catanatron/
```

2. Install dependencies (needs Python3.8)
Create a virtual environment with Python3.8 or higher. Then:

```
pip install -r dev-requirements.txt
Expand All @@ -35,51 +32,109 @@ pip install -e catanatron_gym
pip install -e catanatron_experimental
```

3. Use `catanatron-play` to run simulations and generate datasets!
## Usage

Run simulations and generate datasets via the CLI:

```
catanatron-play --players=R,W,F,AB:2 --num=1000
catanatron-play --players=R,R,R,W --num=100
```

Currently, we can execute one game in ~76 milliseconds and the best bot is `AB:2` (basically an Alpha Beta Prunning algorithm with a hand-crafted value function).

See more information with `catanatron-play --help`.

## How to make Catanatron stronger?
## Try Your Own Bots

There are two main ways of testing a potentially stronger bot.
Implement your own bots by creating a file (e.g. `myplayers.py`) with some `Player` implementations:

- Use Contender Bot in `catanatron-play`. There is already some infrastructure in which you can change the weights of the hand-crafted features in the current evaluation function right now ([minimax.py](catanatron_experimental/catanatron_experimental/machine_learning/players/minimax.py)). You could also come up with new hand-crafted features! To do this, edit the `CONTENDER_WEIGHTS` and/or `contender_fn` function and run a command like: `catanatron-play --players=AB:2,AB:2:False:C --num=100` to see if your changes improve the main bot (it should consistently win more games).
```python
from catanatron import Player
from catanatron_experimental.cli.cli_players import register_player

- If your bot idea is considerably different than the tree-search structure `AlphaBetaPlayer` follows, implement your idea in the provided `MyPlayer` stub ([my_player.py](catanatron_experimental/catanatron_experimental/my_player.py)), and test it against the best bot: `catanatron-play --players=AB:2,M --num=100`. See other example player implementations in [catanatron_core/catanatron/players](catanatron_core/catanatron/players) and [minimax.py](catanatron_experimental/catanatron_experimental/machine_learning/players/minimax.py)
@register_player("FOO")
class FooPlayer(Player):
def decide(self, game, playable_actions):
"""Should return one of the playable_actions.
Args:
game (Game): complete game state. read-only.
playable_actions (Iterable[Action]): options to choose from
Return:
action (Action): Chosen element of playable_actions
"""
# ===== YOUR CODE HERE =====
# As an example we simply return the first action:
return playable_actions[0]
# ===== END YOUR CODE =====
```

Run it by passing the source code to `catanatron-play`:

```
catanatron-play --code=myplayers.py --players=R,R,R,FOO --num=10
```

## How to Make Catanatron Stronger?

The best bot right now is Alpha Beta Search with a hand-crafted value function. One of the most promising ways of improving Catanatron
is to copy the code from ([minimax.py](catanatron_experimental/catanatron_experimental/machine_learning/players/minimax.py)) to your
`myplayers.py` and tweak the weights of the value function. You can
also come up with your own innovative features!

For more sophisticated approaches, see example player implementations in [catanatron_core/catanatron/players](catanatron_core/catanatron/players)

If you find a bot that consistently beats the best bot right now, please submit a Pull Request! :)

## Watching Games (Browser UI)
## Advanced Usage

### Inspecting Games (Browser UI)

We provide a [docker-compose.yml](docker-compose.yml) with everything needed to watch games (useful for debugging). It contains all the web-server infrastructure needed to render a game in a browser.

<p align="left">
<img src="https://raw.githubusercontent.com/bcollazo/catanatron/master/docs/source/_static/CatanatronUI.png">
</p>

We provide a [docker-compose.yml](docker-compose.yml) with everything needed to watch games (useful for debugging). It contains all the web-server infrastructure needed to render a game in a browser.

To use, ensure you have [Docker Compose](https://docs.docker.com/compose/install/) installed, and run (from repo root):
To use, ensure you have [Docker Compose](https://docs.docker.com/compose/install/) installed, and run (from this repo's root):

```
docker-compose up
```

Then alongside it, use the `open_link` helper function to open up any finished game you have:
You can now use the `--db` flag to make the catanatron-play simulator save
the game in the database for inspection via the web server.

```
catanatron-play --players=W,W,W,W --db --num=1
```

NOTE: A great contribution would be to make the Web UI allow to step forwards and backwards in a game to inspect it (ala chess.com).

### As a Package / Library

You can also use `catanatron` package directly which provides a core
implementation of the Settlers of Catan game logic.

```python
from catanatron import Game, RandomPlayer, Color

# Play a simple 4v4 game
players = [
RandomPlayer(Color.RED),
RandomPlayer(Color.BLUE),
RandomPlayer(Color.WHITE),
RandomPlayer(Color.ORANGE),
]
game = Game(players)
print(game.play()) # returns winning color
```

You can use the `open_link` helper function to open up the game (useful for debugging):

```python
from catanatron_server.utils import open_link
open_link(game) # opens game in browser
```

See [sample.py](sample.py) for an example of this (run `python sample.py`).

NOTE: A great contribution would be to make the Web UI allow to step forwards and backwards in a game to inspect it (ala chess.com).

## Architecture

The code is divided in the following 5 components (folders):
Expand Down
7 changes: 7 additions & 0 deletions catanatron_core/catanatron/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
"""
This is to allow an API like:
from catanatron import Game, Player, Color, Accumulator
"""
from catanatron.game import Game, Accumulator
from catanatron.models.player import Player, Color, RandomPlayer
43 changes: 30 additions & 13 deletions catanatron_core/catanatron/game.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from catanatron.models.enums import Action
from catanatron.state import State, apply_action
from catanatron.state_functions import player_key
from catanatron.models.map import BaseMap
from catanatron.models.map import CatanMap
from catanatron.models.player import Color, Player

# To timeout RandomRobots from getting stuck...
Expand All @@ -29,8 +29,11 @@ def initialize(self, game):
"""
pass

def step(self, game, action):
"""Called after each action taken by a player"""
def step(self, game_before_action, action):
"""Called after each action taken by a player.
Game should be right before action is taken.
"""
pass

def finalize(self, game):
Expand All @@ -53,23 +56,28 @@ def __init__(
self,
players: Iterable[Player],
seed: int = None,
catan_map: BaseMap = None,
discard_limit: int = 7,
vps_to_win: int = 10,
catan_map: CatanMap = None,
initialize: bool = True,
):
"""Creates a game (doesn't run it).
Args:
players (Iterable[Player]): list of players, should be at most 4.
seed (int, optional): Random seed to use (for reproducing games). Defaults to None.
catan_map (BaseMap, optional): Map configuration to use. 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.
initialize (bool, optional): Whether to initialize. Defaults to True.
"""
if initialize:
self.seed = seed or random.randrange(sys.maxsize)
random.seed(self.seed)

self.id = str(uuid.uuid4())
self.state = State(players, catan_map or BaseMap())
self.vps_to_win = vps_to_win
self.state = State(players, catan_map, discard_limit=discard_limit)

def play(self, accumulators=[], decide_fn=None):
"""Executes game until a player wins or exceeded TURNS_LIMIT.
Expand All @@ -84,17 +92,17 @@ def play(self, accumulators=[], decide_fn=None):
Returns:
Color: winning color or None if game exceeded TURNS_LIMIT
"""
initial_game_state = self.copy()
for accumulator in accumulators:
accumulator.initialize(self)
accumulator.initialize(initial_game_state)
while self.winning_color() is None and self.state.num_turns < TURNS_LIMIT:
action = self.play_tick(decide_fn=decide_fn)
for accumulator in accumulators:
accumulator.step(self, action)
self.play_tick(decide_fn=decide_fn, accumulators=accumulators)
final_game_state = self.copy()
for accumulator in accumulators:
accumulator.finalize(self)
accumulator.finalize(final_game_state)
return self.winning_color()

def play_tick(self, decide_fn=None):
def play_tick(self, decide_fn=None, accumulators=[]):
"""Advances game by one ply (player decision).
Args:
Expand All @@ -112,6 +120,11 @@ def play_tick(self, decide_fn=None):
if decide_fn is not None
else player.decide(self, actions)
)
# Call accumulator.step here, because we want game_before_action, action
if len(accumulators) > 0:
game_snapshot = self.copy()
for accumulator in accumulators:
accumulator.step(game_snapshot, action)
return self.execute(action)

def execute(self, action: Action, validate_action: bool = True) -> Action:
Expand All @@ -132,7 +145,10 @@ def winning_color(self) -> Union[Color, None]:
winning_player = None
for player in self.state.players:
key = player_key(self.state, player.color)
if self.state.player_state[f"{key}_ACTUAL_VICTORY_POINTS"] >= 10:
if (
self.state.player_state[f"{key}_ACTUAL_VICTORY_POINTS"]
>= self.vps_to_win
):
winning_player = player

return None if winning_player is None else winning_player.color
Expand All @@ -147,5 +163,6 @@ def copy(self) -> "Game":
game_copy = Game([], None, None, initialize=False)
game_copy.seed = self.seed
game_copy.id = self.id
game_copy.vps_to_win = self.vps_to_win
game_copy.state = self.state.copy()
return game_copy
4 changes: 2 additions & 2 deletions catanatron_core/catanatron/json.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from enum import Enum

from catanatron.game import Game
from catanatron.models.map import Water, Port, Tile
from catanatron.models.map import Water, Port, LandTile
from catanatron.models.player import Color
from catanatron.models.decks import Deck
from catanatron.models.enums import Resource, Action, ActionType
Expand Down Expand Up @@ -103,7 +103,7 @@ def default(self, obj):
"direction": self.default(obj.direction),
"resource": self.default(obj.resource),
}
if isinstance(obj, Tile):
if isinstance(obj, LandTile):
if obj.resource is None:
return {"id": obj.id, "type": "DESERT"}
return {
Expand Down
2 changes: 1 addition & 1 deletion catanatron_core/catanatron/models/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ def city_possibilities(state, color) -> List[Action]:

def robber_possibilities(state, color) -> List[Action]:
actions = []
for coordinate, tile in state.board.map.resource_tiles:
for (coordinate, tile) in state.board.map.land_tiles.items():
if coordinate == state.board.robber_coordinate:
continue # ignore. must move robber.

Expand Down
41 changes: 22 additions & 19 deletions catanatron_core/catanatron/models/board.py
Original file line number Diff line number Diff line change
@@ -1,38 +1,39 @@
import pickle
from collections import defaultdict
from typing import Set, Tuple
import functools

import networkx as nx

from catanatron.models.player import Color
from catanatron.models.map import BaseMap, NUM_NODES
from catanatron.models.map import (
BASE_MAP_TEMPLATE,
MINI_MAP_TEMPLATE,
NUM_NODES,
CatanMap,
)
from catanatron.models.enums import BuildingType


NODE_DISTANCES = None
EDGES = None

# Used to find relationships between nodes and edges
sample_map = BaseMap()
base_map = CatanMap(BASE_MAP_TEMPLATE)
mini_map = CatanMap(MINI_MAP_TEMPLATE)
STATIC_GRAPH = nx.Graph()
for tile in sample_map.tiles.values():
for tile in base_map.tiles.values():
STATIC_GRAPH.add_nodes_from(tile.nodes.values())
STATIC_GRAPH.add_edges_from(tile.edges.values())


@functools.lru_cache(1)
def get_node_distances():
global NODE_DISTANCES, STATIC_GRAPH
if NODE_DISTANCES is None:
NODE_DISTANCES = nx.floyd_warshall(STATIC_GRAPH)

return NODE_DISTANCES
global STATIC_GRAPH
return nx.floyd_warshall(STATIC_GRAPH)


def get_edges():
global EDGES, STATIC_GRAPH
if EDGES is None:
EDGES = list(STATIC_GRAPH.subgraph(range(NUM_NODES)).edges())
return EDGES
@functools.lru_cache(3) # None, range(54), range(24)
def get_edges(land_nodes=None):
global STATIC_GRAPH, NUM_NODES
return list(STATIC_GRAPH.subgraph(land_nodes or range(NUM_NODES)).edges())


EdgeId = Tuple[int, int]
Expand Down Expand Up @@ -60,15 +61,17 @@ class Board:

def __init__(self, catan_map=None, initialize=True):
if initialize:
self.map = catan_map or BaseMap() # Static State (no need to copy)
self.map = catan_map or CatanMap(
BASE_MAP_TEMPLATE
) # Static State (no need to copy)

self.buildings = dict() # node_id => (color, building_type)
self.roads = dict() # (node_id, node_id) => color

# color => int{}[] (list of node_id sets) one per component
# nodes in sets are incidental (might not be owned by player)
self.connected_components = defaultdict(list)
self.board_buildable_ids = set(range(NUM_NODES))
self.board_buildable_ids = set(self.map.land_nodes)
self.road_lengths = defaultdict(int)
self.road_color = None
self.road_length = 0
Expand Down Expand Up @@ -244,7 +247,7 @@ def buildable_node_ids(self, color: Color, initial_build_phase=False):
def buildable_edges(self, color: Color):
"""List of (n1,n2) tuples. Edges are in n1 < n2 order."""
global STATIC_GRAPH
buildable_subgraph = STATIC_GRAPH.subgraph(range(NUM_NODES))
buildable_subgraph = STATIC_GRAPH.subgraph(self.map.land_nodes)
expandable = set()

# non-enemy-nodes in your connected components
Expand Down
Loading

0 comments on commit cf0b483

Please sign in to comment.