Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions catanatron/catanatron/apply_action.py
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,9 @@ def apply_roll(state: State, action: Action, action_record=None):
key = player_key(state, action.color)
state.player_state[f"{key}_HAS_ROLLED"] = True

dices = action_record.result if action_record is not None else roll_dice()
dices = (
action_record.result if action_record is not None else roll_dice(state.random)
)
number = dices[0] + dices[1]
action = Action(action.color, action.action_type, dices)

Expand Down Expand Up @@ -524,13 +526,14 @@ def apply_cancel_trade(state: State, action: Action):


# ===== Helper Functions =====
def roll_dice():
def roll_dice(rng=None):
"""Yields two random numbers

Returns:
tuple[int, int]: 2-tuple of random numbers from 1 to 6 inclusive.
"""
return (random.randint(1, 6), random.randint(1, 6))
rng = rng if rng is not None else random
return (rng.randint(1, 6), rng.randint(1, 6))


def yield_resources(board: Board, resource_freqdeck, number):
Expand Down
4 changes: 3 additions & 1 deletion catanatron/catanatron/game.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ def __init__(
"""
if initialize:
self.seed = seed if seed is not None else random.randrange(sys.maxsize)
random.seed(self.seed)
self.random = random.Random(self.seed)

self.id = str(uuid.uuid4())
self.vps_to_win = vps_to_win
Expand All @@ -126,6 +126,7 @@ def __init__(
discard_limit=discard_limit,
friendly_robber=friendly_robber,
number_placement=number_placement,
rng=self.random,
)
self.playable_actions = generate_playable_actions(self.state)

Expand Down Expand Up @@ -220,6 +221,7 @@ def copy(self) -> "Game":
"""
game_copy = Game(players=[], initialize=False)
game_copy.seed = self.seed
game_copy.random = self.random
game_copy.id = self.id
game_copy.vps_to_win = self.vps_to_win
game_copy.friendly_robber = self.friendly_robber
Expand Down
13 changes: 9 additions & 4 deletions catanatron/catanatron/models/map.py
Original file line number Diff line number Diff line change
Expand Up @@ -207,8 +207,11 @@ def __init__(
def from_template(
map_template: MapTemplate,
number_placement: NumberPlacement = "official_spiral",
rng=None,
):
tiles = initialize_tiles(map_template, number_placement=number_placement)
tiles = initialize_tiles(
map_template, number_placement=number_placement, rng=rng
)

return CatanMap.from_tiles(tiles)

Expand Down Expand Up @@ -313,6 +316,7 @@ def initialize_tiles(
shuffled_port_resources_param=None,
shuffled_tile_resources_param=None,
number_placement: NumberPlacement = "official_spiral",
rng=None,
) -> Dict[Coordinate, Tile]:
"""Initializes a new random board, based on the MapTemplate.

Expand All @@ -329,13 +333,14 @@ def initialize_tiles(
Returns:
Dict[Coordinate, Tile]: Coordinate to initialized Tile mapping.
"""
shuffled_port_resources = shuffled_port_resources_param or random.sample(
rng = rng if rng is not None else random
shuffled_port_resources = shuffled_port_resources_param or rng.sample(
map_template.port_resources, len(map_template.port_resources)
)
shuffled_tile_resources = shuffled_tile_resources_param or random.sample(
shuffled_tile_resources = shuffled_tile_resources_param or rng.sample(
map_template.tile_resources, len(map_template.tile_resources)
)
shuffled_numbers = shuffled_numbers_param or random.sample(
shuffled_numbers = shuffled_numbers_param or rng.sample(
map_template.numbers, len(map_template.numbers)
)

Expand Down
3 changes: 1 addition & 2 deletions catanatron/catanatron/models/player.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import random
import builtins

from enum import Enum
Expand Down Expand Up @@ -85,4 +84,4 @@ class RandomPlayer(Player):
"""Random AI player that selects an action randomly from the list of playable_actions"""

def decide(self, game, playable_actions):
return random.choice(playable_actions)
return game.state.random.choice(playable_actions)
5 changes: 3 additions & 2 deletions catanatron/catanatron/players/mcts.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import math
import time
from collections import defaultdict
import random

from catanatron.game import Game
from catanatron.models.player import Player
Expand Down Expand Up @@ -99,7 +98,9 @@ def select(self):
children = self.children[action]
children_states = list(map(lambda c: c[0], children))
children_probas = list(map(lambda c: c[1], children))
return random.choices(children_states, weights=children_probas, k=1)[0]
return self.game.state.random.choices(
children_states, weights=children_probas, k=1
)[0]

def choose_best_action(self):
scores = []
Expand Down
5 changes: 2 additions & 3 deletions catanatron/catanatron/players/minimax.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import time
import random
from typing import Any

from catanatron.game import Game
Expand Down Expand Up @@ -57,8 +56,8 @@ def decide(self, game: Game, playable_actions):
if len(actions) == 1:
return actions[0]

if self.epsilon is not None and random.random() < self.epsilon:
return random.choice(playable_actions)
if self.epsilon is not None and game.state.random.random() < self.epsilon:
return game.state.random.choice(playable_actions)

start = time.time()
state_id = str(len(game.state.action_records))
Expand Down
39 changes: 31 additions & 8 deletions catanatron/catanatron/players/playouts.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import time
import random
import multiprocessing
import random
import time
from collections import Counter

from catanatron.game import Game
Expand Down Expand Up @@ -55,25 +55,48 @@ def decide(self, game: Game, playable_actions):

def run_playouts(action_applied_game_copy, num_playouts):
start = time.time()
params = []
for _ in range(num_playouts):
params.append(action_applied_game_copy)
params = [
(action_applied_game_copy, seed)
for seed in _derive_playout_seeds(action_applied_game_copy, num_playouts)
]
if USE_MULTIPROCESSING:
with multiprocessing.Pool(NUM_WORKERS) as p:
counter = Counter(p.map(run_playout, params))
counter = Counter(p.map(_run_seeded_playout, params))
else:
counter = Counter(map(run_playout, params))
counter = Counter(map(_run_seeded_playout, params))
duration = time.time() - start
# print(f"{num_playouts} playouts took: {duration}. Results: {counter}")
return counter


def _derive_playout_seeds(game, count):
"""Derive distinct deterministic child seeds without advancing the game.

Pool workers deserialize their inputs independently. Passing the same game
repeatedly would therefore restart every worker from the same RNG state
and turn supposedly random playouts into identical simulations.
"""
seed_rng = random.Random()
seed_rng.setstate(game.state.random.getstate())
return [seed_rng.getrandbits(128) for _ in range(count)]


def _run_seeded_playout(params):
game, seed = params
game_copy = game.copy()
rng = random.Random(seed)
game_copy.random = rng
game_copy.state.random = rng
game_copy.play(decide_fn=decide_fn)
return game_copy.winning_color()


def run_playout(action_applied_game_copy):
game_copy = action_applied_game_copy.copy()
game_copy.play(decide_fn=decide_fn)
return game_copy.winning_color()


def decide_fn(self, game, playable_actions):
index = random.randrange(0, len(playable_actions))
index = game.state.random.randrange(0, len(playable_actions))
return playable_actions[index]
3 changes: 1 addition & 2 deletions catanatron/catanatron/players/search.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import random

from catanatron.state_functions import (
player_key,
Expand Down Expand Up @@ -32,4 +31,4 @@ def decide(self, game: Game, playable_actions):
best_value = value
best_actions = [action]

return random.choice(best_actions)
return game.state.random.choice(best_actions)
5 changes: 2 additions & 3 deletions catanatron/catanatron/players/value.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import random

from catanatron.state_functions import (
get_longest_road_length,
Expand Down Expand Up @@ -162,8 +161,8 @@ def decide(self, game, playable_actions):
if len(playable_actions) == 1:
return playable_actions[0]

if self.epsilon is not None and random.random() < self.epsilon:
return random.choice(playable_actions)
if self.epsilon is not None and game.state.random.random() < self.epsilon:
return game.state.random.choice(playable_actions)

best_value = float("-inf")
best_action = None
Expand Down
4 changes: 1 addition & 3 deletions catanatron/catanatron/players/weighted_random.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import random

from catanatron.models.player import Player
from catanatron.models.actions import ActionType

Expand All @@ -23,4 +21,4 @@ def decide(self, game, playable_actions):
weight = WEIGHTS_BY_ACTION_TYPE.get(action.action_type, 1)
bloated_actions.extend([action] * weight)

return random.choice(bloated_actions)
return game.state.random.choice(bloated_actions)
13 changes: 10 additions & 3 deletions catanatron/catanatron/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,12 +93,17 @@ def __init__(
friendly_robber=False,
number_placement: NumberPlacement = "official_spiral",
initialize=True,
rng=None,
):
if initialize:
self.players = random.sample(players, len(players))
self.random = rng if rng is not None else random.Random()
self.players = self.random.sample(players, len(players))
self.colors = tuple([player.color for player in self.players])
self.board = Board(
catan_map or CatanMap.from_template(BASE_MAP_TEMPLATE, number_placement)
catan_map
or CatanMap.from_template(
BASE_MAP_TEMPLATE, number_placement, rng=self.random
)
)
self.discard_limit = discard_limit
self.friendly_robber = friendly_robber
Expand All @@ -114,7 +119,7 @@ def __init__(

self.resource_freqdeck = starting_resource_bank()
self.development_listdeck = starting_devcard_bank()
random.shuffle(self.development_listdeck)
self.random.shuffle(self.development_listdeck)

# Auxiliary attributes to implement game logic
self.buildings_by_color: Dict[Color, Dict[Any, Any]] = {
Expand Down Expand Up @@ -158,6 +163,8 @@ def copy(self):
State: State copy.
"""
state_copy = State([], None, initialize=False)
# Shared by reference: simulations on copies must advance the same stream.
state_copy.random = self.random
state_copy.players = self.players
state_copy.discard_limit = self.discard_limit # immutable
state_copy.friendly_robber = self.friendly_robber # immutable
Expand Down
3 changes: 1 addition & 2 deletions catanatron/catanatron/state_functions.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
of the code decoupled from state representation.
"""

import random
from typing import Optional

from catanatron.models.decks import ROAD_COST_FREQDECK, freqdeck_add
Expand Down Expand Up @@ -321,7 +320,7 @@ def player_deck_replenish(state: State, color, resource, amount=1):

def player_deck_random_select(state: State, color):
deck_array = player_deck_to_array(state, color)
return random.choice(deck_array)
return state.random.choice(deck_array)


def play_dev_card(state: State, color, dev_card):
Expand Down
29 changes: 28 additions & 1 deletion tests/test_game.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
player_clean_turn,
player_has_rolled,
)
from catanatron.game import Game, is_valid_trade
from catanatron.game import TURNS_LIMIT, Game, is_valid_trade
from catanatron.apply_action import apply_action
from catanatron.state_functions import (
player_key,
Expand Down Expand Up @@ -654,3 +654,30 @@ def test_cannot_extend_road_past_enemy_settlement_at_endpoint():
f"Blue should not be able to build a road from enemy-settled node 10, "
f"but found: {[e for e in blue_edges if 10 in e]}"
)


def test_seeded_games_are_isolated_from_each_other():
# Play two games interleaved and check both match their solo runs.
def make_players():
return [RandomPlayer(Color.RED), RandomPlayer(Color.BLUE)]

def play_solo(seed):
game = Game(make_players(), seed=seed)
game.play()
return [str(a) for a in game.state.action_records]

solo_a, solo_b = play_solo(7), play_solo(11)

def ongoing(game):
return game.winning_color() is None and game.state.num_turns < TURNS_LIMIT

game_a = Game(make_players(), seed=7)
game_b = Game(make_players(), seed=11)
while ongoing(game_a) or ongoing(game_b):
if ongoing(game_a):
game_a.play_tick()
if ongoing(game_b):
game_b.play_tick()

assert [str(a) for a in game_a.state.action_records] == solo_a
assert [str(a) for a in game_b.state.action_records] == solo_b
22 changes: 14 additions & 8 deletions tests/test_gym.py
Original file line number Diff line number Diff line change
Expand Up @@ -251,8 +251,7 @@ def test_action_space_conversion_roundtrip():
), f"Action conversion failed: {action} -> {action_int} -> {recovered_action}"


def test_gym_reproducibility():
# Play a game with the same seed, and ensure the game is the same
def _play_seeded_gym_game(seed):
env = gymnasium.make(
"catanatron/Catanatron-v0",
config={
Expand All @@ -261,24 +260,31 @@ def test_gym_reproducibility():
]
},
)
observation, info = env.reset(seed=123)
env.action_space.seed(123)
observation, info = env.reset(seed=seed)
rng = random.Random(seed)

game = env.unwrapped.game
center_tile = game.state.board.map.land_tiles[(0, 0, 0)]
assert center_tile.resource == ORE
assert center_tile.number == 11

done = False
reward = 0
while not done:
action_mask = env.action_masks()
action_mask = env.unwrapped.action_masks()
valid_indices = np.flatnonzero(action_mask)
action = random.choice(valid_indices)
action = rng.choice(list(valid_indices))

observation, reward, terminated, truncated, info = env.step(action)
done = terminated or truncated
game = env.unwrapped.game
game_json = json.loads(json.dumps(game, cls=GameEncoder))
env.close()
return game_json


def test_gym_reproducibility():
# The same seed and action sequence must yield the same game.
first = _play_seeded_gym_game(123)
second = _play_seeded_gym_game(123)

assert game_json["state_index"] == 125
assert first == second
Loading