#
# BigBrotherBot(B3) (www.bigbrotherbot.net)
# Copyright (C) 2011 Thomas LEVEIL
# 
# 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., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
#
# CHANGELOG
# 1.0
#  update parser for BF3 R20
# 1.1
#  add event EVT_GAMESERVER_CONNECT which is triggered every time B3 connects to the game server
# 1.1.1
#  fix and refactor admin.yell
# 1.2
#  introduce new setting 'big_b3_private_responses'
# 1.3
#  introduce new setting 'big_msg_duration'
#  refactor the code that reads the config file
# 1.4
#  commands can now start with just the '/' character if the user wants to hide the command from other players instead
#  of having to type '/!'
# 1.4.1
#  add a space between the bot name and the message in saybig()
# 1.4.2
#  fixes bug regarding round count on round change events
# 1.4.3 - 1.4.5
#  improves handling of commands prefixed only with '/' instead of usual command prefixes. Leading '/' is removed if
#  followed by an existing command name or if followed by a command prefix.
# 1.5
#  parser can now create EVT_CLIENT_TEAM_SAY events (requires BF3 server R21)
# 1.5.1
#  fixes issue with BF3 failing to provide EA_GUID https://github.com/courgette/big-brother-bot/issues/69
# 1.5.2
#  fixes issue that made B3 fail to ban/tempban a client with empty guid
# 1.6
#  replace admin plugin !map command with a Frostbite2 specific implementation. Now can call !map <map>, <gamemode>
#  refactor getMapsSoundingLike
#  add getGamemodeSoundingLike
# 1.7
#  replace admin plugin !map command with a Frostbite2 specific implementation. Now can call !map <map>[, <gamemode>[, <num of rounds>]]
#  when returning map info, provide : map name (gamemode) # rounds
# 1.8
#  isolate the patching code in a module function
# 1.8.1
#  improve punkbuster event parsing
#
 
__author__  = 'Courgette'
__version__ = '1.8.1'
 
 
import sys, re, traceback, time, string, Queue, threading, new
import b3.parser
from b3.parsers.frostbite2.rcon import Rcon as FrostbiteRcon
from b3.parsers.frostbite2.protocol import FrostbiteServer, CommandFailedError, CommandError, NetworkError
from b3.parsers.frostbite2.util import PlayerInfoBlock, MapListBlock, BanlistContent
import b3.events
import b3.cvar
from b3.functions import getStuffSoundingLike
 
 
# how long should the bot try to connect to the Frostbite server before giving out (in second)
GAMESERVER_CONNECTION_WAIT_TIMEOUT = 600
 
class AbstractParser(b3.parser.Parser):
    """
    An abstract base class to help with developing frostbite2 parsers
    """
 
    gameName = None
    privateMsg = True
 
    # hard limit for rcon command admin.say
    SAY_LINE_MAX_LENGTH = 128
 
    OutputClass = FrostbiteRcon
    _serverConnection = None
    _nbConsecutiveConnFailure = 0
 
    frostbite_event_queue = Queue.Queue(400)
    sayqueue = Queue.Queue(100)
    sayqueue_get_timeout = 2
    sayqueuelistener = None
 
    # frostbite2 engine does not support color code, so we need this property
    # in order to get stripColors working
    _reColor = re.compile(r'(\^[0-9])')
 
    _settings = {
        'line_length': 128,
        'min_wrap_length': 128,
        'message_delay': .8,
        'big_msg_duration': 4,
        'big_b3_private_responses': False,
        }
 
    _gameServerVars = () # list available cvar
 
    _commands = {
        'message': ('admin.say', '%(message)s', 'player', '%(cid)s'),
        'saySquad': ('admin.say', '%(message)s', 'squad', '%(teamId)s', '%(squadId)s'),
        'sayTeam': ('admin.say', '%(message)s', 'team', '%(teamId)s'),
        'say': ('admin.say', '%(message)s', 'all'),
        'bigmessage': ('admin.yell', '%(message)s', '%(big_msg_duration)i', 'player', '%(cid)s'),
        'yellSquad': ('admin.yell', '%(message)s', '%(big_msg_duration)i', 'squad', '%(teamId)s', '%(squadId)s'),
        'yellTeam': ('admin.yell', '%(message)s', '%(big_msg_duration)i', 'team', '%(teamId)s'),
        'yell': ('admin.yell', '%(message)s', '%(big_msg_duration)i'),
        'kick': ('admin.kickPlayer', '%(cid)s', '%(reason)s'),
        'ban': ('banList.add', 'guid', '%(guid)s', 'perm', '%(reason)s'),
        'banByName': ('banList.add', 'name', '%(name)s', 'perm', '%(reason)s'),
        'banByIp': ('banList.add', 'ip', '%(ip)s', 'perm', '%(reason)s'),
        'unban': ('banList.remove', 'guid', '%(guid)s'),
        'unbanByIp': ('banList.remove', 'ip', '%(ip)s'),
        'tempban': ('banList.add', 'guid', '%(guid)s', 'seconds', '%(duration)d', '%(reason)s'),
        'tempbanByName': ('banList.add', 'name', '%(name)s', 'seconds', '%(duration)d', '%(reason)s'),
        }
 
    _eventMap = {
    }
 
    _punkbusterMessageFormats = (
        (re.compile(r'^.*: PunkBuster Server for .+ \((?P<version>.+)\)\sEnabl.*$'), 'OnPBVersion'),
        (re.compile(r'^.*: Running PB Scheduled Task \(slot #(?P<slot>\d+)\)\s+(?P<task>.*)$'), 'OnPBScheduledTask'),
        (re.compile(r'^.*: Lost Connection \(slot #(?P<slot>\d+)\) (?P<ip>[^:]+):(?P<port>\d+) (?P<pbuid>[^\s]+)\(-\)\s(?P<name>.+)$'), 'OnPBLostConnection'),
        (re.compile(r'^.*: Master Query Sent to \((?P<pbmaster>[^\s]+)\) (?P<ip>[^:]+)$'), 'OnPBMasterQuerySent'),
        (re.compile(r'^.*: Player GUID Computed (?P<pbid>[0-9a-fA-F]+)\(-\) \(slot #(?P<slot>\d+)\) (?P<ip>[^:]+):(?P<port>\d+)\s(?P<name>.+)$'), 'OnPBPlayerGuid'),
        (re.compile(r'^.*: New Connection \(slot #(?P<slot>\d+)\) (?P<ip>[^:]+):(?P<port>\d+) \[(?P<something>[^\s]+)\]\s"(?P<name>.+)".*$'), 'OnPBNewConnection'),
        (re.compile(r'^.*:\s+(?P<index>\d+)\s+(?P<pbid>[0-9a-fA-F]+) {(?P<min_elapsed>\d+)/(?P<duration>\d+)}\s+"(?P<name>[^"]+)"\s+"(?P<ip>[^:]+):(?P<port>\d+)"\s+"?(?P<reason>.*)"\s+"(?P<private_reason>.*)"$'), None), # banlist item
        (re.compile(r'^.*: Guid=(?P<search>.*) Not Found in the Ban List$'), None),
        (re.compile(r'^.*: End of Ban List \(\d+ of \d+ displayed\)$'), None),
        (re.compile(r'^.*: Guid (?P<pbid>[0-9a-fA-F]+) has been Unbanned$'), None),
        (re.compile(r'^.*: PB UCON "(?P<from>.+)"@(?P<ip>[\d.]+):(?P<port>\d+) \[(?P<cmd>.*)\]$'), 'OnPBUCON'),
        (re.compile(r'^.*: Player List: \[Slot #\] \[GUID\] \[Address\] \[Status\] \[Power\] \[Auth Rate\] \[Recent SS\] \[O/S\] \[Name\]$'), None),
        (re.compile(r'^.*: (?P<slot>\d+)\s+(?P<pbid>[0-9a-fA-F]+)\(-\) (?P<ip>[^:]+):(?P<port>\d+) (?P<status>.+)\s+(?P<power>\d+)\s+(?P<authrate>\d+\.\d+)\s+(?P<recentSS>\d+)\s+\((?P<os>.+)\)\s+"(?P<name>.+)".*$'), 'OnPBPlistItem'),
        (re.compile(r'^.*: End of Player List \(\d+ Players\)$'), None),
        (re.compile(r'^.*: Invalid Player Specified: (?P<data>.*)$'), None),
        (re.compile(r'^.*: Received Download File: (?P<file>.*)$'), None),
        (re.compile(r'^.*: Matched: (?P<name>.*) \(slot #(?P<slot>\d+)\)$'), None),
        (re.compile(r'^.*: (?P<num>\d+) Ban Records Updated in (?P<filename>.*)$'), None),
        (re.compile(r'^.*: Ban Added to Ban List$'), None),
        (re.compile(r'^.*: Ban Failed$'), None),
        (re.compile(r'^.*: Received Master Security Information$'), None),
        (re.compile(r'^.*: Auto Screenshot\s+(?P<ssid>\d+)\s+Requested from (?P<slot>\d+) (?P<name>.+)$'), None),
        (re.compile(r'^.*: Screenshot (?P<imgpath>.+)\s+successfully received \(MD5=(?P<md5>[0-9A-F]+)\) from (?P<slot>\d+) (?P<name>.+) \[(?P<pbid>[0-9a-fA-F]+)\(-\) (?P<ip>[^:]+):(?P<port>\d+)\]$'), 'OnPBScreenshotReceived'),
    )
 
    # if Punkbuster is set, then it will be used to kick/ban/unban
    PunkBuster = None
    # if ban_with_server is True, then the Frostbite server will be used for ban
    ban_with_server = True
 
    # flag to find out if we need to fire a EVT_GAME_ROUND_START event.
    _waiting_for_round_start = True
 
 
    def __new__(cls, *args, **kwargs):
        AbstractParser.patch_b3_Clients_getByMagic()
        patch_b3_clients()
        return b3.parser.Parser.__new__(cls)
 
    @staticmethod
    def patch_b3_Clients_getByMagic():
        """
        The b3.clients.Client.getByMagic method does not behave as intended for Frostbive server when id is a string
        composed of digits exclusively. In such case it behave as if id was a slot number as for Quake3 servers.
        This method patches the self.clients object so that it getByMagic method behaves as expected for Frostbite servers.
        """
        def new_clients_getByMagic(self, id):
            id = id.strip()
 
            if re.match(r'^@([0-9]+)$', id):
                return self.getByDB(id)
            elif id[:1] == '\\':
                c = self.getByName(id[1:])
                if c and not c.hide:
                    return [c]
                else:
                    return []
            else:
                return self.getClientsByName(id)
        b3.clients.Clients.getByMagic = new_clients_getByMagic
 
 
    def patch_b3_admin_plugin(self):
        """
        Monkey patches the admin plugin
        """
 
        def parse_map_parameters(self, data, client):
            """
            Method that parses a command parameters of extract map, gamemode and number of rounds.
            Expecting one, two or three parameters separated by a comma.
            <map> [, gamemode [, num of rounds]]
            """
            gamemode_data = num_rounds = None
 
            if ',' in data:
                parts = [x.strip() for x in data.split(',')]
                if len(parts) > 3:
                    client.message("Invalid parameters. At max 3 parameters are expected")
                    return
                elif len(parts) == 3:
                    gamemode_data = parts[1]
                    num_rounds = parts[2]
                elif len(parts) == 2:
                    if re.match('\d+', parts[1]):
                        # 2nd param is the number of rounds
                        num_rounds = parts[1]
                    else:
                        gamemode_data = parts[1]
                map_data = parts[0]
            else:
                map_data = data.strip()
 
            if gamemode_data is None:
                gamemode_data = self.console.game.gameType
 
            if num_rounds is None:
                # get the number of round from the current map
                try:
                    num_rounds = int(self.console.game.serverinfo['roundsTotal'])
                except Exception, err:
                    self.warning("could not get current number of rounds", exc_info=err)
                    client.message("please specify the number of rounds you want")
                    return
            else:
                # validate given number of rounds
                try:
                    num_rounds = int(num_rounds)
                except Exception, err:
                    self.warning("could not read the number of rounds of '%s'" % num_rounds, exc_info=err)
                    client.message("could not read the number of rounds of '%s'" % num_rounds)
                    return
 
            map_id = self.console.getMapIdByName(map_data)
            if type(map_id) is list:
                client.message('do you mean : %s ?' % ', '.join(map_id))
                return
 
            gamemode_id = self.console.getGamemodeSoundingLike(map_id, gamemode_data)
            if type(gamemode_id) is list:
                client.message('do you mean : %s ?' % ', '.join(gamemode_id))
                return
 
            return map_id, gamemode_id, num_rounds
 
 
        """
        Monkey path the cmd_map method of the loaded AdminPlugin instance to accept optional 2nd and 3rd parameters
        which are the game mode and number of rounds
        """
        def new_cmd_map(self, data, client, cmd=None):
            """\
            <map> [, gamemode [, num of rounds]] - switch current map. Optionally specify a gamemode and # of rounds by separating them from the map name with a commas
            """
            if not data:
                client.message('Invalid parameters, type !help map')
                return
 
            parsed_data = self.parse_map_parameters(data, client)
            if not parsed_data:
                return
 
            map_id, gamemode_id, num_rounds = parsed_data
            try:
                suggestions = self.console.changeMap(map_id, gamemode_id=gamemode_id, number_of_rounds=num_rounds)
            except CommandFailedError, err:
                if err.message == ['InvalidGameModeOnMap']:
                    client.message("%s cannot be played with gamemode %s" % self.console.getEasyName(map_id), self.console.getGameMode(gamemode_id))
                    client.message("supported gamemodes are : " + ', '.join([self.console.getGameMode(mode_id) for mode_id in self.console.getSupportedGameModesByMapId(map_id)]))
                    return
                elif err.message == ['InvalidRoundsPerMap']:
                    client.message("number of rounds must be 1 or greater")
                    return
                elif err.message == ['Full']:
                    client.message("Map list maximum size has been reached")
                    return
                else:
                    raise
            else:
                if type(suggestions) == list:
                    client.message('do you mean : %s ?' % ', '.join(map_id))
                    return
 
        adminPlugin = self.getPlugin('admin')
 
        adminPlugin.parse_map_parameters = new.instancemethod(parse_map_parameters, adminPlugin)
 
        cmd = adminPlugin._commands['map']
        cmd.func = new.instancemethod(new_cmd_map, adminPlugin)
        cmd.help = new_cmd_map.__doc__.strip()
 
 
 
    def run(self):
        """Main worker thread for B3"""
        self.bot('Start listening ...')
        self.screen.write('Startup Complete : B3 is running! Let\'s get to work!\n\n')
        self.screen.write('(If you run into problems, check %s for detailed log info)\n' % self.config.getpath('b3', 'logfile'))
        #self.screen.flush()
 
        self.updateDocumentation()
 
        ## the block below can activate additional logging for the FrostbiteServer class
