#!/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.""")