#!/usr/bin/env python
"""pysweeper.py -- A reimplementation of the classic Minesweeper game
Copyright 2002 Brett Smith <bcsmit1@engr.uky.edu>

Pysweeper is an implementation of the classic game, Minesweeper.  This
file provides the core game logic, and works with an interface to
actually work with a player and play the game.
"""
# This program is free software; you can redistribute it and/or modify it
# under the terms of the GNU General Public License as published by the
# Free Software Foundation; either version 2 of the License, or (at your
# option) any later version.
#
# This program is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General
# Public License for more details.
#
# You should have received a copy of the GNU General Public License along
# with this program; if not, write to the Free Software Foundation, Inc.,
# 59 Temple Place, Suite 330, Boston, MA, 02111-1307.
#
# Last revised: $Date: 2002/06/29 17:09:39 $

import math
import getopt
import os
import random
import sys
import time
import traceback

random.seed()

class Field:
    """Provide a playing field for a Minesweeper game.

    This class internally represents a Minesweeper playing field, and provides
    all functions necessary for the basic manipulations used in the game.
    """
    def __init__(self, rows = 16, cols = 16, mines = 40):
        """Initialize the playing field.

        This function creates a playing field of the given size, and randomly
        places mines within it.

        rows and cols are the numbers of rows and columns of the playing
        field, respectively.  mines is the number of mines to be placed within
        the field.
        """
        for var in (rows, cols, mines):
            if var < 0:
                raise ValueError, "all arguments must be > 0"
        if mines >= (rows * cols):
            raise ValueError, "mines must be < (rows * cols)"
                
        self.rows = rows
        self.cols = cols
        self.mines = mines
        self.cleared = 0
        self.flags = 0
        self.start_time = None

        minelist = []
        self.freecoords = {}
        for col in range(cols):
            self.freecoords[col] = range(rows)
        while mines > 0:
            y = random.choice(self.freecoords.keys())
            x = random.randrange(len(self.freecoords[y]))
            minelist.append((self.freecoords[y][x], y))
            del self.freecoords[y][x]
            if not self.freecoords[y]:
                del self.freecoords[y]
            mines = mines - 1

        self.board = []
        for col in range(cols):
            self.board.append([(-2, 0)] * rows)
            for row in range(rows):
                if (row, col) in minelist:
                    self.board[col][row] = (-1, 0)


    def _get_adjacent(self, x, y):
        """Provide a list of all tiles adjacent to the given tile.

        This function takes the x and y coordinates of a tile, and returns a
        list of a 2-tuples containing the coordinates of all adjacent tiles.

        x and y are the x and y coordinates of the base tile, respectively.
        """
        adjlist = [(x - 1, y - 1), (x, y - 1), (x + 1, y - 1),
                   (x - 1, y), (x + 1, y),
                   (x - 1, y + 1), (x, y + 1), (x + 1, y + 1)]
        rmlist = []
        for adjpair in adjlist:
            for value, index in [(-1, 0), (-1, 1), (self.cols, 0),
                                 (self.rows, 1)]:
                if adjpair[index] == value:
                    rmlist.append(adjpair)
                    break
        for item in rmlist:
            adjlist.remove(item)
        return adjlist


    def open(self, coordlist, y = None):
        """Open one or more tiles.

        This function opens all the tiles provided, and others as
        appropriate, and returns a list indicating which tiles were opened
        and the result of the open.  If a tile is opened which has no
        adjacent mines, all adjacent tiles will be opened automatically.

        The function returns a list of 2-tuples.  The first value is a
        2-tuple with the x and y coordinates of the opened tile; the second
        value indicates the number of mines adjacent to the tile, or -1 if
        the tile contains a mine.

        If a value for y is given, the tile at (coordlist, y) will be
        opened; otherwise, the function will open the tiles whose
        coordinates are given in 2-tuples in coordlist.
        """
        if y is not None:
            coordlist = [(coordlist, y)]
        opened = []
        while len(coordlist) != 0:
            x, y = coordlist.pop()
            not_done = 1
            if (self.board[x][y][1] == 1) or (self.board[x][y][0] >= 0):
                not_done = 0
            elif self.board[x][y][0] == -1:
                if self.cleared > 0:
                    self.board[x][y] = (-1, -1)
                    opened.append(((x, y), -1))
                    not_done = 0
                else:
                    while self.board[x][y][0] == -1:
                        # The first opened block is a mine; move it elsewhere.
                        newx = random.choice(self.freecoords.keys())
                        newy = random.randrange(len(self.freecoords[newx]))
                        self.board[x][y] = (-2, 0)
                        self.board[newx][newy] = (-1,
                                                  self.board[newx][newy][1])
            if not_done:
                adjlist = self._get_adjacent(x, y)
                adjcount = 0
                for adjx, adjy in adjlist:
                    if self.board[adjx][adjy][0] == -1:
                        adjcount = adjcount + 1
                self.board[x][y] = (adjcount, -1)
                if self.cleared is 0:
                    del self.freecoords
                    self.start_time = time.time()
                self.cleared = self.cleared + 1
                opened.append(((x, y), adjcount))
                if adjcount == 0:
                    coordlist.extend(adjlist)
        return opened


    def open_adjacent(self, x, y):
        """Open all unflagged tiles adjacent to the given one, if appropriate.

        This function counts the number of tiles adjacent to the given one
        which are flagged.  If that count matches the number of mines adjacent
        to the tile, all unflagged adjacent tiles are opened, using
        Field.open().

        x and y are the x and y coordinates of the tile to be flagged,
        respectively.
        """
        adjmines = self.board[x][y][0]
        if self.board[x][y][1] != -1:
            return []
        adjlist = self._get_adjacent(x, y)
        flagcount = 0
        for adjx, adjy in adjlist:
            if self.board[adjx][adjy][1] == 1:
                flagcount = flagcount + 1
        if adjmines == flagcount:
            return self.open(adjlist)
        else:
            return []
        

    def flag(self, x, y):
        """Flag or unflag an unopened tile.

        This function attempts to toggle the flagged status of the tile at
        the given coordinates, and returns a value indicating the action
        which occurred.  A return value of -1 indicates that the tile is
        opened and cannot be flagged; 0 indicates that the tile is unflagged;
        and 1 indicates that the tile was flagged.

        x and y are the x and y coordinates of the tile to be flagged,
        respectively.
        """
        if self.board[x][y][1] == -1:
            return -1
        elif self.board[x][y][1] == 0:
            self.board[x][y] = (self.board[x][y][0], 1)
            self.flags = self.flags + 1
            return 1
        else:
            self.board[x][y] = (self.board[x][y][0], 0)
            self.flags = self.flags - 1
            return 0


    def get_diff(self):
        """Return a list providing mine locations.

        This function provides a list of 2-tuples.  The first value of each
        2-tuple is a 2-tuple, providing the x and y coordinates of a tile;
        the second value of the 2-tuple is either 1 or -1.  1 indicates that
        a mine is at those coordinates; -1 indicates that a mine is not at
        those coordinates, but a flag was placed there.
        """
        diff = []
        for y in range(self.rows):
            for x in range(self.cols):
                if self.board[x][y] == (-2, 1):
                    diff.extend([((x, y), -1)])
                elif ((self.board[x][y][0] == -1) and
                      (self.board[x][y][1] == 0)):
                    diff.extend([((x, y), 1)])
        return diff


    def playtime(self):
        """Return a string representing the current play time.

        This function returns a string which provides a human-readable
        representation of the amount of time the current game has been
        played, starting when the first tile is opened.  If the player
        takes an inordinate amount of time (9999 minutes, 0 seconds -- or
        longer), the returned string will be '9999:00+'.
        """
        if self.start_time is None:
            return '00:00'
        rawtime = int(time.time() - self.start_time)
        mins = int(math.floor(rawtime / 60.0))
        secs = rawtime % 60
        if mins > 9998:
            return '9999:00+'
        elif (mins < 10) and (secs < 10):
            return '0%i:0%i' % (mins, secs)
        elif mins < 10:
            return '0%i:%i' % (mins, secs)
        elif secs < 10:
            return '%i:0%i' % (mins, secs)
        else:
            return '%i:%i' % (mins, secs)


    def won(self):
        """Indicate whether or not the game has been won.

        This function will return a true value if the game has been won, and
        a false value otherwise.
        """
        return ((self.flags == self.mines) and
                (self.cleared == (self.rows * self.cols) - self.mines))
                
        

