"""ThudBoard - The Discworld Boardgame without rules

Version 1.1
Copyright 2003, 2004 by Marc Boeren
"""
import os
import copy
import re

#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~#

class ThudMove(object):
    """A single move from a piece, including thudded opponents

    A piece is either a Dwarf (d) or a Troll (T). If it is neither of these,
    it will be represented by a question mark (?). From- and to-positions
    are given in Board-coordinates (A1..P15), the thud-list is a list of 
    such coordinates.

    These pieces are used in a ThudBattle. This ThudBattle will store extra
    information, such as the physical possibility of the move and if the 
    rules were applied for this move (ensuring at least physical possibility).

    """
    dwarf, troll = 0, 1
    def __init__(self, piece, from_pos, 
                              to_pos, 
                              thud_list, 
                              possible=False, rules=True):
        self.piece = piece
        self.from_pos = from_pos.upper()
        self.to_pos = to_pos.upper()
        self.thud_list = [thudded.upper() for thudded in thud_list]
        self.possible = possible
        self.rules = rules

    def get_height(self):
        """Return the number of lines in a text-representation."""
        lines = 1
        if self.thud_list:
            lines+= 1 + (len(self.thud_list)-1)/4
        return lines

    def __repr__(self):
        return self.str_save()

    def txt_move(self, savedstate=False):
        """Return a multi-line string containing the move as text.

        Example for a Troll move and a subsequent Thudding of two Dwarfs:
             ~ T J10 - L12
                  x L13 x M13

        """
        txt = prepend = ['']
        if not self.rules: txt = [" ~"] # rules not enforced
        if not self.possible: txt = [" %"] # impossible move (implies '~')
        if savedstate: prepend = [" >>>"]
        piece_txt = {-1:'?', self.dwarf:'d', self.troll:'T'}[self.piece]
        txt = prepend + txt
        txt+= [" %s  %s - %s" % (piece_txt, 
                                 self.from_pos.upper(), 
                                 self.to_pos.upper())]
        counter = 0
        for thudded in self.thud_list:
            if not counter: txt+=["\n"] + prepend + ["    "]
            txt+= [" x %s" % thudded.upper()]
            counter = (counter+1)%4;
        return "".join(txt)

    def str_save(self):
        """Return a single-line text representation that can be 
        used by load()"""
        txt = "".join(self.txt_move().split())
        return txt

    def load_str(self, txt):
        """Initialize the move from a text-representation generated
        by save() or loaded from a clipboard"""
        movepattern = "^([~|%%])?([D|T|?])?(%(coord)s)-(%(coord)s)((X%(coord)s)*)?$" % \
             {'coord':"[A-HJ-P][1-9][0-5]?"}
        match = re.match(movepattern, txt.upper())
        if not match: return False
        state, p, fp, tp, tl = match.group(1, 2, 3, 4, 5)
        piece_check = {None:-1, '?':-1, 'D':self.dwarf, 'T':self.troll}
        if not p in piece_check: return False
        # note: rules and possible are not checked, they may be faked
        self.rules = not (state)
        self.possible = not (state and state=='%')
        if not self.possible: self.rules = False
        self.piece = piece_check[p]
        self.from_pos = fp
        self.to_pos = tp
        self.thud_list = [coord for coord in tl.split('X') if coord.strip()]
        return True

#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~#

