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
25 class PlayerError (Exception):
28 class NameMixin (object):
29 """Simple mixin for pretty-printing named objects.
31 def __init__(self, name):
39 class ID_CmpMixin (object):
40 """Simple mixin to ensure the fancier comparisons are all based on
43 def __cmp__(self, other):
44 return cmp(id(self), id(other))
45 def __eq__(self, other):
46 return self.__cmp__(other) == 0
47 def __ne__(self, other):
48 return self.__cmp__(other) != 0
50 class Territory (NameMixin, ID_CmpMixin, list):
51 """An occupiable territory.
53 Contains a list of neighboring territories.
55 def __init__(self, name, short_name=None, type=-1,
56 link_names=[], continent=None, player=None):
57 NameMixin.__init__(self, name)
58 ID_CmpMixin.__init__(self)
60 self.short_name = short_name
61 if short_name == None:
62 self.short_name = name
63 self._card_type = type # for Deck construction
64 self._link_names = list(link_names) # used by World._resolve_link_names
65 self.continent = continent # used by World.production
66 self.player = player # who owns this territory
67 self.armies = 0 # number of occupying armies
69 if self.short_name == self.name:
71 return '%s (%s)' % (self.name, self.short_name)
72 def borders(self, other):
74 if id(t) == id(other):
78 class Continent (NameMixin, ID_CmpMixin, list):
79 """A group of Territories.
81 Stores the army-production bonus if it's owned by a single player.
83 def __init__(self, name, production, territories=[]):
84 NameMixin.__init__(self, name)
85 ID_CmpMixin.__init__(self)
86 list.__init__(self, territories)
87 self.production = production
88 def append(self, territory):
89 """Add a new territory (setting the territory's .continent
92 list.append(self, territory)
93 territory.continent = self
94 def territory_by_name(self, name):
95 """Find a Territory instance by name (long or short, case
99 if name.lower() in [t.short_name.lower(), t.name.lower()]:
102 def single_player(self):
103 """Is the continent owned by a single player?
106 for territory in self:
107 if territory.player != p:
111 class World (NameMixin, ID_CmpMixin, list):
112 """Store the world map and current world state.
114 Holds list of Continents. Also controls territory-based army
115 production (via production).
117 def __init__(self, name, continents=[]):
118 NameMixin.__init__(self, name)
119 ID_CmpMixin.__init__(self)
120 list.__init__(self, continents)
121 self.initial_armies = { # num_players:num_armies
122 2: 40, 3:35, 4:30, 5:25, 6:20
124 def territories(self):
125 """Iterate through all the territories in the world.
127 for continent in self:
128 for territory in continent:
130 def territory_by_name(self, name):
131 """Find a Territory instance by name (long or short, case
134 for continent in self:
136 return continent.territory_by_name(name)
140 def continent_by_name(self, name):
141 """Find a Continent instance by name (case insensitive).
143 for continent in self:
144 if continent.name.lower() == name.lower():
147 def _resolve_link_names(self):
148 """Initialize Territory links.
150 The Territory class doesn't actually link to neighbors after
151 initialization, but one of each linked pair has the others
152 name in _link_names. This method goes through the territories,
153 looks up the referenced link target, and joins the pair.
155 self._check_short_names()
156 for territory in self.territories():
157 for name in territory._link_names:
158 other = self.territory_by_name(name)
159 if not territory.borders(other):
160 territory.append(other)
161 if not other.borders(territory):
162 other.append(territory)
163 def _check_short_names(self):
164 """Ensure there are no short_name collisions.
167 for t in self.territories():
168 if t.short_name.lower() not in ts:
169 ts[t.short_name.lower()] = t
171 raise ValueError('%s shared by %s and %s'
172 % (t.short_name.lower(), ts[t.short_name.lower()], t))
173 def production(self, player):
174 """Calculate the number of armies a player should earn based
175 on territory occupation.
177 ts = list(player.territories(self))
178 production = max(3, len(ts) / 3)
179 continents = set([t.continent.name for t in ts])
180 for c_name in continents:
181 c = self.continent_by_name(c_name)
182 if c.single_player() == True:
183 production += c.production
184 return (production, {})
185 def place_territory_production(self, territory_production):
186 """Place armies based on {territory_name: num_armies, ...}.
188 for territory_name,production in territory_production.items():
189 t = self.territory_by_name(territory_name)
190 t.armies += production
192 class Card (ID_CmpMixin):
193 """Represent a territory card (or wild)
195 Nothing exciting going on here, just a class for pretty-printing
198 def __init__(self, deck, type_, territory=None):
199 ID_CmpMixin.__init__(self)
201 self.territory = territory
204 if self.territory == None:
205 return '<Card %s>' % (self.deck.type_names[self.type])
207 return '<Card %s %s>' % (self.territory,
208 self.deck.type_names[self.type])
210 return self.__str__()
213 """All the cards yet to be handed out in a given game.
215 Controls the type branding (via type_names) and army production
216 values for scoring sets (via production_value).
218 def __init__(self, territories=[], num_wilds=2,
219 type_names=['Wild', 'Infantry', 'Cavalry', 'Artillery']):
220 list.__init__(self, [Card(self, t._card_type, t) for t in territories])
221 self.type_names = type_names
222 for i in range(num_wilds):
223 self.append(Card(self, 0))
224 self._production_sequence = [4, 6, 8, 10, 12, 15]
225 self._production_index = 0
227 """Shuffle the remaining cards in the deck.
230 def production_value(self, index):
233 >>> [d.production_value(i) for i in range(8)]
234 [4, 6, 8, 10, 12, 15, 20, 25]
236 if index < len(self._production_sequence):
237 return self._production_sequence[index]
238 extra = index - len(self._production_sequence) + 1
239 return self._production_sequence[-1] + 5 * extra
240 def production(self, player, cards=None):
243 >>> a = Player('Alice')
244 >>> b = Player('Bob')
245 >>> d.production(a, None)
247 >>> d.production(a, [Card(d, 1, Territory('a')),
248 ... Card(d, 1, Territory('b'))])
249 Traceback (most recent call last):
251 PlayerError: [<Card a Infantry>, <Card b Infantry>] is not a scoring set
252 >>> d.production(a, [Card(d, 1, Territory('a', player=a)),
253 ... Card(d, 1, Territory('b', player=b)),
254 ... Card(d, 1, Territory('c'))])
256 >>> p,tp = d.production(a, [Card(d, 1, Territory('a', player=a)),
257 ... Card(d, 2, Territory('b', player=a)),
258 ... Card(d, 0, Territory('c', player=a))])
261 >>> sorted(tp.items())
262 [('a', 1), ('b', 1), ('c', 1)]
268 p = self.production_value(self._production_index)
269 self._production_index += 1
270 territory_production = {}
272 if c.territory != None and c.territory.player == player:
273 territory_production[c.territory.name] = 1
274 return (p, territory_production)
275 raise PlayerError('%s is not a scoring set' % h)
278 """Represent a hand of cards.
280 This is the place to override the set of allowed scoring
281 combinations. You should override one of
287 Adding additional scoring methods as needed (e.g. flush).
289 def __init__(self, cards=[]):
290 list.__init__(self, cards)
294 s = sorted(set([card.type for card in self]))
296 or (len(s) == 2 and s[0] == 0):
302 if len(set([card.type for card in self])) == 3:
306 """The hand is any valid scoring combination.
308 return self.set() or self.run()
309 def subhands(self, lengths=None):
310 """Return all possible subhands.
312 Lengths can either be a list of allowed subhand lengths or
313 None. If None, all possible subhand lengths are allowed.
316 >>> h = Hand([Card(d, 1, Territory('a')),
317 ... Card(d, 1, Territory('b')),
318 ... Card(d, 1, Territory('c')),
319 ... Card(d, 1, Territory('d'))])
320 >>> for hand in h.subhands():
326 [<Card a Infantry>, <Card b Infantry>]
327 [<Card a Infantry>, <Card c Infantry>]
328 [<Card a Infantry>, <Card d Infantry>]
329 [<Card b Infantry>, <Card c Infantry>]
330 [<Card b Infantry>, <Card d Infantry>]
331 [<Card c Infantry>, <Card d Infantry>]
332 [<Card a Infantry>, <Card b Infantry>, <Card c Infantry>]
333 [<Card a Infantry>, <Card b Infantry>, <Card d Infantry>]
334 [<Card a Infantry>, <Card c Infantry>, <Card d Infantry>]
335 [<Card b Infantry>, <Card c Infantry>, <Card d Infantry>]
336 [<Card a Infantry>, <Card b Infantry>, <Card c Infantry>, <Card d Infantry>]
338 for i in range(len(self)):
339 i += 1 # check all sub-hands of length i
340 if lengths != None and i not in lengths:
341 continue # don't check this length
343 stop = range(len(self)-i, len(self))
344 while indices != stop:
345 yield Hand([self[i] for i in indices])
346 indices = self._increment(indices, stop)
347 yield Hand([self[i] for i in indices])
348 def _increment(self, indices, stop):
351 >>> h = Hand([Card(d, 1, Territory('a'))])
352 >>> h._increment([0, 1, 2], [2, 3, 4])
354 >>> h._increment([0, 1, 3], [2, 3, 4])
356 >>> h._increment([0, 1, 4], [2, 3, 4])
359 moveable = [i for i,m in zip(indices, stop) if i < m]
360 assert len(moveable) > 0, 'At stop? indices: %s, stop: %s' % (indices, stop)
361 key = indices.index(moveable[-1])
362 new = indices[key] + 1
363 for i in range(key, len(indices)):
364 indices[i] = new + i-key
367 """Return a list of all possible scoring subhands.
369 for h in self.subhands():
373 class Player (NameMixin, ID_CmpMixin):
374 """Represent a risk player.
376 This class implements a very basic AI player. Subclasses should
377 consider overriding the "action-required" methods:
385 And the "report" methods:
390 def __init__(self, name):
391 NameMixin.__init__(self, name)
392 ID_CmpMixin.__init__(self)
395 self._message_index = 0
396 def territories(self, world):
397 """Iterate through all territories owned by this player.
399 for t in world.territories():
402 def border_territories(self, world):
403 """Iterate through all territories owned by this player which
404 border another player's territories.
406 for t in self.territories(world):
408 if neighbor.player != self:
411 def report(self, world, log):
412 """Send reports about death and game endings.
414 These events mark the end of contact and require no change in
415 player status or response, so they get a special command
416 seperate from the usual action family. The action commands in
417 Player subclasses can notify the player (possibly by calling
418 report internally) if they feel so inclined.
422 draw - another notification-only method
424 print 'Reporting for %s:\n %s' \
425 % (self, '\n '.join(log[self._message_index:]))
426 self._message_index = len(log)
427 def draw(self, world, log, cards=[]):
428 """Only called if you earned a new card (or cards).
432 report - another notification-only method
435 def select_territory(self, world, log):
436 """Return the selected territory's name.
438 free_territories = [t for t in world.territories() if t.player == None]
439 return random.sample(free_territories, 1)[0].name
440 def play_cards(self, world, log, play_required=True):
441 """Decide whether or not to turn in a set of cards.
443 Return a list of cards to turn in or None. If play_required
444 is True, you *must* play.
446 if play_required == True:
447 return random.sample(list(self.hand.possible()), 1)[0]
448 def place_armies(self, world, log, remaining=1, this_round=1):
449 """Both during setup and before each turn.
451 Return {territory_name: num_armies, ...}
453 t = random.sample(list(self.border_territories(world)), 1)[0]
454 return {t.name: this_round}
455 def attack_and_fortify(self, world, log, mode='attack'):
456 """Return list of (source, target, armies) tuples. Place None
457 in the list to end this phase.
459 assert mode != 'fortify', mode
460 possible_attacks = []
461 for t in self.border_territories(world):
462 if t.armies <= 3: #1: # be more conservative, only attack with 3 dice
464 targets = [border_t for border_t in t if border_t.player != self]
466 possible_attacks.append((t.name, tg.name, min(3, t.armies-1)))
467 if len(possible_attacks) == 0:
468 return [None, None] # stop attack phase, then stop fortification phase
469 return random.sample(possible_attacks, 1) # + [None]
470 def support_attack(self, world, log, source, target):
471 """Follow up on a conquest by moving additional armies.
473 return source.armies-1
475 class Engine (ID_CmpMixin):
478 Basic usage will be along the lines of
480 >>> world = generate_earth()
481 >>> players = [Player('Alice'), Player('Bob'), Player('Charlie')]
482 >>> e = Engine(world, players)
483 >>> e.run() # doctest: +ELLIPSIS
486 def __init__(self, world, players, deck_class=Deck, logger_class=Logger):
487 ID_CmpMixin.__init__(self)
489 self.deck = deck_class(world.territories())
490 self.log = logger_class()
491 self.players = players
493 return '<engine %s %s>' % (self.world, self.players)
495 return self.__str__()
497 """The main entry point.
503 """Setup phase. Pick territories, place initial armies, and
506 for p in self.players:
508 random.shuffle(self.players)
510 self.select_territories()
511 self.place_initial_armies()
512 for p in self.players:
515 """Main gameplay phase. Take turns until only one Player survives.
519 living = len(self.living_players())
521 self.play_turn(self.players[active_player])
522 living = len(self.living_players())
523 active_player = (active_player + 1) % len(self.players)
525 while self.players[active_player].alive == False:
526 active_player = (active_player + 1) % len(self.players)
529 """The end of the game.
531 Currently just a notification hook.
533 self.log('Game over.')
534 for p in self.players:
535 p.report(self.world, self.log)
536 def play_turn(self, player):
537 """Work through the phases of player's turn.
539 self.log("%s's turn (territory score: %s)"
540 % (player, [(p,len(list(p.territories(self.world))))
541 for p in self.players]))
542 self.play_cards_and_place_armies(player)
543 captures = self.attack_and_fortify(player)
544 self.end_of_turn_cards(player, captures)
545 def select_territories(self):
546 for t in self.world.territories():
548 for i in range(len(list(self.world.territories()))):
549 p = self.players[i % len(self.players)]
550 t_name = p.select_territory(self.world, self.log)
551 t = self.world.territory_by_name(t_name)
553 raise PlayerError('Cannot select %s owned by %s'
555 self.log('%s selects %s' % (p, t))
558 def place_initial_armies(self):
559 already_placed = [len(list(p.territories(self.world))) for p in self.players]
560 s = list(set(already_placed))
561 assert len(s) in [1,2], already_placed
562 if len(s) == 2: # catch up the players who are one territory short
563 assert min(s) == max(s)-1, 'Min %d, max %d' % (min(s), max(s))
564 for p,placed in zip(self.players, already_placed):
566 self.player_place_armies(p, remaining, 1)
567 remaining = self.world.initial_armies[len(self.players)] - max(s)
569 for p in self.players:
570 self.player_place_armies(p, remaining, 1)
572 def player_place_armies(self, player, remaining=1, this_round=1):
573 placements = player.place_armies(self.world, self.log, remaining, this_round)
574 if sum(placements.values()) != this_round:
575 raise PlayerError('Placing more than %d armies' % this_round)
576 for ter_name,armies in placements.items():
577 t = self.world.territory_by_name(ter_name)
578 if t.player != player:
579 raise PlayerError('Placing armies in %s owned by %s'
582 raise PlayerError('Placing a negative number of armies (%d) in %s'
584 self.log('%s places %s' % (player, placements))
585 for terr_name,armies in placements.items():
586 t = self.world.territory_by_name(terr_name)
588 def deal(self, player, number):
590 for i in range(number):
591 cards.append(self.deck.pop())
592 player.hand.extend(cards)
593 player.draw(self.world, self.log, cards)
594 self.log('%s dealt %d cards' % (player, number))
595 def play_cards_and_place_armies(self, player, additional_armies=0):
596 cards_required = len(player.hand) >= 5
597 cards = player.play_cards(
598 self.world, self.log, play_required=cards_required)
599 if cards_required == True and cards == None:
600 raise PlayerError('You have %d >= 5 cards in your hand, you must play'
602 w_prod,w_terr_prod = self.world.production(player)
603 self.log('%s earned %d armies from territories' % (player, w_prod))
604 c_prod,c_terr_prod = self.deck.production(player, cards)
606 self.log('%s played %s, earning %d armies'
607 % (player, cards, c_prod+sum(c_terr_prod.values())))
610 player.hand.remove(c)
611 for terr,prod in c_terr_prod.items():
612 if terr in w_terr_prod:
613 w_terr_prod[terr] += prod
615 w_terr_prod[terr] = prod
616 self.world.place_territory_production(w_terr_prod)
617 if len(w_terr_prod) > 0:
618 self.log('%s was required to place %s' % (player, w_terr_prod))
619 armies = w_prod + c_prod
620 self.player_place_armies(player, armies, armies)
621 def attack_and_fortify(self, player):
625 actions = player.attack_and_fortify(self.world, self.log, mode)
626 for action in actions:
632 assert mode == 'fortify', mode
634 source_name,target_name,armies = action
635 source = self.world.territory_by_name(source_name)
636 target = self.world.territory_by_name(target_name)
637 if not source.borders(target):
638 raise PlayerError('Cannot reach %s from %s to %s'
639 % (target, source, mode))
641 tplayer = target.player
642 capture = self.attack(source, target, armies)
645 if len(list(tplayer.territories(self.world))) == 0:
646 self.player_killed(tplayer, killer=player)
648 assert mode == 'fortify', mode
649 self.fortify(source, target, armies)
650 def attack(self, source, target, armies):
651 if source.player == target.player:
652 raise PlayerError('%s attacking %s, but you own both.'
655 raise PlayerError('%s attacking %s with 0 armies.'
657 if armies >= source.armies:
658 raise PlayerError('%s attacking %s with %d armies, but only %d are available.'
659 % (source, target, armies, source.armies-1))
660 a_dice = sorted([random.randint(1, 6) for i in range(armies)],
662 t_dice = sorted([random.randint(1, 6) for i in range(min(2, target.armies))],
666 for a,d in zip(a_dice, t_dice):
671 source.armies -= a_dead
672 target.armies -= t_dead
673 if target.armies == 0:
674 self.takeover(source, target, remaining_attackers=armies-a_dead)
675 self.log('%s conquered %s from %s with %d:%d. Deaths %d:%d. Remaining %d:%d'
676 % (source.player, target, source, armies, len(t_dice),
677 a_dead, t_dead, source.armies, target.armies))
678 assert target.armies > 0, target
680 self.log('%s attacked %s from %s with %d:%d. Deaths %d:%d. Remaining %d:%d' \
681 % (source.player, target, source, armies, len(t_dice),
682 a_dead, t_dead, source.armies, target.armies))
683 assert target.armies > 0, target
685 def takeover(self, source, target, remaining_attackers):
686 source.armies -= remaining_attackers
687 target.armies += remaining_attackers
688 target.player = source.player
689 if source.armies > 1:
690 support = source.player.support_attack(
691 self.world, self.log, source, target)
692 if support < 0 or support >= source.armies:
694 'Cannot support from %s to %s with %d armies, only %d available'
695 % (source, target, support, source.armies-1))
696 source.armies -= support
697 target.armies += support
698 def player_killed(self, player, killer):
700 killer.hand.extend(player.hand)
701 if len(self.living_players()) > 1:
702 while len(killer.hand) > 5:
703 self.play_cards_and_place_armies(killer)
704 self.log('%s killed by %s' % (player, killer))
705 if len(self.living_players()) > 1:
706 player.report(self.world, self.log)
707 # else the game is over, and killed will hear about this then.
708 def end_of_turn_cards(self, player, captures):
709 """Deal end-of-turn reward for any territory captures.
711 if captures > 0 and len(self.deck) > 0 and len(self.living_players()) > 1:
713 def living_players(self):
714 return [p for p in self.players if p.alive == True]
717 def generate_earth():
719 c = Continent('North America', 5)
720 c.append(Territory('Alaska', 'ala', 1, ['kam', 'nwt']))
721 c.append(Territory('Northwest Territory', 'nwt', 2, ['alb', 'ont', 'gre']))
722 c.append(Territory('Greenland', 'gre', 3, ['ont', 'que', 'ice']))
723 c.append(Territory('Alberta', 'alb', 1, ['ont', 'wus']))
724 c.append(Territory('Ontario', 'ont', 2, ['wus', 'eus', 'que']))
725 c.append(Territory('Quebec', 'que', 3, ['eus']))
726 c.append(Territory('Western United States', 'wus', 1, ['eus', 'cam']))
727 c.append(Territory('Eastern United States', 'eus', 2, ['cam']))
728 c.append(Territory('Central America', 'cam', 3, ['ven']))
731 c = Continent('Europe', 5)
732 c.append(Territory('Iceland', 'ice', 1, ['gbr', 'sca']))
733 c.append(Territory('Scandanavia', 'sca', 2, ['gbr', 'neu', 'ukr']))
734 c.append(Territory('Ukraine', 'ukr', 3, ['neu', 'seu', 'ura', 'afg', 'mea']))
735 c.append(Territory('Great Britain', 'gbr', 1, ['neu', 'weu']))
736 c.append(Territory('Northern Europe', 'neu', 2, ['weu', 'seu']))
737 c.append(Territory('Western Europe', 'weu', 3, ['naf', 'seu']))
738 c.append(Territory('Southern Europe', 'seu', 1, ['naf', 'egy', 'mea']))
741 c = Continent('Asia', 7)
742 c.append(Territory('Urals', 'ura', 2, ['afg', 'chi', 'sib']))
743 c.append(Territory('Siberia', 'sib', 3, ['chi', 'mon', 'irk', 'yak']))
744 c.append(Territory('Yakutsk', 'yak', 1, ['irk', 'kam']))
745 c.append(Territory('Kamchatka', 'kam', 2, ['mon', 'jap']))
746 c.append(Territory('Irkutsk', 'irk', 3, ['mon']))
747 c.append(Territory('Mongolia', 'mon', 1, ['chi', 'jap']))
748 c.append(Territory('Japan', 'jap', 2))
749 c.append(Territory('Afghanistan', 'afg', 3, ['mea', 'indi', 'chi']))
750 c.append(Territory('China', 'chi', 1, ['indi', 'sia']))
751 c.append(Territory('Middle East', 'mea', 2, ['egy', 'eaf', 'indi']))
752 c.append(Territory('India', 'indi', 3, ['sia']))
753 c.append(Territory('Siam', 'sia', 1, ['indo']))
757 c = Continent('South America', 2)
758 c.append(Territory('Venezuala', 'ven', 2, ['per', 'bra']))
759 c.append(Territory('Peru', 'per', 3, ['arg', 'bra']))
760 c.append(Territory('Brazil', 'bra', 1, ['arg', 'naf']))
761 c.append(Territory('Argentina', 'arg', 2))
764 c = Continent('Africa', 3)
765 c.append(Territory('North Africa', 'naf', 3, ['egy', 'eaf', 'con']))
766 c.append(Territory('Egypt', 'egy', 1, ['eaf']))
767 c.append(Territory('East Africa', 'eaf', 2, ['con', 'saf', 'mad']))
768 c.append(Territory('Congo', 'con', 3, ['saf']))
769 c.append(Territory('South Africa', 'saf', 1, ['mad']))
770 c.append(Territory('Madagascar', 'mad', 2))
773 c = Continent('Australia', 2)
774 c.append(Territory('Indonesia', 'indo', 3, ['ngu', 'wau']))
775 c.append(Territory('New Guinea', 'ngu', 1, ['wau', 'eau']))
776 c.append(Territory('Western Australia', 'wau', 2, ['eau']))
777 c.append(Territory('Eastern Australia', 'eau', 3))
780 w._resolve_link_names()
785 failures,tests = doctest.testmod(sys.modules[__name__])
789 from player.email import EmailPlayer
790 world = generate_earth()
791 players = [EmailPlayer('Alice', 'alice@example.com', 'server@example.com'),
792 Player('Bob'), Player('Charlie')]
793 e = Engine(world, players)
796 if __name__ == '__main__':
798 failures = self.test()