Use codecs.open to pull the README text into setup.py's long_description.
[pyrisk.git] / pyrisk / base.py
index 463ddff84526f27209c870a8028efff9a637e54f..8229f6080e0e9f40519cc93d7d048f7e54972464 100644 (file)
@@ -19,9 +19,9 @@
 
 import random
 
-from .log import Logger
-
-VERSION='0.1'
+from .log import Logger, BeginGame, EndGame, Killed, StartTurn, DealtCards, \
+    EarnsArmies, SelectTerritory, PlaceArmies, PlayCards, Attack, Conquer, \
+    Fortify
 
 
 class PlayerError (Exception):
@@ -76,6 +76,11 @@ class Territory (NameMixin, ID_CmpMixin, list):
             if id(t) == id(other):
                 return True
         return False
+    def border(self):
+        for t in self:
+            if t.player != self.player:
+                return True
+        return False
 
 class Continent (NameMixin, ID_CmpMixin, list):
     """A group of Territories.
@@ -401,15 +406,6 @@ class Player (NameMixin, ID_CmpMixin):
         for t in world.territories():
             if t.player == self:
                 yield t
-    def border_territories(self, world):
-        """Iterate through all territories owned by this player which
-        border another player's territories.
-        """
-        for t in self.territories(world):
-            for neighbor in t:
-                if neighbor.player != self:
-                    yield t
-                    break
     def report(self, world, log):
         """Send reports about death and game endings.
 
@@ -424,7 +420,7 @@ class Player (NameMixin, ID_CmpMixin):
         draw - another notification-only method
         """
         print 'Reporting for %s:\n  %s' \
-            % (self, '\n  '.join(log[self._message_index:]))
+            % (self, '\n  '.join([str(e) for e in log[self._message_index:]]))
         self._message_index = len(log)
     def draw(self, world, log, cards=[]):
         """Only called if you earned a new card (or cards).
@@ -434,12 +430,13 @@ class Player (NameMixin, ID_CmpMixin):
         report - another notification-only method
         """
         pass
-    def select_territory(self, world, log):
+    def select_territory(self, world, log, error=None):
         """Return the selected territory's name.
         """
         free_territories = [t for t in world.territories() if t.player == None]
         return random.sample(free_territories, 1)[0].name
-    def play_cards(self, world, log, play_required=True):
+    def play_cards(self, world, log, error=None,
+                   play_required=True):
         """Decide whether or not to turn in a set of cards.
 
         Return a list of cards to turn in or None.  If play_required
@@ -447,29 +444,47 @@ class Player (NameMixin, ID_CmpMixin):
         """
         if play_required == True:
             return random.sample(list(self.hand.possible()), 1)[0]
-    def place_armies(self, world, log, remaining=1, this_round=1):
+    def place_armies(self, world, log, error=None,
+                     remaining=1, this_round=1):
         """Both during setup and before each turn.
 
         Return {territory_name: num_armies, ...}
         """
-        t = random.sample(list(self.border_territories(world)), 1)[0]
-        return {t.name: this_round}
-    def attack_and_fortify(self, world, log, mode='attack'):
+        terr = random.sample([t for t in self.territories(world)
+                              if t.border()], 1)[0]
+        return {terr.name: this_round}
+    def attack_and_fortify(self, world, log, error=None,
+                           mode='attack'):
         """Return list of (source, target, armies) tuples.  Place None
         in the list to end this phase.
         """
         assert mode != 'fortify', mode
         possible_attacks = []
-        for t in self.border_territories(world):
+        for t in self.territories(world):
+            if not t.border():
+                continue
             if t.armies <= 3: #1: # be more conservative, only attack with 3 dice
                 continue
             targets = [border_t for border_t in t if border_t.player != self]
             for tg in targets:
-                possible_attacks.append((t.name, tg.name, min(3, t.armies-1)))
+                possible_attacks.append(
+                    (t.name, tg.name, min(3, t.armies-1)))
         if len(possible_attacks) == 0:
-            return [None, None] # stop attack phase, then stop fortification phase
-        return random.sample(possible_attacks, 1) # + [None]
-    def support_attack(self, world, log, source, target):
+            fortifications = []
+            for t in self.territories(world):
+                if t.border() or t.armies == 1:
+                    continue
+                targets = list(t)
+                for tg in targets:
+                    fortifications.append(
+                        (t.name, tg.name, t.armies-1))
+            if len(fortifications) > 1:
+                fortifications = random.sample(fortifications, 1)
+            # stop attack phase, fortify, stop fortification phase
+            return [None] + fortifications + [None]
+        return random.sample(possible_attacks, 1)
+    def support_attack(self, world, log, error,
+                       source, target):
         """Follow up on a conquest by moving additional armies.
         """
         return source.armies-1
@@ -508,6 +523,7 @@ class Engine (ID_CmpMixin):
         for p in self.players:
             p.alive = True
         random.shuffle(self.players)
+        self.log(BeginGame(self.players))
         self.deck.shuffle()
         self.select_territories()
         self.place_initial_armies()
@@ -532,31 +548,44 @@ class Engine (ID_CmpMixin):
 
         Currently just a notification hook.
         """
