diff --git a/README.md b/README.md index cbddf267..1a22202e 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,11 @@ # Killing Floor 2 Magicked Administrator -Scripted management, statistics, and bot for ranked Killing Floor 2 servers. +Scripted management, statistics, and bot for ranked Killing Floor 2 servers. Provides in-game commands, player stat tracking and ranking, live MOTD scoreboard and stats, greeter, and admin functions. Running entirely through the web administrator, it does not affect a server's ranked/custom status. It can be ran either directly on the server or remotely, and manage multiple servers at once. ### Player commands +* !me - display a summary of your stats +* !stats _player_ - display a summary of _player_'s stats * !help - displays the help text in chat +* !info - displays information about this project * !dosh - display the players recorded dosh and rank by dosh * !top\_dosh - displays the players with the highest recorded dosh * !kills - display the players recorded kills and rank by kills @@ -11,34 +14,41 @@ Scripted management, statistics, and bot for ranked Killing Floor 2 servers. * !server\_dosh - displays total dosh earned on the server ### Admin commands -* !difficulty {normal|hard|suicidal|hell} - sets difficulty of next game Example: !difficulty hard -* !length {short|medium|long} - sets length of next game Example: !length medium -* !start\_tc n command - repeat command every n seconds Example: !start\_tc 5 say test +* !difficulty {normal|hard|suicidal|hell} - sets difficulty of next game + - Example: !difficulty hard +* !length {short|medium|long} - sets length of next game + - Example: !length medium +* !start\_tc _n_ _command_ - repeat _command_ every _n_ seconds + - Example: !start\_tc 5 say test * !stop\_tc - stop all timed commands -* !start\_wc n command - run command when wave n is reached. Example: !start\_wc say Wave Started. - This posts a message at EVERY wave start. Example: !start\_wc 4 say Wave 4 Started. - This posts a message when wave 4 starts. +* !start\_wc _n command_ - run _command_ when wave _n_ is reached. + - Example: !start\_wc say Wave Started. - posts a message EVERY wave. + - Example: !start\_wc 4 say Wave 4 Started. - This posts a message when wave 4 starts. * !stop\_wc - stop all wave commands -* !start\_trc command - run command every time the trader opens Example: !start\_trc say Traders open. +* !start\_trc _command_ - run _command_ every time the trader opens + - Example: !start\_trc say Traders open. * !stop\_trc - stop trader commands -* !say mesg - display mesg, for use in conjuction with other admin commands Example: !say This is an example. +* !say _mesg_ - display _mesg_ in the chat, generally for use in conjuction with other commands + - Example: !say This is an example. * !silent - toggles output in chat -* !restart - immidiately restarts the current map -* !toggle\_pass - enables or disables the configured game password (the password you entered in your config) +* !restart - immediately restarts the current map +* !load_map _map_name_ - immediately loads _map_name_ +* !toggle\_pass - toggles the configured game password (specified in `magicked_admin.conf`) ### Other features -* Writing a server_name.motd file with pairs of %PLR and %SCR and enabling the motd_scoreboard option will put a live scoreboard in the motd. -* Writting a server_name.init with a list of commands will run the commands when the bot starts on server_name +* Writing a `server_name.motd` file with pairs of `%PLR` and `%SCR` and enabling the motd_scoreboard option will put a live scoreboard in the motd. + - `%SRV_D` and `%SRV_K` will be replaced by the total dosh and kills on the server respectively. +* Writting a `server_name.init` with a list of commands will run the commands when the bot starts on server_name ## Dependancies/building * Python 3.4+ * cx_freeze * requests * lxml -* configparser -* sqlite3 * colorama * termcolor -build by running the provided scripts `build.bat` or `build.sh` after installing dependancies via pip. Alternatively get a build from the releases page. +build by running the provided scripts `build.bat` or `build.sh` after installing dependancies via pip. ## Minimal configuration If running from source you will need to copy the example configs from the `config` folder to the root folder, the build scripts do this automatically. diff --git a/magicked_admin/chatbot/chatbot.py b/magicked_admin/chatbot/chatbot.py index 00767eb6..915bab3c 100644 --- a/magicked_admin/chatbot/chatbot.py +++ b/magicked_admin/chatbot/chatbot.py @@ -1,19 +1,24 @@ from server.chat.listener import Listener from chatbot.commands.command_map import CommandMap +from chatbot.commands.event_commands import CommandGreeter + +import logging +import sys -import time -import threading -import server from os import path #from FuzzyWuzzy import Fuzz #from FuzzyWuzzy import process -from utils.text import trim_string, millify +logger = logging.getLogger(__name__) +if __debug__ and not hasattr(sys, 'frozen'): + logger.setLevel(logging.DEBUG) +else: + logger.setLevel(logging.INFO) class Chatbot(Listener): - def __init__(self, server): + def __init__(self, server, greeter_enabled=True): self.server = server self.chat = server.chat # The in-game chat can fit 21 Ws horizontally @@ -22,11 +27,12 @@ def __init__(self, server): self.commands = CommandMap(server, self) self.silent = False + self.greeter_enabled = True if path.exists(server.name + ".init"): self.execute_script(server.name + ".init") - print("INFO: Bot on server " + server.name + " initialised") + logger.debug("Bot on server " + server.name + " initialised") def receive_message(self, username, message, admin=False): if message[0] == '!': @@ -38,22 +44,17 @@ def command_handler(self, username, args, admin=False): if args is None or len(args) == 0: return - ''' Put FuzzyWuzzy Here? You said that it might be handy elsewhere, not sure what you want to do with it. - choices = ['restart','toggle_pass','silent','length','difficulty','players','game','help','info','kills', - 'dosh','top_kills','total_kills','top_dosh','me','stats'] - match = process.extractOne(args, choices, scorer= fuzz.ratio, scorecutoff= 90)''' - if args[0].lower() in self.commands.command_map: command = self.commands.command_map[args[0].lower()] + if not self.greeter_enabled and isinstance(command, CommandGreeter): + return response = command.execute(username, args, admin) if not self.silent: self.chat.submit_message(response) - # What would be the best way of handling CD commands? - elif username != "server" and not self.silent: - self.chat.submit_message("Sorry, I didn't understand that request.") def execute_script(self, file_name): - print("INFO: Executing script: " + file_name) + logger.debug("Executing script: " + file_name) + print("Executing script: " + file_name) with open(file_name) as script: for line in script: print("\t\t" + line.strip()) diff --git a/magicked_admin/chatbot/commands/command_map.py b/magicked_admin/chatbot/commands/command_map.py index 8aa4f1ef..edacde56 100644 --- a/magicked_admin/chatbot/commands/command_map.py +++ b/magicked_admin/chatbot/commands/command_map.py @@ -14,8 +14,11 @@ def generate_map(self): wave_event_manager = CommandOnWaveManager(self.server, self.chatbot) trader_event_manager = CommandOnTraderManager(self.server, self.chatbot) time_event_manager = CommandOnTimeManager(self.server, self.chatbot) + greeter = CommandGreeter(self.server) command_map = { + 'new_game': greeter, + 'player_join': greeter, 'stop_wc': wave_event_manager, 'start_wc': wave_event_manager, 'new_wave': wave_event_manager, @@ -27,6 +30,7 @@ def generate_map(self): 't_open': trader_event_manager, 'say': CommandSay(self.server), 'restart': CommandRestart(self.server), + 'load_map': CommandLoadMap(self.server), 'toggle_pass': CommandTogglePassword(self.server), 'silent': CommandSilent(self.server, self.chatbot), 'length': CommandLength(self.server), diff --git a/magicked_admin/chatbot/commands/event_commands.py b/magicked_admin/chatbot/commands/event_commands.py index 20d9997f..42208485 100644 --- a/magicked_admin/chatbot/commands/event_commands.py +++ b/magicked_admin/chatbot/commands/event_commands.py @@ -1,9 +1,74 @@ from chatbot.commands.command import Command +from utils.text import millify +from utils.time import seconds_to_hhmmss -import time import threading +import logging +import sys +import datetime -ALL_WAVES = 99 +ALL_WAVES = 999 + +logger = logging.getLogger(__name__) +if __debug__ and not hasattr(sys, 'frozen'): + logger.setLevel(logging.DEBUG) +else: + logger.setLevel(logging.INFO) + + +class CommandGreeter(Command): + def __init__(self, server, admin_only=True): + Command.__init__(self, server, admin_only) + + self.new_game_grace = 35 + self.new_game_time = datetime.datetime.now() + + def execute(self, username, args, admin): + if not self.authorise(admin): + return self.not_auth_message + + if args[0] == "new_game": + logger.debug("Greeter received new game event") + self.new_game_time = datetime.datetime.now() + return None + now = datetime.datetime.now() + elapsed_time = now - self.new_game_time + seconds = elapsed_time.total_seconds() + + if seconds < self.new_game_grace: + logger.debug("Skipping welcome {}, new_game happened recently ({})" + " [{}/{}]" + .format(username, self.server.name, seconds, + self.new_game_grace)) + return None + + if len(args) < 2: + return "Missing argument (username)" + + requested_username = " ".join(args[1:]) + + player = self.server.get_player(requested_username) + if not player: + logger.debug("DEBUG: Bad player join command (not found) [{}]" + .format(requested_username)) + return "Couldn't greet player {}.".format(requested_username) + + if player.total_logins > 1: + pos_kills = self.server.database.rank_kills(requested_username) + pos_dosh = self.server.database.rank_dosh(requested_username) + return "\nWelcome back {}.\n" \ + "You've killed {} zeds (#{}) and \n" \ + "earned £{} (#{}) \nover {} sessions " \ + "({}).".format(player.username, + millify(player.total_kills), + pos_kills, + millify(player.total_dosh), + pos_dosh, + player.total_logins, + seconds_to_hhmmss(player.total_time))\ + .encode("iso-8859-1", "ignore") + else: + return None class CommandOnWave: diff --git a/magicked_admin/chatbot/commands/info_commands.py b/magicked_admin/chatbot/commands/info_commands.py index 986fec87..08cbc274 100644 --- a/magicked_admin/chatbot/commands/info_commands.py +++ b/magicked_admin/chatbot/commands/info_commands.py @@ -1,6 +1,7 @@ from chatbot.commands.command import Command from server.player import Player from utils.time import seconds_to_hhmmss +from utils.text import millify import datetime @@ -91,16 +92,13 @@ def execute(self, username, args, admin): time = seconds_to_hhmmss( player.total_time + session_time ) - message = "Stats for " + player.username + ":\n" + \ - "Sessions:\t\t\t" + str(player.total_logins) + "\n" + \ - "Play time:\t\t" + time +"\n" + \ - "Deaths:\t\t\t" + str(player.total_deaths) + "\n" + \ - "Kills:\t\t\t\t" + str(player.total_kills) + "\n" + \ - "Dosh earned:\t\t" + str(player.total_dosh) + "\n" + \ - "Dosh spent:\t\t" + str(player.total_dosh_spent) + "\n" + \ - "Health lost:\t\t" + str(player.total_health_lost) + "\n" + \ - "Dosh this game:\t" + str(player.game_dosh) + "\n" + \ - "Kills this wave:\t\t" + str(player.wave_kills) + "\n" + \ - "Dosh this wave:\t" + str(player.wave_dosh) + message = "Stats for {}:\n".format(player.username) +\ + "Total play time: {} ({} sessions)\n"\ + .format(time, player.total_logins) +\ + "Total deaths: {}\n".format(player.total_deaths) +\ + "Total kills: {}\n".format(millify(player.total_kills)) +\ + "Total dosh earned: {}\n"\ + .format(millify(player.total_dosh)) +\ + "Dosh this game: {}".format(millify(player.game_dosh)) return message diff --git a/magicked_admin/chatbot/commands/player_commands.py b/magicked_admin/chatbot/commands/player_commands.py index 67cee6e1..eade70fd 100644 --- a/magicked_admin/chatbot/commands/player_commands.py +++ b/magicked_admin/chatbot/commands/player_commands.py @@ -69,12 +69,18 @@ def execute(self, username, args, admin): if not self.authorise(admin): return self.not_auth_message + if len(args) > 1 and args[1] == '-w' and len(self.server.players) > 0: + self.server.players.sort(key=lambda player: player.wave_kills, reverse=True) + top_killer = self.server.players[0] + return "Player {} killed the most zeds this wave: {} zeds"\ + .format(top_killer.username, top_killer.wave_kills) + self.server.write_all_players() killers = self.server.database.top_kills() if len(killers) < 5: return "Not enough data." # [row][col] - return "\n\nTop 5 players by kills:\n"+ \ + return "\n\nTop 5 players by total kills:\n" + \ "\t"+str(millify(killers[0][1])) + "\t-\t" + trim_string(killers[0][0],20) + "\n" + \ "\t"+str(millify(killers[1][1])) + "\t-\t" + trim_string(killers[1][0],20) + "\n" + \ "\t"+str(millify(killers[2][1])) + "\t-\t" + trim_string(killers[2][0],20) + "\n" + \ @@ -90,12 +96,19 @@ def execute(self, username, args, admin): if not self.authorise(admin): return self.not_auth_message + if len(args) > 1 and args[1] == '-w' and len(self.server.players) > 0: + self.server.players.sort(key=lambda player: player.wave_dosh, reverse=True) + top_dosh = self.server.players[0] + return "Player {} earned the most this wave: £{}"\ + .format(top_dosh.username, millify(top_dosh.wave_dosh))\ + .encode("iso-8859-1", "ignore") + self.server.write_all_players() doshers = self.server.database.top_dosh() if len(doshers) < 5: return "Not enough data." - message = "\n\nTop 5 players by earnings:\n"+ \ + message = "\n\nTop 5 players by earnings:\n" + \ "\t£"+str(millify(doshers[0][1])) + "\t-\t" + trim_string(doshers[0][0],20) + "\n" + \ "\t£"+str(millify(doshers[1][1])) + "\t-\t" + trim_string(doshers[1][0],20) + "\n" + \ "\t£"+str(millify(doshers[2][1])) + "\t-\t" + trim_string(doshers[2][0],20) + "\n" + \ diff --git a/magicked_admin/chatbot/commands/server_commands.py b/magicked_admin/chatbot/commands/server_commands.py index 015f55b8..476dd6db 100644 --- a/magicked_admin/chatbot/commands/server_commands.py +++ b/magicked_admin/chatbot/commands/server_commands.py @@ -31,6 +31,21 @@ def execute(self, username, args, admin): return "Restarting map." +class CommandLoadMap(Command): + def __init__(self, server, admin_only=True): + Command.__init__(self, server, admin_only) + + def execute(self, username, args, admin): + if not self.authorise(admin): + return self.not_auth_message + + if len(args) < 2: + return "Missing argument (map name)" + + self.server.change_map(args[1]) + return "Changing map." + + class CommandTogglePassword(Command): def __init__(self, server, admin_only=True): Command.__init__(self, server, admin_only) @@ -57,7 +72,7 @@ def execute(self, username, args, admin): if self.chatbot.silent: self.chatbot.silent = False - return "Silent mode disabled." + return None else: self.chatbot.command_handler("server", "say Silent mode enabled.", admin=True) @@ -74,11 +89,11 @@ def execute(self, username, args, admin): if len(args) < 2: return "Length not recognised. Options are short, medium, or long." - if args[1] == "short": + if args[1] in ["short", "0"]: length = server.LEN_SHORT - elif args[1] == "medium": + elif args[1] in ["medium", "med", "normal", "1"]: length = server.LEN_NORM - elif args[1] == "long": + elif args[1] in ["long", "2"]: length = server.LEN_LONG else: return "Length not recognised. Options are short, medium, or long." @@ -98,13 +113,13 @@ def execute(self, username, args, admin): return "Difficulty not recognised. " + \ "Options are normal, hard, suicidal, or hell." - if args[1] == "normal": + if args[1] in ["normal", "0"]: difficulty = server.DIFF_NORM - elif args[1] == "hard": + elif args[1] in ["hard", "1"]: difficulty = server.DIFF_HARD - elif args[1] == "suicidal": + elif args[1] in ["suicidal", "sui", "2"]: difficulty = server.DIFF_SUI - elif args[1] == "hell": + elif args[1] in ["hell", "hoe", "hellonearth", "3"]: difficulty = server.DIFF_HOE else: return "Difficulty not recognised. " + \ diff --git a/magicked_admin/config/magicked_admin.conf.example b/magicked_admin/config/magicked_admin.conf.example index 5d9b3819..bd0f1c3a 100644 --- a/magicked_admin/config/magicked_admin.conf.example +++ b/magicked_admin/config/magicked_admin.conf.example @@ -5,4 +5,5 @@ password = 123 game_password = game_password motd_scoreboard = False scoreboard_type = kills +enable_greeter = True diff --git a/magicked_admin/config/server_one.init.example b/magicked_admin/config/server_one.init.example index d04160f2..091bc861 100644 --- a/magicked_admin/config/server_one.init.example +++ b/magicked_admin/config/server_one.init.example @@ -1 +1,4 @@ +silent start_wc 1 say I'm a bot, type !help for usage +start_trc top_dosh -w +silent diff --git a/magicked_admin/config/server_one.motd.example b/magicked_admin/config/server_one.motd.example index 8625637c..895c14a3 100644 --- a/magicked_admin/config/server_one.motd.example +++ b/magicked_admin/config/server_one.motd.example @@ -1,5 +1,7 @@ Welcome to our server. +%SRV_K Zeds killed on this server. + Top Players (total dosh): 1. %PLR [%SCR] 2. %PLR [%SCR] 3. %PLR [%SCR] 4. %PLR [%SCR] 5. %PLR [%SCR] 6. %PLR [%SCR] diff --git a/magicked_admin/database/database.py b/magicked_admin/database/database.py index 24009352..3071f486 100644 --- a/magicked_admin/database/database.py +++ b/magicked_admin/database/database.py @@ -1,6 +1,14 @@ import sqlite3 import datetime +import logging from os import path +import sys + +logger = logging.getLogger(__name__) +if __debug__ and not hasattr(sys, 'frozen'): + logger.setLevel(logging.DEBUG) +else: + logger.setLevel(logging.INFO) class ServerDatabase: @@ -13,10 +21,11 @@ def __init__(self, name): check_same_thread=False) self.cur = self.conn.cursor() - print("INFO: Database for " + name + " initialised") + logger.debug("Database for " + name + " initialised") def build_schema(self): - print("INFO: Building fresh schema...") + print("Building new database...") + logger.debug("Building new database...") conn = sqlite3.connect(self.sqlite_db_file) cur = conn.cursor() @@ -27,14 +36,33 @@ def build_schema(self): conn.commit() conn.close() - def start_session(self, player): - pass + def rank_kills(self, username): + query = "select p1.*"\ + ",("" \ + ""select count(*)" \ + "from players as p2"" \ + ""where p2.kills > p1.kills"\ + ") as kill_rank"" \ + ""from players as p1"" \ + ""where p1.username = ?" + self.cur.execute(query, (username,)) + all_rows = self.cur.fetchall() - def end_session(self, player): - pass + return all_rows[0][-1] + 1 + + def rank_dosh(self, username): + query = "select p1.*"\ + ",("" \ + ""select count(*)" \ + "from players as p2"" \ + ""where p2.dosh > p1.dosh"\ + ") as kill_rank"" \ + ""from players as p1"" \ + ""where p1.username = ?" + self.cur.execute(query, (username,)) + all_rows = self.cur.fetchall() - def end_game(self, game): - pass + return all_rows[0][-1] + 1 # SUM(dosh_spent) Add in later. def server_dosh(self): diff --git a/magicked_admin/main.py b/magicked_admin/main.py index fe117305..309e6fae 100644 --- a/magicked_admin/main.py +++ b/magicked_admin/main.py @@ -4,23 +4,36 @@ from utils.text import str_to_bool import configparser +import logging import sys import signal import os -DEBUG = True +from colorama import init +init() + +logging.basicConfig(stream=sys.stdout) + +logger = logging.getLogger(__name__) +if __debug__ and not hasattr(sys, 'frozen'): + logger.setLevel(logging.DEBUG) +else: + logger.setLevel(logging.INFO) if not os.path.exists("./magicked_admin.conf"): - sys.exit("Configuration file not found.") + logger.error("Configuration file not found") + input("Press enter to exit...") + sys.exit() + config = configparser.ConfigParser() config.read("./magicked_admin.conf") -class MagickedAdministrator(): + +class MagickedAdministrator: def __init__(self): self.servers = [] self.chatbots = [] - self.watchdogs = [] self.motd_updaters = [] signal.signal(signal.SIGINT, self.terminate) @@ -36,6 +49,9 @@ def run(self): config[server_name]["motd_scoreboard"] ) scoreboard_type = config[server_name]["scoreboard_type"] + enable_greeter = str_to_bool( + config[server_name]["enable_greeter"] + ) server = Server(server_name, address, user, password, game_password) @@ -46,21 +62,18 @@ def run(self): motd_updater.start() self.motd_updaters.append(motd_updater) - cb = Chatbot(server) + cb = Chatbot(server, greeter_enabled=enable_greeter) server.chat.add_listener(cb) self.chatbots.append(cb) - print("INFO: Initialisation complete\n") + print("Initialisation complete") def terminate(self, signal, frame): - print("\nINFO: Terminating...") + print("Terminating, saving data...") for server in self.servers: - server.terminate() + server.write_all_players(final=True) - for wd in self.watchdogs: - wd.terminate() - for motd_updater in self.motd_updaters: - motd_updater.terminate() + sys.exit() if __name__ == "__main__": diff --git a/magicked_admin/server/chat/chat.py b/magicked_admin/server/chat/chat.py index 5e80d5ef..32f8c012 100644 --- a/magicked_admin/server/chat/chat.py +++ b/magicked_admin/server/chat/chat.py @@ -1,11 +1,16 @@ import threading import requests +import time +import logging from lxml import html -from colorama import init from termcolor import colored +import sys -# what the fuck does this do -init() +logger = logging.getLogger(__name__) +if __debug__ and not hasattr(sys, 'frozen'): + logger.setLevel(logging.DEBUG) +else: + logger.setLevel(logging.INFO) class ChatLogger(threading.Thread): @@ -23,23 +28,23 @@ def __init__(self, server): self.poll_session = server.new_session() - self.exit_flag = threading.Event() - self.print_messages = True self.silent = False threading.Thread.__init__(self) def run(self): - while not self.exit_flag.wait(self.time_interval): + while True: try: response = self.poll_session.post( self.chat_request_url, self.chat_request_payload, timeout=2 ) - except requests.exceptions.RequestException as e: - print("INFO: Couldn't retrieve chat (RequestException)") + except requests.exceptions.RequestException: + logger.debug("Couldn't retrieve chat (RequestException) ({})" + .format(self.server.name)) + time.sleep(self.time_interval + 3) continue if response.text: @@ -48,22 +53,27 @@ def run(self): for message_html in messages_html: message_tree = html.fromstring(message_html) - try: - # xpath returns a list but theres only ever one of each because i split earlier - username = message_tree.xpath('//span[starts-with(@class,\'username\')]/text()')[0] - user_type = message_tree.xpath('//span[starts-with(@class,\'username\')]/@class')[0] - message = message_tree.xpath('//span[@class="message"]/text()')[0] - admin = True if "admin" in user_type else False - - self.handle_message(username, message, admin) - except IndexError: - # Messages without usernames are not handled correctly. In particular, Controlled Difficulty - # This is basic support for Controlled Difficulty messages. It may need to be expanded to support - # other mutators and mods however. - username = "Controlled Difficulty" - admin = False - message = message_tree.xpath('//span[@class="message"]/text()')[0] - self.handle_message(username, message, admin) + # xpath returns a list but theres only ever one of each because i split earlier + username_arr = message_tree.xpath('//span[starts-with(@class,\'username\')]/text()') + if len(username_arr) < 1: + logger.debug("Message without username '{}' ({})" + .format(message, self.server.name)) + continue + username = username_arr[0] + + user_type_arr = message_tree.xpath('//span[starts-with(@class,\'username\')]/@class') + if len(user_type_arr) < 1: + logger.debug("Message without user type '{}' ({})" + .format(message, self.server.name)) + continue + user_type = user_type_arr[0] + + message = message_tree.xpath('//span[@class="message"]/text()')[0] + admin = True if "admin" in user_type else False + + self.handle_message(username, message, admin) + + time.sleep(self.time_interval) def handle_message(self, username, message, admin): @@ -74,8 +84,6 @@ def handle_message(self, username, message, admin): if command: print_line = colored(print_line, 'green') - elif username == "Controlled Difficulty": - print_line = colored(print_line, 'cyan') else: print_line = colored(print_line, 'yellow') print(print_line) @@ -102,8 +110,5 @@ def submit_message(self, message): try: self.server.session.post(chat_submit_url, message_payload) - except requests.exceptions.RequestException as e: - print("INFO: Couldn't submit message (RequestException)") - - def terminate(self): - self.exit_flag.set() + except requests.exceptions.RequestException: + logger.debug("Couldn't submit message (RequestException)") diff --git a/magicked_admin/server/managers/motd_updater.py b/magicked_admin/server/managers/motd_updater.py index f7963851..16603d15 100644 --- a/magicked_admin/server/managers/motd_updater.py +++ b/magicked_admin/server/managers/motd_updater.py @@ -1,11 +1,20 @@ from os import path import threading import requests +import time +import logging +import sys from lxml import html from utils.text import millify from utils.text import trim_string +logger = logging.getLogger(__name__) +if __debug__ and not hasattr(sys, 'frozen'): + logger.setLevel(logging.DEBUG) +else: + logger.setLevel(logging.INFO) + class MotdUpdater(threading.Thread): @@ -16,12 +25,10 @@ def __init__(self, server, scoreboard_type): self.time_interval = 5 * 60 self.motd = self.load_motd() - self.exit_flag = threading.Event() - threading.Thread.__init__(self) def run(self): - while not self.exit_flag.wait(self.time_interval): + while True: self.server.write_all_players() try: motd_payload = self.get_configuration() @@ -36,21 +43,24 @@ def run(self): except requests.exceptions.RequestException: continue + time.sleep(self.time_interval) + def submit_motd(self, payload): motd_url = "http://" + self.server.address + \ "/ServerAdmin/settings/welcome" - print("INFO: Updating MOTD") + logger.debug("Updating MOTD ({})".format(self.server.name)) try: self.server.session.post(motd_url, data=payload) self.server.save_settings() except requests.exceptions.RequestException: - print("INFO: Couldn't submit motd (RequestException)") + logger.warning("Couldn't submit motd (RequestException) to {}" + .format(self.server.name)) raise def load_motd(self): if not path.exists(self.server.name + ".motd"): - print("WARNING: No motd file for " + self.server.name) + logger.warning("No motd file for " + self.server.name) return "" motd_f = open(self.server.name + ".motd") @@ -64,8 +74,9 @@ def render_motd(self, src_motd): elif self.scoreboard_type in ['Dosh','dosh']: scores = self.server.database.top_dosh() else: - print("ERROR: Bad configuration, scoreboard_type. " + - "Options are: dosh, kills") + logger.error("Bad configuration, scoreboard_type. " + "Options are: dosh, kills ({})" + .format(self.server.name)) return for player in scores: @@ -93,7 +104,7 @@ def get_configuration(self): try: motd_response = self.server.session.get(motd_url, timeout=2) except requests.exceptions.RequestException as e: - print("INFO: Couldn't get motd config(RequestException)") + logger.debug("Couldn't get motd config(RequestException)") raise motd_tree = html.fromstring(motd_response.content) @@ -112,5 +123,3 @@ def get_configuration(self): 'action': 'save' } - def terminate(self): - self.exit_flag.set() diff --git a/magicked_admin/server/managers/server_mapper.py b/magicked_admin/server/managers/server_mapper.py index d1a4654f..cedd95cc 100644 --- a/magicked_admin/server/managers/server_mapper.py +++ b/magicked_admin/server/managers/server_mapper.py @@ -1,12 +1,21 @@ import threading import requests import time +import logging +import sys from lxml import html from lxml.html.clean import Cleaner +from termcolor import colored from server.player import Player +logger = logging.getLogger(__name__) +if __debug__ and not hasattr(sys, 'frozen'): + logger.setLevel(logging.DEBUG) +else: + logger.setLevel(logging.INFO) + class ServerMapper(threading.Thread): def __init__(self, server): @@ -14,156 +23,172 @@ def __init__(self, server): self.time_interval = 6 self.last_wave = 0 - self.exit_flag = threading.Event() threading.Thread.__init__(self) - print("INFO: Mapper for " + server.name + " initialised") + logger.debug("Mapper for " + server.name + " initialised") - def run(self): + def poll(self): info_url = "http://" + self.server.address + \ "/ServerAdmin/current/info" - - while not self.exit_flag.wait(self.time_interval): - try: - info_page_response = self.server.session.post(info_url, - timeout=2) - except requests.exceptions.RequestException: - print("INFO: Couldn't get info page (RequestException), " - "sleeping for 5 seconds") - time.sleep(5) - continue - - # Look into this encoding, pages are encoded in Windows 1252. - info_tree = html.fromstring(info_page_response.content - .decode('cp1252')) - dds = info_tree.xpath('//dd/text()') - - z, zr = info_tree.xpath('//dd[@class="gs_wave"]/text()')[0]\ - .split("/") - z, zr = int(z), int(zr) - if z == zr and z > 1: - # The if ensures - if not self.server.trader_time: - self.server.trader_open() + try: + info_page_response = self.server.session.post(info_url, + timeout=2) + except requests.exceptions.RequestException: + logger.debug("Couldn't get info page (RequestException)" + " on {} sleeping for 5 seconds" + .format(self.server.name)) + time.sleep(5) + return + + info_tree = html.fromstring(info_page_response.content) + + headings, players_table = self.get_current_players(info_tree) + self.update_players(headings, players_table) + + self.update_game(info_tree) + + def get_current_players(self, info_tree): + table_head_pat = '//table[@id="players"]//thead//tr//th' + # Some but not all headers have an for sorting columns + # that needs to be removed + cleaner = Cleaner() + cleaner.remove_tags = ['a'] + + headings = [] + required_headings = {'Name', 'Perk', 'Dosh', 'Health', + 'Kills', 'Ping', 'Admin'} + for heading in info_tree.xpath(table_head_pat): + heading = cleaner.clean_html(heading) + headings += heading.xpath('//th/text()') + + if not required_headings.issubset(set(headings)): + logger.error("Player is missing columns ({}) on {}" + .format(required_headings - set(headings), + self.server.name)) + + player_rows_pat = '//table[@id="players"]//tbody//tr' + player_rows_tree = info_tree.xpath(player_rows_pat) + + players_table = [] + + for player_row in player_rows_tree: + values = [] + for value in player_row: + if not value.text_content(): + values += [None] + else: + values += [value.text_content()] + + if values[0] == "There are no players": + logger.debug("No players on server {}" + .format(self.server.name)) + elif len(values) != len(headings): + logger.warning("Player row ({}) length did not " + "match the table length on {}" + .format(player_row[headings.index("Name")], + self.server.name)) else: - if self.server.trader_time: - self.server.trader_close() - self.server.zeds_killed = z - self.server.zeds_wave = zr - - map_title = info_tree.xpath('//dl//dd/@title')[1] - map_name = dds[0] + players_table += [values] + + return (headings, players_table) + + def update_players(self, headings, players_table): + # Remove players that have quit + for player in self.server.players: + if player.username not in \ + [player_row[headings.index("Name")] + for player_row in players_table]: + self.server.player_quit(player) + + for player_row in players_table: + username = player_row[headings.index("Name")] + new_perk = player_row[headings.index("Perk")] + if not new_perk: + new_perk = "N/A" try: - wave, length = dds[7].split("/") - difficulty = dds[8] - except ValueError: - wave, length = dds[8].split("/") - difficulty = dds[9] - - self.server.game['map_title'] = map_title - self.server.game['map_name'] = map_name - self.server.game['wave'] = wave - self.server.game['length'] = length - self.server.game['difficulty'] = difficulty - - if int(wave) < self.last_wave: - self.server.new_game() - elif int(wave) > self.last_wave: - self.server.new_wave() - self.last_wave = int(wave) - - table_head_pat = '//table[@id="players"]//thead//tr//th' - # Some but not all headers have an for sorting columns - # that needs to be removed - cleaner = Cleaner() - cleaner.remove_tags = ['a'] - - headings = [] - required_headings = {'Name', 'Perk', 'Dosh', 'Health', - 'Kills', 'Ping', 'Admin'} - for heading in info_tree.xpath(table_head_pat): - heading = cleaner.clean_html(heading) - headings += heading.xpath('//th/text()') - - if not required_headings.issubset(set(headings)): - print("ERROR: Missing player columns {}" - .format(required_headings - set(headings))) - - player_rows_pat = '//table[@id="players"]//tbody//tr' - player_rows_tree = info_tree.xpath(player_rows_pat) - - players_table = [] - - for player_row in player_rows_tree: - values = [] - for value in player_row: - if not value.text_content(): - values += [None] - else: - values += [value.text_content()] - - if values[0] == "There are no players": - print("DEBUG: No players") - elif len(values) != len(headings): - print("ERROR: A player row length did not" + - "match the table length") - else: - players_table += [values] - - # Remove players that have quit - for player in self.server.players: - if player.username not in \ - [player_row[headings.index("Name")] - for player_row in players_table]: - self.server.player_quit(player) - - for player_row in players_table: - username = player_row[headings.index("Name")] - new_perk = player_row[headings.index("Perk")] - if not new_perk: - print("DEBUG: Null perk on: " + player_row[headings.index("Name")]) - new_perk = "N/A" - try: - new_health = int(player_row[headings.index("Health")]) - except TypeError: - print("DEBUG: Null health on: " + player_row[headings.index("Name")]) - new_health = 0 - new_kills = int(player_row[headings.index("Kills")]) - new_ping = int(player_row[headings.index("Ping")]) - new_dosh = int(player_row[headings.index("Dosh")]) - - player = self.server.get_player(username) - # New players - if player is None: - player = Player(username, new_perk) - player.kills = new_kills - player.health = new_health - player.dosh = new_dosh - self.server.player_join(player) - continue - - if new_health == 0 and \ - new_health < player.health and \ - new_kills > 0: - print("INFO: Player " + player.username + " died") - player.total_deaths += 1 - - player.perk = new_perk - player.total_kills += new_kills - player.kills - player.wave_kills += new_kills - player.kills - player.wave_dosh += new_dosh - player.dosh + new_health = int(player_row[headings.index("Health")]) + except TypeError: + new_health = 0 + new_kills = int(player_row[headings.index("Kills")]) + new_ping = int(player_row[headings.index("Ping")]) + new_dosh = int(player_row[headings.index("Dosh")]) + + player = self.server.get_player(username) + # If the player has just joined, inform the server and skip maths + if player is None: + player = Player(username, new_perk) player.kills = new_kills - if new_health < player.health: - player.total_health_lost += player.health - new_health player.health = new_health - player.ping = new_ping - if new_dosh > player.dosh: - player.game_dosh += new_dosh - player.dosh - player.total_dosh += new_dosh - player.dosh - - else: - player.total_dosh_spent += player.dosh - new_dosh player.dosh = new_dosh + self.server.player_join(player) + continue + + # Players can also have 0 HP while in lobby, do additional checks + if new_health == 0 and \ + new_health < player.health and \ + new_kills > 0: + message = "Player {} died on {}"\ + .format(player.username, self.server.name) + print(colored(message, 'red')) + player.total_deaths += 1 + + player.perk = new_perk + player.ping = new_ping - def terminate(self): - self.exit_flag.set() + player.total_kills += new_kills - player.kills + player.wave_kills += new_kills - player.kills + player.kills = new_kills + + if new_dosh > player.dosh: + player.wave_dosh += new_dosh - player.dosh + player.game_dosh += new_dosh - player.dosh + player.total_dosh += new_dosh - player.dosh + else: + player.total_dosh_spent += player.dosh - new_dosh + player.dosh = new_dosh + + if new_health < player.health: + player.total_health_lost += player.health - new_health + player.health = new_health + + def update_game(self, info_tree): + dds = info_tree.xpath('//dd/text()') + + z, zr = info_tree.xpath('//dd[@class="gs_wave"]/text()')[0] \ + .split("/") + z, zr = int(z), int(zr) + if z == zr and z > 1: + # The if ensures trader_open is only sent once + if not self.server.trader_time: + self.server.trader_open() + else: + if self.server.trader_time: + self.server.trader_close() + self.server.zeds_killed = z + self.server.zeds_wave = zr + + map_title = info_tree.xpath('//dl//dd/@title')[1] + map_name = dds[0] + try: + wave, length = dds[7].split("/") + difficulty = dds[8] + except ValueError: + wave, length = dds[8].split("/") + difficulty = dds[9] + + self.server.game['map_title'] = map_title + self.server.game['map_name'] = map_name + self.server.game['wave'] = wave + self.server.game['length'] = length + self.server.game['difficulty'] = difficulty + + if int(wave) < self.last_wave: + self.server.new_game() + elif int(wave) > self.last_wave: + self.server.new_wave() + self.last_wave = int(wave) + + def run(self): + while True: + self.poll() + time.sleep(self.time_interval) diff --git a/magicked_admin/server/server.py b/magicked_admin/server/server.py index 04eea783..b89c7ce8 100644 --- a/magicked_admin/server/server.py +++ b/magicked_admin/server/server.py @@ -1,9 +1,11 @@ -from os import path +import requests +import sys +import logging -import requests, sys from hashlib import sha1 from lxml import html from time import sleep +from termcolor import colored from server.chat.chat import ChatLogger from server.managers.server_mapper import ServerMapper @@ -12,12 +14,18 @@ DIFF_NORM = "0.0000" DIFF_HARD = "1.0000" DIFF_SUI = "2.0000" -DIFF_HOE = "4.0000" +DIFF_HOE = "3.0000" LEN_SHORT = "0" LEN_NORM = "1" LEN_LONG = "2" +logger = logging.getLogger(__name__) +if __debug__ and not hasattr(sys, 'frozen'): + logger.setLevel(logging.DEBUG) +else: + logger.setLevel(logging.INFO) + class Server: def __init__(self, name, address, username, password, game_password): @@ -32,7 +40,7 @@ def __init__(self, name, address, username, password, game_password): self.game_password = game_password self.database = ServerDatabase(name) - print("INFO: Connecting to: " + self.address + " (" + self.name + ")") + print("Connecting to: {} ({})".format(self.name, self.address)) self.session = self.new_session() self.general_settings = self.load_general_settings() @@ -54,7 +62,7 @@ def __init__(self, name, address, username, password, game_password): self.mapper = ServerMapper(self) self.mapper.start() - print("INFO: Server " + name + " initialised") + logger.debug("Server " + name + " initialised") def new_session(self): login_url = "http://" + self.address + "/ServerAdmin/" @@ -81,14 +89,14 @@ def new_session(self): login_response = s.post(login_url, data=login_payload) if "Invalid credentials" in login_response.text: - print("ERROR: Bad credentials for server: " + self.name) + logger.error("Bad credentials for server: " + self.name) input("Press enter to exit...") sys.exit() # Add in something to retry for X times. except requests.exceptions.RequestException: - print("ERROR: Network error on: " + self.address + - " (" + self.name + "), bad address?") + logger.error("Network error on: " + self.address + + " (" + self.name + "), bad address?") input("Press enter to exit...") sys.exit() @@ -103,8 +111,8 @@ def load_general_settings(self): try: general_settings_response = self.session.get(general_settings_url) except requests.exceptions.RequestException as e: - print("INFO: Couldn't get settings " + self.name + - " (RequestException)") + logger.debug("Couldn't get settings " + self.name + + " (RequestException), sleeping for 3s") sleep(3) general_settings_tree = html.fromstring( general_settings_response.content @@ -152,6 +160,9 @@ def trader_close(self): self.chat.handle_message("server", "!t_close", admin=True) def new_game(self): + message = "New game on {}, map: {}"\ + .format(self.name, self.game['map_title']) + print(colored(message, 'magenta')) self.chat.handle_message("server", "!new_game", admin=True) def get_player(self, username): @@ -164,15 +175,18 @@ def player_join(self, player): self.database.load_player(player) player.total_logins += 1 self.players.append(player) + message = "Player {} joined {}".format(player.username, self.name) + print(colored(message, 'cyan')) self.chat.handle_message("server", - "!p_join " + player.username, + "!player_join " + player.username, admin=True) - print("INFO: Player " + player.username + " joined") def player_quit(self, quit_player): for player in self.players: if player.username == quit_player.username: - print("INFO: Player " + player.username + " quit") + message = "Player {} quit {}"\ + .format(quit_player.username, self.name) + print(colored(message, 'cyan')) self.chat.handle_message("server", "!p_quit " + player.username, admin=True) @@ -180,7 +194,7 @@ def player_quit(self, quit_player): self.players.remove(player) def write_all_players(self, final=False): - print("INFO: Writing players") + logger.debug("Flushing database ({})".format(self.name)) for player in self.players: self.database.save_player(player, final) @@ -192,9 +206,9 @@ def set_difficulty(self, difficulty): self.general_settings['settings_GameDifficulty_raw'] = difficulty try: self.session.post(general_settings_url, self.general_settings) - except requests.exceptions.RequestException as e: - print("INFO: Couldn't set difficulty " + self.name + - " (RequestException)") + except requests.exceptions.RequestException: + logger.warning("Couldn't set difficulty on {} (RequestException)" + .format(self.name)) sleep(3) def set_length(self, length): @@ -206,8 +220,8 @@ def set_length(self, length): try: self.session.post(general_settings_url, self.general_settings) except requests.exceptions.RequestException: - print("INFO: Couldn't set length " + self.name + - " (RequestException)") + logger.warning("Couldn't set length on {} (RequestException)" + .format(self.name)) sleep(3) def save_settings(self): @@ -217,9 +231,9 @@ def save_settings(self): "/ServerAdmin/settings/general" try: self.session.post(general_settings_url, self.general_settings) - except requests.exceptions.RequestException as e: - print("INFO: Couldn't set general settings " + self.name + - " (RequestException), retrying") + except requests.exceptions.RequestException: + logger.warning("Couldn't set general settings on {} " + "(RequestException)".format(self.name)) sleep(3) def toggle_game_password(self): @@ -231,10 +245,10 @@ def toggle_game_password(self): try: passwords_response = self.session.get(passwords_url) - except requests.exceptions.RequestException as e: - print("INFO: Couldn't get password state " + self.name + - " (RequestException)") - sleep(3) + except requests.exceptions.RequestException: + logger.warning("Couldn't get password state on {} " + "(RequestException), returning".format(self.name)) + return passwords_tree = html.fromstring(passwords_response.content) password_state = passwords_tree.xpath( @@ -250,8 +264,8 @@ def toggle_game_password(self): try: self.session.post(passwords_url, payload) except requests.exceptions.RequestException: - print("INFO: Couldn't set password " + self.name + - " (RequestException)") + logger.warning("Couldn't set password on {} (RequestException)" + .format(self.name)) sleep(3) if password_state == 'False': return True @@ -271,18 +285,9 @@ def change_map(self, new_map): try: self.session.post(map_url, payload) except requests.exceptions.RequestException: - print("INFO: Couldn't set map " + self.name + - " (RequestException)") + logger.warning("Couldn't set map on {} (RequestException)" + .format(self.name)) sleep(3) def restart_map(self): self.change_map(self.game['map_title']) - - def terminate(self): - self.mapper.terminate() - self.mapper.join() - - self.chat.terminate() - self.chat.join() - - self.write_all_players(final=True)