from abc import ABC, abstractmethod import traceback from typing import Any, Dict, Iterator, List, Optional, Set, Tuple, Type from typing_extensions import Final from bemani.common import ( Model, ValidatedDict, Profile, PlayStatistics, GameConstants, RegionConstants, Time, cache, ) from bemani.data import Config, Data, Arcade, Machine, UserID, RemoteUser class ProfileCreationException(Exception): pass class Status: """ List of statuses we return to the game for various reasons. """ SUCCESS: Final[int] = 0 NO_PROFILE: Final[int] = 109 NOT_ALLOWED: Final[int] = 110 NOT_REGISTERED: Final[int] = 112 INVALID_PIN: Final[int] = 116 class Factory(ABC): """ 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__requests service methods or handle___request methods on them that Dispatch will look up in order to handle calls. """ MANAGED_CLASSES: List[Type["Base"]] @classmethod @abstractmethod 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 NotImplementedError("Override this in subclass!") @classmethod def run_scheduled_work(cls, data: Data, config: Config) -> 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[GameConstants, 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[GameConstants, 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 @abstractmethod def create( cls, data: Data, config: Config, 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__request method on it, for the particular call that Dispatch wants to resolve, or None if we can't look up a game. """ raise NotImplementedError("Override this in subclass!") class Base(ABC): """ 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: GameConstants """ Override this in your subclass. """ version: int """ Override this in your subclass. """ name: str @property 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 [] @property 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 @property def supports_expired_profiles(self) -> bool: """ Override this in your subclass if your game or series requires non-expired profiles in order to correctly present migrations to the user. """ return True @property def requires_extended_regions(self) -> bool: """ Override this in your subclass if your game requires an updated region list. """ return False def __init__(self, data: Data, config: Config, model: Model) -> None: self.data = data self.config = config self.model = model # Provided purely for convenience. You can also import cache directly from bemani.common # in order to use the object for decorators such as @cache.memoize. self.cache = cache @classmethod def create( cls, data: Data, config: Config, 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__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.gamecode 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.gamecode].create(data, config, model, parentmodel=parentmodel) @classmethod def register(cls, gamecode: 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[gamecode] = handler cls.__registered_handlers.add(handler) @classmethod def run_scheduled_work(cls, data: Data, config: Config) -> 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[GameConstants, 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[GameConstants, 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 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[Profile]: """ 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) -> Profile: """ 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 = Profile( self.game, self.version, "", 0, ) return profile def get_any_profiles(self, userids: List[UserID]) -> List[Tuple[UserID, Profile]]: """ 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 Profile(self.game, self.version, "", 0), ) for (userid, profile) in profiles ] def put_profile(self, userid: UserID, profile: Profile) -> 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, stats: Optional[PlayStatistics] = 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. stats - A play statistics object we should store extra data from. """ 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.data.local.game.get_settings(self.game, userid) or ValidatedDict({}) if stats is not None: for key in 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] = 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", Time.now())) settings.replace_int("last_play_timestamp", 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 played only once today (the play we are saving). 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 yesterday, so we have only one consecutive day. 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 get_machine(self) -> Machine: return self.data.local.machine.get_machine(self.config.machine.pcbid) def update_machine_name(self, newname: Optional[str]) -> None: if newname is None: return machine = self.get_machine() machine.name = newname self.data.local.machine.put_machine(machine) def update_machine_data(self, newdata: Dict[str, Any]) -> None: machine = self.get_machine() machine.data.update(newdata) self.data.local.machine.put_machine(machine) def update_machine(self, newmachine: Machine) -> None: machine = self.data.local.machine.get_machine(self.config.machine.pcbid) machine.name = newmachine.name machine.data = newmachine.data self.data.local.machine.put_machine(machine) def get_arcade(self) -> Optional[Arcade]: machine = self.get_machine() if machine.arcade is None: return None return self.data.local.machine.get_arcade(machine.arcade) def get_machine_region(self) -> int: arcade = self.get_arcade() if arcade is None: return RegionConstants.db_to_game_region(self.requires_extended_regions, self.config.server.region) else: return RegionConstants.db_to_game_region(self.requires_extended_regions, arcade.region) def get_game_config(self) -> ValidatedDict: machine = self.data.local.machine.get_machine(self.config.machine.pcbid) # If this machine belongs to an arcade, use its settings. If the settings aren't present, # default to the game's defaults. if machine.arcade is not None: settings = self.data.local.machine.get_settings(machine.arcade, self.game, self.version, "game_config") if settings is None: settings = ValidatedDict() return settings # If this machine does not belong to an arcade, use the server-wide settings. If the settings # aren't present, default ot the game's default. else: settings = self.data.local.machine.get_settings( self.data.local.machine.DEFAULT_SETTINGS_ARCADE, self.game, self.version, "game_config", ) if settings is None: settings = ValidatedDict() return settings def get_play_statistics(self, userid: UserID) -> PlayStatistics: """ 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 PlayStatistics( self.game, 0, 0, 0, 0, Time.now(), Time.now(), ) # Grab the last saved settings and today's date. settings = self.data.local.game.get_settings(self.game, userid) today_play_date = Time.todays_date() yesterday_play_date = Time.yesterdays_date() if settings is None: return PlayStatistics( self.game, 1, 1, 1, 1, Time.now(), Time.now(), ) # Calculate whether we are on our first play of the day or not. last_play_date = settings.get_int_array("last_play_date", 3) 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 last played today, so the total days and today plays are accurate # as stored. today_count = settings.get_int("today_plays", 0) total_days = settings.get_int("total_days", 1) consecutive_days = settings.get_int("consecutive_days", 1) else: if last_play_date[0] != 0 and last_play_date[1] != 0 and last_play_date[2] != 0: # We've played before but not today, so the total days is # the stored count plus today. total_days = settings.get_int("total_days") + 1 else: # We've never played before, so the total days is just 1. total_days = 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've played before, and it was yesterday, so today is the # next consecutive day. So add the current value and today. consecutive_days = settings.get_int("consecutive_days") + 1 else: # This is the first consecutive day, we've either never played # or we played a bunch but in the past before yesterday. consecutive_days = 1 # We haven't played yet today. today_count = 0 # Grab any extra settings that a game may have stored here. extra_settings: Dict[str, Any] = { key: value for (key, value) in settings.items() if key not in { "total_plays", "today_plays", "total_days", "first_play_timestamp", "last_play_timestamp", "last_play_date", "consecutive_days", } } return PlayStatistics( self.game, settings.get_int("total_plays") + 1, today_count + 1, total_days, consecutive_days, settings.get_int("first_play_timestamp", Time.now()), settings.get_int("last_play_timestamp", Time.now()), extra_settings, )