def show_usage(error = None):
    """Show usage information, with an error if given, and exit appropriately.

    This function will display a standard, run-of-the-mill usage message,
    providing rudimentary help with the game's command-line switches.  If an
    error message is given, it will be printed preceding the rest of the
    message, and the program will exit with an exit code of 2; otherwise,
    the program will exit with a code of 0.
    """
    if error is not None:
        print "Error: %s." % error
    print "Usage: %s [-d] [-r,--rows ROWS]" % sys.argv[0],
    print "[-c,--cols,--columns COLUMNS] [-m,--mines MINES] [--dir DIRECTORY]"
    print "  -h,--help:           Display this usage message and exit."
    print "  -v,--version:        Display the program version and exit."
    print "  -r,--rows:           Set the number of rows in the playing field."
    print "  -c,--cols,--columns: Set the number of columns in the playing",
    print "field."
    print "  -m,--mines:          Set the number of mines in the playing",
    print "field."
    print "  --dir:               Add a directory for finding external data."
    print "  -d,--debug:          Enable debugging."
    if error is None:
        sys.exit(0)
    else:
        sys.exit(2)
        

def show_version():
    """Print version and copyright information, and exit normally."""
    print "Pysweeper 1.0 -- An implementation of the classic Minesweeper game."
    print "Copyright 2002 Brett Smith <bcsmit1@engr.uky.edu>"
    print ""
    print "This program is free software; you can redistribute it and/or modify"
    print "it under the terms of the GNU General Public License as published by"
    print "the Free Software Foundation; either version 2 of the License, or"
    print "(at your option) any later version."
    print ""
    print "This program is distributed in the hope that it will be useful, but"
    print "WITHOUT ANY WARRANTY; without even the implied warranty of"
    print "MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU"
    print "General Public License for more details."
    sys.exit(0)


