#!/usr/bin/env python2
"""Interface to the high score file.

This provides an interface for the loading and saving the game's high
score file.  High score files are generally placed in a location shared
between all the players on that machine, but not writable by them (on
filesystems which support file permissions).  For the game to be able
to write to the score file, it must be run from a setgid wrapper.

All the fun is in the ScoreFile class.

There's also a write_wrapper_source subroutine, see its documentation
for information about the need for a set-group-id (SGID) wrapper.

NOTE: You should be warned that if run SGID, this module will change
the process EGID upon import.

This code pickles a machine-wide high-score listing to (by default)
/var/games. The high-score file contains username, realname, date,
score/level, version and custom info fields, and should be created
by your installation script. In order to get around permissions
issues under unix, scorefile.py will create a C source file for
you, which you must then compile and install properly yourself
- read the write_wrapper_source docstring carefully. This code
should work with little modification on non-unix filesystems.
"""
__author__ = "Kevin Turner"
__copyright__ = "Copyright (c) 2001 Kevin Turner"
__license__ = "Public Domain"
__version__ = "1.0"



TRUE = 1 == 1
FALSE = not TRUE

import os
import time
import pickle
import sys
from types import *

# Try importing pygame, so we can define some rendering methods.
try:
    import pygame
except ImportError:
    pass

# According to the Filesystem Hierarchy Standard (2.2 beta),
# /var/games is the place to put score files.
# XXX - What's the default path for non-Unix systems?
default_dir = os.path.normpath('/var/games')

__methods__ = ['write_wrapper_source']

class ScoreFileError:
    def __init__(self, errorstring):
        self.value = errorstring
    def __str__(self):
        return `self.value`

class ScoreRecord:
    """An individual record in the score file.

    score: A numeric value indicating the player's score.

    level: The number of the highest level or wave reached by the player.
           May be "None" if the game is not structured by level.

    login: The identity of this user, according to the OS,
           e.g. "agreensp".

    name: The name by which the player wishes to be known,
          e.g. "Alan Greenspan" or "MoneyMan".

    version: A string describing the version, or release, of the game
             software.

    date: The date and time when the score was recorded.  Stored in UTC,
          as a tuple.

    extradata: A dictionary object containing game-specific stats, e.g.
               board size, difficulty level, character race/class, etc.
               You may put anything in here you like, as long as it's
               pickleable.
    """

    __members__ = ['date','login','name',
                   'score','level', 'extradata']
    __methods__ = ['get_date']

    def __init__(self, score, name=None, level=None, extradata=None):
        self.date = time.gmtime(time.time())

        # XXX - If we're a network game server, os.getlogin() probably
        # doesn't return what we want.
        try:
            self.login = os.getlogin()
        except:
            self.login = None

        self.score = score
        self.name = name
        self.level = level

        self.extradata = extradata

    def get_date(self, formatstring=None):
        """Returns a string describing the time of the entry, in local time.

        formatstring is the same format as used by time.strftime()."""

        if not formatstring:
            formatstring = '%c'

        formatstring = str(formatstring)

        # self.date is a UTC time in a tuple.
        utc_seconds = time.mktime(self.date)

        # I thought I would have to test for time.daylight here,
        # and then use either time.altzone or time.timezone accordingly,
        # but apparently one of the other time functions already handles it,
        # because if I use altzone, I'm over-correcting by an hour.
        local_offset = -time.timezone

        local_seconds = utc_seconds + local_offset

        local_time = time.localtime(local_seconds)

        time_string = time.strftime(formatstring, local_time)

        return time_string

    def __getattr__(self,name):
        "Check extradata to see if has this attribute."
        if name != 'extradata' and self.extradata \
           and self.extradata.has_key(name):
            return self.extradata[name]
        raise AttributeError

    def __str__(self):
        ds = {'score':self.score,
              'name':self.name,
              'level':self.level,
              'login':self.login,
              'date':self.get_date()}

        if self.extradata:
            ds.update(self.extradata)

        return str(ds)
# ScoreRecord

