Mercurial > libervia-backend
view src/plugins/plugin_misc_room_game.py @ 787:dd656d745d6a
test: added tests for XEP-0033
author | souliane <souliane@mailoo.org> |
---|---|
date | Sun, 05 Jan 2014 13:05:31 +0100 |
parents | bfabeedbf32e |
children | 19262fb77230 |
line wrap: on
line source
#!/usr/bin/python # -*- coding: utf-8 -*- # SAT: a jabber client # Copyright (C) 2009, 2010, 2011, 2012, 2013 Jérôme Poisson (goffi@goffi.org) # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # This program 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 Affero General Public License for more details. # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. from sat.core.i18n import _ from logging import debug, warning, error from twisted.words.protocols.jabber.jid import JID from twisted.words.xish import domish from time import time from wokkel import disco, iwokkel from zope.interface import implements import copy try: from twisted.words.protocols.xmlstream import XMPPHandler except ImportError: from wokkel.subprotocols import XMPPHandler # Don't forget to set it to False before you commit _DEBUG = False PLUGIN_INFO = { "name": "Room game", "import_name": "ROOM-GAME", "type": "MISC", "protocols": [], "dependencies": ["XEP-0045", "XEP-0249"], "main": "RoomGame", "handler": "no", # handler MUST be "no" (dynamic inheritance) "description": _("""Base class for MUC games""") } class RoomGame(object): """This class is used to help launching a MUC game.""" # Values for self.invite_mode (who can invite after the game creation) FROM_ALL, FROM_NONE, FROM_REFEREE, FROM_PLAYERS = xrange(0, 4) # Values for self.wait_mode (for who we should wait before creating the game) FOR_ALL, FOR_NONE = xrange(0, 2) # Values for self.join_mode (who can join the game - NONE means solo game) ALL, INVITED, NONE = xrange(0, 3) # Values for ready_mode (how to turn a MUC user into a player) ASK, FORCE = xrange(0, 2) MESSAGE = '/message' REQUEST = '%s/%s[@xmlns="%s"]' def __init__(self, host): self.host = host def _init_(self, host, plugin_info, ns_tag, game_init={}, player_init={}): """ @param host @param plugin_info: PLUGIN_INFO map of the game plugin @ns_tag: couple (nameservice, tag) to construct the messages @param game_init: dictionary for general game initialization @param player_init: dictionary for player initialization, applicable to each player """ self.host = host self.name = plugin_info["import_name"] self.ns_tag = ns_tag self.request = self.REQUEST % (self.MESSAGE, ns_tag[1], ns_tag[0]) self.game_init = game_init self.player_init = player_init self.games = {} self.invitations = {} # list of couple (x, y) with x the time and y a list of users # These are the default settings, which can be overwritten by child class after initialization self.invite_mode = self.FROM_PLAYERS if self.player_init == {} else self.FROM_NONE self.wait_mode = self.FOR_NONE if self.player_init == {} else self.FOR_ALL self.join_mode = self.INVITED self.ready_mode = self.FORCE # TODO: asking for confirmation is not implemented host.trigger.add("MUC user joined", self.userJoinedTrigger) host.trigger.add("MUC user left", self.userLeftTrigger) def createOrInvite(self, room, other_players, profile): """ This is called only when someone explicitly wants to play. The game must not be created if one already exists in the room, or its creation could be postponed until all the expected players join the room (in that case it will be created from userJoinedTrigger). @param room: instance of wokkel.muc.Room @param other_players: list for other players JID userhosts """ user_jid = self.host.getJidNStream(profile)[0] room_jid_s = room.occupantJID.userhost() nick = self.host.plugins["XEP-0045"].getRoomNick(room_jid_s, profile) nicks = [nick] if self.gameExists(room_jid_s): if not self.checkJoinAuth(room_jid_s, user_jid.userhost(), nick): return nicks.extend(self.invitePlayers(room, other_players, nick, profile)) self.updatePlayers(room_jid_s, nicks, profile) else: self.initGame(room_jid_s, nick) (auth, waiting, missing) = self.checkWaitAuth(room, other_players) nicks.extend(waiting) nicks.extend(self.invitePlayers(room, missing, nick, profile)) if auth: self.createGame(room_jid_s, nicks, profile) else: self.updatePlayers(room_jid_s, nicks, profile) def initGame(self, room_jid_s, referee_nick): """Important: do not add the referee to 'players' yet. For a <players /> message to be emitted whenever a new player is joining, it is necessary to not modify 'players' outside of updatePlayers. """ referee = room_jid_s + '/' + referee_nick self.games[room_jid_s] = {'referee': referee, 'players': [], 'started': False} self.games[room_jid_s].update(copy.deepcopy(self.game_init)) def gameExists(self, room_jid_s, started=False): """Return True if a game has been initialized/started. @param started: if False, the game must be initialized only, otherwise it must be initialized and started with createGame. @return: True if a game is initialized/started in that room""" return room_jid_s in self.games and (not started or self.games[room_jid_s]['started']) def checkJoinAuth(self, room_jid_s, user_jid_s=None, nick="", verbose=False): """Checks if this profile is allowed to join the game. The parameter nick is used to check if the user is already a player in that game. When this method is called from userJoinedTrigger, nick is also used to check the user identity instead of user_jid_s (see TODO remark below). @param room_jid_s: the room hosting the game @param user_jid_s: JID userhost of the user @param nick: nick of the user """ auth = False if not self.gameExists(room_jid_s): auth = False elif self.join_mode == self.ALL or self.isPlayer(room_jid_s, nick): auth = True elif self.join_mode == self.INVITED: # considering all the batches of invitations for invitations in self.invitations[room_jid_s]: if user_jid_s is not None: if user_jid_s in invitations[1]: auth = True break else: # TODO: that's not secure enough but what to do if # wokkel.muc.User's 'entity' attribute is not set?! if nick in [JID(invited).user for invited in invitations[1]]: auth = True break if not auth and (verbose or _DEBUG): debug(_("%s not allowed to join the game %s in %s") % (user_jid_s or nick, self.name, room_jid_s)) return auth def updatePlayers(self, room_jid_s, nicks, profile): """Signal to the room that some players joined the game""" if nicks == []: return new_nicks = set(nicks).difference(self.games[room_jid_s]['players']) if len(new_nicks) == 0: return self.games[room_jid_s]['players'].extend(new_nicks) self.signalPlayers(room_jid_s, [JID(room_jid_s)], profile) def signalPlayers(self, room_jid_s, recipients, profile): """Let these guys know that we are playing (they may not play themselves).""" if self.gameExists(room_jid_s, started=True): element = self.createStartElement(self.games[room_jid_s]['players']) else: element = self.createStartElement(self.games[room_jid_s]['players'], name="players") elements = [(element, None, None)] for child in self.getSyncData(room_jid_s): # TODO: sync data may be different and private to each player, # in that case send a separate message to the new players elements.append((child, None, None)) for recipient in recipients: self.sendElements(recipient, elements, profile=profile) def getSyncData(self, room_jid_s): """This method may be overwritten by any child class. @return: a list of child elements to be added for the game to be synchronized. """ return [] def invitePlayers(self, room, other_players, nick, profile): """Invite players to a room, associated game may exist or not. @param room: wokkel.muc.Room instance @param other_players: list of JID userhosts to invite @param nick: nick of the user who send the invitation @return: list of the invited players who were already in the room """ room_jid = room.occupantJID.userhostJID() room_jid_s = room.occupantJID.userhost() if not self.checkInviteAuth(room_jid_s, nick): return [] self.invitations.setdefault(room_jid_s, []) # TODO: remove invitation waiting for too long, using the time data self.invitations[room_jid_s].append((time(), other_players)) nicks = [nick] for player_jid in [JID(player) for player in other_players]: # TODO: find a way to make it secure other_nick = self.host.plugins["XEP-0045"].getRoomNickOfUser(room, player_jid, secure=False) if other_nick is None: self.host.plugins["XEP-0249"].invite(player_jid, room_jid, {"game": self.name}, profile) else: nicks.append(other_nick) return nicks def checkInviteAuth(self, room_jid_s, nick, verbose=False): """Checks if this profile is allowed to invite players""" auth = False if self.invite_mode == self.FROM_ALL or not self.gameExists(room_jid_s): auth = True elif self.invite_mode == self.FROM_NONE: auth = not self.gameExists(room_jid_s, started=True) elif self.invite_mode == self.FROM_REFEREE: auth = self.isReferee(room_jid_s, nick) elif self.invite_mode == self.FROM_PLAYERS: auth = self.isPlayer(room_jid_s, nick) if not auth and (verbose or _DEBUG): debug(_("%s not allowed to invite for the game %s in %s") % (nick, self.name, room_jid_s)) return auth def isReferee(self, room_jid_s, nick): """Checks if the player with this nick is the referee for the game in this room""" if not self.gameExists(room_jid_s): return False return room_jid_s + '/' + nick == self.games[room_jid_s]['referee'] def isPlayer(self, room_jid_s, nick): """Checks if the player with this nick is a player for the game in this room. Important: the referee is not in the 'players' list right after the game initialization - check with isReferee to be sure nick is not a player. """ if not self.gameExists(room_jid_s): return False return nick in self.games[room_jid_s]['players'] or self.isReferee(room_jid_s, nick) def checkWaitAuth(self, room, other_players, verbose=False): """Check if we must wait before starting the game or not. @return: (x, y, z) with: x: False if we must wait, True otherwise y: the nicks of the players that have been checked and confirmed z: the players that have not been checked or that are missing """ if self.wait_mode == self.FOR_NONE or other_players == []: result = (True, [], other_players) elif len(room.roster) < len(other_players) + 1: result = (False, [], other_players) else: # TODO: find a way to make it secure (nicks, missing) = self.host.plugins["XEP-0045"].getRoomNicksOfUsers(room, other_players, secure=False) result = (len(nicks) == len(other_players), nicks, missing) if not result[0] and (verbose or _DEBUG): debug(_("Still waiting for %s before starting the game %s in %s") % (result[2], self.name, room.occupantJID.userhost())) return result def getUniqueName(self, muc_service="", profile_key='@DEFAULT@'): room = self.host.plugins["XEP-0045"].getUniqueName(muc_service, profile_key=profile_key) return "sat_%s_%s" % (self.name.lower(), room) if room != "" else "" def prepareRoom(self, other_players=[], room_jid=None, profile_key='@NONE@'): """Prepare the room for a game: create it and invite players. @param other_players: list for other players JID userhosts @param room_jid: JID userhost of the room to reuse or None to create a new room """ debug(_('Preparing room for %s game') % self.name) profile = self.host.memory.getProfileName(profile_key) if not profile: error(_("Unknown profile")) return def roomJoined(room): """@param room: instance of wokkel.muc.Room""" self.createOrInvite(room, other_players, profile) def afterClientInit(room_jid): """Create/join the given room, or a unique generated one if no room is specified. @param room_jid: room to join """ if room_jid is not None and room_jid != "": # a room name has been specified if room_jid in self.host.plugins["XEP-0045"].clients[profile].joined_rooms: roomJoined(self.host.plugins["XEP-0045"].clients[profile].joined_rooms[room_jid]) return else: room_jid = self.getUniqueName(profile_key=profile_key) if room_jid == "": return user_jid = self.host.getJidNStream(profile)[0] d = self.host.plugins["XEP-0045"].join(JID(room_jid), user_jid.user, {}, profile) d.addCallback(roomJoined) client = self.host.getClient(profile) if not client: error(_('No client for this profile key: %s') % profile_key) return client.client_initialized.addCallback(lambda ignore: afterClientInit(room_jid)) def userJoinedTrigger(self, room, user, profile): """This trigger is used to check if the new user can take part of a game, create the game if we were waiting for him or just update the players list. @room: wokkel.muc.Room object. room.roster is a dict{wokkel.muc.User.nick: wokkel.muc.User} @user: wokkel.muc.User object. user.nick is a unicode and user.entity a JID @return: True to not interrupt the main process. """ room_jid_s = room.occupantJID.userhost() profile_nick = room.occupantJID.resource if not self.isReferee(room_jid_s, profile_nick): return True # profile is not the referee if not self.checkJoinAuth(room_jid_s, nick=user.nick): # user not allowed but let him know that we are playing :p self.signalPlayers(room_jid_s, [JID(room_jid_s + '/' + user.nick)], profile) return True if self.wait_mode == self.FOR_ALL: # considering the last batch of invitations batch = len(self.invitations[room_jid_s]) - 1 if batch < 0: error("Invitations from %s to play %s in %s have been lost!" % (profile_nick, self.name, room_jid_s)) return True other_players = self.invitations[room_jid_s][batch][1] (auth, nicks, dummy) = self.checkWaitAuth(room, other_players) if auth: del self.invitations[room_jid_s][batch] nicks.insert(0, profile_nick) # add the referee self.createGame(room_jid_s, nicks, profile_key=profile) return True # let the room know that a new player joined self.updatePlayers(room_jid_s, [user.nick], profile) return True def userLeftTrigger(self, room, user, profile): """This trigger is used to update or stop the game when a user leaves. @room: wokkel.muc.Room object. room.roster is a dict{wokkel.muc.User.nick: wokkel.muc.User} @user: wokkel.muc.User object. user.nick is a unicode and user.entity a JID @return: True to not interrupt the main process. """ room_jid_s = room.occupantJID.userhost() profile_nick = room.occupantJID.resource if not self.isReferee(room_jid_s, profile_nick): return True # profile is not the referee if self.isPlayer(room_jid_s, user.nick): try: self.games[room_jid_s]['players'].remove(user.nick) except ValueError: pass if self.wait_mode == self.FOR_ALL: # allow this user to join the game again user_jid = user.entity.userhost() if len(self.invitations[room_jid_s]) == 0: self.invitations[room_jid_s].append((time(), [user_jid])) else: batch = 0 # add to the first batch of invitations if user_jid not in self.invitations[room_jid_s][batch][1]: self.invitations[room_jid_s][batch][1].append(user_jid) return True def checkCreateGameAndInit(self, room_jid_s, profile): """Check if that profile can create the game. If the game can be created but is not initialized yet, this method will also do the initialization @return: a couple (create, sync) with: - create: set to True to allow the game creation - sync: set to True to advice a game synchronization """ user_nick = self.host.plugins["XEP-0045"].getRoomNick(room_jid_s, profile) if not user_nick: error('Internal error') return False, False if self.gameExists(room_jid_s): referee = self.isReferee(room_jid_s, user_nick) if self.gameExists(room_jid_s, started=True): warning(_("%s game already created in room %s") % (self.name, room_jid_s)) return False, referee elif not referee: warning(_("%s game in room %s can only be created by %s") % (self.name, room_jid_s, user_nick)) return False, False else: self.initGame(room_jid_s, user_nick) return True, False def createGame(self, room_jid_s, nicks=[], profile_key='@NONE@'): """Create a new game - this can be called directly from a frontend and skips all the checks and invitation system, but the game must not exist and all the players must be in the room already. @param room_jid: JID userhost of the room @param nicks: list of players nicks in the room @param profile_key: %(doc_profile_key)s""" debug(_("Creating %s game in room %s") % (self.name, room_jid_s)) profile = self.host.memory.getProfileName(profile_key) if not profile: error(_("profile %s is unknown") % profile_key) return (create, sync) = self.checkCreateGameAndInit(room_jid_s, profile) if not create: if sync: debug(_('Synchronize game %s in %s for %s') % (self.name, room_jid_s, ', '.join(nicks))) # TODO: we should call a method to re-send the information to a player who left # and joined the room again, currently: we may restart a whole new round... self.updatePlayers(room_jid_s, nicks, profile) return self.games[room_jid_s]['started'] = True self.updatePlayers(room_jid_s, nicks, profile) if self.player_init == {}: return # specific data to each player status = {} players_data = {} for nick in nicks: # The dict must be COPIED otherwise it is shared between all users players_data[nick] = copy.deepcopy(self.player_init) status[nick] = "init" self.games[room_jid_s].update({'status': status, 'players_data': players_data}) def playerReady(self, player, referee, profile_key='@NONE@'): """Must be called when player is ready to start a new game""" profile = self.host.memory.getProfileName(profile_key) if not profile: error(_("profile %s is unknown") % profile_key) return debug('new player ready: %s' % profile) self.send(JID(referee), 'player_ready', {'player': player}, profile=profile) def newRound(self, room_jid, data, profile): """Launch a new round (reinit the user data)""" debug(_('new round for %s game') % self.name) game_data = self.games[room_jid.userhost()] players = game_data['players'] players_data = game_data['players_data'] game_data['stage'] = "init" common_data, msg_elts = copy.deepcopy(data) if data is not None else (None, None) if isinstance(msg_elts, dict): for player in players: to_jid = JID(room_jid.userhost() + "/" + player) # FIXME: gof: elem = msg_elts[player] if isinstance(msg_elts[player], domish.Element) else None self.send(to_jid, elem, profile=profile) elif isinstance(msg_elts, domish.Element): self.send(room_jid, msg_elts, profile=profile) if common_data is not None: for player in players: players_data[player].update(common_data) def createGameElt(self, to_jid, type_="normal"): """Create a generic domish Element for the game""" type_ = "normal" if to_jid.resource else "groupchat" elt = domish.Element((None, 'message')) elt["to"] = to_jid.full() elt["type"] = type_ elt.addElement(self.ns_tag) return elt def createStartElement(self, players=None, name="started"): """Create a game "started" domish Element @param name: element name (default: "started"). """ started_elt = domish.Element((None, name)) if players is None: return started_elt idx = 0 for player in players: player_elt = domish.Element((None, 'player')) player_elt.addContent(player) player_elt['index'] = str(idx) idx += 1 started_elt.addChild(player_elt) return started_elt def sendElements(self, to_jid, data, profile=None): """ @param to_jid: recipient JID @param data: list of (elem, attr, content) with: - elem: domish.Element, unicode or a couple: - domish.Element to be directly added as a child to the message - unicode name or couple (uri, name) to create a new domish.Element and add it as a child to the message (see domish.Element.addElement) - attrs: dictionary of attributes for the new child - content: unicode that is appended to the child content @param profile: the profile from which the message is sent """ if profile is None: error(_("Message can not be sent without a sender profile")) return msg = self.createGameElt(to_jid) for elem, attrs, content in data: if elem is not None: if isinstance(elem, domish.Element): msg.firstChildElement().addChild(elem) else: elem = msg.firstChildElement().addElement(elem) if attrs is not None: elem.attributes.update(attrs) if content is not None: elem.addContent(content) self.host.profiles[profile].xmlstream.send(msg) def send(self, to_jid, elem=None, attrs=None, content=None, profile=None): """ @param to_jid: recipient JID @param elem: domish.Element, unicode or a couple: - domish.Element to be directly added as a child to the message - unicode name or couple (uri, name) to create a new domish.Element and add it as a child to the message (see domish.Element.addElement) @param attrs: dictionary of attributes for the new child @param content: unicode that is appended to the child content @param profile: the profile from which the message is sent """ self.sendElements(to_jid, [(elem, attrs, content)], profile) def getHandler(self, profile): return RoomGameHandler(self) class RoomGameHandler (XMPPHandler): implements(iwokkel.IDisco) def __init__(self, plugin_parent): self.plugin_parent = plugin_parent self.host = plugin_parent.host def connectionInitialized(self): self.xmlstream.addObserver(self.plugin_parent.request, self.plugin_parent.room_game_cmd, profile=self.parent.profile) def getDiscoInfo(self, requestor, target, nodeIdentifier=''): return [disco.DiscoFeature(self.plugin_parent.ns_tag[0])] def getDiscoItems(self, requestor, target, nodeIdentifier=''): return []