Skip to content

Commit

Permalink
Implement Domestic Trading Mechanics
Browse files Browse the repository at this point in the history
  • Loading branch information
bcollazo committed Aug 27, 2022
1 parent 4b05b1c commit e456d69
Show file tree
Hide file tree
Showing 7 changed files with 361 additions and 13 deletions.
37 changes: 33 additions & 4 deletions catanatron_core/catanatron/game.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,45 @@
import sys
from typing import Iterable, Union

from catanatron.models.enums import Action
from catanatron.models.enums import Action, ActionPrompt, ActionType
from catanatron.state import State, apply_action
from catanatron.state_functions import player_key
from catanatron.state_functions import player_key, player_has_rolled
from catanatron.models.map import CatanMap
from catanatron.models.player import Color, Player

# To timeout RandomRobots from getting stuck...
TURNS_LIMIT = 1000


def is_valid_action(game, action):
"""True if its a valid action right now. An action is valid
if its in playable_actions or if its a OFFER_TRADE in the right time."""
if action.action_type == ActionType.OFFER_TRADE:
return (
game.state.current_color() == action.color
and game.state.current_prompt == ActionPrompt.PLAY_TURN
and player_has_rolled(game.state, action.color)
and is_valid_trade(action.value)
)

return action in game.state.playable_actions


def is_valid_trade(action_value):
"""Checks the value of a OFFER_TRADE does not
give away resources or trade matching resources.
"""
offering = action_value[:5]
asking = action_value[5:]
if sum(offering) == 0 or sum(asking) == 0:
return False # cant give away cards

for i, j in zip(offering, asking):
if i > 0 and j > 0:
return False # cant trade same resources
return True