-        self.log('Game over.')
+        self.log(EndGame(self.players))
         for p in self.players:
             p.report(self.world, self.log)
     def play_turn(self, player):
         """Work through the phases of player's turn.
         """
-        self.log("%s's turn (territory score: %s)"
-                 % (player, [(p,len(list(p.territories(self.world))))
-                             for p in self.players]))
+        self.log(StartTurn(player, self.players, self.world))
         self.play_cards_and_place_armies(player)
         captures = self.attack_and_fortify(player)
         self.end_of_turn_cards(player, captures)
     def select_territories(self):
         for t in self.world.territories():
             t.player = None
-        for i in range(len(list(self.world.territories()))):
+        num_terrs = len(list(self.world.territories()))
+        for i in range(num_terrs-1):
             p = self.players[i % len(self.players)]
-            t_name = p.select_territory(self.world, self.log)
-            t = self.world.territory_by_name(t_name)
-            if t.player != None:
-                raise PlayerError('Cannot select %s owned by %s'
-                                  % (t, t.player))
-            self.log('%s selects %s' % (p, t))
+            error = None
+            while True:
+                try:
+                    t_name = p.select_territory(self.world, self.log, error)
+                    try:
+                        t = self.world.territory_by_name(t_name)
+                    except KeyError:
+                        raise PlayerError('Invalid territory "%s"' % t_name)
+                    if t.player != None:
+                        raise PlayerError('Cannot select %s owned by %s'
+                                          % (t, t.player))
+                    break
+                except PlayerError, error:
+                    continue
+            self.log(SelectTerritory(p, t))
             t.player = p
             t.armies = 1
+        # last player has no choice.
+        p = self.players[(num_terrs-1) % len(self.players)]
+        t = [t for t in self.world.territories() if t.player == None][0]
+        t.player = p
+        t.armies = 1
     def place_initial_armies(self):
         already_placed = [len(list(p.territories(self.world))) for p in self.players]
         s = list(set(already_placed))
@@ -572,18 +601,28 @@ class Engine (ID_CmpMixin):
                 self.player_place_armies(p, remaining, 1)
             remaining -= 1
     def player_place_armies(self, player, remaining=1, this_round=1):
-        placements = player.place_armies(self.world, self.log, remaining, this_round)
-        if sum(placements.values()) != this_round:
-            raise PlayerError('Placing more than %d armies' % this_round)
-        for ter_name,armies in placements.items():
-            t = self.world.territory_by_name(ter_name)
-            if t.player != player:
-                raise PlayerError('Placing armies in %s owned by %s'
-                                  % (t, t.player))
-            if armies < 0:
-                raise PlayerError('Placing a negative number of armies (%d) in %s'
-                                  % (armies, t))
-        self.log('%s places %s' % (player, placements))
+        error = None
+        while True:
+            try:
+                placements = player.place_armies(self.world, self.log, error,
+                                                 remaining, this_round)
+                if sum(placements.values()) != this_round:
+                    raise PlayerError('Placing more than %d armies' % this_round)
+                for ter_name,armies in placements.items():
+                    try:
+                        t = self.world.territory_by_name(ter_name)
+                    except KeyError:
+                        raise PlayerError('Invalid territory "%s"' % t_name)
+                    if t.player != player:
+                        raise PlayerError('Placing armies in %s owned by %s'
+                                          % (t, t.player))
+                    if armies < 0:
+                        raise PlayerError('Placing a negative number of armies (%d) in %s'
+                                          % (armies, t))
+                break
+            except PlayerError, error:
+                continue
+        self.log(PlaceArmies(player, placements))
         for terr_name,armies in placements.items():
             t = self.world.territory_by_name(terr_name)
             t.armies += armies