def fail(error, debug = 0):
    """Print an error message, with traceback if desired, and exit.

    This function will print an error message with the given body.  It will
    also print an exception traceback if debug is true.  It will then exit
    with an exit code of 1.

    error is the body of the error message.  If debug is a true value, a
    traceback will be printed.
    """
    print "Error: %s; exiting...." % error
    if debug:
        traceback.print_exc()
    sys.exit(1)


def set_option(options, name, value, minvalue = 0):
    """Set a value of a dictionary, with type and bounds checking.

    This function will set a given dictionary key to the given value, if it
    can be converted to an integer and is above a given value.  If any of
    the checks fail, the program will abort with an appropriate error
    message.

    options is the dictionary which contains the key to be set.  name is the
    name of the dictionary key to be set.  value is the value which will be
    checked and stored.  minvalue is the minimum value; the variable value
    must be larger than minvalue to be set.
    """
    try:
        value = int(value)
    except ValueError:
        show_usage("Bad argument (%s) for option %s" % (value, name))
    else:
        if value > minvalue:
            options[name] = value
        else:
            fail("bad value for option %s (too small)" % name)


def debug(objectlist):
    """Provide internal information about given objects.

    This function takes a list of objects, and then prints as much internal
    information about their state as possible.  Very much a dirty hack.

    objectlist is the list of objects to be debugged.
    """
    for object in objectlist:
        for variable in dir(object):
            attribute = getattr(object, variable)
            if not callable(attribute):
                print '%s: %s' % (variable, `attribute`)
        