class GameAccumulator:
"""Interface to hook into different game lifecycle events.
Expand Down Expand Up @@ -131,9 +160,9 @@ def play_tick(self, decide_fn=None, accumulators=[]):

def execute(self, action: Action, validate_action: bool = True) -> Action:
"""Internal call that carries out decided action by player"""
if validate_action and action not in self.state.playable_actions:
if validate_action and not is_valid_action(self, action):
raise ValueError(
f"{action} not in playable actions: {self.state.playable_actions}"
f"{action} not playable right now. playable_actions={self.state.playable_actions}"
)

return apply_action(self.state, action)
Expand Down
27 changes: 26 additions & 1 deletion catanatron_core/catanatron/models/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
)
from catanatron.state_functions import (
get_player_buildings,
get_player_freqdeck,
player_can_afford_dev_card,
player_can_play_dev,
player_has_rolled,
Expand Down Expand Up @@ -89,8 +90,32 @@ def generate_playable_actions(state) -> List[Action]:
return actions
elif action_prompt == ActionPrompt.DISCARD:
return discard_possibilities(color)
elif action_prompt == ActionPrompt.DECIDE_TRADE:
actions = [Action(color, ActionType.REJECT_TRADE, state.current_trade)]

# can only accept if have enough cards
freqdeck = get_player_freqdeck(state, color)
asked = state.current_trade[5:10]
if freqdeck_contains(freqdeck, asked):
actions.append(Action(color, ActionType.ACCEPT_TRADE, state.current_trade))

return actions
elif action_prompt == ActionPrompt.DECIDE_ACCEPTEES:
# you should be able to accept for each of the "accepting players"
actions = [Action(color, ActionType.CANCEL_TRADE, None)]

for (other_color, accepted) in zip(state.colors, state.acceptees):
if accepted:
actions.append(
Action(
color,
ActionType.CONFIRM_TRADE,
(*state.current_trade[:10], other_color),
)
)
return actions
else:
raise RuntimeError("Unknown ActionPrompt")
raise RuntimeError("Unknown ActionPrompt: " + str(action_prompt))


def monopoly_possibilities(color) -> List[Action]:
Expand Down
15 changes: 12 additions & 3 deletions catanatron_core/catanatron/models/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ class ActionPrompt(Enum):
PLAY_TURN = "PLAY_TURN"
DISCARD = "DISCARD"
MOVE_ROBBER = "MOVE_ROBBER"
DECIDE_TRADE = "DECIDE_TRADE"
DECIDE_ACCEPTEES = "DECIDE_ACCEPTEES"


class ActionType(Enum):
Expand All @@ -67,12 +69,19 @@ class ActionType(Enum):
PLAY_MONOPOLY = "PLAY_MONOPOLY" # value is Resource
PLAY_ROAD_BUILDING = "PLAY_ROAD_BUILDING" # value is None

# Trade
# ===== Trade
# MARITIME_TRADE value is 5-resouce tuple, where last resource is resource asked.
# resources in index 2 and 3 might be None, denoting a port-trade.
MARITIME_TRADE = "MARITIME_TRADE"

# TODO: Domestic trade. Im thinking should contain SUGGEST_TRADE, ACCEPT_TRADE actions...
# Domestic Trade (player to player trade)
# Values for all three is a 10-resource tuple, first 5 is offered freqdeck, last 5 is
# receiving freqdeck.
OFFER_TRADE = "OFFER_TRADE"
ACCEPT_TRADE = "ACCEPT_TRADE"
REJECT_TRADE = "REJECT_TRADE"
# CONFIRM_TRADE value is 11-tuple. first 10 as in OFFER_TRADE, last is color of accepting player
CONFIRM_TRADE = "CONFIRM_TRADE"
CANCEL_TRADE = "CANCEL_TRADE" # value is None

END_TURN = "END_TURN" # value is None

Expand Down
3 changes: 2 additions & 1 deletion catanatron_core/catanatron/models/player.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ def __init__(self, color, is_bot=True):
self.is_bot = is_bot

def decide(self, game, playable_actions):
"""Should return one of the playable_actions.
"""Should return one of the playable_actions or
an OFFER_TRADE action if its your turn and you have already rolled.
Args:
game (Game): complete game state. read-only.
Expand Down
91 changes: 90 additions & 1 deletion catanatron_core/catanatron/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import random
import pickle
from collections import defaultdict
from typing import Any, List
from typing import Any, List, Tuple

from catanatron.models.map import BASE_MAP_TEMPLATE, CatanMap
from catanatron.models.board import Board
Expand Down Expand Up @@ -166,6 +166,10 @@ def __init__(
self.is_road_building = False
self.free_roads_available = 0

self.is_resolving_trade = False
self.current_trade: Tuple = (0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)
self.acceptees = tuple(False for _ in self.colors)

self.playable_actions = generate_playable_actions(self)

def current_player(self):
Expand Down Expand Up @@ -214,6 +218,10 @@ def copy(self):
state_copy.is_road_building = self.is_road_building
state_copy.free_roads_available = self.free_roads_available

state_copy.is_resolving_trade = self.is_resolving_trade
state_copy.current_trade = self.current_trade
state_copy.acceptees = self.acceptees

state_copy.playable_actions = self.playable_actions
return state_copy

Expand Down Expand Up @@ -591,9 +599,90 @@ def apply_action(state: State, action: Action):
# state.current_player_index stays the same
state.current_prompt = ActionPrompt.PLAY_TURN
state.playable_actions = generate_playable_actions(state)
elif action.action_type == ActionType.OFFER_TRADE:
state.is_resolving_trade = True
state.current_trade = (*action.value, state.current_turn_index)

# go in seating order; order won't matter because of "acceptees hook"
state.current_player_index = next(
i for i, c in enumerate(state.colors) if c != action.color
) # cant ask yourself
state.current_prompt = ActionPrompt.DECIDE_TRADE

state.playable_actions = generate_playable_actions(state)
elif action.action_type == ActionType.ACCEPT_TRADE:
# add yourself to self.acceptees
index = state.colors.index(action.color)
new_acceptess = list(state.acceptees)
new_acceptess[index] = True # type: ignore
state.acceptees = tuple(new_acceptess)

try:
# keep going around table w/o asking yourself or players that have answered
state.current_player_index = next(
i
for i, c in enumerate(state.colors)
if c != action.color and i > state.current_player_index
)
# .is_resolving_trade, .current_trade, .current_prompt, .acceptees stay the same
except StopIteration:
# by this action, there is at least 1 acceptee, so go to DECIDE_ACCEPTEES
# .is_resolving_trade, .current_trade, .acceptees stay the same
state.current_player_index = state.current_turn_index
state.current_prompt = ActionPrompt.DECIDE_ACCEPTEES

state.playable_actions = generate_playable_actions(state)
elif action.action_type == ActionType.REJECT_TRADE:
try:
# keep going around table w/o asking yourself or players that have answered
state.current_player_index = next(
i
for i, c in enumerate(state.colors)
if c != action.color and i > state.current_player_index
)
# .is_resolving_trade, .current_trade, .current_prompt, .acceptees stay the same
except StopIteration:
# if no acceptees at this point, go back to PLAY_TURN
if sum(state.acceptees) == 0:
reset_trading_state(state)

state.current_player_index = state.current_turn_index
state.current_prompt = ActionPrompt.PLAY_TURN
else:
# go to offering player with all the answers
# .is_resolving_trade, .current_trade, .acceptees stay the same
state.current_player_index = state.current_turn_index
state.current_prompt = ActionPrompt.DECIDE_ACCEPTEES

state.playable_actions = generate_playable_actions(state)
elif action.action_type == ActionType.CONFIRM_TRADE:
# apply trade
offering = action.value[:5]
asking = action.value[5:10]
enemy = action.value[10]
player_freqdeck_subtract(state, action.color, offering)
player_freqdeck_add(state, action.color, asking)
player_freqdeck_subtract(state, enemy, asking)
player_freqdeck_add(state, enemy, offering)

reset_trading_state(state)

state.current_player_index = state.current_turn_index
state.current_prompt = ActionPrompt.PLAY_TURN
elif action.action_type == ActionType.CANCEL_TRADE:
reset_trading_state(state)

state.current_player_index = state.current_turn_index
state.current_prompt = ActionPrompt.PLAY_TURN
else:
raise ValueError("Unknown ActionType " + str(action.action_type))

# TODO: Think about possible-action/idea vs finalized-action design
state.actions.append(action)
return action


def reset_trading_state(state):
state.is_resolving_trade = False
state.current_trade = (0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)
state.acceptees = tuple(False for _ in state.colors)
11 changes: 11 additions & 0 deletions catanatron_core/catanatron/state_functions.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,17 @@ def get_player_buildings(state, color_param, building_type_param):
return state.buildings_by_color[color_param][building_type_param]


def get_player_freqdeck(state, color):
key = player_key(state, color)
return [
state.player_state[f"{key}_WOOD_IN_HAND"],
state.player_state[f"{key}_BRICK_IN_HAND"],
state.player_state[f"{key}_SHEEP_IN_HAND"],
state.player_state[f"{key}_WHEAT_IN_HAND"],
state.player_state[f"{key}_ORE_IN_HAND"],
]


# ===== State Mutators
def build_settlement(state, color, node_id, is_free):
state.buildings_by_color[color][SETTLEMENT].append(node_id)
Expand Down
Loading

0 comments on commit e456d69

Please sign in to comment.