Began versioning (v0.1), engine and basic AI.
authorW. Trevor King <wking@drexel.edu>
Thu, 25 Mar 2010 21:28:52 +0000 (17:28 -0400)
committerW. Trevor King <wking@drexel.edu>
Thu, 25 Mar 2010 21:28:52 +0000 (17:28 -0400)
risk/__init__.py [new file with mode: 0644]
risk/base.py [new file with mode: 0755]

diff --git a/risk/__init__.py b/risk/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/risk/base.py b/risk/base.py
new file mode 100755 (executable)
index 0000000..dbff83b
--- /dev/null
@@ -0,0 +1,554 @@
+#!/usr/bin/python
+#
+# A Python engine for Risk-like games
+
+import random
+
+
+VERSION='0.1'
+
+
+class PlayerError (Exception):
+    pass
+
+class ID_CmpMixin (object):
+    def __str__(self):
+        return self.name
+    def __repr__(self):
+        return self.__str__()
+    def __cmp__(self, other):
+        return cmp(id(self), id(other))
+    def __eq__(self, other):
+        return self.__cmp__(other) == 0
+    def __ne__(self, other):
+        return self.__cmp__(other) != 0
+
+class Territory (list, ID_CmpMixin):
+    def __init__(self, name, short_name=None, type=-1,
+                 link_names=[], continent=None, player=None):
+        list.__init__(self)
+        ID_CmpMixin.__init__(self)
+        self.name = name
+        self.short_name = short_name
+        if short_name == None:
+            self.short_name = name
+        self._card_type = type
+        self._link_names = list(link_names)
+        self.continent = continent
+        self.player = player
+        self.card = None
+        self.armies = 0
+    def __str__(self):
+        if self.short_name == self.name:
+            return self.name
+        return '%s (%s)' % (self.name, self.short_name)
+    def __repr__(self):
+        return self.__str__()
+    def borders(self, other):
+        for t in self:
+            if id(t) == id(other):
+                return True
+        return False
+
+class Continent (list, ID_CmpMixin):
+    def __init__(self, name, production, territories=[]):
+        list.__init__(self, territories)
+        ID_CmpMixin.__init__(self)
+        self.name = name
+        self.production = production
+    def append(self, territory):
+        list.append(self, territory)
+        territory.continent = self
+    def territory_by_name(self, name):
+        for t in self:
+            if name.lower() in [t.short_name.lower(), t.name.lower()]:
+                #assert self.contains_territory(t), t
+                return t
+        raise KeyError(name)
+    def contains_territory(self, territory):
+        for t in self:
+            if t == territory:
+                return True
+        return False
+    def single_player(self):
+        p = self[0].player
+        for territory in self:
+            if territory.player != p:
+                return False
+        return True
+
+class World (list, ID_CmpMixin):
+    def __init__(self, name, continents=[]):
+        list.__init__(self, continents)
+        ID_CmpMixin.__init__(self)
+        self.name = name
+        self.initial_armies = { # num_players:num_armies
+            2: 40, 3:35, 4:30, 5:25, 6:20
+                }
+    def territories(self):
+        for continent in self:
+            for territory in continent:
+                yield territory
+    def territory_by_name(self, name):
+        for continent in self:
+            try:
+                return continent.territory_by_name(name)
+            except KeyError:
+                pass
+        raise KeyError(name)
+    def contains_territory(self, territory):
+        for continent in self:
+            if continent.contains_territory(territory):
+                return True
+        return False
+    def continent_by_name(self, name):
+        for continent in self:
+            if continent.name.lower() == name.lower():
+                return continent
+        raise KeyError(name)
+    def _resolve_link_names(self):
+        self._check_short_names()
+        for territory in self.territories():
+            for name in territory._link_names:
+                other = self.territory_by_name(name)
+                if not territory.borders(other):
+                    territory.append(other)
+                if not other.borders(territory):
+                    other.append(territory)
+    def _check_short_names(self):
+        ts = {}
+        for t in self.territories():
+            if t.short_name not in ts:
+                ts[t.short_name] = t
+            else:
+                raise ValueError('%s shared by %s and %s'
+                                 % (t.short_name, ts[t.short_name], t))
+    def production(self, player):
+        ts = list(player.territories(world))
+        production = max(3, len(ts) / 3)
+        continents = set([t.continent.name for t in ts])
+        for c_name in continents:
+            c = self.continent_by_name(c_name)
+            if c.single_player() == True:
+                production += c.production
+        return (production, {})
+    def place_territory_production(self, territory_production):
+        for territory_name,production in territory_production.items():
+            t = self.territory_by_name(territory_name)
+            t.armies += production
+
+class Card (ID_CmpMixin):
+    def __init__(self, deck, type_, territory=None):
+        ID_CmpMixin.__init__(self)
+        self.deck = deck
+        self.territory = territory
+        self.type = type_
+    def __str__(self):
+        if self.territory == None:
+            return '<Card %s>' % (self.deck.type_names[self.type])
+
+        return '<Card %s %s>' % (self.territory,
+                                 self.deck.type_names[self.type])
+    def __repr__(self):
+        return self.__str__()
+
+class Deck (list):
+    def __init__(self, territories=[]):
+        list.__init__(self, [Card(self, t._card_type, t) for t in territories])
+        random.shuffle(self)
+        self.type_names = ['Wild', 'Infantry', 'Cavalry', 'Artillery', 'Wild']
+        for i in range(2):
+            self.append(Card(self, 0))
+        self._production_sequence = [4, 6, 8, 10, 12, 15]
+        self._production_index = 0
+    def production_value(self, index):
+        """
+        >>> d = Deck()
+        >>> [d.production_value(i) for i in range(8)]
+        [4, 6, 8, 10, 12, 15, 20, 25]
+        """
+        if index < len(self._production_sequence):
+            return self._production_sequence[index]
+        extra = index - len(self._production_sequence) + 1
+        return self._production_sequence[-1] + 5 * extra
+    def production(self, player, cards=None):
+        """
+        >>> d = Deck()
+        >>> a = Player('Alice')
+        >>> b = Player('Bob')
+        >>> d.production(a, None)
+        (0, {})
+        >>> d.production(a, [Card(d, 1, Territory('a')),
+        ...                  Card(d, 1, Territory('b'))])
+        Traceback (most recent call last):
+          ...
+        PlayerError: You must play cards in groups of 3, not 2
+        ([<Card a Infantry>, <Card b Infantry>])
+        >>> d.production(a, [Card(d, 1, Territory('a', player=a)),
+        ...                  Card(d, 1, Territory('b', player=b)),
+        ...                  Card(d, 1, Territory('c'))])
+        (4, {'a': 1})
+        >>> p,tp = d.production(a, [Card(d, 1, Territory('a', player=a)),
+        ...                     Card(d, 2, Territory('b', player=a)),
+        ...                     Card(d, 0, Territory('c', player=a))])
+        >>> p
+        6
+        >>> sorted(tp.items())
+        [('a', 1), ('b', 1), ('c', 1)]
+        """
+        if cards == None:
+            return (0, {})
+        if len(cards) != 3:
+            raise PlayerError('You must play cards in groups of 3, not %d\n(%s)'
+                              % (len(cards), cards))
+        h = Hand(cards)
+        if h.set() or h.run():
+            p = self.production_value(self._production_index)
+            self._production_index += 1
+            territory_production = {}
+            for c in cards:
+                if c.territory != None and c.territory.player == player:
+                    territory_production[c.territory.name] = 1
+            return (p, territory_production)
+        raise PlayerError('%s is neither a set nor a run' % cards)
+
+class Hand (list):
+    def __init__(self, cards=[]):
+        list.__init__(self, cards)
+    def set(self):
+        s = sorted(set([card.type for card in self]))
+        if len(s) == 1 \
+                or (len(s) == 2 and s[0] == 0):
+            return True
+        return False
+    def run(self):
+        if len(set([card.type for card in self])) == 3:
+            return True
+        return False
+    def possible(self):
+        if len(self) >= 3:
+            for i,c1 in enumerate(self[:-2]):
+                for j,c2 in enumerate(self[i+1:-1]):
+                    for c3 in self[i+j+2:]:
+                        h = Hand([c1, c2, c3])
+                        if h.set() or h.run():
+                            yield h
+
+class Player (ID_CmpMixin):
+    def __init__(self, name):
+        self.name = name
+        ID_CmpMixin.__init__(self)
+        self.alive = True
+        self.hand = Hand()
+    def territories(self, world):
+        for t in world.territories():
+            if t.player == self:
+                yield t
+    def border_territories(self, world):
+        for t in self.territories(world):
+            for neighbor in t:
+                if neighbor.player != self:
+                    yield t
+                    break
+    def phase_select_territory(self, world):
+        """Return the selected territory
+        """
+        free_territories = [t for t in world.territories() if t.player == None]
+        return random.sample(free_territories, 1)[0]
+    def phase_play_cards(self, world, play_required=True):
+        if play_required == True:
+            return random.sample(list(self.hand.possible()), 1)[0]
+    def phase_place_armies(self, world, 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 phase_attack(self, world):
+        """Return list of (source, target, armies) tuples.  Place None
+        in the list to end this phase.
+        """
+        possible_attacks = []
+        for t in self.border_territories(world):
+            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)))
+        if len(possible_attacks) == 0:
+            return [None]
+        return random.sample(possible_attacks, 1) # + [None]
+    def phase_support_attack(self, world, source, target):
+        return source.armies-1
+    def phase_fortify(self, world):
+        """Return list of (source, target, armies) tuples.  Place None
+        in the list to end this phase.
+        """
+        return [None]
+    def phase_draw(self, cards=[]):
+        """Only called if you earned a new card (or cards)"""
+        self.hand.extend(cards)
+
+class Engine (ID_CmpMixin):
+    def __init__(self, world, players, deck_class=Deck):
+        ID_CmpMixin.__init__(self)
+        self.world = world
+        self.deck = deck_class(world.territories())
+        self.players = players
+    def __str__(self):
+        return '<engine %s %s>' % (self.world, self.players)
+    def __repr__(self):
+        return self.__str__()
+    def run(self):
+        self.setup()
+        self.play()
+    def setup(self):
+        for p in self.players:
+            p.alive = True
+        random.shuffle(players)
+        self.select_territories()
+        self.place_initial_armies()
+        self.deal()
+    def play(self):
+        turn = 0
+        active_player = 0
+        living = len(self.living_players())
+        while living > 1:
+            self.play_turn(self.players[active_player])
+            living = len(self.living_players())
+            active_player = (active_player + 1) % living
+            turn += 1
+    def play_turn(self, player):
+        print "%s's turn (territory score: %s)" \
+            % (player, [(p,len(list(p.territories(self.world))))
+                        for p in self.players])
+        self.play_cards_and_place_armies(player)
+        captures = self.attack_phase(player)
+        if captures > 0 and len(self.deck) > 0 and len(self.living_players()) > 1:
+            player.phase_draw([self.deck.pop()])
+    def select_territories(self):
+        for t in self.world.territories():
+            t.player = None
+        for i in range(len(list(self.world.territories()))):
+            p = self.players[i % len(self.players)]
+            t = p.phase_select_territory(self.world)
+            if t.player != None:
+                raise PlayerError('Cannot select %s owned by %s'
+                                  % (t, t.player))
+            print '%s selects %s' % (p, t)
+            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))
+        assert len(s) in [1,2], already_placed
+        if len(s) == 2: # catch up the players who are one territory short
+            assert min(s) == max(s)-1, 'Min %d, max %d' % (min(s), max(s))
+            for p,placed in zip(self.players, already_placed):
+                if placed == min(s):
+                    self.player_place_armies(p, remaining, 1)
+        remaining = self.world.initial_armies[len(self.players)] - max(s)
+        while remaining > 0:
+            for p in self.players:
+                self.player_place_armies(p, remaining, 1)
+            remaining -= 1
+    def player_place_armies(self, player, remaining=1, this_round=1):
+        placements = player.phase_place_armies(self.world, 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 = 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))
+        print '%s places %s' % (player, placements)
+        for ter_name,armies in placements.items():
+            t = world.territory_by_name(ter_name)
+            t.armies += armies
+    def deal(self):
+        for p in self.players:
+            cards = []
+            for i in range(3):
+                cards.append(self.deck.pop())
+            p.phase_draw(cards)
+        print 'Initial hands dealt'
+    def play_cards_and_place_armies(self, player, additional_armies=0):
+        cards_required = len(player.hand) >= 5
+        cards = player.phase_play_cards(
+            self.world, 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))
+        w_prod,w_terr_prod = self.world.production(player)
+        print '%s earned %d armies from territories' % (player, w_prod)
+        c_prod,c_terr_prod = self.deck.production(player, cards)
+        if c_prod > 0:
+            print '%s played %s, earning %d armies' \
+                % (player, cards, c_prod+sum(c_terr_prod.values()))
+        if cards != None:
+            for c in cards:
+                player.hand.remove(c)
+        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:
+            print '%s was required to place %s' % (player, w_terr_prod)
+        armies = w_prod + c_prod
+        self.player_place_armies(player, armies, armies)
+    def attack_phase(self, player):
+        captures = 0
+        while True:
+            attacks = player.phase_attack(self.world)
+            for attack in attacks:
+                if attack == None:
+                    return captures
+                source_name,target_name,armies = attack
+                source = self.world.territory_by_name(source_name)
+                target = self.world.territory_by_name(target_name)
+                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)
+    def attack(self, source, target, armies):
+        if source.player == target.player:
+            raise PlayerError('%s attacking %s, but you own both.'
+                              % (source, target))
+        if armies == 0:
+            raise PlayerError('%s attacking %s with 0 armies.'
+                              % (source, target))
+        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)],
+                        reverse=True)
+        t_dice = sorted([random.randint(1, 6) for i in range(min(2, target.armies))],
+                        reverse=True)
+        a_dead = 0
+        t_dead = 0
+        for a,d in zip(a_dice, t_dice):
+            if d >= a:
+                a_dead += 1
+            else:
+                t_dead += 1
+        source.armies -= a_dead
+        target.armies -= t_dead
+        if target.armies == 0:
+            self.takeover(source, target, remaining_attackers=armies-a_dead)
+            print '%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)
+            assert target.armies > 0, target
+            return True
+        print '%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)
+        assert target.armies > 0, target
+        return False
+    def takeover(self, source, target, remaining_attackers):
+        source.armies -= remaining_attackers
+        target.armies += remaining_attackers
+        target.player = source.player
+        support = source.player.phase_support_attack(self.world, 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))
+        source.armies -= support
+        target.armies += support
+    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)
+        print '%s killed by %s' % (player, killer)
+    def living_players(self):
+        return [p for p in self.players if p.alive == True]
+
+
+def generate_earth():
+    w = World('Earth')
+    c = Continent('North America', 5)
+    c.append(Territory('Alaska', 'ala', 1, ['kam', 'nwt']))    
+    c.append(Territory('Northwest Territory', 'nwt', 2, ['alb', 'ont', 'gre']))
+    c.append(Territory('Greenland', 'gre', 3, ['ont', 'que', 'ice']))
+    c.append(Territory('Alberta', 'alb', 1, ['ont', 'wus']))
+    c.append(Territory('Ontario', 'ont', 2, ['wus', 'eus', 'que']))
+    c.append(Territory('Quebec', 'que', 3, ['eus']))
+    c.append(Territory('Western United States', 'wus', 1, ['eus', 'cam']))
+    c.append(Territory('Eastern United States', 'eus', 2, ['cam']))
+    c.append(Territory('Central America', 'cam', 3, ['ven']))
+    w.append(c)
+    
+    c = Continent('Europe', 5)
+    c.append(Territory('Iceland', 'ice', 1, ['gbr', 'sca']))
+    c.append(Territory('Scandanavia', 'sca', 2, ['gbr', 'neu', 'ukr']))
+    c.append(Territory('Ukraine', 'ukr', 3, ['neu', 'seu', 'ura', 'afg', 'mea']))
+    c.append(Territory('Great Britain', 'gbr', 1, ['neu', 'weu']))
+    c.append(Territory('Northern Europe', 'neu', 2, ['weu', 'seu']))
+    c.append(Territory('Western Europe', 'weu', 3, ['naf', 'seu']))
+    c.append(Territory('Southern Europe', 'seu', 1, ['naf', 'egy', 'mea']))
+    w.append(c)
+
+    c = Continent('Asia', 7)
+    c.append(Territory('Urals', 'ura', 2, ['afg', 'chi', 'sib']))
+    c.append(Territory('Siberia', 'sib', 3, ['chi', 'mon', 'irk', 'yak']))
+    c.append(Territory('Yakutsk', 'yak', 1, ['irk', 'kam']))
+    c.append(Territory('Kamchatka', 'kam', 2, ['mon', 'jap']))
+    c.append(Territory('Irkutsk', 'irk', 3, ['mon']))
+    c.append(Territory('Mongolia', 'mon', 1, ['chi', 'jap']))
+    c.append(Territory('Japan', 'jap', 2))
+    c.append(Territory('Afghanistan', 'afg', 3, ['mea', 'indi', 'chi']))
+    c.append(Territory('China', 'chi', 1, ['indi', 'sia']))
+    c.append(Territory('Middle East', 'mea', 2, ['egy', 'eaf', 'indi']))
+    c.append(Territory('India', 'indi', 3, ['sia']))
+    c.append(Territory('Siam', 'sia', 1, ['indo']))
+
+    w.append(c)
+
+    c = Continent('South America', 2)
+    c.append(Territory('Venezuala', 'ven', 2, ['per', 'bra']))
+    c.append(Territory('Peru', 'per', 3, ['arg', 'bra']))
+    c.append(Territory('Brazil', 'bra', 1, ['arg', 'naf']))
+    c.append(Territory('Argentina', 'arg', 2))
+    w.append(c)
+
+    c = Continent('Africa', 3)
+    c.append(Territory('North Africa', 'naf', 3, ['egy', 'eaf', 'con']))
+    c.append(Territory('Egypt', 'egy', 1, ['eaf']))
+    c.append(Territory('East Africa', 'eaf', 2, ['con', 'saf', 'mad']))
+    c.append(Territory('Congo', 'con', 3, ['saf']))
+    c.append(Territory('South Africa', 'saf', 1, ['mad']))
+    c.append(Territory('Madagascar', 'mad', 2))
+    w.append(c)
+
+    c = Continent('Australia', 2)
+    c.append(Territory('Indonesia', 'indo', 3, ['ngu', 'wau']))
+    c.append(Territory('New Guinea', 'ngu', 1, ['wau', 'eau']))
+    c.append(Territory('Western Australia', 'wau', 2, ['eau']))
+    c.append(Territory('Eastern Australia', 'eau', 3))
+    w.append(c)
+
+    w._resolve_link_names()
+    return w
+
+if __name__ == '__main__':
+    import doctest
+    import sys
+
+    failures,tests = doctest.testmod()
+    if failures > 0:
+        sys.exit(1)
+
+    world = generate_earth()
+    players = [Player('A'), Player('B')]
+    e = Engine(world, players)
+    e.run()