Better documentation for PlayStatistics and Profile objects as well as stats get and update functions.

This commit is contained in:
Jennifer Taylor
2026-08-29 23:32:21 +00:00
parent 0685ae5d02
commit 0718b09037
2 changed files with 135 additions and 88 deletions

View File

@@ -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)

View File

@@ -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