Only call Player.report (prev. Player.phase_report) as final contact
[pyrisk.git] / risk / base.py
1 """A Python engine for Risk-like games
2 """
3
4 import random
5
6 from .log import Logger
7
8 VERSION='0.1'
9
10
11 class PlayerError (Exception):
12     pass
13
14 class ID_CmpMixin (object):
15     def __str__(self):
16         return self.name
17     def __repr__(self):
18         return self.__str__()
19     def __cmp__(self, other):
20         return cmp(id(self), id(other))
21     def __eq__(self, other):
22         return self.__cmp__(other) == 0
23     def __ne__(self, other):
24         return self.__cmp__(other) != 0
25
26 class Territory (list, ID_CmpMixin):
27     def __init__(self, name, short_name=None, type=-1,
28                  link_names=[], continent=None, player=None):
29         list.__init__(self)
30         ID_CmpMixin.__init__(self)
31         self.name = name
32         self.short_name = short_name
33         if short_name == None:
34             self.short_name = name
35         self._card_type = type
36         self._link_names = list(link_names)
37         self.continent = continent
38         self.player = player
39         self.card = None
40         self.armies = 0
41     def __str__(self):
42         if self.short_name == self.name:
43             return self.name
44         return '%s (%s)' % (self.name, self.short_name)
45     def __repr__(self):
46         return self.__str__()
47     def borders(self, other):
48         for t in self:
49             if id(t) == id(other):
50                 return True
51         return False
52
53 class Continent (list, ID_CmpMixin):
54     def __init__(self, name, production, territories=[]):
55         list.__init__(self, territories)
56         ID_CmpMixin.__init__(self)
57         self.name = name
58         self.production = production
59     def append(self, territory):
60         list.append(self, territory)
61         territory.continent = self
62     def territory_by_name(self, name):
63         for t in self:
64             if name.lower() in [t.short_name.lower(), t.name.lower()]:
65                 #assert self.contains_territory(t), t
66                 return t
67         raise KeyError(name)
68     def contains_territory(self, territory):
69         for t in self:
70             if t == territory:
71                 return True
72         return False
73     def single_player(self):
74         p = self[0].player
75         for territory in self:
76             if territory.player != p:
77                 return False
78         return True
79
80 class World (list, ID_CmpMixin):
81     def __init__(self, name, continents=[]):
82         list.__init__(self, continents)
83         ID_CmpMixin.__init__(self)
84         self.name = name
85         self.initial_armies = { # num_players:num_armies
86             2: 40, 3:35, 4:30, 5:25, 6:20
87                 }
88     def territories(self):
89         for continent in self:
90             for territory in continent:
91                 yield territory
92     def territory_by_name(self, name):
93         for continent in self:
94             try:
95                 return continent.territory_by_name(name)
96             except KeyError:
97                 pass
98         raise KeyError(name)
99     def contains_territory(self, territory):
100         for continent in self:
101             if continent.contains_territory(territory):
102                 return True
103         return False
104     def continent_by_name(self, name):
105         for continent in self:
106             if continent.name.lower() == name.lower():
107                 return continent
108         raise KeyError(name)
109     def _resolve_link_names(self):
110         self._check_short_names()
111         for territory in self.territories():
112             for name in territory._link_names:
113                 other = self.territory_by_name(name)
114                 if not territory.borders(other):
115                     territory.append(other)
116                 if not other.borders(territory):
117                     other.append(territory)
118     def _check_short_names(self):
119         ts = {}
120         for t in self.territories():
121             if t.short_name not in ts:
122                 ts[t.short_name] = t
123             else:
124                 raise ValueError('%s shared by %s and %s'
125                                  % (t.short_name, ts[t.short_name], t))
126     def production(self, player):
127         ts = list(player.territories(self))
128         production = max(3, len(ts) / 3)
129         continents = set([t.continent.name for t in ts])
130         for c_name in continents:
131             c = self.continent_by_name(c_name)
132             if c.single_player() == True:
133                 production += c.production
134         return (production, {})
135     def place_territory_production(self, territory_production):
136         for territory_name,production in territory_production.items():
137             t = self.territory_by_name(territory_name)
138             t.armies += production
139
140 class Card (ID_CmpMixin):
141     def __init__(self, deck, type_, territory=None):
142         ID_CmpMixin.__init__(self)
143         self.deck = deck
144         self.territory = territory
145         self.type = type_
146     def __str__(self):
147         if self.territory == None:
148             return '<Card %s>' % (self.deck.type_names[self.type])
149
150         return '<Card %s %s>' % (self.territory,
151                                  self.deck.type_names[self.type])
152     def __repr__(self):
153         return self.__str__()
154
155 class Deck (list):
156     def __init__(self, territories=[]):
157         list.__init__(self, [Card(self, t._card_type, t) for t in territories])
158         random.shuffle(self)
159         self.type_names = ['Wild', 'Infantry', 'Cavalry', 'Artillery', 'Wild']
160         for i in range(2):
161             self.append(Card(self, 0))
162         self._production_sequence = [4, 6, 8, 10, 12, 15]
163         self._production_index = 0
164     def production_value(self, index):
165         """
166         >>> d = Deck()
167         >>> [d.production_value(i) for i in range(8)]
168         [4, 6, 8, 10, 12, 15, 20, 25]
169         """
170         if index < len(self._production_sequence):
171             return self._production_sequence[index]
172         extra = index - len(self._production_sequence) + 1
173         return self._production_sequence[-1] + 5 * extra
174     def production(self, player, cards=None):
175         """
176         >>> d = Deck()
177         >>> a = Player('Alice')
178         >>> b = Player('Bob')
179         >>> d.production(a, None)
180         (0, {})
181         >>> d.production(a, [Card(d, 1, Territory('a')),
182         ...                  Card(d, 1, Territory('b'))])
183         Traceback (most recent call last):
184           ...
185         PlayerError: You must play cards in groups of 3, not 2
186         ([<Card a Infantry>, <Card b Infantry>])
187         >>> d.production(a, [Card(d, 1, Territory('a', player=a)),
188         ...                  Card(d, 1, Territory('b', player=b)),
189         ...                  Card(d, 1, Territory('c'))])
190         (4, {'a': 1})
191         >>> p,tp = d.production(a, [Card(d, 1, Territory('a', player=a)),
192         ...                     Card(d, 2, Territory('b', player=a)),
193         ...                     Card(d, 0, Territory('c', player=a))])
194         >>> p
195         6
196         >>> sorted(tp.items())
197         [('a', 1), ('b', 1), ('c', 1)]
198         """
199         if cards == None:
200             return (0, {})
201         if len(cards) != 3:
202             raise PlayerError('You must play cards in groups of 3, not %d\n(%s)'
203                               % (len(cards), cards))
204         h = Hand(cards)
205         if h.set() or h.run():
206             p = self.production_value(self._production_index)
207             self._production_index += 1
208             territory_production = {}
209             for c in cards:
210                 if c.territory != None and c.territory.player == player:
211                     territory_production[c.territory.name] = 1
212             return (p, territory_production)
213         raise PlayerError('%s is neither a set nor a run' % cards)
214
215 class Hand (list):
216     def __init__(self, cards=[]):
217         list.__init__(self, cards)
218     def set(self):
219         s = sorted(set([card.type for card in self]))
220         if len(s) == 1 \
221                 or (len(s) == 2 and s[0] == 0):
222             return True
223         return False
224     def run(self):
225         if len(set([card.type for card in self])) == 3:
226             return True
227         return False
228     def possible(self):
229         if len(self) >= 3:
230             for i,c1 in enumerate(self[:-2]):
231                 for j,c2 in enumerate(self[i+1:-1]):
232                     for c3 in self[i+j+2:]:
233                         h = Hand([c1, c2, c3])
234                         if h.set() or h.run():
235                             yield h
236
237 class Player (ID_CmpMixin):
238     def __init__(self, name):
239         self.name = name
240         ID_CmpMixin.__init__(self)
241         self.alive = True
242         self.hand = Hand()
243         self._message_index = 0
244     def territories(self, world):
245         for t in world.territories():
246             if t.player == self:
247                 yield t
248     def border_territories(self, world):
249         for t in self.territories(world):
250             for neighbor in t:
251                 if neighbor.player != self:
252                     yield t
253                     break
254     def report(self, world, log):
255         """Send reports about death and game endings.
256
257         These events mark the end of contact and require no change in
258         player status or response, so they get a special command
259         seperate from the usual phase_* family.  The phase_* commands
260         in Player subclasses can notify the player (possibly by
261         calling report internally) if they feel so inclined.
262         """
263         print 'Reporting for %s:\n  %s' \
264             % (self, '\n  '.join(log[self._message_index:]))
265         self._message_index = len(log)
266     def phase_select_territory(self, world, log):
267         """Return the selected territory
268         """
269         free_territories = [t for t in world.territories() if t.player == None]
270         return random.sample(free_territories, 1)[0]
271     def phase_play_cards(self, world, log, play_required=True):
272         if play_required == True:
273             return random.sample(list(self.hand.possible()), 1)[0]
274     def phase_place_armies(self, world, log, remaining=1, this_round=1):
275         """Both during setup and before each turn.
276
277         Return {territory_name: num_armies, ...}
278         """
279         t = random.sample(list(self.border_territories(world)), 1)[0]
280         return {t.name: this_round}
281     def phase_attack(self, world, log):
282         """Return list of (source, target, armies) tuples.  Place None
283         in the list to end this phase.
284         """
285         possible_attacks = []
286         for t in self.border_territories(world):
287             if t.armies <= 3: #1: # be more conservative, only attack with 3 dice
288                 continue
289             targets = [border_t for border_t in t if border_t.player != self]
290             for tg in targets:
291                 possible_attacks.append((t.name, tg.name, min(3, t.armies-1)))
292         if len(possible_attacks) == 0:
293             return [None]
294         return random.sample(possible_attacks, 1) # + [None]
295     def phase_support_attack(self, world, log, source, target):
296         return source.armies-1
297     def phase_fortify(self, world, log):
298         """Return list of (source, target, armies) tuples.  Place None
299         in the list to end this phase.
300         """
301         return [None]
302     def phase_draw(self, world, log, cards=[]):
303         """Only called if you earned a new card (or cards)"""
304         self.hand.extend(cards)
305
306 class Engine (ID_CmpMixin):
307     def __init__(self, world, players, deck_class=Deck, logger_class=Logger):
308         ID_CmpMixin.__init__(self)
309         self.world = world
310         self.deck = deck_class(world.territories())
311         self.log = logger_class()
312         self.players = players
313     def __str__(self):
314         return '<engine %s %s>' % (self.world, self.players)
315     def __repr__(self):
316         return self.__str__()
317     def run(self):
318         self.setup()
319         self.play()
320         self.log('Game over.')
321         for p in self.players:
322             p.report(self.world, self.log)
323     def setup(self):
324         for p in self.players:
325             p.alive = True
326         random.shuffle(self.players)
327         self.select_territories()
328         self.place_initial_armies()
329         self.deal()
330     def play(self):
331         turn = 0
332         active_player = 0
333         living = len(self.living_players())
334         while living > 1:
335             self.play_turn(self.players[active_player])
336             living = len(self.living_players())
337             active_player = (active_player + 1) % living
338             turn += 1
339     def play_turn(self, player):
340         self.log("%s's turn (territory score: %s)"
341                  % (player, [(p,len(list(p.territories(self.world))))
342                              for p in self.players]))
343         self.play_cards_and_place_armies(player)
344         captures = self.attack_phase(player)
345         if captures > 0 and len(self.deck) > 0 and len(self.living_players()) > 1:
346             player.phase_draw(self.world, self.log, [self.deck.pop()])
347     def select_territories(self):
348         for t in self.world.territories():
349             t.player = None
350         for i in range(len(list(self.world.territories()))):
351             p = self.players[i % len(self.players)]
352             t = p.phase_select_territory(self.world, self.log)
353             if t.player != None:
354                 raise PlayerError('Cannot select %s owned by %s'
355                                   % (t, t.player))
356             self.log('%s selects %s' % (p, t))
357             t.player = p
358             t.armies = 1
359     def place_initial_armies(self):
360         already_placed = [len(list(p.territories(self.world))) for p in self.players]
361         s = list(set(already_placed))
362         assert len(s) in [1,2], already_placed
363         if len(s) == 2: # catch up the players who are one territory short
364             assert min(s) == max(s)-1, 'Min %d, max %d' % (min(s), max(s))
365             for p,placed in zip(self.players, already_placed):
366                 if placed == min(s):
367                     self.player_place_armies(p, remaining, 1)
368         remaining = self.world.initial_armies[len(self.players)] - max(s)
369         while remaining > 0:
370             for p in self.players:
371                 self.player_place_armies(p, remaining, 1)
372             remaining -= 1
373     def player_place_armies(self, player, remaining=1, this_round=1):
374         placements = player.phase_place_armies(self.world, self.log, remaining, this_round)
375         if sum(placements.values()) != this_round:
376             raise PlayerError('Placing more than %d armies' % this_round)
377         for ter_name,armies in placements.items():
378             t = self.world.territory_by_name(ter_name)
379             if t.player != player:
380                 raise PlayerError('Placing armies in %s owned by %s'
381                                   % (t, t.player))
382             if armies < 0:
383                 raise PlayerError('Placing a negative number of armies (%d) in %s'
384                                   % (armies, t))
385         self.log('%s places %s' % (player, placements))
386         for ter_name,armies in placements.items():
387             t = self.world.territory_by_name(ter_name)
388             t.armies += armies
389     def deal(self):
390         for p in self.players:
391             cards = []
392             for i in range(3):
393                 cards.append(self.deck.pop())
394             p.phase_draw(self.world, self.log, cards)
395         self.log('Initial hands dealt')
396     def play_cards_and_place_armies(self, player, additional_armies=0):
397         cards_required = len(player.hand) >= 5
398         cards = player.phase_play_cards(
399             self.world, self.log, play_required=cards_required)
400         if cards_required == True and cards == None:
401             raise PlayerError('You have %d >= 5 cards in your hand, you must play'
402                               % len(player.hand))
403         w_prod,w_terr_prod = self.world.production(player)
404         self.log('%s earned %d armies from territories' % (player, w_prod))
405         c_prod,c_terr_prod = self.deck.production(player, cards)
406         if c_prod > 0:
407             self.log('%s played %s, earning %d armies'
408                      % (player, cards, c_prod+sum(c_terr_prod.values())))
409         if cards != None:
410             for c in cards:
411                 player.hand.remove(c)
412         for terr,prod in c_terr_prod.items():
413             if terr in w_terr_prod:
414                 w_terr_prod[terr] += prod
415             else:
416                 w_terr_prod[terr] = prod
417         self.world.place_territory_production(w_terr_prod)
418         if len(w_terr_prod) > 0:
419             self.log('%s was required to place %s' % (player, w_terr_prod))
420         armies = w_prod + c_prod
421         self.player_place_armies(player, armies, armies)
422     def attack_phase(self, player):
423         captures = 0
424         while True:
425             attacks = player.phase_attack(self.world, self.log)
426             for attack in attacks:
427                 if attack == None:
428                     return captures
429                 source_name,target_name,armies = attack
430                 source = self.world.territory_by_name(source_name)
431                 target = self.world.territory_by_name(target_name)
432                 tplayer = target.player
433                 capture = self.attack(source, target, armies)
434                 if capture == True:
435                     captures += 1
436                     if len(list(tplayer.territories(self.world))) == 0:
437                         self.player_killed(tplayer, killer=player)
438     def attack(self, source, target, armies):
439         if source.player == target.player:
440             raise PlayerError('%s attacking %s, but you own both.'
441                               % (source, target))
442         if armies == 0:
443             raise PlayerError('%s attacking %s with 0 armies.'
444                               % (source, target))
445         if armies >= source.armies:
446             raise PlayerError('%s attacking %s with %d armies, but only %d are available.'
447                               % (source, target, armies, source.armies-1))
448         a_dice = sorted([random.randint(1, 6) for i in range(armies)],
449                         reverse=True)
450         t_dice = sorted([random.randint(1, 6) for i in range(min(2, target.armies))],
451                         reverse=True)
452         a_dead = 0
453         t_dead = 0
454         for a,d in zip(a_dice, t_dice):
455             if d >= a:
456                 a_dead += 1
457             else:
458                 t_dead += 1
459         source.armies -= a_dead
460         target.armies -= t_dead
461         if target.armies == 0:
462             self.takeover(source, target, remaining_attackers=armies-a_dead)
463             self.log('%s conquered %s from %s with %d:%d.  Deaths %d:%d.  Remaining %d:%d'
464                      % (source.player, target, source, armies, len(t_dice),
465                         a_dead, t_dead, source.armies, target.armies))
466             assert target.armies > 0, target
467             return True
468         self.log('%s attacked %s from %s with %d:%d.  Deaths %d:%d.  Remaining %d:%d' \
469                      % (source.player, target, source, armies, len(t_dice),
470                         a_dead, t_dead, source.armies, target.armies))
471         assert target.armies > 0, target
472         return False
473     def takeover(self, source, target, remaining_attackers):
474         source.armies -= remaining_attackers
475         target.armies += remaining_attackers
476         target.player = source.player
477         support = source.player.phase_support_attack(self.world, self.log, source, target)
478         if support < 0 or support >= source.armies:
479             raise PlayerError('Cannot support from %s to %s with %d armies, only %d available'
480                               % (source, target, support, source.armies-1))
481         source.armies -= support
482         target.armies += support
483     def player_killed(self, player, killer):
484         player.alive = False
485         killer.hand.extend(player.hand)
486         if len(self.living_players()) > 1:
487             while len(killer.hand) > 5:
488                 self.play_cards_and_place_armies(killer)
489         self.log('%s killed by %s' % (player, killer))
490         if len(self.living_players()) > 1:
491             player.report(self.world, self.log)
492             # else the game is over, and killed will hear about this then.
493     def living_players(self):
494         return [p for p in self.players if p.alive == True]
495
496
497 def generate_earth():
498     w = World('Earth')
499     c = Continent('North America', 5)
500     c.append(Territory('Alaska', 'ala', 1, ['kam', 'nwt']))    
501     c.append(Territory('Northwest Territory', 'nwt', 2, ['alb', 'ont', 'gre']))
502     c.append(Territory('Greenland', 'gre', 3, ['ont', 'que', 'ice']))
503     c.append(Territory('Alberta', 'alb', 1, ['ont', 'wus']))
504     c.append(Territory('Ontario', 'ont', 2, ['wus', 'eus', 'que']))
505     c.append(Territory('Quebec', 'que', 3, ['eus']))
506     c.append(Territory('Western United States', 'wus', 1, ['eus', 'cam']))
507     c.append(Territory('Eastern United States', 'eus', 2, ['cam']))
508     c.append(Territory('Central America', 'cam', 3, ['ven']))
509     w.append(c)
510     
511     c = Continent('Europe', 5)
512     c.append(Territory('Iceland', 'ice', 1, ['gbr', 'sca']))
513     c.append(Territory('Scandanavia', 'sca', 2, ['gbr', 'neu', 'ukr']))
514     c.append(Territory('Ukraine', 'ukr', 3, ['neu', 'seu', 'ura', 'afg', 'mea']))
515     c.append(Territory('Great Britain', 'gbr', 1, ['neu', 'weu']))
516     c.append(Territory('Northern Europe', 'neu', 2, ['weu', 'seu']))
517     c.append(Territory('Western Europe', 'weu', 3, ['naf', 'seu']))
518     c.append(Territory('Southern Europe', 'seu', 1, ['naf', 'egy', 'mea']))
519     w.append(c)
520
521     c = Continent('Asia', 7)
522     c.append(Territory('Urals', 'ura', 2, ['afg', 'chi', 'sib']))
523     c.append(Territory('Siberia', 'sib', 3, ['chi', 'mon', 'irk', 'yak']))
524     c.append(Territory('Yakutsk', 'yak', 1, ['irk', 'kam']))
525     c.append(Territory('Kamchatka', 'kam', 2, ['mon', 'jap']))
526     c.append(Territory('Irkutsk', 'irk', 3, ['mon']))
527     c.append(Territory('Mongolia', 'mon', 1, ['chi', 'jap']))
528     c.append(Territory('Japan', 'jap', 2))
529     c.append(Territory('Afghanistan', 'afg', 3, ['mea', 'indi', 'chi']))
530     c.append(Territory('China', 'chi', 1, ['indi', 'sia']))
531     c.append(Territory('Middle East', 'mea', 2, ['egy', 'eaf', 'indi']))
532     c.append(Territory('India', 'indi', 3, ['sia']))
533     c.append(Territory('Siam', 'sia', 1, ['indo']))
534
535     w.append(c)
536
537     c = Continent('South America', 2)
538     c.append(Territory('Venezuala', 'ven', 2, ['per', 'bra']))
539     c.append(Territory('Peru', 'per', 3, ['arg', 'bra']))
540     c.append(Territory('Brazil', 'bra', 1, ['arg', 'naf']))
541     c.append(Territory('Argentina', 'arg', 2))
542     w.append(c)
543
544     c = Continent('Africa', 3)
545     c.append(Territory('North Africa', 'naf', 3, ['egy', 'eaf', 'con']))
546     c.append(Territory('Egypt', 'egy', 1, ['eaf']))
547     c.append(Territory('East Africa', 'eaf', 2, ['con', 'saf', 'mad']))
548     c.append(Territory('Congo', 'con', 3, ['saf']))
549     c.append(Territory('South Africa', 'saf', 1, ['mad']))
550     c.append(Territory('Madagascar', 'mad', 2))
551     w.append(c)
552
553     c = Continent('Australia', 2)
554     c.append(Territory('Indonesia', 'indo', 3, ['ngu', 'wau']))
555     c.append(Territory('New Guinea', 'ngu', 1, ['wau', 'eau']))
556     c.append(Territory('Western Australia', 'wau', 2, ['eau']))
557     c.append(Territory('Eastern Australia', 'eau', 3))
558     w.append(c)
559
560     w._resolve_link_names()
561     return w
562
563 def test():
564     import doctest
565     failures,tests = doctest.testmod()
566     return failures
567
568 def random_game():
569     world = generate_earth()
570     players = [Player('Alice'), Player('Bob'), Player('Charlie')]
571     e = Engine(world, players)
572     e.run()
573
574 if __name__ == '__main__':
575     import sys
576     failures = self.test()
577     if failures > 0:
578         sys.exit(1)
579     self.random_game()