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].name
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_name = p.select_territory(self.world, self.log)
537 t = self.world.territory_by_name(t_name)
539 raise PlayerError('Cannot select %s owned by %s'
541 self.log('%s selects %s' % (p, t))
544 def place_initial_armies(self):
545 already_placed = [len(list(p.territories(self.world))) for p in self.players]
546 s = list(set(already_placed))
547 assert len(s) in [1,2], already_placed
548 if len(s) == 2: # catch up the players who are one territory short
549 assert min(s) == max(s)-1, 'Min %d, max %d' % (min(s), max(s))
550 for p,placed in zip(self.players, already_placed):
552 self.player_place_armies(p, remaining, 1)
553 remaining = self.world.initial_armies[len(self.players)] - max(s)
555 for p in self.players:
556 self.player_place_armies(p, remaining, 1)
558 def player_place_armies(self, player, remaining=1, this_round=1):
559 placements = player.place_armies(self.world, self.log, remaining, this_round)
560 if sum(placements.values()) != this_round:
561 raise PlayerError('Placing more than %d armies' % this_round)
562 for ter_name,armies in placements.items():
563 t = self.world.territory_by_name(ter_name)
564 if t.player != player:
565 raise PlayerError('Placing armies in %s owned by %s'
568 raise PlayerError('Placing a negative number of armies (%d) in %s'
570 self.log('%s places %s' % (player, placements))
571 for terr_name,armies in placements.items():
572 t = self.world.territory_by_name(terr_name)
574 def deal(self, player, number):
576 for i in range(number):
577 cards.append(self.deck.pop())
578 player.hand.extend(cards)
579 player.draw(self.world, self.log, cards)
580 self.log('%s dealt %d cards' % (player, number))
581 def play_cards_and_place_armies(self, player, additional_armies=0):
582 cards_required = len(player.hand) >= 5
583 cards = player.play_cards(
584 self.world, self.log, play_required=cards_required)
585 if cards_required == True and cards == None:
586 raise PlayerError('You have %d >= 5 cards in your hand, you must play'
588 w_prod,w_terr_prod = self.world.production(player)
589 self.log('%s earned %d armies from territories' % (player, w_prod))
590 c_prod,c_terr_prod = self.deck.production(player, cards)
592 self.log('%s played %s, earning %d armies'
593 % (player, cards, c_prod+sum(c_terr_prod.values())))
596 player.hand.remove(c)
597 for terr,prod in c_terr_prod.items():
598 if terr in w_terr_prod:
599 w_terr_prod[terr] += prod
601 w_terr_prod[terr] = prod
602 self.world.place_territory_production(w_terr_prod)
603 if len(w_terr_prod) > 0:
604 self.log('%s was required to place %s' % (player, w_terr_prod))
605 armies = w_prod + c_prod
606 self.player_place_armies(player, armies, armies)
607 def attack_and_fortify(self, player):
611 actions = player.attack_and_fortify(self.world, self.log, mode)
612 for action in actions:
618 assert mode == 'fortify', mode
620 source_name,target_name,armies = action
621 source = self.world.territory_by_name(source_name)
622 target = self.world.territory_by_name(target_name)
623 if not source.borders(target):
624 raise PlayerError('Cannot reach %s from %s to %s'
625 % (target, source, mode))
627 tplayer = target.player
628 capture = self.attack(source, target, armies)
631 if len(list(tplayer.territories(self.world))) == 0:
632 self.player_killed(tplayer, killer=player)
634 assert mode == 'fortify', mode
635 self.fortify(source, target, armies)
636 def attack(self, source, target, armies):
637 if source.player == target.player:
638 raise PlayerError('%s attacking %s, but you own both.'
641 raise PlayerError('%s attacking %s with 0 armies.'
643 if armies >= source.armies:
644 raise PlayerError('%s attacking %s with %d armies, but only %d are available.'
645 % (source, target, armies, source.armies-1))
646 a_dice = sorted([random.randint(1, 6) for i in range(armies)],
648 t_dice = sorted([random.randint(1, 6) for i in range(min(2, target.armies))],
652 for a,d in zip(a_dice, t_dice):
657 source.armies -= a_dead
658 target.armies -= t_dead
659 if target.armies == 0:
660 self.takeover(source, target, remaining_attackers=armies-a_dead)
661 self.log('%s conquered %s from %s with %d:%d. Deaths %d:%d. Remaining %d:%d'
662 % (source.player, target, source, armies, len(t_dice),
663 a_dead, t_dead, source.armies, target.armies))
664 assert target.armies > 0, target
666 self.log('%s attacked %s from %s with %d:%d. Deaths %d:%d. Remaining %d:%d' \
667 % (source.player, target, source, armies, len(t_dice),
668 a_dead, t_dead, source.armies, target.armies))
669 assert target.armies > 0, target
671 def takeover(self, source, target, remaining_attackers):
672 source.armies -= remaining_attackers
673 target.armies += remaining_attackers
674 target.player = source.player
675 support = source.player.support_attack(self.world, self.log, source, target)
676 if support < 0 or support >= source.armies:
677 raise PlayerError('Cannot support from %s to %s with %d armies, only %d available'
678 % (source, target, support, source.armies-1))
679 source.armies -= support
680 target.armies += support
681 def player_killed(self, player, killer):
683 killer.hand.extend(player.hand)
684 if len(self.living_players()) > 1:
685 while len(killer.hand) > 5:
686 self.play_cards_and_place_armies(killer)
687 self.log('%s killed by %s' % (player, killer))
688 if len(self.living_players()) > 1:
689 player.report(self.world, self.log)
690 # else the game is over, and killed will hear about this then.
691 def end_of_turn_cards(self, player, captures):
692 """Deal end-of-turn reward for any territory captures.
694 if captures > 0 and len(self.deck) > 0 and len(self.living_players()) > 1:
696 def living_players(self):
697 return [p for p in self.players if p.alive == True]
700 def generate_earth():
702 c = Continent('North America', 5)
703 c.append(Territory('Alaska', 'ala', 1, ['kam', 'nwt']))
704 c.append(Territory('Northwest Territory', 'nwt', 2, ['alb', 'ont', 'gre']))
705 c.append(Territory('Greenland', 'gre', 3, ['ont', 'que', 'ice']))
706 c.append(Territory('Alberta', 'alb', 1, ['ont', 'wus']))
707 c.append(Territory('Ontario', 'ont', 2, ['wus', 'eus', 'que']))
708 c.append(Territory('Quebec', 'que', 3, ['eus']))
709 c.append(Territory('Western United States', 'wus', 1, ['eus', 'cam']))
710 c.append(Territory('Eastern United States', 'eus', 2, ['cam']))
711 c.append(Territory('Central America', 'cam', 3, ['ven']))
714 c = Continent('Europe', 5)
715 c.append(Territory('Iceland', 'ice', 1, ['gbr', 'sca']))
716 c.append(Territory('Scandanavia', 'sca', 2, ['gbr', 'neu', 'ukr']))
717 c.append(Territory('Ukraine', 'ukr', 3, ['neu', 'seu', 'ura', 'afg', 'mea']))
718 c.append(Territory('Great Britain', 'gbr', 1, ['neu', 'weu']))
719 c.append(Territory('Northern Europe', 'neu', 2, ['weu', 'seu']))
720 c.append(Territory('Western Europe', 'weu', 3, ['naf', 'seu']))
721 c.append(Territory('Southern Europe', 'seu', 1, ['naf', 'egy', 'mea']))
724 c = Continent('Asia', 7)
725 c.append(Territory('Urals', 'ura', 2, ['afg', 'chi', 'sib']))
726 c.append(Territory('Siberia', 'sib', 3, ['chi', 'mon', 'irk', 'yak']))
727 c.append(Territory('Yakutsk', 'yak', 1, ['irk', 'kam']))
728 c.append(Territory('Kamchatka', 'kam', 2, ['mon', 'jap']))
729 c.append(Territory('Irkutsk', 'irk', 3, ['mon']))
730 c.append(Territory('Mongolia', 'mon', 1, ['chi', 'jap']))
731 c.append(Territory('Japan', 'jap', 2))
732 c.append(Territory('Afghanistan', 'afg', 3, ['mea', 'indi', 'chi']))
733 c.append(Territory('China', 'chi', 1, ['indi', 'sia']))
734 c.append(Territory('Middle East', 'mea', 2, ['egy', 'eaf', 'indi']))
735 c.append(Territory('India', 'indi', 3, ['sia']))
736 c.append(Territory('Siam', 'sia', 1, ['indo']))
740 c = Continent('South America', 2)
741 c.append(Territory('Venezuala', 'ven', 2, ['per', 'bra']))
742 c.append(Territory('Peru', 'per', 3, ['arg', 'bra']))
743 c.append(Territory('Brazil', 'bra', 1, ['arg', 'naf']))
744 c.append(Territory('Argentina', 'arg', 2))
747 c = Continent('Africa', 3)
748 c.append(Territory('North Africa', 'naf', 3, ['egy', 'eaf', 'con']))
749 c.append(Territory('Egypt', 'egy', 1, ['eaf']))
750 c.append(Territory('East Africa', 'eaf', 2, ['con', 'saf', 'mad']))
751 c.append(Territory('Congo', 'con', 3, ['saf']))
752 c.append(Territory('South Africa', 'saf', 1, ['mad']))
753 c.append(Territory('Madagascar', 'mad', 2))
756 c = Continent('Australia', 2)
757 c.append(Territory('Indonesia', 'indo', 3, ['ngu', 'wau']))
758 c.append(Territory('New Guinea', 'ngu', 1, ['wau', 'eau']))
759 c.append(Territory('Western Australia', 'wau', 2, ['eau']))
760 c.append(Territory('Eastern Australia', 'eau', 3))
763 w._resolve_link_names()
768 failures,tests = doctest.testmod(sys.modules[__name__])
772 world = generate_earth()
773 players = [Player('Alice'), Player('Bob'), Player('Charlie')]
774 e = Engine(world, players)
777 if __name__ == '__main__':
779 failures = self.test()