diff --git a/dplib/server.py b/dplib/server.py index 19735d7..9980dc3 100644 --- a/dplib/server.py +++ b/dplib/server.py @@ -15,11 +15,14 @@ # along with this program. If not, see . import re +import select from collections import OrderedDict from enum import Enum +from subprocess import Popen import asyncio import os from socket import socket, AF_INET, SOCK_DGRAM +from time import time from dplib.parse import render_text, decode_ingame_text @@ -37,11 +40,46 @@ class ServerEvent(Enum): ELIM_TEAMS_FLAG = 9 ROUND_STARTED = 10 TEAM_SWITCHED = 11 - GAME_END = 12 + DISCONNECT = 12 + FLAG_GRAB = 13 + FLAG_DROP = 14 + ROUND_END = 15 + GAMEMODE = 16 + GAME_END = 17 + LOG_STATS = 18 + + +class GameMode(Enum): + CTF = 'CTF' + ONE_FLAG = '1Flag' + ELIMINATION = 'Elim' + DEATHMATCH = 'DM' + SIEGE = 'Siege' + TDM = 'TDM' + KOTH = 'KOTH' + PONG = 'Pong' + + @classmethod + def is_valid(cls, gamemode): + return gamemode in cls._value2member_map_ + + @classmethod + def get_list(cls): + return set(cls._value2member_map_) + class BadRconPasswordError(Exception): pass + +class SecurityCheckError(Exception): + pass + + +class MapNotFoundError(Exception): + pass + + class ListenerType(Enum): PERMANENT = 0 TRIGGER_ONCE = 1 @@ -50,19 +88,31 @@ class ListenerType(Enum): REGEXPS = OrderedDict([ (re.compile('^\\[\d\d:\d\d:\d\d\\] (?:(?:\\[OBS\\] )|(?:\\[ELIM\\] ))?(.*?): (.*?)\r?\n'), ServerEvent.CHAT), # [19:54:18] hTml: test - (re.compile('^\\[\d\d:\d\d:\d\d\\] \\*(.*?) \\((.*?)\\) eliminated \\*(.*?) \\((.*?)\\)\r?\n'), ServerEvent.ELIM), + (re.compile( + '^\\[\d\d:\d\d:\d\d\\] \\*(.*?) (?:\\((.*?)\\) eliminated \\*(.*?) \\((.*?)\\)\\.\r?\n|' + 'eliminated ((?:himself)|(?:herself)) with a paintgren\\.\r?\n)'), ServerEvent.ELIM), # [18:54:24] *|ACEBot_1| (Spyder SE) eliminated *|herself| (Spyder SE). + # [12:25:44] *whoa eliminated herself with a paintgren. + # [12:26:09] *whoa eliminated himself with a paintgren. + (re.compile('^\\[\d\d:\d\d:\d\d\\] \\*(.*?)\\\'s (.*?) revived!\r?\n'), ServerEvent.RESPAWN), # [19:03:57] *Red's ACEBot_6 revived! + (re.compile('^\\[\d\d:\d\d:\d\d\\] (.*?) entered the game \\((.*?)\\) \\[(.*?)\\]\r?\n'), ServerEvent.ENTRANCE), - # [19:03:57] mRokita entered the game (build 41) [127.0.0.1:22345] - (re.compile('^\\[\d\d:\d\d:\d\d\\] \\*(.*?)\\\'s (.*?) returned the(?: \\*(.*?))? flag!\r?\n'), ServerEvent.FLAG_CAPTURED), + # [19:03:57] mRokita entered the game (build 41) + + (re.compile('^\\[\d\d:\d\d:\d\d\\] \\*(.*?)\\\'s (.*?) returned the(?: \\*(.*?))? flag!\r?\n' + ), + ServerEvent.FLAG_CAPTURED), # [18:54:24] *Red's hTml returned the *Blue flag! - (re.compile('^\\[\d\d:\d\d:\d\d\\] \\*(.*?)\\\'s (.*?) earned (\d+) points for possesion of eliminated teams flag!'), + + (re.compile('^\\[\d\d:\d\d:\d\d\\] \\*(.*?)\\\'s (.*?) earned (\d+) points for possesion of eliminated teams flag!\r?\n'), ServerEvent.ELIM_TEAMS_FLAG), # [19:30:23] *Blue's mRokita earned 3 points for possesion of eliminated teams flag! + (re.compile('^\\[\d\d:\d\d:\d\d\\] Round started\\.\\.\\.\r?\n'), ServerEvent.ROUND_STARTED), # [10:20:11] Round started... + (re.compile( '(?:^\\[\d\d:\d\d:\d\d\\] (.*?) switched from \\*((?:Red)|(?:Purple)|(?:Blue)|(?:Yellow))' ' to \\*((?:Red)|(?:Purple)|(?:Blue)|(?:Yellow))\\.\r?\n)|' @@ -71,11 +121,85 @@ class ListenerType(Enum): # [10:20:11] mRokita switched from Blue to Red. # [10:20:11] mRokita is now observing. # [10:20:11] mRokita is now observing. - (re.compile('^\\[\d\d:\d\d:\d\d\\] \t\tGameEnd\t.+\t(.*?)\r?\n'), ServerEvent.GAME_END), - # [10:20:11] == Map Loaded: airtime == + + (re.compile('^\[\d\d:\d\d:\d\d\] GameEnd (.*?) (.*?)\r?\n'), ServerEvent.GAME_END), + # [22:40:33] GameEnd 441.9 No winner + # [22:40:33] GameEnd 1032.6 Red:23,Blue:22 + # [22:40:33] GameEnd 4.9 DPBot01 wins! + # [22:40:33] GameEnd 42.9 Yellow:5,Blue:0,Purple:0,Red:0 + # [22:40:33] GameEnd 42.9 Yellow:5,Blue:12,Purple:7 + (re.compile('^\\[\d\d:\d\d:\d\d\\] == Map Loaded: (.+) ==\r?\n'), ServerEvent.MAPCHANGE), - # [19:54:54] name1 changed name to name2. + # [10:20:11] == Map Loaded: airtime == + (re.compile('^\\[\d\d:\d\d:\d\d\\] (.*?) changed name to (.*?)\\.\r?\n'), ServerEvent.NAMECHANGE), + # [19:54:54] name1 changed name to name2. + + (re.compile('^\\[\d\d:\d\d:\d\d\\] (.*?) disconnected\\.\r?\n'), ServerEvent.DISCONNECT), + # [19:03:57] whoa disconnected. + + (re.compile('^\\[\d\d:\d\d:\d\d\\] \\*(.*?) got the(?: \\*(.*?))? flag\\!\r?\n'), ServerEvent.FLAG_GRAB), + # [19:03:57] *whoa got the *Red flag! + + (re.compile('^\\[\d\d:\d\d:\d\d\\] \\*(.*?) dropped the flag\\!\r?\n'), ServerEvent.FLAG_DROP), + # [19:03:57] *whoa dropped the flag! + + (re.compile('^\\[\d\d:\d\d:\d\d\\]( (.*?) team wins the round\\!|' + ' RoundEnd (.*?) (.*?) (.*?))\r?\n'), ServerEvent.ROUND_END), + # [14:38:50] Blue team wins the round! + # [01:14:17] RoundEnd 443.9 Red FlagCapture + + (re.compile('^\\[\d\d:\d\d:\d\d\\] === (' + '(?:Deathmatch)' + '|(?:Team Flag CTF)' + '|(?:Single Flag CTF)' + '|(?:Team Siege)' + '|(?:Team Elim)' + '|(?:Team Siege)' + '|(?:Team Deathmatch)' + '|(?:Team KOTH)' + '|(?:Pong)' + ') ===\r?\n'), ServerEvent.GAMEMODE), + # [09:58:11] === Team Flag CTF === + # [13:16:19] === Team Siege === + # [21:53:54] === Pong === + # [12:21:05] === Deathmatch === + + (re.compile( + '^\[\d\d:\d\d:\d\d\] Stats for (.*?):\r?\n' + ' Weapon: Sho Kil \%Acc\r?\n' + '\[\d\d:\d\d:\d\d\] PGP:\s*(.*?) \s*(.*?) \s*(.*?)\r?\n' + '\[\d\d:\d\d:\d\d\] Trracer:\s*(.*?) \s*(.*?) \s*(.*?)\r?\n' + '\[\d\d:\d\d:\d\d\] Stingray:\s*(.*?) \s*(.*?) \s*(.*?)\r?\n' + '\[\d\d:\d\d:\d\d\] VM\-68:\s*(.*?) \s*(.*?) \s*(.*?)\r?\n' + '\[\d\d:\d\d:\d\d\] Spyder SE:\s*(.*?) \s*(.*?) \s*(.*?)\r?\n' + '\[\d\d:\d\d:\d\d\] Carbine:\s*(.*?) \s*(.*?) \s*(.*?)\r?\n' + '\[\d\d:\d\d:\d\d\] Autococker:\s*(.*?) \s*(.*?) \s*(.*?)\r?\n' + '\[\d\d:\d\d:\d\d\] Automag:\s*(.*?) \s*(.*?) \s*(.*?)\r?\n' + '\[\d\d:\d\d:\d\d\] PaintGren:\s*(.*?) \s*(.*?) \s*(.*?)\r?\n' + '\[\d\d:\d\d:\d\d\] Total kills/shots: (.*?): (.*?)\r?\n' + '\[\d\d:\d\d:\d\d\] Total alive time: (.*?) secs\r?\n' + '\[\d\d:\d\d:\d\d\] Total elim time: (.*?) secs\r?\n' + '\[\d\d:\d\d:\d\d\] Shots/sec: (.*?)\r?\n' + ), ServerEvent.LOG_STATS), + + + #'^\[\d\d:\d\d:\d\d\] Stats for (.*?):\n Weapon: Sho Kil \%Acc\n\[\d\d:\d\d:\d\d\] PGP:\s*(.*?) \s*(.*?) \s*(.*?)\r?\n\[\d\d:\d\d:\d\d\] Trracer:\s*(.*?) \s*(.*?) \s*(.*?)\r?\n\[\d\d:\d\d:\d\d\] Stingray:\s*(.*?) \s*(.*?) \s*(.*?)\r?\n\[\d\d:\d\d:\d\d\] VM\-68:\s*(.*?) \s*(.*?) \s*(.*?)\r?\n\[\d\d:\d\d:\d\d\] Spyder SE:\s*(.*?) \s*(.*?) \s*(.*?)\r?\n\[\d\d:\d\d:\d\d\] Carbine:\s*(.*?) \s*(.*?) \s*(.*?)\r?\n\[\d\d:\d\d:\d\d\] Autococker:\s*(.*?) \s*(.*?) \s*(.*?)\r?\n\[\d\d:\d\d:\d\d\] Automag:\s*(.*?) \s*(.*?) \s*(.*?)\r?\n\[\d\d:\d\d:\d\d\] PaintGren:\s*(.*?) \s*(.*?) \s*(.*?)\r?\n\[\d\d:\d\d:\d\d\] Total kills/shots: (.*?) (.*?)\r?\n\[\d\d:\d\d:\d\d\] Total alive time: (.*?) secs\r?\n\[\d\d:\d\d:\d\d\] Total elim time: (.*?) secs\r?\n\[\d\d:\d\d:\d\d\] Shots/sec: (.*?)\r?\n' + # [10:44:35] Stats for whoa@60fps: + # Weapon: Sho Kil %Acc + # [10:44:35] PGP: 0 0 0.00 + # [10:44:35] Trracer: 0 0 0.00 + # [10:44:35] Stingray: 0 0 0.00 + # [10:44:35] VM-68: 0 0 0.00 + # [10:44:35] Spyder SE: 0 0 0.00 + # [10:44:35] Carbine: 0 0 0.00 + # [10:44:35] Autococker: 11 1 9.09 + # [10:44:35] Automag: 0 0 0.00 + # [10:44:35] PaintGren: 0 0 0.00 + # [10:44:35] Total kills/shots: 1/11: 9.09% + # [10:44:35] Total alive time: 49.3 secs + # [10:44:35] Total elim time: 5.0 secs + # [10:44:35] Shots/sec: 0.22 ]) @@ -98,6 +222,25 @@ def __init__(self, server, id, dplogin, nick, build): self.nick = nick self.build = build + @property + def is_bot(self): + return self.dplogin == 'bot' + + def nick_check(self, o): + if o.nick == self.nick: + return True + + def __eq__(self, o): + if o.id == self.id: + return True + if not type(o) == Player: + return False + if o.dplogin == self.dplogin: + return True + if o.nick == self.nick: + return True + return super().__eq__(o) + class Server(object): """ @@ -109,18 +252,22 @@ class Server(object): :type port: int :param logfile: Path to logfile :param rcon_password: rcon password + :param pty_master: Master of the dp2 process (Linux only, useful only if you want to run the server from your Python script). Go to the getting started section for details. + :type pty_master: int :param init_vars: Send come commands used for security """ - def __init__(self, hostname, port=27910, logfile=None, rcon_password=None, pty_master=None, init_vars=True): + def __init__(self, hostname, port=27910, logfile=None, rcon_password=None, pty_master=None, pty_slave=None, init_vars=True): self.__rcon_password = rcon_password self.__hostname = hostname self.__init_vars = init_vars self.__port = port self.__log_file = None + self.__is_secure = False self.__alive = False self.__logfile_name = logfile if not pty_master else None - self.__pty_master = pty_master + self._pty_master = pty_master + self._pty_slave = pty_slave self.handlers = { ServerEvent.CHAT: 'on_chat', @@ -134,6 +281,12 @@ def __init__(self, hostname, port=27910, logfile=None, rcon_password=None, pty_m ServerEvent.GAME_END: 'on_game_end', ServerEvent.MAPCHANGE: 'on_mapchange', ServerEvent.NAMECHANGE: 'on_namechange', + ServerEvent.DISCONNECT: 'on_disconnect', + ServerEvent.FLAG_GRAB: 'on_flag_grab', + ServerEvent.FLAG_DROP: 'on_flag_drop', + ServerEvent.ROUND_END: 'on_round_end', + ServerEvent.GAMEMODE: 'gamemode', + ServerEvent.LOG_STATS: 'log_stats', } self.__listeners = { ServerEvent.CHAT: [], @@ -147,16 +300,23 @@ def __init__(self, hostname, port=27910, logfile=None, rcon_password=None, pty_m ServerEvent.GAME_END: [], ServerEvent.MAPCHANGE: [], ServerEvent.NAMECHANGE: [], + ServerEvent.DISCONNECT: [], + ServerEvent.FLAG_GRAB: [], + ServerEvent.FLAG_DROP: [], + ServerEvent.ROUND_END: [], + ServerEvent.GAMEMODE: [], + ServerEvent.LOG_STATS: [], } - self.loop = asyncio.get_event_loop() + self.loop = None + @property def is_listening(self): """ Check if the main loop is running. :rtype: bool """ - return self.__alive + return self.loop is not None @asyncio.coroutine def on_chat(self, nick, message): @@ -234,7 +394,7 @@ def on_entrance(self, nick, build, addr): pass @asyncio.coroutine - def on_game_end(self, score_blue, score_red, score_yellow, score_purple): + def on_game_end(self, time, results): """ On game end, can be overriden using the :func:`.Server.event` decorator. @@ -246,7 +406,7 @@ def on_game_end(self, score_blue, score_red, score_yellow, score_purple): pass @asyncio.coroutine - def on_elim(self, killer_nick, killer_weapon, victim_nick, victim_weapon): + def on_elim(self, killer_nick, killer_weapon, victim_nick, victim_weapon, suicide): """ On elim can be overridden using the :func:`.Server.event` decorator. @@ -276,7 +436,7 @@ def on_respawn(self, team, nick): @asyncio.coroutine def on_mapchange(self, mapname): """ - On mapcange, can be overridden using the :func:`.Server.event` decorator. + On map change, can be overridden using the :func:`.Server.event` decorator. :param mapname: Mapname :type mapname: str @@ -296,6 +456,68 @@ def on_namechange(self, old_nick, new_nick): """ pass + @asyncio.coroutine + def on_disconnect(self, nick): + """ + On disconnect, can be overridden using the :func:`.Server.event`decorator. + + :param nick: Disconnected player's nick + :type nick: str + """ + pass + + @asyncio.coroutine + def on_flag_grab(self, nick, flag): + """ + On flag grab, can be overridden using the :func:`.Server.event` decorator. + + :param nick: Player's nick + :type nick: str + :param team: Flag color (Blue|Red|Yellow|Purple) + :type team: str + """ + pass + + @asyncio.coroutine + def on_flag_drop(self, nick): + """ + On flag drop, can be overridden using the :func:`.Server.event` decorator. + + :param nick: Player's nick + :type nick: str + :param team: Flag color (Blue|Red|Yellow|Purple) + :type team: str + """ + pass + + @asyncio.coroutine + def on_round_end(self): + """ + Onround end, can be overridden using the :func:`.Server.event` decorator. + + """ + pass + + @asyncio.coroutine + def gamemode(self, gamemode): + """ + Gamemode, can be overridden using the :func:`.Server.event` decorator. + + :param gamemode: map's gamemode + :type gamemode: str + """ + pass + + @asyncio.coroutine + def log_stats(self, nick, pgp_shots, pgp_kills, pgp_accuracy, trracer_shots, trracer_kills, trracer_accuracy, stingray_shots, stingray_kills, stingray_accuracy, vm_68_shots, vm_68_kills, vm_68_accuracy, spyder_se_shots, spyder_se_kills, spyder_se_accuracy, carbine_shots, carbine_kills, carbine_accuracy, autococker_shots, autococker_kills, autococker_accuracy, automag_shots, automag_kills, automag_accuracy, paintgren_thrown, paintgren_kills, paintgren_accuracy, kills_to_shots, total_accuracy, total_time_alive, total_time_elim, shots_to_sec): + """ + Log stats, can be overridden using the :func:`.Server.event` decorator. + + :param stats: map's stats for a player + :type gamemode: dict + """ + pass + def event(self, func): """ Decorator, used for event registration. @@ -328,6 +550,8 @@ def stop_listening(self): Stop the main loop """ self.__alive = False + self.__is_secure = False + def __perform_listeners(self, event_type, args, kwargs): """ @@ -349,12 +573,10 @@ def __perform_listeners(self, event_type, args, kwargs): for i in reversed(to_remove): self.__listeners[event_type].pop(i) - def nicks_valid(self, *nicks): - nicks_ingame = [p.nick for p in self.get_players()] for nick in nicks: - if not nick in nicks_ingame: + if nick not in nicks_ingame: return False return True @@ -377,10 +599,12 @@ def __handle_event(self, event_type, args): self.__perform_listeners(ServerEvent.CHAT, args, kwargs) elif event_type == ServerEvent.ELIM: kwargs = { - 'killer_nick': args[0], - 'killer_weapon': args[1], - 'victim_nick': args[2], - 'victim_weapon': args[3], + 'killer_nick': args[0], + 'killer_weapon': args[1], + 'victim_nick': args[2], + 'victim_weapon': args[3], + 'suicide': args[4], + } self.__perform_listeners(ServerEvent.ELIM, args, kwargs) elif event_type == ServerEvent.RESPAWN: @@ -402,6 +626,7 @@ def __handle_event(self, event_type, args): 'nick': args[1], 'flag': args[2], } + self.__perform_listeners(ServerEvent.FLAG_CAPTURED, args, kwargs) elif event_type == ServerEvent.ELIM_TEAMS_FLAG: kwargs = { 'team': args[0], @@ -425,27 +650,10 @@ def __handle_event(self, event_type, args): self.__perform_listeners(ServerEvent.TEAM_SWITCHED, new_args, kwargs) elif event_type == ServerEvent.GAME_END: kwargs = { - 'score_blue': None, - 'score_red': None, - 'score_purple': None, - 'score_yellow': None, + 'time': args[0], + 'results': args[1], } - teams = args.split(',') - for t in teams: - data = t.split(':') - if data[0] == 'Blue': - kwargs['score_blue'] = data[1] - elif data[0] == 'Red': - kwargs['score_red'] = data[1] - elif data[0] == 'Yellow': - kwargs['score_yellow'] = data[1] - elif data[0] == 'Purple': - kwargs['score_purple'] = data[1] - self.__perform_listeners(ServerEvent.GAME_END, - (kwargs['score_blue'], - kwargs['score_red'], - kwargs['score_yellow'], - kwargs['score_purple']), kwargs) + self.__perform_listeners(ServerEvent.GAME_END, args, kwargs) elif event_type == ServerEvent.MAPCHANGE: kwargs = { 'mapname': args @@ -458,7 +666,76 @@ def __handle_event(self, event_type, args): } self.__perform_listeners(ServerEvent.NAMECHANGE, (kwargs['old_nick'], kwargs['new_nick']), kwargs) - asyncio.async(self.get_event_handler(event_type)(**kwargs)) + elif event_type == ServerEvent.DISCONNECT: + kwargs = { + 'nick': args + } + self.__perform_listeners(ServerEvent.DISCONNECT, (kwargs['nick'],), kwargs) + + elif event_type == ServerEvent.FLAG_GRAB: + kwargs = { + 'nick': args[0], + 'flag': args[1], + } + self.__perform_listeners(ServerEvent.FLAG_GRAB, (kwargs['nick'], kwargs['flag']), kwargs) + + elif event_type == ServerEvent.FLAG_DROP: + kwargs = { + 'nick': args + } + self.__perform_listeners(ServerEvent.FLAG_GRAB, (kwargs['nick'],), kwargs) + + elif event_type == ServerEvent.ROUND_END: + kwargs = dict() + self.__perform_listeners(ServerEvent.ROUND_END, args, kwargs) + + elif event_type == ServerEvent.GAMEMODE: + kwargs = { + 'gamemode': args + } + self.__perform_listeners(ServerEvent.GAMEMODE, args, kwargs) + + elif event_type == ServerEvent.LOG_STATS: + kwargs = { + 'nick': args[0], + 'pgp_shots': args[1], + 'pgp_kills': args[2], + 'pgp_accuracy': args[3], + 'trracer_shots': args[4], + 'trracer_kills': args[5], + 'trracer_accuracy': args[6], + 'stingray_shots': args[7], + 'stingray_kills': args[8], + 'stingray_accuracy': args[9], + 'vm_68_shots': args[10], + 'vm_68_kills': args[11], + 'vm_68_accuracy': args[12], + 'spyder_se_shots': args[13], + 'spyder_se_kills': args[14], + 'spyder_se_accuracy': args[15], + 'carbine_shots': args[16], + 'carbine_kills': args[17], + 'carbine_accuracy': args[18], + 'autococker_shots': args[19], + 'autococker_kills': args[20], + 'autococker_accuracy': args[21], + 'automag_shots': args[22], + 'automag_kills': args[23], + 'automag_accuracy': args[24], + 'paintgren_thrown': args[25], + 'paintgren_kills': args[26], + 'paintgren_accuracy': args[27], + 'kills_to_shots': args[28], + 'total_accuracy': args[29], + 'total_time_alive': args[30], + 'total_time_elim': args[31], + 'shots_to_sec': args[32], + + } + + self.__perform_listeners(ServerEvent.LOG_STATS, args, kwargs) + + asyncio.ensure_future(self.get_event_handler(event_type)(**kwargs)) def get_event_handler(self, event_type): return getattr(self, self.handlers[event_type]) @@ -482,11 +759,12 @@ def __parse_line(self, line): continue yield from self.__handle_event(event_type=e, args=res) - def rcon(self, command): + def rcon(self, command, socket_timeout=20): """ Execute a console command using RCON. :param command: Command + :param socket_timeout: Timeout for the UDP socket. :return: Response from server @@ -504,11 +782,9 @@ def rcon(self, command): """ sock = socket(AF_INET, SOCK_DGRAM) sock.connect((self.__hostname, self.__port)) - sock.settimeout(3) - sock.send(bytes('\xFF\xFF\xFF\xFFrcon {} {}\n'.format(self.__rcon_password, command), 'latin-1')) + sock.settimeout(socket_timeout) + sock.send(bytes('\xFF\xFF\xFF\xFFrcon {} {}\n'.format(self.__rcon_password, command).encode('latin-1'))) ret = sock.recv(2048).decode('latin-1') - if ret == '\xFF\xFF\xFF\xFFprint\nBad rcon_password.\n': - raise BadRconPasswordError('Bad rcon password') return ret def status(self): @@ -524,6 +800,60 @@ def status(self): sock.send(b'\xFF\xFF\xFF\xFFstatus\n') return sock.recv(2048).decode('latin-1') + def new_map(self, map_name, gamemode=None): + """ + Changes the map using sv newmap . + + :param map_name: map name, without .bsp + :param gamemode: Game mode + :type gamemode: GameMode + + :return: Rcon response + :raise MapNotFoundError: When map is not found on the server + :rtype: str + """ + command = 'sv newmap {map}' + if gamemode: + if not GameMode.is_valid(gamemode): + raise ValueError("Invalid gamemode") + command += ' {gamemode}' + res = self.rcon(command.format(map=map_name, gamemode=gamemode)) + if 'Cannot find mapfile' in res or 'usage' in res: + raise MapNotFoundError + return res + + def add_bot(self, nick=None): + """ + Adds a DP2Bot to the game + + :return: Rcon response + :param nick: Name of the bot (optional) + :rtype: str + """ + command = 'sv addbot' + if nick: + command += ' {name}' + res = self.rcon(command.format(name=nick)) + return res + + def remove_bot(self, nick): + """ + Removes a bot from the server + :param nick: Name of the bot or 'all' + :return: Rcon response + :rtype: str + :raises ValueError: When bot is not on the server + """ + if nick == 'all' or\ + nick in [p.nick for p in self.get_players() if p.is_bot]: + command = 'sv removebot {nick}' + return self.rcon(command.format(nick=nick)) + else: + raise ValueError("Bot \"%s\" is not in the playerlist" % nick) + + + + def permaban(self, ip=None): """ Bans IP address or range of adresses and saves ban list to disk. @@ -564,18 +894,22 @@ def tempoban(self, id=None, nick=None, duration=3): :param id: Player's id :param nick: Player's nick :param duration: Ban duration in minutes (defaults to 3) - + :return: Rcon response :rtype: str """ + if not any([nick, id]): + raise TypeError('Player id or nick is required.') if type(duration) != int: raise TypeError('Ban duration should be an integer, not a ' + str(type(duration))) if nick: - id = self.get_ingame_info(nick).id - if id: - return self.rcon('tban %s %s' % (id, str(duration))) - else: - raise TypeError('Player id or nick is required.') + player = self.get_ingame_info(nick=nick) + elif id: + player = self.get_ingame_info(ingame_id=nick) + if not player: + raise ValueError( + "Player \"%s\" is not in the playerlist" % nick) + return self.rcon('tban %s %s' % (player.id, str(duration))) def remove_tempobans(self): """ @@ -596,12 +930,15 @@ def kick(self, id=None, nick=None): :return: Rcon response :rtype: str """ - if nick: - id = self.get_ingame_info(nick).id - if id: - return self.rcon('kick %s' % id) - else: + if not any([id, nick]): raise TypeError('Player id or nick is required.') + if nick: + player = self.get_ingame_info(nick=nick) + elif id: + player = self.get_ingame_info(id=id) + if not player: + raise ValueError("Player \"%s\" is not in the playerlist" % nick) + return self.rcon('kick %s' % id) def say(self, message): """ @@ -836,22 +1173,19 @@ def wait_for_flag_captured(self, timeout=None, team=None, nick=None, flag=None, return data @asyncio.coroutine - def wait_for_game_end(self, timeout=None, score_blue=None, score_red=None, score_yellow=None, score_purple=None, check=None): + def wait_for_game_end(self, timeout=None, time=None, results=None, check=None): """ Waits for game end. :param timeout: Time to wait for event, if exceeded, returns None. :param score_blue: Blue score - :param score_red: Red score. - :param score_yellow: Yellow score. - :param score_purple: Purple score. :param check: Check function, ignored if none. :return: Returns an empty dict. :rtype: dict """ future = asyncio.Future(loop=self.loop) - margs = (score_blue, score_red, score_yellow, score_purple) + margs = (time, results) predicate = self.__get_predicate(margs, check) self.__listeners[ServerEvent.GAME_END].append((predicate, future)) try: @@ -893,10 +1227,10 @@ def wait_for_mapchange(self, timeout=None, mapname=None, check=None): Waits for mapchange. :param timeout: Time to wait for elimination event, if exceeded, returns None. - :param mapname: Killer's nick to match, ignored if None. + :param mapname: Map name to match. :param check: Check function, ignored if None. - :return: Returns message info dict keys: ('killer_nick', 'killer_weapon', 'victim_nick', 'victim_weapon') + :return: Returns message str with mapname :rtype: dict """ future = asyncio.Future(loop=self.loop) @@ -909,6 +1243,28 @@ def wait_for_mapchange(self, timeout=None, mapname=None, check=None): mapchange_info = None return mapchange_info + @asyncio.coroutine + def wait_for_gamemode(self, timeout=None, gamemode=None, check=None): + """ + Waits for gamemode. + + :param timeout: Time to wait for gamemode event, if exceeded, returns None. + :param gamemode: Game mode to match + :param check: Check function, ignored if None. + + :return: Returns message str with the gamemode + :rtype: dict + """ + future = asyncio.Future(loop=self.loop) + margs = (gamemode,) + predicate = self.__get_predicate(margs, check) + self.__listeners[ServerEvent.GAMEMODE].append((predicate, future)) + try: + gamemode_info = yield from asyncio.wait_for(future, timeout, loop=self.loop) + except asyncio.TimeoutError: + gamemode_info = None + return gamemode_info + @asyncio.coroutine def wait_for_namechange(self, timeout=None, old_nick=None, new_nick=None, check=None): """ @@ -968,6 +1324,29 @@ def on_chat(nick, message): message = None return message + @asyncio.coroutine + def wait_for_flag_drop(self, timeout=None, nick=None, check=None): + """ + Waits for flag drop. + + :param timeout: Time to wait for event, if exceeded, returns None. + :param nick: Player's nick. + :param flag: dropped flag. + :param check: Check function, ignored if none. + + :return: Returns an empty dict. + :rtype: dict + """ + future = asyncio.Future(loop=self.loop) + margs = (nick) + predicate = self.__get_predicate(margs, check) + self.__listeners[ServerEvent.FLAG_DROP].append((predicate, future)) + try: + data = yield from asyncio.wait_for(future, timeout, + loop=self.loop) + except asyncio.TimeoutError: + data = None + return data def start(self, scan_old=False, realtime=True, debug=False): """ @@ -978,35 +1357,85 @@ def start(self, scan_old=False, realtime=True, debug=False): :param realtime: Wait for incoming logfile data :type realtime: bool """ - if not (self.__logfile_name or self.__pty_master): - raise AttributeError("Logfile name or PTY slave is required.") + if not (self.__logfile_name or self._pty_master): + raise AttributeError("Logfile name or a Popen process is required.") self.__alive = True - self.__log_file = open(self.__logfile_name, 'rb') if self.__logfile_name else None - if not scan_old and self.__log_file: + + if self.__logfile_name: + self.__log_file = open(self.__logfile_name, 'rb') + + if self.__log_file and scan_old: self.__log_file.readlines() - if self.__pty_master: - buf = '' + + buf = '' if realtime: while self.__alive: - if self.__log_file: - line = self.__log_file.readline().decode('latin-1') - elif self.__pty_master: - if '\n' not in buf: - buf += os.read(self.__pty_master, 128).decode('latin-1') - l = buf.splitlines(keepends=True) - if l and '\n' in l[0]: - line = l[0] - buf = ''.join(l[1:]) + try: + buf += self._read_log() + lines = buf.splitlines(True) + line = '' + start_pattern = re.compile("\[\d\d:\d\d:\d\d\] Stats for (.*?):\r?\n") + end_pattern = re.compile("\[\d\d:\d\d:\d\d\] Shots\/sec: (.*?)\r?\n") + for line in lines: + if start_pattern.match(line): + # if the line matches the start pattern for multiline text block from g_writestats + # read the log line by line and keep adding them to buffer, + # till a match with the end pattern + while True: + buf += self._read_log() + if end_pattern.match(buf.splitlines(True)[-1]): + line = buf + buf = '' + break + if line and line[-1] != '\n': + continue + if debug: + print("[DPLib] %s" % line.strip()) + yield from self.__parse_line(line) + if not line or line[-1] != '\n': + buf = line else: - line = None - if line: - if debug: - print("[DPLib] %s" % line.strip()) - yield from self.__parse_line(line) - yield from asyncio.sleep(0.05) + buf = '' + yield from asyncio.sleep(0.05) + except OSError as e: + raise e + yield from self._perform_cleanup() + @asyncio.coroutine + def _perform_cleanup(self): + if self.loop: + tasks = [t for t in asyncio.Task.all_tasks() if t is not + asyncio.Task.current_task()] + [task.cancel() for task in tasks] + yield from asyncio.gather(*tasks) if self.__log_file: - self.__log_file.close() + try: + self.__log_file.close() + except OSError: + pass + if self._pty_master: + try: + os.close(self._pty_master) + except OSError: + pass + if self._pty_slave: + try: + os.close(self._pty_slave) + except OSError: + pass + + def _read_log(self): + try: + if self.__log_file: + return self.__log_file.readline().decode('latin-1') + elif self._pty_master: + r, w, x = select.select([self._pty_master], [], [], 0.01) + if r: + return os.read(self._pty_master, 1024).decode('latin-1') + else: + return '' + except OSError: + return '' def get_players(self): """ @@ -1077,21 +1506,98 @@ def get_status(self): dictionary[variables[i]] = variables[i + 1] return dictionary - def get_ingame_info(self, nick): + def get_ingame_info(self, nick=None, ingame_id=None, dplogin=None, player=None): """ Get ingame info about a player with nickname :param nick: Nick - :return: An instance of :class:`.Player` + :param dplogin: Nick + + + :param player: An instance of :class:`.Player` + + :return: An instance of :class:`.Player`, None if not on the server """ + if not any([nick, ingame_id, dplogin, player]): + raise ValueError("At least one argument is required") players = self.get_players() for p in players: - if p.nick == nick: + if nick and p.nick == nick: + return p + if ingame_id and p.id == ingame_id: + return p + if dplogin and p.dplogin == dplogin: + return p + if player and p == player: return p return None - def run(self, scan_old=False, realtime=True, debug=False): + @asyncio.coroutine + def wait_for_ingame_info(self, nick=None, + ingame_id=None, dplogin=None, + player=None, max_tries=5, sleep_interval=.2): + tries = 0 + while True: + info = self.get_ingame_info(nick) + tries += 1 + if info and not info.dplogin and tries < max_tries: + yield from asyncio.sleep(sleep_interval) + else: + return info + + @property + def is_secure(self): + return self.__is_secure + + def make_secure(self, timeout=10): + """ + This function fixes some compatibility and security issues on DP server side + - Adds "mapchange" to sv_blockednames + - Sets sl_logging to 1 + + + All variables are set using the rcon protocol, use this function if you want to wait for the server to start. + + :param timeout: Timeout in seconds + """ + sl_logging_set = False + g_writestats_set = False + sv_blockednames_set = False + self.__is_secure = False + start_time = time() + while not (sl_logging_set and sv_blockednames_set) and time() - start_time < timeout: + try: + if not sl_logging_set: + sl_logging = self.get_cvar('sl_logging') + if sl_logging != '1': + self.set_cvar('sl_logging', '1') + else: + sl_logging_set = True + if not g_writestats_set: + g_writestats = self.get_cvar('g_writestats') + if g_writestats != '1': + self.set_cvar('g_writestats', '1') + else: + g_writestats_set = True + if not sv_blockednames_set: + blockednames = self.get_cvar('sv_blockednames') + + if not 'maploaded' in blockednames: + self.set_cvar('sv_blockednames', ','.join([blockednames, 'maploaded'])) + else: + sv_blockednames_set = True + except ConnectionError or timeout: + pass + if not (sl_logging_set and sv_blockednames_set): + raise SecurityCheckError( + "Configuring the DP server failed," + " check if the server is running " + "and the rcon_password is correct.") + else: + self.__is_secure = True + + def run(self, scan_old=False, realtime=True, debug=False, make_secure=True): """ Runs the main loop using asyncio. @@ -1100,9 +1606,20 @@ def run(self, scan_old=False, realtime=True, debug=False): :param realtime: Wait for incoming logfile data :type realtime: bool """ - if self.__init_vars and self.__rcon_password: - blockednames = self.get_cvar('sv_blockednames') - if not 'maploaded' in blockednames.split(','): - # A player with name "maploaded" would block the mapchange event - self.set_cvar('sv_blockednames', ','.join([blockednames, 'maploaded'])) + if make_secure and not self.__rcon_password: + raise AttributeError( + "Setting the rcon_password is required to secure DPLib." + " You have to either set a rcon_password or add set" + " \"sl_logging 1; set sv_blockednames mapname\" " + "to your DP server config and use Server.run with" + " make_secure=False") + if make_secure: + self.make_secure() + while self.loop: + pass + self.loop = asyncio.new_event_loop() + asyncio.set_event_loop(self.loop) self.loop.run_until_complete(self.start(scan_old, realtime, debug)) + self.loop.close() + self.loop = None +