Ejemplo n.º 1
0
 def __init__(self, game_state_store: GameStateStore):
     logger.debug("Creating GameStateMachine instance.")
     self.game_time: float = 0.0
     self._event_queue = curio.UniversalQueue()
     self._universal_event_handler = UniversalEventHandler()
     self._game_state_store = game_state_store
     self._game_loop_is_running = False
Ejemplo n.º 2
0
 def __init__(self, game_state_store: GameStateStore):
     logger.debug("Creating Server instance.")
     self.connections: dict = {}
     self.host_client: tuple = None
     self.game_state_store = game_state_store
     self._universal_event_handler = UniversalEventHandler()
     self._hostname: str = None
     self._port: int = None
Ejemplo n.º 3
0
    def test_asynchronous_event_handler_with_kwarg(self):
        handler = UniversalEventHandler()
        testlist = []

        async def on_foo(biz=0, bar="nobizbaz"):
            testlist.append(bar)

        handler.register_event_handler("FOO", on_foo)
        curio.run(handler.handle, Event("FOO", bar="bizbaz"))
        assert "bizbaz" in testlist
Ejemplo n.º 4
0
    def test_synchronous_event_handler(self):
        handler = UniversalEventHandler()
        testlist = []

        def on_foo(bar):
            testlist.append(bar)

        assert not handler.has_event_type("FOO")
        handler.register_event_handler("FOO", on_foo)
        assert handler.has_event_type("FOO")
        curio.run(handler.handle, Event("FOO", "baz"))
        assert "baz" in testlist
        handler.register_event_handler("BAR", lambda: testlist.pop())
        assert curio.run(handler.handle, Event("BAR")) == "baz"
        assert not testlist
Ejemplo n.º 5
0
 def __init__(self):
     logger.debug("Creating Client instance.")
     self.connection = None
     self._universal_event_handler = UniversalEventHandler()
