view src/plugins/plugin_misc_room_game.py @ 771:bfabeedbf32e

core: i18n refactoring: - _() is no more installed in __builtin__ - instead, there is a new sat.core.i18n module - added D_() method for deferred translation - languageSwitch method allow to dynamically change translation language - import gettext is tested against ImportError, and dummy methods are used when not available (mainly useful for Libervia)
author Goffi <goffi@goffi.org>
date Sun, 29 Dec 2013 17:06:01 +0100
parents 539f278bc265
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 []