comparison src/plugins/plugin_misc_room_game.py @ 718:074970227bc0

plugin tools: turn src/plugin/games.py into a plugin and move it to src/plugins/plugin_misc_room_game.py
author souliane <souliane@mailoo.org>
date Thu, 21 Nov 2013 18:23:08 +0100
parents src/tools/plugins/games.py@358018c5c398
children 539f278bc265
comparison
equal deleted inserted replaced
717:358018c5c398 718:074970227bc0
1 #!/usr/bin/python
2 # -*- coding: utf-8 -*-
3
4 # SAT: a jabber client
5 # Copyright (C) 2009, 2010, 2011, 2012, 2013 Jérôme Poisson (goffi@goffi.org)
6
7 # This program is free software: you can redistribute it and/or modify
8 # it under the terms of the GNU Affero General Public License as published by
9 # the Free Software Foundation, either version 3 of the License, or
10 # (at your option) any later version.
11
12 # This program is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 # GNU Affero General Public License for more details.
16
17 # You should have received a copy of the GNU Affero General Public License
18 # along with this program. If not, see <http://www.gnu.org/licenses/>.
19
20 from logging import debug, warning, error
21 from twisted.words.protocols.jabber.jid import JID
22 from twisted.words.xish import domish
23 from time import time
24 from wokkel import disco, iwokkel
25 from zope.interface import implements
26 try:
27 from twisted.words.protocols.xmlstream import XMPPHandler
28 except ImportError:
29 from wokkel.subprotocols import XMPPHandler
30
31 # Don't forget to set it to False before you commit
32 _DEBUG = False
33 _DEBUG_FILE = False
34
35 PLUGIN_INFO = {
36 "name": "Room game",
37 "import_name": "ROOM-GAME",
38 "type": "MISC",
39 "protocols": [],
40 "dependencies": ["XEP-0045", "XEP-0249"],
41 "main": "RoomGame",
42 "handler": "no", # handler MUST be "no" (dynamic inheritance)
43 "description": _("""Base class for MUC games""")
44 }
45
46
47 class RoomGame(object):
48 """This class is used to help launching a MUC game."""
49
50 # Values for self.invite_mode (who can invite after the game creation)
51 FROM_ALL, FROM_NONE, FROM_REFEREE, FROM_PLAYERS = xrange(0, 4)
52 # Values for self.wait_mode (for who we should wait before creating the game)
53 FOR_ALL, FOR_NONE = xrange(0, 2)
54 # Values for self.join_mode (who can join the game - NONE means solo game)
55 ALL, INVITED, NONE = xrange(0, 3)
56 # Values for ready_mode (how to turn a MUC user into a player)
57 ASK, FORCE = xrange(0, 2)
58
59 MESSAGE = '/message'
60 REQUEST = '%s/%s[@xmlns="%s"]'
61
62 def __init__(self, host):
63 self.host = host
64
65 def _init_(self, host, plugin_info, ns_tag, game_init={}, player_init={}):
66 """
67 @param host
68 @param plugin_info: PLUGIN_INFO map of the game plugin
69 @ns_tag: couple (nameservice, tag) to construct the messages
70 @param game_init: dictionary for general game initialization
71 @param player_init: dictionary for player initialization, applicable to each player
72 """
73 self.host = host
74 self.name = plugin_info["import_name"]
75 self.ns_tag = ns_tag
76 self.request = self.REQUEST % (self.MESSAGE, ns_tag[1], ns_tag[0])
77 self.game_init = game_init
78 self.player_init = player_init
79 self.games = {}
80 self.invitations = {} # list of couple (x, y) with x the time and y a list of users
81
82 # These are the default settings, which can be overwritten by child class after initialization
83 self.invite_mode = self.FROM_PLAYERS if self.player_init == {} else self.FROM_NONE
84 self.wait_mode = self.FOR_NONE if self.player_init == {} else self.FOR_ALL
85 self.join_mode = self.INVITED
86 self.ready_mode = self.FORCE # TODO: asking for confirmation is not implemented
87
88 host.trigger.add("MUC user joined", self.userJoinedTrigger)
89 host.trigger.add("MUC user left", self.userLeftTrigger)
90
91 def createOrInvite(self, room, other_players, profile):
92 """
93 This is called only when someone explicitly wants to play.
94 The game must not be created if one already exists in the room,
95 or its creation could be postponed until all the expected players
96 join the room (in that case it will be created from userJoinedTrigger).
97 @param room: instance of wokkel.muc.Room
98 @param other_players: list for other players JID userhosts
99 """
100 user_jid = self.host.getJidNStream(profile)[0]
101 room_jid_s = room.occupantJID.userhost()
102 nick = self.host.plugins["XEP-0045"].getRoomNick(room_jid_s, profile)
103 nicks = [nick]
104 if self.gameExists(room_jid_s):
105 if not self.checkJoinAuth(room_jid_s, user_jid.userhost(), nick):
106 return
107 nicks.extend(self.invitePlayers(room, other_players, nick, profile))
108 self.updatePlayers(room_jid_s, nicks, profile)
109 else:
110 self.initGame(room_jid_s, nick)
111 (auth, waiting, missing) = self.checkWaitAuth(room, other_players)
112 nicks.extend(waiting)
113 nicks.extend(self.invitePlayers(room, missing, nick, profile))
114 if auth:
115 self.createGame(room_jid_s, nicks, profile)
116 else:
117 self.updatePlayers(room_jid_s, nicks, profile)
118
119 def initGame(self, room_jid_s, referee_nick):
120 """Important: do not add the referee to 'players' yet. For a
121 <players /> message to be emitted whenever a new player is joining,
122 it is necessary to not modify 'players' outside of updatePlayers.
123 """
124 referee = room_jid_s + '/' + referee_nick
125 self.games[room_jid_s] = {'referee': referee, 'players': [], 'started': False}
126 self.games[room_jid_s].update(self.game_init)
127
128 def gameExists(self, room_jid_s, started=False):
129 """Return True if a game has been initialized/started.
130 @param started: if False, the game must be initialized only,
131 otherwise it must be initialized and started with createGame.
132 @return: True if a game is initialized/started in that room"""
133 return room_jid_s in self.games and (not started or self.games[room_jid_s]['started'])
134
135 def checkJoinAuth(self, room_jid_s, user_jid_s=None, nick="", verbose=False):
136 """Checks if this profile is allowed to join the game.
137 The parameter nick is used to check if the user is already
138 a player in that game. When this method is called from
139 userJoinedTrigger, nick is also used to check the user
140 identity instead of user_jid_s (see TODO remark below).
141 @param room_jid_s: the room hosting the game
142 @param user_jid_s: JID userhost of the user
143 @param nick: nick of the user
144 """
145 auth = False
146 if not self.gameExists(room_jid_s):
147 auth = False
148 elif self.join_mode == self.ALL or self.isPlayer(room_jid_s, nick):
149 auth = True
150 elif self.join_mode == self.INVITED:
151 # considering all the batches of invitations
152 for invitations in self.invitations[room_jid_s]:
153 if user_jid_s is not None:
154 if user_jid_s in invitations[1]:
155 auth = True
156 break
157 else:
158 # TODO: that's not secure enough but what to do if
159 # wokkel.muc.User's 'entity' attribute is not set?!
160 if nick in [JID(invited).user for invited in invitations[1]]:
161 auth = True
162 break
163
164 if not auth and (verbose or _DEBUG):
165 debug(_("%s not allowed to join the game %s in %s") % (user_jid_s or nick, self.name, room_jid_s))
166 return auth
167
168 def updatePlayers(self, room_jid_s, nicks, profile):
169 """Signal to the room or to each player that some players joined the game"""
170 if nicks == []:
171 return
172 new_nicks = set(nicks).difference(self.games[room_jid_s]['players'])
173 if len(new_nicks) == 0:
174 return
175 self.games[room_jid_s]['players'].extend(new_nicks)
176 self.signalPlayers(room_jid_s, [JID(room_jid_s)], profile)
177
178 def signalPlayers(self, room_jid_s, recipients, profile):
179 """Let these guys know that we are playing (they may not play themselves)."""
180 if self.gameExists(room_jid_s, started=True):
181 element = self.createStartElement(self.games[room_jid_s]['players'])
182 else:
183 element = self.createStartElement(self.games[room_jid_s]['players'], name="players")
184 for recipient in recipients:
185 self.send(recipient, element, profile=profile)
186
187 def invitePlayers(self, room, other_players, nick, profile):
188 """Invite players to a room, associated game may exist or not.
189 @param room: wokkel.muc.Room instance
190 @param other_players: list of JID userhosts to invite
191 @param nick: nick of the user who send the invitation
192 @return: list of the invited players who were already in the room
193 """
194 room_jid = room.occupantJID.userhostJID()
195 room_jid_s = room.occupantJID.userhost()
196 if not self.checkInviteAuth(room_jid_s, nick):
197 return []
198 self.invitations.setdefault(room_jid_s, [])
199 # TODO: remove invitation waiting for too long, using the time data
200 self.invitations[room_jid_s].append((time(), other_players))
201 nicks = [nick]
202 for player_jid in [JID(player) for player in other_players]:
203 # TODO: find a way to make it secure
204 other_nick = self.host.plugins["XEP-0045"].getRoomNickOfUser(room, player_jid, secure=False)
205 if other_nick is None:
206 self.host.plugins["XEP-0249"].invite(player_jid, room_jid, {"game": self.name}, profile)
207 else:
208 nicks.append(other_nick)
209 return nicks
210
211 def checkInviteAuth(self, room_jid_s, nick, verbose=False):
212 """Checks if this profile is allowed to invite players"""
213 auth = False
214 if self.invite_mode == self.FROM_ALL or not self.gameExists(room_jid_s):
215 auth = True
216 elif self.invite_mode == self.FROM_NONE:
217 auth = not self.gameExists(room_jid_s, started=True)
218 elif self.invite_mode == self.FROM_REFEREE:
219 auth = self.isReferee(room_jid_s, nick)
220 elif self.invite_mode == self.FROM_PLAYERS:
221 auth = self.isPlayer(room_jid_s, nick)
222 if not auth and (verbose or _DEBUG):
223 debug(_("%s not allowed to invite for the game %s in %s") % (nick, self.name, room_jid_s))
224 return auth
225
226 def isReferee(self, room_jid_s, nick):
227 """Checks if the player with this nick is the referee for the game in this room"""
228 if not self.gameExists(room_jid_s):
229 return False
230 return room_jid_s + '/' + nick == self.games[room_jid_s]['referee']
231
232 def isPlayer(self, room_jid_s, nick):
233 """Checks if the player with this nick is a player for the game in this room.
234 Important: the referee is not in the 'players' list right after the game
235 initialization - check with isReferee to be sure nick is not a player.
236 """
237 if not self.gameExists(room_jid_s):
238 return False
239 return nick in self.games[room_jid_s]['players'] or self.isReferee(room_jid_s, nick)
240
241 def checkWaitAuth(self, room, other_players, verbose=False):
242 """Check if we must wait before starting the game or not.
243 @return: (x, y, z) with:
244 x: False if we must wait, True otherwise
245 y: the nicks of the players that have been checked and confirmed
246 z: the players that have not been checked or that are missing
247 """
248 if self.wait_mode == self.FOR_NONE or other_players == []:
249 result = (True, [], other_players)
250 elif len(room.roster) < len(other_players) + 1:
251 result = (False, [], other_players)
252 else:
253 # TODO: find a way to make it secure
254 (nicks, missing) = self.host.plugins["XEP-0045"].getRoomNicksOfUsers(room, other_players, secure=False)
255 result = (len(nicks) == len(other_players), nicks, missing)
256 if not result[0] and (verbose or _DEBUG):
257 debug(_("Still waiting for %s before starting the game %s in %s") % (result[2], self.name, room.occupantJID.userhost()))
258 return result
259
260 def getUniqueName(self, muc_service="", profile_key='@DEFAULT@'):
261 room = self.host.plugins["XEP-0045"].getUniqueName(muc_service, profile_key=profile_key)
262 return "sat_%s_%s" % (self.name.lower(), room) if room != "" else ""
263
264 def prepareRoom(self, other_players=[], room_jid=None, profile_key='@NONE@'):
265 """Prepare the room for a game: create it and invite players.
266 @param other_players: list for other players JID userhosts
267 @param room_jid: JID userhost of the room to reuse or None to create a new room
268 """
269 debug(_('Preparing room for %s game') % self.name)
270 profile = self.host.memory.getProfileName(profile_key)
271 if not profile:
272 error(_("Unknown profile"))
273 return
274
275 def roomJoined(room):
276 """@param room: instance of wokkel.muc.Room"""
277 self.createOrInvite(room, other_players, profile)
278
279 def afterClientInit(room_jid):
280 """Create/join the given room, or a unique generated one if no room is specified.
281 @param room_jid: room to join
282 """
283 if room_jid is not None and room_jid != "": # a room name has been specified
284 if room_jid in self.host.plugins["XEP-0045"].clients[profile].joined_rooms:
285 roomJoined(self.host.plugins["XEP-0045"].clients[profile].joined_rooms[room_jid])
286 return
287 else:
288 room_jid = self.getUniqueName(profile_key=profile_key)
289 if room_jid == "":
290 return
291 user_jid = self.host.getJidNStream(profile)[0]
292 d = self.host.plugins["XEP-0045"].join(JID(room_jid), user_jid.user, {}, profile)
293 d.addCallback(roomJoined)
294
295 client = self.host.getClient(profile)
296 if not client:
297 error(_('No client for this profile key: %s') % profile_key)
298 return
299 client.client_initialized.addCallback(lambda ignore: afterClientInit(room_jid))
300
301 def userJoinedTrigger(self, room, user, profile):
302 """This trigger is used to check if the new user can take part of a game,
303 create the game if we were waiting for him or just update the players list.
304 @room: wokkel.muc.Room object. room.roster is a dict{wokkel.muc.User.nick: wokkel.muc.User}
305 @user: wokkel.muc.User object. user.nick is a unicode and user.entity a JID
306 @return: True to not interrupt the main process.
307 """
308 room_jid_s = room.occupantJID.userhost()
309 profile_nick = room.occupantJID.resource
310 if not self.isReferee(room_jid_s, profile_nick):
311 return True # profile is not the referee
312 if not self.checkJoinAuth(room_jid_s, nick=user.nick):
313 # user not allowed but let him know that we are playing :p
314 self.signalPlayers(room_jid_s, [JID(room_jid_s + '/' + user.nick)], profile)
315 return True
316 if self.wait_mode == self.FOR_ALL:
317 # considering the last batch of invitations
318 batch = len(self.invitations[room_jid_s]) - 1
319 if batch < 0:
320 error("Invitations from %s to play %s in %s have been lost!" % (profile_nick, self.name, room_jid_s))
321 return True
322 other_players = self.invitations[room_jid_s][batch][1]
323 (auth, nicks, dummy) = self.checkWaitAuth(room, other_players)
324 if auth:
325 del self.invitations[room_jid_s][batch]
326 nicks.insert(0, profile_nick) # add the referee
327 self.createGame(room_jid_s, nicks, profile_key=profile)
328 return True
329 # let the room know that a new player joined
330 self.updatePlayers(room_jid_s, [user.nick], profile)
331 return True
332
333 def userLeftTrigger(self, room, user, profile):
334 """This trigger is used to update or stop the game when a user leaves.
335 @room: wokkel.muc.Room object. room.roster is a dict{wokkel.muc.User.nick: wokkel.muc.User}
336 @user: wokkel.muc.User object. user.nick is a unicode and user.entity a JID
337 @return: True to not interrupt the main process.
338 """
339 room_jid_s = room.occupantJID.userhost()
340 profile_nick = room.occupantJID.resource
341 if not self.isReferee(room_jid_s, profile_nick):
342 return True # profile is not the referee
343 if self.isPlayer(room_jid_s, user.nick):
344 try:
345 self.games[room_jid_s]['players'].remove(user.nick)
346 except ValueError:
347 pass
348 if self.wait_mode == self.FOR_ALL:
349 # allow this user to join the game again
350 user_jid = user.entity.userhost()
351 if len(self.invitations[room_jid_s]) == 0:
352 self.invitations[room_jid_s].append((time(), [user_jid]))
353 else:
354 batch = 0 # add to the first batch of invitations
355 if user_jid not in self.invitations[room_jid_s][batch][1]:
356 self.invitations[room_jid_s][batch][1].append(user_jid)
357 return True
358
359 def checkCreateGameAndInit(self, room_jid_s, profile):
360 """Check if that profile can create the game. If the game can be created
361 but is not initialized yet, this method will also do the initialization
362 @return: a couple (create, sync) with:
363 - create: set to True to allow the game creation
364 - sync: set to True to advice a game synchronization
365 """
366 user_nick = self.host.plugins["XEP-0045"].getRoomNick(room_jid_s, profile)
367 if not user_nick:
368 error('Internal error')
369 return False, False
370 if self.gameExists(room_jid_s):
371 referee = self.isReferee(room_jid_s, user_nick)
372 if self.gameExists(room_jid_s, started=True):
373 warning(_("%s game already created in room %s") % (self.name, room_jid_s))
374 return False, referee
375 elif not referee:
376 warning(_("%s game in room %s can only be created by %s") % (self.name, room_jid_s, user_nick))
377 return False, False
378 else:
379 self.initGame(room_jid_s, user_nick)
380 return True, False
381
382 def createGame(self, room_jid_s, nicks=[], profile_key='@NONE@'):
383 """Create a new game - this can be called directly from a frontend
384 and skips all the checks and invitation system, but the game must
385 not exist and all the players must be in the room already.
386 @param room_jid: JID userhost of the room
387 @param nicks: list of players nicks in the room
388 @param profile_key: %(doc_profile_key)s"""
389 debug(_("Creating %s game in room %s") % (self.name, room_jid_s))
390 profile = self.host.memory.getProfileName(profile_key)
391 if not profile:
392 error(_("profile %s is unknown") % profile_key)
393 return
394 (create, sync) = self.checkCreateGameAndInit(room_jid_s, profile)
395 if not create:
396 if sync:
397 debug(_('Synchronize game %s in %s for %s') % (self.name, room_jid_s, ', '.join(nicks)))
398 # TODO: we should call a method to re-send the information to a player who left
399 # and joined the room again, currently: we may restart a whole new round...
400 self.updatePlayers(room_jid_s, nicks, profile)
401 return
402 self.games[room_jid_s]['started'] = True
403 self.updatePlayers(room_jid_s, nicks, profile)
404 if self.player_init == {}:
405 return
406 # specific data to each player
407 status = {}
408 players_data = {}
409 for nick in nicks:
410 # The dict must be COPIED otherwise it is shared between all users
411 players_data[nick] = self.player_init.copy()
412 status[nick] = "init"
413 self.games[room_jid_s].update({'status': status, 'players_data': players_data})
414
415 def playerReady(self, player, referee, profile_key='@NONE@'):
416 """Must be called when player is ready to start a new game"""
417 profile = self.host.memory.getProfileName(profile_key)
418 if not profile:
419 error(_("profile %s is unknown") % profile_key)
420 return
421 debug('new player ready: %s' % profile)
422 self.send(JID(referee), 'player_ready', {'player': player}, profile=profile)
423
424 def newRound(self, room_jid, data, profile):
425 """Launch a new round (reinit the user data)"""
426 debug(_('new round for %s game') % self.name)
427 game_data = self.games[room_jid.userhost()]
428 players = game_data['players']
429 players_data = game_data['players_data']
430 game_data['stage'] = "init"
431
432 common_data, msg_elts = data if data is not None else (None, None)
433
434 if isinstance(msg_elts, dict):
435 for player in players:
436 to_jid = JID(room_jid.userhost() + "/" + player) # FIXME: gof:
437 elem = msg_elts[player] if isinstance(msg_elts[player], domish.Element) else None
438 self.send(to_jid, elem, profile=profile)
439 elif isinstance(msg_elts, domish.Element):
440 self.send(room_jid, msg_elts, profile=profile)
441 if common_data is not None:
442 for player in players:
443 players_data[player].update(common_data)
444
445 def createGameElt(self, to_jid, type_="normal"):
446 """Create a generic domish Element for the game"""
447 type_ = "normal" if to_jid.resource else "groupchat"
448 elt = domish.Element((None, 'message'))
449 elt["to"] = to_jid.full()
450 elt["type"] = type_
451 elt.addElement(self.ns_tag)
452 return elt
453
454 def createStartElement(self, players=None, name="started"):
455 """Create a game "started" domish Element
456 @param name: element name (default: "started").
457 """
458 started_elt = domish.Element((None, name))
459 if players is None:
460 return started_elt
461 idx = 0
462 for player in players:
463 player_elt = domish.Element((None, 'player'))
464 player_elt.addContent(player)
465 player_elt['index'] = str(idx)
466 idx += 1
467 started_elt.addChild(player_elt)
468 return started_elt
469
470 def send(self, to_jid, elem=None, attrs=None, content=None, profile=None):
471 """
472 @param to_jid: recipient JID
473 @param elem: domish.Element, unicode or a couple:
474 - domish.Element to be directly added as a child to the message
475 - unicode name or couple (uri, name) to create a new domish.Element
476 and add it as a child to the message (see domish.Element.addElement)
477 @param attrs: dictionary of attributes for the new child
478 @param content: unicode that is appended to the child content
479 @param profile: the profile from which the message is sent
480 """
481 if profile is None:
482 error(_("Message can not be sent without a sender profile"))
483 return
484 msg = self.createGameElt(to_jid)
485 if elem is not None:
486 if isinstance(elem, domish.Element):
487 msg.firstChildElement().addChild(elem)
488 else:
489 elem = msg.firstChildElement().addElement(elem)
490 if attrs is not None:
491 elem.attributes.update(attrs)
492 if content is not None:
493 elem.addContent(content)
494 self.host.profiles[profile].xmlstream.send(msg)
495
496 if _DEBUG_FILE:
497 # From here you will see all the game messages
498 file_ = open("/tmp/game_messages", "a")
499 file_.write("%s from %s to %s: %s\n" % (self.name, profile, "room" if to_jid.resource is None else to_jid.resource, elem.toXml()))
500 file_.close()
501
502 def getHandler(self, profile):
503 return RoomGameHandler(self)
504
505
506 class RoomGameHandler (XMPPHandler):
507 implements(iwokkel.IDisco)
508
509 def __init__(self, plugin_parent):
510 self.plugin_parent = plugin_parent
511 self.host = plugin_parent.host
512
513 def connectionInitialized(self):
514 self.xmlstream.addObserver(self.plugin_parent.request, self.plugin_parent.room_game_cmd, profile=self.parent.profile)
515
516 def getDiscoInfo(self, requestor, target, nodeIdentifier=''):
517 return [disco.DiscoFeature(self.plugin_parent.ns_tag[0])]
518
519 def getDiscoItems(self, requestor, target, nodeIdentifier=''):
520 return []