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 phase_report(self, world, log):
255 print 'Reporting for %s:\n %s' \
256 % (self, '\n '.join(log[self._message_index:]))
257 self._message_index = len(log)
258 def phase_select_territory(self, world):
259 """Return the selected territory
261 free_territories = [t for t in world.territories() if t.player == None]
262 return random.sample(free_territories, 1)[0]
263 def phase_play_cards(self, world, play_required=True):
264 if play_required == True:
265 return random.sample(list(self.hand.possible()), 1)[0]
266 def phase_place_armies(self, world, remaining=1, this_round=1):
267 """Both during setup and before each turn.
269 Return {territory_name: num_armies, ...}
271 t = random.sample(list(self.border_territories(world)), 1)[0]
272 return {t.name: this_round}
273 def phase_attack(self, world):
274 """Return list of (source, target, armies) tuples. Place None
275 in the list to end this phase.
277 possible_attacks = []
278 for t in self.border_territories(world):
279 if t.armies <= 3: #1: # be more conservative, only attack with 3 dice
281 targets = [border_t for border_t in t if border_t.player != self]
283 possible_attacks.append((t.name, tg.name, min(3, t.armies-1)))
284 if len(possible_attacks) == 0:
286 return random.sample(possible_attacks, 1) # + [None]
287 def phase_support_attack(self, world, source, target):
288 return source.armies-1
289 def phase_fortify(self, world):
290 """Return list of (source, target, armies) tuples. Place None
291 in the list to end this phase.
294 def phase_draw(self, cards=[]):
295 """Only called if you earned a new card (or cards)"""
296 self.hand.extend(cards)
298 class Engine (ID_CmpMixin):
299 def __init__(self, world, players, deck_class=Deck, logger_class=Logger):
300 ID_CmpMixin.__init__(self)
302 self.deck = deck_class(world.territories())
303 self.log = logger_class()
304 self.players = players
306 return '<engine %s %s>' % (self.world, self.players)
308 return self.__str__()
312 self.log('Game over.')
313 for p in self.players:
314 p.phase_report(self.world, self.log)
316 for p in self.players:
318 random.shuffle(self.players)
319 self.select_territories()
320 self.place_initial_armies()
325 living = len(self.living_players())
327 self.play_turn(self.players[active_player])
328 living = len(self.living_players())
329 active_player = (active_player + 1) % living
331 def play_turn(self, player):
332 self.log("%s's turn (territory score: %s)"
333 % (player, [(p,len(list(p.territories(self.world))))
334 for p in self.players]))
335 player.phase_report(self.world, self.log)
336 self.play_cards_and_place_armies(player)
337 captures = self.attack_phase(player)
338 if captures > 0 and len(self.deck) > 0 and len(self.living_players()) > 1:
339 player.phase_draw([self.deck.pop()])
340 def select_territories(self):
341 for t in self.world.territories():
343 for i in range(len(list(self.world.territories()))):
344 p = self.players[i % len(self.players)]
345 p.phase_report(self.world, self.log)
346 t = p.phase_select_territory(self.world)
348 raise PlayerError('Cannot select %s owned by %s'
350 self.log('%s selects %s' % (p, t))
353 def place_initial_armies(self):
354 already_placed = [len(list(p.territories(self.world))) for p in self.players]
355 s = list(set(already_placed))
356 assert len(s) in [1,2], already_placed
357 if len(s) == 2: # catch up the players who are one territory short
358 assert min(s) == max(s)-1, 'Min %d, max %d' % (min(s), max(s))
359 for p,placed in zip(self.players, already_placed):
361 p.phase_report(self.world, self.log)
362 self.player_place_armies(p, remaining, 1)
363 remaining = self.world.initial_armies[len(self.players)] - max(s)
365 for p in self.players:
366 p.phase_report(self.world, self.log)
367 self.player_place_armies(p, remaining, 1)
369 def player_place_armies(self, player, remaining=1, this_round=1):
370 placements = player.phase_place_armies(self.world, remaining, this_round)
371 if sum(placements.values()) != this_round:
372 raise PlayerError('Placing more than %d armies' % this_round)
373 for ter_name,armies in placements.items():
374 t = self.world.territory_by_name(ter_name)
375 if t.player != player:
376 raise PlayerError('Placing armies in %s owned by %s'
379 raise PlayerError('Placing a negative number of armies (%d) in %s'
381 self.log('%s places %s' % (player, placements))
382 for ter_name,armies in placements.items():
383 t = self.world.territory_by_name(ter_name)
386 for p in self.players:
389 cards.append(self.deck.pop())
391 self.log('Initial hands dealt')
392 def play_cards_and_place_armies(self, player, additional_armies=0):
393 cards_required = len(player.hand) >= 5
394 cards = player.phase_play_cards(
395 self.world, play_required=cards_required)
396 if cards_required == True and cards == None:
397 raise PlayerError('You have %d >= 5 cards in your hand, you must play'
399 w_prod,w_terr_prod = self.world.production(player)
400 self.log('%s earned %d armies from territories' % (player, w_prod))
401 c_prod,c_terr_prod = self.deck.production(player, cards)
403 self.log('%s played %s, earning %d armies'
404 % (player, cards, c_prod+sum(c_terr_prod.values())))
407 player.hand.remove(c)
408 for terr,prod in c_terr_prod.items():
409 if terr in w_terr_prod:
410 w_terr_prod[terr] += prod
412 w_terr_prod[terr] = prod
413 self.world.place_territory_production(w_terr_prod)
414 if len(w_terr_prod) > 0:
415 self.log('%s was required to place %s' % (player, w_terr_prod))
416 armies = w_prod + c_prod
417 self.player_place_armies(player, armies, armies)
418 def attack_phase(self, player):
421 attacks = player.phase_attack(self.world)
422 for attack in attacks:
425 source_name,target_name,armies = attack
426 source = self.world.territory_by_name(source_name)
427 target = self.world.territory_by_name(target_name)
428 tplayer = target.player
429 capture = self.attack(source, target, armies)
432 if len(list(tplayer.territories(self.world))) == 0:
433 self.player_killed(tplayer, killer=player)
434 def attack(self, source, target, armies):
435 if source.player == target.player:
436 raise PlayerError('%s attacking %s, but you own both.'
439 raise PlayerError('%s attacking %s with 0 armies.'
441 if armies >= source.armies:
442 raise PlayerError('%s attacking %s with %d armies, but only %d are available.'
443 % (source, target, armies, source.armies-1))
444 a_dice = sorted([random.randint(1, 6) for i in range(armies)],
446 t_dice = sorted([random.randint(1, 6) for i in range(min(2, target.armies))],
450 for a,d in zip(a_dice, t_dice):
455 source.armies -= a_dead
456 target.armies -= t_dead
457 if target.armies == 0:
458 self.takeover(source, target, remaining_attackers=armies-a_dead)
459 self.log('%s conquered %s from %s with %d:%d. Deaths %d:%d. Remaining %d:%d'
460 % (source.player, target, source, armies, len(t_dice),
461 a_dead, t_dead, source.armies, target.armies))
462 assert target.armies > 0, target
464 self.log('%s attacked %s from %s with %d:%d. Deaths %d:%d. Remaining %d:%d' \
465 % (source.player, target, source, armies, len(t_dice),
466 a_dead, t_dead, source.armies, target.armies))
467 assert target.armies > 0, target
469 def takeover(self, source, target, remaining_attackers):
470 source.armies -= remaining_attackers
471 target.armies += remaining_attackers
472 target.player = source.player
473 support = source.player.phase_support_attack(self.world, source, target)
474 if support < 0 or support >= source.armies:
475 raise PlayerError('Cannot support from %s to %s with %d armies, only %d available'
476 % (source, target, support, source.armies-1))
477 source.armies -= support
478 target.armies += support
479 def player_killed(self, player, killer):
481 killer.hand.extend(player.hand)
482 if len(self.living_players()) > 1:
483 while len(killer.hand) > 5:
484 self.play_cards_and_place_armies(killer)
485 self.log('%s killed by %s' % (player, killer))
486 if len(self.living_players()) > 1:
487 player.phase_report(self.world, self.log)
488 # else the game is over, and killed will hear about this then.
489 def living_players(self):
490 return [p for p in self.players if p.alive == True]
493 def generate_earth():
495 c = Continent('North America', 5)
496 c.append(Territory('Alaska', 'ala', 1, ['kam', 'nwt']))
497 c.append(Territory('Northwest Territory', 'nwt', 2, ['alb', 'ont', 'gre']))
498 c.append(Territory('Greenland', 'gre', 3, ['ont', 'que', 'ice']))
499 c.append(Territory('Alberta', 'alb', 1, ['ont', 'wus']))
500 c.append(Territory('Ontario', 'ont', 2, ['wus', 'eus', 'que']))
501 c.append(Territory('Quebec', 'que', 3, ['eus']))
502 c.append(Territory('Western United States', 'wus', 1, ['eus', 'cam']))
503 c.append(Territory('Eastern United States', 'eus', 2, ['cam']))
504 c.append(Territory('Central America', 'cam', 3, ['ven']))
507 c = Continent('Europe', 5)
508 c.append(Territory('Iceland', 'ice', 1, ['gbr', 'sca']))
509 c.append(Territory('Scandanavia', 'sca', 2, ['gbr', 'neu', 'ukr']))
510 c.append(Territory('Ukraine', 'ukr', 3, ['neu', 'seu', 'ura', 'afg', 'mea']))
511 c.append(Territory('Great Britain', 'gbr', 1, ['neu', 'weu']))
512 c.append(Territory('Northern Europe', 'neu', 2, ['weu', 'seu']))
513 c.append(Territory('Western Europe', 'weu', 3, ['naf', 'seu']))
514 c.append(Territory('Southern Europe', 'seu', 1, ['naf', 'egy', 'mea']))
517 c = Continent('Asia', 7)
518 c.append(Territory('Urals', 'ura', 2, ['afg', 'chi', 'sib']))
519 c.append(Territory('Siberia', 'sib', 3, ['chi', 'mon', 'irk', 'yak']))
520 c.append(Territory('Yakutsk', 'yak', 1, ['irk', 'kam']))
521 c.append(Territory('Kamchatka', 'kam', 2, ['mon', 'jap']))
522 c.append(Territory('Irkutsk', 'irk', 3, ['mon']))
523 c.append(Territory('Mongolia', 'mon', 1, ['chi', 'jap']))
524 c.append(Territory('Japan', 'jap', 2))
525 c.append(Territory('Afghanistan', 'afg', 3, ['mea', 'indi', 'chi']))
526 c.append(Territory('China', 'chi', 1, ['indi', 'sia']))
527 c.append(Territory('Middle East', 'mea', 2, ['egy', 'eaf', 'indi']))
528 c.append(Territory('India', 'indi', 3, ['sia']))
529 c.append(Territory('Siam', 'sia', 1, ['indo']))
533 c = Continent('South America', 2)
534 c.append(Territory('Venezuala', 'ven', 2, ['per', 'bra']))
535 c.append(Territory('Peru', 'per', 3, ['arg', 'bra']))
536 c.append(Territory('Brazil', 'bra', 1, ['arg', 'naf']))
537 c.append(Territory('Argentina', 'arg', 2))
540 c = Continent('Africa', 3)
541 c.append(Territory('North Africa', 'naf', 3, ['egy', 'eaf', 'con']))
542 c.append(Territory('Egypt', 'egy', 1, ['eaf']))
543 c.append(Territory('East Africa', 'eaf', 2, ['con', 'saf', 'mad']))
544 c.append(Territory('Congo', 'con', 3, ['saf']))
545 c.append(Territory('South Africa', 'saf', 1, ['mad']))
546 c.append(Territory('Madagascar', 'mad', 2))
549 c = Continent('Australia', 2)
550 c.append(Territory('Indonesia', 'indo', 3, ['ngu', 'wau']))
551 c.append(Territory('New Guinea', 'ngu', 1, ['wau', 'eau']))
552 c.append(Territory('Western Australia', 'wau', 2, ['eau']))
553 c.append(Territory('Eastern Australia', 'eau', 3))
556 w._resolve_link_names()
561 failures,tests = doctest.testmod()
565 world = generate_earth()
566 players = [Player('Alice'), Player('Bob'), Player('Charlie')]
567 e = Engine(world, players)
570 if __name__ == '__main__':
572 failures = self.test()