diff --git a/.gitignore b/.gitignore index 798847a..9cceadd 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,6 @@ __pycache__ .ipynb_checkpoints/ .vscode/ .spyproject/ +.idea/ *.egg-info/ diff --git a/setup.py b/setup.py index 80959a6..5d9660c 100644 --- a/setup.py +++ b/setup.py @@ -26,7 +26,7 @@ def __readme__(): setup( name='traze-client', - version='1.2', + version='1.3', author="Danny Lade", author_email="dannylade@gmail.com", description=("A client for the simple tron-like multi client online game called 'Traze' which is using MQTT for communication."), # noqa @@ -44,7 +44,14 @@ def __readme__(): ], classifiers=[ - "Development Status :: 5 - Production/Stable", + # Development Status :: 1 - Planning + # Development Status :: 2 - Pre-Alpha + # Development Status :: 3 - Alpha + # Development Status :: 4 - Beta + # Development Status :: 5 - Production/Stable + # Development Status :: 6 - Mature + # Development Status :: 7 - Inactive + "Development Status :: 1 - Planning", "Topic :: Games/Entertainment :: Simulation", "Programming Language :: Python :: 3.5", "License :: OSI Approved :: GNU Library or Lesser General Public License (LGPL)", # noqa diff --git a/traze/adapter.py b/traze/adapter.py index e78789a..6814ce6 100644 --- a/traze/adapter.py +++ b/traze/adapter.py @@ -17,22 +17,51 @@ @author: Danny Lade """ import uuid +import json import functools import paho.mqtt.client as mqtt from .log import setup_custom_logger -from .topic import MqttTopic __all__ = [ "TrazeMqttAdapter" ] +class _MqttTopic: + + def __init__(self, client, name, *args): + def topic_name(topicName, *args): + if not args: + return topicName + return topicName.replace('+', '%s') % (args) + + self._client = client + self._name = topic_name(name, *args) + self.functions = set() + + def subscribe(self, on_payload_func): + def on_message(client, userdata, message): + payload = json.loads(str(message.payload, 'utf-8')) + for on_payload in self.functions: + on_payload(payload) + + if not self.functions: + self._client.subscribe(self._name) + self._client.message_callback_add(self._name, on_message) + + if on_payload_func not in self.functions: + self.functions.add(on_payload_func) + + def publish(self, obj=None): + self._client.publish(self._name, json.dumps(obj)) + + class TrazeMqttAdapter: def __init__(self, host='traze.iteratec.de', port=8883, transport='tcp'): - self.logger = setup_custom_logger(name=type(self).__name__) + self.logger = setup_custom_logger(self) def _on_connect(client, userdata, flags, rc): self.logger.info("Connected the MQTT broker.") @@ -50,16 +79,17 @@ def _on_disconnect(client, userdata, rc): self._client.connect(host, port) self._client.loop_start() - def on_heartbeat(self, game_name, on_heartbeat): - # there is no heartbeat from server but the grid-event is a good base - self.__get_topic__('traze/+/grid', game_name).subscribe(on_heartbeat) - + # + # world based topic(s) + # - parameters: None + # def on_game_info(self, on_game_info): self.__get_topic__('traze/games').subscribe(on_game_info) - def on_player_info(self, game_name, on_player_info): - self.__get_topic__('traze/+/player/+', game_name, self.__client_id__).subscribe(on_player_info) # noqa - + # + # game based topic(s) + # - parameters: game_name + # def on_grid(self, game_name, on_grid): self.__get_topic__('traze/+/grid', game_name).subscribe(on_grid) @@ -69,6 +99,13 @@ def on_players(self, game_name, on_players): def on_ticker(self, game_name, on_ticker): self.__get_topic__('traze/+/ticker', game_name).subscribe(on_ticker) + def on_player_info(self, game_name, on_player_info): + self.__get_topic__('traze/+/player/+', game_name, self.__client_id__).subscribe(on_player_info) # noqa + + # + # player based topic(s) + # - parameters: game_name, player_id/player_name + # def publish_join(self, game_name, player_name): self.__get_topic__('traze/+/join', game_name).publish({'name': player_name, 'mqttClientName': self.__client_id__}) # noqa @@ -81,6 +118,6 @@ def publish_bail(self, game_name, player_id, player_token): def disconnect(self): self._client.disconnect() - @functools.lru_cache() + @functools.lru_cache() # singleton by parameter (for same arguments always return the same object) def __get_topic__(self, topic_name, *args): - return MqttTopic(self._client, topic_name, *args) + return _MqttTopic(self._client, topic_name, *args) diff --git a/traze/bot.py b/traze/bot.py index 67972a2..294d4c4 100644 --- a/traze/bot.py +++ b/traze/bot.py @@ -40,23 +40,13 @@ def __repr__(self): @classmethod def from_name(cls, name): - for action in Action: - if action.name == name: - return action - raise ValueError('{} is not a valid action name'.format(name)) + return cls.__members__[name] class BotBase(Player, metaclass=ABCMeta): - def __init__(self, game, name=None): - def on_update(): - next_action = None - actions = self.actions - if actions: - next_action = self.next_action(actions) - if next_action: - self.steer(next_action) - super().__init__(game, name, on_update) + def __init__(self, game, name=None): + super().__init__(game, name) def play(self, count=1, suppress_server_timeout=False): for i in range(1, count + 1): @@ -76,6 +66,20 @@ def play(self, count=1, suppress_server_timeout=False): self.destroy() + def on_update(self): + self.logger.debug("on_update: {}".format((self.x, self.y))) + + next_action = None + actions = self.actions + if actions: + next_action = self.next_action(actions) + self.steer(next_action) + + def on_dead(self): + self.logger.debug("on_dead: {}".format((self.x, self.y))) + + return + @property def actions(self): valid_actions = set() @@ -83,6 +87,7 @@ def actions(self): if self.valid(self.x + action.dX, self.y + action.dY): valid_actions.add(action) + self.logger.debug("valid_actions: {}".format(valid_actions)) return tuple(valid_actions) @abstractmethod diff --git a/traze/client.py b/traze/client.py index 9f14aca..dd15439 100644 --- a/traze/client.py +++ b/traze/client.py @@ -18,8 +18,9 @@ """ import time import copy -from .log import setup_custom_logger +from abc import ABCMeta, abstractmethod +from .log import setup_custom_logger from .adapter import TrazeMqttAdapter @@ -27,17 +28,9 @@ class NotConnected(TimeoutError): pass -class TileOutOfBoundsException(Exception): - pass - - -class PlayerNotJoinedException(Exception): - pass - - class Base: def __init__(self, parent=None, name=None): - self.logger = setup_custom_logger(name=type(self).__name__) + self.logger = setup_custom_logger(self) self.__adapter__ = None self._parent = parent @@ -56,7 +49,7 @@ def adapter(self): return None -class Grid(Base): +class Grid(Base, metaclass=ABCMeta): def __init__(self, game): super().__init__(game) self.width = 0 @@ -75,14 +68,15 @@ def update_grid(self, payload): def game(self): return self._parent - def get_tile(self, x, y): - if (x < 0 or x >= self.width or y < 0 or y >= self.height): - raise TileOutOfBoundsException + def __getitem__(self, coordinates): + x, y = coordinates + if x < 0 or x >= self.width or y < 0 or y >= self.height: + raise IndexError return self.tiles[x][y] -class Player(Base): - def __init__(self, game, name, on_update): +class Player(Base, metaclass=ABCMeta): + def __init__(self, game, name): super().__init__(game, name=name) self.__reset__() @@ -92,18 +86,20 @@ def on_join(payload): self._x, self._y = payload['position'] self.logger.info("Welcome '{}' ({}) at {}!\n".format(self.name, self._id, (self._x, self._y))) # noqa - self._alive = True - on_update() # very first call, if born + self._joined = True - def on_heartbeat(payload): - if not self.alive: + def on_grid(payload): + if not self._joined: return + self.game.grid.update_grid(payload) - bike_position = self.game.grid.bike_positions.get(self._id) - if bike_position: - self._x, self._y = bike_position - on_update() # call if heartbeat + if self.last_course: + self._alive = True + self._x, self._y = self.game.grid.bike_positions.get(self._id, (self._x, self._y)) + + self.logger.debug("on_grid: position={}".format((self._x, self._y))) + self.on_update() def on_ticker(payload): if not self.alive: @@ -115,17 +111,22 @@ def on_ticker(payload): self.logger.debug("ticker: {}".format(payload)) - if payload['casualty'] == self._id or payload['type'] == 'collision': # noqa + if payload['casualty'] == self._id or payload['type'] == 'collision': + self._alive = False + self._joined = False + self.on_dead() + self.__reset__() self.adapter.on_player_info(self.game.name, on_join) self.adapter.on_ticker(self.game.name, on_ticker) - self.adapter.on_heartbeat(self.game.name, on_heartbeat) + self.adapter.on_grid(self.game.name, on_grid) def __reset__(self): self._id = None self._secret = None self._alive = False + self._joined = False self.last_course = None self._x, self._y = [-1, -1] @@ -142,6 +143,14 @@ def join(self): time.sleep(0.5) raise NotConnected() + @abstractmethod + def on_update(self): + pass + + @abstractmethod + def on_dead(self): + pass + @property def game(self): return self._parent @@ -152,34 +161,31 @@ def alive(self): @property def x(self): - if self.alive: - return self._x - else: - raise PlayerNotJoinedException + return self._x @property def y(self): - if self.alive: - return self._y - else: - raise PlayerNotJoinedException + return self._y def valid(self, x, y): try: - return (self.game.grid.get_tile(x, y) == 0) - except TileOutOfBoundsException: + return self.game.grid[x, y] == 0 + except IndexError: return False def steer(self, course): - if course != self.last_course: - self.last_course = course - self.logger.debug("steer {}".format(course)) - self.adapter.publish_steer(self.game.name, self._id, self._secret, course) # noqa + if course == self.last_course: + return + + self.logger.debug("steer {}".format(course)) + + self.last_course = course + self.adapter.publish_steer(self.game.name, self._id, self._secret, course) # noqa def bail(self): self.logger.debug("bail: {} ({})".format(self.game.name, self._id)) + self.adapter.publish_bail(self.game.name, self._id, self._secret) - self._alive = False self.__reset__() def destroy(self): @@ -192,7 +198,7 @@ def __str__(self): return "{}(name={}, id={}, x={}, y={})".format(self.__class__.__name__, self.name, self._id, self._x, self._y) # noqa -class Game(Base): +class Game(Base, metaclass=ABCMeta): def __init__(self, world, name): super().__init__(world, name=name) self._grid = Grid(self) @@ -213,20 +219,18 @@ def __init__(self, adapter=None): self.__adapter__ = adapter if adapter else TrazeMqttAdapter() self.__games__ = dict() - def add_game(name): - if name not in self.__games__: - self.__games__[name] = Game(self, name) - - def game_info(payload): + def on_game_info(payload): for game in payload: - add_game(game['name']) + name = game['name'] + if name not in self.__games__: + self.__games__[name] = Game(self, name) - self.adapter.on_game_info(game_info) + self.adapter.on_game_info(on_game_info) @property def games(self): for _ in range(30): if self.__games__: - return list(self.__games__.values()) + return tuple(self.__games__.values()) time.sleep(0.5) raise NotConnected() diff --git a/traze/log.py b/traze/log.py index 57e0e6e..42d27fa 100644 --- a/traze/log.py +++ b/traze/log.py @@ -17,23 +17,29 @@ @author: Danny Lade """ import logging +from logging import NOTSET, DEBUG, INFO, WARNING, ERROR, CRITICAL __all__ = [ - "setup_custom_logger" + "NOTSET", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL", + "DEFAULT_LEVEL", + "setup_custom_logger", ] +global DEFAULT_LEVEL +DEFAULT_LEVEL = logging.INFO + # make RootLogger quiet ROOT_LOGGER = logging.getLogger() ROOT_LOGGER.handlers = [] -def setup_custom_logger(name, level=logging.INFO): +def setup_custom_logger(obj): formatter = logging.Formatter(fmt='%(asctime)s - %(levelname)s - %(name)s - %(message)s') # noqa handler = logging.StreamHandler() handler.setFormatter(formatter) - logger = logging.getLogger(name) - logger.setLevel(level) + logger = logging.getLogger(type(obj).__name__) + logger.setLevel(DEFAULT_LEVEL) logger.addHandler(handler) return logger diff --git a/traze/topic.py b/traze/topic.py deleted file mode 100644 index dc22f6c..0000000 --- a/traze/topic.py +++ /dev/null @@ -1,56 +0,0 @@ -# -*- coding: utf-8 -*- -# -# Copyright 2018 The Traze Authors. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -""" -@author: Danny Lade -""" -import json -import functools - - -__all__ = [ - "MqttTopic" -] - - -class MqttTopic: - @classmethod - @functools.lru_cache() - def name(cls, topic_name, *args): - if not args: - return topic_name - return topic_name.replace('+', '%s') % (args) - - def __init__(self, client, name, *args): - self._client = client - self._name = MqttTopic.name(name, *args) - self.functions = set() - - def subscribe(self, on_payload_func): - def on_message(client, userdata, message): - payload = json.loads(str(message.payload, 'utf-8')) - for on_payload in self.functions: - on_payload(payload) - - if not self.functions: - self._client.subscribe(self._name) - self._client.message_callback_add(self._name, on_message) - - # TODO - this check does not work if the function is defined anonymously # noqa - if on_payload_func not in self.functions: - self.functions.add(on_payload_func) - - def publish(self, obj=None): - self._client.publish(self._name, json.dumps(obj))