Changeset View
Changeset View
Standalone View
Standalone View
EcheLOn/EcheLOn.py
#!/usr/bin/env python3 | #!/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 <http://www.gnu.org/licenses/>. | |||||
""" | |||||
import logging, time, traceback | # Copyright (C) 2020 Wildfire Games. | ||||
from optparse import OptionParser | # 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 <http://www.gnu.org/licenses/>. | |||||
"""0ad XMPP-bot responsible for managing game ratings.""" | |||||
import argparse | |||||
import difflib | |||||
import logging | |||||
import sys | |||||
from collections import deque | |||||
import sleekxmpp | import sleekxmpp | ||||
from sleekxmpp.stanza import Iq | from sleekxmpp.stanza import Iq | ||||
from sleekxmpp.xmlstream import ElementBase, register_stanza_plugin, ET | |||||
from sleekxmpp.xmlstream.handler import Callback | from sleekxmpp.xmlstream.handler import Callback | ||||
from sleekxmpp.xmlstream.matcher import StanzaPath | 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 EcheLOn.ELO import get_rating_adjustment | |||||
from EcheLOn.LobbyRanking import Game, Player, PlayerInfo | |||||
from stanzas import (BoardListXmppPlugin, GameReportXmppPlugin, ProfileXmppPlugin) | |||||
from utils import LimitedSizeDict | |||||
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 | # Rating that new players should be inserted into the | ||||
# database with, before they've played any games. | # 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): | class Leaderboard(object): | ||||
""" | """Class that provides and manages leaderboard data.""" | ||||
Retrieves the profile for the specified JID | |||||
""" | |||||
stats = {} | |||||
player = db.query(Player).filter(Player.jid.ilike(str(JID))) | |||||
if not player.first(): | def __init__(self, db_url): | ||||
return | """Initialize the leaderboard.""" | ||||
self.rating_messages = deque() | |||||
queried_player = player.first() | engine = create_engine(db_url) | ||||
playerID = queried_player.id | session_factory = sessionmaker(bind=engine) | ||||
if queried_player.rating != -1: | self.db = scoped_session(session_factory) | ||||
stats['rating'] = str(queried_player.rating) | |||||
rank = db.query(Player).filter(Player.rating >= queried_player.rating).count() | def get_or_create_player(self, jid): | ||||
stats['rank'] = str(rank) | """Get a player from the leaderboard database. | ||||
if queried_player.highest_rating != -1: | Get player information from the leaderboard database and | ||||
stats['highestRating'] = str(queried_player.highest_rating) | create him first, if he doesn't exist yet. | ||||
gamesPlayed = db.query(PlayerInfo).filter_by(player_id=playerID).count() | Arguments: | ||||
wins = db.query(Game).filter_by(winner_id=playerID).count() | jid (sleekxmpp.jid.JID): JID of the player to get | ||||
stats['totalGamesPlayed'] = str(gamesPlayed) | |||||
stats['wins'] = str(wins) | Returns: | ||||
stats['losses'] = str(gamesPlayed - wins) | Player instance representing the player specified by the | ||||
return stats | supplied JID | ||||
def getOrCreatePlayer(self, JID): | |||||
""" | """ | ||||
Stores a player(JID) in the database if they don't yet exist. | player = self.db.query(Player).filter(Player.jid.ilike(str(jid))).first() | ||||
Returns either the newly created instance of | if player: | ||||
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 player | ||||
return players.first() | |||||
def removePlayer(self, JID): | player = Player(jid=str(jid), rating=-1) | ||||
""" | self.db.add(player) | ||||
Remove a player(JID) from database. | self.db.commit() | ||||
Returns the player that was removed, or None | logging.debug("Created player %s", jid) | ||||
if that player didn't exist. | return player | ||||
def get_profile(self, jid): | |||||
"""Get the leaderboard profile for the specified player. | |||||
Arguments: | |||||
jid (sleekxmpp.jid.JID): JID of the player to retrieve the | |||||
profile for | |||||
Returns: | |||||
dict with statistics about the requested player or None if | |||||
the player isn't known | |||||
""" | """ | ||||
players = db.query(Player).filter(Player.jid.ilike(str(JID))) | stats = {} | ||||
player = players.first() | player = self.db.query(Player).filter(Player.jid.ilike(str(jid))).first() | ||||
if not player: | if not player: | ||||
return None | logging.debug("Couldn't find profile for player %s", jid) | ||||
players.delete() | return {} | ||||
return player | |||||
if player.rating != -1: | |||||
stats['rating'] = player.rating | |||||
rank = self.db.query(Player).filter(Player.rating >= player.rating).count() | |||||
stats['rank'] = rank | |||||
if player.highest_rating != -1: | |||||
stats['highestRating'] = player.highest_rating | |||||
games_played = self.db.query(PlayerInfo).filter_by(player_id=player.id).count() | |||||
wins = self.db.query(Game).filter_by(winner_id=player.id).count() | |||||
stats['totalGamesPlayed'] = games_played | |||||
stats['wins'] = wins | |||||
stats['losses'] = games_played - wins | |||||
return stats | |||||
def _add_game(self, game_report): # pylint: disable=too-many-locals | |||||
"""Add a game to the database. | |||||
Add a game to the database and update the data on a | |||||
player from game results. | |||||
Arguments: | |||||
game_report (dict): a report about a game | |||||
Returns: | |||||
Game object for the created game or None if the creation | |||||
failed for any reason. | |||||
def addGame(self, gamereport): | |||||
""" | """ | ||||
Adds a game to the database and updates the data | # Discard any games still in progress. We shouldn't get | ||||
on a player(JID) from game results. | # reports from those games anyway. | ||||
Returns the created Game object, or None if | if 'active' in dict.values(game_report['playerStates']): | ||||
the creation failed for any reason. | logging.warning("Received a game report for an unfinished game") | ||||
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 | return None | ||||
players = map(lambda jid: db.query(Player).filter(Player.jid.ilike(str(jid))).first(), | players = self.db.query(Player).filter(func.lower(Player.jid).in_( | ||||
dict.keys(gamereport['playerStates'])) | dict.keys(game_report['playerStates']))) | ||||
winning_jid = [jid for jid, state in game_report['playerStates'].items() | |||||
if state == 'won'][0] | |||||
winning_jid = list(dict.keys({jid: state for jid, state in | # single_stats = {'timeElapsed', 'mapName', 'teamsLocked', 'matchID'} | ||||
gamereport['playerStates'].items() | total_score_stats = {'economyScore', 'militaryScore', 'totalScore'} | ||||
if state == 'won'}))[0] | resource_stats = {'foodGathered', 'foodUsed', 'woodGathered', 'woodUsed', 'stoneGathered', | ||||
'stoneUsed', 'metalGathered', 'metalUsed', 'vegetarianFoodGathered', | |||||
def get(stat, jid): | 'treasuresCollected', 'lootCollected', 'tributesSent', | ||||
return gamereport[stat][jid] | 'tributesReceived'} | ||||
units_stats = {'totalUnitsTrained', 'totalUnitsLost', 'enemytotalUnitsKilled', | |||||
singleStats = {'timeElapsed', 'mapName', 'teamsLocked', 'matchID'} | 'infantryUnitsTrained', 'infantryUnitsLost', 'enemyInfantryUnitsKilled', | ||||
totalScoreStats = {'economyScore', 'militaryScore', 'totalScore'} | 'workerUnitsTrained', 'workerUnitsLost', 'enemyWorkerUnitsKilled', | ||||
resourceStats = {'foodGathered', 'foodUsed', 'woodGathered', 'woodUsed', | 'femaleCitizenUnitsTrained', 'femaleCitizenUnitsLost', | ||||
'stoneGathered', 'stoneUsed', 'metalGathered', 'metalUsed', 'vegetarianFoodGathered', | 'enemyFemaleCitizenUnitsKilled', 'cavalryUnitsTrained', 'cavalryUnitsLost', | ||||
'treasuresCollected', 'lootCollected', 'tributesSent', 'tributesReceived'} | 'enemyCavalryUnitsKilled', 'championUnitsTrained', 'championUnitsLost', | ||||
unitsStats = {'totalUnitsTrained', 'totalUnitsLost', 'enemytotalUnitsKilled', 'infantryUnitsTrained', | 'enemyChampionUnitsKilled', 'heroUnitsTrained', 'heroUnitsLost', | ||||
'infantryUnitsLost', 'enemyInfantryUnitsKilled', 'workerUnitsTrained', 'workerUnitsLost', | 'enemyHeroUnitsKilled', 'shipUnitsTrained', 'shipUnitsLost', | ||||
'enemyWorkerUnitsKilled', 'femaleCitizenUnitsTrained', 'femaleCitizenUnitsLost', 'enemyFemaleCitizenUnitsKilled', | 'enemyShipUnitsKilled', 'traderUnitsTrained', 'traderUnitsLost', | ||||
'cavalryUnitsTrained', 'cavalryUnitsLost', 'enemyCavalryUnitsKilled', 'championUnitsTrained', | 'enemyTraderUnitsKilled'} | ||||
'championUnitsLost', 'enemyChampionUnitsKilled', 'heroUnitsTrained', 'heroUnitsLost', | buildings_stats = {'totalBuildingsConstructed', 'totalBuildingsLost', | ||||
'enemyHeroUnitsKilled', 'shipUnitsTrained', 'shipUnitsLost', 'enemyShipUnitsKilled', 'traderUnitsTrained', | 'enemytotalBuildingsDestroyed', 'civCentreBuildingsConstructed', | ||||
'traderUnitsLost', 'enemyTraderUnitsKilled'} | 'civCentreBuildingsLost', 'enemyCivCentreBuildingsDestroyed', | ||||
buildingsStats = {'totalBuildingsConstructed', 'totalBuildingsLost', 'enemytotalBuildingsDestroyed', | 'houseBuildingsConstructed', 'houseBuildingsLost', | ||||
'civCentreBuildingsConstructed', 'civCentreBuildingsLost', 'enemyCivCentreBuildingsDestroyed', | 'enemyHouseBuildingsDestroyed', 'economicBuildingsConstructed', | ||||
'houseBuildingsConstructed', 'houseBuildingsLost', 'enemyHouseBuildingsDestroyed', | 'economicBuildingsLost', 'enemyEconomicBuildingsDestroyed', | ||||
'economicBuildingsConstructed', 'economicBuildingsLost', 'enemyEconomicBuildingsDestroyed', | 'outpostBuildingsConstructed', 'outpostBuildingsLost', | ||||
'outpostBuildingsConstructed', 'outpostBuildingsLost', 'enemyOutpostBuildingsDestroyed', | 'enemyOutpostBuildingsDestroyed', 'militaryBuildingsConstructed', | ||||
'militaryBuildingsConstructed', 'militaryBuildingsLost', 'enemyMilitaryBuildingsDestroyed', | 'militaryBuildingsLost', 'enemyMilitaryBuildingsDestroyed', | ||||
'fortressBuildingsConstructed', 'fortressBuildingsLost', 'enemyFortressBuildingsDestroyed', | 'fortressBuildingsConstructed', 'fortressBuildingsLost', | ||||
'wonderBuildingsConstructed', 'wonderBuildingsLost', 'enemyWonderBuildingsDestroyed'} | 'enemyFortressBuildingsDestroyed', 'wonderBuildingsConstructed', | ||||
marketStats = {'woodBought', 'foodBought', 'stoneBought', 'metalBought', 'tradeIncome'} | 'wonderBuildingsLost', 'enemyWonderBuildingsDestroyed'} | ||||
miscStats = {'civs', 'teams', 'percentMapExplored'} | market_stats = {'woodBought', 'foodBought', 'stoneBought', 'metalBought', 'tradeIncome'} | ||||
misc_stats = {'civs', 'teams', 'percentMapExplored'} | |||||
stats = totalScoreStats | resourceStats | unitsStats | buildingsStats | marketStats | miscStats | stats = total_score_stats | resource_stats | units_stats | buildings_stats | market_stats \ | ||||
playerInfos = [] | | misc_stats | ||||
player_infos = [] | |||||
for player in players: | for player in players: | ||||
jid = player.jid | player_jid = sleekxmpp.jid.JID(player.jid) | ||||
playerinfo = PlayerInfo(player=player) | player_info = PlayerInfo(player=player) | ||||
for reportname in stats: | for report_name in stats: | ||||
setattr(playerinfo, reportname, get(reportname, jid.lower())) | setattr(player_info, report_name, game_report[report_name][player_jid]) | ||||
playerInfos.append(playerinfo) | player_infos.append(player_info) | ||||
game = Game(map=gamereport['mapName'], duration=int(gamereport['timeElapsed']), teamsLocked=bool(gamereport['teamsLocked']), matchID=gamereport['matchID']) | game = Game(map=game_report['mapName'], duration=int(game_report['timeElapsed']), | ||||
game.players.extend(players) | teamsLocked=bool(game_report['teamsLocked']), matchID=game_report['matchID']) | ||||
game.player_info.extend(playerInfos) | game.player_info.extend(player_infos) | ||||
game.winner = db.query(Player).filter(Player.jid.ilike(str(winning_jid))).first() | game.winner = self.db.query(Player).filter(Player.jid.ilike(str(winning_jid))).first() | ||||
db.add(game) | self.db.add(game) | ||||
db.commit() | self.db.commit() | ||||
return game | return game | ||||
def verifyGame(self, gamereport): | @staticmethod | ||||
""" | def _verify_game(game_report): | ||||
Returns a boolean based on whether the game should be rated. | """Check whether or not the game should be rated. | ||||
Here, we can specify the criteria for rated games. | |||||
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 = list(dict.keys({jid: state for jid, state in | winning_jids = [jid for jid, state in game_report['playerStates'].items() | ||||
gamereport['playerStates'].items() | if state == 'won'] | ||||
if state == 'won'})) | # We only support 1v1s right now. | ||||
# We only support 1v1s right now. TODO: Support team games. | if len(winning_jids) > 1 or len(dict.keys(game_report['playerStates'])) != 2: | ||||
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 False | ||||
return True | return True | ||||
def rateGame(self, game): | def _rate_game(self, game): | ||||
""" | """Update player ratings based on game outcome. | ||||
Takes a game with 2 players and alters their ratings | |||||
based on the result of the game. | Take a game with 2 players and alters their ratings based on | ||||
Returns self. | the result of the game. | ||||
Side effects: | |||||
Changes the game's players' ratings in the database. | Adjusts the players ratings in the database. | ||||
Arguments: | |||||
game (Game): game to rate | |||||
""" | """ | ||||
player1 = game.players[0] | player1 = game.players[0] | ||||
player2 = game.players[1] | player2 = game.players[1] | ||||
# TODO: Support draws. Since it's impossible to draw in the game currently, | # Since it's impossible to draw in the game currently, the | ||||
# the database model, and therefore this code, requires a winner. | # database model, and therefore this code, requires a winner. | ||||
# The Elo implementation does not, however. | # The Elo implementation does not, however. | ||||
result = 1 if player1 == game.winner else -1 | result = 1 if player1 == game.winner else -1 | ||||
# Player's ratings are -1 unless they have played a rated game. | # Player's ratings are -1 unless they have played a rated game. | ||||
if player1.rating == -1: | if player1.rating == -1: | ||||
player1.rating = leaderboard_default_rating | player1.rating = LEADERBOARD_DEFAULT_RATING | ||||
if player2.rating == -1: | if player2.rating == -1: | ||||
player2.rating = leaderboard_default_rating | player2.rating = LEADERBOARD_DEFAULT_RATING | ||||
try: | |||||
rating_adjustment1 = int(get_rating_adjustment(player1.rating, player2.rating, | rating_adjustment1 = int(get_rating_adjustment(player1.rating, player2.rating, | ||||
len(player1.games), len(player2.games), result)) | len(player1.games), len(player2.games), | ||||
result)) | |||||
rating_adjustment2 = int(get_rating_adjustment(player2.rating, player1.rating, | rating_adjustment2 = int(get_rating_adjustment(player2.rating, player1.rating, | ||||
len(player2.games), len(player1.games), result * -1)) | len(player2.games), len(player1.games), | ||||
result * -1)) | |||||
except ValueError: | |||||
rating_adjustment1 = 0 | |||||
rating_adjustment2 = 0 | |||||
if result == 1: | if result == 1: | ||||
resultQualitative = "won" | result_qualitative = 'won' | ||||
elif result == 0: | elif result == 0: | ||||
resultQualitative = "drew" | result_qualitative = 'drew' | ||||
else: | else: | ||||
resultQualitative = "lost" | result_qualitative = 'lost' | ||||
name1 = '@'.join(player1.jid.split('@')[:-1]) | name1 = sleekxmpp.jid.JID(player1.jid).local | ||||
name2 = '@'.join(player2.jid.split('@')[:-1]) | name2 = sleekxmpp.jid.JID(player2.jid).local | ||||
self.lastRated = "A rated game has ended. %s %s against %s. Rating Adjustment: %s (%s -> %s) and %s (%s -> %s)."%(name1, | self.rating_messages.append("A rated game has ended. %s %s against %s. Rating " | ||||
resultQualitative, name2, name1, player1.rating, player1.rating + rating_adjustment1, | "Adjustment: %s (%s -> %s) and %s (%s -> %s)." % | ||||
name2, player2.rating, player2.rating + rating_adjustment2) | (name1, result_qualitative, name2, name1, player1.rating, | ||||
player1.rating + rating_adjustment1, name2, player2.rating, | |||||
player2.rating + rating_adjustment2)) | |||||
player1.rating += rating_adjustment1 | player1.rating += rating_adjustment1 | ||||
player2.rating += rating_adjustment2 | player2.rating += rating_adjustment2 | ||||
if not player1.highest_rating: | if not player1.highest_rating: | ||||
player1.highest_rating = -1 | player1.highest_rating = -1 | ||||
if not player2.highest_rating: | if not player2.highest_rating: | ||||
player2.highest_rating = -1 | player2.highest_rating = -1 | ||||
if player1.rating > player1.highest_rating: | player1.highest_rating = max(player1.rating, player1.highest_rating) | ||||
player1.highest_rating = player1.rating | player2.highest_rating = max(player2.rating, player2.highest_rating) | ||||
if player2.rating > player2.highest_rating: | self.db.commit() | ||||
player2.highest_rating = player2.rating | |||||
db.commit() | |||||
return self | |||||
def getLastRatedMessage(self): | def get_rating_messages(self): | ||||
""" | """Get messages announcing rated games. | ||||
Gets the string of the last rated game. Triggers an update | |||||
chat for the bot. | Returns: | ||||
""" | list with the a messages about rated games | ||||
return self.lastRated | |||||
def addAndRateGame(self, gamereport): | |||||
""" | """ | ||||
Calls addGame and if the game has only two | return self.rating_messages | ||||
players, also calls rateGame. | |||||
Returns the result of addGame. | 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.addGame(gamereport) | game = self._add_game(game_report) | ||||
if game and self.verifyGame(gamereport): | if game and self._verify_game(game_report): | ||||
self.rateGame(game) | self._rate_game(game) | ||||
else: | |||||
self.lastRated = "" | |||||
return game | return game | ||||
def getBoard(self): | def get_board(self, limit=100): | ||||
""" | """Return the ratings of the highest ranked players. | ||||
Returns a dictionary of player rankings to | |||||
JIDs for sending. | Arguments: | ||||
limit (int): Number of players to return | |||||
Returns: | |||||
dict with player JIDs, nicks and ratings | |||||
""" | """ | ||||
board = {} | ratings = {} | ||||
players = db.query(Player).filter(Player.rating != -1).order_by(Player.rating.desc()).limit(100).all() | players = self.db.query(Player).filter(Player.rating != -1) \ | ||||
for rank, player in enumerate(players): | .order_by(Player.rating.desc()).limit(limit) | ||||
board[player.jid] = {'name': '@'.join(player.jid.split('@')[:-1]), 'rating': str(player.rating)} | for player in players: | ||||
return board | ratings[player.jid] = {'name': sleekxmpp.jid.JID(player.jid).local, | ||||
'rating': player.rating} | |||||
def getRatingList(self, nicks): | return ratings | ||||
""" | |||||
Returns a rating list of players | def get_rating_list(self, nicks): | ||||
currently in the lobby by nick | """Return the ratings of all online players. | ||||
because the client can't link | |||||
JID to nick conveniently. | 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 | |||||
""" | """ | ||||
ratinglist = {} | ratings = {} | ||||
players = db.query(Player.jid, Player.rating).filter(func.upper(Player.jid).in_([ str(JID).upper() for JID in list(nicks) ])) | 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: | for player in players: | ||||
rating = str(player.rating) if player.rating != -1 else '' | rating = str(player.rating) if player.rating != -1 else '' | ||||
for JID in list(nicks): | for jid in list(nicks): | ||||
if JID.upper() == player.jid.upper(): | if jid == sleekxmpp.jid.JID(player.jid): | ||||
ratinglist[nicks[JID]] = {'name': nicks[JID], 'rating': rating} | ratings[nicks[str(jid)]] = {'name': nicks[jid], 'rating': rating} | ||||
break | break | ||||
return ratinglist | return ratings | ||||
## 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): | class ReportManager(object): | ||||
"""Class which manages different game reports from clients. | |||||
Calls leaderboard functions as appropriate. | |||||
""" | """ | ||||
Adds a game to the interface between a raw report | |||||
and the leaderboard database. | def __init__(self, leaderboard): | ||||
"""Initialize the report manager. | |||||
Arguments: | |||||
leaderboard (Leaderboard): Leaderboard the manager is for | |||||
""" | """ | ||||
# cleanRawGameReport is a copy of rawGameReport with all reporter specific information removed. | self.leaderboard = leaderboard | ||||
cleanRawGameReport = rawGameReport.copy() | self.interim_report_tracker = LimitedSizeDict(size_limit=2**12) | ||||
del cleanRawGameReport["playerID"] | |||||
def add_report(self, jid, raw_game_report): | |||||
if cleanRawGameReport not in self.interimReportTracker: | """Add a game to the interface between a raw report and the leaderboard database. | ||||
# Store the game. | |||||
appendIndex = len(self.interimReportTracker) | Arguments: | ||||
self.interimReportTracker.append(cleanRawGameReport) | jid (sleekxmpp.jid.JID): JID of the player who submitted | ||||
# Initilize the JIDs and store the initial JID. | the report | ||||
numPlayers = self.getNumPlayers(rawGameReport) | raw_game_report (dict): Game report generated by 0ad | ||||
JIDs = [None] * numPlayers | |||||
if numPlayers - int(rawGameReport["playerID"]) > -1: | """ | ||||
JIDs[int(rawGameReport["playerID"])-1] = str(JID).lower() | player_index = int(raw_game_report['playerID']) - 1 | ||||
self.interimJIDTracker.append(JIDs) | 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: | else: | ||||
# We get the index at which the JIDs coresponding to the game are stored. | player_jids[player_index] = str(jid) | ||||
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() | 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 | |||||
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. | Returns a processed gameReport of type dict. | ||||
""" | """ | ||||
processedGameReport = {} | processed_game_report = {} | ||||
for key in rawGameReport: | for key, value in game_report['report'].items(): | ||||
if rawGameReport[key].find(",") == -1: | if ',' not in value: | ||||
processedGameReport[key] = rawGameReport[key] | processed_game_report[key] = value | ||||
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: | else: | ||||
i += 1 | stat_to_jid = {} | ||||
self.leaderboard.lastRated = "" | 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 | |||||
def getNumPlayers(self, rawGameReport): | |||||
""" | """ | ||||
Computes the number of players in a raw gameReport. | if 'playerStates' in raw_game_report and ',' in raw_game_report['playerStates']: | ||||
Returns int, the number of players. | 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 | |||||
""" | """ | ||||
# Find a key in the report which holds values for multiple players. | report1_list = ['{ %s: %s }' % (key, value) for key, value in report1.items()] | ||||
for key in rawGameReport: | report2_list = ['{ %s: %s }' % (key, value) for key, value in report2.items()] | ||||
if rawGameReport[key].find(",") != -1: | return '\n'.join(difflib.ndiff(report1_list, report2_list)) | ||||
# 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("<player>%s</player>" % 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("<command>%s</command>" % command) | |||||
self.xml.append(commandXml) | |||||
def addRecipient(self, recipient): | |||||
recipientXml = ET.fromstring("<recipient>%s</recipient>" % 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("<sender>%s</sender>" % 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("<command>%s</command>" % command) | |||||
self.xml.append(commandXml) | |||||
def addRecipient(self, recipient): | |||||
recipientXml = ET.fromstring("<recipient>%s</recipient>" % 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): | class EcheLOn(sleekxmpp.ClientXMPP): | ||||
""" | """Main class which handles IQ data and sends new data.""" | ||||
A simple list provider | |||||
""" | def __init__(self, sjid, password, room, nick, leaderboard): | ||||
def __init__(self, sjid, password, room, nick): | """Initialize EcheLOn.""" | ||||
sleekxmpp.ClientXMPP.__init__(self, sjid, password) | sleekxmpp.ClientXMPP.__init__(self, sjid, password) | ||||
self.sjid = sjid | self.whitespace_keepalive = False | ||||
self.sjid = sleekxmpp.jid.JID(sjid) | |||||
self.room = room | self.room = room | ||||
self.nick = nick | 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.leaderboard = leaderboard | ||||
self.reportManager = ReportManager(self.leaderboard) | self.report_manager = 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, BoardListXmppPlugin) | ||||
register_stanza_plugin(Iq, GameReportXmppPlugin) | register_stanza_plugin(Iq, GameReportXmppPlugin) | ||||
register_stanza_plugin(Iq, ProfileXmppPlugin) | register_stanza_plugin(Iq, ProfileXmppPlugin) | ||||
self.register_handler(Callback('Iq Player', | self.register_handler(Callback('Iq Boardlist', StanzaPath('iq@type=get/boardlist'), | ||||
StanzaPath('iq/player'), | self._iq_board_list_handler)) | ||||
self.iqhandler, | self.register_handler(Callback('Iq GameReport', StanzaPath('iq@type=set/gamereport'), | ||||
instream=True)) | self._iq_game_report_handler)) | ||||
self.register_handler(Callback('Iq Boardlist', | self.register_handler(Callback('Iq Profile', StanzaPath('iq@type=get/profile'), | ||||
StanzaPath('iq/boardlist'), | self._iq_profile_handler)) | ||||
self.iqhandler, | |||||
instream=True)) | self.add_event_handler('session_start', self._session_start) | ||||
self.register_handler(Callback('Iq GameReport', | self.add_event_handler('muc::%s::got_online' % self.room, self._muc_online) | ||||
StanzaPath('iq/gamereport'), | self.add_event_handler('muc::%s::got_offline' % self.room, self._muc_offline) | ||||
self.iqhandler, | self.add_event_handler('groupchat_message', self._muc_message) | ||||
instream=True)) | |||||
self.register_handler(Callback('Iq Profile', | def _session_start(self, event): # pylint: disable=unused-argument | ||||
StanzaPath('iq/profile'), | """Join MUC channel and announce presence. | ||||
self.iqhandler, | |||||
instream=True)) | Arguments: | ||||
event (dict): empty dummy dict | |||||
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.plugin['xep_0045'].joinMUC(self.room, self.nick) | ||||
self.send_presence() | self.send_presence() | ||||
self.get_roster() | self.get_roster() | ||||
logging.info("EcheLOn started") | logging.info("EcheLOn started") | ||||
def muc_online(self, presence): | def _muc_online(self, presence): | ||||
""" | """Add joining players to the list of players. | ||||
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): | Arguments: | ||||
""" | presence (sleekxmpp.stanza.presence.Presence): Received | ||||
Process presence stanza from a chat room. | presence stanza. | ||||
""" | |||||
# 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 | nick = str(presence['muc']['nick']) | ||||
This method should be very robust because we could receive anything | 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. | |||||
""" | """ | ||||
if iq['type'] == 'error': | nick = str(presence['muc']['nick']) | ||||
logging.error('iqhandler error' + iq['error']['condition']) | jid = sleekxmpp.jid.JID(presence['muc']['jid']) | ||||
#self.disconnect() | |||||
elif iq['type'] == 'get': | 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 | |||||
""" | """ | ||||
Request lists. | 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 'boardlist' in iq.loaded_plugins: | if iq['from'].resource not in ['0ad']: | ||||
return | |||||
command = iq['boardlist']['command'] | command = iq['boardlist']['command'] | ||||
recipient = iq['boardlist']['recipient'] | self.leaderboard.get_or_create_player(iq['from']) | ||||
if command == 'getleaderboard': | if command == 'getleaderboard': | ||||
try: | try: | ||||
self.sendBoardList(iq['from'], recipient) | self._send_leaderboard(iq) | ||||
except: | except Exception: | ||||
traceback.print_exc() | logging.exception("Failed to process get leaderboard request from %s", | ||||
logging.error("Failed to process leaderboardlist request from %s" % iq['from'].bare) | iq['from'].bare) | ||||
elif command == 'getratinglist': | elif command == 'getratinglist': | ||||
try: | try: | ||||
self.sendRatingList(iq['from']); | self._send_rating_list(iq) | ||||
except: | except Exception: | ||||
traceback.print_exc() | logging.exception("Failed to send the rating list to %s", iq['from']) | ||||
else: | |||||
logging.error("Failed to process boardlist request from %s" % iq['from'].bare) | def _iq_game_report_handler(self, iq): | ||||
elif 'profile' in iq.loaded_plugins: | """Handle end of game reports from clients. | ||||
command = iq['profile']['command'] | |||||
recipient = iq['profile']['recipient'] | Arguments: | ||||
try: | iq (sleekxmpp.stanza.iq.IQ): Received IQ stanza | ||||
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 iq['from'].resource not in ['0ad']: | ||||
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 | 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: | try: | ||||
iq.send(block=False, now=True) | self.report_manager.add_report(iq['from'], iq['gamereport']['game']) | ||||
except: | except Exception: | ||||
logging.error("Failed to send rating list") | logging.exception("Failed to update game statistics for %s", iq['from'].bare) | ||||
def sendProfile(self, to, player, recipient): | rating_messages = self.leaderboard.get_rating_messages() | ||||
""" | if rating_messages: | ||||
Send the player profile to a specified target. | while rating_messages: | ||||
""" | message = rating_messages.popleft() | ||||
if to == "": | self.send_message(mto=self.room, mbody=message, mtype='groupchat', mnick=self.nick) | ||||
logging.error("Failed to send profile") | self._broadcast_rating_list() | ||||
return | |||||
online = False; | def _iq_profile_handler(self, iq): | ||||
## Pull stats and add it to the stanza | """Handle profile requests from clients. | ||||
for JID in list(self.nicks): | |||||
if self.nicks[JID] == player: | |||||
stats = self.leaderboard.getProfile(JID) | |||||
online = True | |||||
break | |||||
if online == False: | Arguments: | ||||
stats = self.leaderboard.getProfile(player + "@" + str(recipient).split('@')[1]) | iq (sleekxmpp.stanza.iq.IQ): Received IQ stanza | ||||
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 | if iq['from'].resource not in ['0ad']: | ||||
return | |||||
## Try sending the stanza | |||||
try: | try: | ||||
iq.send(block=False, now=True) | self._send_profile(iq, iq['profile']['command']) | ||||
except: | except Exception: | ||||
traceback.print_exc() | logging.exception("Failed to send profile about %s to %s", iq['profile']['command'], | ||||
logging.error("Failed to send profile") | iq['from'].bare) | ||||
def sendProfileNotFound(self, to, player, recipient): | def _send_leaderboard(self, iq): | ||||
""" | """Send the whole leaderboard. | ||||
Send a profile not-found error to a specified target. | |||||
""" | Arguments: | ||||
stz = ProfileXmppPlugin() | iq (sleekxmpp.stanza.iq.IQ): IQ stanza to reply to | ||||
iq = self.Iq() | |||||
iq['type'] = 'result' | """ | ||||
ratings = self.leaderboard.get_board() | |||||
filler = str(0) | |||||
stz.addItem(player, str(-2), filler, filler, filler, filler, filler) | iq = iq.reply(clear=True) | ||||
stz.addCommand(player) | stanza = BoardListXmppPlugin() | ||||
stz.addRecipient(recipient) | stanza.add_command('boardlist') | ||||
iq.setPayload(stz) | for player in ratings.values(): | ||||
## Check recipient exists | stanza.add_item(player['name'], player['rating']) | ||||
if str(to) not in self.nicks: | iq.set_payload(stanza) | ||||
logging.error("No player with the XmPP ID '%s' known to send profile to" % str(to)) | |||||
return | |||||
## Set additional IQ attributes | try: | ||||
iq['to'] = to | iq.send(block=False) | ||||
except Exception: | |||||
logging.exception("Failed to send leaderboard to %s", iq['to']) | |||||
def _send_rating_list(self, iq): | |||||
"""Send the ratings of all online players. | |||||
Arguments: | |||||
iq (sleekxmpp.stanza.iq.IQ): IQ stanza to reply to | |||||
""" | |||||
nicks = {} | |||||
for nick in self.plugin['xep_0045'].getRoster(self.room): | |||||
if nick == self.nick: | |||||
continue | |||||
jid_str = self.plugin['xep_0045'].getJidProperty(self.room, nick, 'jid') | |||||
jid = sleekxmpp.jid.JID(jid_str) | |||||
nicks[jid] = nick | |||||
ratings = self.leaderboard.get_rating_list(nicks) | |||||
iq = iq.reply(clear=True) | |||||
stanza = BoardListXmppPlugin() | |||||
stanza.add_command('ratinglist') | |||||
for player in ratings.values(): | |||||
stanza.add_item(player['name'], player['rating']) | |||||
iq.set_payload(stanza) | |||||
## Try sending the stanza | |||||
try: | try: | ||||
iq.send(block=False, now=True) | iq.send(block=False) | ||||
except: | except Exception: | ||||
traceback.print_exc() | logging.exception("Failed to send rating list to %s", iq['to']) | ||||
logging.error("Failed to send profile") | |||||
def _broadcast_rating_list(self): | |||||
"""Broadcast the ratings of all online players.""" | |||||
nicks = {} | |||||
for nick in self.plugin['xep_0045'].getRoster(self.room): | |||||
if nick == self.nick: | |||||
continue | |||||
jid_str = self.plugin['xep_0045'].getJidProperty(self.room, nick, 'jid') | |||||
jid = sleekxmpp.jid.JID(jid_str) | |||||
nicks[jid] = nick | |||||
ratings = self.leaderboard.get_rating_list(nicks) | |||||
stanza = BoardListXmppPlugin() | |||||
stanza.add_command('ratinglist') | |||||
for player in ratings.values(): | |||||
stanza.add_item(player['name'], player['rating']) | |||||
for jid in nicks: | |||||
iq = self.make_iq_result(ito=jid) | |||||
iq.set_payload(stanza) | |||||
try: | |||||
iq.send(block=False) | |||||
except Exception: | |||||
logging.exception("Failed to send rating list to %s", jid) | |||||
def _send_profile(self, iq, player_nick): | |||||
"""Send the player profile to a specified target. | |||||
Arguments: | |||||
iq (sleekxmpp.stanza.iq.IQ): IQ stanza to reply to | |||||
player_nick (str): The nick of the player to get the | |||||
profile for | |||||
""" | |||||
jid_str = self.plugin['xep_0045'].getJidProperty(self.room, player_nick, 'jid') | |||||
player_jid = sleekxmpp.jid.JID(jid_str) if jid_str else None | |||||
# The player the profile got requested for is not online, so | |||||
# let's assume the JID contains the nick as local part. | |||||
if not player_jid: | |||||
player_jid = sleekxmpp.jid.JID('%s@%s/%s' % (player_nick, self.sjid.domain, '0ad')) | |||||
## Main Program ## | try: | ||||
if __name__ == '__main__': | stats = self.leaderboard.get_profile(player_jid) | ||||
# Setup the command line arguments. | except Exception: | ||||
optp = OptionParser() | logging.exception("Failed to get leaderboard profile for player %s", player_jid) | ||||
stats = {} | |||||
# Output verbosity options. | iq = iq.reply(clear=True) | ||||
optp.add_option('-q', '--quiet', help='set logging to ERROR', | stanza = ProfileXmppPlugin() | ||||
action='store_const', dest='loglevel', | if stats: | ||||
const=logging.ERROR, default=logging.INFO) | stanza.add_item(player_nick, stats['rating'], stats['highestRating'], | ||||
optp.add_option('-d', '--debug', help='set logging to DEBUG', | stats['rank'], stats['totalGamesPlayed'], stats['wins'], | ||||
action='store_const', dest='loglevel', | stats['losses']) | ||||
const=logging.DEBUG, default=logging.INFO) | else: | ||||
optp.add_option('-v', '--verbose', help='set logging to COMM', | stanza.add_item(player_nick, -2) | ||||
action='store_const', dest='loglevel', | stanza.add_command(player_nick) | ||||
const=5, default=logging.INFO) | iq.set_payload(stanza) | ||||
# 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 | try: | ||||
xmpp = EcheLOn(opts.xlogin+'@'+opts.xdomain+'/CC', opts.xpassword, opts.xroom+'@conference.'+opts.xdomain, opts.xnickname) | iq.send(block=False) | ||||
except Exception: | |||||
logging.exception("Failed to send profile to %s", iq['to']) | |||||
def parse_args(args): | |||||
"""Parse command line arguments. | |||||
Arguments: | |||||
args (dict): Raw command line arguments given to the script | |||||
Returns: | |||||
Parsed command line arguments | |||||
""" | |||||
parser = argparse.ArgumentParser(formatter_class=argparse.ArgumentDefaultsHelpFormatter, | |||||
description="EcheLOn - XMPP Rating Bot") | |||||
log_settings = parser.add_mutually_exclusive_group() | |||||
log_settings.add_argument('-q', '--quiet', help="only log errors", action='store_const', | |||||
dest='log_level', const=logging.ERROR) | |||||
log_settings.add_argument('-d', '--debug', help="log debug messages", action='store_const', | |||||
dest='log_level', const=logging.DEBUG) | |||||
log_settings.add_argument('-v', '--verbose', help="log more informative messages", | |||||
action='store_const', dest='log_level', const=logging.INFO) | |||||
log_settings.set_defaults(log_level=logging.WARNING) | |||||
parser.add_argument('-m', '--domain', help="XMPP server to connect to", | |||||
default='lobby.wildfiregames.com') | |||||
parser.add_argument('-l', '--login', help="username for login", default='EcheLOn') | |||||
parser.add_argument('-p', '--password', help="password for login", default='XXXXXX') | |||||
parser.add_argument('-n', '--nickname', help="nickname shown to players", default='RatingsBot') | |||||
parser.add_argument('-r', '--room', help="XMPP MUC room to join", default='arena') | |||||
parser.add_argument('--database-url', help="URL for the leaderboard database", | |||||
default='sqlite:///lobby_rankings.sqlite3') | |||||
parser.add_argument('-s', '--server', help='address of the ejabberd server', | |||||
action='store', dest='xserver') | |||||
parser.add_argument('-t', '--disable-tls', help='Pass this argument to connect without TLS encryption', | |||||
action='store_true', dest='xdisabletls') | |||||
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_0030') # Service Discovery | ||||
xmpp.register_plugin('xep_0004') # Data Forms | xmpp.register_plugin('xep_0004') # Data Forms | ||||
xmpp.register_plugin('xep_0045') # Multi-User Chat # used | xmpp.register_plugin('xep_0045') # Multi-User Chat | ||||
xmpp.register_plugin('xep_0060') # PubSub | xmpp.register_plugin('xep_0060') # Publish-Subscribe | ||||
xmpp.register_plugin('xep_0199') # XMPP Ping | xmpp.register_plugin('xep_0199', {'keepalive': True}) # XMPP Ping | ||||
if xmpp.connect((opts.xserver, 5222), True, not opts.xdisabletls): | if xmpp.connect((args.xserver, 5222) if args.xserver else None, True, not args.xdisabletls): | ||||
xmpp.process(threaded=False) | xmpp.process() | ||||
else: | else: | ||||
logging.error("Unable to connect") | logging.error("Unable to connect") | ||||
if __name__ == '__main__': | |||||
main() |
Wildfire Games · Phabricator