class ThudBattle(object):
    """From a starting position you play a number of moves.

    The default Thud starting position is supplied, but you can override
    this with any prepared board setup. The list of moves is applied
    sequentially to the starting position to obtain the current position.
    This applying will stop as soon as an impossible move presents itself.

    You can view the history of the battle by setting the current position
    to different stages in the game (read: any move is a stage). New moves
    will be added after the current position, discarding any future moves.
    A new move is not allowed before the last saved move (the saved move is
    highlighted somehow). To do this anyway, you must go to some point in 
    history and save the game from there. Note that a saved game will contain
    any future moves, although the saved move is remembered.

    The current move is stored in 'index'. The saved move is stored in 
    'saved_index'.
    """
    name = '<anonymous battle>'
    battledir = ''
    dwarf, troll = 0, 1
    start_position = [['F1', 'G1', 'J1', 'K1', 'L2', 'M3', 'N4', 'O5', 
                       'P6', 'P7', 'P9', 'P10', 'O11', 'N12', 'M13', 'L14', 
                       'K15', 'J15', 'G15', 'F15', 'E14', 'D13', 'C12', 'B11', 
                       'A10', 'A9', 'A7', 'A6', 'B5', 'C4', 'D3', 'E2'], 
                      ['G7', 'H7', 'J7', 'G8', 'J8', 'G9', 'H9', 'J9']]
    position = [[], []]
    moves = []
    index = -1
    saved_index = -1
    def __init__(self, name = None, start_pos = None, moves = None, index = -1):
        self.init_positions()
        if name:
            self.name = name
        if start_pos:
            self.start_position = copy.deepcopy(start_pos)
        self.position = copy.deepcopy(self.start_position)
        if moves:
            self.moves = copy.deepcopy(moves)
            self.index = len(moves) - 1
        if index:
            self.index = index
        self.get_position(index)
        self.saved_index = self.index
        self.filename = name

    def init_positions(self):
        # calculate valid and invalid coordinates
        self.mapx = dict(zip(['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 
                         'J', 'K', 'L', 'M', 'N', 'O', 'P'], range(15)))
        self.remapx = dict([(value, key) for key, value in self.mapx.items()])
        self.invalid_position = ['H8']
        for i in range(5):
            for j in range(5-i):
                self.invalid_position+= ["%s%d" % (self.remapx[j], 1+i),
                                         "%s%d" % (self.remapx[14-j], 1+i),
                                         "%s%d" % (self.remapx[j], 15-i),
                                         "%s%d" % (self.remapx[14-j], 15-i)]
        self.valid_position = []
        for i in range(15):
            for j in range(15):
                position = "%s%d" % (self.remapx[j], 1+i)
                if position not in self.invalid_position:
                    self.valid_position+= [position]

    def get_position(self, index):
        """Return the board configuration after move 'index' is played."""
        self.position = copy.deepcopy(self.start_position)
        if index<0:
            self.index = -1
            return self.position
        # notice the LAST played move is index, so this must
        # be included in the range
        for i in range(index+1):
            m = self.moves[i]
            if not self.update_position(m):
                self.index = i-1
                return self.position
        self.index = index
        return self.position

    def update_position(self, thudmove):
        """Return the board configuration with the given move."""
        if not self.check_move(thudmove): return False
        self.position[thudmove.piece].remove(thudmove.from_pos)
        for thudded in thudmove.thud_list:
            if thudded in self.position[thudmove.piece]:
                self.position[thudmove.piece].remove(thudded)
            if thudded in self.position[1-thudmove.piece]:
                self.position[1-thudmove.piece].remove(thudded)
        self.position[thudmove.piece].append(thudmove.to_pos)
        return True

    def save_index(self):
        """Set the saved_index to the current index."""
        self.saved_index = self.index

    def piece_on(self, coordinate):
        """Return the piece on the given coordinate, if any."""
        if coordinate in self.position[self.dwarf]: return self.dwarf
        if coordinate in self.position[self.troll]: return self.troll
        return -1

    def add_move(self, piece, from_pos, to_pos, thud_list):
        """Alternative for add_thudmove."""
        return self.add_thudmove(ThudMove(piece, from_pos, to_pos, thud_list, True, False))

    def add_thudmove(self, thudmove):
        """Update the internals and the position with this move."""
        if self.index<self.saved_index: return False
        if not self.check_move(thudmove): return False
        self.moves = self.moves[:self.index+1] # inclusive!
        self.moves.append(copy.copy(thudmove))
        self.index = len(self.moves)-1
        self.update_position(thudmove)
        return True

    def check_move(self, thudmove):
        """Return if the move is physically possible."""
        # check type of piece
        if thudmove.piece==-1:
            if thudmove.from_pos in self.position[self.dwarf]: thudmove.piece = self.dwarf
            if thudmove.from_pos in self.position[self.troll]: thudmove.piece = self.troll
        if thudmove.piece not in (self.dwarf, self.troll):
            thudmove.possible = False
            return False
        # check start and end-positions
        if thudmove.from_pos not in self.position[thudmove.piece] \
        or (thudmove.to_pos in self.position[thudmove.piece] \
                 and thudmove.to_pos not in thudmove.thud_list) \
        or (thudmove.to_pos in self.position[1-thudmove.piece] \
                 and thudmove.to_pos not in thudmove.thud_list):
            thudmove.possible = False
            return False
        # check thuds
        for thudded in thudmove.thud_list:
            if thudded not in self.position[thudmove.piece] \
            and thudded not in self.position[1-thudmove.piece]:
                thudmove.possible = False
                return False
        return True

    def get_height(self, index):
        """Return the number of lines the move will be represented by."""
        if index<0: return 1
        return self.moves[index].get_height()

    def get_offset_and_height(self, index, base_index=0):
        """Return the cumulative number of lines for all previous moves and
        the height of the move."""
        if index<0: return (0, 1)
        if index==0: return (0, self.get_height(index))
        offset = 0
        base_offset = 0
        for i in range(index):
            offset+= self.get_height(i)
            if i<base_index:
                base_offset+= self.get_height(i)
        return (offset-base_offset, self.get_height(index))

    def get_index(self, lines = -1, base_index=0):
        """Return the move that is on the given line in the text-
        representation of the battle."""
        if lines<0: return -1
        index = 0
        while lines>=0 and index<len(self.moves):
            if index>=base_index:
                h = self.get_height(index)
                lines-= h
                if lines < 0:
                    return index
            index+= 1
        return -1

    def txt_battle_move(self):
        """Return a text-representation of the current move."""
        return self.moves[self.index].txt_move()

    def txt_battle_moves(self, mutable_offset, maxlines=25, history_lines=0):
        """Return a list with the text-representation of the moves."""
        from_index = mutable_offset[0]
        battle = []
        for i in range(len(self.moves)):
            battle+= [self.moves[i].txt_move(self.saved_index==i)]
        if history_lines:
            while history_lines>1+"\n".join(battle[from_index:self.index+1]).count("\n"):
                from_index-=1
                if from_index<=0: break
        if from_index<0:
            from_index = 0
        mutable_offset[0] = from_index
        battlelines = "\n".join(battle[from_index:]).split("\n")
        return battlelines[0:maxlines]

    def txt_battle_positions(self, position):
        """Return a list with the text-representation of the position."""
        battle = []
        battle+= ["\nDwarves\n"]
        counter = 0
        for dwarf in position[self.dwarf]:
            battle+= [" %-3s " % dwarf.lower()]
            counter = (counter+1)%8;
            if not counter: battle+="\n"
        battle+= ["\nTrolls\n"]
        counter = 0
        for troll in position[self.troll]:
            battle+= [" %-3s " % troll.upper()]
            counter = (counter+1)%8;
            if not counter: battle+="\n"
        return battle

    def txt_battle(self):
        """Return a string representing the entire battle."""
        battle = ["\n\nBattle positions\n"]
        battle+= self.txt_battle_positions(self.start_position)

        battle+= ["\nBattle moves\n"]
        battle+= "\n".join(self.txt_battle_moves())

        position = self.get_position(len(self.moves))
        battle+= ["\n\nNew battle positions\n"]
        battle+= self.txt_battle_positions(position)

        return "".join(battle)

    def str_position(self, position):
        str = []
        for piece in position[self.dwarf]:
            str+= ["d%s" % piece.upper()]
        for piece in position[self.troll]:
            str+= ["T%s" % piece.upper()]
        return ",".join(str)

    def position_str(self, str):
        coordre = re.compile("[A-HJ-P][1-9][0-5]?")
        position = [[], []]
        pieces = str.split(",")
        for piece in pieces:
            p = piece[0]
            coord = piece[1:]
            if p not in ('d', 'T'): return False
            if p=='d': p = self.dwarf
            else: p = self.troll
            if not coordre.match(coord): return False
            position[p].append(coord)
        return position

    def save_position(self, filename = None, index = -1):
        if not filename: 
            filename = self.get_filename()
        try:
            f = file(filename, "wb")
        except:
            f = None
        if not f: return False
        f.write(self.str_position(self.get_position(index)) + "\n")
        f.close()
        return True

    def save(self, filename = None):
        if not filename: 
            filename = self.get_filename()
        try:
            f = file(filename, "wb")
        except:
            f = None
        if not f: return False
        f.write(self.str_position(self.start_position) + "\n")
        for i in range(len(self.moves)):
            if self.index==i: f.write(">")
            f.write(self.moves[i].str_save() + "\n")
        f.close()
        self.filename = filename
        self.name = os.path.split(filename)[1]
        self.name = os.path.splitext(self.name)[0]
        return True

    def load(self, filename):
        if not filename:
            filename = self.get_filename()
        try:
            f = file(filename, "rb")
        except:
            f = None
        if not f: return False
        lines = f.readlines()
        f.close()
        name = os.path.split(filename)[1]
        name = os.path.splitext(name)[0]
        start_position = self.position_str("".join(lines[0].split()))
        if not start_position: return False
        moves = []
        index = -1
        for i in range(1, len(lines)):
            moves.append(ThudMove(-1, "", "", []))
            line = lines[i]
            if line[0]==">":
                index = i-1
                line = line[1:]
            if not moves[i-1].load_str("".join(line.split())):
                return False

        self.name = name
        self.filename = filename
        self.start_position = copy.deepcopy(start_position)
        self.position = copy.deepcopy(start_position)
        self.moves = copy.deepcopy(moves)
        self.index = index
        self.get_position(index)
        self.saved_index = self.index
        return True

    def get_filename(self):
        filename = self.filename
        if not filename: 
            filename = self.name
            filename = os.path.join(self.battledir, filename+".thud")
        if not self.name or filename=='<anonymous battle>': 
            filename = os.path.join(self.battledir, "")
        return filename

#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~#

if __name__=='__main__':
    print "This file is not meant to be executed. Run thud.py instead."""