Ejemplo n.º 6
0
class Client:

    """Exchange events with a PyGaSe server and access a synchronized game state.

    # Attributes
    connection (pygase.connection.ClientConnection): object that contains all networking information

    # Example
    ```python
    from time import sleep
    # Connect a client to the server from the Backend code example
    client = Client()
    client.connect_in_thread(hostname="localhost", port=8080)
    # Increase `bar` five times, then reset `foo`
    for i in range(5):
        client.dispatch_event("SET_BAR", new_bar=i)
        sleep(1)
    client.dispatch_event("RESET_FOO")
    ```

    """

    def __init__(self):
        logger.debug("Creating Client instance.")
        self.connection = None
        self._universal_event_handler = UniversalEventHandler()

    def connect(self, port: int, hostname: str = "localhost") -> None:
        """Open a connection to a PyGaSe server.

        This is a blocking function but can also be spawned as a coroutine or in a thread
        via #Client.connect_in_thread().

        # Arguments
        port (int): port number of the server to which to connect
        hostname (str): hostname or IPv4 address of the server to which to connect

        """
        self.connection = ClientConnection((hostname, port), self._universal_event_handler)
        curio.run(self.connection.loop)

    @awaitable(connect)
    async def connect(self, port: int, hostname: str = "localhost") -> None:  # pylint: disable=function-redefined
        # pylint: disable=missing-docstring
        self.connection = ClientConnection((hostname, port), self._universal_event_handler)
        await self.connection.loop()

    def connect_in_thread(self, port: int, hostname: str = "localhost") -> threading.Thread:
        """Open a connection in a seperate thread.

        See #Client.connect().

        # Returns
        threading.Thread: the thread the client loop runs in

        """
        self.connection = ClientConnection((hostname, port), self._universal_event_handler)
        thread = threading.Thread(target=curio.run, args=(self.connection.loop,))
        thread.start()
        return thread

    def disconnect(self, shutdown_server: bool = False) -> None:
        """Close the client connection.

        This method can also be spawned as a coroutine.

        # Arguments
        shutdown_server (bool): wether or not the server should be shut down
            (only has an effect if the client has host permissions)

        """
        self.connection.shutdown(shutdown_server)

    @awaitable(disconnect)
    async def disconnect(self, shutdown_server: bool = False) -> None:  # pylint: disable=function-redefined
        # pylint: disable=missing-docstring
        await self.connection.shutdown(shutdown_server)

    def access_game_state(self):
        """Return a context manager to access the shared game state.

        Can be used in a `with` block to lock the synchronized `game_state` while working with it.

        # Example
        ```python
        with client.access_game_state() as game_state:
            do_stuff(game_state)
        ```

        """
        return self.connection.game_state_context

    def wait_until(self, game_state_condition, timeout: float = 1.0) -> None:
        """Block until a condition on the game state is satisfied.

        # Arguments
        game_state_condition (callable): function that takes a #pygase.GameState instance and returns a bool
        timeout (float): time in seconds after which to raise a #TimeoutError

        # Raises
        TimeoutError: if the condition is not met after `timeout` seconds

        """
        condition_satisfied = False
        t0 = time.time()
        while not condition_satisfied:
            with self.access_game_state() as game_state:
                if game_state_condition(game_state):
                    condition_satisfied = True
            if time.time() - t0 > timeout:
                raise TimeoutError("Condition not satisfied after timeout of " + str(timeout) + " seconds.")
            time.sleep(timeout / 100)

    def try_to(self, function, timeout: float = 1.0):
        """Execute a function using game state attributes that might not yet exist.

        This method repeatedly tries to execute `function(game_state)`, ignoring #KeyError exceptions,
        until it either works or times out.

        # Arguments
        function (callable): function that takes a #pygase.GameState instance and returns anything
        timeout (float): time in seconds after which to raise a #TimeoutError

        # Returns
        any: whatever `function(game_state)` returns

        # Raises
        TimeoutError: if the function doesn't run through after `timeout` seconds

        """
        result = None
        t0 = time.time()
        while True:
            with self.access_game_state() as game_state:
                try:
                    result = function(game_state)
                except (KeyError, AttributeError):
                    pass
            if result is not None:
                return result
            if time.time() - t0 > timeout:
                raise TimeoutError("Condition not satisfied after timeout of " + str(timeout) + " seconds.")
            time.sleep(timeout / 100)

    def dispatch_event(self, event_type: str, *args, retries: int = 0, ack_callback=None, **kwargs) -> None:
        """Send an event to the server.

        # Arguments
        event_type (str): event type identifier that links to a handler
        retries (int): number of times the event is to be resent in case it times out
        ack_callback (callable, coroutine): will be invoked after the event was received

        Additional positional and keyword arguments will be sent as event data and passed to the handler function.

        ---
        `ack_callback` should not perform any long-running blocking operations (say a `while True` loop), as that will
        block the connections asynchronous event loop. Use a coroutine instead, with appropriately placed `await`s.

        """
        event = Event(event_type, *args, **kwargs)
        timeout_callback = None
        if retries > 0:
            timeout_callback = lambda: self.dispatch_event(  # type: ignore
                event_type, *args, retries=retries - 1, ack_callback=ack_callback, **kwargs
            ) or logger.warning(  # type: ignore
                f"Event of type {event_type} timed out. Retrying to send event to server."
            )
        self.connection.dispatch_event(event, ack_callback, timeout_callback)

    def register_event_handler(self, event_type: str, event_handler_function) -> None:
        """Register an event handler for a specific event type.

        # Arguments
        event_type (str): event type to link the handler function to
        handler_func (callable, coroutine): will be called for events of the given type

        """
        self._universal_event_handler.register_event_handler(event_type, event_handler_function)
