1 """A Python engine for Risk-like games
6 from .log import Logger
11 class PlayerError (Exception):
14 class ID_CmpMixin (object):
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
26 class Territory (list, ID_CmpMixin):
27 def __init__(self, name, short_name=None, type=-1,
28 link_names=[], continent=None, player=None):
30 ID_CmpMixin.__init__(self)
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
42 if self.short_name == self.name:
44 return '%s (%s)' % (self.name, self.short_name)
47 def borders(self, other):
49 if id(t) == id(other):
53 class Continent (list, ID_CmpMixin):
54 def __init__(self, name, production, territories=[]):
55 list.__init__(self, territories)
56 ID_CmpMixin.__init__(self)
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):
64 if name.lower() in [t.short_name.lower(), t.name.lower()]:
65 #assert self.contains_territory(t), t
68 def contains_territory(self, territory):
73 def single_player(self):
75 for territory in self:
76 if territory.player != p:
80 class World (list, ID_CmpMixin):
81 def __init__(self, name, continents=[]):
82 list.__init__(self, continents)
83 ID_CmpMixin.__init__(self)
85 self.initial_armies = { # num_players:num_armies
86 2: 40, 3:35, 4:30, 5:25, 6:20
88 def territories(self):
89 for continent in self:
90 for territory in continent:
92 def territory_by_name(self, name):
93 for continent in self:
95 return continent.territory_by_name(name)
99 def contains_territory(self, territory):
100 for continent in self:
101 if continent.contains_territory(territory):
104 def continent_by_name(self, name):
105 for continent in self:
106 if continent.name.lower() == name.lower():
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):
120 for t in self.territories():
121 if t.short_name not in ts:
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
140 class Card (ID_CmpMixin):
141 def __init__(self, deck, type_, territory=None):
142 ID_CmpMixin.__init__(self)
144 self.territory = territory
147 if self.territory == None:
148 return '<Card %s>' % (self.deck.type_names[self.type])
150 return '<Card %s %s>' % (self.territory,
151 self.deck.type_names[self.type])
153 return self.__str__()
156 def __init__(self, territories=[]):
157 list.__init__(self, [Card(self, t._card_type, t) for t in territories])
159 self.type_names = ['Wild', 'Infantry', 'Cavalry', 'Artillery', 'Wild']
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):
167 >>> [d.production_value(i) for i in range(8)]
168 [4, 6, 8, 10, 12, 15, 20, 25]
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):
177 >>> a = Player('Alice')
178 >>> b = Player('Bob')
179 >>> d.production(a, None)
181 >>> d.production(a, [Card(d, 1, Territory('a')),
182 ... Card(d, 1, Territory('b'))])
183 Traceback (most recent call last):
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'))])
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))])
196 >>> sorted(tp.items())
197 [('a', 1), ('b', 1), ('c', 1)]
202 raise PlayerError('You must play cards in groups of 3, not %d\n(%s)'
203 % (len(cards), cards))
205 if h.set() or h.run():
206 p = self.production_value(self._production_index)
207 self._production_index += 1
208 territory_production = {}
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)
216 def __init__(self, cards=[]):
217 list.__init__(self, cards)
219 s = sorted(set([card.type for card in self]))
221 or (len(s) == 2 and s[0] == 0):
225 if len(set([card.type for card in 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():
237 class Player (ID_CmpMixin):
238 def __init__(self, name):
240 ID_CmpMixin.__init__(self)
243 self._message_index = 0
244 def territories(self, world):
245 for t in world.territories():
248 def border_territories(self, world):
249 for t in self.territories(world):
251 if neighbor.player != self:
254 def report(self, world, log):
255 """Send reports about death and game endings.
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.
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
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.
277 Return {territory_name: num_armies, ...}
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.
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
289 targets = [border_t for border_t in t if border_t.player != self]
291 possible_attacks.append((t.name, tg.name, min(3, t.armies-1)))
292 if len(possible_attacks) == 0:
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.
302 def phase_draw(self, world, log, cards=[]):
303 """Only called if you earned a new card (or cards)"""
304 self.hand.extend(cards)
306 class Engine (ID_CmpMixin):
307 def __init__(self, world, players, deck_class=Deck, logger_class=Logger):
308 ID_CmpMixin.__init__(self)
310 self.deck = deck_class(world.territories())
311 self.log = logger_class()
312 self.players = players
314 return '<engine %s %s>' % (self.world, self.players)
316 return self.__str__()
320 self.log('Game over.')
321 for p in self.players:
322 p.report(self.world, self.log)
324 for p in self.players:
326 random.shuffle(self.players)
327 self.select_territories()
328 self.place_initial_armies()
333 living = len(self.living_players())
335 self.play_turn(self.players[active_player])
336 living = len(self.living_players())
337 active_player = (active_player + 1) % living
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():
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)
354 raise PlayerError('Cannot select %s owned by %s'
356 self.log('%s selects %s' % (p, t))
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):
367 self.player_place_armies(p, remaining, 1)
368 remaining = self.world.initial_armies[len(self.players)] - max(s)
370 for p in self.players:
371 self.player_place_armies(p, 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'
383 raise PlayerError('Placing a negative number of armies (%d) in %s'
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)
390 for p in self.players:
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'
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)
407 self.log('%s played %s, earning %d armies'
408 % (player, cards, c_prod+sum(c_terr_prod.values())))
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
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):
425 attacks = player.phase_attack(self.world, self.log)
426 for attack in attacks:
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)
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.'
443 raise PlayerError('%s attacking %s with 0 armies.'
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)],
450 t_dice = sorted([random.randint(1, 6) for i in range(min(2, target.armies))],
454 for a,d in zip(a_dice, t_dice):
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
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
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):
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]
497 def generate_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']))
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']))
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']))
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))
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))
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))
560 w._resolve_link_names()
565 failures,tests = doctest.testmod()
569 world = generate_earth()
570 players = [Player('Alice'), Player('Bob'), Player('Charlie')]
571 e = Engine(world, players)
574 if __name__ == '__main__':
576 failures = self.test()