435 lines
17 KiB
Python
435 lines
17 KiB
Python
|
import traceback
|
||
|
from typing import Any, Dict, Iterator, List, Optional, Set, Tuple, Type
|
||
|
|
||
|
from bemani.common import Model, ValidatedDict, Time
|
||
|
from bemani.data import Data, UserID, RemoteUser
|
||
|
|
||
|
|
||
|
class ProfileCreationException(Exception):
|
||
|
pass
|
||
|
|
||
|
|
||
|
class Status:
|
||
|
"""
|
||
|
List of statuses we return to the game for various reasons.
|
||
|
"""
|
||
|
SUCCESS = 0
|
||
|
NO_PROFILE = 109
|
||
|
NOT_ALLOWED = 110
|
||
|
NOT_REGISTERED = 112
|
||
|
INVALID_PIN = 116
|
||
|
|
||
|
|
||
|
class Factory:
|
||
|
"""
|
||
|
The base class every game factory inherits from. Defines a create method
|
||
|
which should return some game class which can handle packets. Game classes
|
||
|
inherit from Base, and have handle_<call>_request methods on them that
|
||
|
Dispatch will look up in order to handle calls.
|
||
|
"""
|
||
|
|
||
|
MANAGED_CLASSES: List[Type["Base"]] = []
|
||
|
|
||
|
@classmethod
|
||
|
def register_all(cls) -> None:
|
||
|
"""
|
||
|
Subclasses of this class should use this function to register themselves
|
||
|
with Base, using Base.register(). Factories specify the game code that
|
||
|
they support, which Base will use when routing requests.
|
||
|
"""
|
||
|
raise Exception('Override this in subclass!')
|
||
|
|
||
|
@classmethod
|
||
|
def run_scheduled_work(cls, data: Data, config: Dict[str, Any]) -> None:
|
||
|
"""
|
||
|
Subclasses of this class should use this function to run any scheduled
|
||
|
work on classes which it is a factory for. This is usually used for
|
||
|
out-of-band DB operations such as generating new weekly/daily charts,
|
||
|
calculating league scores, etc.
|
||
|
"""
|
||
|
for game in cls.MANAGED_CLASSES:
|
||
|
try:
|
||
|
events = game.run_scheduled_work(data, config)
|
||
|
except Exception:
|
||
|
events = []
|
||
|
stack = traceback.format_exc()
|
||
|
print(stack)
|
||
|
data.local.network.put_event(
|
||
|
'exception',
|
||
|
{
|
||
|
'service': 'scheduler',
|
||
|
'traceback': stack,
|
||
|
},
|
||
|
)
|
||
|
for event in events:
|
||
|
data.local.network.put_event(event[0], event[1])
|
||
|
|
||
|
@classmethod
|
||
|
def all_games(cls) -> Iterator[Tuple[str, int, str]]:
|
||
|
"""
|
||
|
Given a particular factory, iterate over all game, version combinations.
|
||
|
Useful for loading things from the DB without wanting to hardcode values.
|
||
|
"""
|
||
|
for game in cls.MANAGED_CLASSES:
|
||
|
yield (game.game, game.version, game.name)
|
||
|
|
||
|
@classmethod
|
||
|
def all_settings(cls) -> Iterator[Tuple[str, int, Dict[str, Any]]]:
|
||
|
"""
|
||
|
Given a particular factory, iterate over all game, version combinations that
|
||
|
have settings and return those settings.
|
||
|
"""
|
||
|
for game in cls.MANAGED_CLASSES:
|
||
|
yield (game.game, game.version, game.get_settings())
|
||
|
|
||
|
@classmethod
|
||
|
def create(cls, data: Data, config: Dict[str, Any], model: Model, parentmodel: Optional[Model]=None) -> Optional['Base']:
|
||
|
"""
|
||
|
Given a modelstring and an optional parent model, return an instantiated game class that can handle a packet.
|
||
|
|
||
|
Parameters:
|
||
|
data - A Data singleton for DB access
|
||
|
config - Configuration dictionary
|
||
|
model - A parsed Model, used by game factories to determine which game class to return
|
||
|
parentmodel - The parent model doing the requesting. In some cases, games request an older
|
||
|
version game class to migrate profiles. This presents a problem when they don't
|
||
|
specify version strings, because some game lookups are ambiguous without them.
|
||
|
This allows a factory to determine which game to return based on the parent
|
||
|
requesting model, assuming that we want one version back.
|
||
|
|
||
|
Returns:
|
||
|
A subclass of Base that hopefully has a handle_<call>_request method on it, for the particular
|
||
|
call that Dispatch wants to resolve, or None if we can't look up a game.
|
||
|
"""
|
||
|
raise Exception('Override this in subclass!')
|
||
|
|
||
|
|
||
|
class Base:
|
||
|
"""
|
||
|
The base class every game class inherits from. Incudes handlers for card management, PASELI, most
|
||
|
non-game startup packets, and simple code for loading/storing profiles.
|
||
|
"""
|
||
|
|
||
|
__registered_games: Dict[str, Type[Factory]] = {}
|
||
|
__registered_handlers: Set[Type[Factory]] = set()
|
||
|
|
||
|
"""
|
||
|
Override this in your subclass.
|
||
|
"""
|
||
|
game = 'dummy'
|
||
|
|
||
|
"""
|
||
|
Override this in your subclass.
|
||
|
"""
|
||
|
version = 0
|
||
|
|
||
|
"""
|
||
|
Override this in your subclass.
|
||
|
"""
|
||
|
name = 'dummy'
|
||
|
|
||
|
def __init__(self, data: Data, config: Dict[str, Any], model: Model) -> None:
|
||
|
self.data = data
|
||
|
self.config = config
|
||
|
self.model = model
|
||
|
|
||
|
@classmethod
|
||
|
def create(cls, data: Data, config: Dict[str, Any], model: Model, parentmodel: Optional[Model]=None) -> Optional['Base']:
|
||
|
"""
|
||
|
Given a modelstring and an optional parent model, return an instantiated game class that can handle a packet.
|
||
|
|
||
|
Note that this is provided here as game factories register with Base to advertise that the will
|
||
|
handle some model string. This allows game code to ask for other game classes by model only.
|
||
|
|
||
|
Parameters:
|
||
|
data - A Data singleton for DB access
|
||
|
config - Configuration dictionary
|
||
|
model - A parsed Model, used by game factories to determine which game class to return
|
||
|
parentmodel - The parent model doing the requesting. In some cases, games request an older
|
||
|
version game class to migrate profiles. This presents a problem when they don't
|
||
|
specify version strings, because some game lookups are ambiguous without them.
|
||
|
This allows a factory to determine which game to return based on the parent
|
||
|
requesting model, assuming that we want one version back.
|
||
|
|
||
|
Returns:
|
||
|
A subclass of Base that hopefully has a handle_<call>_request method on it, for the particular
|
||
|
call that Dispatch wants to resolve, or an instance of Base itself if no game is registered for
|
||
|
this model. Its possible to return None from this function if a registered game has no way of
|
||
|
handling this particular modelstring.
|
||
|
"""
|
||
|
if model.game not in cls.__registered_games:
|
||
|
# Return just this base model, which will provide nothing
|
||
|
return Base(data, config, model)
|
||
|
else:
|
||
|
# Return the registered module providing this game
|
||
|
return cls.__registered_games[model.game].create(data, config, model, parentmodel=parentmodel)
|
||
|
|
||
|
@classmethod
|
||
|
def register(cls, game: str, handler: Type[Factory]) -> None:
|
||
|
"""
|
||
|
Register a factory to handle a game. Note that the game should be the game
|
||
|
code as returned by a game, such as "LDJ" or "MDX".
|
||
|
|
||
|
Parameters:
|
||
|
game - 3-character string identifying a game
|
||
|
handler - A factory which has a create() method that can spawn game classes.
|
||
|
"""
|
||
|
cls.__registered_games[game] = handler
|
||
|
cls.__registered_handlers.add(handler)
|
||
|
|
||
|
@classmethod
|
||
|
def run_scheduled_work(cls, data: Data, config: Dict[str, Any]) -> List[Tuple[str, Dict[str, Any]]]:
|
||
|
"""
|
||
|
Run any out-of-band scheduled work that is applicable to this game.
|
||
|
"""
|
||
|
return []
|
||
|
|
||
|
@classmethod
|
||
|
def get_settings(cls) -> Dict[str, Any]:
|
||
|
"""
|
||
|
Return any game settings this game wishes a front-end to modify.
|
||
|
"""
|
||
|
return {}
|
||
|
|
||
|
@classmethod
|
||
|
def all_games(cls) -> Iterator[Tuple[str, int, str]]:
|
||
|
"""
|
||
|
Given all registered factories, iterate over all game, version combinations.
|
||
|
Useful for loading things from the DB without wanting to hardcode values.
|
||
|
"""
|
||
|
for factory in cls.__registered_handlers:
|
||
|
for game in factory.MANAGED_CLASSES:
|
||
|
yield (game.game, game.version, game.name)
|
||
|
|
||
|
@classmethod
|
||
|
def all_settings(cls) -> Iterator[Tuple[str, int, Dict[str, Any]]]:
|
||
|
"""
|
||
|
Given all registered factories, iterate over all game, version combinations that
|
||
|
have settings and return those settings.
|
||
|
"""
|
||
|
for factory in cls.__registered_handlers:
|
||
|
for game in factory.MANAGED_CLASSES:
|
||
|
yield (game.game, game.version, game.get_settings())
|
||
|
|
||
|
def extra_services(self) -> List[str]:
|
||
|
"""
|
||
|
A list of extra services that this game needs to advertise.
|
||
|
Override in your subclass if you need to advertise extra
|
||
|
services for a particular game or series.
|
||
|
"""
|
||
|
return []
|
||
|
|
||
|
def supports_paseli(self) -> bool:
|
||
|
"""
|
||
|
An override so that particular games can disable PASELI support
|
||
|
regardless of the server settings. Some games and some regions
|
||
|
are buggy with respect to PASELI.
|
||
|
"""
|
||
|
return True
|
||
|
|
||
|
def bind_profile(self, userid: UserID) -> None:
|
||
|
"""
|
||
|
Handling binding the user's profile to this version on this server.
|
||
|
|
||
|
Parameters:
|
||
|
userid - The user ID we are binding the profile for.
|
||
|
"""
|
||
|
|
||
|
def has_profile(self, userid: UserID) -> bool:
|
||
|
"""
|
||
|
Return whether a user has a profile for this game/version on this server.
|
||
|
|
||
|
Parameters:
|
||
|
userid - The user ID we are binding the profile for.
|
||
|
|
||
|
Returns:
|
||
|
True if the profile exists, False if not.
|
||
|
"""
|
||
|
return self.data.local.user.get_profile(self.game, self.version, userid) is not None
|
||
|
|
||
|
def get_profile(self, userid: UserID) -> Optional[ValidatedDict]:
|
||
|
"""
|
||
|
Return the profile for a user given this game/version on any connected server.
|
||
|
|
||
|
Parameters:
|
||
|
userid - The user ID we are getting the profile for.
|
||
|
|
||
|
Returns:
|
||
|
A dictionary representing the user's profile, or None if it doesn't exist.
|
||
|
"""
|
||
|
return self.data.remote.user.get_profile(self.game, self.version, userid)
|
||
|
|
||
|
def get_any_profile(self, userid: UserID) -> ValidatedDict:
|
||
|
"""
|
||
|
Return ANY profile for a user in a game series.
|
||
|
|
||
|
Tries to look up the profile for a userid/game/version on any connected server.
|
||
|
If that fails, looks for the latest profile that the user has for the current
|
||
|
game series. This is usually used for fetching profiles to display names for
|
||
|
scores, as users can earn scores on different mixes of games and on remote
|
||
|
networks.
|
||
|
|
||
|
Parameters:
|
||
|
userid - The user ID we are getting the profile for.
|
||
|
|
||
|
Returns:
|
||
|
A dictionary representing the user's profile, or an empty dictionary if
|
||
|
none was found.
|
||
|
"""
|
||
|
profile = self.data.remote.user.get_any_profile(self.game, self.version, userid)
|
||
|
if profile is None:
|
||
|
profile = ValidatedDict()
|
||
|
return profile
|
||
|
|
||
|
def get_any_profiles(self, userids: List[UserID]) -> List[Tuple[UserID, ValidatedDict]]:
|
||
|
"""
|
||
|
Does the identical thing to the above function, but takes a list of user IDs to
|
||
|
fetch in bulk.
|
||
|
|
||
|
Parameters:
|
||
|
userids - List of user IDs we are getting the profile for.
|
||
|
|
||
|
Returns:
|
||
|
A list of tuples with the User ID and dictionary representing the user's profile,
|
||
|
or an empty dictionary if nothing was found.
|
||
|
"""
|
||
|
userids = list(set(userids))
|
||
|
profiles = self.data.remote.user.get_any_profiles(self.game, self.version, userids)
|
||
|
return [
|
||
|
(userid, profile if profile is not None else ValidatedDict())
|
||
|
for (userid, profile) in profiles
|
||
|
]
|
||
|
|
||
|
def put_profile(self, userid: UserID, profile: ValidatedDict) -> None:
|
||
|
"""
|
||
|
Save a new profile for this user given a game/version.
|
||
|
|
||
|
Parameters:
|
||
|
userid - The user ID we are saving the profile for.
|
||
|
profile - A dictionary that should be looked up later using get_profile.
|
||
|
"""
|
||
|
if RemoteUser.is_remote(userid):
|
||
|
raise Exception('Trying to save a remote profile locally!')
|
||
|
self.data.local.user.put_profile(self.game, self.version, userid, profile)
|
||
|
|
||
|
def update_play_statistics(self, userid: UserID, extra_stats: Optional[Dict[str, Any]]=None) -> None:
|
||
|
"""
|
||
|
Given a user ID, calculate new play statistics.
|
||
|
|
||
|
Handles keeping track of statistics such as consecutive days played, last
|
||
|
play date, times played today, times played total, etc.
|
||
|
|
||
|
Parameters:
|
||
|
userid - The user ID we are binding the profile for.
|
||
|
"""
|
||
|
if RemoteUser.is_remote(userid):
|
||
|
raise Exception('Trying to save remote statistics locally!')
|
||
|
|
||
|
# We store the play statistics in a series-wide settings blob so its available
|
||
|
# across all game versions, since it isn't game-specific.
|
||
|
settings = self.get_play_statistics(userid)
|
||
|
|
||
|
if extra_stats is not None:
|
||
|
for key in extra_stats:
|
||
|
# Make sure we don't override anything we manage here
|
||
|
if key in [
|
||
|
'total_plays',
|
||
|
'today_plays',
|
||
|
'total_days',
|
||
|
'first_play_timestamp',
|
||
|
'last_play_timestamp',
|
||
|
'last_play_date',
|
||
|
'consecutive_days',
|
||
|
]:
|
||
|
continue
|
||
|
# Safe to copy over
|
||
|
settings[key] = extra_stats[key]
|
||
|
|
||
|
settings.replace_int('total_plays', settings.get_int('total_plays') + 1)
|
||
|
settings.replace_int('first_play_timestamp', settings.get_int('first_play_timestamp', int(Time.now())))
|
||
|
settings.replace_int('last_play_timestamp', int(Time.now()))
|
||
|
|
||
|
last_play_date = settings.get_int_array('last_play_date', 3)
|
||
|
today_play_date = Time.todays_date()
|
||
|
yesterday_play_date = Time.yesterdays_date()
|
||
|
if (
|
||
|
last_play_date[0] == today_play_date[0] and
|
||
|
last_play_date[1] == today_play_date[1] and
|
||
|
last_play_date[2] == today_play_date[2]
|
||
|
):
|
||
|
# We already played today, add one
|
||
|
settings.replace_int('today_plays', settings.get_int('today_plays') + 1)
|
||
|
else:
|
||
|
# We played on a new day, so count total days up
|
||
|
settings.replace_int('total_days', settings.get_int('total_days') + 1)
|
||
|
|
||
|
# We haven't played yet today, reset to one
|
||
|
settings.replace_int('today_plays', 1)
|
||
|
if (
|
||
|
last_play_date[0] == yesterday_play_date[0] and
|
||
|
last_play_date[1] == yesterday_play_date[1] and
|
||
|
last_play_date[2] == yesterday_play_date[2]
|
||
|
):
|
||
|
# We played yesterday, add one to consecutive days
|
||
|
settings.replace_int('consecutive_days', settings.get_int('consecutive_days') + 1)
|
||
|
else:
|
||
|
# We haven't played yet today or yesterday, reset consecutive days
|
||
|
settings.replace_int('consecutive_days', 1)
|
||
|
settings.replace_int_array('last_play_date', 3, today_play_date)
|
||
|
|
||
|
# Save back
|
||
|
self.data.local.game.put_settings(self.game, userid, settings)
|
||
|
|
||
|
def get_machine_id(self) -> int:
|
||
|
machine = self.data.local.machine.get_machine(self.config['machine']['pcbid'])
|
||
|
return machine.id
|
||
|
|
||
|
def update_machine_name(self, newname: Optional[str]) -> None:
|
||
|
if newname is None:
|
||
|
return
|
||
|
machine = self.data.local.machine.get_machine(self.config['machine']['pcbid'])
|
||
|
machine.name = newname
|
||
|
self.data.local.machine.put_machine(machine)
|
||
|
|
||
|
def update_machine_data(self, newdata: Dict[str, Any]) -> None:
|
||
|
machine = self.data.local.machine.get_machine(self.config['machine']['pcbid'])
|
||
|
machine.data.update(newdata)
|
||
|
self.data.local.machine.put_machine(machine)
|
||
|
|
||
|
def get_game_config(self) -> ValidatedDict:
|
||
|
machine = self.data.local.machine.get_machine(self.config['machine']['pcbid'])
|
||
|
if machine.arcade is not None:
|
||
|
settings = self.data.local.machine.get_settings(machine.arcade, self.game, self.version, 'game_config')
|
||
|
else:
|
||
|
settings = None
|
||
|
|
||
|
if settings is None:
|
||
|
settings = ValidatedDict()
|
||
|
return settings
|
||
|
|
||
|
def get_play_statistics(self, userid: UserID) -> ValidatedDict:
|
||
|
"""
|
||
|
Given a user ID, get the play statistics.
|
||
|
|
||
|
Note that games wishing to use this when generating profiles to send to
|
||
|
a game should call update_play_statistics when parsing a profile save.
|
||
|
|
||
|
Parameters:
|
||
|
userid - The user ID we are binding the profile for.
|
||
|
|
||
|
Returns a dictionary optionally containing the following attributes:
|
||
|
total_plays - Integer count of total plays for this game series
|
||
|
first_play_timestamp - Unix timestamp of first play time
|
||
|
last_play_timestamp - Unix timestamp of last play time
|
||
|
last_play_date - List of ints in the form of [YYYY, MM, DD] of last play date
|
||
|
today_plays - Number of times played today
|
||
|
total_days - Total individual days played
|
||
|
consecutive_days - Number of consecutive days played at this time.
|
||
|
"""
|
||
|
if RemoteUser.is_remote(userid):
|
||
|
return ValidatedDict({})
|
||
|
settings = self.data.local.game.get_settings(self.game, userid)
|
||
|
if settings is None:
|
||
|
return ValidatedDict({})
|
||
|
return settings
|