@@ -593,62 +632,72 @@ class Engine (ID_CmpMixin):
             cards.append(self.deck.pop())
         player.hand.extend(cards)
         player.draw(self.world, self.log, cards)
-        self.log('%s dealt %d cards' % (player, number))
+        self.log(DealtCards(player, number, len(self.deck)))
     def play_cards_and_place_armies(self, player, additional_armies=0):
         cards_required = len(player.hand) >= 5
-        cards = player.play_cards(
-            self.world, self.log, play_required=cards_required)
-        if cards_required == True and cards == None:
-            raise PlayerError('You have %d >= 5 cards in your hand, you must play'
-                              % len(player.hand))
+        error = None
+        while True:
+            try:
+                cards = player.play_cards(
+                    self.world, self.log, error, play_required=cards_required)
+                if cards_required == True and cards == None:
+                    raise PlayerError('You have %d >= 5 cards in your hand, you must play'
+                                      % len(player.hand))
+                c_prod,c_terr_prod = self.deck.production(player, cards)
+                break
+            except PlayerError, error:
+                continue
         w_prod,w_terr_prod = self.world.production(player)
-        self.log('%s earned %d armies from territories' % (player, w_prod))
-        c_prod,c_terr_prod = self.deck.production(player, cards)
-        if c_prod > 0:
-            self.log('%s played %s, earning %d armies'
-                     % (player, cards, c_prod+sum(c_terr_prod.values())))
+        self.log(EarnsArmies(player, w_prod, w_terr_prod))
         if cards != None:
             for c in cards:
                 player.hand.remove(c)
+            self.log(PlayCards(player, cards, c_prod, c_terr_prod))
         for terr,prod in c_terr_prod.items():
             if terr in w_terr_prod:
                 w_terr_prod[terr] += prod
             else:
                 w_terr_prod[terr] = prod
         self.world.place_territory_production(w_terr_prod)
-        if len(w_terr_prod) > 0:
-            self.log('%s was required to place %s' % (player, w_terr_prod))
         armies = w_prod + c_prod
         self.player_place_armies(player, armies, armies)
     def attack_and_fortify(self, player):
         captures = 0
         mode = 'attack'
+        error = None
         while True:
-            actions = player.attack_and_fortify(self.world, self.log, mode)
-            for action in actions:
-                if action == None:
+            try:
+                actions = player.attack_and_fortify(self.world, self.log, error, mode)
+                for action in actions:
+                    if action == None:
+                        if mode == 'attack':
+                            mode = 'fortify'
+                            continue
+                        else:
+                            assert mode == 'fortify', mode
+                            return captures
+                    source_name,target_name,armies = action
+                    try:
+                        source = self.world.territory_by_name(source_name)
+                    except KeyError:
+                        raise PlayerError('Invalid territory "%s"' % source_name)
+                    try:
+                        target = self.world.territory_by_name(target_name)
+                    except KeyError:
+                        raise PlayerError('Invalid territory "%s"' % targer_name)
                     if mode == 'attack':
-                        mode = 'fortify'
-                        continue
+                        tplayer = target.player
+                        capture = self.attack(source, target, armies)
+                        if capture == True:
+                            captures += 1
+                            if len(list(tplayer.territories(self.world))) == 0:
+                                self.player_killed(tplayer, killer=player)
                     else:
                         assert mode == 'fortify', mode