Ejemplo n.º 7
0
class Server:
    """Listen to clients and orchestrate the flow of events and state updates.

    The #Server instance does not contain game logic or state, it is only responsible for connections
    to clients. The state is provided by a #GameStateStore and game logic by a #GameStateMachine.

    # Arguments
    game_state_store (GameStateStore): part of the backend that provides an interface to the #pygase.GameState

    # Attributes
    connections (list): contains each clients address as a key leading to the
        corresponding #pygase.connection.ServerConnection instance
    host_client (tuple): address of the host client (who has permission to shutdown the server), if there is any
    game_state_store (GameStateStore): game state repository

    # Members
    hostname (str): read-only access to the servers hostname
    port (int): read-only access to the servers port number

    """
    def __init__(self, game_state_store: GameStateStore):
        logger.debug("Creating Server instance.")
        self.connections: dict = {}
        self.host_client: tuple = None
        self.game_state_store = game_state_store
        self._universal_event_handler = UniversalEventHandler()
        self._hostname: str = None
        self._port: int = None

    def run(self,
            port: int = 0,
            hostname: str = "localhost",
            event_wire=None) -> None:
        """Start the server under a specified address.

        This is a blocking function but can also be spawned as a coroutine or in a thread
        via #Server.run_in_thread().

        # Arguments
        port (int): port number the server will be bound to, default will be an available
           port chosen by the computers network controller
        hostname (str): hostname or IP address the server will be bound to.
           Defaults to `'localhost'`.
        event_wire (GameStateMachine): object to which events are to be repeated
           (has to implement a `_push_event(event)` method and is typically a #GameStateMachine)

        """
        curio.run(self.run, port, hostname, event_wire)

    @awaitable(run)
    async def run(  # pylint: disable=function-redefined
            self,
            port: int = 0,
            hostname: str = "localhost",
            event_wire=None) -> None:
        # pylint: disable=missing-docstring
        await ServerConnection.loop(hostname, port, self, event_wire)

    def run_in_thread(self,
                      port: int = 0,
                      hostname: str = "localhost",
                      event_wire=None,
                      daemon=True) -> threading.Thread:
        """Start the server in a seperate thread.

        See #Server.run().

        # Returns
        threading.Thread: the thread the server loop runs in

        """
        thread = threading.Thread(target=self.run,
                                  args=(port, hostname, event_wire),
                                  daemon=daemon)
        thread.start()
        return thread

    @property
    def hostname(self) -> str:
        """Get the hostname or IP address on which the server listens.

        Returns `None` when the server is not running.

        """
        return "localhost" if self._hostname == "127.0.0.1" else self._hostname

    @property
    def port(self) -> int:
        """Get the port number on which the server listens.

        Returns `None` when the server is not running.

        """
        return self._port

    def shutdown(self) -> None:
        """Shut down the server.

        The server can be restarted via #Server.run() in which case it will remember previous connections.
        This method can also be spawned as a coroutine.

        """
        curio.run(self.shutdown)

    @awaitable(shutdown)
    async def shutdown(self) -> None:  # pylint: disable=function-redefined
        # pylint: disable=missing-docstring
        async with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
            await sock.sendto("shut_me_down".encode("utf-8"),
                              (self._hostname, self._port))

    # advanced type checking for target client and callback would be helpful
    def dispatch_event(self,
                       event_type: str,
                       *args,
                       target_client="all",
                       retries: int = 0,
                       ack_callback=None,
                       **kwargs) -> None:
        """Send an event to one or all clients.

        # Arguments
        event_type (str): identifies the event and links it to a handler
        target_client (tuple, str): either `'all'` for an event broadcast, or a clients address as a tuple
        retries (int): number of times the event is to be resent in case it times out
        ack_callback (callable, coroutine): will be executed after the event was received
            and be passed a reference to the corresponding #pygase.connection.ServerConnection instance

        Additional positional and keyword arguments will be sent as event data and passed to the clients
        handler function.

        """
        event = Event(event_type, *args, **kwargs)

        def get_ack_callback(connection):
            if ack_callback is not None:
                return lambda: ack_callback(connection)
            return None

        timeout_callback = None
        if retries > 0:

            timeout_callback = lambda: self.dispatch_event(  # type: ignore
                event_type,
                *args,
                target_client=target_client,
                retries=retries - 1,
                ack_callback=ack_callback,
                **kwargs,
            ) or logger.warning(  # type: ignore
                f"Event of type {event_type} timed out. Retrying to send event to server."
            )

        if target_client == "all":
            for connection in self.connections.values():
                connection.dispatch_event(event, get_ack_callback(connection),
                                          timeout_callback, **kwargs)
        else:
            self.connections[target_client].dispatch_event(
                event, get_ack_callback(self.connections[target_client]),
                timeout_callback, **kwargs)

    # add advanced type checking for handler functions
    def register_event_handler(self, event_type: str,
                               event_handler_function) -> None:
        """Register an event handler for a specific event type.

        # Arguments
        event_type (str): event type to link the handler function to
        handler_func (callable, coroutine): will be called for received events of the given type

        """
        self._universal_event_handler.register_event_handler(
            event_type, event_handler_function)
