1 # Copyright (C) 2010 W. Trevor King <wking@drexel.edu>
3 # This program is free software; you can redistribute it and/or modify
4 # it under the terms of the GNU General Public License as published by
5 # the Free Software Foundation; either version 2 of the License, or
6 # (at your option) any later version.
8 # This program is distributed in the hope that it will be useful,
9 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # GNU General Public License for more details.
13 # You should have received a copy of the GNU General Public License along
14 # with this program; if not, write to the Free Software Foundation, Inc.,
15 # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
17 """A Python engine for Risk-like games
22 from .log import Logger
27 class PlayerError (Exception):
30 class NameMixin (object):
31 """Simple mixin for pretty-printing named objects.
33 def __init__(self, name):
41 class ID_CmpMixin (object):
42 """Simple mixin to ensure the fancier comparisons are all based on
45 def __cmp__(self, other):
46 return cmp(id(self), id(other))
47 def __eq__(self, other):
48 return self.__cmp__(other) == 0
49 def __ne__(self, other):
50 return self.__cmp__(other) != 0
52 class Territory (NameMixin, ID_CmpMixin, list):
53 """An occupiable territory.
55 Contains a list of neighboring territories.
57 def __init__(self, name, short_name=None, type=-1,
58 link_names=[], continent=None, player=None):
59 NameMixin.__init__(self, name)
60 ID_CmpMixin.__init__(self)
62 self.short_name = short_name
63 if short_name == None:
64 self.short_name = name
65 self._card_type = type # for Deck construction
66 self._link_names = list(link_names) # used by World._resolve_link_names
67 self.continent = continent # used by World.production
68 self.player = player # who owns this territory
69 self.armies = 0 # number of occupying armies
71 if self.short_name == self.name:
73 return '%s (%s)' % (self.name, self.short_name)
74 def borders(self, other):
76 if id(t) == id(other):
80 class Continent (NameMixin, ID_CmpMixin, list):
81 """A group of Territories.
83 Stores the army-production bonus if it's owned by a single player.
85 def __init__(self, name, production, territories=[]):
86 NameMixin.__init__(self, name)
87 ID_CmpMixin.__init__(self)
88 list.__init__(self, territories)
89 self.production = production
90 def append(self, territory):
91 """Add a new territory (setting the territory's .continent
94 list.append(self, territory)
95 territory.continent = self
96 def territory_by_name(self, name):
97 """Find a Territory instance by name (long or short, case
101 if name.lower() in [t.short_name.lower(), t.name.lower()]:
104 def single_player(self):
105 """Is the continent owned by a single player?
108 for territory in self:
109 if territory.player != p:
113 class World (NameMixin, ID_CmpMixin, list):
114 """Store the world map and current world state.
116 Holds list of Continents. Also controls territory-based army
117 production (via production).
119 def __init__(self, name, continents=[]):
120 NameMixin.__init__(self, name)
121 ID_CmpMixin.__init__(self)
122 list.__init__(self, continents)
123 self.initial_armies = { # num_players:num_armies
124 2: 40, 3:35, 4:30, 5:25, 6:20
126 def territories(self):
127 """Iterate through all the territories in the world.
129 for continent in self:
130 for territory in continent:
132 def territory_by_name(self, name):
133 """Find a Territory instance by name (long or short, case
136 for continent in self:
138 return continent.territory_by_name(name)
142 def continent_by_name(self, name):
143 """Find a Continent instance by name (case insensitive).
145 for continent in self:
146 if continent.name.lower() == name.lower():
149 def _resolve_link_names(self):
150 """Initialize Territory links.
152 The Territory class doesn't actually link to neighbors after
153 initialization, but one of each linked pair has the others
154 name in _link_names. This method goes through the territories,
155 looks up the referenced link target, and joins the pair.
157 self._check_short_names()
158 for territory in self.territories():
159 for name in territory._link_names:
160 other = self.territory_by_name(name)
161 if not territory.borders(other):
162 territory.append(other)
163 if not other.borders(territory):
164 other.append(territory)
165 def _check_short_names(self):
166 """Ensure there are no short_name collisions.
169 for t in self.territories():
170 if t.short_name.lower() not in ts:
171 ts[t.short_name.lower()] = t
173 raise ValueError('%s shared by %s and %s'
174 % (t.short_name.lower(), ts[t.short_name.lower()], t))
175 def production(self, player):
176 """Calculate the number of armies a player should earn based
177 on territory occupation.
179 ts = list(player.territories(self))
180 production = max(3, len(ts) / 3)
181 continents = set([t.continent.name for t in ts])
182 for c_name in continents:
183 c = self.continent_by_name(c_name)
184 if c.single_player() == True:
185 production += c.production
186 return (production, {})
187 def place_territory_production(self, territory_production):
188 """Place armies based on {territory_name: num_armies, ...}.
190 for territory_name,production in territory_production.items():
191 t = self.territory_by_name(territory_name)
192 t.armies += production
194 class Card (ID_CmpMixin):
195 """Represent a territory card (or wild)
197 Nothing exciting going on here, just a class for pretty-printing
200 def __init__(self, deck, type_, territory=None):
201 ID_CmpMixin.__init__(self)
203 self.territory = territory
206 if self.territory == None:
207 return '<Card %s>' % (self.deck.type_names[self.type])
209 return '<Card %s %s>' % (self.territory,
210 self.deck.type_names[self.type])
212 return self.__str__()
215 """All the cards yet to be handed out in a given game.
217 Controls the type branding (via type_names) and army production
218 values for scoring sets (via production_value).
220 def __init__(self, territories=[], num_wilds=2,
221 type_names=['Wild', 'Infantry', 'Cavalry', 'Artillery']):
222 list.__init__(self, [Card(self, t._card_type, t) for t in territories])
223 self.type_names = type_names
224 for i in range(num_wilds):
225 self.append(Card(self, 0))
226 self._production_sequence = [4, 6, 8, 10, 12, 15]
227 self._production_index = 0
229 """Shuffle the remaining cards in the deck.
232 def production_value(self, index):
235 >>> [d.production_value(i) for i in range(8)]
236 [4, 6, 8, 10, 12, 15, 20, 25]
238 if index < len(self._production_sequence):
239 return self._production_sequence[index]
240 extra = index - len(self._production_sequence) + 1
241 return self._production_sequence[-1] + 5 * extra
242 def production(self, player, cards=None):
245 >>> a = Player('Alice')
246 >>> b = Player('Bob')
247 >>> d.production(a, None)
249 >>> d.production(a, [Card(d, 1, Territory('a')),
250 ... Card(d, 1, Territory('b'))])
251 Traceback (most recent call last):
253 PlayerError: [<Card a Infantry>, <Card b Infantry>] is not a scoring set
254 >>> d.production(a, [Card(d, 1, Territory('a', player=a)),
255 ... Card(d, 1, Territory('b', player=b)),
256 ... Card(d, 1, Territory('c'))])
258 >>> p,tp = d.production(a, [Card(d, 1, Territory('a', player=a)),
259 ... Card(d, 2, Territory('b', player=a)),
260 ... Card(d, 0, Territory('c', player=a))])
263 >>> sorted(tp.items())
264 [('a', 1), ('b', 1), ('c', 1)]
270 p = self.production_value(self._production_index)
271 self._production_index += 1
272 territory_production = {}
274 if c.territory != None and c.territory.player == player:
275 territory_production[c.territory.name] = 1
276 return (p, territory_production)
277 raise PlayerError('%s is not a scoring set' % h)
280 """Represent a hand of cards.
282 This is the place to override the set of allowed scoring
283 combinations. You should override one of
289 Adding additional scoring methods as needed (e.g. flush).
291 def __init__(self, cards=[]):
292 list.__init__(self, cards)
296 s = sorted(set([card.type for card in self]))
298 or (len(s) == 2 and s[0] == 0):
304 if len(set([card.type for card in self])) == 3:
308 """The hand is any valid scoring combination.
310 return self.set() or self.run()
311 def subhands(self, lengths=None):
312 """Return all possible subhands.
314 Lengths can either be a list of allowed subhand lengths or
315 None. If None, all possible subhand lengths are allowed.
318 >>> h = Hand([Card(d, 1, Territory('a')),
319 ... Card(d, 1, Territory('b')),
320 ... Card(d, 1, Territory('c')),
321 ... Card(d, 1, Territory('d'))])
322 >>> for hand in h.subhands():
328 [<Card a Infantry>, <Card b Infantry>]
329 [<Card a Infantry>, <Card c Infantry>]
330 [<Card a Infantry>, <Card d Infantry>]
331 [<Card b Infantry>, <Card c Infantry>]
332 [<Card b Infantry>, <Card d Infantry>]
333 [<Card c Infantry>, <Card d Infantry>]
334 [<Card a Infantry>, <Card b Infantry>, <Card c Infantry>]
335 [<Card a Infantry>, <Card b Infantry>, <Card d Infantry>]
336 [<Card a Infantry>, <Card c Infantry>, <Card d Infantry>]
337 [<Card b Infantry>, <Card c Infantry>, <Card d Infantry>]
338 [<Card a Infantry>, <Card b Infantry>, <Card c Infantry>, <Card d Infantry>]
340 for i in range(len(self)):
341 i += 1 # check all sub-hands of length i
342 if lengths != None and i not in lengths:
343 continue # don't check this length
345 stop = range(len(self)-i, len(self))
346 while indices != stop:
347 yield Hand([self[i] for i in indices])
348 indices = self._increment(indices, stop)
349 yield Hand([self[i] for i in indices])
350 def _increment(self, indices, stop):
353 >>> h = Hand([Card(d, 1, Territory('a'))])
354 >>> h._increment([0, 1, 2], [2, 3, 4])
356 >>> h._increment([0, 1, 3], [2, 3, 4])
358 >>> h._increment([0, 1, 4], [2, 3, 4])
361 moveable = [i for i,m in zip(indices, stop) if i < m]
362 assert len(moveable) > 0, 'At stop? indices: %s, stop: %s' % (indices, stop)
363 key = indices.index(moveable[-1])
364 new = indices[key] + 1
365 for i in range(key, len(indices)):
366 indices[i] = new + i-key
369 """Return a list of all possible scoring subhands.
371 for h in self.subhands():
375 class Player (NameMixin, ID_CmpMixin):
376 """Represent a risk player.
378 This class implements a very basic AI player. Subclasses should
379 consider overriding the "action-required" methods:
387 And the "report" methods:
392 def __init__(self, name):
393 NameMixin.__init__(self, name)
394 ID_CmpMixin.__init__(self)
397 self._message_index = 0
398 def territories(self, world):
399 """Iterate through all territories owned by this player.
401 for t in world.territories():
404 def border_territories(self, world):
405 """Iterate through all territories owned by this player which
406 border another player's territories.
408 for t in self.territories(world):
410 if neighbor.player != self:
413 def report(self, world, log):
414 """Send reports about death and game endings.
416 These events mark the end of contact and require no change in
417 player status or response, so they get a special command
418 seperate from the usual action family. The action commands in
419 Player subclasses can notify the player (possibly by calling
420 report internally) if they feel so inclined.
424 draw - another notification-only method
426 print 'Reporting for %s:\n %s' \
427 % (self, '\n '.join(log[self._message_index:]))
428 self._message_index = len(log)
429 def draw(self, world, log, cards=[]):
430 """Only called if you earned a new card (or cards).
434 report - another notification-only method
437 def select_territory(self, world, log):
438 """Return the selected territory's name.
440 free_territories = [t for t in world.territories() if t.player == None]
441 return random.sample(free_territories, 1)[0].name
442 def play_cards(self, world, log, play_required=True):
443 """Decide whether or not to turn in a set of cards.
445 Return a list of cards to turn in or None. If play_required
446 is True, you *must* play.
448 if play_required == True:
449 return random.sample(list(self.hand.possible()), 1)[0]
450 def place_armies(self, world, log, remaining=1, this_round=1):
451 """Both during setup and before each turn.
453 Return {territory_name: num_armies, ...}
455 t = random.sample(list(self.border_territories(world)), 1)[0]
456 return {t.name: this_round}
457 def attack_and_fortify(self, world, log, mode='attack'):
458 """Return list of (source, target, armies) tuples. Place None
459 in the list to end this phase.
461 assert mode != 'fortify', mode
462 possible_attacks = []
463 for t in self.border_territories(world):
464 if t.armies <= 3: #1: # be more conservative, only attack with 3 dice
466 targets = [border_t for border_t in t if border_t.player != self]
468 possible_attacks.append((t.name, tg.name, min(3, t.armies-1)))
469 if len(possible_attacks) == 0:
470 return [None, None] # stop attack phase, then stop fortification phase
471 return random.sample(possible_attacks, 1) # + [None]
472 def support_attack(self, world, log, source, target):
473 """Follow up on a conquest by moving additional armies.
475 return source.armies-1
477 class Engine (ID_CmpMixin):
480 Basic usage will be along the lines of
482 >>> world = generate_earth()
483 >>> players = [Player('Alice'), Player('Bob'), Player('Charlie')]
484 >>> e = Engine(world, players)
485 >>> e.run() # doctest: +ELLIPSIS
488 def __init__(self, world, players, deck_class=Deck, logger_class=Logger):
489 ID_CmpMixin.__init__(self)
491 self.deck = deck_class(world.territories())
492 self.log = logger_class()
493 self.players = players
495 return '<engine %s %s>' % (self.world, self.players)
497 return self.__str__()
499 """The main entry point.
505 """Setup phase. Pick territories, place initial armies, and
508 for p in self.players:
510 random.shuffle(self.players)
512 self.select_territories()
513 self.place_initial_armies()
514 for p in self.players:
517 """Main gameplay phase. Take turns until only one Player survives.
521 living = len(self.living_players())
523 self.play_turn(self.players[active_player])
524 living = len(self.living_players())
525 active_player = (active_player + 1) % len(self.players)
527 while self.players[active_player].alive == False:
528 active_player = (active_player + 1) % len(self.players)
531 """The end of the game.
533 Currently just a notification hook.
535 self.log('Game over.')
536 for p in self.players:
537 p.report(self.world, self.log)
538 def play_turn(self, player):
539 """Work through the phases of player's turn.
541 self.log("%s's turn (territory score: %s)"
542 % (player, [(p,len(list(p.territories(self.world))))
543 for p in self.players]))
544 self.play_cards_and_place_armies(player)
545 captures = self.attack_and_fortify(player)
546 self.end_of_turn_cards(player, captures)
547 def select_territories(self):
548 for t in self.world.territories():
550 for i in range(len(list(self.world.territories()))):
551 p = self.players[i % len(self.players)]
552 t_name = p.select_territory(self.world, self.log)
553 t = self.world.territory_by_name(t_name)
555 raise PlayerError('Cannot select %s owned by %s'
557 self.log('%s selects %s' % (p, t))
560 def place_initial_armies(self):
561 already_placed = [len(list(p.territories(self.world))) for p in self.players]
562 s = list(set(already_placed))
563 assert len(s) in [1,2], already_placed
564 if len(s) == 2: # catch up the players who are one territory short
565 assert min(s) == max(s)-1, 'Min %d, max %d' % (min(s), max(s))
566 for p,placed in zip(self.players, already_placed):
568 self.player_place_armies(p, remaining, 1)
569 remaining = self.world.initial_armies[len(self.players)] - max(s)
571 for p in self.players:
572 self.player_place_armies(p, remaining, 1)
574 def player_place_armies(self, player, remaining=1, this_round=1):
575 placements = player.place_armies(self.world, self.log, remaining, this_round)
576 if sum(placements.values()) != this_round:
577 raise PlayerError('Placing more than %d armies' % this_round)
578 for ter_name,armies in placements.items():
579 t = self.world.territory_by_name(ter_name)
580 if t.player != player:
581 raise PlayerError('Placing armies in %s owned by %s'
584 raise PlayerError('Placing a negative number of armies (%d) in %s'
586 self.log('%s places %s' % (player, placements))
587 for terr_name,armies in placements.items():
588 t = self.world.territory_by_name(terr_name)
590 def deal(self, player, number):
592 for i in range(number):
593 cards.append(self.deck.pop())
594 player.hand.extend(cards)
595 player.draw(self.world, self.log, cards)
596 self.log('%s dealt %d cards' % (player, number))
597 def play_cards_and_place_armies(self, player, additional_armies=0):
598 cards_required = len(player.hand) >= 5
599 cards = player.play_cards(
600 self.world, self.log, play_required=cards_required)
601 if cards_required == True and cards == None:
602 raise PlayerError('You have %d >= 5 cards in your hand, you must play'
604 w_prod,w_terr_prod = self.world.production(player)
605 self.log('%s earned %d armies from territories' % (player, w_prod))
606 c_prod,c_terr_prod = self.deck.production(player, cards)
608 self.log('%s played %s, earning %d armies'
609 % (player, cards, c_prod+sum(c_terr_prod.values())))
612 player.hand.remove(c)
613 for terr,prod in c_terr_prod.items():
614 if terr in w_terr_prod:
615 w_terr_prod[terr] += prod
617 w_terr_prod[terr] = prod
618 self.world.place_territory_production(w_terr_prod)
619 if len(w_terr_prod) > 0:
620 self.log('%s was required to place %s' % (player, w_terr_prod))
621 armies = w_prod + c_prod
622 self.player_place_armies(player, armies, armies)
623 def attack_and_fortify(self, player):
627 actions = player.attack_and_fortify(self.world, self.log, mode)
628 for action in actions:
634 assert mode == 'fortify', mode
636 source_name,target_name,armies = action
637 source = self.world.territory_by_name(source_name)
638 target = self.world.territory_by_name(target_name)
639 if not source.borders(target):
640 raise PlayerError('Cannot reach %s from %s to %s'
641 % (target, source, mode))
643 tplayer = target.player
644 capture = self.attack(source, target, armies)
647 if len(list(tplayer.territories(self.world))) == 0:
648 self.player_killed(tplayer, killer=player)
650 assert mode == 'fortify', mode
651 self.fortify(source, target, armies)
652 def attack(self, source, target, armies):
653 if source.player == target.player:
654 raise PlayerError('%s attacking %s, but you own both.'
657 raise PlayerError('%s attacking %s with 0 armies.'
659 if armies >= source.armies:
660 raise PlayerError('%s attacking %s with %d armies, but only %d are available.'
661 % (source, target, armies, source.armies-1))
662 a_dice = sorted([random.randint(1, 6) for i in range(armies)],
664 t_dice = sorted([random.randint(1, 6) for i in range(min(2, target.armies))],
668 for a,d in zip(a_dice, t_dice):
673 source.armies -= a_dead
674 target.armies -= t_dead
675 if target.armies == 0:
676 self.takeover(source, target, remaining_attackers=armies-a_dead)
677 self.log('%s conquered %s from %s with %d:%d. Deaths %d:%d. Remaining %d:%d'
678 % (source.player, target, source, armies, len(t_dice),
679 a_dead, t_dead, source.armies, target.armies))
680 assert target.armies > 0, target
682 self.log('%s attacked %s from %s with %d:%d. Deaths %d:%d. Remaining %d:%d' \
683 % (source.player, target, source, armies, len(t_dice),
684 a_dead, t_dead, source.armies, target.armies))
685 assert target.armies > 0, target
687 def takeover(self, source, target, remaining_attackers):
688 source.armies -= remaining_attackers
689 target.armies += remaining_attackers
690 target.player = source.player
691 if source.armies > 1:
692 support = source.player.support_attack(
693 self.world, self.log, source, target)
694 if support < 0 or support >= source.armies:
696 'Cannot support from %s to %s with %d armies, only %d available'
697 % (source, target, support, source.armies-1))
698 source.armies -= support
699 target.armies += support
700 def player_killed(self, player, killer):
702 killer.hand.extend(player.hand)
703 if len(self.living_players()) > 1:
704 while len(killer.hand) > 5:
705 self.play_cards_and_place_armies(killer)
706 self.log('%s killed by %s' % (player, killer))
707 if len(self.living_players()) > 1:
708 player.report(self.world, self.log)
709 # else the game is over, and killed will hear about this then.
710 def end_of_turn_cards(self, player, captures):
711 """Deal end-of-turn reward for any territory captures.
713 if captures > 0 and len(self.deck) > 0 and len(self.living_players()) > 1:
715 def living_players(self):
716 return [p for p in self.players if p.alive == True]
719 def generate_earth():
721 c = Continent('North America', 5)
722 c.append(Territory('Alaska', 'ala', 1, ['kam', 'nwt']))
723 c.append(Territory('Northwest Territory', 'nwt', 2, ['alb', 'ont', 'gre']))
724 c.append(Territory('Greenland', 'gre', 3, ['ont', 'que', 'ice']))
725 c.append(Territory('Alberta', 'alb', 1, ['ont', 'wus']))
726 c.append(Territory('Ontario', 'ont', 2, ['wus', 'eus', 'que']))
727 c.append(Territory('Quebec', 'que', 3, ['eus']))
728 c.append(Territory('Western United States', 'wus', 1, ['eus', 'cam']))
729 c.append(Territory('Eastern United States', 'eus', 2, ['cam']))
730 c.append(Territory('Central America', 'cam', 3, ['ven']))
733 c = Continent('Europe', 5)
734 c.append(Territory('Iceland', 'ice', 1, ['gbr', 'sca']))
735 c.append(Territory('Scandanavia', 'sca', 2, ['gbr', 'neu', 'ukr']))
736 c.append(Territory('Ukraine', 'ukr', 3, ['neu', 'seu', 'ura', 'afg', 'mea']))
737 c.append(Territory('Great Britain', 'gbr', 1, ['neu', 'weu']))
738 c.append(Territory('Northern Europe', 'neu', 2, ['weu', 'seu']))
739 c.append(Territory('Western Europe', 'weu', 3, ['naf', 'seu']))
740 c.append(Territory('Southern Europe', 'seu', 1, ['naf', 'egy', 'mea']))
743 c = Continent('Asia', 7)
744 c.append(Territory('Urals', 'ura', 2, ['afg', 'chi', 'sib']))
745 c.append(Territory('Siberia', 'sib', 3, ['chi', 'mon', 'irk', 'yak']))
746 c.append(Territory('Yakutsk', 'yak', 1, ['irk', 'kam']))
747 c.append(Territory('Kamchatka', 'kam', 2, ['mon', 'jap']))
748 c.append(Territory('Irkutsk', 'irk', 3, ['mon']))
749 c.append(Territory('Mongolia', 'mon', 1, ['chi', 'jap']))
750 c.append(Territory('Japan', 'jap', 2))
751 c.append(Territory('Afghanistan', 'afg', 3, ['mea', 'indi', 'chi']))
752 c.append(Territory('China', 'chi', 1, ['indi', 'sia']))
753 c.append(Territory('Middle East', 'mea', 2, ['egy', 'eaf', 'indi']))
754 c.append(Territory('India', 'indi', 3, ['sia']))
755 c.append(Territory('Siam', 'sia', 1, ['indo']))
759 c = Continent('South America', 2)
760 c.append(Territory('Venezuala', 'ven', 2, ['per', 'bra']))
761 c.append(Territory('Peru', 'per', 3, ['arg', 'bra']))
762 c.append(Territory('Brazil', 'bra', 1, ['arg', 'naf']))
763 c.append(Territory('Argentina', 'arg', 2))
766 c = Continent('Africa', 3)
767 c.append(Territory('North Africa', 'naf', 3, ['egy', 'eaf', 'con']))
768 c.append(Territory('Egypt', 'egy', 1, ['eaf']))
769 c.append(Territory('East Africa', 'eaf', 2, ['con', 'saf', 'mad']))
770 c.append(Territory('Congo', 'con', 3, ['saf']))
771 c.append(Territory('South Africa', 'saf', 1, ['mad']))
772 c.append(Territory('Madagascar', 'mad', 2))
775 c = Continent('Australia', 2)
776 c.append(Territory('Indonesia', 'indo', 3, ['ngu', 'wau']))
777 c.append(Territory('New Guinea', 'ngu', 1, ['wau', 'eau']))
778 c.append(Territory('Western Australia', 'wau', 2, ['eau']))
779 c.append(Territory('Eastern Australia', 'eau', 3))
782 w._resolve_link_names()
787 failures,tests = doctest.testmod(sys.modules[__name__])
791 from player.email import EmailPlayer
792 world = generate_earth()
793 players = [EmailPlayer('Alice', 'alice@example.com', 'server@example.com'),
794 Player('Bob'), Player('Charlie')]
795 e = Engine(world, players)
798 if __name__ == '__main__':
800 failures = self.test()