class ScoreFile:
    """A high score file which may be read from and added to.

    Entries are stored sorted by score."""

    __members__ = ['filename', 'game_name', 'game_version', 'records']
    __methods__ = ['add', 'write', 'crop', 'top', 'latest']

    # TODO: Provide a default DRAW method to render some scores to a SDL surface.

    def __init__(self, game_name, game_version, filename=None, dirname=None):
        """Load or create a score file.

        game_name: this name is stored in the score file, and possibly used to
                   generate a filename if none is supplied.
        game_version: this is the current version of the game, stored with each
                      entry.
        filename: the name of the high score file.  If none is given, an attempt
                  is made to construct one from game_name.
        dirname: the directory where the high score file should be placed, if
                 the filename is not specified as an absolute pathname.
                 The Filesystem Hierarchy Standard dictates that this be
                 /var/games.
        """
        self.game_name = str(game_name)
        self.game_version = str(game_version)


        if dirname:
            dirname = os.path.normpath(dirname)
        else:
            dirname = default_dir

        if not os.path.abspath(dirname):
            dirname = os.path.join(default_dir,dirname)

        if filename:
            filename = os.path.normpath(filename)
        else:
            # find a score file, or make a filename up (based on
            # game_name), or get one from the configuration module
            # XXX - How should this work on non-Unix platforms?
            # XXX - configuration module does not exist

            # XXX - filter game_name so it contains only
            #  filesystem-friendly characters.
            filename = self.game_name + '.hiscore'

        if not os.path.isabs(filename):
            filename = os.path.join(dirname, filename)

        # This sort of thing *should* be done by the game's installer.
        # We'll make a bumbling attempt to try and get something working,
        # though.
        if not os.path.exists(os.path.dirname(filename)):
            if _obtain_permission():
                os.makedirs(os.path.dirname(filename),0775)
            else:
                os.makedirs(os.path.dirname(filename))

        if not os.path.exists(filename):
            _obtain_permission()
            if os.access(os.path.dirname(filename), os.W_OK):
                # we can create a scorefile, but for now it's empty.
                self.records = []
            else:
                raise ScoreFileError, \
                      "I can't write to directory %s to create the "\
                      "score file." % os.path.dirname(filename)
            _drop_permission()
        else:
            _obtain_permission()
            fobj = open(filename, 'r')
            try:
                loaded_scorefile = pickle.load(fobj)
                self.records = loaded_scorefile.records
                del loaded_scorefile
            except EOFError:
                # We get this if the file is zero-sized.
                self.records = []
            fobj.close()
            _drop_permission()

        self.filename = filename
    # ScoreFile.__init__()

    def add(self, score, name=None, level=None, extradata=None, write=TRUE):
        """Make a new entry in the high score file.

        See ScoreRecord for a description of the arguments.
        Returns the entry's place in the high score list, with
        1 being the highest.

        write: Should the score file be written to disk after this add?
               If 'must', then raise an exception if the write fails.
               Otherwise, if true, attempt the write (but only print a
               warning if it fails), if false, add the entry to the score
               file in memory but make no attempt to save to disk.
        """
        # XXX - doc string should do necessary translcusion of ScoreRecord
        # argument docs, don't make the reader jump around to do our work.

        score = float(score)
        name = str(name)

        score_rec = ScoreRecord(score, name, level, extradata)

        score_rec.game_version = self.game_version

        pos = self.place(score)

        self.records.insert(pos-1, score_rec)

        if(write):
            if write=='must':
                self.write()
            else:
                try:
                    self.write()
                except IOError:
                    print "WARNING: failed to save scorefile, got %s: %s"\
                          % sys.exc_info()[:2]
        # endif(write)
        return pos
    # ScoreFile.add()

    def place(self,score):
        """Returns the place that this score would earn in the list."""

        if (not self.records) or score >= self.records[0].score:
            # New High Score!
            pos = 0
        elif score < self.records[-1].score:
            # Congratz -- Yours is the lowest score yet.
            pos = len(self.records)
        else:
            # binary search to find the insertion location
            high_end = len(self.records) - 1
            low_end = 0
            pos = 0
            while abs(high_end - low_end) > 1:
                pos = int(round((high_end + low_end) / 2))
                # Reminder: A HIGHER score means a LOWER index.
                if(score >= self.records[pos].score):
                    high_end = pos
                else:
                    low_end = pos
            # whend
        # fi
        return pos + 1
    # ScoreFile.place()

    def write(self):
        """Save the score file to disk.

        Note: ScoreFile.add() calls this method, so you should
        rarely need to do so yourself."""
        assert(self.filename)

        if _obtain_permission():
            # Err.  We can't mask the write bit for the file's owner,
            # otherwise this user won't be able to write to it in the future!
            # Ideally, the file has already been created (by the install process),
            # and so is already owned by someone else.
            os.umask(02)
        fobj = open(self.filename, 'w')
        pickle.dump(self, fobj)
        fobj.close()
        _drop_permission()

    def crop(self, bytes): # XXX - Untested!
        """Limits the size of the scorefile.

        This method may be used to limit the size of the scorefile.
        It will remove as many entries from the scorefile as are
        required to make it fit in the specified number of bytes.
        The entries removed are those with the lowest scores.

        Returns the number of entries trimmed."""

        bytes = long(bytes)
        assert(bytes > 0)

        trimmed_entries = 0

        while 1:
            pickledfile = pickle.dumps(self)

            fat = len(pickledfile) - bytes

            if fat <= 0:
                return trimmed_entries
            else:
                if len(self.records) == 0:
                     raise ScoreFileError, "Failed to make the score "\
                           "file small enough for you."
                del self.records[-1]
                trimmed_entries = trimmed_entries + 1
        # whend
    # ScoreFile.crop()

    def __get_item__(self, key):
        """Scores are returned sorted highest to lowest."""
        return self.records[key]

    def get_by_time(self, key):
        """Like get_item, but sorted by time, not score."""
        time_list = map(None, self.records[:].date, range(len(self.records)))

        time_list.sort()

        time_sorted = [ self.records[tl[1]] for tl in time_list ]

        return time_sorted[key]
    # ScoreFile.get_by_time()

    def top(self, n=1):
        """Returns a list of the top n scores (defaulting to 1).

        Returns a list of ScoreRecords.
        Note: This object supports the standard slice syntax,
        so this method is equivalent to my_scorefile[:n]."""

        return self[:n]

    def latest(self, n=1):
        """Returns the n most recent entries in the score file.

        If n is omitted, it defaults to 1.
        Returns a list of ScoreRecords, starting with the most recent."""

        results = self.get_by_time(-n)
        results.reverse()
        return results

    def __len__(self):
        """The number of entries in the high score file."""
        return len(self.records)

    def __str__(self):
        s = "%s: Score File for %s, contains %d entries.\n" %\
            (self.filename, self.game_name, len(self.records))
        for i in self.records:
            sr = "%d\t%s\t%s\t%s\t%s" \
                 % (i.score, i.level, i.name, i.login, i.get_date())
            if i.extradata:
                sr = sr + str(i.extradata)
            s = s + sr + "\n"
        return s
    # ScoreFile.__str__()