def get_options():
    """Parse command-line options.

    This function reads the command-line options from sys.argv[1:] and
    returns a dict containing all configuration options with their given
    values.  It will abort the program if appropriate; for example, if
    an option has a bad argument, or a bad option is given.
    """
    game_opts = {'rows': 20, 'cols': 30, 'mines': 110, 'debug': 0}
    if os.name is 'posix':
        game_opts['paths'] = ['/usr/share/games/pysweeper',
                              '/usr/local/share/games/pysweeper', sys.path[0],
                              '.']
    else:
        game_opts['paths'] = [sys.path[0], '.']

    try:
        options = getopt.getopt(sys.argv[1:], 'hvdr:c:m:',
                                ['help', 'rows=', 'columns=', 'cols=', 'dir=',
                                 'mines=', 'version', 'debug'])[0]
    except getopt.error:
        show_usage(sys.exc_info()[1])

    set_size = 0
    set_mines = 0
    for option, argument in options:
        if option in ('-h', '--help'):
            show_usage()
        elif option in ('-v', '--version'):
            show_version()
        elif option in ('-d', '--debug'):
            game_opts['debug'] = 1
        elif option in ('-r', '--rows'):
            set_option(game_opts, 'rows', argument)
            set_size = 1
        elif option in ('-c', '--cols', '--columns'):
            set_option(game_opts, 'cols', argument)
            set_size = 1
        elif option in ('-m', '--mines'):
            set_option(game_opts, 'mines', argument)
            set_mines = 1
        elif option == '--dir':
            argument = os.path.normcase(argument)
            argument = os.path.normpath(argument)
            game_opts['paths'].insert(0, argument)
    if set_size and (not set_mines):
        game_opts['mines'] = int(round(game_opts['rows'] * game_opts['cols']
                                       * .15625))
    if game_opts['mines'] > (game_opts['rows'] * game_opts['cols']):
        show_usage("Too many mines (%i) for a %ix%i playing field" %
                   (game_opts['mines'], game_opts['rows'], game_opts['cols']))
    return game_opts


def init_ui(game_opts):
    """Initialize a game interface and return it.

    This function attempts to import and initialize a user interface for the
    game.  If successful, it returns the initialized interface.  Otherwise,
    it aborts the program, with a traceback if debug is true.

    game_opts is a dictionary of game options, as provided by get_options().
    """
    try:
        error_type = "import"
        import sdl_ui
        error_type = "initialize"
        ui = sdl_ui.SDL_UI(game_opts['rows'], game_opts['cols'],
                           game_opts['mines'], 1, game_opts['paths'])
    except (ImportError, 'UIError'), reason:
        fail("failed to %s UI (%s)" % (error_type, reason),
             game_opts['debug'])
    return ui


def run(game_opts, ui):
    """Run the game with the given options and interface.

    This function runs the main game loop with the given options and
    interface.  It exits only when the player quits.

    game_opts is a dictionary of game options, as provided by get_options().
    ui is the interface to use for the game.
    """
    no_quit = 1
    while no_quit:
        field = Field(game_opts['rows'], game_opts['cols'], game_opts['mines'])
        ui.reset(game_opts['rows'], game_opts['cols'], game_opts['mines'])

        no_reset = 1
        while no_reset:
            input = ui.get_input()
            output = []
            # This loop generates the list of commands for ui.update().
            # It is a list of 2-tuples.  The first value of each tuple is a
            # string, indicating the type of output; the second value is an
            # n-sequence of parameters, which varies from one action to
            # another.
            #
            # This loop also takes other internal actions as necessary
            # given the input -- for example, prints debugging information.
            for act, pos in input:
                if act == 'quit':
                    no_reset = 0
                    no_quit = 0
                elif act == 'reset':
                    no_reset = 0
                elif act in ('open', 'sweep'):
                    if act == 'open':
                        opened = field.open(pos[0], pos[1])
                    else:
                        opened = field.open_adjacent(pos[0], pos[1])
                    output.extend([('open', opened)])
                    for result in opened:
                        if result[1] == 0:
                            break  # We couldn't have hit any mines.
                        if result[1] == -1:
                            enddata = field.get_diff()
                            output.extend([('show', enddata)])
                            break
                elif act == 'flag':
                    result = field.flag(pos[0], pos[1])
                    if result != -1:
                        output.extend([('flag', [((pos[0], pos[1]), result)])])
                elif (game_opts['debug'] and (act == 'debug')):
                    debug((field, ui))
            ui.update(output, field.flags, field.playtime(), field.won())
            ui.wait()


if __name__ == '__main__':
    sys.path.append(os.path.normpath(os.path.join(sys.prefix,
                                                  'lib/games/pysweeper')))
    game_opts = get_options()
    ui = init_ui(game_opts)
    run(game_opts, ui)
