initial commit

This commit is contained in:
Evgenii Alekseev 2019-09-06 00:54:27 +03:00
commit 9f19519b75
47 changed files with 2027 additions and 0 deletions

96
.gitignore vendored Normal file
View File

@ -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

View File

@ -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)''')
]

9
package/ini/ffxivbis.ini Normal file
View File

@ -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

View File

@ -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

View File

@ -0,0 +1,3 @@
[sqlite]
database_path = /home/arcanis/Documents/github/ffxivbis/ffxivbis.db
migrations_path = /home/arcanis/Documents/github/ffxivbis/migrations

44
setup.py Normal file
View File

@ -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'],
},
)

0
src/service/__init__.py Normal file
View File

View File

26
src/service/api/json.py Normal file
View File

@ -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

18
src/service/api/routes.py Normal file
View File

@ -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)

30
src/service/api/utils.py Normal file
View File

@ -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'
)

View File

View File

@ -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)

View File

@ -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)

View File

@ -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)

46
src/service/api/web.py Normal file
View File

@ -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

View File

View File

@ -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()

View File

@ -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)

View File

View File

@ -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

View File

@ -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')))

View File

@ -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())

View File

@ -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))

View File

@ -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)]

72
src/service/core/party.py Normal file
View File

@ -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)

109
src/service/core/sqlite.py Normal file
View File

@ -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)
)

View File

@ -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()

View File

@ -0,0 +1 @@
__version__ = '0.1.0'

View File

47
src/service/models/bis.py Normal file
View File

@ -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)

78
src/service/models/job.py Normal file
View File

@ -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

View File

@ -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]

131
src/service/models/piece.py Normal file
View File

@ -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

View File

@ -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

View File

@ -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))

5
test/__init__.py Normal file
View File

@ -0,0 +1,5 @@
import os
import sys
sys.path.append(os.path.join(os.path.dirname(os.path.dirname(__file__)), 'src'))

128
test/conftest.py Normal file
View File

@ -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)

10
test/test_ariyala.py Normal file
View File

@ -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

20
test/test_bis.py Normal file
View File

@ -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}

View File

@ -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)]

95
test/test_party.py Normal file
View File

@ -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

13
test/test_piece.py Normal file
View File

@ -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

73
test/test_player.py Normal file
View File

@ -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)

67
test/test_view_bis.py Normal file
View File

@ -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

88
test/test_view_loot.py Normal file
View File

@ -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
)

81
test/test_view_player.py Normal file
View File

@ -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