-                        return captures
-                source_name,target_name,armies = action
-                source = self.world.territory_by_name(source_name)
-                target = self.world.territory_by_name(target_name)
-                if not source.borders(target):
-                    raise PlayerError('Cannot reach %s from %s to %s'
-                                      % (target, source, mode))
-                if mode == 'attack':
-                    tplayer = target.player
-                    capture = self.attack(source, target, armies)
-                    if capture == True:
-                        captures += 1
-                        if len(list(tplayer.territories(self.world))) == 0:
-                            self.player_killed(tplayer, killer=player)
-                else:
-                    assert mode == 'fortify', mode
-                    self.fortify(source, target, armies)
+                        self.fortify(source, target, armies)
+                        return captures # only allow one fortification
+            except PlayerError, error:
+                continue
     def attack(self, source, target, armies):
         if source.player == target.player:
             raise PlayerError('%s attacking %s, but you own both.'
@@ -659,29 +708,28 @@ class Engine (ID_CmpMixin):
         if armies >= source.armies:
             raise PlayerError('%s attacking %s with %d armies, but only %d are available.'
                               % (source, target, armies, source.armies-1))
-        a_dice = sorted([random.randint(1, 6) for i in range(armies)],
+        if not source.borders(target):
+            raise PlayerError('Cannot reach %s from %s to attack'
+                              % (target, source))
+        s_dice = sorted([random.randint(1, 6) for i in range(armies)],
                         reverse=True)
         t_dice = sorted([random.randint(1, 6) for i in range(min(2, target.armies))],
                         reverse=True)
-        a_dead = 0
+        s_dead = 0
         t_dead = 0
-        for a,d in zip(a_dice, t_dice):
+        for a,d in zip(s_dice, t_dice):
             if d >= a:
-                a_dead += 1
+                s_dead += 1
             else:
                 t_dead += 1
-        source.armies -= a_dead
+        source.armies -= s_dead
         target.armies -= t_dead
         if target.armies == 0:
-            self.takeover(source, target, remaining_attackers=armies-a_dead)
-            self.log('%s conquered %s from %s with %d:%d.  Deaths %d:%d.  Remaining %d:%d'
-                     % (source.player, target, source, armies, len(t_dice),
-                        a_dead, t_dead, source.armies, target.armies))
+            self.takeover(source, target, remaining_attackers=armies-s_dead)
+            self.log(Conquer(source, target, s_dice, t_dice, s_dead, t_dead))
             assert target.armies > 0, target
             return True
-        self.log('%s attacked %s from %s with %d:%d.  Deaths %d:%d.  Remaining %d:%d' \
-                     % (source.player, target, source, armies, len(t_dice),
-                        a_dead, t_dead, source.armies, target.armies))
+        self.log(Attack(source, target, s_dice, t_dice, s_dead, t_dead))
         assert target.armies > 0, target
         return False
     def takeover(self, source, target, remaining_attackers):
@@ -689,21 +737,42 @@ class Engine (ID_CmpMixin):
         target.armies += remaining_attackers
         target.player = source.player
         if source.armies > 1:
-            support = source.player.support_attack(
-                self.world, self.log, source, target)
-            if support < 0 or support >= source.armies:
-                raise PlayerError(
-                    'Cannot support from %s to %s with %d armies, only %d available'
-                    % (source, target, support, source.armies-1))
+            error = None
+            while True:
+                try:
+                    support = source.player.support_attack(
+                        self.world, self.log, error, source, target)
+                    if support < 0 or support >= source.armies:
+                        raise PlayerError(
+                            'Cannot support from %s to %s with %d armies, only %d available'
+                            % (source, target, support, source.armies-1))
+                    break
+                except PlayerError, error:
+                    continue
             source.armies -= support
             target.armies += support
+    def fortify(self, source, target, armies):
+        if source.player != target.player:
+            raise PlayerError('%s (%s) cannot fortifiy %s (%s).'
+                              % (source, source.player, target, target.player))
+        if armies == 0:
+            return
+        if armies >= source.armies:
+            raise PlayerError('%s fortifying %s with %d armies, but only %d are available.'
+                              % (source, target, armies, source.armies-1))
+        if not source.borders(target):
+            raise PlayerError('Cannot reach %s from %s to fortify'
+                              % (target, source))
+        source.armies -= armies
+        target.armies += armies
+        self.log(Fortify(source, target, armies))
     def player_killed(self, player, killer):
         player.alive = False
         killer.hand.extend(player.hand)
         if len(self.living_players()) > 1:
             while len(killer.hand) > 5:
                 self.play_cards_and_place_armies(killer)
-        self.log('%s killed by %s' % (player, killer))
+        self.log(Killed(player, killer))
         if len(self.living_players()) > 1:
             player.report(self.world, self.log)
             # else the game is over, and killed will hear about this then.
@@ -788,10 +857,8 @@ def test():
     return failures
 
 def random_game():
-    from player.email import EmailPlayer
     world = generate_earth()
-    players = [EmailPlayer('Alice', 'alice@example.com', 'server@example.com'),
-               Player('Bob'), Player('Charlie')]
+    players = [Player('Alice'), Player('Bob'), Player('Charlie')]
     e = Engine(world, players)
     e.run()