From 0718b0903758b2ca777b85e23df314b6a52eced7 Mon Sep 17 00:00:00 2001 From: Jennifer Taylor Date: Sat, 29 Aug 2026 23:32:21 +0000 Subject: [PATCH] Better documentation for PlayStatistics and Profile objects as well as stats get and update functions. --- bemani/backend/base.py | 173 ++++++++++++++++++--------------- bemani/common/validateddict.py | 50 +++++++--- 2 files changed, 135 insertions(+), 88 deletions(-) diff --git a/bemani/backend/base.py b/bemani/backend/base.py index 8049b3f..8faa58b 100644 --- a/bemani/backend/base.py +++ b/bemani/backend/base.py @@ -367,75 +367,6 @@ class Base(ABC): 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 @@ -500,22 +431,34 @@ class Base(ABC): def get_play_statistics(self, userid: UserID) -> PlayStatistics: """ - Given a user ID, get the play statistics. + 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 - 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. + 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( @@ -606,3 +549,79 @@ class Base(ABC): 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) diff --git a/bemani/common/validateddict.py b/bemani/common/validateddict.py index cc2ba8a..691686a 100644 --- a/bemani/common/validateddict.py +++ b/bemani/common/validateddict.py @@ -5,6 +5,13 @@ from bemani.common.constants import GameConstants def intish(val: Any, base: int = 10) -> Optional[int]: + """ + Given a value of any type, if it can be interpreted as an int, do so. + This includes native integers, floats as well as strings that contain + only an integer value. If the input turns out not to be an integer, + returns None instead. + """ + if val is None: return None try: @@ -20,11 +27,14 @@ class ValidatedDict(dict): non-default values when data is good. Used primarily for storing data pulled directly from game responses, or reading data to echo to a game. - All of the get functions will verify that the attribute exists and is the right - type. If it is not, the default value is returned. + All the get functions will verify that the attribute exists and is the right + type. If it is not, the default value is returned. This is a strict check, so + for instance if you need an int and the type is a float, this will choose the + default value instead of coercing the float to an int. - all of the set functions will verify that the to-be-stored value matches the - type. If it does not, the value is not updated. + All the set functions will verify that the to-be-stored value matches the + type. If it does not, the value is not updated. This is a strict check, just like + the various get functions. """ def clone(self) -> "ValidatedDict": @@ -449,7 +459,10 @@ class Profile(ValidatedDict): """ A special case of a ValidatedDict, a profile is guaranteed to also contain references to how it was created or loaded, including the game/version - combo and the refid and extid associated wit the profile. + combo and the refid and extid associated with the profile. This is normally + fetched by calling get_profile, get_any_profile, or get_any_profiles from + within a game's handler. This is normally persisted by calling put_profile + from within the same game's handler. """ def __init__( @@ -472,9 +485,18 @@ class Profile(ValidatedDict): class PlayStatistics(ValidatedDict): """ - A special case of a ValidatedDict, a play statistics object is guaranteed - to also contain several values representing last play times, total play times, - and the like. + A special case of a ValidatedDict, a play statistics object is guaranteed to + also contain several values representing last play times, total play times, + and the like. This structure represents per-series play statistics in a consistent + manner across all game series. To fetch play statistics for a given game you + should call get_play_statistics. To update play statistics for a given game you + should call update_play_statistics. + + You can keep extra values in this structure just like any other ValidatedDict. + You might want to do that if you have settings, statistics or options that span + an entire game series. To do that, you can use this like any other ValidatedDict + after calling get_play_statistics, and you can include a PlayStatistics with extra + values in an update_play_statistics call. """ def __init__( @@ -491,12 +513,18 @@ class PlayStatistics(ValidatedDict): super().__init__(extra_values or {}) self.game = game # How many actual profiles saves have we registered across all games in this series. + # Note that on a fresh profile this will be 1, not 0, because it reflects the assumption + # that you are looking up the PlayStatistics for a user during profile load. self.total_plays = total_plays - # How many actual profile saves have we registered today, so far. + # How many actual profile saves have we registered today, so far. Note that on the first + # play of the day this will be 1, not 0. self.today_plays = today_plays - # How many total days that we have registered at least one profile save. + # How many total days that we have registered at least one profile save. Note that on + # a fresh profile this will be 1 because it includes today in that count. self.total_days = total_days - # How many consecutive days in a row we registered at least one profile save. + # How many consecutive days in a row we registered at least one profile save. Note that + # if this is the first consective day (either due to a new profile or they haven't played + # in awhile) this will be 1. self.consecutive_days = consecutive_days # The timestamp of the very first play session, in seconds. self.first_play_timestamp = first_play_timestamp