"""A Python engine for Risk-like games """ import random from .log import Logger VERSION='0.1' class PlayerError (Exception): pass class NameMixin (object): """Simple mixin for pretty-printing named objects. """ def __init__(self, name): self.name = name def __str__(self): return self.name def __repr__(self): return self.__str__() class ID_CmpMixin (object): """Simple mixin to ensure the fancier comparisons are all based on __cmp__(). """ def __cmp__(self, other): return cmp(id(self), id(other)) def __eq__(self, other): return self.__cmp__(other) == 0 def __ne__(self, other): return self.__cmp__(other) != 0 class Territory (NameMixin, ID_CmpMixin, list): """An occupiable territory. Contains a list of neighboring territories. """ def __init__(self, name, short_name=None, type=-1, link_names=[], continent=None, player=None): NameMixin.__init__(self, name) ID_CmpMixin.__init__(self) list.__init__(self) self.short_name = short_name if short_name == None: self.short_name = name self._card_type = type # for Deck construction self._link_names = list(link_names) # used by World._resolve_link_names self.continent = continent # used by World.production self.player = player # who owns this territory self.armies = 0 # number of occupying armies def __str__(self): if self.short_name == self.name: return self.name return '%s (%s)' % (self.name, self.short_name) def borders(self, other): for t in self: if id(t) == id(other): return True return False class Continent (NameMixin, ID_CmpMixin, list): """A group of Territories. Stores the army-production bonus if it's owned by a single player. """ def __init__(self, name, production, territories=[]): NameMixin.__init__(self, name) ID_CmpMixin.__init__(self) list.__init__(self, territories) self.production = production def append(self, territory): """Add a new territory (setting the territory's .continent attribute). """ list.append(self, territory) territory.continent = self def territory_by_name(self, name): """Find a Territory instance by name (long or short, case insensitive). """ for t in self: if name.lower() in [t.short_name.lower(), t.name.lower()]: return t raise KeyError(name) def single_player(self): """Is the continent owned by a single player? """ p = self[0].player for territory in self: if territory.player != p: return False return True class World (NameMixin, ID_CmpMixin, list): """Store the world map and current world state. Holds list of Continents. Also controls territory-based army production (via production). """ def __init__(self, name, continents=[]): NameMixin.__init__(self, name) ID_CmpMixin.__init__(self) list.__init__(self, continents) self.initial_armies = { # num_players:num_armies 2: 40, 3:35, 4:30, 5:25, 6:20 } def territories(self): """Iterate through all the territories in the world. """ for continent in self: for territory in continent: yield territory def territory_by_name(self, name): """Find a Territory instance by name (long or short, case insensitive). """ for continent in self: try: return continent.territory_by_name(name) except KeyError: pass raise KeyError(name) def continent_by_name(self, name): """Find a Continent instance by name (case insensitive). """ for continent in self: if continent.name.lower() == name.lower(): return continent raise KeyError(name) def _resolve_link_names(self): """Initialize Territory links. The Territory class doesn't actually link to neighbors after initialization, but one of each linked pair has the others name in _link_names. This method goes through the territories, looks up the referenced link target, and joins the pair. """ self._check_short_names() for territory in self.territories(): for name in territory._link_names: other = self.territory_by_name(name) if not territory.borders(other): territory.append(other) if not other.borders(territory): other.append(territory) def _check_short_names(self): """Ensure there are no short_name collisions. """ ts = {} for t in self.territories(): if t.short_name.lower() not in ts: ts[t.short_name.lower()] = t else: raise ValueError('%s shared by %s and %s' % (t.short_name.lower(), ts[t.short_name.lower()], t)) def production(self, player): """Calculate the number of armies a player should earn based on territory occupation. """ ts = list(player.territories(self)) production = max(3, len(ts) / 3) continents = set([t.continent.name for t in ts]) for c_name in continents: c = self.continent_by_name(c_name) if c.single_player() == True: production += c.production return (production, {}) def place_territory_production(self, territory_production): """Place armies based on {territory_name: num_armies, ...}. """ for territory_name,production in territory_production.items(): t = self.territory_by_name(territory_name) t.armies += production class Card (ID_CmpMixin): """Represent a territory card (or wild) Nothing exciting going on here, just a class for pretty-printing card names. """ def __init__(self, deck, type_, territory=None): ID_CmpMixin.__init__(self) self.deck = deck self.territory = territory self.type = type_ def __str__(self): if self.territory == None: return '' % (self.deck.type_names[self.type]) return '' % (self.territory, self.deck.type_names[self.type]) def __repr__(self): return self.__str__() class Deck (list): """All the cards yet to be handed out in a given game. Controls the type branding (via type_names) and army production values for scoring sets (via production_value). """ def __init__(self, territories=[], num_wilds=2, type_names=['Wild', 'Infantry', 'Cavalry', 'Artillery']): list.__init__(self, [Card(self, t._card_type, t) for t in territories]) self.type_names = type_names for i in range(num_wilds): self.append(Card(self, 0)) self._production_sequence = [4, 6, 8, 10, 12, 15] self._production_index = 0 def shuffle(self): """Shuffle the remaining cards in the deck. """ random.shuffle(self) def production_value(self, index): """ >>> d = Deck() >>> [d.production_value(i) for i in range(8)] [4, 6, 8, 10, 12, 15, 20, 25] """ if index < len(self._production_sequence): return self._production_sequence[index] extra = index - len(self._production_sequence) + 1 return self._production_sequence[-1] + 5 * extra def production(self, player, cards=None): """ >>> d = Deck() >>> a = Player('Alice') >>> b = Player('Bob') >>> d.production(a, None) (0, {}) >>> d.production(a, [Card(d, 1, Territory('a')), ... Card(d, 1, Territory('b'))]) Traceback (most recent call last): ... PlayerError: [, ] is not a scoring set >>> d.production(a, [Card(d, 1, Territory('a', player=a)), ... Card(d, 1, Territory('b', player=b)), ... Card(d, 1, Territory('c'))]) (4, {'a': 1}) >>> p,tp = d.production(a, [Card(d, 1, Territory('a', player=a)), ... Card(d, 2, Territory('b', player=a)), ... Card(d, 0, Territory('c', player=a))]) >>> p 6 >>> sorted(tp.items()) [('a', 1), ('b', 1), ('c', 1)] """ if cards == None: return (0, {}) h = Hand(cards) if h.scores(): p = self.production_value(self._production_index) self._production_index += 1 territory_production = {} for c in cards: if c.territory != None and c.territory.player == player: territory_production[c.territory.name] = 1 return (p, territory_production) raise PlayerError('%s is not a scoring set' % h) class Hand (list): """Represent a hand of cards. This is the place to override the set of allowed scoring combinations. You should override one of * set * run * scores Adding additional scoring methods as needed (e.g. flush). """ def __init__(self, cards=[]): list.__init__(self, cards) def set(self): if len(self) != 3: return False s = sorted(set([card.type for card in self])) if len(s) == 1 \ or (len(s) == 2 and s[0] == 0): return True return False def run(self): if len(self) != 3: return False if len(set([card.type for card in self])) == 3: return True return False def scores(self): """The hand is any valid scoring combination. """ return self.set() or self.run() def subhands(self, lengths=None): """Return all possible subhands. Lengths can either be a list of allowed subhand lengths or None. If None, all possible subhand lengths are allowed. >>> d = Deck() >>> h = Hand([Card(d, 1, Territory('a')), ... Card(d, 1, Territory('b')), ... Card(d, 1, Territory('c')), ... Card(d, 1, Territory('d'))]) >>> for hand in h.subhands(): ... print hand [] [] [] [] [, ] [, ] [, ] [, ] [, ] [, ] [, , ] [, , ] [, , ] [, , ] [, , , ] """ for i in range(len(self)): i += 1 # check all sub-hands of length i if lengths != None and i not in lengths: continue # don't check this length indices = range(i) stop = range(len(self)-i, len(self)) while indices != stop: yield Hand([self[i] for i in indices]) indices = self._increment(indices, stop) yield Hand([self[i] for i in indices]) def _increment(self, indices, stop): """ >>> d = Deck() >>> h = Hand([Card(d, 1, Territory('a'))]) >>> h._increment([0, 1, 2], [2, 3, 4]) [0, 1, 3] >>> h._increment([0, 1, 3], [2, 3, 4]) [0, 1, 4] >>> h._increment([0, 1, 4], [2, 3, 4]) [0, 2, 3] """ moveable = [i for i,m in zip(indices, stop) if i < m] assert len(moveable) > 0, 'At stop? indices: %s, stop: %s' % (indices, stop) key = indices.index(moveable[-1]) new = indices[key] + 1 for i in range(key, len(indices)): indices[i] = new + i-key return indices def possible(self): """Return a list of all possible scoring subhands. """ for h in self.subhands(): if h.scores(): yield h class Player (NameMixin, ID_CmpMixin): """Represent a risk player. This class implements a very basic AI player. Subclasses should consider overriding the "action-required" methods: * select_territory * play_cards * place_armies * attack_and_fortify * support_attack And the "report" methods: * report * draw """ def __init__(self, name): NameMixin.__init__(self, name) ID_CmpMixin.__init__(self) self.alive = True self.hand = Hand() self._message_index = 0 def territories(self, world): """Iterate through all territories owned by this player. """ for t in world.territories(): if t.player == self: yield t def border_territories(self, world): """Iterate through all territories owned by this player which border another player's territories. """ for t in self.territories(world): for neighbor in t: if neighbor.player != self: yield t break def report(self, world, log): """Send reports about death and game endings. These events mark the end of contact and require no change in player status or response, so they get a special command seperate from the usual action family. The action commands in Player subclasses can notify the player (possibly by calling report internally) if they feel so inclined. See also -------- draw - another notification-only method """ print 'Reporting for %s:\n %s' \ % (self, '\n '.join(log[self._message_index:])) self._message_index = len(log) def draw(self, world, log, cards=[]): """Only called if you earned a new card (or cards). See also -------- report - another notification-only method """ pass def select_territory(self, world, log): """Return the selected territory """ free_territories = [t for t in world.territories() if t.player == None] return random.sample(free_territories, 1)[0] def play_cards(self, world, log, play_required=True): """Decide whether or not to turn in a set of cards. Return a list of cards to turn in or None. If play_required is True, you *must* play. """ if play_required == True: return random.sample(list(self.hand.possible()), 1)[0] def place_armies(self, world, log, remaining=1, this_round=1): """Both during setup and before each turn. Return {territory_name: num_armies, ...} """ t = random.sample(list(self.border_territories(world)), 1)[0] return {t.name: this_round} def attack_and_fortify(self, world, log, mode='attack'): """Return list of (source, target, armies) tuples. Place None in the list to end this phase. """ assert mode != 'fortify', mode possible_attacks = [] for t in self.border_territories(world): if t.armies <= 3: #1: # be more conservative, only attack with 3 dice continue targets = [border_t for border_t in t if border_t.player != self] for tg in targets: possible_attacks.append((t.name, tg.name, min(3, t.armies-1))) if len(possible_attacks) == 0: return [None, None] # stop attack phase, then stop fortification phase return random.sample(possible_attacks, 1) # + [None] def support_attack(self, world, log, source, target): """Follow up on a conquest by moving additional armies. """ return source.armies-1 class Engine (ID_CmpMixin): """Drive the game. Basic usage will be along the lines of >>> world = generate_earth() >>> players = [Player('Alice'), Player('Bob'), Player('Charlie')] >>> e = Engine(world, players) >>> e.run() # doctest: +ELLIPSIS ... """ def __init__(self, world, players, deck_class=Deck, logger_class=Logger): ID_CmpMixin.__init__(self) self.world = world self.deck = deck_class(world.territories()) self.log = logger_class() self.players = players def __str__(self): return '' % (self.world, self.players) def __repr__(self): return self.__str__() def run(self): """The main entry point. """ self.setup() self.play() self.game_over() def setup(self): """Setup phase. Pick territories, place initial armies, and deal initial hands. """ for p in self.players: p.alive = True random.shuffle(self.players) deck.shuffle() self.select_territories() self.place_initial_armies() for p in self.players: self.deal(p, 3) def play(self): """Main gameplay phase. Take turns until only one Player survives. """ turn = 0 active_player = 0 living = len(self.living_players()) while living > 1: self.play_turn(self.players[active_player]) living = len(self.living_players()) active_player = (active_player + 1) % len(self.players) if living > 1: while self.players[active_player].alive == False: active_player = (active_player + 1) % len(self.players) turn += 1 def game_over(self): """The end of the game. Currently just a notification hook. """ self.log('Game over.') for p in self.players: p.report(self.world, self.log) def play_turn(self, player): """Work through the phases of player's turn. """ self.log("%s's turn (territory score: %s)" % (player, [(p,len(list(p.territories(self.world)))) for p in self.players])) self.play_cards_and_place_armies(player) captures = self.attack_and_fortify(player) if captures > 0 and len(self.deck) > 0 and len(self.living_players()) > 1: self.deal(player, 1) def select_territories(self): for t in self.world.territories(): t.player = None for i in range(len(list(self.world.territories()))): p = self.players[i % len(self.players)] t = p.select_territory(self.world, self.log) if t.player != None: raise PlayerError('Cannot select %s owned by %s' % (t, t.player)) self.log('%s selects %s' % (p, t)) t.player = p t.armies = 1 def place_initial_armies(self): already_placed = [len(list(p.territories(self.world))) for p in self.players] s = list(set(already_placed)) assert len(s) in [1,2], already_placed if len(s) == 2: # catch up the players who are one territory short assert min(s) == max(s)-1, 'Min %d, max %d' % (min(s), max(s)) for p,placed in zip(self.players, already_placed): if placed == min(s): self.player_place_armies(p, remaining, 1) remaining = self.world.initial_armies[len(self.players)] - max(s) while remaining > 0: for p in self.players: self.player_place_armies(p, remaining, 1) remaining -= 1 def player_place_armies(self, player, remaining=1, this_round=1): placements = player.place_armies(self.world, self.log, remaining, this_round) if sum(placements.values()) != this_round: raise PlayerError('Placing more than %d armies' % this_round) for ter_name,armies in placements.items(): t = self.world.territory_by_name(ter_name) if t.player != player: raise PlayerError('Placing armies in %s owned by %s' % (t, t.player)) if armies < 0: raise PlayerError('Placing a negative number of armies (%d) in %s' % (armies, t)) self.log('%s places %s' % (player, placements)) for terr_name,armies in placements.items(): t = self.world.territory_by_name(terr_name) t.armies += armies def deal(self, player, number): cards = [] for i in range(number): cards.append(self.deck.pop()) player.hand.extend(cards) player.draw(self.world, self.log, cards) self.log('%s dealt %d cards' % (player, number)) def play_cards_and_place_armies(self, player, additional_armies=0): cards_required = len(player.hand) >= 5 cards = player.play_cards( self.world, self.log, play_required=cards_required) if cards_required == True and cards == None: raise PlayerError('You have %d >= 5 cards in your hand, you must play' % len(player.hand)) w_prod,w_terr_prod = self.world.production(player) self.log('%s earned %d armies from territories' % (player, w_prod)) c_prod,c_terr_prod = self.deck.production(player, cards) if c_prod > 0: self.log('%s played %s, earning %d armies' % (player, cards, c_prod+sum(c_terr_prod.values()))) if cards != None: for c in cards: player.hand.remove(c) for terr,prod in c_terr_prod.items(): if terr in w_terr_prod: w_terr_prod[terr] += prod else: w_terr_prod[terr] = prod self.world.place_territory_production(w_terr_prod) if len(w_terr_prod) > 0: self.log('%s was required to place %s' % (player, w_terr_prod)) armies = w_prod + c_prod self.player_place_armies(player, armies, armies) def attack_and_fortify(self, player): captures = 0 mode = 'attack' while True: actions = player.attack_and_fortify(self.world, self.log, mode) for action in actions: if action == None: if mode == 'attack': mode = 'fortify' continue else: assert mode == 'fortify', mode return captures source_name,target_name,armies = action source = self.world.territory_by_name(source_name) target = self.world.territory_by_name(target_name) if mode == 'attack': tplayer = target.player capture = self.attack(source, target, armies) if capture == True: captures += 1 if len(list(tplayer.territories(self.world))) == 0: self.player_killed(tplayer, killer=player) else: assert mode == 'fortify', mode self.fortify(source, target, armies) def attack(self, source, target, armies): if source.player == target.player: raise PlayerError('%s attacking %s, but you own both.' % (source, target)) if armies == 0: raise PlayerError('%s attacking %s with 0 armies.' % (source, target)) if armies >= source.armies: raise PlayerError('%s attacking %s with %d armies, but only %d are available.' % (source, target, armies, source.armies-1)) a_dice = sorted([random.randint(1, 6) for i in range(armies)], reverse=True) t_dice = sorted([random.randint(1, 6) for i in range(min(2, target.armies))], reverse=True) a_dead = 0 t_dead = 0 for a,d in zip(a_dice, t_dice): if d >= a: a_dead += 1 else: t_dead += 1 source.armies -= a_dead target.armies -= t_dead if target.armies == 0: self.takeover(source, target, remaining_attackers=armies-a_dead) self.log('%s conquered %s from %s with %d:%d. Deaths %d:%d. Remaining %d:%d' % (source.player, target, source, armies, len(t_dice), a_dead, t_dead, source.armies, target.armies)) assert target.armies > 0, target return True self.log('%s attacked %s from %s with %d:%d. Deaths %d:%d. Remaining %d:%d' \ % (source.player, target, source, armies, len(t_dice), a_dead, t_dead, source.armies, target.armies)) assert target.armies > 0, target return False def takeover(self, source, target, remaining_attackers): source.armies -= remaining_attackers target.armies += remaining_attackers target.player = source.player support = source.player.support_attack(self.world, self.log, source, target) if support < 0 or support >= source.armies: raise PlayerError('Cannot support from %s to %s with %d armies, only %d available' % (source, target, support, source.armies-1)) source.armies -= support target.armies += support def player_killed(self, player, killer): player.alive = False killer.hand.extend(player.hand) if len(self.living_players()) > 1: while len(killer.hand) > 5: self.play_cards_and_place_armies(killer) self.log('%s killed by %s' % (player, killer)) if len(self.living_players()) > 1: player.report(self.world, self.log) # else the game is over, and killed will hear about this then. def living_players(self): return [p for p in self.players if p.alive == True] def generate_earth(): w = World('Earth') c = Continent('North America', 5) c.append(Territory('Alaska', 'ala', 1, ['kam', 'nwt'])) c.append(Territory('Northwest Territory', 'nwt', 2, ['alb', 'ont', 'gre'])) c.append(Territory('Greenland', 'gre', 3, ['ont', 'que', 'ice'])) c.append(Territory('Alberta', 'alb', 1, ['ont', 'wus'])) c.append(Territory('Ontario', 'ont', 2, ['wus', 'eus', 'que'])) c.append(Territory('Quebec', 'que', 3, ['eus'])) c.append(Territory('Western United States', 'wus', 1, ['eus', 'cam'])) c.append(Territory('Eastern United States', 'eus', 2, ['cam'])) c.append(Territory('Central America', 'cam', 3, ['ven'])) w.append(c) c = Continent('Europe', 5) c.append(Territory('Iceland', 'ice', 1, ['gbr', 'sca'])) c.append(Territory('Scandanavia', 'sca', 2, ['gbr', 'neu', 'ukr'])) c.append(Territory('Ukraine', 'ukr', 3, ['neu', 'seu', 'ura', 'afg', 'mea'])) c.append(Territory('Great Britain', 'gbr', 1, ['neu', 'weu'])) c.append(Territory('Northern Europe', 'neu', 2, ['weu', 'seu'])) c.append(Territory('Western Europe', 'weu', 3, ['naf', 'seu'])) c.append(Territory('Southern Europe', 'seu', 1, ['naf', 'egy', 'mea'])) w.append(c) c = Continent('Asia', 7) c.append(Territory('Urals', 'ura', 2, ['afg', 'chi', 'sib'])) c.append(Territory('Siberia', 'sib', 3, ['chi', 'mon', 'irk', 'yak'])) c.append(Territory('Yakutsk', 'yak', 1, ['irk', 'kam'])) c.append(Territory('Kamchatka', 'kam', 2, ['mon', 'jap'])) c.append(Territory('Irkutsk', 'irk', 3, ['mon'])) c.append(Territory('Mongolia', 'mon', 1, ['chi', 'jap'])) c.append(Territory('Japan', 'jap', 2)) c.append(Territory('Afghanistan', 'afg', 3, ['mea', 'indi', 'chi'])) c.append(Territory('China', 'chi', 1, ['indi', 'sia'])) c.append(Territory('Middle East', 'mea', 2, ['egy', 'eaf', 'indi'])) c.append(Territory('India', 'indi', 3, ['sia'])) c.append(Territory('Siam', 'sia', 1, ['indo'])) w.append(c) c = Continent('South America', 2) c.append(Territory('Venezuala', 'ven', 2, ['per', 'bra'])) c.append(Territory('Peru', 'per', 3, ['arg', 'bra'])) c.append(Territory('Brazil', 'bra', 1, ['arg', 'naf'])) c.append(Territory('Argentina', 'arg', 2)) w.append(c) c = Continent('Africa', 3) c.append(Territory('North Africa', 'naf', 3, ['egy', 'eaf', 'con'])) c.append(Territory('Egypt', 'egy', 1, ['eaf'])) c.append(Territory('East Africa', 'eaf', 2, ['con', 'saf', 'mad'])) c.append(Territory('Congo', 'con', 3, ['saf'])) c.append(Territory('South Africa', 'saf', 1, ['mad'])) c.append(Territory('Madagascar', 'mad', 2)) w.append(c) c = Continent('Australia', 2) c.append(Territory('Indonesia', 'indo', 3, ['ngu', 'wau'])) c.append(Territory('New Guinea', 'ngu', 1, ['wau', 'eau'])) c.append(Territory('Western Australia', 'wau', 2, ['eau'])) c.append(Territory('Eastern Australia', 'eau', 3)) w.append(c) w._resolve_link_names() return w def test(): import doctest, sys failures,tests = doctest.testmod(sys.modules[__name__]) return failures def random_game(): world = generate_earth() players = [Player('Alice'), Player('Bob'), Player('Charlie')] e = Engine(world, players) e.run() if __name__ == '__main__': import sys failures = self.test() if failures > 0: sys.exit(1) self.random_game()