1 """A Python engine for Risk-like games
6 from .log import Logger
11 class PlayerError (Exception):
14 class NameMixin (object):
15 """Simple mixin for pretty-printing named objects.
17 def __init__(self, name):
25 class ID_CmpMixin (object):
26 """Simple mixin to ensure the fancier comparisons are all based on
29 def __cmp__(self, other):
30 return cmp(id(self), id(other))
31 def __eq__(self, other):
32 return self.__cmp__(other) == 0
33 def __ne__(self, other):
34 return self.__cmp__(other) != 0
36 class Territory (NameMixin, ID_CmpMixin, list):
37 """An occupiable territory.
39 Contains a list of neighboring territories.
41 def __init__(self, name, short_name=None, type=-1,
42 link_names=[], continent=None, player=None):
43 NameMixin.__init__(self, name)
44 ID_CmpMixin.__init__(self)
46 self.short_name = short_name
47 if short_name == None:
48 self.short_name = name
49 self._card_type = type # for Deck construction
50 self._link_names = list(link_names) # used by World._resolve_link_names
51 self.continent = continent # used by World.production
52 self.player = player # who owns this territory
53 self.armies = 0 # number of occupying armies
55 if self.short_name == self.name:
57 return '%s (%s)' % (self.name, self.short_name)
58 def borders(self, other):
60 if id(t) == id(other):
64 class Continent (NameMixin, ID_CmpMixin, list):
65 """A group of Territories.
67 Stores the army-production bonus if it's owned by a single player.
69 def __init__(self, name, production, territories=[]):
70 NameMixin.__init__(self, name)
71 ID_CmpMixin.__init__(self)
72 list.__init__(self, territories)
73 self.production = production
74 def append(self, territory):
75 """Add a new territory (setting the territory's .continent
78 list.append(self, territory)
79 territory.continent = self
80 def territory_by_name(self, name):
81 """Find a Territory instance by name (long or short, case
85 if name.lower() in [t.short_name.lower(), t.name.lower()]:
88 def single_player(self):
89 """Is the continent owned by a single player?
92 for territory in self:
93 if territory.player != p:
97 class World (NameMixin, ID_CmpMixin, list):
98 """Store the world map and current world state.
100 Holds list of Continents. Also controls territory-based army
101 production (via production).
103 def __init__(self, name, continents=[]):
104 NameMixin.__init__(self, name)
105 ID_CmpMixin.__init__(self)
106 list.__init__(self, continents)
107 self.initial_armies = { # num_players:num_armies
108 2: 40, 3:35, 4:30, 5:25, 6:20
110 def territories(self):
111 """Iterate through all the territories in the world.
113 for continent in self:
114 for territory in continent:
116 def territory_by_name(self, name):
117 """Find a Territory instance by name (long or short, case
120 for continent in self:
122 return continent.territory_by_name(name)
126 def continent_by_name(self, name):
127 """Find a Continent instance by name (case insensitive).
129 for continent in self:
130 if continent.name.lower() == name.lower():
133 def _resolve_link_names(self):
134 """Initialize Territory links.
136 The Territory class doesn't actually link to neighbors after
137 initialization, but one of each linked pair has the others
138 name in _link_names. This method goes through the territories,
139 looks up the referenced link target, and joins the pair.
141 self._check_short_names()
142 for territory in self.territories():
143 for name in territory._link_names:
144 other = self.territory_by_name(name)
145 if not territory.borders(other):
146 territory.append(other)
147 if not other.borders(territory):
148 other.append(territory)
149 def _check_short_names(self):
150 """Ensure there are no short_name collisions.
153 for t in self.territories():
154 if t.short_name.lower() not in ts:
155 ts[t.short_name.lower()] = t
157 raise ValueError('%s shared by %s and %s'
158 % (t.short_name.lower(), ts[t.short_name.lower()], t))
159 def production(self, player):
160 """Calculate the number of armies a player should earn based
161 on territory occupation.
163 ts = list(player.territories(self))
164 production = max(3, len(ts) / 3)
165 continents = set([t.continent.name for t in ts])
166 for c_name in continents:
167 c = self.continent_by_name(c_name)
168 if c.single_player() == True:
169 production += c.production
170 return (production, {})
171 def place_territory_production(self, territory_production):
172 """Place armies based on {territory_name: num_armies, ...}.
174 for territory_name,production in territory_production.items():
175 t = self.territory_by_name(territory_name)
176 t.armies += production
178 class Card (ID_CmpMixin):
179 """Represent a territory card (or wild)
181 Nothing exciting going on here, just a class for pretty-printing
184 def __init__(self, deck, type_, territory=None):
185 ID_CmpMixin.__init__(self)
187 self.territory = territory
190 if self.territory == None:
191 return '<Card %s>' % (self.deck.type_names[self.type])
193 return '<Card %s %s>' % (self.territory,
194 self.deck.type_names[self.type])
196 return self.__str__()
199 """All the cards yet to be handed out in a given game.
201 Controls the type branding (via type_names) and army production
202 values for scoring sets (via production_value).
204 def __init__(self, territories=[], num_wilds=2,
205 type_names=['Wild', 'Infantry', 'Cavalry', 'Artillery']):
206 list.__init__(self, [Card(self, t._card_type, t) for t in territories])
207 self.type_names = type_names
208 for i in range(num_wilds):
209 self.append(Card(self, 0))
210 self._production_sequence = [4, 6, 8, 10, 12, 15]
211 self._production_index = 0
213 """Shuffle the remaining cards in the deck.
216 def production_value(self, index):
219 >>> [d.production_value(i) for i in range(8)]
220 [4, 6, 8, 10, 12, 15, 20, 25]
222 if index < len(self._production_sequence):
223 return self._production_sequence[index]
224 extra = index - len(self._production_sequence) + 1
225 return self._production_sequence[-1] + 5 * extra
226 def production(self, player, cards=None):
229 >>> a = Player('Alice')
230 >>> b = Player('Bob')
231 >>> d.production(a, None)
233 >>> d.production(a, [Card(d, 1, Territory('a')),
234 ... Card(d, 1, Territory('b'))])
235 Traceback (most recent call last):
237 PlayerError: [<Card a Infantry>, <Card b Infantry>] is not a scoring set
238 >>> d.production(a, [Card(d, 1, Territory('a', player=a)),
239 ... Card(d, 1, Territory('b', player=b)),
240 ... Card(d, 1, Territory('c'))])
242 >>> p,tp = d.production(a, [Card(d, 1, Territory('a', player=a)),
243 ... Card(d, 2, Territory('b', player=a)),
244 ... Card(d, 0, Territory('c', player=a))])
247 >>> sorted(tp.items())
248 [('a', 1), ('b', 1), ('c', 1)]
254 p = self.production_value(self._production_index)
255 self._production_index += 1
256 territory_production = {}
258 if c.territory != None and c.territory.player == player:
259 territory_production[c.territory.name] = 1
260 return (p, territory_production)
261 raise PlayerError('%s is not a scoring set' % h)
264 """Represent a hand of cards.
266 This is the place to override the set of allowed scoring
267 combinations. You should override one of
273 Adding additional scoring methods as needed (e.g. flush).
275 def __init__(self, cards=[]):
276 list.__init__(self, cards)
280 s = sorted(set([card.type for card in self]))
282 or (len(s) == 2 and s[0] == 0):
288 if len(set([card.type for card in self])) == 3:
292 """The hand is any valid scoring combination.
294 return self.set() or self.run()
295 def subhands(self, lengths=None):
296 """Return all possible subhands.
298 Lengths can either be a list of allowed subhand lengths or
299 None. If None, all possible subhand lengths are allowed.
302 >>> h = Hand([Card(d, 1, Territory('a')),
303 ... Card(d, 1, Territory('b')),
304 ... Card(d, 1, Territory('c')),
305 ... Card(d, 1, Territory('d'))])
306 >>> for hand in h.subhands():
312 [<Card a Infantry>, <Card b Infantry>]
313 [<Card a Infantry>, <Card c Infantry>]
314 [<Card a Infantry>, <Card d Infantry>]
315 [<Card b Infantry>, <Card c Infantry>]
316 [<Card b Infantry>, <Card d Infantry>]
317 [<Card c Infantry>, <Card d Infantry>]
318 [<Card a Infantry>, <Card b Infantry>, <Card c Infantry>]
319 [<Card a Infantry>, <Card b Infantry>, <Card d Infantry>]
320 [<Card a Infantry>, <Card c Infantry>, <Card d Infantry>]
321 [<Card b Infantry>, <Card c Infantry>, <Card d Infantry>]
322 [<Card a Infantry>, <Card b Infantry>, <Card c Infantry>, <Card d Infantry>]
324 for i in range(len(self)):
325 i += 1 # check all sub-hands of length i
326 if lengths != None and i not in lengths:
327 continue # don't check this length
329 stop = range(len(self)-i, len(self))
330 while indices != stop:
331 yield Hand([self[i] for i in indices])
332 indices = self._increment(indices, stop)
333 yield Hand([self[i] for i in indices])
334 def _increment(self, indices, stop):
337 >>> h = Hand([Card(d, 1, Territory('a'))])
338 >>> h._increment([0, 1, 2], [2, 3, 4])
340 >>> h._increment([0, 1, 3], [2, 3, 4])
342 >>> h._increment([0, 1, 4], [2, 3, 4])
345 moveable = [i for i,m in zip(indices, stop) if i < m]
346 assert len(moveable) > 0, 'At stop? indices: %s, stop: %s' % (indices, stop)
347 key = indices.index(moveable[-1])
348 new = indices[key] + 1
349 for i in range(key, len(indices)):
350 indices[i] = new + i-key
353 """Return a list of all possible scoring subhands.
355 for h in self.subhands():
359 class Player (NameMixin, ID_CmpMixin):
360 """Represent a risk player.
362 This class implements a very basic AI player. Subclasses should
363 consider overriding the "action-required" methods:
371 And the "report" methods:
376 def __init__(self, name):
377 NameMixin.__init__(self, name)
378 ID_CmpMixin.__init__(self)
381 self._message_index = 0
382 def territories(self, world):
383 """Iterate through all territories owned by this player.
385 for t in world.territories():
388 def border_territories(self, world):
389 """Iterate through all territories owned by this player which
390 border another player's territories.
392 for t in self.territories(world):
394 if neighbor.player != self:
397 def report(self, world, log):
398 """Send reports about death and game endings.
400 These events mark the end of contact and require no change in
401 player status or response, so they get a special command
402 seperate from the usual action family. The action commands in
403 Player subclasses can notify the player (possibly by calling
404 report internally) if they feel so inclined.
408 draw - another notification-only method
410 print 'Reporting for %s:\n %s' \
411 % (self, '\n '.join(log[self._message_index:]))
412 self._message_index = len(log)
413 def draw(self, world, log, cards=[]):
414 """Only called if you earned a new card (or cards).
418 report - another notification-only method
421 def select_territory(self, world, log):
422 """Return the selected territory
424 free_territories = [t for t in world.territories() if t.player == None]
425 return random.sample(free_territories, 1)[0]
426 def play_cards(self, world, log, play_required=True):
427 """Decide whether or not to turn in a set of cards.
429 Return a list of cards to turn in or None. If play_required
430 is True, you *must* play.
432 if play_required == True:
433 return random.sample(list(self.hand.possible()), 1)[0]
434 def place_armies(self, world, log, remaining=1, this_round=1):
435 """Both during setup and before each turn.
437 Return {territory_name: num_armies, ...}
439 t = random.sample(list(self.border_territories(world)), 1)[0]
440 return {t.name: this_round}
441 def attack_and_fortify(self, world, log, mode='attack'):
442 """Return list of (source, target, armies) tuples. Place None
443 in the list to end this phase.
445 assert mode != 'fortify', mode
446 possible_attacks = []
447 for t in self.border_territories(world):
448 if t.armies <= 3: #1: # be more conservative, only attack with 3 dice
450 targets = [border_t for border_t in t if border_t.player != self]
452 possible_attacks.append((t.name, tg.name, min(3, t.armies-1)))
453 if len(possible_attacks) == 0:
454 return [None, None] # stop attack phase, then stop fortification phase
455 return random.sample(possible_attacks, 1) # + [None]
456 def support_attack(self, world, log, source, target):
457 """Follow up on a conquest by moving additional armies.
459 return source.armies-1
461 class Engine (ID_CmpMixin):
464 Basic usage will be along the lines of
466 >>> world = generate_earth()
467 >>> players = [Player('Alice'), Player('Bob'), Player('Charlie')]
468 >>> e = Engine(world, players)
469 >>> e.run() # doctest: +ELLIPSIS
472 def __init__(self, world, players, deck_class=Deck, logger_class=Logger):
473 ID_CmpMixin.__init__(self)
475 self.deck = deck_class(world.territories())
476 self.log = logger_class()
477 self.players = players
479 return '<engine %s %s>' % (self.world, self.players)
481 return self.__str__()
483 """The main entry point.
489 """Setup phase. Pick territories, place initial armies, and
492 for p in self.players:
494 random.shuffle(self.players)
496 self.select_territories()
497 self.place_initial_armies()
498 for p in self.players:
501 """Main gameplay phase. Take turns until only one Player survives.
505 living = len(self.living_players())
507 self.play_turn(self.players[active_player])
508 living = len(self.living_players())
509 active_player = (active_player + 1) % len(self.players)
511 while self.players[active_player].alive == False:
512 active_player = (active_player + 1) % len(self.players)
515 """The end of the game.
517 Currently just a notification hook.
519 self.log('Game over.')
520 for p in self.players:
521 p.report(self.world, self.log)
522 def play_turn(self, player):
523 """Work through the phases of player's turn.
525 self.log("%s's turn (territory score: %s)"
526 % (player, [(p,len(list(p.territories(self.world))))
527 for p in self.players]))
528 self.play_cards_and_place_armies(player)
529 captures = self.attack_and_fortify(player)
530 self.end_of_turn_cards(player, captures)
531 def select_territories(self):
532 for t in self.world.territories():
534 for i in range(len(list(self.world.territories()))):
535 p = self.players[i % len(self.players)]
536 t = p.select_territory(self.world, self.log)
538 raise PlayerError('Cannot select %s owned by %s'
540 self.log('%s selects %s' % (p, t))
543 def place_initial_armies(self):
544 already_placed = [len(list(p.territories(self.world))) for p in self.players]
545 s = list(set(already_placed))
546 assert len(s) in [1,2], already_placed
547 if len(s) == 2: # catch up the players who are one territory short
548 assert min(s) == max(s)-1, 'Min %d, max %d' % (min(s), max(s))
549 for p,placed in zip(self.players, already_placed):
551 self.player_place_armies(p, remaining, 1)
552 remaining = self.world.initial_armies[len(self.players)] - max(s)
554 for p in self.players:
555 self.player_place_armies(p, remaining, 1)
557 def player_place_armies(self, player, remaining=1, this_round=1):
558 placements = player.place_armies(self.world, self.log, remaining, this_round)
559 if sum(placements.values()) != this_round:
560 raise PlayerError('Placing more than %d armies' % this_round)
561 for ter_name,armies in placements.items():
562 t = self.world.territory_by_name(ter_name)
563 if t.player != player:
564 raise PlayerError('Placing armies in %s owned by %s'
567 raise PlayerError('Placing a negative number of armies (%d) in %s'
569 self.log('%s places %s' % (player, placements))
570 for terr_name,armies in placements.items():
571 t = self.world.territory_by_name(terr_name)
573 def deal(self, player, number):
575 for i in range(number):
576 cards.append(self.deck.pop())
577 player.hand.extend(cards)
578 player.draw(self.world, self.log, cards)
579 self.log('%s dealt %d cards' % (player, number))
580 def play_cards_and_place_armies(self, player, additional_armies=0):
581 cards_required = len(player.hand) >= 5
582 cards = player.play_cards(
583 self.world, self.log, play_required=cards_required)
584 if cards_required == True and cards == None:
585 raise PlayerError('You have %d >= 5 cards in your hand, you must play'
587 w_prod,w_terr_prod = self.world.production(player)
588 self.log('%s earned %d armies from territories' % (player, w_prod))
589 c_prod,c_terr_prod = self.deck.production(player, cards)
591 self.log('%s played %s, earning %d armies'
592 % (player, cards, c_prod+sum(c_terr_prod.values())))
595 player.hand.remove(c)
596 for terr,prod in c_terr_prod.items():
597 if terr in w_terr_prod:
598 w_terr_prod[terr] += prod
600 w_terr_prod[terr] = prod
601 self.world.place_territory_production(w_terr_prod)
602 if len(w_terr_prod) > 0:
603 self.log('%s was required to place %s' % (player, w_terr_prod))
604 armies = w_prod + c_prod
605 self.player_place_armies(player, armies, armies)
606 def attack_and_fortify(self, player):
610 actions = player.attack_and_fortify(self.world, self.log, mode)
611 for action in actions:
617 assert mode == 'fortify', mode
619 source_name,target_name,armies = action
620 source = self.world.territory_by_name(source_name)
621 target = self.world.territory_by_name(target_name)
622 if not source.borders(target):
623 raise PlayerError('Cannot reach %s from %s to %s'
624 % (target, source, mode))
626 tplayer = target.player
627 capture = self.attack(source, target, armies)
630 if len(list(tplayer.territories(self.world))) == 0:
631 self.player_killed(tplayer, killer=player)
633 assert mode == 'fortify', mode
634 self.fortify(source, target, armies)
635 def attack(self, source, target, armies):
636 if source.player == target.player:
637 raise PlayerError('%s attacking %s, but you own both.'
640 raise PlayerError('%s attacking %s with 0 armies.'
642 if armies >= source.armies:
643 raise PlayerError('%s attacking %s with %d armies, but only %d are available.'
644 % (source, target, armies, source.armies-1))
645 a_dice = sorted([random.randint(1, 6) for i in range(armies)],
647 t_dice = sorted([random.randint(1, 6) for i in range(min(2, target.armies))],
651 for a,d in zip(a_dice, t_dice):
656 source.armies -= a_dead
657 target.armies -= t_dead
658 if target.armies == 0:
659 self.takeover(source, target, remaining_attackers=armies-a_dead)
660 self.log('%s conquered %s from %s with %d:%d. Deaths %d:%d. Remaining %d:%d'
661 % (source.player, target, source, armies, len(t_dice),
662 a_dead, t_dead, source.armies, target.armies))
663 assert target.armies > 0, target
665 self.log('%s attacked %s from %s with %d:%d. Deaths %d:%d. Remaining %d:%d' \
666 % (source.player, target, source, armies, len(t_dice),
667 a_dead, t_dead, source.armies, target.armies))
668 assert target.armies > 0, target
670 def takeover(self, source, target, remaining_attackers):
671 source.armies -= remaining_attackers
672 target.armies += remaining_attackers
673 target.player = source.player
674 support = source.player.support_attack(self.world, self.log, source, target)
675 if support < 0 or support >= source.armies:
676 raise PlayerError('Cannot support from %s to %s with %d armies, only %d available'
677 % (source, target, support, source.armies-1))
678 source.armies -= support
679 target.armies += support
680 def player_killed(self, player, killer):
682 killer.hand.extend(player.hand)
683 if len(self.living_players()) > 1:
684 while len(killer.hand) > 5:
685 self.play_cards_and_place_armies(killer)
686 self.log('%s killed by %s' % (player, killer))
687 if len(self.living_players()) > 1:
688 player.report(self.world, self.log)
689 # else the game is over, and killed will hear about this then.
690 def end_of_turn_cards(self, player, captures):
691 """Deal end-of-turn reward for any territory captures.
693 if captures > 0 and len(self.deck) > 0 and len(self.living_players()) > 1:
695 def living_players(self):
696 return [p for p in self.players if p.alive == True]
699 def generate_earth():
701 c = Continent('North America', 5)
702 c.append(Territory('Alaska', 'ala', 1, ['kam', 'nwt']))
703 c.append(Territory('Northwest Territory', 'nwt', 2, ['alb', 'ont', 'gre']))
704 c.append(Territory('Greenland', 'gre', 3, ['ont', 'que', 'ice']))
705 c.append(Territory('Alberta', 'alb', 1, ['ont', 'wus']))
706 c.append(Territory('Ontario', 'ont', 2, ['wus', 'eus', 'que']))
707 c.append(Territory('Quebec', 'que', 3, ['eus']))
708 c.append(Territory('Western United States', 'wus', 1, ['eus', 'cam']))
709 c.append(Territory('Eastern United States', 'eus', 2, ['cam']))
710 c.append(Territory('Central America', 'cam', 3, ['ven']))
713 c = Continent('Europe', 5)
714 c.append(Territory('Iceland', 'ice', 1, ['gbr', 'sca']))
715 c.append(Territory('Scandanavia', 'sca', 2, ['gbr', 'neu', 'ukr']))
716 c.append(Territory('Ukraine', 'ukr', 3, ['neu', 'seu', 'ura', 'afg', 'mea']))
717 c.append(Territory('Great Britain', 'gbr', 1, ['neu', 'weu']))
718 c.append(Territory('Northern Europe', 'neu', 2, ['weu', 'seu']))
719 c.append(Territory('Western Europe', 'weu', 3, ['naf', 'seu']))
720 c.append(Territory('Southern Europe', 'seu', 1, ['naf', 'egy', 'mea']))
723 c = Continent('Asia', 7)
724 c.append(Territory('Urals', 'ura', 2, ['afg', 'chi', 'sib']))
725 c.append(Territory('Siberia', 'sib', 3, ['chi', 'mon', 'irk', 'yak']))
726 c.append(Territory('Yakutsk', 'yak', 1, ['irk', 'kam']))
727 c.append(Territory('Kamchatka', 'kam', 2, ['mon', 'jap']))
728 c.append(Territory('Irkutsk', 'irk', 3, ['mon']))
729 c.append(Territory('Mongolia', 'mon', 1, ['chi', 'jap']))
730 c.append(Territory('Japan', 'jap', 2))
731 c.append(Territory('Afghanistan', 'afg', 3, ['mea', 'indi', 'chi']))
732 c.append(Territory('China', 'chi', 1, ['indi', 'sia']))
733 c.append(Territory('Middle East', 'mea', 2, ['egy', 'eaf', 'indi']))
734 c.append(Territory('India', 'indi', 3, ['sia']))
735 c.append(Territory('Siam', 'sia', 1, ['indo']))
739 c = Continent('South America', 2)
740 c.append(Territory('Venezuala', 'ven', 2, ['per', 'bra']))
741 c.append(Territory('Peru', 'per', 3, ['arg', 'bra']))
742 c.append(Territory('Brazil', 'bra', 1, ['arg', 'naf']))
743 c.append(Territory('Argentina', 'arg', 2))
746 c = Continent('Africa', 3)
747 c.append(Territory('North Africa', 'naf', 3, ['egy', 'eaf', 'con']))
748 c.append(Territory('Egypt', 'egy', 1, ['eaf']))
749 c.append(Territory('East Africa', 'eaf', 2, ['con', 'saf', 'mad']))
750 c.append(Territory('Congo', 'con', 3, ['saf']))
751 c.append(Territory('South Africa', 'saf', 1, ['mad']))
752 c.append(Territory('Madagascar', 'mad', 2))
755 c = Continent('Australia', 2)
756 c.append(Territory('Indonesia', 'indo', 3, ['ngu', 'wau']))
757 c.append(Territory('New Guinea', 'ngu', 1, ['wau', 'eau']))
758 c.append(Territory('Western Australia', 'wau', 2, ['eau']))
759 c.append(Territory('Eastern Australia', 'eau', 3))
762 w._resolve_link_names()
767 failures,tests = doctest.testmod(sys.modules[__name__])
771 world = generate_earth()
772 players = [Player('Alice'), Player('Bob'), Player('Charlie')]
773 e = Engine(world, players)
776 if __name__ == '__main__':
778 failures = self.test()