# ScoreFile


def _obtain_permission():
    """Re-gain our SGID group identity."""
    if _SGID_WRAPPER:
        os.setregid(_user_gid,_privledged_gid)
        return TRUE
    return FALSE

def _drop_permission():
    """Drop our SGID group identity for the time being."""
    if _SGID_WRAPPER:
        os.setregid(_privledged_gid,_user_gid)
        return TRUE
    return FALSE

# top-level code
if os.environ.has_key('RUN_BY_WRAPPER'):
    _privledged_gid = os.getegid()
    _user_gid = os.getgid()
    if _privledged_gid != _user_gid:
        _SGID_WRAPPER = TRUE
    else:
        _SGID_WRAPPER = FALSE
        print "Apparently run from a wrapper, but it doesn't seem to be SGID."
    # XXX: This causes us to drop our SGID permissions upon importing
    # the module!  If our parent module was not expecting those to
    # change, it may not be happy with us.
    _drop_permission()
else:
    _SGID_WRAPPER = FALSE
    print "Warning: Not run from a SGID wrapper, I may use inappropriate"
    print " ownership and/or permissions for the high score file."
    # TODO:  At this point, if we believe there *should* be a wrapper installed,
    #  but we just weren't run by it, we could exec it here, if we wanted to.
# top-level code

def write_wrapper_source(path,source_file):
    """Writes C source code that exec's path to source_file.

    On some platforms (e.g. unix) it's desirable to have a shared score
    file between users.  This requires that the game be able to write to
    the score file no matter who's playing, but we'd rather not make the
    file world-writable.

    If the score file is installed as owned by a "games" group, then
    the game could write to that file if it were owned by the games
    group and had its sgid bit set (chmod g+s).  Unfortunately for
    us, most systems don't use sgid for interpreted files (e.g. your
    python script), so we need to make a "real executable" to put the
    sgid bit on.  This, having gained the "games" permissions, will
    in turn run our python script.

    This function writes the source for such a wrapper program to
    a file.  You must then see to it that the file is compiled and
    properly installed."""

    path = os.path.normpath(path)
    source_file = os.path.normpath(source_file)

    if not os.path.exists(path):
        print "Warning: path",path,"doesn't seem to exist."

    fobj = open(source_file, 'w')

    s = '#include <unistd.h>\n#include <stdlib.h>\n'
    s = s + "/* Compile like: gcc -o my_wrapper %s */\n" % source_file
    s = s + """
            int main(int argc, char* argv[])
            {
            putenv("RUN_BY_WRAPPER=1");
            """
    fobj.write(s)
    fobj.write("return execv(\"%s\",argv);\n}\n" % path)
    fobj.close()
# write_wrapper_source()
