Index: EcheLOn/ELO.py =================================================================== --- EcheLOn/ELO.py +++ EcheLOn/ELO.py @@ -1,90 +0,0 @@ -"""Copyright (C) 2014 Wildfire Games. - * This file is part of 0 A.D. - * - * 0 A.D. is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 2 of the License, or - * (at your option) any later version. - * - * 0 A.D. is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with 0 A.D. If not, see . -""" - -############ Constants ############ -# Difference between two ratings such that it is -# regarded as a "sure win" for the higher player. -# No points are gained or lost for such a game. -elo_sure_win_difference = 600.0 - -# Lower ratings "move faster" and change more -# dramatically than higher ones. Anything rating above -# this value moves at the same rate as this value. -elo_k_factor_constant_rating = 2200.0 - -# This preset number of games is the number of games -# where a player is considered "stable". -# Rating volatility is constant after this number. -volatility_constant = 20.0 - -# Fair rating adjustment loses against inflation -# This constant will battle inflation. -# NOTE: This can be adjusted as needed by a -# bot/server administrator -anti_inflation = 0.015 - -############ Functions ############ -def get_rating_adjustment(rating, opponent_rating, games_played, opponent_games_played, result): - """ - Calculates the rating adjustment after a 1v1 game finishes using simplified ELO. - - Arguments: - rating, opponent_rating - Ratings of the players before this game. - games_played, opponent_games_played - Number of games each player has played - before this game. - result - 1 for the first player (rating, games_played) won, 0 for draw, or - -1 for the second player (opponent_rating, opponent_games_played) won. - - Returns: - The integer that should be subtracted from the loser's rating and added - to the winner's rating to get their new ratings. - - TODO: Team games. - """ - player_volatility = (min(games_played, volatility_constant) / volatility_constant + 0.25) / 1.25 - rating_k_factor = 50.0 * (min(rating, elo_k_factor_constant_rating) / elo_k_factor_constant_rating + 1.0) / 2.0 - volatility = rating_k_factor * player_volatility - difference = opponent_rating - rating - if result == 1: - return round(max(0, (difference + result * elo_sure_win_difference) / volatility - anti_inflation)) - elif result == -1: - return round(min(0, (difference + result * elo_sure_win_difference) / volatility - anti_inflation)) - else: - return round(difference / volatility - anti_inflation) - -# Inflation test - A slightly negative is better than a slightly positive -# Lower rated players stop playing more often than higher rated players -# Uncomment to test. -# In this example, two evenly matched players play for 150000 games. -""" -from random import randrange -r1start = 1600 -r2start = 1600 -r1 = r1start -r2 = r2start -for x in range(0, 150000): - res = randrange(3)-1 # How often one wins against the other - if res >= 1: - res = 1 - elif res <= -1: - res = -1 - r1gain = get_rating_adjustment(r1, r2, 20, 20, res) - r2gain = get_rating_adjustment(r2, r1, 20, 20, -1 * res) - r1 += r1gain - r2 += r2gain -print(str(r1) + " " + str(r2) + " : " + str(r1 + r2-r1start - r2start)) -""" Index: EcheLOn/EcheLOn.py =================================================================== --- EcheLOn/EcheLOn.py +++ EcheLOn/EcheLOn.py @@ -1,795 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -"""Copyright (C) 2018 Wildfire Games. - * This file is part of 0 A.D. - * - * 0 A.D. is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 2 of the License, or - * (at your option) any later version. - * - * 0 A.D. is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with 0 A.D. If not, see . -""" - -import logging, time, traceback -from optparse import OptionParser - -import sleekxmpp -from sleekxmpp.stanza import Iq -from sleekxmpp.xmlstream import ElementBase, register_stanza_plugin, ET -from sleekxmpp.xmlstream.handler import Callback -from sleekxmpp.xmlstream.matcher import StanzaPath - -from sqlalchemy import func - -from LobbyRanking import session as db, Game, Player, PlayerInfo -from ELO import get_rating_adjustment -# Rating that new players should be inserted into the -# database with, before they've played any games. -leaderboard_default_rating = 1200 - -## Class that contains and manages leaderboard data ## -class LeaderboardList(): - def __init__(self, room): - self.room = room - self.lastRated = "" - - def getProfile(self, JID): - """ - Retrieves the profile for the specified JID - """ - stats = {} - player = db.query(Player).filter(Player.jid.ilike(str(JID))) - - if not player.first(): - return - - queried_player = player.first() - playerID = queried_player.id - if queried_player.rating != -1: - stats['rating'] = str(queried_player.rating) - rank = db.query(Player).filter(Player.rating >= queried_player.rating).count() - stats['rank'] = str(rank) - - if queried_player.highest_rating != -1: - stats['highestRating'] = str(queried_player.highest_rating) - - gamesPlayed = db.query(PlayerInfo).filter_by(player_id=playerID).count() - wins = db.query(Game).filter_by(winner_id=playerID).count() - stats['totalGamesPlayed'] = str(gamesPlayed) - stats['wins'] = str(wins) - stats['losses'] = str(gamesPlayed - wins) - return stats - - def getOrCreatePlayer(self, JID): - """ - Stores a player(JID) in the database if they don't yet exist. - Returns either the newly created instance of - the Player model, or the one that already - exists in the database. - """ - players = db.query(Player).filter(Player.jid.ilike(str(JID))) - if not players.first(): - player = Player(jid=str(JID), rating=-1) - db.add(player) - db.commit() - return player - return players.first() - - def removePlayer(self, JID): - """ - Remove a player(JID) from database. - Returns the player that was removed, or None - if that player didn't exist. - """ - players = db.query(Player).filter(Player.jid.ilike(str(JID))) - player = players.first() - if not player: - return None - players.delete() - return player - - def addGame(self, gamereport): - """ - Adds a game to the database and updates the data - on a player(JID) from game results. - Returns the created Game object, or None if - the creation failed for any reason. - Side effects: - Inserts a new Game instance into the database. - """ - # Discard any games still in progress. - if any(map(lambda state: state == 'active', - dict.values(gamereport['playerStates']))): - return None - - players = map(lambda jid: db.query(Player).filter(Player.jid.ilike(str(jid))).first(), - dict.keys(gamereport['playerStates'])) - - winning_jid = list(dict.keys({jid: state for jid, state in - gamereport['playerStates'].items() - if state == 'won'}))[0] - - def get(stat, jid): - return gamereport[stat][jid] - - singleStats = {'timeElapsed', 'mapName', 'teamsLocked', 'matchID'} - totalScoreStats = {'economyScore', 'militaryScore', 'totalScore'} - resourceStats = {'foodGathered', 'foodUsed', 'woodGathered', 'woodUsed', - 'stoneGathered', 'stoneUsed', 'metalGathered', 'metalUsed', 'vegetarianFoodGathered', - 'treasuresCollected', 'lootCollected', 'tributesSent', 'tributesReceived'} - unitsStats = {'totalUnitsTrained', 'totalUnitsLost', 'enemytotalUnitsKilled', 'infantryUnitsTrained', - 'infantryUnitsLost', 'enemyInfantryUnitsKilled', 'workerUnitsTrained', 'workerUnitsLost', - 'enemyWorkerUnitsKilled', 'femaleCitizenUnitsTrained', 'femaleCitizenUnitsLost', 'enemyFemaleCitizenUnitsKilled', - 'cavalryUnitsTrained', 'cavalryUnitsLost', 'enemyCavalryUnitsKilled', 'championUnitsTrained', - 'championUnitsLost', 'enemyChampionUnitsKilled', 'heroUnitsTrained', 'heroUnitsLost', - 'enemyHeroUnitsKilled', 'shipUnitsTrained', 'shipUnitsLost', 'enemyShipUnitsKilled', 'traderUnitsTrained', - 'traderUnitsLost', 'enemyTraderUnitsKilled'} - buildingsStats = {'totalBuildingsConstructed', 'totalBuildingsLost', 'enemytotalBuildingsDestroyed', - 'civCentreBuildingsConstructed', 'civCentreBuildingsLost', 'enemyCivCentreBuildingsDestroyed', - 'houseBuildingsConstructed', 'houseBuildingsLost', 'enemyHouseBuildingsDestroyed', - 'economicBuildingsConstructed', 'economicBuildingsLost', 'enemyEconomicBuildingsDestroyed', - 'outpostBuildingsConstructed', 'outpostBuildingsLost', 'enemyOutpostBuildingsDestroyed', - 'militaryBuildingsConstructed', 'militaryBuildingsLost', 'enemyMilitaryBuildingsDestroyed', - 'fortressBuildingsConstructed', 'fortressBuildingsLost', 'enemyFortressBuildingsDestroyed', - 'wonderBuildingsConstructed', 'wonderBuildingsLost', 'enemyWonderBuildingsDestroyed'} - marketStats = {'woodBought', 'foodBought', 'stoneBought', 'metalBought', 'tradeIncome'} - miscStats = {'civs', 'teams', 'percentMapExplored'} - - stats = totalScoreStats | resourceStats | unitsStats | buildingsStats | marketStats | miscStats - playerInfos = [] - for player in players: - jid = player.jid - playerinfo = PlayerInfo(player=player) - for reportname in stats: - setattr(playerinfo, reportname, get(reportname, jid.lower())) - playerInfos.append(playerinfo) - - game = Game(map=gamereport['mapName'], duration=int(gamereport['timeElapsed']), teamsLocked=bool(gamereport['teamsLocked']), matchID=gamereport['matchID']) - game.players.extend(players) - game.player_info.extend(playerInfos) - game.winner = db.query(Player).filter(Player.jid.ilike(str(winning_jid))).first() - db.add(game) - db.commit() - return game - - def verifyGame(self, gamereport): - """ - Returns a boolean based on whether the game should be rated. - Here, we can specify the criteria for rated games. - """ - winning_jids = list(dict.keys({jid: state for jid, state in - gamereport['playerStates'].items() - if state == 'won'})) - # We only support 1v1s right now. TODO: Support team games. - if len(winning_jids) * 2 > len(dict.keys(gamereport['playerStates'])): - # More than half the people have won. This is not a balanced team game or duel. - return False - if len(dict.keys(gamereport['playerStates'])) != 2: - return False - return True - - def rateGame(self, game): - """ - Takes a game with 2 players and alters their ratings - based on the result of the game. - Returns self. - Side effects: - Changes the game's players' ratings in the database. - """ - player1 = game.players[0] - player2 = game.players[1] - # TODO: Support draws. Since it's impossible to draw in the game currently, - # the database model, and therefore this code, requires a winner. - # The Elo implementation does not, however. - result = 1 if player1 == game.winner else -1 - # Player's ratings are -1 unless they have played a rated game. - if player1.rating == -1: - player1.rating = leaderboard_default_rating - if player2.rating == -1: - player2.rating = leaderboard_default_rating - - rating_adjustment1 = int(get_rating_adjustment(player1.rating, player2.rating, - len(player1.games), len(player2.games), result)) - rating_adjustment2 = int(get_rating_adjustment(player2.rating, player1.rating, - len(player2.games), len(player1.games), result * -1)) - if result == 1: - resultQualitative = "won" - elif result == 0: - resultQualitative = "drew" - else: - resultQualitative = "lost" - name1 = '@'.join(player1.jid.split('@')[:-1]) - name2 = '@'.join(player2.jid.split('@')[:-1]) - self.lastRated = "A rated game has ended. %s %s against %s. Rating Adjustment: %s (%s -> %s) and %s (%s -> %s)."%(name1, - resultQualitative, name2, name1, player1.rating, player1.rating + rating_adjustment1, - name2, player2.rating, player2.rating + rating_adjustment2) - player1.rating += rating_adjustment1 - player2.rating += rating_adjustment2 - if not player1.highest_rating: - player1.highest_rating = -1 - if not player2.highest_rating: - player2.highest_rating = -1 - if player1.rating > player1.highest_rating: - player1.highest_rating = player1.rating - if player2.rating > player2.highest_rating: - player2.highest_rating = player2.rating - db.commit() - return self - - def getLastRatedMessage(self): - """ - Gets the string of the last rated game. Triggers an update - chat for the bot. - """ - return self.lastRated - - def addAndRateGame(self, gamereport): - """ - Calls addGame and if the game has only two - players, also calls rateGame. - Returns the result of addGame. - """ - game = self.addGame(gamereport) - if game and self.verifyGame(gamereport): - self.rateGame(game) - else: - self.lastRated = "" - return game - - def getBoard(self): - """ - Returns a dictionary of player rankings to - JIDs for sending. - """ - board = {} - players = db.query(Player).filter(Player.rating != -1).order_by(Player.rating.desc()).limit(100).all() - for rank, player in enumerate(players): - board[player.jid] = {'name': '@'.join(player.jid.split('@')[:-1]), 'rating': str(player.rating)} - return board - - def getRatingList(self, nicks): - """ - Returns a rating list of players - currently in the lobby by nick - because the client can't link - JID to nick conveniently. - """ - ratinglist = {} - players = db.query(Player.jid, Player.rating).filter(func.upper(Player.jid).in_([ str(JID).upper() for JID in list(nicks) ])) - for player in players: - rating = str(player.rating) if player.rating != -1 else '' - for JID in list(nicks): - if JID.upper() == player.jid.upper(): - ratinglist[nicks[JID]] = {'name': nicks[JID], 'rating': rating} - break - return ratinglist - -## Class which manages different game reports from clients ## -## and calls leaderboard functions as appropriate. ## -class ReportManager(): - def __init__(self, leaderboard): - self.leaderboard = leaderboard - self.interimReportTracker = [] - self.interimJIDTracker = [] - - def addReport(self, JID, rawGameReport): - """ - Adds a game to the interface between a raw report - and the leaderboard database. - """ - # cleanRawGameReport is a copy of rawGameReport with all reporter specific information removed. - cleanRawGameReport = rawGameReport.copy() - del cleanRawGameReport["playerID"] - - if cleanRawGameReport not in self.interimReportTracker: - # Store the game. - appendIndex = len(self.interimReportTracker) - self.interimReportTracker.append(cleanRawGameReport) - # Initilize the JIDs and store the initial JID. - numPlayers = self.getNumPlayers(rawGameReport) - JIDs = [None] * numPlayers - if numPlayers - int(rawGameReport["playerID"]) > -1: - JIDs[int(rawGameReport["playerID"])-1] = str(JID).lower() - self.interimJIDTracker.append(JIDs) - else: - # We get the index at which the JIDs coresponding to the game are stored. - index = self.interimReportTracker.index(cleanRawGameReport) - # We insert the new report JID into the ascending list of JIDs for the game. - JIDs = self.interimJIDTracker[index] - if len(JIDs) - int(rawGameReport["playerID"]) > -1: - JIDs[int(rawGameReport["playerID"])-1] = str(JID).lower() - self.interimJIDTracker[index] = JIDs - - self.checkFull() - - def expandReport(self, rawGameReport, JIDs): - """ - Takes an raw game report and re-formats it into - Python data structures leaving JIDs empty. - Returns a processed gameReport of type dict. - """ - processedGameReport = {} - for key in rawGameReport: - if rawGameReport[key].find(",") == -1: - processedGameReport[key] = rawGameReport[key] - else: - split = rawGameReport[key].split(",") - # Remove the false split positive. - split.pop() - statToJID = {} - for i, part in enumerate(split): - statToJID[JIDs[i]] = part - processedGameReport[key] = statToJID - return processedGameReport - - def checkFull(self): - """ - Searches internal database to check if enough - reports have been submitted to add a game to - the leaderboard. If so, the report will be - interpolated and addAndRateGame will be - called with the result. - """ - i = 0 - length = len(self.interimReportTracker) - while(i < length): - numPlayers = self.getNumPlayers(self.interimReportTracker[i]) - numReports = 0 - for JID in self.interimJIDTracker[i]: - if JID != None: - numReports += 1 - if numReports == numPlayers: - try: - self.leaderboard.addAndRateGame(self.expandReport(self.interimReportTracker[i], self.interimJIDTracker[i])) - except: - traceback.print_exc() - del self.interimJIDTracker[i] - del self.interimReportTracker[i] - length -= 1 - else: - i += 1 - self.leaderboard.lastRated = "" - - def getNumPlayers(self, rawGameReport): - """ - Computes the number of players in a raw gameReport. - Returns int, the number of players. - """ - # Find a key in the report which holds values for multiple players. - for key in rawGameReport: - if rawGameReport[key].find(",") != -1: - # Count the number of values, minus one for the false split positive. - return len(rawGameReport[key].split(","))-1 - # Return -1 in case of failure. - return -1 - -## Class for custom player stanza extension ## -class PlayerXmppPlugin(ElementBase): - name = 'query' - namespace = 'jabber:iq:player' - interfaces = set(('game', 'online')) - sub_interfaces = interfaces - plugin_attrib = 'player' - - def addPlayerOnline(self, player): - playerXml = ET.fromstring("%s" % player) - self.xml.append(playerXml) - -## Class for custom boardlist and ratinglist stanza extension ## -class BoardListXmppPlugin(ElementBase): - name = 'query' - namespace = 'jabber:iq:boardlist' - interfaces = set(('board', 'command', 'recipient')) - sub_interfaces = interfaces - plugin_attrib = 'boardlist' - def addCommand(self, command): - commandXml = ET.fromstring("%s" % command) - self.xml.append(commandXml) - def addRecipient(self, recipient): - recipientXml = ET.fromstring("%s" % recipient) - self.xml.append(recipientXml) - def addItem(self, name, rating): - itemXml = ET.Element("board", {"name": name, "rating": rating}) - self.xml.append(itemXml) - -## Class for custom gamereport stanza extension ## -class GameReportXmppPlugin(ElementBase): - name = 'report' - namespace = 'jabber:iq:gamereport' - plugin_attrib = 'gamereport' - interfaces = ('game', 'sender') - sub_interfaces = interfaces - def addSender(self, sender): - senderXml = ET.fromstring("%s" % sender) - self.xml.append(senderXml) - def getGame(self): - """ - Required to parse incoming stanzas with this - extension. - """ - game = self.xml.find('{%s}game' % self.namespace) - data = {} - for key, item in game.items(): - data[key] = item - return data - -## Class for custom profile ## -class ProfileXmppPlugin(ElementBase): - name = 'query' - namespace = 'jabber:iq:profile' - interfaces = set(('profile', 'command', 'recipient')) - sub_interfaces = interfaces - plugin_attrib = 'profile' - def addCommand(self, command): - commandXml = ET.fromstring("%s" % command) - self.xml.append(commandXml) - def addRecipient(self, recipient): - recipientXml = ET.fromstring("%s" % recipient) - self.xml.append(recipientXml) - def addItem(self, player, rating, highestRating, rank, totalGamesPlayed, wins, losses): - itemXml = ET.Element("profile", {"player": player, "rating": rating, "highestRating": highestRating, - "rank" : rank, "totalGamesPlayed" : totalGamesPlayed, "wins" : wins, - "losses" : losses}) - self.xml.append(itemXml) - -## Main class which handles IQ data and sends new data ## -class EcheLOn(sleekxmpp.ClientXMPP): - """ - A simple list provider - """ - def __init__(self, sjid, password, room, nick): - sleekxmpp.ClientXMPP.__init__(self, sjid, password) - self.sjid = sjid - self.room = room - self.nick = nick - self.ratingListCache = {} - self.ratingCacheReload = True - self.boardListCache = {} - self.boardCacheReload = True - - # Init leaderboard object - self.leaderboard = LeaderboardList(room) - - # gameReport to leaderboard abstraction - self.reportManager = ReportManager(self.leaderboard) - - # Store mapping of nicks and XmppIDs, attached via presence stanza - self.nicks = {} - - self.lastLeft = "" - - register_stanza_plugin(Iq, PlayerXmppPlugin) - register_stanza_plugin(Iq, BoardListXmppPlugin) - register_stanza_plugin(Iq, GameReportXmppPlugin) - register_stanza_plugin(Iq, ProfileXmppPlugin) - - self.register_handler(Callback('Iq Player', - StanzaPath('iq/player'), - self.iqhandler, - instream=True)) - self.register_handler(Callback('Iq Boardlist', - StanzaPath('iq/boardlist'), - self.iqhandler, - instream=True)) - self.register_handler(Callback('Iq GameReport', - StanzaPath('iq/gamereport'), - self.iqhandler, - instream=True)) - self.register_handler(Callback('Iq Profile', - StanzaPath('iq/profile'), - self.iqhandler, - instream=True)) - - self.add_event_handler("session_start", self.start) - self.add_event_handler("muc::%s::got_online" % self.room, self.muc_online) - self.add_event_handler("muc::%s::got_offline" % self.room, self.muc_offline) - - def start(self, event): - """ - Process the session_start event - """ - self.plugin['xep_0045'].joinMUC(self.room, self.nick) - self.send_presence() - self.get_roster() - logging.info("EcheLOn started") - - def muc_online(self, presence): - """ - Process presence stanza from a chat room. - """ - if presence['muc']['nick'] != self.nick: - # If it doesn't already exist, store player JID mapped to their nick. - if str(presence['muc']['jid']) not in self.nicks: - self.nicks[str(presence['muc']['jid'])] = presence['muc']['nick'] - # Check the jid isn't already in the lobby. - logging.debug("Client '%s' connected with a nick of '%s'." %(presence['muc']['jid'], presence['muc']['nick'])) - - def muc_offline(self, presence): - """ - Process presence stanza from a chat room. - """ - # Clean up after a player leaves - if presence['muc']['nick'] != self.nick: - # Remove them from the local player list. - self.lastLeft = str(presence['muc']['jid']) - if str(presence['muc']['jid']) in self.nicks: - del self.nicks[str(presence['muc']['jid'])] - - def iqhandler(self, iq): - """ - Handle the custom stanzas - This method should be very robust because we could receive anything - """ - if iq['type'] == 'error': - logging.error('iqhandler error' + iq['error']['condition']) - #self.disconnect() - elif iq['type'] == 'get': - """ - Request lists. - """ - if 'boardlist' in iq.loaded_plugins: - command = iq['boardlist']['command'] - recipient = iq['boardlist']['recipient'] - if command == 'getleaderboard': - try: - self.sendBoardList(iq['from'], recipient) - except: - traceback.print_exc() - logging.error("Failed to process leaderboardlist request from %s" % iq['from'].bare) - elif command == 'getratinglist': - try: - self.sendRatingList(iq['from']); - except: - traceback.print_exc() - else: - logging.error("Failed to process boardlist request from %s" % iq['from'].bare) - elif 'profile' in iq.loaded_plugins: - command = iq['profile']['command'] - recipient = iq['profile']['recipient'] - try: - self.sendProfile(iq['from'], command, recipient) - except: - try: - self.sendProfileNotFound(iq['from'], command, recipient) - except: - logging.debug("No record found for %s" % command) - else: - logging.error("Unknown 'get' type stanza request from %s" % iq['from'].bare) - elif iq['type'] == 'result': - """ - Iq successfully received - """ - pass - elif iq['type'] == 'set': - if 'gamereport' in iq.loaded_plugins: - """ - Client is reporting end of game statistics - """ - if iq['gamereport']['sender']: - sender = iq['gamereport']['sender'] - else: - sender = iq['from'] - try: - self.leaderboard.getOrCreatePlayer(iq['gamereport']['sender']) - self.reportManager.addReport(sender, iq['gamereport']['game']) - if self.leaderboard.getLastRatedMessage() != "": - self.ratingCacheReload = True - self.boardCacheReload = True - self.send_message(mto=self.room, mbody=self.leaderboard.getLastRatedMessage(), mtype="groupchat", - mnick=self.nick) - self.sendRatingList(iq['from']) - except: - traceback.print_exc() - logging.error("Failed to update game statistics for %s" % iq['from'].bare) - elif 'player' in iq.loaded_plugins: - player = iq['player']['online'] - #try: - self.leaderboard.getOrCreatePlayer(player) - #except: - #logging.debug("Could not create new user %s" % player) - else: - logging.error("Failed to process stanza type '%s' received from %s" % iq['type'], iq['from'].bare) - - def sendBoardList(self, to, recipient): - """ - Send the whole leaderboard list. - If no target is passed the boardlist is broadcasted - to all clients. - """ - ## See if we can squeak by with the cached version. - # Leaderboard cache is reloaded upon a new rated game being rated. - if self.boardCacheReload: - self.boardListCache = self.leaderboard.getBoard() - self.boardCacheReload = False - - stz = BoardListXmppPlugin() - iq = self.Iq() - iq['type'] = 'result' - for i in self.boardListCache: - stz.addItem(self.boardListCache[i]['name'], self.boardListCache[i]['rating']) - stz.addCommand('boardlist') - stz.addRecipient(recipient) - iq.setPayload(stz) - ## Check recipient exists - if str(to) not in self.nicks: - logging.error("No player with the XmPP ID '%s' known to send boardlist to" % str(to)) - return - ## Set additional IQ attributes - iq['to'] = to - ## Try sending the stanza - try: - iq.send(block=False, now=True) - except: - logging.error("Failed to send leaderboard list") - - def sendRatingList(self, to): - """ - Send the rating list. - """ - ## Attempt to use the cache. - # Cache is invalidated when a new game is rated or a uncached player - # comes online. - if self.ratingCacheReload: - self.ratingListCache = self.leaderboard.getRatingList(self.nicks) - self.ratingCacheReload = False - else: - for JID in list(self.nicks): - if JID not in self.ratingListCache: - self.ratingListCache = self.leaderboard.getRatingList(self.nicks) - self.ratingCacheReload = False - break - - stz = BoardListXmppPlugin() - iq = self.Iq() - iq['type'] = 'result' - for i in self.ratingListCache: - stz.addItem(self.ratingListCache[i]['name'], self.ratingListCache[i]['rating']) - stz.addCommand('ratinglist') - iq.setPayload(stz) - ## Check recipient exists - if str(to) not in self.nicks: - logging.error("No player with the XmPP ID '%s' known to send ratinglist to" % str(to)) - return - ## Set additional IQ attributes - iq['to'] = to - ## Try sending the stanza - try: - iq.send(block=False, now=True) - except: - logging.error("Failed to send rating list") - - def sendProfile(self, to, player, recipient): - """ - Send the player profile to a specified target. - """ - if to == "": - logging.error("Failed to send profile") - return - - online = False; - ## Pull stats and add it to the stanza - for JID in list(self.nicks): - if self.nicks[JID] == player: - stats = self.leaderboard.getProfile(JID) - online = True - break - - if online == False: - stats = self.leaderboard.getProfile(player + "@" + str(recipient).split('@')[1]) - stz = ProfileXmppPlugin() - iq = self.Iq() - iq['type'] = 'result' - - stz.addItem(player, stats['rating'], stats['highestRating'], stats['rank'], stats['totalGamesPlayed'], stats['wins'], stats['losses']) - stz.addCommand(player) - stz.addRecipient(recipient) - iq.setPayload(stz) - ## Check recipient exists - if str(to) not in self.nicks: - logging.error("No player with the XmPP ID '%s' known to send profile to" % str(to)) - return - - ## Set additional IQ attributes - iq['to'] = to - - ## Try sending the stanza - try: - iq.send(block=False, now=True) - except: - traceback.print_exc() - logging.error("Failed to send profile") - - def sendProfileNotFound(self, to, player, recipient): - """ - Send a profile not-found error to a specified target. - """ - stz = ProfileXmppPlugin() - iq = self.Iq() - iq['type'] = 'result' - - filler = str(0) - stz.addItem(player, str(-2), filler, filler, filler, filler, filler) - stz.addCommand(player) - stz.addRecipient(recipient) - iq.setPayload(stz) - ## Check recipient exists - if str(to) not in self.nicks: - logging.error("No player with the XmPP ID '%s' known to send profile to" % str(to)) - return - - ## Set additional IQ attributes - iq['to'] = to - - ## Try sending the stanza - try: - iq.send(block=False, now=True) - except: - traceback.print_exc() - logging.error("Failed to send profile") - -## Main Program ## -if __name__ == '__main__': - # Setup the command line arguments. - optp = OptionParser() - - # Output verbosity options. - optp.add_option('-q', '--quiet', help='set logging to ERROR', - action='store_const', dest='loglevel', - const=logging.ERROR, default=logging.INFO) - optp.add_option('-d', '--debug', help='set logging to DEBUG', - action='store_const', dest='loglevel', - const=logging.DEBUG, default=logging.INFO) - optp.add_option('-v', '--verbose', help='set logging to COMM', - action='store_const', dest='loglevel', - const=5, default=logging.INFO) - - # EcheLOn configuration options - optp.add_option('-m', '--domain', help='set EcheLOn domain', - action='store', dest='xdomain', - default="lobby.wildfiregames.com") - optp.add_option('-l', '--login', help='set EcheLOn login', - action='store', dest='xlogin', - default="EcheLOn") - optp.add_option('-p', '--password', help='set EcheLOn password', - action='store', dest='xpassword', - default="XXXXXX") - optp.add_option('-n', '--nickname', help='set EcheLOn nickname', - action='store', dest='xnickname', - default="Ratings") - optp.add_option('-r', '--room', help='set muc room to join', - action='store', dest='xroom', - default="arena") - - # ejabberd server options - optp.add_option('-s', '--server', help='address of the ejabberd server', - action='store', dest='xserver', - default="localhost") - optp.add_option('-t', '--disable-tls', help='Pass this argument to connect without TLS encryption', - action='store_true', dest='xdisabletls', - default=False) - - opts, args = optp.parse_args() - - # Setup logging. - logging.basicConfig(level=opts.loglevel, - format='%(asctime)s %(levelname)-8s %(message)s', datefmt='%Y-%m-%d %H:%M:%S') - - # EcheLOn - xmpp = EcheLOn(opts.xlogin+'@'+opts.xdomain+'/CC', opts.xpassword, opts.xroom+'@conference.'+opts.xdomain, opts.xnickname) - xmpp.register_plugin('xep_0030') # Service Discovery - xmpp.register_plugin('xep_0004') # Data Forms - xmpp.register_plugin('xep_0045') # Multi-User Chat # used - xmpp.register_plugin('xep_0060') # PubSub - xmpp.register_plugin('xep_0199') # XMPP Ping - - if xmpp.connect((opts.xserver, 5222), True, not opts.xdisabletls): - xmpp.process(threaded=False) - else: - logging.error("Unable to connect") Index: EcheLOn/LobbyRanking.py =================================================================== --- EcheLOn/LobbyRanking.py +++ EcheLOn/LobbyRanking.py @@ -1,140 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -"""Copyright (C) 2013 Wildfire Games. - * This file is part of 0 A.D. - * - * 0 A.D. is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 2 of the License, or - * (at your option) any later version. - * - * 0 A.D. is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with 0 A.D. If not, see . -""" - -import sqlalchemy -from sqlalchemy import Column, ForeignKey, Integer, String, Boolean -from sqlalchemy.orm import relationship, sessionmaker -from sqlalchemy.ext.declarative import declarative_base - -engine = sqlalchemy.create_engine('sqlite:///lobby_rankings.sqlite3') -Session = sessionmaker(bind=engine) -session = Session() -Base = declarative_base() - -class Player(Base): - __tablename__ = 'players' - - id = Column(Integer, primary_key=True) - jid = Column(String(255)) - rating = Column(Integer) - highest_rating = Column(Integer) - games = relationship('Game', secondary='players_info') - # These two relations really only exist to satisfy the linkage - # between PlayerInfo and Player and Game and player. - games_info = relationship('PlayerInfo', backref='player') - games_won = relationship('Game', backref='winner') - -class PlayerInfo(Base): - __tablename__ = 'players_info' - - id = Column(Integer, primary_key=True) - player_id = Column(Integer, ForeignKey('players.id')) - game_id = Column(Integer, ForeignKey('games.id')) - civs = Column(String(20)) - teams = Column(Integer) - economyScore = Column(Integer) - militaryScore = Column(Integer) - totalScore = Column(Integer) - foodGathered = Column(Integer) - foodUsed = Column(Integer) - woodGathered = Column(Integer) - woodUsed = Column(Integer) - stoneGathered = Column(Integer) - stoneUsed = Column(Integer) - metalGathered = Column(Integer) - metalUsed = Column(Integer) - vegetarianFoodGathered = Column(Integer) - treasuresCollected = Column(Integer) - lootCollected = Column(Integer) - tributesSent = Column(Integer) - tributesReceived = Column(Integer) - totalUnitsTrained = Column(Integer) - totalUnitsLost = Column(Integer) - enemytotalUnitsKilled = Column(Integer) - infantryUnitsTrained = Column(Integer) - infantryUnitsLost = Column(Integer) - enemyInfantryUnitsKilled = Column(Integer) - workerUnitsTrained = Column(Integer) - workerUnitsLost = Column(Integer) - enemyWorkerUnitsKilled = Column(Integer) - femaleCitizenUnitsTrained = Column(Integer) - femaleCitizenUnitsLost = Column(Integer) - enemyFemaleCitizenUnitsKilled = Column(Integer) - cavalryUnitsTrained = Column(Integer) - cavalryUnitsLost = Column(Integer) - enemyCavalryUnitsKilled = Column(Integer) - championUnitsTrained = Column(Integer) - championUnitsLost = Column(Integer) - enemyChampionUnitsKilled = Column(Integer) - heroUnitsTrained = Column(Integer) - heroUnitsLost = Column(Integer) - enemyHeroUnitsKilled = Column(Integer) - shipUnitsTrained = Column(Integer) - shipUnitsLost = Column(Integer) - enemyShipUnitsKilled = Column(Integer) - traderUnitsTrained = Column(Integer) - traderUnitsLost = Column(Integer) - enemyTraderUnitsKilled = Column(Integer) - totalBuildingsConstructed = Column(Integer) - totalBuildingsLost = Column(Integer) - enemytotalBuildingsDestroyed = Column(Integer) - civCentreBuildingsConstructed = Column(Integer) - civCentreBuildingsLost = Column(Integer) - enemyCivCentreBuildingsDestroyed = Column(Integer) - houseBuildingsConstructed = Column(Integer) - houseBuildingsLost = Column(Integer) - enemyHouseBuildingsDestroyed = Column(Integer) - economicBuildingsConstructed = Column(Integer) - economicBuildingsLost = Column(Integer) - enemyEconomicBuildingsDestroyed = Column(Integer) - outpostBuildingsConstructed = Column(Integer) - outpostBuildingsLost = Column(Integer) - enemyOutpostBuildingsDestroyed = Column(Integer) - militaryBuildingsConstructed = Column(Integer) - militaryBuildingsLost = Column(Integer) - enemyMilitaryBuildingsDestroyed = Column(Integer) - fortressBuildingsConstructed = Column(Integer) - fortressBuildingsLost = Column(Integer) - enemyFortressBuildingsDestroyed = Column(Integer) - wonderBuildingsConstructed = Column(Integer) - wonderBuildingsLost = Column(Integer) - enemyWonderBuildingsDestroyed = Column(Integer) - woodBought = Column(Integer) - foodBought = Column(Integer) - stoneBought = Column(Integer) - metalBought = Column(Integer) - tradeIncome = Column(Integer) - percentMapExplored = Column(Integer) - -class Game(Base): - __tablename__ = 'games' - - id = Column(Integer, primary_key=True) - map = Column(String(80)) - duration = Column(Integer) - teamsLocked = Column(Boolean) - matchID = Column(String(20)) - winner_id = Column(Integer, ForeignKey('players.id')) - player_info = relationship('PlayerInfo', backref='game') - players = relationship('Player', secondary='players_info') - - -if __name__ == '__main__': - Base.metadata.create_all(engine) - Index: XpartaMuPP/XpartaMuPP.py =================================================================== --- XpartaMuPP/XpartaMuPP.py +++ XpartaMuPP/XpartaMuPP.py @@ -1,663 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -"""Copyright (C) 2018 Wildfire Games. - * This file is part of 0 A.D. - * - * 0 A.D. is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 2 of the License, or - * (at your option) any later version. - * - * 0 A.D. is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with 0 A.D. If not, see . -""" - -import logging, time, traceback -from optparse import OptionParser - -import sleekxmpp -from sleekxmpp.stanza import Iq -from sleekxmpp.xmlstream import ElementBase, register_stanza_plugin, ET -from sleekxmpp.xmlstream.handler import Callback -from sleekxmpp.xmlstream.matcher import StanzaPath - -## Class to tracks all games in the lobby ## -class GameList(): - def __init__(self): - self.gameList = {} - def addGame(self, JID, data): - """ - Add a game - """ - data['players-init'] = data['players'] - data['nbp-init'] = data['nbp'] - data['state'] = 'init' - self.gameList[str(JID)] = data - def removeGame(self, JID): - """ - Remove a game attached to a JID - """ - del self.gameList[str(JID)] - def getAllGames(self): - """ - Returns all games - """ - return self.gameList - def changeGameState(self, JID, data): - """ - Switch game state between running and waiting - """ - JID = str(JID) - if JID in self.gameList: - if self.gameList[JID]['nbp-init'] > data['nbp']: - logging.debug("change game (%s) state from %s to %s", JID, self.gameList[JID]['state'], 'waiting') - self.gameList[JID]['state'] = 'waiting' - else: - logging.debug("change game (%s) state from %s to %s", JID, self.gameList[JID]['state'], 'running') - self.gameList[JID]['state'] = 'running' - self.gameList[JID]['nbp'] = data['nbp'] - self.gameList[JID]['players'] = data['players'] - if 'startTime' not in self.gameList[JID]: - self.gameList[JID]['startTime'] = str(round(time.time())) - -## Class for custom player stanza extension ## -class PlayerXmppPlugin(ElementBase): - name = 'query' - namespace = 'jabber:iq:player' - interfaces = set(('online')) - sub_interfaces = interfaces - plugin_attrib = 'player' - - def addPlayerOnline(self, player): - playerXml = ET.fromstring("%s" % player) - self.xml.append(playerXml) - -## Class for custom gamelist stanza extension ## -class GameListXmppPlugin(ElementBase): - name = 'query' - namespace = 'jabber:iq:gamelist' - interfaces = set(('game', 'command')) - sub_interfaces = interfaces - plugin_attrib = 'gamelist' - - def addGame(self, data): - itemXml = ET.Element("game", data) - self.xml.append(itemXml) - - def getGame(self): - """ - Required to parse incoming stanzas with this - extension. - """ - game = self.xml.find('{%s}game' % self.namespace) - data = {} - for key, item in game.items(): - data[key] = item - return data - -## Class for custom boardlist and ratinglist stanza extension ## -class BoardListXmppPlugin(ElementBase): - name = 'query' - namespace = 'jabber:iq:boardlist' - interfaces = set(('board', 'command', 'recipient')) - sub_interfaces = interfaces - plugin_attrib = 'boardlist' - def addCommand(self, command): - commandXml = ET.fromstring("%s" % command) - self.xml.append(commandXml) - def addRecipient(self, recipient): - recipientXml = ET.fromstring("%s" % recipient) - self.xml.append(recipientXml) - def addItem(self, name, rating): - itemXml = ET.Element("board", {"name": name, "rating": rating}) - self.xml.append(itemXml) - -## Class for custom gamereport stanza extension ## -class GameReportXmppPlugin(ElementBase): - name = 'report' - namespace = 'jabber:iq:gamereport' - plugin_attrib = 'gamereport' - interfaces = ('game', 'sender') - sub_interfaces = interfaces - def addSender(self, sender): - senderXml = ET.fromstring("%s" % sender) - self.xml.append(senderXml) - def addGame(self, gr): - game = ET.fromstring(str(gr)).find('{%s}game' % self.namespace) - self.xml.append(game) - def getGame(self): - """ - Required to parse incoming stanzas with this - extension. - """ - game = self.xml.find('{%s}game' % self.namespace) - data = {} - for key, item in game.items(): - data[key] = item - return data - -## Class for custom profile ## -class ProfileXmppPlugin(ElementBase): - name = 'query' - namespace = 'jabber:iq:profile' - interfaces = set(('profile', 'command', 'recipient')) - sub_interfaces = interfaces - plugin_attrib = 'profile' - def addCommand(self, command): - commandXml = ET.fromstring("%s" % command) - self.xml.append(commandXml) - def addRecipient(self, recipient): - recipientXml = ET.fromstring("%s" % recipient) - self.xml.append(recipientXml) - def addItem(self, player, rating, highestRating, rank, totalGamesPlayed, wins, losses): - itemXml = ET.Element("profile", {"player": player, "rating": rating, "highestRating": highestRating, - "rank" : rank, "totalGamesPlayed" : totalGamesPlayed, "wins" : wins, - "losses" : losses}) - self.xml.append(itemXml) - -## Main class which handles IQ data and sends new data ## -class XpartaMuPP(sleekxmpp.ClientXMPP): - """ - A simple list provider - """ - def __init__(self, sjid, password, room, nick, ratingsbot): - sleekxmpp.ClientXMPP.__init__(self, sjid, password) - self.sjid = sjid - self.room = room - self.nick = nick - self.ratingsBotWarned = False - - self.ratingsBot = ratingsbot - # Game collection - self.gameList = GameList() - - # Store mapping of nicks and XmppIDs, attached via presence stanza - self.nicks = {} - self.presences = {} # Obselete when XEP-0060 is implemented. - - self.lastLeft = "" - - register_stanza_plugin(Iq, PlayerXmppPlugin) - register_stanza_plugin(Iq, GameListXmppPlugin) - register_stanza_plugin(Iq, BoardListXmppPlugin) - register_stanza_plugin(Iq, GameReportXmppPlugin) - register_stanza_plugin(Iq, ProfileXmppPlugin) - - self.register_handler(Callback('Iq Player', - StanzaPath('iq/player'), - self.iqhandler, - instream=True)) - self.register_handler(Callback('Iq Gamelist', - StanzaPath('iq/gamelist'), - self.iqhandler, - instream=True)) - self.register_handler(Callback('Iq Boardlist', - StanzaPath('iq/boardlist'), - self.iqhandler, - instream=True)) - self.register_handler(Callback('Iq GameReport', - StanzaPath('iq/gamereport'), - self.iqhandler, - instream=True)) - self.register_handler(Callback('Iq Profile', - StanzaPath('iq/profile'), - self.iqhandler, - instream=True)) - - self.add_event_handler("session_start", self.start) - self.add_event_handler("muc::%s::got_online" % self.room, self.muc_online) - self.add_event_handler("muc::%s::got_offline" % self.room, self.muc_offline) - self.add_event_handler("groupchat_message", self.muc_message) - self.add_event_handler("changed_status", self.presence_change) - - def start(self, event): - """ - Process the session_start event - """ - self.plugin['xep_0045'].joinMUC(self.room, self.nick) - self.send_presence() - self.get_roster() - logging.info("XpartaMuPP started") - - def muc_online(self, presence): - """ - Process presence stanza from a chat room. - """ - if self.ratingsBot in self.nicks: - self.relayRatingListRequest(self.ratingsBot) - self.relayPlayerOnline(presence['muc']['jid']) - if presence['muc']['nick'] != self.nick: - # If it doesn't already exist, store player JID mapped to their nick. - if str(presence['muc']['jid']) not in self.nicks: - self.nicks[str(presence['muc']['jid'])] = presence['muc']['nick'] - self.presences[str(presence['muc']['jid'])] = "available" - # Check the jid isn't already in the lobby. - # Send Gamelist to new player. - self.sendGameList(presence['muc']['jid']) - logging.debug("Client '%s' connected with a nick of '%s'." %(presence['muc']['jid'], presence['muc']['nick'])) - - def muc_offline(self, presence): - """ - Process presence stanza from a chat room. - """ - # Clean up after a player leaves - if presence['muc']['nick'] != self.nick: - # Delete any games they were hosting. - for JID in self.gameList.getAllGames(): - if JID == str(presence['muc']['jid']): - self.gameList.removeGame(JID) - self.sendGameList() - break - # Remove them from the local player list. - self.lastLeft = str(presence['muc']['jid']) - if str(presence['muc']['jid']) in self.nicks: - del self.nicks[str(presence['muc']['jid'])] - del self.presences[str(presence['muc']['jid'])] - if presence['muc']['nick'] == self.ratingsBot: - self.ratingsBotWarned = False - - def muc_message(self, msg): - """ - Process new messages from the chatroom. - """ - if msg['mucnick'] != self.nick and self.nick.lower() in msg['body'].lower(): - self.send_message(mto=msg['from'].bare, - mbody="I am the administrative bot in this lobby and cannot participate in any games.", - mtype='groupchat') - - def presence_change(self, presence): - """ - Processes presence change - """ - prefix = "%s/" % self.room - nick = str(presence['from']).replace(prefix, "") - for JID in self.nicks: - if self.nicks[JID] == nick: - if self.presences[JID] == 'dnd' and (str(presence['type']) == "available" or str(presence['type']) == "away"): - self.sendGameList(JID) - self.relayBoardListRequest(JID) - self.presences[JID] = str(presence['type']) - break - - - def iqhandler(self, iq): - """ - Handle the custom stanzas - This method should be very robust because we could receive anything - """ - if iq['type'] == 'error': - logging.error('iqhandler error' + iq['error']['condition']) - #self.disconnect() - elif iq['type'] == 'get': - """ - Request lists. - """ - # Send lists/register on leaderboard; depreciated once muc_online - # can send lists/register automatically on joining the room. - if 'boardlist' in iq.loaded_plugins: - command = iq['boardlist']['command'] - try: - self.relayBoardListRequest(iq['from']) - except: - traceback.print_exc() - logging.error("Failed to process leaderboardlist request from %s" % iq['from'].bare) - elif 'profile' in iq.loaded_plugins: - command = iq['profile']['command'] - try: - self.relayProfileRequest(iq['from'], command) - except: - pass - else: - logging.error("Unknown 'get' type stanza request from %s" % iq['from'].bare) - elif iq['type'] == 'result': - """ - Iq successfully received - """ - if 'boardlist' in iq.loaded_plugins: - recipient = iq['boardlist']['recipient'] - self.relayBoardList(iq['boardlist'], recipient) - elif 'profile' in iq.loaded_plugins: - recipient = iq['profile']['recipient'] - player = iq['profile']['command'] - self.relayProfile(iq['profile'], player, recipient) - else: - pass - elif iq['type'] == 'set': - if 'gamelist' in iq.loaded_plugins: - """ - Register-update / unregister a game - """ - command = iq['gamelist']['command'] - if command == 'register': - # Add game - try: - if iq['from'] in self.nicks: - self.gameList.addGame(iq['from'], iq['gamelist']['game']) - self.sendGameList() - except: - traceback.print_exc() - logging.error("Failed to process game registration data") - elif command == 'unregister': - # Remove game - try: - self.gameList.removeGame(iq['from']) - self.sendGameList() - except: - traceback.print_exc() - logging.error("Failed to process game unregistration data") - - elif command == 'changestate': - # Change game status (waiting/running) - try: - self.gameList.changeGameState(iq['from'], iq['gamelist']['game']) - self.sendGameList() - except: - traceback.print_exc() - logging.error("Failed to process changestate data. Trying to add game") - try: - if iq['from'] in self.nicks: - self.gameList.addGame(iq['from'], iq['gamelist']['game']) - self.sendGameList() - except: - pass - else: - logging.error("Failed to process command '%s' received from %s" % command, iq['from'].bare) - elif 'gamereport' in iq.loaded_plugins: - """ - Client is reporting end of game statistics - """ - try: - self.relayGameReport(iq['gamereport'], iq['from']) - except: - traceback.print_exc() - logging.error("Failed to update game statistics for %s" % iq['from'].bare) - else: - logging.error("Failed to process stanza type '%s' received from %s" % iq['type'], iq['from'].bare) - - def sendGameList(self, to = ""): - """ - Send a massive stanza with the whole game list. - If no target is passed the gamelist is broadcasted - to all clients. - """ - games = self.gameList.getAllGames() - - stz = GameListXmppPlugin() - - ## Pull games and add each to the stanza - for JIDs in games: - g = games[JIDs] - stz.addGame(g) - - ## Set additional IQ attributes - iq = self.Iq() - iq['type'] = 'result' - iq.setPayload(stz) - if to == "": - for JID in list(self.presences): - if self.presences[JID] != "available" and self.presences[JID] != "away": - continue - iq['to'] = JID - - ## Try sending the stanza - try: - iq.send(block=False, now=True) - except: - logging.error("Failed to send game list") - else: - ## Check recipient exists - if str(to) not in self.nicks: - logging.error("No player with the XmPP ID '%s' known to send gamelist to." % str(to)) - return - iq['to'] = to - - ## Try sending the stanza - try: - iq.send(block=False, now=True) - except: - logging.error("Failed to send game list") - - def relayBoardListRequest(self, recipient): - """ - Send a boardListRequest to EcheLOn. - """ - to = self.ratingsBot - if to not in self.nicks: - self.warnRatingsBotOffline() - return - stz = BoardListXmppPlugin() - iq = self.Iq() - iq['type'] = 'get' - stz.addCommand('getleaderboard') - stz.addRecipient(recipient) - iq.setPayload(stz) - ## Set additional IQ attributes - iq['to'] = to - ## Try sending the stanza - try: - iq.send(block=False, now=True) - except: - logging.error("Failed to send leaderboard list request") - - def relayRatingListRequest(self, recipient): - """ - Send a ratingListRequest to EcheLOn. - """ - to = self.ratingsBot - if to not in self.nicks: - self.warnRatingsBotOffline() - return - stz = BoardListXmppPlugin() - iq = self.Iq() - iq['type'] = 'get' - stz.addCommand('getratinglist') - iq.setPayload(stz) - ## Set additional IQ attributes - iq['to'] = to - ## Try sending the stanza - try: - iq.send(block=False, now=True) - except: - logging.error("Failed to send rating list request") - - def relayProfileRequest(self, recipient, player): - """ - Send a profileRequest to EcheLOn. - """ - to = self.ratingsBot - if to not in self.nicks: - self.warnRatingsBotOffline() - return - stz = ProfileXmppPlugin() - iq = self.Iq() - iq['type'] = 'get' - stz.addCommand(player) - stz.addRecipient(recipient) - iq.setPayload(stz) - ## Set additional IQ attributes - iq['to'] = to - ## Try sending the stanza - try: - iq.send(block=False, now=True) - except: - logging.error("Failed to send profile request") - - def relayPlayerOnline(self, jid): - """ - Tells EcheLOn that someone comes online. - """ - ## Check recipient exists - to = self.ratingsBot - if to not in self.nicks: - return - stz = PlayerXmppPlugin() - iq = self.Iq() - iq['type'] = 'set' - stz.addPlayerOnline(jid) - iq.setPayload(stz) - ## Set additional IQ attributes - iq['to'] = to - ## Try sending the stanza - try: - iq.send(block=False, now=True) - except: - logging.error("Failed to send player muc online") - - def relayGameReport(self, data, sender): - """ - Relay a game report to EcheLOn. - """ - to = self.ratingsBot - if to not in self.nicks: - self.warnRatingsBotOffline() - return - stz = GameReportXmppPlugin() - stz.addGame(data) - stz.addSender(sender) - iq = self.Iq() - iq['type'] = 'set' - iq.setPayload(stz) - ## Set additional IQ attributes - iq['to'] = to - ## Try sending the stanza - try: - iq.send(block=False, now=True) - except: - logging.error("Failed to send game report request") - - def relayBoardList(self, boardList, to = ""): - """ - Send the whole leaderboard list. - If no target is passed the boardlist is broadcasted - to all clients. - """ - iq = self.Iq() - iq['type'] = 'result' - iq.setPayload(boardList) - ## Check recipient exists - if to == "": - # Rating List - for JID in list(self.presences): - if self.presences[JID] != "available" and self.presences[JID] != "away": - continue - ## Set additional IQ attributes - iq['to'] = JID - ## Try sending the stanza - try: - iq.send(block=False, now=True) - except: - logging.error("Failed to send rating list") - else: - # Leaderboard or targeted rating list - if str(to) not in self.nicks: - logging.error("No player with the XmPP ID '%s' known to send boardlist to" % str(to)) - return - ## Set additional IQ attributes - iq['to'] = to - ## Try sending the stanza - try: - iq.send(block=False, now=True) - except: - logging.error("Failed to send leaderboard list") - - def relayProfile(self, data, player, to): - """ - Send the player profile to a specified target. - """ - if to == "": - logging.error("Failed to send profile, target unspecified") - return - - iq = self.Iq() - iq['type'] = 'result' - iq.setPayload(data) - ## Check recipient exists - if str(to) not in self.nicks: - logging.error("No player with the XmPP ID '%s' known to send profile to" % str(to)) - return - - ## Set additional IQ attributes - iq['to'] = to - - ## Try sending the stanza - try: - iq.send(block=False, now=True) - except: - traceback.print_exc() - logging.error("Failed to send profile") - - def warnRatingsBotOffline(self): - """ - Warns that the ratings bot is offline. - """ - if not self.ratingsBotWarned: - logging.warn("Ratings bot '%s' is offline" % str(self.ratingsBot)) - self.ratingsBotWarned = True - -## Main Program ## -if __name__ == '__main__': - # Setup the command line arguments. - optp = OptionParser() - - # Output verbosity options. - optp.add_option('-q', '--quiet', help='set logging to ERROR', - action='store_const', dest='loglevel', - const=logging.ERROR, default=logging.INFO) - optp.add_option('-d', '--debug', help='set logging to DEBUG', - action='store_const', dest='loglevel', - const=logging.DEBUG, default=logging.INFO) - optp.add_option('-v', '--verbose', help='set logging to COMM', - action='store_const', dest='loglevel', - const=5, default=logging.INFO) - - # XpartaMuPP configuration options - optp.add_option('-m', '--domain', help='set xpartamupp domain', - action='store', dest='xdomain', - default="lobby.wildfiregames.com") - optp.add_option('-l', '--login', help='set xpartamupp login', - action='store', dest='xlogin', - default="xpartamupp") - optp.add_option('-p', '--password', help='set xpartamupp password', - action='store', dest='xpassword', - default="XXXXXX") - optp.add_option('-n', '--nickname', help='set xpartamupp nickname', - action='store', dest='xnickname', - default="WFGbot") - optp.add_option('-r', '--room', help='set muc room to join', - action='store', dest='xroom', - default="arena") - optp.add_option('-e', '--elo', help='set rating bot username', - action='store', dest='xratingsbot', - default="disabled") - - # ejabberd server options - optp.add_option('-s', '--server', help='address of the ejabberd server', - action='store', dest='xserver', - default="localhost") - optp.add_option('-t', '--disable-tls', help='Pass this argument to connect without TLS encryption', - action='store_true', dest='xdisabletls', - default=False) - - opts, args = optp.parse_args() - - # Setup logging. - logging.basicConfig(level=opts.loglevel, - format='%(asctime)s %(levelname)-8s %(message)s', datefmt='%Y-%m-%d %H:%M:%S') - - # XpartaMuPP - xmpp = XpartaMuPP(opts.xlogin+'@'+opts.xdomain+'/CC', opts.xpassword, opts.xroom+'@conference.'+opts.xdomain, opts.xnickname, opts.xratingsbot+'@'+opts.xdomain+'/CC') - xmpp.register_plugin('xep_0030') # Service Discovery - xmpp.register_plugin('xep_0004') # Data Forms - xmpp.register_plugin('xep_0045') # Multi-User Chat # used - xmpp.register_plugin('xep_0060') # PubSub - xmpp.register_plugin('xep_0199') # XMPP Ping - - if xmpp.connect((opts.xserver, 5222), True, not opts.xdisabletls): - xmpp.process(threaded=False) - else: - logging.error("Unable to connect") Index: setup.py =================================================================== --- setup.py +++ setup.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 + +"""setup.py for 0ad XMPP lobby bots.""" + +from setuptools import find_packages, setup + +setup( + name='xpartamupp', + version='0.23', + description='Multiplayer lobby bots for 0ad', + packages=find_packages(), + entry_points={ + 'console_scripts': [ + 'echelon=xpartamupp.echelon:main', + 'xpartamupp=xpartamupp.xpartamupp:main', + 'echelon-db=xpartamupp.lobby_ranking:main', + ] + }, + install_requires=[ + 'dnspython', + 'sleekxmpp', + 'sqlalchemy', + ], + tests_require=[ + 'coverage', + 'hypothesis', + 'parameterized', + ], + classifiers=[ + 'Development Status :: 3 - Alpha', + 'License :: OSI Approved :: GNU General Public License v2 or later (GPLv2+)', + 'Operating System :: OS Independent', + 'Programming Language :: Python', + 'Programming Language :: Python :: 3.4', + 'Programming Language :: Python :: 3.5', + 'Programming Language :: Python :: 3.6', + 'Topic :: Games/Entertainment', + 'Topic :: Internet :: XMPP', + ], + zip_safe=False, + test_suite='tests', +) Index: tests/test_echelon.py =================================================================== --- tests/test_echelon.py +++ tests/test_echelon.py @@ -0,0 +1,173 @@ +# Copyright (C) 2018 Wildfire Games. +# This file is part of 0 A.D. +# +# 0 A.D. is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 2 of the License, or +# (at your option) any later version. +# +# 0 A.D. is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with 0 A.D. If not, see . + +# pylint: disable=no-self-use + +"""Tests for EcheLOn.""" + +import sys + +from argparse import Namespace +from unittest import TestCase +from unittest.mock import Mock, call, patch + +from parameterized import parameterized +from sleekxmpp.jid import JID +from sqlalchemy import create_engine + +from xpartamupp.echelon import main, parse_args, Leaderboard +from xpartamupp.lobby_ranking import Base + + +class TestLeaderboard(TestCase): + """Test Leaderboard functionality.""" + + def setUp(self): + """Set up a leaderboard instance.""" + db_url = 'sqlite://' + engine = create_engine(db_url) + Base.metadata.create_all(engine) + with patch('xpartamupp.echelon.create_engine') as create_engine_mock: + create_engine_mock.return_value = engine + self.leaderboard = Leaderboard(db_url) + + def test_create_player(self): + """Test creating a new player.""" + player = self.leaderboard.get_or_create_player(JID('john@localhost')) + self.assertEqual(player.id, 1) + self.assertEqual(player.jid, 'john@localhost') + self.assertEqual(player.rating, -1) + self.assertEqual(player.highest_rating, None) + self.assertEqual(player.games, []) + self.assertEqual(player.games_info, []) + self.assertEqual(player.games_won, []) + + def test_get_profile_no_player(self): + """Test profile retrieval fro not existing player.""" + profile = self.leaderboard.get_profile(JID('john@localhost')) + self.assertEqual(profile, dict()) + + def test_get_profile_player_without_games(self): + """Test profile retrieval for existing player.""" + self.leaderboard.get_or_create_player(JID('john@localhost')) + profile = self.leaderboard.get_profile(JID('john@localhost')) + self.assertDictEqual(profile, {'highestRating': None, 'losses': 0, 'totalGamesPlayed': 0, + 'wins': 0}) + + +class TestReportManager(TestCase): + """Test ReportManager functionality.""" + + pass + + +class TestArgumentParsing(TestCase): + """Test handling of parsing command line parameters.""" + + @parameterized.expand([ + ([], Namespace(domain='lobby.wildfiregames.com', login='EcheLOn', log_level=30, + nickname='RatingsBot', password='XXXXXX', room='arena', + database_url='sqlite:///lobby_rankings.sqlite3')), + (['--debug'], + Namespace(domain='lobby.wildfiregames.com', login='EcheLOn', log_level=10, + nickname='RatingsBot', password='XXXXXX', room='arena', + database_url='sqlite:///lobby_rankings.sqlite3')), + (['--quiet'], + Namespace(domain='lobby.wildfiregames.com', login='EcheLOn', log_level=40, + nickname='RatingsBot', password='XXXXXX', room='arena', + database_url='sqlite:///lobby_rankings.sqlite3')), + (['--verbose'], + Namespace(domain='lobby.wildfiregames.com', login='EcheLOn', log_level=20, + nickname='RatingsBot', password='XXXXXX', room='arena', + database_url='sqlite:///lobby_rankings.sqlite3')), + (['-m', 'lobby.domain.tld'], + Namespace(domain='lobby.domain.tld', login='EcheLOn', log_level=30, nickname='RatingsBot', + password='XXXXXX', room='arena', + database_url='sqlite:///lobby_rankings.sqlite3')), + (['--domain=lobby.domain.tld'], + Namespace(domain='lobby.domain.tld', login='EcheLOn', log_level=30, nickname='RatingsBot', + password='XXXXXX', room='arena', + database_url='sqlite:///lobby_rankings.sqlite3')), + (['-m' 'lobby.domain.tld', '-l', 'bot', '-p', '123456', '-n', 'Bot', '-r', 'arena123', + '-v'], + Namespace(domain='lobby.domain.tld', login='bot', log_level=20, nickname='Bot', + password='123456', room='arena123', + database_url='sqlite:///lobby_rankings.sqlite3')), + (['--domain=lobby.domain.tld', '--login=bot', '--password=123456', '--nickname=Bot', + '--room=arena123', '--database-url=sqlite:////tmp/db.sqlite3', '--verbose'], + Namespace(domain='lobby.domain.tld', login='bot', log_level=20, nickname='Bot', + password='123456', room='arena123', + database_url='sqlite:////tmp/db.sqlite3')), + ]) + def test_valid(self, cmd_args, expected_args): + """Test valid parameter combinations.""" + self.assertEqual(parse_args(cmd_args), expected_args) + + @parameterized.expand([ + (['-f'],), + (['--foo'],), + (['--debug', '--quiet'],), + (['--quiet', '--verbose'],), + (['--debug', '--verbose'],), + (['--debug', '--quiet', '--verbose'],), + ]) + def test_invalid(self, cmd_args): + """Test invalid parameter combinations.""" + with self.assertRaises(SystemExit): + parse_args(cmd_args) + + +class TestMain(TestCase): + """Test main method.""" + + def test_success(self): + """Test successful execution.""" + with patch('xpartamupp.echelon.parse_args') as args_mock, \ + patch('xpartamupp.echelon.Leaderboard') as leaderboard_mock, \ + patch('xpartamupp.echelon.EcheLOn') as xmpp_mock: + args_mock.return_value = Mock(log_level=30, login='EcheLOn', + domain='lobby.wildfiregames.com', password='XXXXXX', + room='arena', nickname='RatingsBot', + database_url='sqlite:///lobby_rankings.sqlite3') + main() + args_mock.assert_called_once_with(sys.argv[1:]) + leaderboard_mock.assert_called_once_with('sqlite:///lobby_rankings.sqlite3') + xmpp_mock().register_plugin.assert_has_calls([call('xep_0004'), call('xep_0030'), + call('xep_0045'), call('xep_0060'), + call('xep_0199', {'keepalive': True})], + any_order=True) + xmpp_mock().connect.assert_called_once_with() + xmpp_mock().process.assert_called_once_with() + + def test_failing_connect(self): + """Test failing connect to XMPP server.""" + with patch('xpartamupp.echelon.parse_args') as args_mock, \ + patch('xpartamupp.echelon.Leaderboard') as leaderboard_mock, \ + patch('xpartamupp.echelon.EcheLOn') as xmpp_mock: + args_mock.return_value = Mock(log_level=30, login='EcheLOn', + domain='lobby.wildfiregames.com', password='XXXXXX', + room='arena', nickname='RatingsBot', + database_url='sqlite:///lobby_rankings.sqlite3') + xmpp_mock().connect.return_value = False + main() + args_mock.assert_called_once_with(sys.argv[1:]) + leaderboard_mock.assert_called_once_with('sqlite:///lobby_rankings.sqlite3') + xmpp_mock().register_plugin.assert_has_calls([call('xep_0004'), call('xep_0030'), + call('xep_0045'), call('xep_0060'), + call('xep_0199', {'keepalive': True})], + any_order=True) + xmpp_mock().connect.assert_called_once_with() + xmpp_mock().process.assert_not_called() Index: tests/test_elo.py =================================================================== --- tests/test_elo.py +++ tests/test_elo.py @@ -0,0 +1,150 @@ +# Copyright (C) 2018 Wildfire Games. +# This file is part of 0 A.D. +# +# 0 A.D. is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 2 of the License, or +# (at your option) any later version. +# +# 0 A.D. is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with 0 A.D. If not, see . + +"""Tests for the ELO-implementation.""" + +from unittest import TestCase + +from hypothesis import assume, example, given +from hypothesis import strategies as st +from parameterized import parameterized + +from xpartamupp.elo import (get_rating_adjustment, ANTI_INFLATION, ELO_K_FACTOR_CONSTANT_RATING, + ELO_SURE_WIN_DIFFERENCE, VOLATILITY_CONSTANT) + + +class TestELO(TestCase): + """Test behavior of ELO calculation.""" + + @parameterized.expand([ + ([1000, 1000, 0, 0, 1], 82), + ([1000, 1000, 0, 0, -1], -83), + ([1000, 1000, 0, 0, 0], 0), + ([1200, 1200, 0, 0, 1], 78), + ([1200, 1200, 0, 0, -1], -78), + ([1200, 1200, 0, 0, 0], 0), + ([1200, 1200, 1, 0, 1], 65), + ([1200, 1200, 1, 0, 0], 0), + ([1200, 1200, 1, 0, -1], -65), + ([1200, 1200, 100, 0, 1], 16), + ([1200, 1200, 100, 0, 0], 0), + ([1200, 1200, 100, 0, -1], -16), + ([1200, 1200, 1000, 0, 1], 16), + ([1200, 1200, 1000, 0, 0], 0), + ([1200, 1200, 1000, 0, -1], -16), + ([1200, 1200, 0, 1, 1], 78), + ([1200, 1200, 0, 1, 0], 0), + ([1200, 1200, 0, 1, -1], -78), + ([1200, 1200, 0, 100, 1], 78), + ([1200, 1200, 0, 100, 0], 0), + ([1200, 1200, 0, 100, -1], -78), + ([1200, 1200, 0, 1000, 1], 78), + ([1200, 1200, 0, 1000, 0], 0), + ([1200, 1200, 0, 1000, -1], -78), + ([1400, 1000, 0, 0, 1], 24), + ([1400, 1000, 0, 0, 0], -49), + ([1400, 1000, 0, 0, -1], -122), + ([1000, 1400, 0, 0, 1], 137), + ([1000, 1400, 0, 0, 0], 55), + ([1000, 1400, 0, 0, -1], -28), + ([2200, 2300, 0, 0, 1], 70), + ([2200, 2300, 0, 0, 0], 10), + ([2200, 2300, 0, 0, -1], -50), + ]) + def test_valid_adjustments(self, args, expected_adjustment): + """Test correctness of valid rating adjustments.""" + self.assertEqual(get_rating_adjustment(*args), expected_adjustment) + + @given(st.integers(min_value=ELO_K_FACTOR_CONSTANT_RATING), + st.integers(min_value=-2099, max_value=ELO_SURE_WIN_DIFFERENCE - 1), st.integers(), + st.integers(), + st.integers(min_value=-1, max_value=1)) + @example(ELO_K_FACTOR_CONSTANT_RATING + 300, 0, 0, 0, 1) + def test_constant_rating(self, rating_player1, difference_player2, played_games_player1, + played_games_player2, result): + """Test that points gained are constant above a threshold.""" + volatility = 50.0 * (min(max(0, played_games_player1), VOLATILITY_CONSTANT) / + VOLATILITY_CONSTANT + 0.25) / 1.25 + rating_adjustment = (difference_player2 + result * ELO_SURE_WIN_DIFFERENCE) / volatility \ + - ANTI_INFLATION + if result == 1: + expected_adjustment = max(0.0, rating_adjustment) + elif result == -1: + expected_adjustment = min(0.0, rating_adjustment) + else: + expected_adjustment = rating_adjustment + + self.assertEqual(get_rating_adjustment(rating_player1, rating_player1 + difference_player2, + played_games_player1, played_games_player2, result), + round(expected_adjustment)) + + @given(st.data()) + def test_sure_win(self, data): + """Test behavior if winning player 1 has >600 points more. + + In this case the winning player shouldn't gain points, as it + was a "sure win" and the loosing player shouldn't loose + points. + """ + rating_player1 = data.draw(st.integers(min_value=-1599)) + difference_player2 = data.draw(st.integers(min_value=ELO_SURE_WIN_DIFFERENCE)) + assume(rating_player1 - difference_player2 > -2200) + played_games_player1 = data.draw(st.integers()) + played_games_player2 = data.draw(st.integers()) + + self.assertEqual(get_rating_adjustment(rating_player1, + rating_player1 - difference_player2, + played_games_player1, played_games_player2, 1), + 0) + self.assertEqual(get_rating_adjustment(rating_player1 - difference_player2, + rating_player1, played_games_player2, + played_games_player1, -1), 0) + + @given(st.integers(min_value=-2199), st.integers(min_value=ELO_SURE_WIN_DIFFERENCE), + st.integers(), + st.integers()) + @example(1000, ELO_SURE_WIN_DIFFERENCE, 0, 0) + def test_sure_loss(self, rating_player1, difference_player2, played_games_player1, + played_games_player2): + """Test behavior if winning player 2 has >600 points more. + + In this case the winning player shouldn't gain points, as it + was a "sure win" and the loosing player shouldn't loose + points. + """ + self.assertEqual(get_rating_adjustment(rating_player1, + rating_player1 - difference_player2 * -1, + played_games_player1, played_games_player2, -1), + 0) + self.assertEqual(get_rating_adjustment(rating_player1 - difference_player2 * -1, + rating_player1, played_games_player2, + played_games_player1, 1), 0) + + @given(st.integers(max_value=-2200), st.integers(), + st.integers(), + st.integers(), + st.one_of(st.just(1), st.just(-1))) + @example(-2200, 2000, 0, 0, 1) + @example(2000, -2200, 0, 0, 1) + def test_minus_2200_bug_workaround(self, rating_player1, rating_player2, + played_games_player1, played_games_player2, result): + """Test workaround for -2200 bug.""" + with self.assertRaises(ValueError): + get_rating_adjustment(rating_player1, rating_player2, played_games_player1, + played_games_player2, result) + with self.assertRaises(ValueError): + get_rating_adjustment(rating_player2, rating_player1, played_games_player1, + played_games_player2, result) Index: tests/test_lobby_ranking.py =================================================================== --- tests/test_lobby_ranking.py +++ tests/test_lobby_ranking.py @@ -0,0 +1,70 @@ +# Copyright (C) 2018 Wildfire Games. +# This file is part of 0 A.D. +# +# 0 A.D. is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 2 of the License, or +# (at your option) any later version. +# +# 0 A.D. is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with 0 A.D. If not, see . + +# pylint: disable=no-self-use + +"""Tests for the database schema.""" + +import sys + +from argparse import Namespace +from unittest import TestCase +from unittest.mock import Mock, patch + +from parameterized import parameterized + +from xpartamupp.lobby_ranking import main, parse_args + + +class TestArgumentParsing(TestCase): + """Test handling of parsing command line parameters.""" + + @parameterized.expand([ + (['create'], Namespace(action='create', database_url='sqlite:///lobby_rankings.sqlite3')), + (['--database-url', 'sqlite:////tmp/db.sqlite3', 'create'], + Namespace(action='create', database_url='sqlite:////tmp/db.sqlite3')), + ]) + def test_valid(self, cmd_args, expected_args): + """Test valid parameter combinations.""" + self.assertEqual(parse_args(cmd_args), expected_args) + + @parameterized.expand([ + ([],), + (['--database-url=sqlite:////tmp/db.sqlite3'],), + ]) + def test_missing_action(self, cmd_args): + """Test invalid parameter combinations.""" + with self.assertRaises(SystemExit): + parse_args(cmd_args) + + +class TestMain(TestCase): + """Test main method.""" + + def test_success(self): + """Test successful execution.""" + with patch('xpartamupp.lobby_ranking.parse_args') as args_mock, \ + patch('xpartamupp.lobby_ranking.create_engine') as create_engine_mock, \ + patch('xpartamupp.lobby_ranking.Base') as declarative_base_mock: + args_mock.return_value = Mock(action='create', + database_url='sqlite:///lobby_rankings.sqlite3') + engine_mock = Mock() + create_engine_mock.return_value = engine_mock + main() + args_mock.assert_called_once_with(sys.argv[1:]) + create_engine_mock.assert_called_once_with( + 'sqlite:///lobby_rankings.sqlite3') + declarative_base_mock.metadata.create_all.assert_any_call(engine_mock) Index: tests/test_utils.py =================================================================== --- tests/test_utils.py +++ tests/test_utils.py @@ -0,0 +1,45 @@ +# Copyright (C) 2018 Wildfire Games. +# This file is part of 0 A.D. +# +# 0 A.D. is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 2 of the License, or +# (at your option) any later version. +# +# 0 A.D. is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with 0 A.D. If not, see . + +"""Tests for utility functions.""" + +from unittest import TestCase + +from hypothesis import given +from hypothesis import strategies as st + +from xpartamupp.utils import LimitedSizeDict + + +class TestLimitedSizeDict(TestCase): + """Test limited size dict.""" + + @given(st.integers(min_value=2, max_value=2**10)) + def test_max_items(self, size_limit): + """Test max items of dicts. + + Test that the dict doesn't grow indefinitely and that the + oldest entries are removed first. + """ + test_dict = LimitedSizeDict(size_limit=size_limit) + for i in range(size_limit): + test_dict[i] = i + self.assertEqual(size_limit, len(test_dict)) + test_dict[size_limit + 1] = size_limit + 1 + self.assertEqual(size_limit, len(test_dict)) + self.assertFalse(0 in test_dict.values()) + self.assertTrue(1 in test_dict.values()) + self.assertTrue(size_limit + 1 in test_dict.values()) Index: tests/test_xpartamupp.py =================================================================== --- tests/test_xpartamupp.py +++ tests/test_xpartamupp.py @@ -0,0 +1,176 @@ +# Copyright (C) 2018 Wildfire Games. +# This file is part of 0 A.D. +# +# 0 A.D. is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 2 of the License, or +# (at your option) any later version. +# +# 0 A.D. is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with 0 A.D. If not, see . + +# pylint: disable=no-self-use + +"""Tests for XPartaMuPP.""" + +import sys + +from argparse import Namespace +from unittest import TestCase +from unittest.mock import Mock, call, patch + +from parameterized import parameterized +from sleekxmpp.jid import JID + +from xpartamupp.xpartamupp import Games, main, parse_args + + +class TestGames(TestCase): + """Test Games class responsible for holding active games.""" + + def test_add(self): + """Test successfully adding a game.""" + games = Games() + jid = JID(jid='player1@domain.tld') + # TODO: Check how the real format of data looks like + game_data = {'players': ['player1', 'player2'], 'nbp': 'foo', 'state': 'init'} + self.assertTrue(games.add_game(jid, game_data)) + all_games = games.get_all_games() + game_data.update({'players-init': game_data['players'], 'nbp-init': game_data['nbp'], + 'state': game_data['state']}) + self.assertDictEqual(all_games, {jid: game_data}) + + @parameterized.expand([ + ('', {}), + ('player1@domain.tld', {}), + ('player1@domain.tld', None), + ('player1@domain.tld', ''), + ]) + def test_add_invalid(self, jid, game_data): + """Test trying to add games with invalid data.""" + games = Games() + self.assertFalse(games.add_game(jid, game_data)) + + def test_remove(self): + """Test removal of games.""" + games = Games() + jid1 = JID(jid='player1@domain.tld') + jid2 = JID(jid='player3@domain.tld') + # TODO: Check how the real format of data looks like + game_data1 = {'players': ['player1', 'player2'], 'nbp': 'foo', 'state': 'init'} + games.add_game(jid1, game_data1) + game_data2 = {'players': ['player3', 'player4'], 'nbp': 'bar', 'state': 'init'} + games.add_game(jid2, game_data2) + game_data1.update({'players-init': game_data1['players'], 'nbp-init': game_data1['nbp'], + 'state': game_data1['state']}) + game_data2.update({'players-init': game_data2['players'], 'nbp-init': game_data2['nbp'], + 'state': game_data2['state']}) + self.assertDictEqual(games.get_all_games(), {jid1: game_data1, jid2: game_data2}) + games.remove_game(jid1) + self.assertDictEqual(games.get_all_games(), {jid2: game_data2}) + games.remove_game(jid2) + self.assertDictEqual(games.get_all_games(), dict()) + + def test_remove_unknown(self): + """Test removal of a game, which doesn't exist.""" + games = Games() + jid = JID(jid='player1@domain.tld') + # TODO: Check how the real format of data looks like + game_data = {'players': ['player1', 'player2'], 'nbp': 'foo', 'state': 'init'} + games.add_game(jid, game_data) + self.assertFalse(games.remove_game(JID('foo@bar.tld'))) + + def test_change_state(self): + """Test state changes of a games.""" + pass + # slightly unknown how to do that properly, as some data structures aren't known + + +class TestArgumentParsing(TestCase): + """Test handling of parsing command line parameters.""" + + @parameterized.expand([ + ([], Namespace(domain='lobby.wildfiregames.com', login='xpartamupp', log_level=30, + nickname='WFGBot', password='XXXXXX', room='arena')), + (['--debug'], + Namespace(domain='lobby.wildfiregames.com', login='xpartamupp', log_level=10, + nickname='WFGBot', password='XXXXXX', room='arena')), + (['--quiet'], + Namespace(domain='lobby.wildfiregames.com', login='xpartamupp', log_level=40, + nickname='WFGBot', password='XXXXXX', room='arena')), + (['--verbose'], + Namespace(domain='lobby.wildfiregames.com', login='xpartamupp', log_level=20, + nickname='WFGBot', password='XXXXXX', room='arena')), + (['-m', 'lobby.domain.tld'], + Namespace(domain='lobby.domain.tld', login='xpartamupp', log_level=30, nickname='WFGBot', + password='XXXXXX', room='arena')), + (['--domain=lobby.domain.tld'], + Namespace(domain='lobby.domain.tld', login='xpartamupp', log_level=30, nickname='WFGBot', + password='XXXXXX', room='arena')), + (['-m' 'lobby.domain.tld', '-l', 'bot', '-p', '123456', '-n', 'Bot', '-r', 'arena123', + '-v'], + Namespace(domain='lobby.domain.tld', login='bot', log_level=20, + nickname='Bot', password='123456', room='arena123')), + (['--domain=lobby.domain.tld', '--login=bot', '--password=123456', '--nickname=Bot', + '--room=arena123', '--verbose'], + Namespace(domain='lobby.domain.tld', login='bot', log_level=20, + nickname='Bot', password='123456', room='arena123')), + ]) + def test_valid(self, cmd_args, expected_args): + """Test valid parameter combinations.""" + self.assertEqual(parse_args(cmd_args), expected_args) + + @parameterized.expand([ + (['-f'],), + (['--foo'],), + (['--debug', '--quiet'],), + (['--quiet', '--verbose'],), + (['--debug', '--verbose'],), + (['--debug', '--quiet', '--verbose'],), + ]) + def test_invalid(self, cmd_args): + """Test invalid parameter combinations.""" + with self.assertRaises(SystemExit): + parse_args(cmd_args) + + +class TestMain(TestCase): + """Test main method.""" + + def test_success(self): + """Test successful execution.""" + with patch('xpartamupp.xpartamupp.parse_args') as args_mock, \ + patch('xpartamupp.xpartamupp.XpartaMuPP') as xmpp_mock: + args_mock.return_value = Mock(log_level=30, login='xpartamupp', + domain='lobby.wildfiregames.com', password='XXXXXX', + room='arena', nickname='WFGBot') + main() + args_mock.assert_called_once_with(sys.argv[1:]) + xmpp_mock().register_plugin.assert_has_calls([call('xep_0004'), call('xep_0030'), + call('xep_0045'), call('xep_0060'), + call('xep_0199', {'keepalive': True})], + any_order=True) + xmpp_mock().connect.assert_called_once_with() + xmpp_mock().process.assert_called_once_with() + + def test_failing_connect(self): + """Test failing connect to XMPP server.""" + with patch('xpartamupp.xpartamupp.parse_args') as args_mock, \ + patch('xpartamupp.xpartamupp.XpartaMuPP') as xmpp_mock: + args_mock.return_value = Mock(log_level=30, login='xpartamupp', + domain='lobby.wildfiregames.com', password='XXXXXX', + room='arena', nickname='WFGBot') + xmpp_mock().connect.return_value = False + main() + args_mock.assert_called_once_with(sys.argv[1:]) + xmpp_mock().register_plugin.assert_has_calls([call('xep_0004'), call('xep_0030'), + call('xep_0045'), call('xep_0060'), + call('xep_0199', {'keepalive': True})], + any_order=True) + xmpp_mock().connect.assert_called_once_with() + xmpp_mock().process.assert_not_called() Index: xpartamupp/echelon.py =================================================================== --- xpartamupp/echelon.py +++ xpartamupp/echelon.py @@ -0,0 +1,800 @@ +#!/usr/bin/env python3 + +# Copyright (C) 2018 Wildfire Games. +# This file is part of 0 A.D. +# +# 0 A.D. is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 2 of the License, or +# (at your option) any later version. +# +# 0 A.D. is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with 0 A.D. If not, see . + +"""0ad XMPP-bot responsible for managing game ratings.""" + +import argparse +import difflib +import logging +import sys +from collections import deque + +import sleekxmpp +from sleekxmpp.stanza import Iq +from sleekxmpp.xmlstream.handler import Callback +from sleekxmpp.xmlstream.matcher import StanzaPath +from sleekxmpp.xmlstream.stanzabase import register_stanza_plugin +from sqlalchemy import create_engine, func +from sqlalchemy.orm import scoped_session, sessionmaker + +from xpartamupp.elo import get_rating_adjustment +from xpartamupp.lobby_ranking import Game, Player, PlayerInfo +from xpartamupp.stanzas import (BoardListXmppPlugin, GameReportXmppPlugin, ProfileXmppPlugin) +from xpartamupp.utils import LimitedSizeDict + +# Rating that new players should be inserted into the +# database with, before they've played any games. +LEADERBOARD_DEFAULT_RATING = 1200 + + +class Leaderboard(object): + """Class that provides and manages leaderboard data.""" + + def __init__(self, db_url): + """Initialize the leaderboard.""" + self.rating_messages = deque() + + engine = create_engine(db_url) + session_factory = sessionmaker(bind=engine) + self.db = scoped_session(session_factory) + + def get_or_create_player(self, jid): + """Get a player from the leaderboard database. + + Get player information from the leaderboard database and + create him first, if he doesn't exist yet. + + Arguments: + jid (sleekxmpp.jid.JID): JID of the player to get + + Returns: + Player instance representing the player specified by the + supplied JID + + """ + player = self.db.query(Player).filter(Player.jid.ilike(str(jid))).first() + if player: + return player + + player = Player(jid=str(jid), rating=-1) + self.db.add(player) + self.db.commit() + logging.debug("Created player %s", jid) + return player + + def get_profile(self, jid): + """Get the leaderboard profile for the specified player. + + Arguments: + jid (sleekxmpp.jid.JID): JID of the player to retrieve the + profile for + + Returns: + dict with statistics about the requested player or None if + the player isn't known + + """ + stats = {} + player = self.db.query(Player).filter(Player.jid.ilike(str(jid))).first() + + if not player: + logging.debug("Couldn't find profile for player %s", jid) + return {} + + if player.rating != -1: + stats['rating'] = player.rating + rank = self.db.query(Player).filter(Player.rating >= player.rating).count() + stats['rank'] = rank + + if player.highest_rating != -1: + stats['highestRating'] = player.highest_rating + + games_played = self.db.query(PlayerInfo).filter_by(player_id=player.id).count() + wins = self.db.query(Game).filter_by(winner_id=player.id).count() + stats['totalGamesPlayed'] = games_played + stats['wins'] = wins + stats['losses'] = games_played - wins + return stats + + def _add_game(self, game_report): # pylint: disable=too-many-locals + """Add a game to the database. + + Add a game to the database and update the data on a + player from game results. + + Arguments: + game_report (dict): a report about a game + + Returns: + Game object for the created game or None if the creation + failed for any reason. + + """ + # Discard any games still in progress. We shouldn't get + # reports from those games anyway. + if 'active' in dict.values(game_report['playerStates']): + logging.warning("Received a game report for an unfinished game") + return None + + players = self.db.query(Player).filter(func.lower(Player.jid).in_( + dict.keys(game_report['playerStates']))) + + winning_jid = [jid for jid, state in game_report['playerStates'].items() + if state == 'won'][0] + + # single_stats = {'timeElapsed', 'mapName', 'teamsLocked', 'matchID'} + total_score_stats = {'economyScore', 'militaryScore', 'totalScore'} + resource_stats = {'foodGathered', 'foodUsed', 'woodGathered', 'woodUsed', 'stoneGathered', + 'stoneUsed', 'metalGathered', 'metalUsed', 'vegetarianFoodGathered', + 'treasuresCollected', 'lootCollected', 'tributesSent', + 'tributesReceived'} + units_stats = {'totalUnitsTrained', 'totalUnitsLost', 'enemytotalUnitsKilled', + 'infantryUnitsTrained', 'infantryUnitsLost', 'enemyInfantryUnitsKilled', + 'workerUnitsTrained', 'workerUnitsLost', 'enemyWorkerUnitsKilled', + 'femaleCitizenUnitsTrained', 'femaleCitizenUnitsLost', + 'enemyFemaleCitizenUnitsKilled', 'cavalryUnitsTrained', 'cavalryUnitsLost', + 'enemyCavalryUnitsKilled', 'championUnitsTrained', 'championUnitsLost', + 'enemyChampionUnitsKilled', 'heroUnitsTrained', 'heroUnitsLost', + 'enemyHeroUnitsKilled', 'shipUnitsTrained', 'shipUnitsLost', + 'enemyShipUnitsKilled', 'traderUnitsTrained', 'traderUnitsLost', + 'enemyTraderUnitsKilled'} + buildings_stats = {'totalBuildingsConstructed', 'totalBuildingsLost', + 'enemytotalBuildingsDestroyed', 'civCentreBuildingsConstructed', + 'civCentreBuildingsLost', 'enemyCivCentreBuildingsDestroyed', + 'houseBuildingsConstructed', 'houseBuildingsLost', + 'enemyHouseBuildingsDestroyed', 'economicBuildingsConstructed', + 'economicBuildingsLost', 'enemyEconomicBuildingsDestroyed', + 'outpostBuildingsConstructed', 'outpostBuildingsLost', + 'enemyOutpostBuildingsDestroyed', 'militaryBuildingsConstructed', + 'militaryBuildingsLost', 'enemyMilitaryBuildingsDestroyed', + 'fortressBuildingsConstructed', 'fortressBuildingsLost', + 'enemyFortressBuildingsDestroyed', 'wonderBuildingsConstructed', + 'wonderBuildingsLost', 'enemyWonderBuildingsDestroyed'} + market_stats = {'woodBought', 'foodBought', 'stoneBought', 'metalBought', 'tradeIncome'} + misc_stats = {'civs', 'teams', 'percentMapExplored'} + + stats = total_score_stats | resource_stats | units_stats | buildings_stats | market_stats \ + | misc_stats + + player_infos = [] + for player in players: + player_jid = sleekxmpp.jid.JID(player.jid) + player_info = PlayerInfo(player=player) + for report_name in stats: + setattr(player_info, report_name, game_report[report_name][player_jid]) + player_infos.append(player_info) + + game = Game(map=game_report['mapName'], duration=int(game_report['timeElapsed']), + teamsLocked=bool(game_report['teamsLocked']), matchID=game_report['matchID']) + game.player_info.extend(player_infos) + game.winner = self.db.query(Player).filter(Player.jid.ilike(str(winning_jid))).first() + self.db.add(game) + self.db.commit() + return game + + @staticmethod + def _verify_game(game_report): + """Check whether or not the game should be rated. + + The criteria for rated games can be specified here. + + Arguments: + game_report (dict): a report about a game + + Returns: + True if the game should be rated, false otherwise. + + """ + winning_jids = [jid for jid, state in game_report['playerStates'].items() + if state == 'won'] + # We only support 1v1s right now. + if len(winning_jids) > 1 or len(dict.keys(game_report['playerStates'])) != 2: + return False + return True + + def _rate_game(self, game): + """Update player ratings based on game outcome. + + Take a game with 2 players and alters their ratings based on + the result of the game. + + Adjusts the players ratings in the database. + + Arguments: + game (Game): game to rate + """ + player1 = game.players[0] + player2 = game.players[1] + # Since it's impossible to draw in the game currently, the + # database model, and therefore this code, requires a winner. + # The Elo implementation does not, however. + result = 1 if player1 == game.winner else -1 + # Player's ratings are -1 unless they have played a rated game. + if player1.rating == -1: + player1.rating = LEADERBOARD_DEFAULT_RATING + if player2.rating == -1: + player2.rating = LEADERBOARD_DEFAULT_RATING + + try: + rating_adjustment1 = int(get_rating_adjustment(player1.rating, player2.rating, + len(player1.games), len(player2.games), + result)) + rating_adjustment2 = int(get_rating_adjustment(player2.rating, player1.rating, + len(player2.games), len(player1.games), + result * -1)) + except ValueError: + rating_adjustment1 = 0 + rating_adjustment2 = 0 + + if result == 1: + result_qualitative = 'won' + elif result == 0: + result_qualitative = 'drew' + else: + result_qualitative = 'lost' + name1 = sleekxmpp.jid.JID(player1.jid).local + name2 = sleekxmpp.jid.JID(player2.jid).local + self.rating_messages.append("A rated game has ended. %s %s against %s. Rating " + "Adjustment: %s (%s -> %s) and %s (%s -> %s)." % + (name1, result_qualitative, name2, name1, player1.rating, + player1.rating + rating_adjustment1, name2, player2.rating, + player2.rating + rating_adjustment2)) + player1.rating += rating_adjustment1 + player2.rating += rating_adjustment2 + if not player1.highest_rating: + player1.highest_rating = -1 + if not player2.highest_rating: + player2.highest_rating = -1 + player1.highest_rating = max(player1.rating, player1.highest_rating) + player2.highest_rating = max(player2.rating, player2.highest_rating) + self.db.commit() + + def get_rating_messages(self): + """Get messages announcing rated games. + + Returns: + list with the a messages about rated games + + """ + return self.rating_messages + + def add_and_rate_game(self, game_report): + """Add and rate a game. + + If the game has only two players, rate the game. + + Arguments: + game_report (dict): a report about a game + + Returns: + Game object + + """ + game = self._add_game(game_report) + if game and self._verify_game(game_report): + self._rate_game(game) + return game + + def get_board(self, limit=100): + """Return the ratings of the highest ranked players. + + Arguments: + limit (int): Number of players to return + + Returns: + dict with player JIDs, nicks and ratings + + """ + ratings = {} + players = self.db.query(Player).filter(Player.rating != -1) \ + .order_by(Player.rating.desc()).limit(limit) + for player in players: + ratings[player.jid] = {'name': sleekxmpp.jid.JID(player.jid).local, + 'rating': player.rating} + return ratings + + def get_rating_list(self, nicks): + """Return the ratings of all online players. + + The returned dictionary is by nick because the client can't + link JID to nick conveniently. + + Arguments: + nicks (dict): Players currently online + + Returns: + dict with player JIDs, nicks and ratings + + """ + ratings = {} + if nicks: + player_filter = func.lower(Player.jid).in_([str(jid).lower() for jid in list(nicks)]) + players = self.db.query(Player.jid, Player.rating).filter(player_filter) + for player in players: + rating = str(player.rating) if player.rating != -1 else '' + for jid in list(nicks): + if jid == sleekxmpp.jid.JID(player.jid): + ratings[nicks[str(jid)]] = {'name': nicks[jid], 'rating': rating} + break + return ratings + + +class ReportManager(object): + """Class which manages different game reports from clients. + + Calls leaderboard functions as appropriate. + """ + + def __init__(self, leaderboard): + """Initialize the report manager. + + Arguments: + leaderboard (Leaderboard): Leaderboard the manager is for + + """ + self.leaderboard = leaderboard + self.interim_report_tracker = LimitedSizeDict(size_limit=2**12) + + def add_report(self, jid, raw_game_report): + """Add a game to the interface between a raw report and the leaderboard database. + + Arguments: + jid (sleekxmpp.jid.JID): JID of the player who submitted + the report + raw_game_report (dict): Game report generated by 0ad + + """ + player_index = int(raw_game_report['playerID']) - 1 + del raw_game_report['playerID'] + match_id = raw_game_report['matchID'] + if match_id not in self.interim_report_tracker: + self.interim_report_tracker[match_id] = { + 'report': raw_game_report, + 'jids': {player_index: str(jid)} + } + else: + current_match = self.interim_report_tracker[match_id] + if raw_game_report != current_match['report']: + report_diff = self._get_report_diff(raw_game_report, current_match['report']) + logging.warning("Retrieved reports for match %s differ:\n %s", match_id, + report_diff) + return + + player_jids = current_match['jids'] + if player_index in player_jids: + if player_jids[player_index] == jid: + logging.warning("Received a report for match %s from player %s twice.", + match_id, jid) + else: + logging.warning("Retrieved a report for match %s for the same player twice, " + "but from two different XMPP accounts: %s vs. %s", match_id, + player_jids[player_index], jid) + return + else: + player_jids[player_index] = str(jid) + + num_players = self._get_num_players(raw_game_report) + num_retrieved_reports = len(player_jids) + if num_retrieved_reports == num_players: + try: + self.leaderboard.add_and_rate_game(self._expand_report( + current_match)) + except Exception: + logging.exception("Failed to add and rate a game.") + del current_match + elif num_retrieved_reports < num_players: + logging.warning("Haven't received all reports for the game yet. %i/%i", + num_retrieved_reports, num_players) + elif num_retrieved_reports > num_players: + logging.warning("Retrieved more reports than players. This shouldn't happen.") + + @staticmethod + def _expand_report(game_report): + """Re-formats a game report into Python data structures. + + Player specific values from the report are replaced with a + dict where the JID of the player is the key. + + Arguments: + game_report (dict): wrapped game report from 0ad + + Returns a processed gameReport of type dict. + """ + processed_game_report = {} + for key, value in game_report['report'].items(): + if ',' not in value: + processed_game_report[key] = value + else: + stat_to_jid = {} + for i, part in enumerate(game_report['report'][key].split(",")[:-1]): + stat_to_jid[game_report['jids'][i]] = part + processed_game_report[key] = stat_to_jid + return processed_game_report + + @staticmethod + def _get_num_players(raw_game_report): + """Compute the number of players from a raw game report. + + Get the number of players who played a game from the + playerStates field in a raw game report. + + Arguments: + raw_game_report (dict): Game report generated by 0ad + + Returns: + int with the number of players in the game + + Raises: + ValueError if the number of players couldn't be determined + + """ + if 'playerStates' in raw_game_report and ',' in raw_game_report['playerStates']: + return len(list(filter(None, raw_game_report['playerStates'].split(",")))) + raise ValueError() + + @staticmethod + def _get_report_diff(report1, report2): + """Get differences between two reports. + + Arguments: + report1 (dict): Game report + report2 (dict): Game report + + Returns: + str with a textual representation of the differences + between the two reports + + """ + report1_list = ['{ %s: %s }' % (key, value) for key, value in report1.items()] + report2_list = ['{ %s: %s }' % (key, value) for key, value in report2.items()] + return '\n'.join(difflib.ndiff(report1_list, report2_list)) + + +class EcheLOn(sleekxmpp.ClientXMPP): + """Main class which handles IQ data and sends new data.""" + + def __init__(self, sjid, password, room, nick, leaderboard): + """Initialize EcheLOn.""" + sleekxmpp.ClientXMPP.__init__(self, sjid, password) + self.whitespace_keepalive = False + + self.sjid = sleekxmpp.jid.JID(sjid) + self.room = room + self.nick = nick + + self.leaderboard = leaderboard + self.report_manager = ReportManager(self.leaderboard) + + register_stanza_plugin(Iq, BoardListXmppPlugin) + register_stanza_plugin(Iq, GameReportXmppPlugin) + register_stanza_plugin(Iq, ProfileXmppPlugin) + + self.register_handler(Callback('Iq Boardlist', StanzaPath('iq@type=get/boardlist'), + self._iq_board_list_handler)) + self.register_handler(Callback('Iq GameReport', StanzaPath('iq@type=set/gamereport'), + self._iq_game_report_handler)) + self.register_handler(Callback('Iq Profile', StanzaPath('iq@type=get/profile'), + self._iq_profile_handler)) + + self.add_event_handler('session_start', self._session_start) + self.add_event_handler('muc::%s::got_online' % self.room, self._muc_online) + self.add_event_handler('muc::%s::got_offline' % self.room, self._muc_offline) + self.add_event_handler('groupchat_message', self._muc_message) + + def _session_start(self, event): # pylint: disable=unused-argument + """Join MUC channel and announce presence. + + Arguments: + event (dict): empty dummy dict + + """ + self.plugin['xep_0045'].joinMUC(self.room, self.nick) + self.send_presence() + self.get_roster() + logging.info("EcheLOn started") + + def _muc_online(self, presence): + """Add joining players to the list of players. + + Arguments: + presence (sleekxmpp.stanza.presence.Presence): Received + presence stanza. + + """ + nick = str(presence['muc']['nick']) + jid = sleekxmpp.jid.JID(presence['muc']['jid']) + + if nick == self.nick: + return + + if jid.resource != '0ad': + return + + self.leaderboard.get_or_create_player(jid) + + self._broadcast_rating_list() + + logging.debug("Client '%s' connected with a nick of '%s'.", jid, nick) + + def _muc_offline(self, presence): + """Remove leaving players from the list of players. + + Arguments: + presence (sleekxmpp.stanza.presence.Presence): Received + presence stanza. + + """ + nick = str(presence['muc']['nick']) + jid = sleekxmpp.jid.JID(presence['muc']['jid']) + + if nick == self.nick: + return + + logging.debug("Client '%s' with nick '%s' disconnected", jid, nick) + + def _muc_message(self, msg): + """Process messages in the MUC room. + + Respond to messages highlighting the bots name with an + informative message. + + Arguments: + msg (sleekxmpp.stanza.message.Message): Received MUC + message + """ + if msg['mucnick'] != self.nick and self.nick.lower() in msg['body'].lower(): + self.send_message(mto=msg['from'].bare, + mbody="I am just a bot and provide the rating functionality for " + "this lobby. Please don't disturb me, calculating these " + "ratings is already difficult enough.", + mtype='groupchat') + + def _iq_board_list_handler(self, iq): + """Handle incoming leaderboard list requests. + + Arguments: + iq (sleekxmpp.stanza.iq.IQ): Received IQ stanza + + """ + if iq['from'].resource not in ['0ad']: + return + + command = iq['boardlist']['command'] + self.leaderboard.get_or_create_player(iq['from']) + if command == 'getleaderboard': + try: + self._send_leaderboard(iq) + except Exception: + logging.exception("Failed to process get leaderboard request from %s", + iq['from'].bare) + elif command == 'getratinglist': + try: + self._send_rating_list(iq) + except Exception: + logging.exception("Failed to send the rating list to %s", iq['from']) + + def _iq_game_report_handler(self, iq): + """Handle end of game reports from clients. + + Arguments: + iq (sleekxmpp.stanza.iq.IQ): Received IQ stanza + + """ + if iq['from'].resource not in ['0ad']: + return + + try: + self.report_manager.add_report(iq['from'], iq['gamereport']['game']) + except Exception: + logging.exception("Failed to update game statistics for %s", iq['from'].bare) + + rating_messages = self.leaderboard.get_rating_messages() + if rating_messages: + while rating_messages: + message = rating_messages.popleft() + self.send_message(mto=self.room, mbody=message, mtype='groupchat', mnick=self.nick) + self._broadcast_rating_list() + + def _iq_profile_handler(self, iq): + """Handle profile requests from clients. + + Arguments: + iq (sleekxmpp.stanza.iq.IQ): Received IQ stanza + + """ + if iq['from'].resource not in ['0ad']: + return + + try: + self._send_profile(iq, iq['profile']['command']) + except Exception: + logging.exception("Failed to send profile about %s to %s", iq['profile']['command'], + iq['from'].bare) + + def _send_leaderboard(self, iq): + """Send the whole leaderboard. + + Arguments: + iq (sleekxmpp.stanza.iq.IQ): IQ stanza to reply to + + """ + ratings = self.leaderboard.get_board() + + iq = iq.reply(clear=True) + stanza = BoardListXmppPlugin() + stanza.add_command('boardlist') + for player in ratings.values(): + stanza.add_item(player['name'], player['rating']) + iq.set_payload(stanza) + + try: + iq.send(block=False) + except Exception: + logging.exception("Failed to send leaderboard to %s", iq['to']) + + def _send_rating_list(self, iq): + """Send the ratings of all online players. + + Arguments: + iq (sleekxmpp.stanza.iq.IQ): IQ stanza to reply to + + """ + nicks = {} + for nick in self.plugin['xep_0045'].getRoster(self.room): + if nick == self.nick: + continue + jid_str = self.plugin['xep_0045'].getJidProperty(self.room, nick, 'jid') + jid = sleekxmpp.jid.JID(jid_str) + nicks[jid] = nick + ratings = self.leaderboard.get_rating_list(nicks) + + iq = iq.reply(clear=True) + stanza = BoardListXmppPlugin() + stanza.add_command('ratinglist') + for player in ratings.values(): + stanza.add_item(player['name'], player['rating']) + iq.set_payload(stanza) + + try: + iq.send(block=False) + except Exception: + logging.exception("Failed to send rating list to %s", iq['to']) + + def _broadcast_rating_list(self): + """Broadcast the ratings of all online players.""" + nicks = {} + for nick in self.plugin['xep_0045'].getRoster(self.room): + if nick == self.nick: + continue + jid_str = self.plugin['xep_0045'].getJidProperty(self.room, nick, 'jid') + jid = sleekxmpp.jid.JID(jid_str) + nicks[jid] = nick + ratings = self.leaderboard.get_rating_list(nicks) + + stanza = BoardListXmppPlugin() + stanza.add_command('ratinglist') + for player in ratings.values(): + stanza.add_item(player['name'], player['rating']) + + for jid in nicks: + iq = self.make_iq_result(ito=jid) + iq.set_payload(stanza) + try: + iq.send(block=False) + except Exception: + logging.exception("Failed to send rating list to %s", jid) + + def _send_profile(self, iq, player_nick): + """Send the player profile to a specified target. + + Arguments: + iq (sleekxmpp.stanza.iq.IQ): IQ stanza to reply to + player_nick (str): The nick of the player to get the + profile for + + """ + jid_str = self.plugin['xep_0045'].getJidProperty(self.room, player_nick, 'jid') + player_jid = sleekxmpp.jid.JID(jid_str) if jid_str else None + + # The player the profile got requested for is not online, so + # let's assume the JID contains the nick as local part. + if not player_jid: + player_jid = sleekxmpp.jid.JID('%s@%s/%s' % (player_nick, self.sjid.domain, '0ad')) + + try: + stats = self.leaderboard.get_profile(player_jid) + except Exception: + logging.exception("Failed to get leaderboard profile for player %s", player_jid) + stats = {} + + iq = iq.reply(clear=True) + stanza = ProfileXmppPlugin() + if stats: + stanza.add_item(player_nick, stats['rating'], stats['highestRating'], + stats['rank'], stats['totalGamesPlayed'], stats['wins'], + stats['losses']) + else: + stanza.add_item(player_nick, -2) + stanza.add_command(player_nick) + iq.set_payload(stanza) + + try: + iq.send(block=False) + except Exception: + logging.exception("Failed to send profile to %s", iq['to']) + + +def parse_args(args): + """Parse command line arguments. + + Arguments: + args (dict): Raw command line arguments given to the script + + Returns: + Parsed command line arguments + + """ + parser = argparse.ArgumentParser(formatter_class=argparse.ArgumentDefaultsHelpFormatter, + description="EcheLOn - XMPP Rating Bot") + + log_settings = parser.add_mutually_exclusive_group() + log_settings.add_argument('-q', '--quiet', help="only log errors", action='store_const', + dest='log_level', const=logging.ERROR) + log_settings.add_argument('-d', '--debug', help="log debug messages", action='store_const', + dest='log_level', const=logging.DEBUG) + log_settings.add_argument('-v', '--verbose', help="log more informative messages", + action='store_const', dest='log_level', const=logging.INFO) + log_settings.set_defaults(log_level=logging.WARNING) + + parser.add_argument('-m', '--domain', help="XMPP server to connect to", + default='lobby.wildfiregames.com') + parser.add_argument('-l', '--login', help="username for login", default='EcheLOn') + parser.add_argument('-p', '--password', help="password for login", default='XXXXXX') + parser.add_argument('-n', '--nickname', help="nickname shown to players", default='RatingsBot') + parser.add_argument('-r', '--room', help="XMPP MUC room to join", default='arena') + parser.add_argument('--database-url', help="URL for the leaderboard database", + default='sqlite:///lobby_rankings.sqlite3') + + return parser.parse_args(args) + + +def main(): + """Entry point a console script.""" + args = parse_args(sys.argv[1:]) + + logging.basicConfig(level=args.log_level, + format='%(asctime)s %(levelname)-8s %(message)s', + datefmt='%Y-%m-%d %H:%M:%S') + + leaderboard = Leaderboard(args.database_url) + xmpp = EcheLOn(sleekxmpp.jid.JID('%s@%s/%s' % (args.login, args.domain, 'EcheLOn')), args.password, + args.room + '@conference.' + args.domain, args.nickname, leaderboard) + xmpp.register_plugin('xep_0030') # Service Discovery + xmpp.register_plugin('xep_0004') # Data Forms + xmpp.register_plugin('xep_0045') # Multi-User Chat + xmpp.register_plugin('xep_0060') # Publish-Subscribe + xmpp.register_plugin('xep_0199', {'keepalive': True}) # XMPP Ping + + if xmpp.connect(): + xmpp.process() + else: + logging.error("Unable to connect") + + +if __name__ == '__main__': + main() Index: xpartamupp/elo.py =================================================================== --- xpartamupp/elo.py +++ xpartamupp/elo.py @@ -0,0 +1,82 @@ +# Copyright (C) 2018 Wildfire Games. +# This file is part of 0 A.D. +# +# 0 A.D. is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 2 of the License, or +# (at your option) any later version. +# +# 0 A.D. is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with 0 A.D. If not, see . + +"""Implementation of the ELO-rating algorithm for 0ad games.""" + +# Difference between two ratings such that it is regarded as a "sure +# win" for the higher player. No points are gained or lost for such a +# game. +ELO_SURE_WIN_DIFFERENCE = 600 + +# Lower ratings "move faster" and change more +# dramatically than higher ones. Anything rating above +# this value moves at the same rate as this value. +ELO_K_FACTOR_CONSTANT_RATING = 2200 + +# This preset number of games is the number of games where a player is +# considered "stable". Rating volatility is constant after this number. +VOLATILITY_CONSTANT = 20 + +# Fair rating adjustment loses against inflation. +# This constant will battle inflation. +# NOTE: This can be adjusted as needed by a bot/server administrator +ANTI_INFLATION = 0.015 + + +def get_rating_adjustment(rating, opponent_rating, games_played, + opponent_games_played, result): # pylint: disable=unused-argument + """Calculate the rating adjustment after rated 1v1 games. + + The rating adjustment is calculated using a simplified + ELO-algorithm. + + The given implementation doesn't work for negative ratings below + -2199. This is a known limitation which is currently considered + to be not relevant in day-to-day use. + + Arguments: + rating (int): Rating of the first player before the game. + opponent_rating (int): Rating of the second player before the + game. + games_played (int): Number of games the first player has played + before this game. + opponent_games_played (int): Number of games the second player + has played before this game. + result (int): 1 if the first player won, 0 if draw or -1 if the + second player won. + + Returns: + int: the adjustment which should be applied to the rating of + the first player + + """ + if rating < -2199 or opponent_rating < -2199: + raise ValueError('Too small rating given: rating: %i, opponent rating: %i' % + (rating, opponent_rating)) + + rating_k_factor = 50.0 * (min(rating, ELO_K_FACTOR_CONSTANT_RATING) / + ELO_K_FACTOR_CONSTANT_RATING + 1.0) / 2.0 + player_volatility = (min(max(0, games_played), VOLATILITY_CONSTANT) / + VOLATILITY_CONSTANT + 0.25) / 1.25 + volatility = rating_k_factor * player_volatility + rating_difference = opponent_rating - rating + rating_adjustment = (rating_difference + result * ELO_SURE_WIN_DIFFERENCE) / volatility - \ + ANTI_INFLATION + if result == 1: + return round(max(0.0, rating_adjustment)) + elif result == -1: + return round(min(0.0, rating_adjustment)) + return round(rating_adjustment) Index: xpartamupp/lobby_ranking.py =================================================================== --- xpartamupp/lobby_ranking.py +++ xpartamupp/lobby_ranking.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python3 + +# Copyright (C) 2018 Wildfire Games. +# This file is part of 0 A.D. +# +# 0 A.D. is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 2 of the License, or +# (at your option) any later version. +# +# 0 A.D. is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with 0 A.D. If not, see . + +"""Database schema used by the XMPP bots to store game information.""" + +import argparse +import sys + +from sqlalchemy import Boolean, Column, ForeignKey, Integer, String, create_engine +from sqlalchemy.orm import relationship +from sqlalchemy.ext.declarative import declarative_base + +Base = declarative_base() + + +class Player(Base): + """Model representing players.""" + + __tablename__ = 'players' + + id = Column(Integer, primary_key=True) + jid = Column(String(255)) + rating = Column(Integer) + highest_rating = Column(Integer) + games = relationship('Game', secondary='players_info') + # These two relations really only exist to satisfy the linkage + # between PlayerInfo and Player and Game and player. + games_info = relationship('PlayerInfo', backref='player') + games_won = relationship('Game', backref='winner') + + +class PlayerInfo(Base): + """Model representing game results.""" + + __tablename__ = 'players_info' + + id = Column(Integer, primary_key=True) + player_id = Column(Integer, ForeignKey('players.id')) + game_id = Column(Integer, ForeignKey('games.id')) + civs = Column(String(20)) + teams = Column(Integer) + economyScore = Column(Integer) + militaryScore = Column(Integer) + totalScore = Column(Integer) + foodGathered = Column(Integer) + foodUsed = Column(Integer) + woodGathered = Column(Integer) + woodUsed = Column(Integer) + stoneGathered = Column(Integer) + stoneUsed = Column(Integer) + metalGathered = Column(Integer) + metalUsed = Column(Integer) + vegetarianFoodGathered = Column(Integer) + treasuresCollected = Column(Integer) + lootCollected = Column(Integer) + tributesSent = Column(Integer) + tributesReceived = Column(Integer) + totalUnitsTrained = Column(Integer) + totalUnitsLost = Column(Integer) + enemytotalUnitsKilled = Column(Integer) + infantryUnitsTrained = Column(Integer) + infantryUnitsLost = Column(Integer) + enemyInfantryUnitsKilled = Column(Integer) + workerUnitsTrained = Column(Integer) + workerUnitsLost = Column(Integer) + enemyWorkerUnitsKilled = Column(Integer) + femaleCitizenUnitsTrained = Column(Integer) + femaleCitizenUnitsLost = Column(Integer) + enemyFemaleCitizenUnitsKilled = Column(Integer) + cavalryUnitsTrained = Column(Integer) + cavalryUnitsLost = Column(Integer) + enemyCavalryUnitsKilled = Column(Integer) + championUnitsTrained = Column(Integer) + championUnitsLost = Column(Integer) + enemyChampionUnitsKilled = Column(Integer) + heroUnitsTrained = Column(Integer) + heroUnitsLost = Column(Integer) + enemyHeroUnitsKilled = Column(Integer) + shipUnitsTrained = Column(Integer) + shipUnitsLost = Column(Integer) + enemyShipUnitsKilled = Column(Integer) + traderUnitsTrained = Column(Integer) + traderUnitsLost = Column(Integer) + enemyTraderUnitsKilled = Column(Integer) + totalBuildingsConstructed = Column(Integer) + totalBuildingsLost = Column(Integer) + enemytotalBuildingsDestroyed = Column(Integer) + civCentreBuildingsConstructed = Column(Integer) + civCentreBuildingsLost = Column(Integer) + enemyCivCentreBuildingsDestroyed = Column(Integer) + houseBuildingsConstructed = Column(Integer) + houseBuildingsLost = Column(Integer) + enemyHouseBuildingsDestroyed = Column(Integer) + economicBuildingsConstructed = Column(Integer) + economicBuildingsLost = Column(Integer) + enemyEconomicBuildingsDestroyed = Column(Integer) + outpostBuildingsConstructed = Column(Integer) + outpostBuildingsLost = Column(Integer) + enemyOutpostBuildingsDestroyed = Column(Integer) + militaryBuildingsConstructed = Column(Integer) + militaryBuildingsLost = Column(Integer) + enemyMilitaryBuildingsDestroyed = Column(Integer) + fortressBuildingsConstructed = Column(Integer) + fortressBuildingsLost = Column(Integer) + enemyFortressBuildingsDestroyed = Column(Integer) + wonderBuildingsConstructed = Column(Integer) + wonderBuildingsLost = Column(Integer) + enemyWonderBuildingsDestroyed = Column(Integer) + woodBought = Column(Integer) + foodBought = Column(Integer) + stoneBought = Column(Integer) + metalBought = Column(Integer) + tradeIncome = Column(Integer) + percentMapExplored = Column(Integer) + + +class Game(Base): + """Model representing games.""" + + __tablename__ = 'games' + + id = Column(Integer, primary_key=True) + map = Column(String(80)) + duration = Column(Integer) + teamsLocked = Column(Boolean) + matchID = Column(String(20)) + winner_id = Column(Integer, ForeignKey('players.id')) + player_info = relationship('PlayerInfo', backref='game') + players = relationship('Player', secondary='players_info') + + +def parse_args(args): + """Parse command line arguments. + + Arguments: + args (dict): Raw command line arguments given to the script + + Returns: + Parsed command line arguments + + """ + parser = argparse.ArgumentParser(formatter_class=argparse.ArgumentDefaultsHelpFormatter, + description="Helper command for database creation") + parser.add_argument('action', help='Action to apply to the database', + choices=['create']) + parser.add_argument('--database-url', help='URL for the leaderboard database', + default='sqlite:///lobby_rankings.sqlite3') + return parser.parse_args(args) + + +def main(): + """Entry point a console script.""" + args = parse_args(sys.argv[1:]) + engine = create_engine(args.database_url) + if args.action == 'create': + Base.metadata.create_all(engine) + + +if __name__ == '__main__': + main() Index: xpartamupp/stanzas.py =================================================================== --- xpartamupp/stanzas.py +++ xpartamupp/stanzas.py @@ -0,0 +1,156 @@ +# Copyright (C) 2018 Wildfire Games. +# This file is part of 0 A.D. +# +# 0 A.D. is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 2 of the License, or +# (at your option) any later version. +# +# 0 A.D. is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with 0 A.D. If not, see . + +"""0ad-specific XMPP-stanzas.""" + +from sleekxmpp.xmlstream import ElementBase, ET + + +class BoardListXmppPlugin(ElementBase): + """Class for custom boardlist and ratinglist stanza extension.""" + + name = 'query' + namespace = 'jabber:iq:boardlist' + interfaces = {'board', 'command'} + sub_interfaces = interfaces + plugin_attrib = 'boardlist' + + def add_command(self, command): + """Add a command to the extension. + + Arguments: + command (str): Command to add + """ + self.xml.append(ET.fromstring('%s' % command)) + + def add_item(self, name, rating): + """Add an item to the extension. + + Arguments: + name (str): Name of the player to add + rating (int): Rating of the player to add + """ + self.xml.append(ET.Element('board', {'name': name, 'rating': str(rating)})) + + +class GameListXmppPlugin(ElementBase): + """Class for custom gamelist stanza extension.""" + + name = 'query' + namespace = 'jabber:iq:gamelist' + interfaces = {'game', 'command'} + sub_interfaces = interfaces + plugin_attrib = 'gamelist' + + def add_game(self, data): + """Add a game to the extension. + + Arguments: + data (dict): game data to add + """ + self.xml.append(ET.Element('game', data)) + + def get_game(self): + """Get game from stanza. + + Required to parse incoming stanzas with this extension. + + Returns: + dict with game data + + """ + game = self.xml.find('{%s}game' % self.namespace) + data = {} + + if game is not None: + for key, item in game.items(): + data[key] = item + return data + + +class GameReportXmppPlugin(ElementBase): + """Class for custom gamereport stanza extension.""" + + name = 'report' + namespace = 'jabber:iq:gamereport' + plugin_attrib = 'gamereport' + interfaces = 'game' + sub_interfaces = interfaces + + def add_game(self, game_report): + """Add a game to the extension. + + Arguments: + game_report (dict): a report about a game + + """ + self.xml.append(ET.fromstring(str(game_report)).find('{%s}game' % self.namespace)) + + def get_game(self): + """Get game from stanza. + + Required to parse incoming stanzas with this extension. + + Returns: + dict with game information + + """ + game = self.xml.find('{%s}game' % self.namespace) + data = {} + + if game is not None: + for key, item in game.items(): + data[key] = item + return data + + +class ProfileXmppPlugin(ElementBase): + """Class for custom profile.""" + + name = 'query' + namespace = 'jabber:iq:profile' + interfaces = {'profile', 'command'} + sub_interfaces = interfaces + plugin_attrib = 'profile' + + def add_command(self, player_nick): + """Add a command to the extension. + + Arguments: + player_nick (str): the nick of the player the profile is about + + """ + self.xml.append(ET.fromstring('%s' % player_nick)) + + def add_item(self, player, rating, highest_rating=0, # pylint: disable=too-many-arguments + rank=0, total_games_played=0, wins=0, losses=0): + """Add an item to the extension. + + Arguments: + player (str): Name of the player + rating (int): Current rating of the player + highest_rating (int): Highest rating the player had + rank (int): Rank of the player + total_games_played (int): Total number of games the player + played + wins (int): Number of won games the player had + losses (int): Number of lost games the player had + """ + item_xml = ET.Element('profile', {'player': player, 'rating': str(rating), + 'highestRating': str(highest_rating), 'rank': str(rank), + 'totalGamesPlayed': str(total_games_played), + 'wins': str(wins), 'losses': str(losses)}) + self.xml.append(item_xml) Index: xpartamupp/utils.py =================================================================== --- xpartamupp/utils.py +++ xpartamupp/utils.py @@ -0,0 +1,48 @@ +# Copyright (C) 2018 Wildfire Games. +# This file is part of 0 A.D. +# +# 0 A.D. is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 2 of the License, or +# (at your option) any later version. +# +# 0 A.D. is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with 0 A.D. If not, see . + +"""Collection of utility functions used by the XMPP-bots.""" + +from collections import OrderedDict + + +class LimitedSizeDict(OrderedDict): + """Dictionary with limited size and FIFO characteristics.""" + + def __init__(self, *args, **kwargs): + """Initialize the dictionary. + + Set the limit to which size the dict should be able to grow. + """ + self.size_limit = kwargs.pop('size_limit', None) + OrderedDict.__init__(self, *args, **kwargs) + self._check_size_limit() + + def __setitem__(self, key, value): # pylint: disable=signature-differs + """Overwrite default method to add size limit check.""" + OrderedDict.__setitem__(self, key, value) + self._check_size_limit() + + def _check_size_limit(self): + """Ensure dict is not larger than the size limit. + + Compares the current size of the dict with the size limit and + removes items from the dict until the size is equal the size + limit. + """ + if self.size_limit: + while len(self) > self.size_limit: + self.popitem(last=False) Index: xpartamupp/xpartamupp.py =================================================================== --- xpartamupp/xpartamupp.py +++ xpartamupp/xpartamupp.py @@ -0,0 +1,356 @@ +#!/usr/bin/env python3 + +# Copyright (C) 2018 Wildfire Games. +# This file is part of 0 A.D. +# +# 0 A.D. is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 2 of the License, or +# (at your option) any later version. +# +# 0 A.D. is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with 0 A.D. If not, see . + +"""0ad XMPP-bot responsible for managing game listings.""" + +import argparse +import logging +import time +import sys + +import sleekxmpp +from sleekxmpp.stanza import Iq +from sleekxmpp.xmlstream.handler import Callback +from sleekxmpp.xmlstream.matcher import StanzaPath +from sleekxmpp.xmlstream.stanzabase import register_stanza_plugin + +from xpartamupp.stanzas import GameListXmppPlugin +from xpartamupp.utils import LimitedSizeDict + + +class Games(object): + """Class to tracks all games in the lobby.""" + + def __init__(self): + """Initialize with empty games.""" + self.games = LimitedSizeDict(size_limit=2**7) + + def add_game(self, jid, data): + """Add a game. + + Arguments: + jid (sleekxmpp.jid.JID): JID of the player who started the + game + data (dict): information about the game + + Returns: + True if adding the game succeeded, False if not + + """ + try: + data['players-init'] = data['players'] + data['nbp-init'] = data['nbp'] + data['state'] = 'init' + except (KeyError, TypeError, ValueError): + logging.warning("Received invalid data for add game from 0ad: %s", data) + return False + else: + self.games[jid] = data + return True + + def remove_game(self, jid): + """Remove a game attached to a JID. + + Arguments: + jid (sleekxmpp.jid.JID): JID of the player whose game to + remove. + + Returns: + True if removing the game succeeded, False if not + + """ + try: + del self.games[jid] + except KeyError: + logging.warning("Game for jid %s didn't exist", jid) + return False + else: + return True + + def get_all_games(self): + """Return all games. + + Returns: + dict containing all games with the JID of the player who + started the game as key. + + """ + return self.games + + def change_game_state(self, jid, data): + """Switch game state between running and waiting. + + Arguments: + jid (sleekxmpp.jid.JID): JID of the player whose game to + change + data (dict): information about the game + + Returns: + True if changing the game state succeeded, False if not + + """ + if jid not in self.games: + logging.warning("Tried to change state for non-existent game %s", jid) + return False + + try: + if self.games[jid]['nbp-init'] > data['nbp']: + logging.debug("change game (%s) state from %s to %s", jid, + self.games[jid]['state'], 'waiting') + self.games[jid]['state'] = 'waiting' + else: + logging.debug("change game (%s) state from %s to %s", jid, + self.games[jid]['state'], 'running') + self.games[jid]['state'] = 'running' + self.games[jid]['nbp'] = data['nbp'] + self.games[jid]['players'] = data['players'] + except (KeyError, ValueError): + logging.warning("Received invalid data for change game state from 0ad: %s", data) + return False + else: + if 'startTime' not in self.games[jid]: + self.games[jid]['startTime'] = str(round(time.time())) + return True + + +class XpartaMuPP(sleekxmpp.ClientXMPP): + """Main class which handles IQ data and sends new data.""" + + def __init__(self, sjid, password, room, nick): + """Initialize XpartaMuPP. + + Arguments: + sjid (sleekxmpp.jid.JID): JID to use for authentication + password (str): password to use for authentication + room (str): XMPP MUC room to join + nick (str): Nick to use in MUC + + """ + sleekxmpp.ClientXMPP.__init__(self, sjid, password) + self.whitespace_keepalive = False + + self.room = room + self.nick = nick + + self.games = Games() + + register_stanza_plugin(Iq, GameListXmppPlugin) + + self.register_handler(Callback('Iq Gamelist', StanzaPath('iq@type=set/gamelist'), + self._iq_game_list_handler)) + + self.add_event_handler('session_start', self._session_start) + self.add_event_handler('muc::%s::got_online' % self.room, self._muc_online) + self.add_event_handler('muc::%s::got_offline' % self.room, self._muc_offline) + self.add_event_handler('groupchat_message', self._muc_message) + + def _session_start(self, event): # pylint: disable=unused-argument + """Join MUC channel and announce presence. + + Arguments: + event (dict): empty dummy dict + + """ + self.plugin['xep_0045'].joinMUC(self.room, self.nick) + self.send_presence() + self.get_roster() + logging.info("XpartaMuPP started") + + def _muc_online(self, presence): + """Add joining players to the list of players. + + Also send a list of games to them, so they see which games + are currently there. + + Arguments: + presence (sleekxmpp.stanza.presence.Presence): Received + presence stanza. + + """ + nick = str(presence['muc']['nick']) + jid = sleekxmpp.jid.JID(presence['muc']['jid']) + + if nick == self.nick: + return + + if jid.resource not in ['0ad', 'CC']: + return + + self._send_game_list(jid) + + logging.debug("Client '%s' connected with a nick '%s'.", jid, nick) + + def _muc_offline(self, presence): + """Remove leaving players from the list of players. + + Also remove the potential game this player was hosting, so we + don't end up with stale games. + + Arguments: + presence (sleekxmpp.stanza.presence.Presence): Received + presence stanza. + + """ + nick = str(presence['muc']['nick']) + jid = sleekxmpp.jid.JID(presence['muc']['jid']) + + if nick == self.nick: + return + + if self.games.remove_game(jid): + self._send_game_list() + + logging.debug("Client '%s' with nick '%s' disconnected", jid, nick) + + def _muc_message(self, msg): + """Process messages in the MUC room. + + Respond to messages highlighting the bots name with an + informative message. + + Arguments: + msg (sleekxmpp.stanza.message.Message): Received MUC + message + """ + if msg['mucnick'] != self.nick and self.nick.lower() in msg['body'].lower(): + self.send_message(mto=msg['from'].bare, + mbody="I am just a bot and I'm responsible to ensure that your're" + "able to see the list of games in here. Aside from that I'm" + "just chilling.", + mtype='groupchat') + + def _iq_game_list_handler(self, iq): + """Handle game state change requests. + + Arguments: + iq (sleekxmpp.stanza.iq.IQ): Received IQ stanza + + """ + if iq['from'].resource != '0ad': + return + + command = iq['gamelist']['command'] + if command == 'register': + success = self.games.add_game(iq['from'], iq['gamelist']['game']) + elif command == 'unregister': + success = self.games.remove_game(iq['from']) + elif command == 'changestate': + success = self.games.change_game_state(iq['from'], iq['gamelist']['game']) + else: + logging.info('Received unknown game command: "%s"', command) + return + + if success: + try: + self._send_game_list() + except Exception: + logging.exception('Failed to send game list after "%s" command', command) + + def _send_game_list(self, to=None): + """Send a massive stanza with the whole game list. + + If no target is passed the gamelist is broadcasted to all + clients. + + Arguments: + to (sleekxmpp.jid.JID): Player to send the game list to. + If None, the game list will be broadcasted + """ + games = self.games.get_all_games() + + stanza = GameListXmppPlugin() + for jid in games: + stanza.add_game(games[jid]) + + if not to: + for nick in self.plugin['xep_0045'].getRoster(self.room): + if nick == self.nick: + continue + jid_str = self.plugin['xep_0045'].getJidProperty(self.room, nick, 'jid') + jid = sleekxmpp.jid.JID(jid_str) + iq = self.make_iq_result(ito=jid) + iq.set_payload(stanza) + try: + iq.send(block=False) + except Exception: + logging.exception("Failed to send game list to %s", jid) + else: + iq = self.make_iq_result(ito=to) + iq.set_payload(stanza) + try: + iq.send(block=False) + except Exception: + logging.exception("Failed to send game list to %s", to) + + +def parse_args(args): + """Parse command line arguments. + + Arguments: + args (dict): Raw command line arguments given to the script + + Returns: + Parsed command line arguments + + """ + parser = argparse.ArgumentParser(formatter_class=argparse.ArgumentDefaultsHelpFormatter, + description="XpartaMuPP - XMPP Multiplayer Game Manager") + + log_settings = parser.add_mutually_exclusive_group() + log_settings.add_argument('-q', '--quiet', help="only log errors", action='store_const', + dest='log_level', const=logging.ERROR) + log_settings.add_argument('-d', '--debug', help="log debug messages", action='store_const', + dest='log_level', const=logging.DEBUG) + log_settings.add_argument('-v', '--verbose', help="log more informative messages", + action='store_const', dest='log_level', const=logging.INFO) + log_settings.set_defaults(log_level=logging.WARNING) + + parser.add_argument('-m', '--domain', help="XMPP server to connect to", + default='lobby.wildfiregames.com') + parser.add_argument('-l', '--login', help="username for login", default='xpartamupp') + parser.add_argument('-p', '--password', help="password for login", default='XXXXXX') + parser.add_argument('-n', '--nickname', help="nickname shown to players", default='WFGBot') + parser.add_argument('-r', '--room', help="XMPP MUC room to join", default='arena') + + return parser.parse_args(args) + + +def main(): + """Entry point a console script.""" + args = parse_args(sys.argv[1:]) + + logging.basicConfig(level=args.log_level, + format='%(asctime)s %(levelname)-8s %(message)s', + datefmt='%Y-%m-%d %H:%M:%S') + + xmpp = XpartaMuPP(sleekxmpp.jid.JID('%s@%s/%s' % (args.login, args.domain, 'XpartaMuPP')), + args.password, args.room + '@conference.' + args.domain, args.nickname) + xmpp.register_plugin('xep_0030') # Service Discovery + xmpp.register_plugin('xep_0004') # Data Forms + xmpp.register_plugin('xep_0045') # Multi-User Chat + xmpp.register_plugin('xep_0060') # Publish-Subscribe + xmpp.register_plugin('xep_0199', {'keepalive': True}) # XMPP Ping + + if xmpp.connect(): + xmpp.process() + else: + logging.error("Unable to connect") + + +if __name__ == '__main__': + main()