from abc import ABC, abstractmethod import traceback from typing import Any, Dict, Final, Iterator, List, Optional, Set, Tuple, Type 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 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 for the current game series. 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. You can call this function as many times as you want during profile load or during any other user-based packet handler and it will return you the calculated statistics for the game series including the current play. 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, including the current play. A fresh profile that has had no profile saves performed against it will have a total plays of 1. first_play_timestamp - Integer Unix timestamp of first play time. last_play_timestamp - Integer 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, including the current play. The first play of the day will have a value of 1 here. total_days - Total individual days played, including the current play. A fresh profile will have a total days of 1 since the play this is looked up for includes the current day. consecutive_days - Number of consecutive days played at this time. If this is the first day in a streak, this will be 1. Otherwise this will be the number of days in a row the user has played this series, including today. """ 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, ) def update_play_statistics(self, userid: UserID, stats: Optional[PlayStatistics] = None) -> None: """ Given a user ID, calculate new play statistics for the current game series. Handles keeping track of statistics such as consecutive days played, last play date, times played today, times played total, etc. Note that this modifies the user's profile to update play statistics at the time of call so you should only call it once on profile save at the end of a round. Optionally you can provide a PlayStatistics that you looked up from get_play_statistics if you are keeping extra data in the series statistics. If you are not doing that and don't have any existing PlayStatistics to persist then you can call this with just the userid and the user's game series statistics will be updated properly. 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)