#        import logging
#        frostbiteServerLogger = logging.getLogger("FrostbiteServer")
#        for handler in logging.getLogger('output').handlers:
#            frostbiteServerLogger.addHandler(handler)
#        frostbiteServerLogger.setLevel(logging.getLogger('output').level)
 
        while self.working:
            if not self._serverConnection or not self._serverConnection.connected:
                try:
                    self.setup_frostbite_connection()
                except CommandError, err:
                    if err.message[0] == 'InvalidPasswordHash':
                        self.error("your rcon password is incorrect. Check setting 'rcon_password' in your main config file.")
                        self.exitcode = 220
                        break
                    else:
                        self.error(err.message)
                except IOError, err:
                    self.error("IOError %s"% err)
                except Exception, err:
                    self.error(err)
                    self.exitcode = 220
                    break
 
            try:
                added, expire, packet = self.frostbite_event_queue.get(timeout=5)
                self.routeFrostbitePacket(packet)
            except Queue.Empty:
                self.verbose2("no game server event to treat in the last 5s")
            except CommandError, err:
                # it does not matter from the parser perspective if Frostbite command failed
                # (timeout or bad reply)
                self.warning(err)
            except NetworkError, e:
                # the connection to the frostbite server is lost
                self.warning(e)
                self.close_frostbite_connection()
            except Exception, e:
                self.error("unexpected error, please report this on the B3 forums")
                self.error(e)
                self.error('%s: %s', e, traceback.extract_tb(sys.exc_info()[2]))
                # unexpected exception, better close the frostbite connection
                self.close_frostbite_connection()
 
 
        self.info("Stop listening for Frostbite2 events")
        # exiting B3
        with self.exiting:
            # If !die or !restart was called, then  we have the lock only after parser.handleevent Thread releases it
            # and set self.working = False and this is one way to get this code is executed.
            # Else there was an unhandled exception above and we end up here. We get the lock instantly.
 
            self.output.frostbite_server = None
 
            # The Frostbite connection is running its own thread to communicate with the game server. We need to tell
            # this thread to stop.
            self.close_frostbite_connection()
 
            # wait for threads to finish
            self.wait_for_threads()
 
            # If !die was called, exitcode have been set to 222
            # If !restart was called, exitcode have been set to 221
            # In both cases, the SystemExit exception that triggered exitcode to be filled with an exit value was
            # caught. Now that we are sure that everything was gracefully stopped, we can re-raise the SystemExit
            # exception.
            if self.exitcode:
                sys.exit(self.exitcode)
 
    def setup_frostbite_connection(self):
        self.info('Connecting to frostbite2 server ...')
        if self._serverConnection:
            self.close_frostbite_connection()
        self._serverConnection = FrostbiteServer(self._rconIp, self._rconPort, self._rconPassword)
 
        timeout = GAMESERVER_CONNECTION_WAIT_TIMEOUT + time.time()
        while time.time() < timeout and not self._serverConnection.connected:
            self.info("retrying to connect to game server...")
            time.sleep(2)
            self.close_frostbite_connection()
            self._serverConnection = FrostbiteServer(self._rconIp, self._rconPort, self._rconPassword)
 
        if self._serverConnection is None or not self._serverConnection.connected:
            self.error("Could not connect to Frostbite2 server")
            self.close_frostbite_connection()
            self.shutdown()
            raise SystemExit()
 
        # listen for incoming game server events
        self._serverConnection.subscribe(self.OnFrosbiteEvent)
 
        self._serverConnection.auth()
        self._serverConnection.command('admin.eventsEnabled', 'true')
 
        # setup Rcon
        self.output.set_frostbite_server(self._serverConnection)
 
        self.queueEvent(b3.events.Event(b3.events.EVT_GAMESERVER_CONNECT, None))
 
        self.checkVersion()
        self.say('%s ^2[ONLINE]' % b3.version)
        self.getServerInfo()
        self.getServerVars()
        self.clients.sync()
 
        # checkout punkbuster support
        try:
            result = self._serverConnection.command('punkBuster.isActive')
        except CommandError, e:
            self.error("could not get punkbuster status : %r" % e)
            self.PunkBuster = None
            self.ban_with_server = True
        else:
            if result and result[0] == 'true':
                self.write(('punkBuster.pb_sv_command', 'pb_sv_plist')) # will make punkbuster send IP address of currently connected players
            elif not self.ban_with_server:
                self.ban_with_server = True
                self.warning("Forcing ban agent to 'server' as we failed to verify that punkbuster is active on the server")
 
    def close_frostbite_connection(self):
        try:
            self._serverConnection.stop()
        except Exception:
            pass
        self._serverConnection = None
 
    def OnFrosbiteEvent(self, packet):
        if not self.working:
            self.verbose("dropping Frostbite event %r" % packet)
        self.console(repr(packet))
        try:
            self.frostbite_event_queue.put((self.time(), self.time() + 10, packet), timeout=2)
        except Queue.Full:
            self.error("Frostbite event queue full, dropping event %r" % packet)
 
    def routeFrostbitePacket(self, packet):
        if packet is None:
            self.warning('cannot route empty packet : %s' % traceback.extract_tb(sys.exc_info()[2]))
        eventType = packet[0]
        eventData = packet[1:]
 
        match = re.search(r"^(?P<actor>[^.]+)\.(on)?(?P<event>.+)$", eventType)
        func = None
        if match:
            func = 'On%s%s' % (string.capitalize(match.group('actor')), \
                               string.capitalize(match.group('event')))
            self.verbose2("looking for event handling method called : " + func)
 
        if match and hasattr(self, func):
            #self.verbose2('routing ----> %s(%r)' % (func,eventData))
            func = getattr(self, func)
            event = func(eventType, eventData)
            #self.debug('event : %s' % event)
            if event:
                self.queueEvent(event)
 
        elif eventType in self._eventMap:
            self.queueEvent(b3.events.Event(
                    self._eventMap[eventType],
                    eventData))
        else:
            data = ''
            if func:
                data = func + ' '
            data += str(eventType) + ': ' + str(eventData)
            self.warning('TODO : handle \'%r\' frostbite2 events' % packet)
            self.queueEvent(b3.events.Event(b3.events.EVT_UNKNOWN, data))
 
 
    def startup(self):
 
        # add specific events
        self.Events.createEvent('EVT_GAMESERVER_CONNECT', 'connected to game server')
        self.Events.createEvent('EVT_CLIENT_SQUAD_CHANGE', 'Client Squad Change')
        self.Events.createEvent('EVT_CLIENT_SPAWN', 'Client Spawn')
        self.Events.createEvent('EVT_GAME_ROUND_PLAYER_SCORES', 'round player scores')
        self.Events.createEvent('EVT_GAME_ROUND_TEAM_SCORES', 'round team scores')
        self.Events.createEvent('EVT_PUNKBUSTER_UNKNOWN', 'PunkBuster unknown')
        self.Events.createEvent('EVT_PUNKBUSTER_MISC', 'PunkBuster misc')
        self.Events.createEvent('EVT_PUNKBUSTER_SCHEDULED_TASK', 'PunkBuster scheduled task')
        self.Events.createEvent('EVT_PUNKBUSTER_LOST_PLAYER', 'PunkBuster client connection lost')
        self.Events.createEvent('EVT_PUNKBUSTER_NEW_CONNECTION', 'PunkBuster client received IP')
        self.Events.createEvent('EVT_PUNKBUSTER_UCON', 'PunkBuster UCON')
        self.Events.createEvent('EVT_PUNKBUSTER_SCREENSHOT_RECEIVED', 'PunkBuster Screenshot received')
 
        self.load_conf_max_say_line_length()
        self.load_config_message_delay()
        self.load_conf_ban_agent()
        self.load_conf_big_b3_private_responses()
        self.load_conf_big_msg_duration()
 
        self.start_sayqueue_worker()
 
        # start crontab to trigger playerlist events
        self.cron + b3.cron.CronTab(self.clients.sync, minute='*/5')
 
 
    def pluginsStarted(self):
        self.patch_b3_admin_plugin()
 
    def sayqueuelistener_worker(self):
        self.info("sayqueuelistener job started")
        while self.working:
            try:
                msg = self.sayqueue.get(timeout=self.sayqueue_get_timeout)
                for line in self.getWrap(self.stripColors(self.msgPrefix + ' ' + msg), self._settings['line_length'], self._settings['min_wrap_length']):
                    self.write(self.getCommand('say', message=line))
                    if self.working:
                        time.sleep(self._settings['message_delay'])
                self.sayqueue.task_done()
            except Queue.Empty:
                #self.verbose2("sayqueuelistener: had nothing to do in the last %s sec" % self.sayqueue_get_timeout)
                pass
            except Exception, err:
                self.error(err)
        self.info("sayqueuelistener job ended")
 
    def start_sayqueue_worker(self):
        self.sayqueuelistener = threading.Thread(target=self.sayqueuelistener_worker, name="sayqueuelistener")
        self.sayqueuelistener.setDaemon(True)
        self.sayqueuelistener.start()
 
    def wait_for_threads(self):
        if hasattr(self, 'sayqueuelistener') and self.sayqueuelistener:
            self.sayqueuelistener.join()
 
    def getCommand(self, cmd, **kwargs):
        """Return a reference to a loaded command"""
        try:
            cmd = self._commands[cmd]
        except KeyError:
            return None
 
        preparedcmd = []
        for a in cmd:
            try:
                preparedcmd.append(a % kwargs)
            except KeyError:
                pass
 
        result = tuple(preparedcmd)
        self.debug('getCommand: %s', result)
        return result
 
    def write(self, msg, maxRetries=1, needConfirmation=False):
        """Write a message to Rcon/Console
        Unfortunaltely this has been abused all over B3 
        and B3 plugins to broadcast text :(
        """
        if type(msg) == str:
            # console abuse to broadcast text
            self.say(msg)
        else:
            # Then we got a command
            if self.replay:
                self.bot('Sent rcon message: %s' % msg)
            elif self.output is None:
                pass
            else:
                res = self.output.write(msg, maxRetries=maxRetries, needConfirmation=needConfirmation)
                self.output.flush()
                return res
 
    def getWrap(self, text, length=None, minWrapLen=None):
        """Returns a sequence of lines for text that fits within the limits
        """
        if not text:
            return []
 
        if length is None:
            length = self._settings['line_length']
 
        maxLength = int(length)
 
        if len(text) <= maxLength:
            return [text]
        else:
            wrappoint = text[:maxLength].rfind(" ")
            if wrappoint == 0:
                wrappoint = maxLength
            lines = [text[:wrappoint]]
            remaining = text[wrappoint:]
            while len(remaining) > 0:
                if len(remaining) <= maxLength:
                    lines.append(remaining)
                    remaining = ""
                else:
                    wrappoint = remaining[:maxLength].rfind(" ")
                    if wrappoint == 0:
                        wrappoint = maxLength
                    lines.append(remaining[0:wrappoint])
                    remaining = remaining[wrappoint:]
            return lines
 
 
    ###############################################################################################
    #
    #    Frostbite2 events handlers
    #    
    ###############################################################################################
 
 
    def OnPlayerChat(self, action, data):
        """
        player.onChat <source soldier name: string> <text: string> <target players: player subset>
 
        Effect: Player with name <source soldier name> (or the server, or the 
            server admin) has sent chat message <text> to <target players>
 
        Comment: If <source soldier name> is "Server", then the message was sent 
            from the server rather than from an actual player
        """
        client = self.getClient(data[0])
        if client is None:
            self.warning("Could not get client :( %s" % traceback.extract_tb(sys.exc_info()[2]))
            return
        if client.cid == 'Server':
            # ignore chat events for Server
            return
        text = data[1]
 
        # existing commands can be prefixed with a '/' instead of usual prefixes
        cmdPrefix = '!'
        cmd_prefixes = (cmdPrefix, '@', '&')
        admin_plugin = self.getPlugin('admin')
        if admin_plugin:
            cmdPrefix = admin_plugin.cmdPrefix
            cmd_prefixes = (cmdPrefix, admin_plugin.cmdPrefixLoud, admin_plugin.cmdPrefixBig)
        cmd_name = text[1:].split(' ', 1)[0].lower()
        if len(text) >= 2 and text[0] == '/':
            if text[1] in cmd_prefixes:
                text = text[1:]
            elif cmd_name in admin_plugin._commands:
                text = cmdPrefix + text[1:]
 
        if data[2] in ('team', 'squad'):
            event_type = b3.events.EVT_CLIENT_TEAM_SAY
        else:
            event_type = b3.events.EVT_CLIENT_SAY
        return b3.events.Event(event_type, text, client)
 
 
    def OnPlayerLeave(self, action, data):
        #player.onLeave: ['GunnDawg']
        client = self.getClient(data[0])
        if client: 
            client.endMessageThreads = True
            client.disconnect() # this triggers the EVT_CLIENT_DISCONNECT event
        return None
 
    def OnPlayerJoin(self, action, data):
        """
        player.onJoin <soldier name: string> <id : EAID>
        """
        # we receive this event very early and even before the game client starts to connect to the game server.
        # In some occasions, the game client fails to properly connect and the game server then fails to send
        # us a player.onLeave event resulting in B3 thinking the player is connected while it is not.
        # The fix is to ignore this event. If the game client successfully connect, then we'll receive other
        # events like player.onTeamChange or even a event from punkbuster which will create the Client object.
        pass
 
    def OnPlayerAuthenticated(self, action, data):
        """
        player.authenticated <soldier name: string> <EA_GUID: string>
 
        Effect: Player with name <soldier name> has been authenticated
        """
        try:
            _guid = data[1]
        except IndexError:
            _guid = None
        self.getClient(data[0], guid=_guid)
 
 
    def OnPlayerSpawn(self, action, data):
        """
        Request: player.onSpawn <spawning soldier name: string> <team: int>
        """
        if len(data) < 2:
            return None
        spawner = self.getClient(data[0])
        spawner.team = self.getTeam(data[1])
 
        self._OnServerLevelstarted(action=None, data=None)
 
        return b3.events.Event(b3.events.EVT_CLIENT_SPAWN, (), spawner)
 
 
    def OnPlayerKill(self, action, data):
        """
        Request: player.onKill <killing soldier name: string> 
            <killed soldier name: string> <weapon: string> <headshot: boolean>
 
        Effect: Player with name <killing soldier name> has killed 
            <killed soldier name> Suicide indication is unknown at this moment. 
            If the server kills the player (through admin.killPlayer), the result is unknown. 
        """
        # example suicide : ['Cucurbitaceae', 'Cucurbitaceae', 'M67', 'false']
        # example killed by fire : ['', 'Cucurbitaceae', 'DamageArea', 'false']
        if data[0] == '':
            data[0] = 'Server'
        attacker = self.getClient(data[0])
        if not attacker:
            self.debug('No attacker')
            return None
 
        victim = self.getClient(data[1])
        if not victim:
            self.debug('No victim')
            return None
 
        weapon = data[2]
 
        if data[3] == 'true':
            hitloc = 'head'
        else:
            hitloc = 'torso'
 
        event = b3.events.EVT_CLIENT_KILL
        if victim == attacker:
            event = b3.events.EVT_CLIENT_SUICIDE
        elif attacker.team == victim.team and attacker.team != b3.TEAM_UNKNOWN and attacker.team != b3.TEAM_SPEC:
            event = b3.events.EVT_CLIENT_KILL_TEAM
        return b3.events.Event(event, (100, weapon, hitloc), attacker, victim)
 
 
    def OnPlayerKicked(self, action, data):
        """
        Request: player.onKicked <soldier name: string> <reason: string>
        Effect: Player with name <soldier name> has been kicked
        """
        if len(data) < 2:
            return None
        client = self.getClient(data[0])
        reason = data[1]
        return b3.events.Event(b3.events.EVT_CLIENT_KICK, data=reason, client=client)
 
 
    def OnServerLevelloaded(self, action, data):
        """
        server.onLevelLoaded <level name: string> <gamemode: string> <roundsPlayed: int> <roundsTotal: int>
 
        Effect: Level has completed loading, and will start in a bit
 
        example: ['server.onLevelLoaded', 'MP_001', 'ConquestLarge0', '1', '2']
        """
        self.debug("OnServerLevelLoaded: %s" % data)
        if not self.game.mapName:
            self.game.mapName = data[0]
        if self.game.mapName != data[0]:
            # map change detected
            self.game.startMap()
        self.game.mapName = data[0]
        self.game.gameType = data[1]
        self.game.rounds = int(data[2]) + 1 # round index starts at 0
        self.game.g_maxrounds = int(data[3])
        self.getServerInfo()
        # to debug getEasyName()
        self.info('Loading %s [%s]'  % (self.getEasyName(self.game.mapName), self.game.gameType))
        self._waiting_for_round_start = True
 
        return b3.events.Event(b3.events.EVT_GAME_WARMUP, data[0])
 
    def _OnServerLevelstarted(self, action, data):
        """
        Event server.onLevelStarted was used to be sent in Frostbite1. Unfortunately it does not exists anymore
        in Frostbite2.
        Instead we call this method from OnPlayerSpawn and maintain a flag which tells if we need to fire the
        EVT_GAME_ROUND_START event
        """
        if self._waiting_for_round_start:
            self._waiting_for_round_start = False
 
            # as the game server provides us the exact round number in OnServerLoadinglevel()
            # hence we need to deduct one to compensate?
            # we'll still leave the call here since it provides us self.game.roundTime()
            # next function call will increase roundcount by one, this is not wanted
            correct_rounds_value = self.game.rounds
            self.game.startRound()
            self.game.rounds = correct_rounds_value
            self.queueEvent(b3.events.Event(b3.events.EVT_GAME_ROUND_START, self.game))
 
 
    def OnServerRoundover(self, action, data):
        """
        server.onRoundOver <winning team: Team ID>
 
        Effect: The round has just ended, and <winning team> won
        """
        #['server.onRoundOver', '2']
        return b3.events.Event(b3.events.EVT_GAME_ROUND_END, data[0])
 
 
    def OnServerRoundoverplayers(self, action, data):
        """
        server.onRoundOverPlayers <end-of-round soldier info : player info block>
 
        Effect: The round has just ended, and <end-of-round soldier info> is the final detailed player stats
        """
        #['server.onRoundOverPlayers', '8', 'clanTag', 'name', 'guid', 'teamId', 'kills', 'deaths', 'score', 'ping', '17', 'RAID', 'mavzee', 'EA_4444444444444444555555555555C023', '2', '20', '17', '310', '147', 'RAID', 'NUeeE', 'EA_1111111111111555555555555554245A', '2', '30', '18', '445', '146', '', 'Strzaerl', 'EA_88888888888888888888888888869F30', '1', '12', '7', '180', '115', '10tr', 'russsssssssker', 'EA_E123456789461416564796848C26D0CD', '2', '12', '12', '210', '141', '', 'Daezch', 'EA_54567891356479846516496842E17F4D', '1', '25', '14', '1035', '129', '', 'Oldqsdnlesss', 'EA_B78945613465798645134659F3079E5A', '1', '8', '12', '120', '256', '', 'TTETqdfs', 'EA_1321654656546544645798641BB6D563', '1', '11', '16', '180', '209', '', 'bozer', 'EA_E3987979878946546546565465464144', '1', '22', '14', '475', '152', '', 'Asdf 1977', 'EA_C65465413213216656546546546029D6', '2', '13', '16', '180', '212', '', 'adfdasse', 'EA_4F313565464654646446446644664572', '1', '4', '25', '45', '162', 'SG1', 'De56546ess', 'EA_123132165465465465464654C2FC2FBB', '2', '5', '8', '75', '159', 'bsG', 'N06540RZ', 'EA_787897944546565656546546446C9467', '2', '8', '14', '100', '115', '', 'Psfds', 'EA_25654321321321000006546464654B81', '2', '15', '15', '245', '140', '', 'Chezear', 'EA_1FD89876543216548796130EB83E411F', '1', '9', '14', '160', '185', '', 'IxSqsdfOKxI', 'EA_481321313132131313213212313112CE', '1', '21', '12', '625', '236', '', 'Ledfg07', 'EA_1D578987994651615166516516136450', '1', '5', '6', '85', '146', '', '5 56 mm', 'EA_90488E6543216549876543216549877B', '2', '0', '0', '0', '192']
        return b3.events.Event(b3.events.EVT_GAME_ROUND_PLAYER_SCORES, PlayerInfoBlock(data))
 
 
    def OnServerRoundoverteamscores(self, action, data):
        """
        server.onRoundOverTeamScores <end-of-round scores: team scores>
 
        Effect: The round has just ended, and <end-of-round scores> is the final ticket/kill/life count for each team
        """
        #['server.onRoundOverTeamScores', '2', '1180', '1200', '1200']
        return b3.events.Event(b3.events.EVT_GAME_ROUND_TEAM_SCORES, data[1])
 
    def OnPunkbusterMessage(self, action, data):
        """handles all punkbuster related events and 
        route them to the appropriate method depending
        on the type of PB message.
 
        Request: punkBuster.onMessage <message: string>
        Effect: PunkBuster server has output a message
        Comment: The entire message is sent as a raw string. It may contain newlines and whatnot.
        """
        #self.debug("PB> %s" % data)
        if data and data[0]:
            match = funcName = None
            for regexp, funcName in self._punkbusterMessageFormats:
                match = re.match(regexp, str(data[0]).strip())
                if match:
                    break
            if match:
                if funcName is None:
                    return b3.events.Event(b3.events.EVT_PUNKBUSTER_MISC, match)
                if hasattr(self, funcName):
                    func = getattr(self, funcName)
                    return func(match, data[0])
                else:
                    self.warning("func %s not found, defaulting to EVT_PUNKBUSTER_UNKNOWN" % funcName)
                    return b3.events.Event(b3.events.EVT_PUNKBUSTER_UNKNOWN, data)
            else:
                self.debug("no pattern matching \"%s\", defaulting to EVT_PUNKBUSTER_UNKNOWN" % str(data[0]).strip())
                return b3.events.Event(b3.events.EVT_PUNKBUSTER_UNKNOWN, data)
 
    def OnPBVersion(self, match,data):
        """PB notifies us of the version numbers
        version = match.group('version')"""
        #self.debug('PunkBuster server named: %s' % match.group('servername') )
        #self.debug('PunkBuster Server version: %s' %( match.group('version') ) )
        pass
 
    def OnPBNewConnection(self, match, data):
        """PunkBuster tells us a new player identified. The player is
        normally already connected and authenticated by B3 by ea_guid
 
        This is our first moment where we receive the clients IP address
        so we also fire the custom event EVT_PUNKBUSTER_NEW_CONNECTION here"""
        name = match.group('name')
        client = self.getClient(name)
        if client:
            #slot = match.group('slot')
            ip = match.group('ip')
            port = match.group('port')
            #something = match.group('something')
            client.ip = ip
            client.port = port
            client.save()
            self.debug('OnPBNewConnection: client updated with %s' % data)
            # This is our first moment where we get a clients IP. Fire this event to accomodate geoIP based plugins like Countryfilter.
            return b3.events.Event(b3.events.EVT_PUNKBUSTER_NEW_CONNECTION, data, client)
        else:
            self.warning('OnPBNewConnection: we\'ve been unable to get the client')
 
    def OnPBLostConnection(self, match, data):
        """PB notifies us it lost track of a player.
        This event is triggered after the OnPlayerLeave, so normaly the client
        is not connected. Anyway our task here is to raise an event not to
        connect/disconnect the client.
        """
        name = match.group('name')
        dict = {
            'slot': match.group('slot'),
            'ip': match.group('ip'),
            'port': match.group('port'),
            'pbuid': match.group('pbuid'),
            'name': name
        }
        self.verbose('PB lost connection: %s' %dict)
        return b3.events.Event(b3.events.EVT_PUNKBUSTER_LOST_PLAYER, dict)
 
    def OnPBScheduledTask(self, match, data):
        """We get notified the server ran a PB scheduled task
        Nothing much to do but it can be interresting to have
        this information logged
        """
        slot = match.group('slot')
        task = match.group('task')
        return b3.events.Event(b3.events.EVT_PUNKBUSTER_SCHEDULED_TASK, {'slot': slot, 'task': task})
 
    def OnPBMasterQuerySent(self, match, data):
        """We get notified that the server sent a ping to the PB masters"""
        #pbmaster = match.group('pbmaster')
        #ip = match.group('ip')
        pass
 
    def OnPBPlayerGuid(self, match, data):
        """We get notified of a player punkbuster GUID"""
        pbid = match.group('pbid')
        #slot = match.group('slot')
        ip = match.group('ip')
        #port = match.group('port')
        name = match.group('name')
        client = self.getClient(name)
        if client:
            client.ip = ip
            client.pbid = pbid
            if not client.guid:
                # a bug in the BF3 server can make admin.listPlayers response reply with players having an
                # empty string as guid. What we can do here is to try to get the guid from the pbid in the
                # B3 database.
                self.debug("Frostbite2 bug : we have no guid for %s. Trying to find client in B3 storage by pbid" % name)
                try:
                    matching_clients = self.storage.getClientsMatching({'pbid': pbid})
                    if len(matching_clients) == 0:
                        self.debug("no client found by pbid")
                    elif len(matching_clients) > 1:
                        self.debug("too many clients found by pbid")
                    else:
                        client.guid = matching_clients[0].guid
                        client.auth()
                except Exception, err:
                    self.warning("failed to try to auth %s by pbid. %r" % (name, err))
            if not client.guid:
                self.error("Game server failed to provide a EA_guid for player %s. Cannot auth player." % name)
            else:
                client.save()
 
    def OnPBPlistItem(self, match, data):
        """we received one of the line containing details about one player"""
        self.OnPBPlayerGuid(match, data)
 
    def OnPBUCON(self, match, data):
        """We get notified of a UCON command
        match groups : from, ip, port, cmd
        """
        return b3.events.Event(b3.events.EVT_PUNKBUSTER_UCON, match.groupdict())
 
    def OnPBScreenshotReceived(self, match, data):
        """We get notified that a screenshot was successfully received by the server"""
        return b3.events.Event(b3.events.EVT_PUNKBUSTER_SCREENSHOT_RECEIVED, match.groupdict())
 
 
    ###############################################################################################
    #
    #    B3 Parser interface implementation
    #    
    ###############################################################################################
 
    def getPlayerList(self, maxRetries=None):
        """return a dict which keys are cid and values a dict of player properties
        as returned by admin.listPlayers.
        Does not return client objects"""
        data = self.write(('admin.listPlayers', 'all'))
        if not data:
            return {}
        players = {}
        pib = PlayerInfoBlock(data)
        for p in pib:
            players[p['name']] = p
        return players
 
 
    def authorizeClients(self):
        players = self.getPlayerList()
        self.verbose('authorizeClients() = %s' % players)
 
        for cid, p in players.iteritems():
            sp = self.clients.getByCID(cid)
            if sp:
                # Only set provided data, otherwise use the currently set data
                sp.ip   = p.get('ip', sp.ip)
                sp.pbid = p.get('pbid', sp.pbid)
                sp.guid = p.get('guid', sp.guid)
                sp.data = p
                newTeam = p.get('teamId', None)
                if newTeam is not None:
                    sp.team = self.getTeam(newTeam)
                sp.teamId = int(newTeam)
                sp.auth()
 
 
    def sync(self):
        plist = self.getPlayerList()
        mlist = {}
 
        for cid, c in plist.iteritems():
            client = self.clients.getByCID(cid)
            if client:
                mlist[cid] = client
                newTeam = c.get('teamId', None)
                if newTeam is not None:
                    client.team = self.getTeam(newTeam)
                client.teamId = int(newTeam)
        return mlist
 
 
    def say(self, msg):
        self.sayqueue.put(msg)
 
    def saybig(self, msg):
        """\
        broadcast a message to all players in a way that will catch their attention.
        """
        if msg and len(msg.strip())>0:
            text = self.stripColors(self.msgPrefix + ' ' + msg)
            for line in self.getWrap(text, self._settings['line_length'], self._settings['min_wrap_length']):
                self.write(self.getCommand('yell', message=line, big_msg_duration=int(float(self._settings['big_msg_duration']))))
 
 
    def kick(self, client, reason='', admin=None, silent=False, *kwargs):
        self.debug('kick reason: [%s]' % reason)
        if isinstance(client, str):
            self.write(self.getCommand('kick', cid=client, reason=reason[:80]))
            return
 
        if admin:
            fullreason = self.getMessage('kicked_by', self.getMessageVariables(client=client, reason=reason, admin=admin))
        else:
            fullreason = self.getMessage('kicked', self.getMessageVariables(client=client, reason=reason))
        fullreason = self.stripColors(fullreason)
        reason = self.stripColors(reason)
 
        if self.PunkBuster:
            self.PunkBuster.kick(client, 0.5, reason)
 
        self.write(self.getCommand('kick', cid=client.cid, reason=reason[:80]))
 
        if not silent and fullreason != '':
            self.say(fullreason)
 
 
    def message(self, client, text):
        try:
            if client is None:
                self.say(text)
            elif client.cid is None:
                pass
            else:
                cmd_name = 'bigmessage' if self._settings['big_b3_private_responses'] else 'message'
                self.write(self.getCommand(cmd_name, message=text, cid=client.cid, big_msg_duration=int(float(self._settings['big_msg_duration']))))
        except Exception, err:
            self.warning(err)
 
 
    def ban(self, client, reason='', admin=None, silent=False, *kwargs):
        """Permanent ban"""
        self.debug('BAN : client: %s, reason: %s', client, reason)
        if isinstance(client, basestring):
            traceback.print_stack() # TODO: remove this stack trace when we figured out when tempban is called with a str as client
            self.write(self.getCommand('banByName', name=client, reason=reason[:80]))
            return
 
        if admin:
            fullreason = self.getMessage('banned_by', self.getMessageVariables(client=client, reason=reason, admin=admin))
        else:
            fullreason = self.getMessage('banned', self.getMessageVariables(client=client, reason=reason))
        fullreason = self.stripColors(fullreason)
        reason = self.stripColors(reason)
 
        if self.ban_with_server:
            if client.cid is None:
                # ban by ip, this happens when we !permban @xx a player that is not connected
                self.debug('EFFECTIVE BAN : %s',self.getCommand('ban', guid=client.guid, reason=reason[:80]))
                try:
                    self.write(self.getCommand('ban', guid=client.guid, reason=reason[:80]))
                    self.write(('banList.save',))
                    if admin:
                        admin.message('banned: %s (@%s) has been added to banlist' % (client.exactName, client.id))
                except CommandFailedError, err:
                    self.error(err)
            elif not client.guid:
                # ban by name
                self.debug('EFFECTIVE BAN : %s',self.getCommand('banByName', name=client.name, reason=reason[:80]))
                try:
                    self.write(self.getCommand('banByName', name=client.name, reason=reason[:80]))
                    self.write(('banList.save',))
                    if admin:
                        admin.message('banned: %s (@%s) has been added to banlist' % (client.exactName, client.id))
                except CommandFailedError, err:
                    self.error(err)
            else:
                # ban by guid
                self.debug('EFFECTIVE BAN : %s',self.getCommand('ban', guid=client.guid, reason=reason[:80]))
                try:
                    self.write(self.getCommand('ban', guid=client.guid, reason=reason[:80]))
                    self.write(('banList.save',))
                    if admin:
                        admin.message('banned: %s (@%s) has been added to banlist' % (client.exactName, client.id))
                except CommandFailedError, err:
                    self.error(err)
 
        if self.PunkBuster:
            self.PunkBuster.banGUID(client, reason)
            # Also issue a server kick in case we do not ban with the server and punkbuster fails
            if client.cid: # only if client is currently connected
                self.write(self.getCommand('kick', cid=client.cid, reason=reason[:80]))
 
        if not silent and fullreason != '':
            self.say(fullreason)
 
        self.queueEvent(b3.events.Event(b3.events.EVT_CLIENT_BAN, {'reason': reason, 'admin': admin}, client))
 
 
    def unban(self, client, reason='', admin=None, silent=False, *kwargs):
        self.debug('UNBAN: Name: %s, Ip: %s, Guid: %s' %(client.name, client.ip, client.guid))
        if client.ip:
            try:
                response = self.write(self.getCommand('unbanByIp', ip=client.ip, reason=reason), needConfirmation=True)
                self.write(('banList.save',))
                #self.verbose(response)
                self.verbose('UNBAN: Removed ip (%s) from banlist' %client.ip)
                if admin:
                    admin.message('Unbanned: %s. His last ip (%s) has been removed from banlist.' % (client.exactName, client.ip))
                if admin:
                    fullreason = self.getMessage('unbanned_by', self.getMessageVariables(client=client, reason=reason, admin=admin))
                else:
                    fullreason = self.getMessage('unbanned', self.getMessageVariables(client=client, reason=reason))
                if not silent and fullreason != '':
                    self.say(fullreason)
            except CommandFailedError, err:
                if "NotInList" in err.message:
                    pass
                else:
                    raise
 
        try:
            response = self.write(self.getCommand('unban', guid=client.guid, reason=reason), needConfirmation=True)
            self.write(('banList.save',))
            #self.verbose(response)
            self.verbose('UNBAN: Removed guid (%s) from banlist' %client.guid)
            if admin:
                admin.message('Unbanned: Removed %s guid from banlist' % client.exactName)
        except CommandFailedError, err:
            if "NotInList" in err.message:
                pass
            else:
                raise
 
        if self.PunkBuster:
            self.PunkBuster.unBanGUID(client)
 
 
    def tempban(self, client, reason='', duration=2, admin=None, silent=False, *kwargs):
        duration = b3.functions.time2minutes(duration)
 
        if isinstance(client, basestring):
            traceback.print_stack() # TODO: remove this stack trace when we figured out when tempban is called with a str as client
            self.write(self.getCommand('tempbanByName', name=client, duration=duration*60, reason=reason[:80]))
            return
 
        if admin:
            fullreason = self.getMessage('temp_banned_by', self.getMessageVariables(client=client, reason=reason, admin=admin, banduration=b3.functions.minutesStr(duration)))
        else:
            fullreason = self.getMessage('temp_banned', self.getMessageVariables(client=client, reason=reason, banduration=b3.functions.minutesStr(duration)))
        fullreason = self.stripColors(fullreason)
        reason = self.stripColors(reason)
 
        if self.PunkBuster:
            if client.cid is not None: # only if player is currently on the server
                # punkbuster acts odd if you ban for more than a day
                # tempban for a day here and let b3 re-ban if the player
                # comes back
                if duration > 1440:
                    duration = 1440
                self.PunkBuster.kick(client, duration, reason)
                # Also issue a server kick in case we do not ban with the server and punkbuster fails
                self.write(self.getCommand('kick', cid=client.cid, reason=reason[:80]))
 
        if self.ban_with_server:
            if client.cid is None:
                # ban by ip, this happens when we !tempban @xx a player that is not connected
                try:
                    self.write(self.getCommand('tempban', guid=client.guid, duration=duration*60, reason=reason[:80]))
                    self.write(('banList.save',))
                except CommandFailedError, err:
                    if admin:
                        admin.message("server replied with error %s" % err.message[0])
                    else:
                        self.error(err)
            elif not client.guid:
                try:
                    self.write(self.getCommand('tempbanByName', name=client.name, duration=duration*60, reason=reason[:80]))
                    self.write(('banList.save',))
                except CommandFailedError, err:
                    if admin:
                        admin.message("server replied with error %s" % err.message[0])
                    else:
                        self.error(err)
            else:
                try:
                    self.write(self.getCommand('tempban', guid=client.guid, duration=duration*60, reason=reason[:80]))
                    self.write(('banList.save',))
                except CommandFailedError, err:
                    if admin:
                        admin.message("server replied with error %s" % err.message[0])
                    else:
                        self.error(err)
 
        if not silent and fullreason != '':
            self.say(fullreason)
 
        self.queueEvent(b3.events.Event(b3.events.EVT_CLIENT_BAN_TEMP, {'reason': reason, 
                                                              'duration': duration, 
                                                              'admin': admin}
                                        , client))
 
 
    def getMap(self):
        """Return the current level name (not easy map name)"""
        self.getServerInfo()
        return self.game.mapName
 
 
    def getMaps(self):
        """Return the map list for the current rotation. (as easy map names)
        This does not return all available maps
        """
        response = []
        for map_list in self.getFullMapRotationList():
            map_id = map_list['name']
            gamemode_id = map_list['gamemode']
            number_of_rounds = map_list['num_of_rounds']
            data = '%s (%s) %s round%s' % (self.getEasyName(map_id), self.getGameMode(gamemode_id), number_of_rounds, 's' if number_of_rounds>1 else '')
            response.append(data)
        return response
 
 
    def rotateMap(self):
        """\
        load the next map/level
        """
        maplist = self.getFullMapRotationList()
        if not len(maplist):
            # maplist is empty, fix this situation by loading save mapList from disk
            try:
                self.write(('mapList.load',))
            except Exception, err:
                self.warning(err)
            maplist = self.getFullMapRotationList()
            if not len(maplist):
                # maplist is still empty, fix this situation by adding current map to map list
                current_max_rounds = self.write(('mapList.getRounds',))[1]
                self.write(('mapList.add', self.game.mapName, self.game.gameType, current_max_rounds, 0))
        mapIndices = self.write(('mapList.getMapIndices', ))
        self.write(('mapList.setNextMapIndex', mapIndices[1]))
        self.write(('mapList.runNextRound',))
        # we create a EVT_GAME_ROUND_END event as the game server won't make one.
        # https://github.com/courgette/big-brother-bot/issues/52
        self.queueEvent(b3.events.Event(b3.events.EVT_GAME_ROUND_END, data=None))
 
 
    def changeMap(self, map_name, gamemode_id=None, number_of_rounds=2):
        """\
        load a given map/level
        return a list of suggested map names in cases it fails to recognize the map that was provided
 
        1) determine the level name
            If map is of the form 'Levels/MP_001' and 'Levels/MP_001' is a supported
            level for the current game mod, then this level is loaded.
 
            In other cases, this method assumes it is given a 'easy map name' (like
            'Port Valdez') and it will do its best to find the level name that seems
            to be for 'Port Valdez' within the supported levels.
 
            If no match is found, then instead of loading the map, this method 
            returns a list of candidate map names
 
        2) if we got a level name
            if the level is not in the current rotation list, then add it to 
            the map list and load it
        """
        map_id = self.getMapIdByName(map_name)
        mapList = self.getFullMapRotationList()
        target_gamemode_id = gamemode_id if gamemode_id else self.game.gameType
 
        # we want to find the next index to set for mapList
        nextMapListIndex = None
 
        # simple case : mapList is empty. Then just add our map at index 0 and load it
        if not len(mapList):
            nextMapListIndex = 0
            self.write(('mapList.add', map_id, target_gamemode_id, number_of_rounds, nextMapListIndex))
        else:
            # the wanted map could already be in the rotation list (if gamemode specified)
            if gamemode_id is not None:
                maps_for_current_gamemode = mapList.getByNameAndGamemode(map_id, gamemode_id)
                if len(maps_for_current_gamemode):
                    nextMapListIndex = maps_for_current_gamemode.keys()[0]
 
            # or it could be in map rotation list for another gamemode
            if nextMapListIndex is None:
                filtered_mapList = mapList.getByName(map_id)
                if len(filtered_mapList):
                    nextMapListIndex = filtered_mapList.keys()[0]
 
            # or map is not found in mapList and we need to insert it after the index of the current map
            current_index = self.write(('mapList.getMapIndices',))[0]
            nextMapListIndex = int(current_index) + 1
            self.write(('mapList.add', map_id, target_gamemode_id, number_of_rounds, nextMapListIndex))
 
        # now we have a nextMapListIndex correctly set to the wanted map
        self.write(('mapList.setNextMapIndex', nextMapListIndex))
        self.say('Changing map to %s (%s) %s round%s' % (self.getEasyName(map_id), self.getGameMode(target_gamemode_id), number_of_rounds, 's' if number_of_rounds>1 else ''))
        time.sleep(1)
        self.write(('mapList.runNextRound',))
 
 
    def getPlayerPings(self):
        """Ask the server for a given client's pings
        """
        raise NotImplementedError
 
 
    def getPlayerScores(self):
        """Ask the server for a given client's team
        """
        scores = {}
        try:
            pib = PlayerInfoBlock(self.write(('admin.listPlayers', 'all')))
            for p in pib:
                scores[p['name']] = int(p['score'])
        except Exception, e:
            self.debug('Unable to retrieve scores from playerlist (%r)' % e)
        return scores
 
 
    ###############################################################################################
    #
    #    Other methods
    #    
    ###############################################################################################
 
    def getMapIdByName(self, map_name):
        """accepts partial name and tries its best to get the one map id. If confusion, return
        suggestions as a list"""
        supportedMaps = self.getSupportedMapIds()
        if map_name not in supportedMaps:
            return self.getMapsSoundingLike(map_name)
        else:
            return map_name
 
 
    def yell(self, client, text):
        """yell text to a given client"""
        try:
            if client is None:
                self.saybig(text)
            elif client.cid is None:
                pass
            else:
                self.write(self.getCommand('bigmessage', message=text, cid=client.cid, big_msg_duration=int(float(self._settings['big_msg_duration']))))
        except Exception, err:
            self.warning(err)
 
    def getFullMapRotationList(self):
        """query the Frostbite2 game server and return a MapListBlock containing all maps of the current
         map rotation list.
        """
        response = MapListBlock()
        offset = 0
        tmp = self.write(('mapList.list', offset))
        tmp_num_maps = len(MapListBlock(tmp))
        while tmp_num_maps:
            response.append(tmp)
            tmp = self.write(('mapList.list', len(response)))
            tmp_num_maps = len(MapListBlock(tmp))
        return response
 
    def getFullBanList(self):
        """query the Frostbite2 game server and return a BanlistContent object containing all bans stored on the game
        server memory.
        """
        response = BanlistContent()
        offset = 0
        tmp = self.write(('banList.list', offset))
        tmp_num_bans = len(BanlistContent(tmp))
        while tmp_num_bans:
            response.append(tmp)
            tmp = self.write(('banList.list', len(response)))
            tmp_num_bans = len(BanlistContent(tmp))
        return response
 
    def getHardName(self, mapname):
        """ Change human map name to map id """
        raise NotImplementedError('getHardName must be implemented in concrete classes')
 
    def getEasyName(self, mapname):
        """ Change map id to map human name """
        raise NotImplementedError('getEasyName must be implemented in concrete classes')
 
    def getGameMode(self, gamemode):
        """ Get gamemode name by id"""
        raise NotImplementedError('getGameMode must be implemented in concrete classes')
 
    def getGameModeId(self, gamemode_name):
        """ Get gamemode id by name """
        raise NotImplementedError('getGameModeId must be implemented in concrete classes')
 
    def getCvar(self, cvarName):
        """Read a server var"""
        if cvarName not in self._gameServerVars:
            self.warning('unknown cvar \'%s\'' % cvarName)
            return None
 
        try:
            words = self.write(('vars.%s' % cvarName,))
        except CommandFailedError, err:
            self.warning(err)
            return
        self.debug('Get cvar %s = %s', cvarName, words)
 
        if words:
            if len(words) == 0:
                return b3.cvar.Cvar(cvarName, value=None)
            else:
                return b3.cvar.Cvar(cvarName, value=words[0])
        return None
 
 
    def setCvar(self, cvarName, value):
        """Set a server var"""
        if cvarName not in self._gameServerVars:
            self.warning('cannot set unknown cvar \'%s\'' % cvarName)
            return
        self.debug('Set cvar %s = \'%s\'', cvarName, value)
        try:
            self.write(('vars.%s' % cvarName, value))
        except CommandFailedError, err:
            self.warning(err)
 
    def checkVersion(self):
        raise NotImplementedError('checkVersion must be implemented in concrete classes')
 
    def getServerVars(self):
        raise NotImplementedError('getServerVars must be implemented in concrete classes')
 
    def getClient(self, cid, guid=None):
        """Get a connected client from storage or create it
        B3 CID   <--> ingame character name
        B3 GUID  <--> EA_guid
        """
        raise NotImplementedError('getClient must be implemented in concrete classes')
 
    def getTeam(self, team):
        """convert frostbite team numbers to B3 team numbers"""
        raise NotImplementedError('getTeam must be implemented in concrete classes')
 
    def getServerInfo(self):
        """query server info, update self.game and return query results
        """
        raise NotImplementedError('getServerInfo must be implemented in concrete classes')
 
 
    def getNextMap(self):
        """Return the name of the next map and gamemode"""
        maps = self.getFullMapRotationList()
        if not len(maps):
            next_map_name = self.game.mapName
            next_map_gamemode = self.game.gameType
            number_of_rounds = int(self.game.serverinfo['roundsTotal'])
        else:
            mapIndices = self.write(('mapList.getMapIndices', ))
            next_map_info = maps[int(mapIndices[1])]
            next_map_name = next_map_info['name']
            next_map_gamemode = next_map_info['gamemode']
            number_of_rounds = next_map_info['num_of_rounds']
        return '%s (%s) %s round%s' % (self.getEasyName(next_map_name), self.getGameMode(next_map_gamemode), number_of_rounds, 's' if number_of_rounds>1 else '')
 
    def getSupportedMapIds(self):
        """return a list of supported levels"""
        # TODO : test this once the command work in BF3
        # TODO : to test this latter, remove getSupportedMapIds from bf3.py
        return self.write(('mapList.availableMaps',))
 
    def getSupportedGameModesByMapId(self, map_id):
        """return a list of supported game modes for the given map id"""
        raise NotImplementedError('getServerInfo must be implemented in concrete classes')
 
    def getMapsSoundingLike(self, mapname):
        """found matching level names for the given mapname (which can either
        be a level name or map name)
        If no exact match is found, then return close candidates as a list
        """
        supportedMaps = self.getSupportedMapIds()
        clean_map_name = mapname.strip().lower()
 
        supportedEasyNames = {}
        for m in supportedMaps:
            supportedEasyNames[self.getEasyName(m).lower()] = m
 
        if clean_map_name in supportedEasyNames:
            return self.getHardName(clean_map_name)
 
        matches = getStuffSoundingLike(mapname, supportedEasyNames.keys())
        if len(matches) == 1:
            # one match, get the map id
            return supportedEasyNames[matches[0]]
        else:
            # multiple matches, provide human friendly suggestions
            return matches[:3]
 
 
    def getGamemodeSoundingLike(self,  map_id, gamemode_name):
        """found the gamemode id for the given gamemode name (which can either
        be a gamemode id or name)
        If no exact match is found, then return close candidates gamemode names
        """
        supported_gamemode_ids = self.getSupportedGameModesByMapId(map_id)
        clean_gamemode_name = gamemode_name.strip().lower()
 
        # try to find exact matches
        for _id in supported_gamemode_ids:
            if clean_gamemode_name == _id.lower():
                return _id
            elif clean_gamemode_name == self.getGameMode(_id).lower():
                return _id
 
        supported_gamemode_names = map(self.getGameMode, supported_gamemode_ids)
 
        shortnames = {
            'cq': 'conquest',
            'cq64': 'conquest64',
            'tdm': 'team deathmatch',
            'sqdm': 'squad deathmatch',
            'ctf': 'capture the flag'
        }
        clean_gamemode_name = shortnames.get(clean_gamemode_name, clean_gamemode_name)
 
        matches = getStuffSoundingLike(clean_gamemode_name, supported_gamemode_names)
        if len(matches) == 1:
            # one match, get the gamemode id
            return self.getGameModeId(matches[0])
        else:
            # multiple matches, provide human friendly suggestions
            return matches[:3]
 
 
    def load_conf_ban_agent(self):
        """setting up ban agent"""
        self.PunkBuster = None
        self.ban_with_server = True
        if self.config.has_option('server', 'ban_agent'):
            ban_agent = self.config.get('server', 'ban_agent')
            if ban_agent is None or ban_agent.lower() not in ('server', 'punkbuster', 'both'):
                self.warning("unexpected value '%s' for ban_agent config option. Expecting one of 'server', 'punkbuster', 'both'." % ban_agent)
            else:
                if ban_agent.lower() == 'server':
                    self.PunkBuster = None
                    self.ban_with_server = True
                    self.info("ban_agent is 'server' -> B3 will ban using the game server banlist")
                elif ban_agent.lower() == 'punkbuster':
                    from b3.parsers.frostbite2.punkbuster import PunkBuster
                    self.PunkBuster = PunkBuster(console=self)
                    self.ban_with_server = False
                    self.info("ban_agent is 'punkbuster' -> B3 will ban using the punkbuster banlist")
                elif ban_agent.lower() == 'both':
                    from b3.parsers.frostbite2.punkbuster import PunkBuster
                    self.PunkBuster = PunkBuster(console=self)
                    self.ban_with_server = True
                    self.info("ban_agent is 'both' -> B3 will ban using both the game server banlist and punkbuster")
                else:
                    self.error("unexpected value '%s' for ban_agent" % ban_agent)
        self.info("ban agent 'server' : %s" % ('activated' if self.ban_with_server else 'deactivated'))
        self.info("ban agent 'punkbuster' : %s" % ('activated' if self.PunkBuster else 'deactivated'))
 
 
    def load_conf_big_b3_private_responses(self):
        """load setting big_b3_private_responses from config"""
        default_value = False
        if self.config.has_option(self.gameName, 'big_b3_private_responses'):
            try:
                self._settings['big_b3_private_responses'] = self.config.getboolean(self.gameName, 'big_b3_private_responses')
                self.info("value for setting %s.big_b3_private_responses is " % self.gameName + (
                    'ON' if self._settings['big_b3_private_responses'] else 'OFF'))
            except ValueError, err:
                self._settings['big_b3_private_responses'] = default_value
                self.warning("Invalid value. %s. Using default value '%s'" % (err, default_value))
        else:
            self._settings['big_b3_private_responses'] = default_value
 
 
    def load_conf_big_msg_duration(self):
        """load setting big_msg_duration from config"""
        default_value = 4
        if self.config.has_option(self.gameName, 'big_msg_duration'):
            try:
                self._settings['big_msg_duration'] = self.config.getint(self.gameName, 'big_msg_duration')
                self.info("value for setting %s.big_msg_duration is %s" % (self.gameName, self._settings['big_msg_duration']))
            except ValueError, err:
                self._settings['big_msg_duration'] = default_value
                self.warning("Invalid value. %s. Using default value '%s'" % (err, default_value))
        else:
            self._settings['big_msg_duration'] = default_value
 
 
    def load_config_message_delay(self):
        if self.config.has_option(self.gameName, 'message_delay'):
            try:
                delay_sec = self.config.getfloat(self.gameName, 'message_delay')
                if delay_sec > 3:
                    self.warning('message_delay cannot be greater than 3')
                    delay_sec = 3
                if delay_sec < .5:
                    self.warning('message_delay cannot be less than 0.5 second.')
                    delay_sec = .5
                self._settings['message_delay'] = delay_sec
            except Exception, err:
                self.error(
                    'failed to read message_delay setting "%s" : %s' % (self.config.get(self.gameName, 'message_delay'), err))
        self.debug('message_delay: %s' % self._settings['message_delay'])
 
 
    def load_conf_max_say_line_length(self):
        if self.config.has_option(self.gameName, 'max_say_line_length'):
            try:
                maxlength = self.config.getint(self.gameName, 'max_say_line_length')
                if maxlength > self.SAY_LINE_MAX_LENGTH:
                    self.warning('max_say_line_length cannot be greater than %s' % self.SAY_LINE_MAX_LENGTH)
                    maxlength = self.SAY_LINE_MAX_LENGTH
                if maxlength < 20:
                    self.warning('max_say_line_length is way too short. using minimum value 20')
                    maxlength = 20
                self._settings['line_length'] = maxlength
                self._settings['min_wrap_length'] = maxlength
            except Exception, err:
                self.error('failed to read max_say_line_length setting "%s" : %s' % (
                    self.config.get(self.gameName, 'max_say_line_length'), err))
        self.debug('line_length: %s' % self._settings['line_length'])
 
 
