Index: trunk/source/tools/lobbybots/EcheLOn/ELO.py
===================================================================
--- trunk/source/tools/lobbybots/EcheLOn/ELO.py
+++ trunk/source/tools/lobbybots/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: trunk/source/tools/lobbybots/EcheLOn/EcheLOn.py
===================================================================
--- trunk/source/tools/lobbybots/EcheLOn/EcheLOn.py
+++ trunk/source/tools/lobbybots/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: trunk/source/tools/lobbybots/EcheLOn/LobbyRanking.py
===================================================================
--- trunk/source/tools/lobbybots/EcheLOn/LobbyRanking.py
+++ trunk/source/tools/lobbybots/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: trunk/source/tools/lobbybots/XpartaMuPP/XpartaMuPP.py
===================================================================
--- trunk/source/tools/lobbybots/XpartaMuPP/XpartaMuPP.py
+++ trunk/source/tools/lobbybots/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: trunk/source/tools/lobbybots/requirements.txt
===================================================================
--- trunk/source/tools/lobbybots/requirements.txt
+++ trunk/source/tools/lobbybots/requirements.txt
@@ -0,0 +1,3 @@
+dnspython
+sleekxmpp
+sqlalchemy
Index: trunk/source/tools/lobbybots/setup.py
===================================================================
--- trunk/source/tools/lobbybots/setup.py
+++ trunk/source/tools/lobbybots/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.24',
+ 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: trunk/source/tools/lobbybots/tests/test_echelon.py
===================================================================
--- trunk/source/tools/lobbybots/tests/test_echelon.py
+++ trunk/source/tools/lobbybots/tests/test_echelon.py
@@ -0,0 +1,176 @@
+# Copyright (C) 2021 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, xserver=None, xdisabletls=False,
+ nickname='RatingsBot', password='XXXXXX', room='arena',
+ database_url='sqlite:///lobby_rankings.sqlite3')),
+ (['--debug'],
+ Namespace(domain='lobby.wildfiregames.com', login='EcheLOn', log_level=10, xserver=None,xdisabletls=False,
+ nickname='RatingsBot', password='XXXXXX', room='arena',
+ database_url='sqlite:///lobby_rankings.sqlite3')),
+ (['--quiet'],
+ Namespace(domain='lobby.wildfiregames.com', login='EcheLOn', log_level=40, xserver=None,xdisabletls=False,
+ nickname='RatingsBot', password='XXXXXX', room='arena',
+ database_url='sqlite:///lobby_rankings.sqlite3')),
+ (['--verbose'],
+ Namespace(domain='lobby.wildfiregames.com', login='EcheLOn', log_level=20, xserver=None, xdisabletls=False,
+ 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', xserver=None, xdisabletls=False,
+ 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', xserver=None, xdisabletls=False,
+ 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', xserver=None, xdisabletls=False,
+ 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', xserver=None, xdisabletls=False,
+ 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',
+ xserver=None, xdisabletls=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(None, True, True)
+ 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',
+ xserver=None, xdisabletls=False)
+
+ 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(None, True, True)
+ xmpp_mock().process.assert_not_called()
Index: trunk/source/tools/lobbybots/tests/test_elo.py
===================================================================
--- trunk/source/tools/lobbybots/tests/test_elo.py
+++ trunk/source/tools/lobbybots/tests/test_elo.py
@@ -0,0 +1,150 @@
+# Copyright (C) 2021 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: trunk/source/tools/lobbybots/tests/test_lobby_ranking.py
===================================================================
--- trunk/source/tools/lobbybots/tests/test_lobby_ranking.py
+++ trunk/source/tools/lobbybots/tests/test_lobby_ranking.py
@@ -0,0 +1,70 @@
+# Copyright (C) 2021 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: trunk/source/tools/lobbybots/tests/test_utils.py
===================================================================
--- trunk/source/tools/lobbybots/tests/test_utils.py
+++ trunk/source/tools/lobbybots/tests/test_utils.py
@@ -0,0 +1,45 @@
+# Copyright (C) 2021 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: trunk/source/tools/lobbybots/tests/test_xpartamupp.py
===================================================================
--- trunk/source/tools/lobbybots/tests/test_xpartamupp.py
+++ trunk/source/tools/lobbybots/tests/test_xpartamupp.py
@@ -0,0 +1,179 @@
+# Copyright (C) 2021 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, xserver=None, xdisabletls=False,
+ nickname='WFGBot', password='XXXXXX', room='arena')),
+ (['--debug'],
+ Namespace(domain='lobby.wildfiregames.com', login='xpartamupp', log_level=10, xserver=None, xdisabletls=False,
+ nickname='WFGBot', password='XXXXXX', room='arena')),
+ (['--quiet'],
+ Namespace(domain='lobby.wildfiregames.com', login='xpartamupp', log_level=40, xserver=None, xdisabletls=False,
+ nickname='WFGBot', password='XXXXXX', room='arena')),
+ (['--verbose'],
+ Namespace(domain='lobby.wildfiregames.com', login='xpartamupp', log_level=20, xserver=None, xdisabletls=False,
+ nickname='WFGBot', password='XXXXXX', room='arena')),
+ (['-m', 'lobby.domain.tld'],
+ Namespace(domain='lobby.domain.tld', login='xpartamupp', log_level=30, nickname='WFGBot', xserver=None, xdisabletls=False,
+ password='XXXXXX', room='arena')),
+ (['--domain=lobby.domain.tld'],
+ Namespace(domain='lobby.domain.tld', login='xpartamupp', log_level=30, nickname='WFGBot', xserver=None, xdisabletls=False,
+ 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, xserver=None, xdisabletls=False,
+ 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, xserver=None, xdisabletls=False,
+ 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',
+ xserver=None, xdisabletls=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(None, True, True)
+ 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',
+ xserver=None, xdisabletls=False)
+
+ 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(None, True, True)
+ xmpp_mock().process.assert_not_called()
Index: trunk/source/tools/lobbybots/xpartamupp/echelon.py
===================================================================
--- trunk/source/tools/lobbybots/xpartamupp/echelon.py
+++ trunk/source/tools/lobbybots/xpartamupp/echelon.py
@@ -1,795 +1,803 @@
#!/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
+# Copyright (C) 2021 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 import ElementBase, register_stanza_plugin, ET
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 sqlalchemy import func
+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
-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
+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)))
+class Leaderboard(object):
+ """Class that provides and manages leaderboard data."""
- if not player.first():
- return
+ def __init__(self, db_url):
+ """Initialize the leaderboard."""
+ self.rating_messages = deque()
- 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)
+ engine = create_engine(db_url)
+ session_factory = sessionmaker(bind=engine)
+ self.db = scoped_session(session_factory)
- if queried_player.highest_rating != -1:
- stats['highestRating'] = str(queried_player.highest_rating)
+ def get_or_create_player(self, jid):
+ """Get a player from the leaderboard database.
- 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
+ Get player information from the leaderboard database and
+ create him first, if he doesn't exist yet.
- 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()
+ Arguments:
+ jid (sleekxmpp.jid.JID): JID of the player to get
- 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
+ Returns:
+ Player instance representing the player specified by the
+ supplied JID
- 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
+ """
+ player = self.db.query(Player).filter(Player.jid.ilike(str(jid))).first()
+ if player:
+ return player
- players = map(lambda jid: db.query(Player).filter(Player.jid.ilike(str(jid))).first(),
- dict.keys(gamereport['playerStates']))
+ player = Player(jid=str(jid), rating=-1)
+ self.db.add(player)
+ self.db.commit()
+ logging.debug("Created player %s", jid)
+ return player
- winning_jid = list(dict.keys({jid: state for jid, state in
- gamereport['playerStates'].items()
- if state == 'won'}))[0]
+ def get_profile(self, jid):
+ """Get the leaderboard profile for the specified player.
- def get(stat, jid):
- return gamereport[stat][jid]
+ Arguments:
+ jid (sleekxmpp.jid.JID): JID of the player to retrieve the
+ profile for
- 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'}
+ Returns:
+ dict with statistics about the requested player or None if
+ the player isn't known
- 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)
+ """
+ stats = {}
+ player = self.db.query(Player).filter(Player.jid.ilike(str(jid))).first()
- 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
+ if not player:
+ logging.debug("Couldn't find profile for player %s", jid)
+ return {}
- 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
+ if player.rating != -1:
+ stats['rating'] = player.rating
+ rank = self.db.query(Player).filter(Player.rating >= player.rating).count()
+ stats['rank'] = rank
- 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
+ if player.highest_rating != -1:
+ stats['highestRating'] = player.highest_rating
- def getLastRatedMessage(self):
- """
- Gets the string of the last rated game. Triggers an update
- chat for the bot.
- """
- return self.lastRated
+ 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 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 _add_game(self, game_report): # pylint: disable=too-many-locals
+ """Add a game to the database.
- 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
+ Add a game to the database and update the data on a
+ player from game results.
- 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
+ Arguments:
+ game_report (dict): a report about a game
-## 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 = []
+ Returns:
+ Game object for the created game or None if the creation
+ failed for any reason.
- 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"]
+ """
+ # 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
- 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
+ players = self.db.query(Player).filter(func.lower(Player.jid).in_(
+ dict.keys(game_report['playerStates'])))
- self.checkFull()
+ winning_jid = [jid for jid, state in game_report['playerStates'].items()
+ if state == 'won'][0]
- 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
+ # 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'}
- 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:
+ 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:
- 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 = ""
+ 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
- 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
+ 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()
-## 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 get_rating_messages(self):
+ """Get messages announcing rated games.
- def addPlayerOnline(self, player):
- playerXml = ET.fromstring("%s" % player)
- self.xml.append(playerXml)
+ Returns:
+ list with the a messages about rated games
-## 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)
+ """
+ return self.rating_messages
-## 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):
+ 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.
"""
- 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)
+ def __init__(self, leaderboard):
+ """Initialize the report manager.
-## Main class which handles IQ data and sends new data ##
+ 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):
- """
- 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
+ """Main class which handles IQ data and sends new data."""
- # Init leaderboard object
- self.leaderboard = LeaderboardList(room)
+ def __init__(self, sjid, password, room, nick, leaderboard):
+ """Initialize EcheLOn."""
+ sleekxmpp.ClientXMPP.__init__(self, sjid, password)
+ self.whitespace_keepalive = False
- # gameReport to leaderboard abstraction
- self.reportManager = ReportManager(self.leaderboard)
+ self.sjid = sleekxmpp.jid.JID(sjid)
+ self.room = room
+ self.nick = nick
- # Store mapping of nicks and XmppIDs, attached via presence stanza
- self.nicks = {}
+ self.leaderboard = leaderboard
+ self.report_manager = ReportManager(self.leaderboard)
- self.lastLeft = ""
+ register_stanza_plugin(Iq, BoardListXmppPlugin)
+ register_stanza_plugin(Iq, GameReportXmppPlugin)
+ register_stanza_plugin(Iq, ProfileXmppPlugin)
- 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 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.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._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)
- 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 _session_start(self, event): # pylint: disable=unused-argument
+ """Join MUC channel and announce presence.
- 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")
+ Arguments:
+ event (dict): empty dummy dict
- 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']))
+ """
+ self.plugin['xep_0045'].joinMUC(self.room, self.nick)
+ self.send_presence()
+ self.get_roster()
+ logging.info("EcheLOn started")
- 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 _muc_online(self, presence):
+ """Add joining players to the list of players.
- 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:
+ 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']
- recipient = iq['boardlist']['recipient']
+ self.leaderboard.get_or_create_player(iq['from'])
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)
+ 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.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._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.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:
+ 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
+
"""
- Client is reporting end of game statistics
+ 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
+
"""
- if iq['gamereport']['sender']:
- sender = iq['gamereport']['sender']
- else:
- sender = iq['from']
+ 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:
- 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)
+ iq.send(block=False)
+ except Exception:
+ logging.exception("Failed to send leaderboard to %s", iq['to'])
- 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
+ def _send_rating_list(self, iq):
+ """Send the ratings of all online players.
- 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")
+ Arguments:
+ iq (sleekxmpp.stanza.iq.IQ): IQ stanza to reply to
- 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
+ """
+ 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)
- 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")
+ 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)
- def sendProfile(self, to, player, recipient):
- """
- Send the player profile to a specified target.
- """
- if to == "":
- logging.error("Failed to send profile")
- return
+ try:
+ iq.send(block=False)
+ except Exception:
+ logging.exception("Failed to send rating list to %s", iq['to'])
- 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
+ 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)
- if online == False:
- stats = self.leaderboard.getProfile(player + "@" + str(recipient).split('@')[1])
- stz = ProfileXmppPlugin()
- iq = self.Iq()
- iq['type'] = 'result'
+ stanza = BoardListXmppPlugin()
+ stanza.add_command('ratinglist')
+ for player in ratings.values():
+ stanza.add_item(player['name'], player['rating'])
- 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
+ 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)
- ## Set additional IQ attributes
- iq['to'] = to
+ def _send_profile(self, iq, player_nick):
+ """Send the player profile to a specified target.
- ## Try sending the stanza
- try:
- iq.send(block=False, now=True)
- except:
- traceback.print_exc()
- logging.error("Failed to send profile")
+ Arguments:
+ iq (sleekxmpp.stanza.iq.IQ): IQ stanza to reply to
+ player_nick (str): The nick of the player to get the
+ profile for
- def sendProfileNotFound(self, to, player, recipient):
- """
- Send a profile not-found error to a specified target.
- """
- stz = ProfileXmppPlugin()
- iq = self.Iq()
- iq['type'] = 'result'
+ """
+ jid_str = self.plugin['xep_0045'].getJidProperty(self.room, player_nick, 'jid')
+ player_jid = sleekxmpp.jid.JID(jid_str) if jid_str else None
- 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
+ # 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'))
- ## Set additional IQ attributes
- iq['to'] = to
+ try:
+ stats = self.leaderboard.get_profile(player_jid)
+ except Exception:
+ logging.exception("Failed to get leaderboard profile for player %s", player_jid)
+ stats = {}
- ## Try sending the stanza
- try:
- iq.send(block=False, now=True)
- except:
- traceback.print_exc()
- logging.error("Failed to send profile")
+ 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)
-## Main Program ##
-if __name__ == '__main__':
- # Setup the command line arguments.
- optp = OptionParser()
+ try:
+ iq.send(block=False)
+ except Exception:
+ logging.exception("Failed to send profile to %s", iq['to'])
- # 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")
+def parse_args(args):
+ """Parse command line arguments.
- # 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)
+ Arguments:
+ args (dict): Raw command line arguments given to the script
- opts, args = optp.parse_args()
+ Returns:
+ Parsed command line arguments
- # Setup logging.
- logging.basicConfig(level=opts.loglevel,
- format='%(asctime)s %(levelname)-8s %(message)s', datefmt='%Y-%m-%d %H:%M:%S')
+ """
+ parser = argparse.ArgumentParser(formatter_class=argparse.ArgumentDefaultsHelpFormatter,
+ description="EcheLOn - XMPP Rating Bot")
- # 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
+ 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)
- if xmpp.connect((opts.xserver, 5222), True, not opts.xdisabletls):
- xmpp.process(threaded=False)
- else:
- logging.error("Unable to connect")
+ 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')
+ parser.add_argument('-s', '--server', help='address of the ejabberd server',
+ action='store', dest='xserver', default=None)
+ parser.add_argument('-t', '--disable-tls', help='Pass this argument to connect without TLS encryption',
+ action='store_true', dest='xdisabletls', default=False)
+
+ 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, 'CC')), 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((args.xserver, 5222) if args.xserver else None, True, not args.xdisabletls):
+ xmpp.process()
+ else:
+ logging.error("Unable to connect")
+
+
+if __name__ == '__main__':
+ main()
Index: trunk/source/tools/lobbybots/xpartamupp/elo.py
===================================================================
--- trunk/source/tools/lobbybots/xpartamupp/elo.py
+++ trunk/source/tools/lobbybots/xpartamupp/elo.py
@@ -1,90 +1,82 @@
-"""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 .
-"""
+# Copyright (C) 2021 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
+"""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.0
+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.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
-# Fair rating adjustment loses against inflation
+# 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
+# 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.
+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, 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.
+ 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:
- The integer that should be subtracted from the loser's rating and added
- to the winner's rating to get their new ratings.
+ int: the adjustment which should be applied to the rating of
+ the first player
- 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)
+ """
+ if rating < -2199 or opponent_rating < -2199:
+ raise ValueError('Too small rating given: rating: %i, opponent rating: %i' %
+ (rating, opponent_rating))
-# 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))
-"""
+ 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: trunk/source/tools/lobbybots/xpartamupp/lobby_ranking.py
===================================================================
--- trunk/source/tools/lobbybots/xpartamupp/lobby_ranking.py
+++ trunk/source/tools/lobbybots/xpartamupp/lobby_ranking.py
@@ -1,140 +1,175 @@
#!/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
+# Copyright (C) 2021 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
-engine = sqlalchemy.create_engine('sqlite:///lobby_rankings.sqlite3')
-Session = sessionmaker(bind=engine)
-session = Session()
Base = declarative_base()
+
class Player(Base):
- __tablename__ = 'players'
+ """Model representing 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')
+ __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'
+ """Model representing game results."""
- 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)
+ __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'
+ """Model representing 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')
+ __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__':
- Base.metadata.create_all(engine)
-
+ main()
Index: trunk/source/tools/lobbybots/xpartamupp/stanzas.py
===================================================================
--- trunk/source/tools/lobbybots/xpartamupp/stanzas.py
+++ trunk/source/tools/lobbybots/xpartamupp/stanzas.py
@@ -0,0 +1,156 @@
+# Copyright (C) 2021 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: trunk/source/tools/lobbybots/xpartamupp/utils.py
===================================================================
--- trunk/source/tools/lobbybots/xpartamupp/utils.py
+++ trunk/source/tools/lobbybots/xpartamupp/utils.py
@@ -0,0 +1,48 @@
+# Copyright (C) 2021 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: trunk/source/tools/lobbybots/xpartamupp/xpartamupp.py
===================================================================
--- trunk/source/tools/lobbybots/xpartamupp/xpartamupp.py
+++ trunk/source/tools/lobbybots/xpartamupp/xpartamupp.py
@@ -1,663 +1,359 @@
#!/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 .
-"""
+# Copyright (C) 2021 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
+"""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 import ElementBase, register_stanza_plugin, ET
from sleekxmpp.xmlstream.handler import Callback
from sleekxmpp.xmlstream.matcher import StanzaPath
+from sleekxmpp.xmlstream.stanzabase import register_stanza_plugin
-## 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()))
+from xpartamupp.stanzas import GameListXmppPlugin
+from xpartamupp.utils import LimitedSizeDict
-## 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 Games(object):
+ """Class to tracks all games in the lobby."""
-## 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 __init__(self):
+ """Initialize with empty games."""
+ self.games = LimitedSizeDict(size_limit=2**7)
- def addGame(self, data):
- itemXml = ET.Element("game", data)
- self.xml.append(itemXml)
+ def add_game(self, jid, data):
+ """Add a 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
+ Arguments:
+ jid (sleekxmpp.jid.JID): JID of the player who started the
+ game
+ data (dict): information about the game
-## 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)
+ Returns:
+ True if adding the game succeeded, False if not
-## 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
+ """
+ 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
-## 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)
+ def remove_game(self, jid):
+ """Remove a game attached to a JID.
-## Main class which handles IQ data and sends new data ##
+ 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):
- """
- 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
+ """Main class which handles IQ data and sends new data."""
- self.ratingsBot = ratingsbot
- # Game collection
- self.gameList = GameList()
+ def __init__(self, sjid, password, room, nick):
+ """Initialize XpartaMuPP.
- # Store mapping of nicks and XmppIDs, attached via presence stanza
- self.nicks = {}
- self.presences = {} # Obselete when XEP-0060 is implemented.
+ 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
- self.lastLeft = ""
+ """
+ sleekxmpp.ClientXMPP.__init__(self, sjid, password)
+ self.whitespace_keepalive = False
- 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.room = room
+ self.nick = nick
- 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.games = Games()
- 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)
+ register_stanza_plugin(Iq, GameListXmppPlugin)
- 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")
+ self.register_handler(Callback('Iq Gamelist', StanzaPath('iq@type=set/gamelist'),
+ self._iq_game_list_handler))
- 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']))
+ 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 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 _session_start(self, event): # pylint: disable=unused-argument
+ """Join MUC channel and announce presence.
- 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')
+ Arguments:
+ event (dict): empty dummy dict
- 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
-
+ """
+ self.plugin['xep_0045'].joinMUC(self.room, self.nick)
+ self.send_presence()
+ self.get_roster()
+ logging.info("XpartaMuPP started")
- 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:
+ 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.
+
"""
- Register-update / unregister a game
+ 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':
- # 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")
+ success = self.games.add_game(iq['from'], iq['gamelist']['game'])
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")
-
+ success = self.games.remove_game(iq['from'])
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
+ success = self.games.change_game_state(iq['from'], iq['gamelist']['game'])
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)
+ logging.info('Received unknown game command: "%s"', command)
+ return
- 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()
+ if success:
+ try:
+ self._send_game_list()
+ except Exception:
+ logging.exception('Failed to send game list after "%s" command', command)
- ## Pull games and add each to the stanza
- for JIDs in games:
- g = games[JIDs]
- stz.addGame(g)
+ def _send_game_list(self, to=None):
+ """Send a massive stanza with the whole game list.
- ## 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
+ If no target is passed the gamelist is broadcasted to all
+ clients.
- ## 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
+ 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()
- ## Try sending the stanza
- try:
- iq.send(block=False, now=True)
- except:
- logging.error("Failed to send game list")
+ stanza = GameListXmppPlugin()
+ for jid in games:
+ stanza.add_game(games[jid])
- 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")
+ 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 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 parse_args(args):
+ """Parse command line arguments.
- 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
+ Arguments:
+ args (dict): Raw command line arguments given to the script
- 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
+ Returns:
+ Parsed command line arguments
- ## Set additional IQ attributes
- iq['to'] = to
+ """
+ parser = argparse.ArgumentParser(formatter_class=argparse.ArgumentDefaultsHelpFormatter,
+ description="XpartaMuPP - XMPP Multiplayer Game Manager")
- ## Try sending the stanza
- try:
- iq.send(block=False, now=True)
- except:
- traceback.print_exc()
- logging.error("Failed to send profile")
+ 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)
- 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
+ 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')
+ parser.add_argument('-s', '--server', help='address of the ejabberd server',
+ action='store', dest='xserver', default=None)
+ parser.add_argument('-t', '--disable-tls', help='Pass this argument to connect without TLS encryption',
+ action='store_true', dest='xdisabletls', default=False)
-## Main Program ##
-if __name__ == '__main__':
- # Setup the command line arguments.
- optp = OptionParser()
+ return parser.parse_args(args)
- # 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")
+def main():
+ """Entry point a console script."""
+ args = parse_args(sys.argv[1:])
- # 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)
+ logging.basicConfig(level=args.log_level,
+ format='%(asctime)s %(levelname)-8s %(message)s',
+ datefmt='%Y-%m-%d %H:%M:%S')
- opts, args = optp.parse_args()
+ xmpp = XpartaMuPP(sleekxmpp.jid.JID('%s@%s/%s' % (args.login, args.domain, 'CC')),
+ 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
- # Setup logging.
- logging.basicConfig(level=opts.loglevel,
- format='%(asctime)s %(levelname)-8s %(message)s', datefmt='%Y-%m-%d %H:%M:%S')
+ if xmpp.connect((args.xserver, 5222) if args.xserver else None, True, not args.xdisabletls):
+ xmpp.process()
+ else:
+ logging.error("Unable to connect")
- # 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")
+if __name__ == '__main__':
+ main()