mirror of
https://github.com/arcan1s/ffxivbis.git
synced 2025-07-15 14:55:49 +00:00
initial commit
This commit is contained in:
0
src/service/__init__.py
Normal file
0
src/service/__init__.py
Normal file
0
src/service/api/__init__.py
Normal file
0
src/service/api/__init__.py
Normal file
26
src/service/api/json.py
Normal file
26
src/service/api/json.py
Normal 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
18
src/service/api/routes.py
Normal 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
30
src/service/api/utils.py
Normal 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'
|
||||
)
|
0
src/service/api/views/__init__.py
Normal file
0
src/service/api/views/__init__.py
Normal file
89
src/service/api/views/bis.py
Normal file
89
src/service/api/views/bis.py
Normal 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)
|
76
src/service/api/views/loot.py
Normal file
76
src/service/api/views/loot.py
Normal 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)
|
65
src/service/api/views/player.py
Normal file
65
src/service/api/views/player.py
Normal 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
46
src/service/api/web.py
Normal 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
|
0
src/service/application/__init__.py
Normal file
0
src/service/application/__init__.py
Normal file
23
src/service/application/application.py
Normal file
23
src/service/application/application.py
Normal 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()
|
25
src/service/application/core.py
Normal file
25
src/service/application/core.py
Normal 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)
|
0
src/service/core/__init__.py
Normal file
0
src/service/core/__init__.py
Normal file
63
src/service/core/ariyala_parser.py
Normal file
63
src/service/core/ariyala_parser.py
Normal 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
|
55
src/service/core/config.py
Normal file
55
src/service/core/config.py
Normal 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')))
|
81
src/service/core/database.py
Normal file
81
src/service/core/database.py
Normal 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())
|
19
src/service/core/exceptions.py
Normal file
19
src/service/core/exceptions.py
Normal 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))
|
24
src/service/core/loot_selector.py
Normal file
24
src/service/core/loot_selector.py
Normal 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
72
src/service/core/party.py
Normal 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
109
src/service/core/sqlite.py
Normal 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)
|
||||
)
|
28
src/service/core/sqlite_helper.py
Normal file
28
src/service/core/sqlite_helper.py
Normal 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()
|
1
src/service/core/version.py
Normal file
1
src/service/core/version.py
Normal file
@ -0,0 +1 @@
|
||||
__version__ = '0.1.0'
|
0
src/service/models/__init__.py
Normal file
0
src/service/models/__init__.py
Normal file
47
src/service/models/bis.py
Normal file
47
src/service/models/bis.py
Normal 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
78
src/service/models/job.py
Normal 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
|
||||
|
11
src/service/models/loot.py
Normal file
11
src/service/models/loot.py
Normal 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
131
src/service/models/piece.py
Normal 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
|
73
src/service/models/player.py
Normal file
73
src/service/models/player.py
Normal 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
|
13
src/service/models/upgrade.py
Normal file
13
src/service/models/upgrade.py
Normal 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))
|
Reference in New Issue
Block a user