def patch_b3_clients():
    #############################################################
    # Below is the code that change a bit the b3.clients.Client
    # class at runtime. What the point of coding in python if we
    # cannot play with its dynamic nature ;)
    #
    # why ?
    # because doing so make sure we're not broking any other
    # working and long tested parser. The changes we make here
    # are only applied when the frostbite parser is loaded.
    #############################################################
 
    ## add a new method to the Client class
    def frostbiteClientMessageQueueWorker(self):
        """
        This take a line off the queue and displays it
        then pause for 'message_delay' seconds
        """
        while self.messagequeue and not self.messagequeue.empty():
            if not self.connected:
                break
            msg = self.messagequeue.get()
            if msg:
                self.console.message(self, msg)
                if self.connected:
                    time.sleep(float(self.console._settings.get('message_delay', 1)))
    b3.clients.Client.messagequeueworker = frostbiteClientMessageQueueWorker
 
    ## override the Client.message() method at runtime
    def frostbiteClientMessageMethod(self, msg):
        if msg and len(msg.strip())>0:
            # do we have a queue?
            if not hasattr(self, 'messagequeue'):
                self.messagequeue = Queue.Queue()
            # fill the queue
            text = self.console.stripColors(self.console.msgPrefix + ' [pm] ' + msg)
            for line in self.console.getWrap(text, self.console._settings['line_length'], self.console._settings['min_wrap_length']):
                self.messagequeue.put(line)
            # create a thread that executes the worker and pushes out the queue
            if not hasattr(self, 'messagehandler') or not self.messagehandler.isAlive():
                self.messagehandler = threading.Thread(target=self.messagequeueworker, name="%s_messagehandler" % self)
                self.messagehandler.setDaemon(True)
                self.messagehandler.start()
            else:
                self.console.verbose('messagehandler for %s isAlive' %self.name)
    b3.clients.Client.message = frostbiteClientMessageMethod
 
    original_client_disconnect_method = b3.clients.Client.disconnect
    def frostbiteClientDisconnect(self):
        original_client_disconnect_method(self)
        if hasattr(self, 'messagequeue'):
            self.messagequeue = None
        if hasattr(self, 'messagehandler') and self.messagehandler:
            self.console.debug("waiting for %s.messageQueueWorker thread to finish" % self)
            self.messagehandler.join()
            self.console.debug("%s.messageQueueWorker thread finished" % self)
 
    b3.clients.Client.disconnect = frostbiteClientDisconnect
 
    ## override the Client.yell() method at runtime
    def frostbiteClientYellMethod(self, msg):
        if msg and len(msg.strip())>0:
            text = self.console.stripColors(self.console.msgPrefix + ' [pm] ' + msg)
            for line in self.console.getWrap(text, self.console._settings['line_length'], self.console._settings['min_wrap_length']):
                self.console.write(self.console.getCommand('bigmessage', message=line, cid=self.cid, big_msg_duration=int(float(self.console._settings['big_msg_duration']))))
    b3.clients.Client.yell = frostbiteClientYellMethod