Ejemplo n.º 8
0
class GameStateMachine:
    """Run a simulation that propagates the game state.

    A #GameStateMachine progresses a game state through time, applying all game simulation logic.
    This class is meant either as a base class from which you inherit and implement the #GameStateMachine.time_step()
    method, or you assign an implementation after instantiation.

    # Arguments
    game_state_store (GameStateStore): part of the PyGaSe backend that provides the state

    # Attributes
    game_time (float): duration the game has been running in seconds

    """
    def __init__(self, game_state_store: GameStateStore):
        logger.debug("Creating GameStateMachine instance.")
        self.game_time: float = 0.0
        self._event_queue = curio.UniversalQueue()
        self._universal_event_handler = UniversalEventHandler()
        self._game_state_store = game_state_store
        self._game_loop_is_running = False

    def _push_event(self, event: Event) -> None:
        """Push an event into the state machines event queue.

        This method can be spawned as a coroutine.

        """
        logger.debug(
            f"State machine receiving event of type {event.type} via event wire."
        )
        self._event_queue.put(event)

    @awaitable(_push_event)
    async def _push_event(self, event: Event) -> None:  # pylint: disable=function-redefined
        logger.debug(
            f"State machine receiving event of type {event.type} via event wire."
        )
        await self._event_queue.put(event)

    # advanced type checking for the handler function would be helpful
    def register_event_handler(self, event_type: str,
                               event_handler_function) -> None:
        """Register an event handler for a specific event type.

        For event handlers to have any effect, the events have to be wired from a #Server to
        the #GameStateMachine via the `event_wire` argument of the #Server.run() method.

        # Arguments
        event_type (str): which type of event to link the handler function to
        handler_func (callable, coroutine): function or coroutine to be invoked for events of the given type

        ---
        In addition to the event data, a #GameStateMachine handler function gets passed
        the following keyword arguments

        - `game_state`: game state at the time of the event
        - `dt`: time since the last time step
        - `client_address`: client which sent the event that is being handled

        It is expected to return an update dict like the `time_step` method.

        """
        self._universal_event_handler.register_event_handler(
            event_type, event_handler_function)

    def run_game_loop(self, interval: float = 0.02) -> None:
        """Simulate the game world.

        This function blocks as it continously progresses the game state through time
        but it can also be spawned as a coroutine or in a thread via #Server.run_game_loop_in_thread().
        As long as the simulation is running, the `game_state.status` will be `GameStatus.get('Active')`.

        # Arguments
        interval (float): (minimum) duration in seconds between consecutive time steps

        """
        curio.run(self.run_game_loop, interval)

    @awaitable(run_game_loop)
    async def run_game_loop(self, interval: float = 0.02) -> None:  # pylint: disable=function-redefined
        # pylint: disable=missing-docstring
        if self._game_state_store.get_game_state(
        ).game_status == GameStatus.get("Paused"):
            self._game_state_store.push_update(
                GameStateUpdate(
                    self._game_state_store.get_game_state().time_order + 1,
                    game_status=GameStatus.get("Active")))
        game_state = self._game_state_store.get_game_state()
        dt = interval
        self._game_loop_is_running = True
        logger.info(
            f"State machine starting game loop with interval of {interval} seconds."
        )
        while game_state.game_status == GameStatus.get("Active"):
            t0 = time.time()
            update_dict = self.time_step(game_state, dt)
            while not self._event_queue.empty():
                event = await self._event_queue.get()
                event_update = await self._universal_event_handler.handle(
                    event, game_state=game_state, dt=dt)
                update_dict.update(event_update)
                if time.time() - t0 > 0.95 * interval:
                    break
            self._game_state_store.push_update(
                GameStateUpdate(game_state.time_order + 1, **update_dict))
            game_state = self._game_state_store.get_game_state()
            dt = max(interval, time.time() - t0)
            await curio.sleep(max(0, interval - dt))
            self.game_time += dt
        logger.info("Game loop stopped.")
        self._game_loop_is_running = False

    def run_game_loop_in_thread(self,
                                interval: float = 0.02) -> threading.Thread:
        """Simulate the game in a seperate thread.

        See #GameStateMachine.run_game_loop().

        # Returns
        threading.Thread: the thread the game loop runs in

        """
        thread = threading.Thread(target=self.run_game_loop, args=(interval, ))
        thread.start()
        return thread

    def stop(self, timeout: float = 1.0) -> bool:
        """Pause the game simulation.

        This sets `self.status` to `Gamestatus.get('Paused')`. This method can also be spawned as a coroutine.
        A subsequent call of #GameStateMachine.run_game_loop() will resume the simulation at the point
        where it was stopped.

        # Arguments
        timeout (float): time in seconds to wait for the simulation to stop

        # Returns
        bool: wether or not the simulation was successfully stopped

        """
        return curio.run(self.stop, timeout)

    @awaitable(stop)
    async def stop(self, timeout: float = 1.0) -> bool:  # pylint: disable=function-redefined
        # pylint: disable=missing-docstring
        logger.info("Trying to stop game loop ...")
        if self._game_state_store.get_game_state(
        ).game_status == GameStatus.get("Active"):
            self._game_state_store.push_update(
                GameStateUpdate(
                    self._game_state_store.get_game_state().time_order + 1,
                    game_status=GameStatus.get("Paused")))
        t0 = time.time()
        while self._game_loop_is_running:
            if time.time() - t0 > timeout:
                break
            await curio.sleep(0)
        return not self._game_loop_is_running

    def time_step(self, game_state: GameState, dt: float) -> dict:
        """Calculate a game state update.

        This method should be implemented to return a dict with all the updated state attributes.

        # Arguments
        game_state (GameState): the state of the game prior to the time step
        dt (float): time in seconds since the last time step, use it to simulate at a consistent speed

        # Returns
        dict: updated game state attributes

        """
        raise NotImplementedError()
Ejemplo n.º 9
0
 def test_register_nonsense(self):
     handler = UniversalEventHandler()
     with pytest.raises(TypeError, match="'list' object is not callable"):
         handler.register_event_handler("FOO", ["Not a function"])