commit 9f19519b7514159dadd6f514604efe7e011f551f Author: Evgeniy Alekseev Date: Fri Sep 6 00:54:27 2019 +0300 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7f6977d --- /dev/null +++ b/.gitignore @@ -0,0 +1,96 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +env/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +parts/ +sdist/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*,cover +.hypothesis/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log* +local_settings.py + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# IPython Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# dotenv +.env + +# virtualenv +venv/ +ENV/ + +# Spyder project settings +.spyderproject + +# Rope project settings +.ropeproject + +*.deb + +.idea/ + +.mypy_cache/ + +/cache + +*.db diff --git a/migrations/20190830_01_sYYZL-init-tables.py b/migrations/20190830_01_sYYZL-init-tables.py new file mode 100644 index 0000000..d027c4a --- /dev/null +++ b/migrations/20190830_01_sYYZL-init-tables.py @@ -0,0 +1,38 @@ +''' +init tables +''' + +from yoyo import step + +__depends__ = {} + +steps = [ + step('''create table players ( + player_id integer primary key, + created integer not null, + nick text not null, + job text not null, + bis_link text, + priority integer not null default 1 + )'''), + step('''create unique index players_nick_job_idx on players(nick, job)'''), + + step('''create table loot ( + loot_id integer primary key, + player_id integer not null, + created integer not null, + piece text not null, + is_tome integer not null, + foreign key (player_id) references players(player_id) on delete cascade + )'''), + step('''create index loot_owner_idx on loot(player_id)'''), + + step('''create table bis ( + player_id integer not null, + created integer not null, + piece text not null, + is_tome integer not null, + foreign key (player_id) references players(player_id) on delete cascade + )'''), + step('''create unique index bis_piece_player_id_idx on bis(player_id, piece)''') +] diff --git a/package/ini/ffxivbis.ini b/package/ini/ffxivbis.ini new file mode 100644 index 0000000..d98a894 --- /dev/null +++ b/package/ini/ffxivbis.ini @@ -0,0 +1,9 @@ +[settings] +include = ffxivbis.ini.d +logging = ffxivbis.ini.d/logging.ini +database = sqlite +priority = is_required loot_count_bis loot_count_total loot_count loot_priority + +[web] +host = 0.0.0.0 +port = 8000 diff --git a/package/ini/ffxivbis.ini.d/logging.ini b/package/ini/ffxivbis.ini.d/logging.ini new file mode 100644 index 0000000..54fdfe2 --- /dev/null +++ b/package/ini/ffxivbis.ini.d/logging.ini @@ -0,0 +1,44 @@ +[loggers] +keys = root,application,database,http + +[handlers] +keys = file_handler + +[formatters] +keys = generic_format + +[handler_console_handler] +class = StreamHandler +level = INFO +formatter = generic_format +args = (sys.stdout,) + +[handler_file_handler] +class = logging.handlers.RotatingFileHandler +level = INFO +formatter = generic_format +args = ('ffxivbis.log', 'a', 20971520, 20) + +[formatter_generic_format] +format = [%(levelname)s] [%(asctime)s] [%(threadName)s] [%(name)s] [%(funcName)s]: %(message)s +datefmt = + +[logger_root] +level = INFO +handlers = file_handler +qualname = root + +[logger_application] +level = INFO +handlers = file_handler +qualname = application + +[logger_database] +level = INFO +handlers = file_handler +qualname = database + +[logger_http] +level = INFO +handlers = file_handler +qualname = http diff --git a/package/ini/ffxivbis.ini.d/sqlite.ini b/package/ini/ffxivbis.ini.d/sqlite.ini new file mode 100644 index 0000000..0513526 --- /dev/null +++ b/package/ini/ffxivbis.ini.d/sqlite.ini @@ -0,0 +1,3 @@ +[sqlite] +database_path = /home/arcanis/Documents/github/ffxivbis/ffxivbis.db +migrations_path = /home/arcanis/Documents/github/ffxivbis/migrations \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..7af20a0 --- /dev/null +++ b/setup.py @@ -0,0 +1,44 @@ +from distutils.util import convert_path +from setuptools import setup, find_packages +from os import path + + +here = path.abspath(path.dirname(__file__)) +metadata = dict() +with open(convert_path('src/service/core/version.py')) as metadata_file: + exec(metadata_file.read(), metadata) + + +setup( + name='ffxivbis', + + version=metadata['__version__'], + zip_safe=False, + + description='Helper to handle loot drop', + + author='Evgeniy Alekseev', + author_email='i@arcanis.me', + + license='BSD', + + packages=find_packages(exclude=['contrib', 'docs', 'tests']), + + install_requires=[ + 'aiohttp', + 'requests', + 'yoyo_migrations' + ], + setup_requires=[ + 'pytest-runner' + ], + tests_require=[ + 'pytest', 'pytest-aiohttp' + ], + + include_package_data=True, + + extras_require={ + 'test': ['coverage', 'pytest'], + }, +) diff --git a/src/service/__init__.py b/src/service/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/service/api/__init__.py b/src/service/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/service/api/json.py b/src/service/api/json.py new file mode 100644 index 0000000..a3b6e40 --- /dev/null +++ b/src/service/api/json.py @@ -0,0 +1,26 @@ +from enum import Enum +from json import JSONEncoder +from typing import Any + + +class HttpEncoder(JSONEncoder): + def default(self, obj: Any) -> Any: + if isinstance(obj, dict): + data = {} + for key, value in obj.items(): + data[key] = self.default(value) + return data + elif isinstance(obj, Enum): + return obj.name + elif hasattr(obj, '_ast'): + return self.default(obj._ast()) + elif hasattr(obj, '__iter__') and not isinstance(obj, str): + return [self.default(value) for value in obj] + elif hasattr(obj, '__dict__'): + data = { + key: self.default(value) + for key, value in obj.__dict__.items() + if not callable(value) and not key.startswith('_')} + return data + else: + return obj diff --git a/src/service/api/routes.py b/src/service/api/routes.py new file mode 100644 index 0000000..b3b3bb3 --- /dev/null +++ b/src/service/api/routes.py @@ -0,0 +1,18 @@ +from aiohttp.web import Application + +from .views.bis import BiSView +from .views.loot import LootView +from .views.player import PlayerView + + +def setup_routes(app: Application) -> None: + app.router.add_get('/api/v1/party', PlayerView) + app.router.add_post('/api/v1/party', PlayerView) + + app.router.add_get('/api/v1/party/bis', BiSView) + app.router.add_post('/api/v1/party/bis', BiSView) + app.router.add_put('/api/v1/party/bis', BiSView) + + app.router.add_get('/api/v1/party/loot', LootView) + app.router.add_post('/api/v1/party/loot', LootView) + app.router.add_put('/api/v1/party/loot', LootView) \ No newline at end of file diff --git a/src/service/api/utils.py b/src/service/api/utils.py new file mode 100644 index 0000000..be59f4d --- /dev/null +++ b/src/service/api/utils.py @@ -0,0 +1,30 @@ +import json + +from aiohttp.web import Response +from typing import Any, Mapping, List + +from .json import HttpEncoder + + +def make_json(response: Any, args: Mapping[str, Any], code: int = 200) -> str: + return json.dumps({ + 'arguments': dict(args), + 'response': response, + 'status': code + }, cls=HttpEncoder, sort_keys=True) + + +def wrap_exception(exception: Exception, args: Mapping[str, Any], code: int = 500) -> Response: + return wrap_json({'message': repr(exception)}, args, code) + + +def wrap_invalid_param(params: List[str], args: Mapping[str, Any], code: int = 400) -> Response: + return wrap_json({'message': 'invalid or missing parameters: `{}`'.format(params)}, args, code) + + +def wrap_json(response: Any, args: Mapping[str, Any], code: int = 200) -> Response: + return Response( + text=make_json(response, args, code), + status=code, + content_type='application/json' + ) diff --git a/src/service/api/views/__init__.py b/src/service/api/views/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/service/api/views/bis.py b/src/service/api/views/bis.py new file mode 100644 index 0000000..f892c1d --- /dev/null +++ b/src/service/api/views/bis.py @@ -0,0 +1,89 @@ +from aiohttp.web import Response, View +from typing import Iterable, Optional + +from service.core.ariyala_parser import AriyalaParser +from service.models.job import Job +from service.models.piece import Piece +from service.models.player import Player, PlayerId + +from ..utils import wrap_exception, wrap_invalid_param, wrap_json + + +class BiSView(View): + + async def get(self) -> Response: + try: + nick = self.request.query.getone('nick', None) + party: Iterable[Player] = [ + player + for player in self.request.app['party'].party + if nick is None or player.nick == nick + ] + loot = list(sum([player.bis.pieces for player in party], [])) + + except Exception as e: + self.request.app.logger.exception('could not get bis') + return wrap_exception(e, self.request.query) + + return wrap_json(loot, self.request.query) + + async def post(self) -> Response: + try: + data = await self.request.json() + except Exception: + data = dict(await self.request.post()) + + required = ['action', 'job', 'nick'] + if any(param not in data for param in required): + return wrap_invalid_param(required, data) + player_id = PlayerId(Job[data['job']], data['nick']) + + action = data.get('action') + if action not in ('add', 'remove'): + return wrap_invalid_param(['action'], data) + + piece: Optional[Piece] = None + try: + if action == 'add': + if 'is_tome' not in data or 'piece' not in data: + return wrap_invalid_param(['is_tome', 'piece'], data) + + piece = Piece.get(data) # type: ignore + self.request.app['party'].set_item_bis(player_id, piece) + + elif action == 'remove': + if 'is_tome' not in data or 'piece' not in data: + return wrap_invalid_param(['is_tome', 'piece'], data) + + piece = Piece.get(data) # type: ignore + self.request.app['party'].remove_item_bis(player_id, piece) + + except Exception as e: + self.request.app.logger.exception('could not add bis') + return wrap_exception(e, data) + + return wrap_json({'piece': piece, 'player_id': player_id}, data) + + async def put(self) -> Response: + try: + data = await self.request.json() + except Exception: + data = dict(await self.request.post()) + + required = ['job', 'link', 'nick'] + if any(param not in data for param in required): + return wrap_invalid_param(required, data) + player_id = PlayerId(Job[data['job']], data['nick']) + + try: + parser = AriyalaParser(self.request.app['config']) + items = parser.get(data['link']) + for piece in items: + self.request.app['party'].set_item_bis(player_id, piece) + self.request.app['party'].set_bis_link(player_id, data['link']) + + except Exception as e: + self.request.app.logger.exception('could not parse bis') + return wrap_exception(e, data) + + return wrap_json({'link': data['link']}, data) \ No newline at end of file diff --git a/src/service/api/views/loot.py b/src/service/api/views/loot.py new file mode 100644 index 0000000..0c2bf36 --- /dev/null +++ b/src/service/api/views/loot.py @@ -0,0 +1,76 @@ +from aiohttp.web import Response, View +from typing import Iterable + +from service.models.job import Job +from service.models.piece import Piece +from service.models.player import Player, PlayerId + +from ..utils import wrap_exception, wrap_invalid_param, wrap_json + + +class LootView(View): + + async def get(self) -> Response: + try: + nick = self.request.query.getone('nick', None) + party: Iterable[Player] = [ + player + for player in self.request.app['party'].party + if nick is None or player.nick == nick + ] + loot = list(sum([player.loot for player in party], [])) + + except Exception as e: + self.request.app.logger.exception('could not get loot') + return wrap_exception(e, self.request.query) + + return wrap_json(loot, self.request.query) + + async def post(self) -> Response: + try: + data = await self.request.json() + except Exception: + data = dict(await self.request.post()) + + required = ['action', 'is_tome', 'job', 'nick', 'piece'] + if any(param not in data for param in required): + return wrap_invalid_param(required, data) + + action = data.get('action') + if action not in ('add', 'remove'): + return wrap_invalid_param(['action'], data) + + try: + player_id = PlayerId(Job[data['job']], data['nick']) + piece = Piece.get(data) + if action == 'add': + self.request.app['party'].set_item(player_id, piece) + + elif action == 'remove': + self.request.app['party'].remove_item(player_id, piece) + + except Exception as e: + self.request.app.logger.exception('could not add loot') + return wrap_exception(e, data) + + return wrap_json({'piece': piece, 'player_id': player_id}, data) + + async def put(self) -> Response: + try: + data = await self.request.json() + except Exception: + data = dict(await self.request.post()) + + required = ['is_tome', 'piece'] + if any(param not in data for param in required): + return wrap_invalid_param(required, data) + + try: + piece = Piece.get(data) + players = self.request.app['loot'].suggest(piece) + + except Exception as e: + self.request.app.logger.exception('could not suggest loot') + return wrap_exception(e, data) + + return wrap_json(players, data) \ No newline at end of file diff --git a/src/service/api/views/player.py b/src/service/api/views/player.py new file mode 100644 index 0000000..3d07c2c --- /dev/null +++ b/src/service/api/views/player.py @@ -0,0 +1,65 @@ +from aiohttp.web import Response, View +from typing import Iterable + +from service.core.ariyala_parser import AriyalaParser +from service.models.bis import BiS +from service.models.job import Job +from service.models.player import Player, PlayerId + +from ..utils import wrap_exception, wrap_invalid_param, wrap_json + + +class PlayerView(View): + + async def get(self) -> Response: + try: + nick = self.request.query.getone('nick', None) + party: Iterable[Player] = [ + player + for player in self.request.app['party'].party + if nick is None or player.nick == nick + ] + + except Exception as e: + self.request.app.logger.exception('could not get loot') + return wrap_exception(e, self.request.query) + + return wrap_json(party, self.request.query) + + async def post(self) -> Response: + try: + data = await self.request.json() + except Exception: + data = dict(await self.request.post()) + + required = ['action', 'job', 'nick'] + if any(param not in data for param in required): + return wrap_invalid_param(required, data) + priority = data.get('priority', 0) + link = data.get('link', None) + + action = data.get('action') + if action not in ('add', 'remove'): + return wrap_invalid_param(['action'], data) + + try: + if action == 'add': + player = Player(Job[data['job']], data['nick'], BiS(), [], link, priority) + player_id = player.player_id + self.request.app['party'].set_player(player) + + if link is not None: + parser = AriyalaParser(self.request.app['config']) + items = parser.get(link) + for piece in items: + self.request.app['party'].set_item_bis(player_id, piece) + + elif action == 'remove': + player_id = PlayerId(Job[data['job']], data['nick']) + self.request.app['party'].remove_player(player_id) + + except Exception as e: + self.request.app.logger.exception('could not add loot') + return wrap_exception(e, data) + + return wrap_json(player_id, data) \ No newline at end of file diff --git a/src/service/api/web.py b/src/service/api/web.py new file mode 100644 index 0000000..51741ba --- /dev/null +++ b/src/service/api/web.py @@ -0,0 +1,46 @@ +import logging + +from aiohttp import web + +from service.core.config import Configuration +from service.core.database import Database +from service.core.loot_selector import LootSelector +from service.core.party import Party + +from .routes import setup_routes + + +async def on_shutdown(app: web.Application) -> None: + app.logger.warning('server terminated') + + +def run_server(app: web.Application) -> None: + app.logger.info('start server') + web.run_app(app, + host=app['config'].get('web', 'host'), + port=app['config'].getint('web', 'port'), + handle_signals=False) + +def setup_service(config: Configuration, database: Database, loot: LootSelector, party: Party) -> web.Application: + app = web.Application(logger=logging.getLogger('http')) + app.on_shutdown.append(on_shutdown) + + app.middlewares.append(web.normalize_path_middleware(append_slash=False, remove_slash=True)) + + # routes + app.logger.info('setup routes') + setup_routes(app) + + app.logger.info('setup configuration') + app['config'] = config + + app.logger.info('setup database') + app['database'] = database + + app.logger.info('setup loot selector') + app['loot'] = loot + + app.logger.info('setup party worker') + app['party'] = party + + return app diff --git a/src/service/application/__init__.py b/src/service/application/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/service/application/application.py b/src/service/application/application.py new file mode 100644 index 0000000..187a050 --- /dev/null +++ b/src/service/application/application.py @@ -0,0 +1,23 @@ +from service.core.config import Configuration + +from .core import Application + + +def get_config(config_path: str) -> Configuration: + config = Configuration() + config.load(config_path, {}) + config.load_logging() + + return config + + +if __name__ == '__main__': + import argparse + + parser = argparse.ArgumentParser(description='Simple loot recorder for FFXIV') + parser.add_argument('-c', '--config', help='configuration path', default='ffxivbis.ini') + args = parser.parse_args() + + config = get_config(args.config) + app = Application(config) + app.run() diff --git a/src/service/application/core.py b/src/service/application/core.py new file mode 100644 index 0000000..a86fd0a --- /dev/null +++ b/src/service/application/core.py @@ -0,0 +1,25 @@ +import logging + +from service.api.web import run_server, setup_service +from service.core.config import Configuration +from service.core.database import Database +from service.core.loot_selector import LootSelector +from service.core.party import Party + + +class Application: + + def __init__(self, config: Configuration) -> None: + self.config = config + self.logger = logging.getLogger('application') + + def run(self) -> None: + database = Database.get(self.config) + database.migration() + party = Party.get(database) + + priority = self.config.get('settings', 'priority').split() + loot_selector = LootSelector(party, priority) + + web = setup_service(self.config, database, loot_selector, party) + run_server(web) \ No newline at end of file diff --git a/src/service/core/__init__.py b/src/service/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/service/core/ariyala_parser.py b/src/service/core/ariyala_parser.py new file mode 100644 index 0000000..3130743 --- /dev/null +++ b/src/service/core/ariyala_parser.py @@ -0,0 +1,63 @@ +import os +import requests + +from typing import Dict, List, Optional + +from service.models.piece import Piece + +from .config import Configuration + + +class AriyalaParser: + + def __init__(self, config: Configuration) -> None: + self.ariyala_url = config.get('ariyala', 'ariyala_url') + self.xivapi_url = config.get('ariyala', 'xivapi_url') + self.request_timeout = config.getfloat('ariyala', 'request_timeout') + + def __remap_key(self, key: str) -> Optional[str]: + if key == 'mainhand': + return 'weapon' + elif key == 'chest': + return 'body' + elif key == 'ringLeft': + return 'left_ring' + elif key == 'ringRight': + return 'right_ring' + elif key in ('head', 'hands', 'waist', 'legs', 'feet', 'ears', 'neck', 'wrist'): + return key + return None + + def get(self, url: str) -> List[Piece]: + items = self.get_ids(url) + return [ + Piece.get({'piece': slot, 'is_tome': self.get_is_tome(item_id)}) # type: ignore + for slot, item_id in items.items() + ] + + def get_ids(self, url: str) -> Dict[str, int]: + norm_path = os.path.normpath(url) + set_id = os.path.basename(norm_path) + response = requests.get('{}/store.app'.format(self.ariyala_url), params={'identifier': set_id}) + response.raise_for_status() + data = response.json() + + job = data['content'] + bis = data['datasets'][job]['normal']['items'] + + result: Dict[str, int] = {} + for original_key, value in bis.items(): + key = self.__remap_key(original_key) + if key is None: + continue + result[key] = value + return result + + def get_is_tome(self, item_id: int) -> bool: + response = requests.get('{}/item/{}'.format(self.xivapi_url, item_id), + params={'columns': 'IsEquippable'}, + timeout=self.request_timeout) + response.raise_for_status() + data = response.json() + + return data['IsEquippable'] == 0 # don't ask diff --git a/src/service/core/config.py b/src/service/core/config.py new file mode 100644 index 0000000..c03f64f --- /dev/null +++ b/src/service/core/config.py @@ -0,0 +1,55 @@ +import configparser +import os + +from logging.config import fileConfig +from typing import Any, Dict, Mapping, Optional + +from .exceptions import MissingConfiguration + + +class Configuration(configparser.RawConfigParser): + + def __init__(self) -> None: + configparser.RawConfigParser.__init__(self, allow_no_value=True) + self.path: Optional[str] = None + self.root_path: Optional[str] = None + + @property + def include(self) -> str: + return self.__with_root_path(self.get('settings', 'include')) + + def __load_section(self, conf: str) -> None: + self.read(os.path.join(self.include, conf)) + + def __with_root_path(self, path: str) -> str: + return os.path.join(self.root_path, path) + + def get_section(self, section: str) -> Dict[str, str]: + if not self.has_section(section): + raise MissingConfiguration(section) + return dict(self[section]) + + def load(self, path: str, values: Mapping[str, Mapping[str, Any]]) -> None: + self.path = path + self.root_path = os.path.dirname(self.path) + + self.read(self.path) + self.load_includes() + + # don't use direct ConfigParser.update here, it overrides whole section + for section, options in values.items(): + if section not in self: + self.add_section(section) + for key, value in options.items(): + self.set(section, key, value) + + def load_includes(self) -> None: + try: + include_dir = self.include + for conf in filter(lambda p: p.endswith('.ini'), sorted(os.listdir(include_dir))): + self.__load_section(conf) + except (FileNotFoundError, configparser.NoOptionError, configparser.NoSectionError): + pass + + def load_logging(self) -> None: + fileConfig(self.__with_root_path(self.get('settings', 'logging'))) diff --git a/src/service/core/database.py b/src/service/core/database.py new file mode 100644 index 0000000..6c10010 --- /dev/null +++ b/src/service/core/database.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +import datetime +import logging + +from yoyo import get_backend, read_migrations +from typing import List, Mapping, Optional, Type, Union + +from service.models.loot import Loot +from service.models.piece import Piece +from service.models.player import Player, PlayerId +from service.models.upgrade import Upgrade + +from .config import Configuration +from .exceptions import InvalidDatabase + + +class Database: + + def __init__(self, migrations_path: str) -> None: + self.migrations_path = migrations_path + self.logger = logging.getLogger('database') + + @staticmethod + def now() -> int: + return int(datetime.datetime.now().timestamp()) + + @classmethod + def get(cls: Type[Database], config: Configuration) -> Database: + database_type = config.get('settings', 'database') + database_settings = config.get_section(database_type) + + if database_type == 'sqlite': + from .sqlite import SQLiteDatabase + obj: Type[Database] = SQLiteDatabase + else: + raise InvalidDatabase(database_type) + + return obj(**database_settings) + + @property + def connection(self) -> str: + raise NotImplementedError + + def delete_piece(self, player_id: PlayerId, piece: Union[Piece, Upgrade]) -> None: + raise NotImplementedError + + def delete_piece_bis(self, player_id: PlayerId, piece: Piece) -> None: + raise NotImplementedError + + def delete_player(self, player_id: PlayerId) -> None: + raise NotImplementedError + + def get_party(self) -> List[Player]: + raise NotImplementedError + + def get_player(self, player_id: PlayerId) -> Optional[int]: + raise NotImplementedError + + def insert_piece(self, player_id: PlayerId, piece: Union[Piece, Upgrade]) -> None: + raise NotImplementedError + + def insert_piece_bis(self, player_id: PlayerId, piece: Piece) -> None: + raise NotImplementedError + + def insert_player(self, player: Player) -> None: + raise NotImplementedError + + def migration(self) -> None: + self.logger.info('perform migrations at {}'.format(self.connection)) + backend = get_backend(self.connection) + migrations = read_migrations(self.migrations_path) + with backend.lock(): + backend.apply_migrations(backend.to_apply(migrations)) + + def set_loot(self, party: Mapping[int, Player], bis: List[Loot], loot: List[Loot]) -> List[Player]: + for piece in bis: + party[piece.player_id].bis.set_item(piece.piece) + for piece in loot: + party[piece.player_id].loot.append(piece.piece) + return list(party.values()) diff --git a/src/service/core/exceptions.py b/src/service/core/exceptions.py new file mode 100644 index 0000000..66c83d2 --- /dev/null +++ b/src/service/core/exceptions.py @@ -0,0 +1,19 @@ +from typing import Any, Mapping + + +class InvalidDatabase(Exception): + + def __init__(self, database_type: str) -> None: + Exception.__init__(self, 'Unsupported database {}'.format(database_type)) + + +class InvalidDataRow(Exception): + + def __init__(self, data: Mapping[str, Any]) -> None: + Exception.__init__(self, 'Invalid data row `{}`'.format(data)) + + +class MissingConfiguration(Exception): + + def __init__(self, section: str) -> None: + Exception.__init__(self, 'Missing configuration section {}'.format(section)) \ No newline at end of file diff --git a/src/service/core/loot_selector.py b/src/service/core/loot_selector.py new file mode 100644 index 0000000..90737d5 --- /dev/null +++ b/src/service/core/loot_selector.py @@ -0,0 +1,24 @@ +from typing import Iterable, List, Tuple, Union + +from service.models.player import Player, PlayerIdWithCounters +from service.models.piece import Piece +from service.models.upgrade import Upgrade + +from .party import Party + + +class LootSelector: + + def __init__(self, party: Party, order_by: List[str] = None) -> None: + self.party = party + self.order_by = order_by or ['is_required', 'loot_count_bis', 'loot_count_total', 'loot_count', 'loot_priority'] + + def __order_by(self, player: Player, piece: Union[Piece, Upgrade]) -> Tuple: + return tuple(map(lambda method: getattr(player, method)(piece), self.order_by)) + + def __sorted_by(self, piece: Union[Piece, Upgrade]) -> Iterable[Player]: + # pycharm is lying, don't trust it + return sorted(self.party.players.values(), key=lambda player: self.__order_by(player, piece), reverse=True) + + def suggest(self, piece: Union[Piece, Upgrade]) -> List[PlayerIdWithCounters]: + return [player.player_id_with_counters(piece) for player in self.__sorted_by(piece)] \ No newline at end of file diff --git a/src/service/core/party.py b/src/service/core/party.py new file mode 100644 index 0000000..b82a1d6 --- /dev/null +++ b/src/service/core/party.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +from threading import Lock +from typing import Dict, List, Optional, Type, Union + +from service.models.piece import Piece +from service.models.player import Player, PlayerId +from service.models.upgrade import Upgrade + +from .database import Database + + +class Party: + + def __init__(self, database: Database) -> None: + self.lock = Lock() + self.players: Dict[PlayerId, Player] = {} + self.database = database + + @property + def party(self) -> List[Player]: + with self.lock: + return list(self.players.values()) + + @classmethod + def get(cls: Type[Party], database: Database) -> Party: + obj = Party(database) + for player in database.get_party(): + obj.set_player(player) + return obj + + def set_bis_link(self, player_id: PlayerId, link: str) -> None: + with self.lock: + player = self.players[player_id] + player.link = link + self.database.insert_player(player) + + def remove_player(self, player_id: PlayerId) -> Optional[Player]: + self.database.delete_player(player_id) + with self.lock: + player = self.players.pop(player_id, None) + return player + + def set_player(self, player: Player) -> PlayerId: + player_id = player.player_id + self.database.insert_player(player) + with self.lock: + self.players[player_id] = player + return player_id + + def set_item(self, player_id: PlayerId, piece: Union[Piece, Upgrade]) -> None: + self.database.insert_piece(player_id, piece) + with self.lock: + self.players[player_id].loot.append(piece) + + def remove_item(self, player_id: PlayerId, piece: Union[Piece, Upgrade]) -> None: + self.database.delete_piece(player_id, piece) + with self.lock: + try: + self.players[player_id].loot.remove(piece) + except ValueError: + pass + + def set_item_bis(self, player_id: PlayerId, piece: Piece) -> None: + self.database.insert_piece_bis(player_id, piece) + with self.lock: + self.players[player_id].bis.set_item(piece) + + def remove_item_bis(self, player_id: PlayerId, piece: Piece) -> None: + self.database.delete_piece_bis(player_id, piece) + with self.lock: + self.players[player_id].bis.remove_item(piece) diff --git a/src/service/core/sqlite.py b/src/service/core/sqlite.py new file mode 100644 index 0000000..1d0bfce --- /dev/null +++ b/src/service/core/sqlite.py @@ -0,0 +1,109 @@ +from typing import List, Optional, Union + +from service.models.bis import BiS +from service.models.job import Job +from service.models.loot import Loot +from service.models.piece import Piece +from service.models.player import Player, PlayerId +from service.models.upgrade import Upgrade + +from .database import Database +from .sqlite_helper import SQLiteHelper + + +class SQLiteDatabase(Database): + + def __init__(self, database_path: str, migrations_path: str) -> None: + Database.__init__(self, migrations_path) + self.database_path = database_path + + @property + def connection(self) -> str: + return 'sqlite:///{path}'.format(path=self.database_path) + + def delete_piece(self, player_id: PlayerId, piece: Union[Piece, Upgrade]) -> None: + player = self.get_player(player_id) + if player is None: + return + + with SQLiteHelper(self.database_path) as cursor: + cursor.execute( + '''delete from loot + where loot_id in ( + select loot_id from loot + where player_id = ? and piece = ? and is_tome = ? order by created desc limit 1 + )''', + (player, piece.name, getattr(piece, 'is_tome', True))) + + def delete_piece_bis(self, player_id: PlayerId, piece: Piece) -> None: + player = self.get_player(player_id) + if player is None: + return + + with SQLiteHelper(self.database_path) as cursor: + cursor.execute( + '''delete from bis where player_id = ? and piece = ?''', + (player, piece.name)) + + def delete_player(self, player_id: PlayerId) -> None: + with SQLiteHelper(self.database_path) as cursor: + cursor.execute('''delete from players where nick = ? and job = ?''', + (player_id.nick, player_id.job.name)) + + def get_party(self) -> List[Player]: + with SQLiteHelper(self.database_path) as cursor: + cursor.execute('''select * from bis''') + bis_pieces = [Loot(row['player_id'], Piece.get(row)) for row in cursor.fetchall()] + cursor.execute('''select * from loot''') + loot_pieces = [Loot(row['player_id'], Piece.get(row)) for row in cursor.fetchall()] + cursor.execute('''select * from players''') + party = { + row['player_id']: Player(Job[row['job']], row['nick'], BiS(), [], row['bis_link'], row['priority']) + for row in cursor.fetchall() + } + return self.set_loot(party, bis_pieces, loot_pieces) + + def get_player(self, player_id: PlayerId) -> Optional[int]: + with SQLiteHelper(self.database_path) as cursor: + cursor.execute('''select player_id from players where nick = ? and job = ?''', + (player_id.nick, player_id.job.name)) + player = cursor.fetchone() + return player['player_id'] if player is not None else None + + def insert_piece(self, player_id: PlayerId, piece: Union[Piece, Upgrade]) -> None: + player = self.get_player(player_id) + if player is None: + return + + with SQLiteHelper(self.database_path) as cursor: + cursor.execute( + '''insert into loot + (created, piece, is_tome, player_id) + values + (?, ?, ?, ?)''', + (Database.now(), piece.name, getattr(piece, 'is_tome', True), player) + ) + + def insert_piece_bis(self, player_id: PlayerId, piece: Piece) -> None: + player = self.get_player(player_id) + if player is None: + return + + with SQLiteHelper(self.database_path) as cursor: + cursor.execute( + '''replace into bis + (created, piece, is_tome, player_id) + values + (?, ?, ?, ?)''', + (Database.now(), piece.name, piece.is_tome, player) + ) + + def insert_player(self, player: Player) -> None: + with SQLiteHelper(self.database_path) as cursor: + cursor.execute( + '''replace into players + (created, nick, job, bis_link, priority) + values + (?, ?, ?, ?, ?)''', + (Database.now(), player.nick, player.job.name, player.link, player.priority) + ) \ No newline at end of file diff --git a/src/service/core/sqlite_helper.py b/src/service/core/sqlite_helper.py new file mode 100644 index 0000000..c5d9413 --- /dev/null +++ b/src/service/core/sqlite_helper.py @@ -0,0 +1,28 @@ +# because sqlite3 does not support context management +import sqlite3 + +from types import TracebackType +from typing import Any, Dict, Optional, Type + + +def dict_factory(cursor: sqlite3.Cursor, row: sqlite3.Row) -> Dict[str, Any]: + return { + key: value + for key, value in zip([column[0] for column in cursor.description], row) + } + + +class SQLiteHelper(): + def __init__(self, database_path: str) -> None: + self.database_path = database_path + + def __enter__(self) -> sqlite3.Cursor: + self.conn = sqlite3.connect(self.database_path) + self.conn.row_factory = dict_factory + self.conn.execute('''pragma foreign_keys = on''') + return self.conn.cursor() + + def __exit__(self, exc_type: Optional[Type[BaseException]], exc_value: Optional[BaseException], + traceback: Optional[TracebackType]) -> None: + self.conn.commit() + self.conn.close() \ No newline at end of file diff --git a/src/service/core/version.py b/src/service/core/version.py new file mode 100644 index 0000000..541f859 --- /dev/null +++ b/src/service/core/version.py @@ -0,0 +1 @@ +__version__ = '0.1.0' \ No newline at end of file diff --git a/src/service/models/__init__.py b/src/service/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/service/models/bis.py b/src/service/models/bis.py new file mode 100644 index 0000000..2f2b752 --- /dev/null +++ b/src/service/models/bis.py @@ -0,0 +1,47 @@ +import itertools + +from dataclasses import dataclass +from typing import Dict, List, Optional, Union + +from .piece import Piece +from .upgrade import Upgrade + + +@dataclass +class BiS: + weapon: Optional[Piece] = None + head: Optional[Piece] = None + body: Optional[Piece] = None + hands: Optional[Piece] = None + waist: Optional[Piece] = None + legs: Optional[Piece] = None + feet: Optional[Piece] = None + ears: Optional[Piece] = None + neck: Optional[Piece] = None + wrist: Optional[Piece] = None + left_ring: Optional[Piece] = None + right_ring: Optional[Piece] = None + + def has_piece(self, piece: Union[Piece, Upgrade]) -> bool: + if isinstance(piece, Piece): + return piece in self.pieces + elif isinstance(piece, Upgrade): + return self.upgrades_required.get(piece) is not None + return False + + @property + def pieces(self) -> List[Piece]: + return [piece for piece in self.__dict__.values() if isinstance(piece, Piece)] + + @property + def upgrades_required(self) -> Dict[Upgrade, int]: + return { + upgrade: len(list(pieces)) + for upgrade, pieces in itertools.groupby(self.pieces, lambda piece: piece.upgrade) + } + + def set_item(self, piece: Union[Piece, Upgrade]) -> None: + setattr(self, piece.name, piece) + + def remove_item(self, piece: Union[Piece, Upgrade]) -> None: + setattr(self, piece.name, None) \ No newline at end of file diff --git a/src/service/models/job.py b/src/service/models/job.py new file mode 100644 index 0000000..1cf2504 --- /dev/null +++ b/src/service/models/job.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +from enum import Enum, auto +from typing import Tuple + +from .piece import Piece, PieceAccessory, Weapon + + +class Job(Enum): + PLD = auto() + WAR = auto() + DRK = auto() + GNB = auto() + WHM = auto() + SCH = auto() + AST = auto() + MNK = auto() + DRG = auto() + NIN = auto() + SAM = auto() + BRD = auto() + MCH = auto() + DNC = auto() + BLM = auto() + SMN = auto() + RDM = auto() + + @staticmethod + def group_accs_dex() -> Tuple: + return Job.group_ranges() + (Job.NIN,) + + @staticmethod + def group_accs_str() -> Tuple: + return Job.group_mnk() + (Job.DRG,) + + @staticmethod + def group_casters() -> Tuple: + return (Job.BLM, Job.SMN, Job.RDM) + + @staticmethod + def group_healers() -> Tuple: + return (Job.WHM, Job.SCH, Job.AST) + + @staticmethod + def group_mnk() -> Tuple: + return (Job.MNK, Job.SAM) + + @staticmethod + def group_ranges() -> Tuple: + return (Job.BRD, Job.MCH, Job.DNC) + + @staticmethod + def group_tanks() -> Tuple: + return (Job.PLD, Job.WAR, Job.DRK, Job.GNB) + + @staticmethod + def has_same_loot(left: Job, right: Job, piece: Piece) -> bool: + # same jobs, alright + if left == right: + return True + + # weapons are unique per class always + if isinstance(piece, Weapon): + return False + + # group comparison + for group in (Job.group_casters(), Job.group_healers(), Job.group_mnk(), Job.group_ranges(), Job.group_tanks()): + if left in group and right in group: + return True + + # accessories group comparison + if isinstance(Piece, PieceAccessory): + for group in (Job.group_accs_dex(), Job.group_accs_str()): + if left in group and right in group: + return True + + return False + diff --git a/src/service/models/loot.py b/src/service/models/loot.py new file mode 100644 index 0000000..668375e --- /dev/null +++ b/src/service/models/loot.py @@ -0,0 +1,11 @@ +from dataclasses import dataclass +from typing import Union + +from .piece import Piece +from .upgrade import Upgrade + + +@dataclass +class Loot: + player_id: int + piece: Union[Piece, Upgrade] \ No newline at end of file diff --git a/src/service/models/piece.py b/src/service/models/piece.py new file mode 100644 index 0000000..881182b --- /dev/null +++ b/src/service/models/piece.py @@ -0,0 +1,131 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Mapping, Type, Union + +from .upgrade import Upgrade + +from service.core.exceptions import InvalidDataRow + + +@dataclass +class Piece: + is_tome: bool + name: str + + @property + def upgrade(self) -> Upgrade: + if not self.is_tome: + return Upgrade.NoUpgrade + elif isinstance(self, Waist) or isinstance(self, PieceAccessory): + return Upgrade.AccessoryUpgrade + elif isinstance(self, Weapon): + return Upgrade.WeaponUpgrade + elif isinstance(self, PieceGear): + return Upgrade.GearUpgrade + return Upgrade.NoUpgrade + + @classmethod + def get(cls: Type[Piece], data: Mapping[str, Any]) -> Union[Piece, Upgrade]: + try: + piece_type = data['piece'] + is_tome = bool(data['is_tome']) + except KeyError: + raise InvalidDataRow(data) + if piece_type.lower() == 'weapon': + return Weapon(is_tome) + elif piece_type.lower() == 'head': + return Head(is_tome) + elif piece_type.lower() == 'body': + return Body(is_tome) + elif piece_type.lower() == 'hands': + return Hands(is_tome) + elif piece_type.lower() == 'waist': + return Waist(is_tome) + elif piece_type.lower() == 'legs': + return Legs(is_tome) + elif piece_type.lower() == 'feet': + return Feet(is_tome) + elif piece_type.lower() == 'ears': + return Ears(is_tome) + elif piece_type.lower() == 'neck': + return Neck(is_tome) + elif piece_type.lower() == 'wrist': + return Wrist(is_tome) + elif piece_type.lower() in ('left_ring', 'right_ring', 'ring'): + return Ring(is_tome, piece_type.lower()) + elif piece_type.lower() in Upgrade.dict_types(): + return Upgrade[piece_type] + else: + raise InvalidDataRow(data) + + +@dataclass +class PieceAccessory(Piece): + pass + + +@dataclass +class PieceGear(Piece): + pass + + +@dataclass +class Weapon(Piece): + name: str = 'weapon' + + +@dataclass +class Head(PieceGear): + name: str = 'head' + + +@dataclass +class Body(PieceGear): + name: str = 'body' + + +@dataclass +class Hands(PieceGear): + name: str = 'hands' + + +@dataclass +class Waist(PieceGear): + name: str = 'waist' + + +@dataclass +class Legs(PieceGear): + name: str = 'legs' + + +@dataclass +class Feet(PieceGear): + name: str = 'feet' + + +@dataclass +class Ears(PieceAccessory): + name: str = 'ears' + + +@dataclass +class Neck(PieceAccessory): + name: str = 'neck' + + +@dataclass +class Wrist(PieceAccessory): + name: str = 'wrist' + + +@dataclass +class Ring(PieceAccessory): + name: str = 'ring' + + # override __eq__method to be able to compare left/right rings + def __eq__(self, other: Any) -> bool: + if not isinstance(other, Ring): + return False + return self.is_tome == other.is_tome diff --git a/src/service/models/player.py b/src/service/models/player.py new file mode 100644 index 0000000..437e921 --- /dev/null +++ b/src/service/models/player.py @@ -0,0 +1,73 @@ +from dataclasses import dataclass +from typing import List, Optional, Union + +from .bis import BiS +from .job import Job +from .piece import Piece +from .upgrade import Upgrade + + +@dataclass +class PlayerId: + job: Job + nick: str + + def __hash__(self) -> int: + return hash(str(self)) + + +@dataclass +class PlayerIdWithCounters(PlayerId): + loot_count: int + loot_count_bis: int + loot_count_total: int + + +@dataclass +class PlayerIdFull: + jobs: List[Job] + nick: str + + +@dataclass +class Player: + job: Job + nick: str + bis: BiS + loot: List[Union[Piece, Upgrade]] + link: Optional[str] = None + priority: int = 0 + + @property + def player_id(self) -> PlayerId: + return PlayerId(self.job, self.nick) + + def player_id_with_counters(self, piece: Union[Piece, Upgrade]) -> PlayerIdWithCounters: + return PlayerIdWithCounters(self.job, self.nick, self.loot_count(piece), + self.loot_count_bis(piece), self.loot_count_total(piece)) + + # ordering methods + def is_required(self, piece: Union[Piece, Upgrade]) -> bool: + # lets check if it is even in bis + if not self.bis.has_piece(piece): + return False + + if isinstance(piece, Piece): + # alright it is in is, lets check if he even got it + return self.loot_count(piece) == 0 + elif isinstance(piece, Upgrade): + # alright it lets check how much upgrades does they need + return self.bis.upgrades_required[piece] > self.loot_count(piece) + return False + + def loot_count(self, piece: Union[Piece, Upgrade]) -> int: + return self.loot.count(piece) + + def loot_count_bis(self, _: Union[Piece, Upgrade]) -> int: + return len([piece for piece in self.loot if self.bis.has_piece(piece)]) + + def loot_count_total(self, _: Union[Piece, Upgrade]) -> int: + return len(self.loot) + + def loot_priority(self, _: Union[Piece, Upgrade]) -> int: + return self.priority diff --git a/src/service/models/upgrade.py b/src/service/models/upgrade.py new file mode 100644 index 0000000..49e00cf --- /dev/null +++ b/src/service/models/upgrade.py @@ -0,0 +1,13 @@ +from enum import Enum, auto +from typing import List + + +class Upgrade(Enum): + NoUpgrade = auto() + AccessoryUpgrade = auto() + GearUpgrade = auto() + WeaponUpgrade = auto() + + @staticmethod + def dict_types() -> List[str]: + return list(map(lambda t: t.name.lower(), Upgrade)) \ No newline at end of file diff --git a/test/__init__.py b/test/__init__.py new file mode 100644 index 0000000..8ec72fd --- /dev/null +++ b/test/__init__.py @@ -0,0 +1,5 @@ +import os +import sys + + +sys.path.append(os.path.join(os.path.dirname(os.path.dirname(__file__)), 'src')) diff --git a/test/conftest.py b/test/conftest.py new file mode 100644 index 0000000..0bd3fdd --- /dev/null +++ b/test/conftest.py @@ -0,0 +1,128 @@ +import os +import pytest +import tempfile + +from typing import Any, List + +from service.api.web import setup_service +from service.core.ariyala_parser import AriyalaParser +from service.core.config import Configuration +from service.core.database import Database +from service.core.loot_selector import LootSelector +from service.core.party import Party +from service.core.sqlite import SQLiteDatabase +from service.models.bis import BiS +from service.models.job import Job +from service.models.piece import Head, Piece, Weapon +from service.models.player import Player + + +@pytest.fixture +def parser(config: Configuration) -> AriyalaParser: + return AriyalaParser(config) + + +@pytest.fixture +def bis() -> BiS: + return BiS() + + +@pytest.fixture +def bis2() -> BiS: + return BiS() + + +@pytest.fixture +def bis_link() -> str: + return 'https://ffxiv.ariyala.com/19V5R' + + +@pytest.fixture +def bis_set() -> List[Piece]: + items: List[Piece] = [] + items.append(Piece.get({'piece': 'weapon', 'is_tome': False})) + items.append(Piece.get({'piece': 'head', 'is_tome': False})) + items.append(Piece.get({'piece': 'body', 'is_tome': False})) + items.append(Piece.get({'piece': 'hands', 'is_tome': True})) + items.append(Piece.get({'piece': 'waist', 'is_tome': True})) + items.append(Piece.get({'piece': 'legs', 'is_tome': True})) + items.append(Piece.get({'piece': 'feet', 'is_tome': False})) + items.append(Piece.get({'piece': 'ears', 'is_tome': False})) + items.append(Piece.get({'piece': 'neck', 'is_tome': True})) + items.append(Piece.get({'piece': 'wrist', 'is_tome': False})) + items.append(Piece.get({'piece': 'left_ring', 'is_tome': True})) + items.append(Piece.get({'piece': 'right_ring', 'is_tome': True})) + + return items + + +@pytest.fixture +def config() -> Configuration: + config = Configuration() + config.load('/dev/null', { + 'ariyala': { + 'ariyala_url': 'https://ffxiv.ariyala.com', + 'request_timeout': 1, + 'xivapi_url': 'https://xivapi.com' + }, + 'settings': { + 'include': '/dev/null' + } + }) + return config + + +@pytest.fixture +def database() -> SQLiteDatabase: + db = SQLiteDatabase( + tempfile.mktemp('-ffxivbis.db'), + os.path.join(os.path.dirname(os.path.dirname(__file__)), 'migrations')) + db.migration() + return db + + +@pytest.fixture +def head_with_upgrade() -> Piece: + return Head(is_tome=True) + + +@pytest.fixture +def party(database: Database) -> Party: + return Party(database) + + +@pytest.fixture +def player(bis: BiS) -> Player: + return Player(Job.WHM, 'A nick', bis, []) + + +@pytest.fixture +def player2(bis2: BiS) -> Player: + return Player(Job.AST, 'Another nick', bis2, [], priority=0) + + +@pytest.fixture +def selector(party: Party, player: Player, player2: Player, + head_with_upgrade: Piece, weapon: Piece) -> LootSelector: + obj = LootSelector(party) + + obj.party.set_player(player) + player.bis.set_item(weapon) + + obj.party.set_player(player2) + player2.bis.set_item(head_with_upgrade) + player2.bis.set_item(weapon) + + return LootSelector(party) + + +@pytest.fixture +def server(loop: Any, aiohttp_client: Any, + config: Configuration, database: Database, selector: LootSelector, party: Party) -> Any: + app = setup_service(config, database, selector, party) + return loop.run_until_complete(aiohttp_client(app)) + + +@pytest.fixture +def weapon() -> Piece: + return Weapon(is_tome=False) \ No newline at end of file diff --git a/test/test_ariyala.py b/test/test_ariyala.py new file mode 100644 index 0000000..6c5dc87 --- /dev/null +++ b/test/test_ariyala.py @@ -0,0 +1,10 @@ +from typing import List + +from service.core.ariyala_parser import AriyalaParser +from service.models.piece import Piece + + +def test_get(parser: AriyalaParser, bis_link: str, bis_set: List[Piece]) -> None: + items = parser.get(bis_link) + + assert items == bis_set diff --git a/test/test_bis.py b/test/test_bis.py new file mode 100644 index 0000000..3781153 --- /dev/null +++ b/test/test_bis.py @@ -0,0 +1,20 @@ +from service.models.bis import BiS +from service.models.piece import Piece +from service.models.upgrade import Upgrade + + +def test_set_item(bis: BiS, weapon: Piece) -> None: + bis.set_item(weapon) + assert bis.has_piece(weapon) + + +def test_remove_item(bis: BiS, weapon: Piece) -> None: + test_set_item(bis, weapon) + bis.remove_item(weapon) + assert not bis.has_piece(weapon) + + +def test_upgrades_required(bis: BiS, weapon: Piece, head_with_upgrade: Piece) -> None: + bis.set_item(weapon) + bis.set_item(head_with_upgrade) + assert bis.upgrades_required == {Upgrade.NoUpgrade: 1, Upgrade.GearUpgrade: 1} diff --git a/test/test_loot_selector.py b/test/test_loot_selector.py new file mode 100644 index 0000000..86d2536 --- /dev/null +++ b/test/test_loot_selector.py @@ -0,0 +1,10 @@ +from service.core.loot_selector import LootSelector +from service.models.piece import Piece +from service.models.player import Player + + +def test_suggest_by_need(selector: LootSelector, player: Player, player2: Player, head_with_upgrade: Piece) -> None: + assert selector.suggest(head_with_upgrade) == \ + [player2.player_id_with_counters(head_with_upgrade), player.player_id_with_counters(head_with_upgrade)] + + diff --git a/test/test_party.py b/test/test_party.py new file mode 100644 index 0000000..ff8bf3b --- /dev/null +++ b/test/test_party.py @@ -0,0 +1,95 @@ +from service.core.database import Database +from service.core.party import Party +from service.models.piece import Piece +from service.models.player import Player + + +def test_set_player(party: Party, player: Player) -> None: + assert len(party.players) == 0 + + party.set_player(player) + assert len(party.players) == 1 + + +def test_remove_player(party: Party, player: Player) -> None: + party.remove_player(player.player_id) + assert len(party.players) == 0 + + party.set_player(player) + party.remove_player(player.player_id) + assert len(party.players) == 0 + + +def test_set_bis_link(party: Party, player: Player, bis_link: str) -> None: + party.set_player(player) + + party.set_bis_link(player.player_id, bis_link) + assert player.link == bis_link + + +def test_set_item(party: Party, player: Player, head_with_upgrade: Piece, weapon: Piece) -> None: + party.set_player(player) + + party.set_item(player.player_id, weapon) + assert abs(player.loot_count(weapon)) == 1 + assert abs(player.loot_count(head_with_upgrade)) == 0 + + +def test_remove_item(party: Party, player: Player, head_with_upgrade: Piece, weapon: Piece) -> None: + party.set_player(player) + + party.remove_item(player.player_id, weapon) + assert abs(player.loot_count(weapon)) == 0 + assert abs(player.loot_count(head_with_upgrade)) == 0 + + party.set_item(player.player_id, weapon) + assert abs(player.loot_count(weapon)) == 1 + assert abs(player.loot_count(head_with_upgrade)) == 0 + + party.remove_item(player.player_id, weapon) + assert abs(player.loot_count(weapon)) == 0 + assert abs(player.loot_count(head_with_upgrade)) == 0 + + +def test_set_item_bis(party: Party, player: Player, head_with_upgrade: Piece, weapon: Piece) -> None: + party.set_player(player) + + party.set_item_bis(player.player_id, head_with_upgrade) + assert player.bis.has_piece(head_with_upgrade) + assert not player.bis.has_piece(weapon) + + +def test_remove_item_bis(party: Party, player: Player, head_with_upgrade: Piece, weapon: Piece) -> None: + party.set_player(player) + + party.remove_item_bis(player.player_id, head_with_upgrade) + assert not player.bis.has_piece(head_with_upgrade) + assert not player.bis.has_piece(weapon) + + party.set_item_bis(player.player_id, head_with_upgrade) + assert player.bis.has_piece(head_with_upgrade) + assert not player.bis.has_piece(weapon) + + party.set_item_bis(player.player_id, weapon) + assert player.bis.has_piece(head_with_upgrade) + assert player.bis.has_piece(weapon) + + party.remove_item_bis(player.player_id, head_with_upgrade) + assert not player.bis.has_piece(head_with_upgrade) + assert player.bis.has_piece(weapon) + + +def test_get(party: Party, database: Database, player: Player, head_with_upgrade: Piece, weapon: Piece, + bis_link: str) -> None: + party.set_player(player) + party.set_bis_link(player.player_id, bis_link) + party.set_item_bis(player.player_id, head_with_upgrade) + party.set_item_bis(player.player_id, weapon) + party.set_item(player.player_id, weapon) + + new_party = Party.get(database) + assert party.party == new_party.party + + party.remove_player(player.player_id) + new_party = Party.get(database) + assert party.party == new_party.party diff --git a/test/test_piece.py b/test/test_piece.py new file mode 100644 index 0000000..33bd084 --- /dev/null +++ b/test/test_piece.py @@ -0,0 +1,13 @@ +from service.models.piece import Piece + + +def test_parse_head(head_with_upgrade: Piece) -> None: + assert Piece.get({'piece': 'head', 'is_tome': True}) == head_with_upgrade + + +def test_parse_weapon(weapon: Piece) -> None: + assert Piece.get({'piece': 'weapon', 'is_tome': False}) == weapon + + +def test_parse_upgrade(head_with_upgrade: Piece) -> None: + assert Piece.get({'piece': head_with_upgrade.upgrade.name, 'is_tome': True}) == head_with_upgrade.upgrade \ No newline at end of file diff --git a/test/test_player.py b/test/test_player.py new file mode 100644 index 0000000..91041c3 --- /dev/null +++ b/test/test_player.py @@ -0,0 +1,73 @@ +from service.models.piece import Piece +from service.models.player import Player + + +def test_loot_count(player: Player, head_with_upgrade: Piece, weapon: Piece) -> None: + assert abs(player.loot_count(head_with_upgrade)) == 0 + assert abs(player.loot_count(weapon)) == 0 + + player.loot.append(head_with_upgrade) + assert abs(player.loot_count(head_with_upgrade)) == 1 + assert abs(player.loot_count(weapon)) == 0 + + player.loot.append(weapon) + assert abs(player.loot_count(head_with_upgrade)) == 1 + assert abs(player.loot_count(weapon)) == 1 + + +def test_loot_count_bis(player: Player, head_with_upgrade: Piece, weapon: Piece) -> None: + assert abs(player.loot_count_bis(head_with_upgrade)) == 0 + assert abs(player.loot_count_bis(weapon)) == 0 + + player.bis.set_item(head_with_upgrade) + player.loot.append(head_with_upgrade) + assert abs(player.loot_count_bis(head_with_upgrade)) == 1 + assert abs(player.loot_count_bis(weapon)) == 1 + + player.bis.set_item(weapon) + assert abs(player.loot_count_bis(head_with_upgrade)) == 1 + assert abs(player.loot_count_bis(weapon)) == 1 + + +def test_loot_count_total(player: Player, head_with_upgrade: Piece, weapon: Piece) -> None: + assert abs(player.loot_count_total(head_with_upgrade)) == 0 + assert abs(player.loot_count_total(weapon)) == 0 + + player.loot.append(head_with_upgrade) + assert abs(player.loot_count_total(head_with_upgrade)) == 1 + assert abs(player.loot_count_total(weapon)) == 1 + + player.loot.append(weapon) + assert abs(player.loot_count_total(head_with_upgrade)) == 2 + assert abs(player.loot_count_total(weapon)) == 2 + + +def test_loot_priority(player: Player, head_with_upgrade: Piece, weapon: Piece) -> None: + assert abs(player.priority) == abs(player.loot_priority(head_with_upgrade)) + assert abs(player.priority) == abs(player.loot_priority(weapon)) + + player.loot.append(head_with_upgrade) + assert abs(player.priority) == abs(player.loot_priority(head_with_upgrade)) + assert abs(player.priority) == abs(player.loot_priority(weapon)) + + player.loot.append(weapon) + assert abs(player.priority) == abs(player.loot_priority(head_with_upgrade)) + assert abs(player.priority) == abs(player.loot_priority(weapon)) + + +def test_is_required(player: Player, head_with_upgrade: Piece, weapon: Piece) -> None: + assert not player.is_required(weapon) + assert not player.is_required(head_with_upgrade) + + player.bis.set_item(weapon) + assert player.is_required(weapon) + assert not player.is_required(head_with_upgrade) + + player.loot.append(weapon) + assert not player.is_required(weapon) + assert not player.is_required(head_with_upgrade) + + player.bis.set_item(head_with_upgrade) + assert not player.is_required(weapon) + assert player.is_required(head_with_upgrade) + assert player.is_required(head_with_upgrade.upgrade) \ No newline at end of file diff --git a/test/test_view_bis.py b/test/test_view_bis.py new file mode 100644 index 0000000..aacff59 --- /dev/null +++ b/test/test_view_bis.py @@ -0,0 +1,67 @@ +from typing import Any, List + +from service.api.utils import make_json +from service.core.party import Party +from service.models.piece import Piece +from service.models.player import Player + + +async def test_bis_get(server: Any, party: Party, player: Player, player2: Player, + head_with_upgrade: Piece, weapon: Piece) -> None: + party.set_item_bis(player.player_id, weapon) + party.set_item_bis(player2.player_id, weapon) + party.set_item_bis(player2.player_id, head_with_upgrade) + + response = await server.get('/api/v1/party/bis') + assert response.status == 200 + assert await response.text() == make_json([weapon, weapon, head_with_upgrade], {}, 200) + + +async def test_bis_get_with_filter(server: Any, party: Party, player: Player, player2: Player, + head_with_upgrade: Piece, weapon: Piece) -> None: + party.set_item_bis(player.player_id, weapon) + party.set_item_bis(player2.player_id, weapon) + party.set_item_bis(player2.player_id, head_with_upgrade) + + response = await server.get('/api/v1/party/bis', params={'nick': player.nick}) + assert response.status == 200 + assert await response.text() == make_json([weapon], {'nick': player.nick}, 200) + + response = await server.get('/api/v1/party/bis', params={'nick': player2.nick}) + assert response.status == 200 + assert await response.text() == make_json([weapon, head_with_upgrade], {'nick': player2.nick}, 200) + + +async def test_bis_post_add(server: Any, player: Player, head_with_upgrade: Piece) -> None: + response = await server.post('/api/v1/party/bis', json={ + 'action': 'add', + 'piece': head_with_upgrade.name, + 'is_tome': head_with_upgrade.is_tome, + 'job': player.job.name, + 'nick': player.nick + }) + assert response.status == 200 + assert player.bis.has_piece(head_with_upgrade) + + +async def test_bis_post_remove(server: Any, player: Player, player2: Player, weapon: Piece) -> None: + response = await server.post('/api/v1/party/bis', json={ + 'action': 'remove', + 'piece': weapon.name, + 'is_tome': weapon.is_tome, + 'job': player.job.name, + 'nick': player.nick + }) + assert response.status == 200 + assert not player.bis.has_piece(weapon) + assert player2.bis.has_piece(weapon) + + +async def test_bis_put(server: Any, player: Player, bis_link: str, bis_set: List[Piece]) -> None: + response = await server.put('/api/v1/party/bis', json={ + 'job': player.job.name, + 'link': bis_link, + 'nick': player.nick + }) + assert response.status == 200 + assert player.bis.pieces == bis_set diff --git a/test/test_view_loot.py b/test/test_view_loot.py new file mode 100644 index 0000000..b0e9c74 --- /dev/null +++ b/test/test_view_loot.py @@ -0,0 +1,88 @@ +from typing import Any + +from service.api.utils import make_json +from service.core.party import Party +from service.models.piece import Piece +from service.models.player import Player + + +async def test_loot_get(server: Any, party: Party, player: Player, player2: Player, weapon: Piece) -> None: + party.set_item(player.player_id, weapon) + party.set_item(player2.player_id, weapon) + + response = await server.get('/api/v1/party/loot') + assert response.status == 200 + assert await response.text() == make_json([weapon, weapon], {}, 200) + + +async def test_loot_get_with_filter(server: Any, party: Party, player: Player, player2: Player, weapon: Piece) -> None: + party.set_item(player.player_id, weapon) + party.set_item(player2.player_id, weapon) + + response = await server.get('/api/v1/party/loot', params={'nick': player.nick}) + assert response.status == 200 + assert await response.text() == make_json([weapon], {'nick': player.nick}, 200) + + response = await server.get('/api/v1/party/loot', params={'nick': player2.nick}) + assert response.status == 200 + assert await response.text() == make_json([weapon], {'nick': player2.nick}, 200) + + +async def test_loot_post_add(server: Any, player: Player, weapon: Piece) -> None: + response = await server.get('/api/v1/party/loot') + assert response.status == 200 + assert await response.text() == make_json([], {}, 200) + assert weapon not in player.loot + + response = await server.post('/api/v1/party/loot', json={ + 'action': 'add', + 'piece': weapon.name, + 'is_tome': weapon.is_tome, + 'job': player.job.name, + 'nick': player.nick + }) + assert response.status == 200 + assert weapon in player.loot + + +async def test_loot_post_remove(server: Any, player: Player, head_with_upgrade: Piece, weapon: Piece) -> None: + assert weapon not in player.loot + player.loot.append(weapon) + player.loot.append(weapon) + assert player.loot.count(weapon) == 2 + + response = await server.post('/api/v1/party/loot', json={ + 'action': 'remove', + 'piece': weapon.name, + 'is_tome': weapon.is_tome, + 'job': player.job.name, + 'nick': player.nick + }) + assert response.status == 200 + assert player.loot.count(weapon) == 1 + + player.loot.append(head_with_upgrade) + + response = await server.post('/api/v1/party/loot', json={ + 'action': 'remove', + 'piece': weapon.name, + 'is_tome': weapon.is_tome, + 'job': player.job.name, + 'nick': player.nick + }) + assert response.status == 200 + assert player.loot.count(weapon) == 0 + assert player.loot.count(head_with_upgrade) == 1 + + +async def test_loot_put(server: Any, player: Player, player2: Player, head_with_upgrade: Piece) -> None: + response = await server.put('/api/v1/party/loot', json={ + 'is_tome': head_with_upgrade.is_tome, + 'piece': head_with_upgrade.name + }) + assert response.status == 200 + assert await response.text() == make_json( + [player2.player_id_with_counters(head_with_upgrade), player.player_id_with_counters(head_with_upgrade)], + {'is_tome': head_with_upgrade.is_tome, 'piece': head_with_upgrade.name}, + 200 + ) diff --git a/test/test_view_player.py b/test/test_view_player.py new file mode 100644 index 0000000..5a254e1 --- /dev/null +++ b/test/test_view_player.py @@ -0,0 +1,81 @@ +from typing import Any, List + +from service.api.utils import make_json +from service.core.party import Party +from service.models.piece import Piece +from service.models.player import Player + + +async def test_players_get(server: Any, party: Party, player: Player) -> None: + party.set_player(player) + + response = await server.get('/api/v1/party') + assert response.status == 200 + assert await response.text() == make_json(party.party, {}, 200) + + +async def test_players_get_with_filter(server: Any, party: Party, player: Player, player2: Player) -> None: + party.set_player(player) + + response = await server.get('/api/v1/party', params={'nick': player.nick}) + assert response.status == 200 + assert await response.text() == make_json([player], {'nick': player.nick}, 200) + + response = await server.get('/api/v1/party', params={'nick': player2.nick}) + assert response.status == 200 + assert await response.text() == make_json([player2], {'nick': player2.nick}, 200) + + +async def test_players_post_add(server: Any, party: Party, player: Player) -> None: + party.remove_player(player.player_id) + + response = await server.get('/api/v1/party', params={'nick': player.nick}) + assert response.status == 200 + assert await response.text() == make_json([], {'nick': player.nick}, 200) + + response = await server.post('/api/v1/party', json={ + 'action': 'add', + 'job': player.job.name, + 'nick': player.nick + }) + assert response.status == 200 + + assert player.player_id in party.players + + +async def test_players_post_remove(server: Any, party: Party, player: Player) -> None: + response = await server.get('/api/v1/party', params={'nick': player.nick}) + assert response.status == 200 + assert await response.text() == make_json([player], {'nick': player.nick}, 200) + + response = await server.post('/api/v1/party', json={ + 'action': 'remove', + 'job': player.job.name, + 'nick': player.nick + }) + assert response.status == 200 + + response = await server.get('/api/v1/party', params={'nick': player.nick}) + assert response.status == 200 + assert await response.text() == make_json([], {'nick': player.nick}, 200) + + assert player.player_id not in party.players + + +async def test_players_post_add_with_link(server: Any, party: Party, player: Player, + bis_link: str, bis_set: List[Piece]) -> None: + party.remove_player(player.player_id) + + response = await server.get('/api/v1/party', params={'nick': player.nick}) + assert response.status == 200 + assert await response.text() == make_json([], {'nick': player.nick}, 200) + + response = await server.post('/api/v1/party', json={ + 'action': 'add', + 'job': player.job.name, + 'nick': player.nick, + 'link': bis_link + }) + assert response.status == 200 + + assert party.players[player.player_id].bis.pieces == bis_set