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
+