Source code for kxg.messages

#!/usr/bin/env python3

from .errors import *

[docs]class Message: # This class defers initializing all of its members until the appropriate # setter is called, rather than initializing everything in a constructor. # This is done to avoid sending unnecessary information over the network.
[docs] class ErrorState: SOFT_SYNC_ERROR = 0 HARD_SYNC_ERROR = 1
[docs] def __repr__(self): return self.__class__.__name__ + '()'
[docs] def was_sent(self): return hasattr(self, 'sender_id')
[docs] def was_sent_by(self, actor_or_id): from .actors import Actor from .forums import IdFactory if isinstance(actor_or_id, Actor): id = actor_or_id.id elif isinstance(actor_or_id, IdFactory): id = actor_or_id.get() else: id = actor_or_id try: return self.sender_id == id except AttributeError: raise ApiUsageError("""\ Can't ask who sent a message before it's been sent. This error means Message.was_sent_by() or Message.was_sent_by_referee() got called on a message that hadn't been sent yet. Normally you would only call these methods from within Message.on_check().""")
[docs] def was_sent_by_referee(self): return self.was_sent_by(1)
[docs] def tokens_to_add(self): yield from []
[docs] def tokens_to_remove(self): yield from []
[docs] def tokens_referenced(self): """ Return a list of all the tokens that are referenced in this message. Tokens that haven't been assigned an id yet are searched recursively for tokens. So this method may return fewer results after the message is sent. This information is used by the game engine to catch mistakes like forgetting to add a token to the world or keeping a stale reference to a token after its been removed. """ tokens = set() # Use the pickle machinery to find all the tokens contained at any # level of this message. When an object is being pickled, the Pickler # calls its persistent_id() method for each object it encounters. We # hijack this method to add every Token we encounter to a list. # This definitely feels like a hacky abuse of the pickle machinery, but # that notwithstanding this should be quite robust and quite fast. def persistent_id(obj): from .tokens import Token if isinstance(obj, Token): tokens.add(obj) # Recursively descend into tokens that haven't been assigned an # id yet, but not into tokens that have. return obj.id from pickle import Pickler from io import BytesIO # Use BytesIO to basically ignore the serialized data stream, since we # only care about visiting all the objects that would be pickled. pickler = Pickler(BytesIO()) pickler.persistent_id = persistent_id pickler.dump(self) return tokens
[docs] def on_check(self, world): """ Confirm that the message is consistent with the `World`. This handler is called by actors. If no `MessageCheck` exception is raised, the message will be sent as usual. Otherwise, the behavior will depend on what kind of actor is handling the message. `Actor` (uniplayer and multiplayer clients) will simply not send the message. `ServerActor` (multiplayer server) will decide if the error should be handled by undoing the message or asking the clients to sync themselves. """ raise NotImplementedError
[docs] def on_prepare_sync(self, world, memento): """ Determine how `on_check` failures on the server should be handled. When `on_check` fails on the server, it means that the client which sent the message is out of sync (since had it been in sync, it would've rejected the message locally). There are two ways to handle this situation, and the role of this handler is to decide which to use. The first is to reject the message. This is considered a "hard sync error". In this case, the out-of-sync client will be instructed to undo this message, and the rest of the clients will never be sent the message in the first place. This approach ensures that messages sent by the server are consistent with the server's `World`, but at the cost of requiring some messages to be undone, which may be jarring for the players. To indicate a hard sync error, return False from this handler. This is the default behavior. The second is to send the message with extra instructions on how to re-synchronize the clients. This is considered a "soft sync error". In this case, the message will be relayed to all clients as usual, but each client will call the `on_sync` handler upon receipt. Any extra information that might be helpful in resynchronizing the clients can be assigned to the *memento* argument, which will be sent to each client along with the message, and then passed to `on_sync`. To indicate a soft sync error, return True from this handler. """ return False
[docs] def on_execute(self, world): """ Update the world with the information stored in the message. This handler is called by the forum on every machine running the game, before any signal-handling callbacks. It is allowed to make changes to the game world, but should not change the message itself. """ pass
[docs] def on_sync(self, world, memento): """ Handle soft synchronization errors. See `on_prepare_sync` for more details on hard/soft synchronization errors. This handler should use any information put in the *memento* by `on_prepare_sync` to quietly re-synchronize the client with the server. """ pass
[docs] def on_undo(self, world): """ Handle hard synchronization errors. See `on_prepare_sync` for more details or hard/soft synchronization error. This handler should undo whatever changes were made to the world in `on_execute`, preferably in a way that is as minimally disruptive to the player as possible. This handler will only called on the client that originally sent this message. """ message_cls = self.__class__.__name__ raise ApiUsageError("""\ The message {self} was rejected by the server. This client attempted to send a {message_cls} message, but it was rejected by the server. To fix this error, either figure out why the client is getting out of sync with the server or implement a {message_cls}.on_undo() that undoes everything done in {message_cls}.on_execute().""")
[docs] def _set_sender_id(self, id_factory): self.sender_id = id_factory.get()
[docs] def _set_server_response_id(self, id): self._server_response_id = id
[docs] def _get_server_response_id(self): return self._server_response_id
[docs] def _set_server_response(self, server_response): self._server_response = server_response
[docs] def _get_server_response(self): try: return self._server_response except AttributeError: return None
[docs] def _assign_token_ids(self, id_factory): """ Assign id numbers to any tokens that will be added to the world by this message. This method is called by `Actor` but not by `ServerActor`, so it's guaranteed to be called exactly once. In fact, this method is not really different from the constructor, except that an `IdFactory` instance is nicely provided. That's useful for assigning ids to tokens but probably nothing else. This method is called before `_check` so that `_check` can make sure that valid ids were assigned (although by default it doesn't). """ for token in self.tokens_to_add(): token._give_id(id_factory)
[docs] def _check(self, world): self.on_check(world)
[docs] def _prepare_sync(self, world, server_response): self._set_server_response(server_response) return self.on_prepare_sync(world, self._server_response)
[docs] def _execute(self, world): # Deal with tokens to be created or destroyed. for token in self.tokens_to_add(): world._add_token(token) # Save the id numbers for the tokens we're removing so we can restore # them if we need to undo this message. self._removed_token_ids = {} for token in self.tokens_to_remove(): self._removed_token_ids[token] = token.id world._remove_token(token) # Let derived classes execute themselves. self.on_execute(world)
[docs] def _sync(self, world): self.on_sync(world, self._server_response)
[docs] def _undo(self, world): # The tokens in self.tokens_to_add() haven't been added to the world # yet, because the message was copied and pickled before it was # executed on the server. We need to access the tokens that are # actually in the world before we can remove them again. for token in self.tokens_to_add(): real_token = world.get_token(token.id) world._remove_token(real_token) # The tokens in self.tokens_to_remove() have already been removed from # the world. We want to add them back, and we want to make sure they # end up with the id as before. for token in self.tokens_to_remove(): token._id = self._removed_token_ids[token] world._add_token(token) # Let derived classes execute themselves. self.on_undo(world)
[docs]class MessageCheck(Exception): pass
[docs]@debug_only def require_message(object): require_instance(Message(), object)
[docs]@debug_only def require_message_cls(cls): if not isinstance(cls, type) or not issubclass(cls, Message): try: wrong_thing = cls.__name__ except: wrong_thing = cls raise ApiUsageError("""\ expected Message subclass, but got {wrong_thing} instead.""")