296 lines
10 KiB
Python
296 lines
10 KiB
Python
# vim: set fileencoding=utf-8
|
|
from typing import Dict, Optional
|
|
from typing_extensions import Final
|
|
|
|
from bemani.backend.base import Base
|
|
from bemani.backend.core import CoreHandler, CardManagerHandler, PASELIHandler
|
|
from bemani.common import Profile, ValidatedDict, GameConstants, DBConstants, Parallel
|
|
from bemani.data import UserID
|
|
from bemani.protocol import Node
|
|
|
|
|
|
class SoundVoltexBase(CoreHandler, CardManagerHandler, PASELIHandler, Base):
|
|
"""
|
|
Base game class for all Sound Voltex version that we support.
|
|
"""
|
|
|
|
game: GameConstants = GameConstants.SDVX
|
|
|
|
CLEAR_TYPE_NO_PLAY: Final[int] = DBConstants.SDVX_CLEAR_TYPE_NO_PLAY
|
|
CLEAR_TYPE_FAILED: Final[int] = DBConstants.SDVX_CLEAR_TYPE_FAILED
|
|
CLEAR_TYPE_CLEAR: Final[int] = DBConstants.SDVX_CLEAR_TYPE_CLEAR
|
|
CLEAR_TYPE_HARD_CLEAR: Final[int] = DBConstants.SDVX_CLEAR_TYPE_HARD_CLEAR
|
|
CLEAR_TYPE_ULTIMATE_CHAIN: Final[int] = DBConstants.SDVX_CLEAR_TYPE_ULTIMATE_CHAIN
|
|
CLEAR_TYPE_PERFECT_ULTIMATE_CHAIN: Final[int] = DBConstants.SDVX_CLEAR_TYPE_PERFECT_ULTIMATE_CHAIN
|
|
|
|
GRADE_NO_PLAY: Final[int] = DBConstants.SDVX_GRADE_NO_PLAY
|
|
GRADE_D: Final[int] = DBConstants.SDVX_GRADE_D
|
|
GRADE_C: Final[int] = DBConstants.SDVX_GRADE_C
|
|
GRADE_B: Final[int] = DBConstants.SDVX_GRADE_B
|
|
GRADE_A: Final[int] = DBConstants.SDVX_GRADE_A
|
|
GRADE_A_PLUS: Final[int] = DBConstants.SDVX_GRADE_A_PLUS
|
|
GRADE_AA: Final[int] = DBConstants.SDVX_GRADE_AA
|
|
GRADE_AA_PLUS: Final[int] = DBConstants.SDVX_GRADE_AA_PLUS
|
|
GRADE_AAA: Final[int] = DBConstants.SDVX_GRADE_AAA
|
|
GRADE_AAA_PLUS: Final[int] = DBConstants.SDVX_GRADE_AAA_PLUS
|
|
GRADE_S: Final[int] = DBConstants.SDVX_GRADE_S
|
|
|
|
CHART_TYPE_NOVICE: Final[int] = 0
|
|
CHART_TYPE_ADVANCED: Final[int] = 1
|
|
CHART_TYPE_EXHAUST: Final[int] = 2
|
|
CHART_TYPE_INFINITE: Final[int] = 3
|
|
CHART_TYPE_MAXIMUM: Final[int] = 4
|
|
|
|
def previous_version(self) -> Optional['SoundVoltexBase']:
|
|
"""
|
|
Returns the previous version of the game, based on this game. Should
|
|
be overridden.
|
|
"""
|
|
return None
|
|
|
|
def get_profile_by_refid(self, refid: Optional[str]) -> Optional[Node]:
|
|
"""
|
|
Given a RefID, return a formatted profile node. Basically every game
|
|
needs a profile lookup, even if it handles where that happens in
|
|
a different request. This is provided for code deduplication.
|
|
"""
|
|
if refid is None:
|
|
return None
|
|
|
|
# First try to load the actual profile
|
|
userid = self.data.remote.user.from_refid(self.game, self.version, refid)
|
|
profile = self.get_profile(userid)
|
|
if profile is None:
|
|
return None
|
|
|
|
# Now, return it
|
|
return self.format_profile(userid, profile)
|
|
|
|
def new_profile_by_refid(self, refid: Optional[str], name: Optional[str], locid: Optional[int]) -> Node:
|
|
"""
|
|
Given a RefID and an optional name, create a profile and then return
|
|
a formatted profile node. Similar rationale to get_profile_by_refid.
|
|
"""
|
|
if refid is None:
|
|
return None
|
|
|
|
if name is None:
|
|
name = 'NONAME'
|
|
|
|
# First, create and save the default profile
|
|
userid = self.data.remote.user.from_refid(self.game, self.version, refid)
|
|
profile = Profile(
|
|
self.game,
|
|
self.version,
|
|
refid,
|
|
0,
|
|
{
|
|
'name': name,
|
|
'loc': locid,
|
|
},
|
|
)
|
|
self.put_profile(userid, profile)
|
|
return self.format_profile(userid, profile)
|
|
|
|
def format_profile(self, userid: UserID, profile: Profile) -> Node:
|
|
"""
|
|
Base handler for a profile. Given a userid and a profile dictionary,
|
|
return a Node representing a profile. Should be overridden.
|
|
"""
|
|
return Node.void('game')
|
|
|
|
def unformat_profile(self, userid: UserID, request: Node, oldprofile: Profile) -> Profile:
|
|
"""
|
|
Base handler for profile parsing. Given a request and an old profile,
|
|
return a new profile that's been updated with the contents of the request.
|
|
Should be overridden.
|
|
"""
|
|
return oldprofile
|
|
|
|
def get_clear_rates(self) -> Dict[int, Dict[int, Dict[str, int]]]:
|
|
"""
|
|
Returns a dictionary similar to the following:
|
|
|
|
{
|
|
musicid: {
|
|
chart: {
|
|
total: total plays,
|
|
clears: total clears,
|
|
average: average score,
|
|
},
|
|
},
|
|
}
|
|
"""
|
|
all_attempts, remote_attempts = Parallel.execute([
|
|
lambda: self.data.local.music.get_all_attempts(
|
|
game=self.game,
|
|
version=self.version,
|
|
),
|
|
lambda: self.data.remote.music.get_clear_rates(
|
|
game=self.game,
|
|
version=self.version,
|
|
)
|
|
])
|
|
attempts: Dict[int, Dict[int, Dict[str, int]]] = {}
|
|
for (_, attempt) in all_attempts:
|
|
# Terrible temporary structure is terrible.
|
|
if attempt.id not in attempts:
|
|
attempts[attempt.id] = {}
|
|
if attempt.chart not in attempts[attempt.id]:
|
|
attempts[attempt.id][attempt.chart] = {
|
|
'total': 0,
|
|
'clears': 0,
|
|
'average': 0,
|
|
}
|
|
|
|
# We saw an attempt, keep the total attempts in sync.
|
|
attempts[attempt.id][attempt.chart]['average'] = int(
|
|
(
|
|
(attempts[attempt.id][attempt.chart]['average'] * attempts[attempt.id][attempt.chart]['total']) +
|
|
attempt.points
|
|
) / (attempts[attempt.id][attempt.chart]['total'] + 1)
|
|
)
|
|
attempts[attempt.id][attempt.chart]['total'] += 1
|
|
|
|
if attempt.data.get_int('clear_type', self.CLEAR_TYPE_NO_PLAY) in [self.CLEAR_TYPE_NO_PLAY, self.CLEAR_TYPE_FAILED]:
|
|
# This attempt was a failure, so don't count it against clears of full combos
|
|
continue
|
|
|
|
# It was at least a clear
|
|
attempts[attempt.id][attempt.chart]['clears'] += 1
|
|
|
|
# Merge in remote attempts
|
|
for songid in remote_attempts:
|
|
if songid not in attempts:
|
|
attempts[songid] = {}
|
|
|
|
for songchart in remote_attempts[songid]:
|
|
if songchart not in attempts[songid]:
|
|
attempts[songid][songchart] = {
|
|
'total': 0,
|
|
'clears': 0,
|
|
'average': 0,
|
|
}
|
|
|
|
attempts[songid][songchart]['total'] += remote_attempts[songid][songchart]['plays']
|
|
attempts[songid][songchart]['clears'] += remote_attempts[songid][songchart]['clears']
|
|
|
|
return attempts
|
|
|
|
def update_score(
|
|
self,
|
|
userid: Optional[UserID],
|
|
songid: int,
|
|
chart: int,
|
|
points: int,
|
|
clear_type: int,
|
|
grade: int,
|
|
combo: int,
|
|
stats: Optional[Dict[str, int]]=None,
|
|
) -> None:
|
|
"""
|
|
Given various pieces of a score, update the user's high score and score
|
|
history in a controlled manner, so all games in SDVX series can expect
|
|
the same attributes in a score.
|
|
"""
|
|
# Range check clear type
|
|
if clear_type not in [
|
|
self.CLEAR_TYPE_NO_PLAY,
|
|
self.CLEAR_TYPE_FAILED,
|
|
self.CLEAR_TYPE_CLEAR,
|
|
self.CLEAR_TYPE_HARD_CLEAR,
|
|
self.CLEAR_TYPE_ULTIMATE_CHAIN,
|
|
self.CLEAR_TYPE_PERFECT_ULTIMATE_CHAIN,
|
|
]:
|
|
raise Exception(f"Invalid clear type value {clear_type}")
|
|
|
|
# Range check grade
|
|
if grade not in [
|
|
self.GRADE_NO_PLAY,
|
|
self.GRADE_D,
|
|
self.GRADE_C,
|
|
self.GRADE_B,
|
|
self.GRADE_A,
|
|
self.GRADE_A_PLUS,
|
|
self.GRADE_AA,
|
|
self.GRADE_AA_PLUS,
|
|
self.GRADE_AAA,
|
|
self.GRADE_AAA_PLUS,
|
|
self.GRADE_S,
|
|
]:
|
|
raise Exception(f"Invalid clear type value {grade}")
|
|
|
|
if userid is not None:
|
|
oldscore = self.data.local.music.get_score(
|
|
self.game,
|
|
self.version,
|
|
userid,
|
|
songid,
|
|
chart,
|
|
)
|
|
else:
|
|
oldscore = None
|
|
|
|
# Score history is verbatum, instead of highest score
|
|
history = ValidatedDict({})
|
|
oldpoints = points
|
|
|
|
if oldscore is None:
|
|
# If it is a new score, create a new dictionary to add to
|
|
scoredata = ValidatedDict({})
|
|
raised = True
|
|
highscore = True
|
|
else:
|
|
# Set the score to any new record achieved
|
|
raised = points > oldscore.points
|
|
highscore = points >= oldscore.points
|
|
points = max(oldscore.points, points)
|
|
scoredata = oldscore.data
|
|
|
|
# Replace clear type and grade
|
|
scoredata.replace_int('clear_type', max(scoredata.get_int('clear_type'), clear_type))
|
|
history.replace_int('clear_type', clear_type)
|
|
scoredata.replace_int('grade', max(scoredata.get_int('grade'), grade))
|
|
history.replace_int('grade', grade)
|
|
|
|
# If we have a combo, replace it
|
|
scoredata.replace_int('combo', max(scoredata.get_int('combo'), combo))
|
|
history.replace_int('combo', combo)
|
|
|
|
# If we have play stats, replace it
|
|
if stats is not None:
|
|
if raised:
|
|
# We have stats, and there's a new high score, update the stats
|
|
scoredata.replace_dict('stats', stats)
|
|
history.replace_dict('stats', stats)
|
|
|
|
# Look up where this score was earned
|
|
lid = self.get_machine_id()
|
|
|
|
if userid is not None:
|
|
# Write the new score back
|
|
self.data.local.music.put_score(
|
|
self.game,
|
|
self.version,
|
|
userid,
|
|
songid,
|
|
chart,
|
|
lid,
|
|
points,
|
|
scoredata,
|
|
highscore,
|
|
)
|
|
|
|
# Save the history of this score too
|
|
self.data.local.music.put_attempt(
|
|
self.game,
|
|
self.version,
|
|
userid,
|
|
songid,
|
|
chart,
|
|
lid,
|
|
oldpoints,
|
|
history,
|
|
raised,
|
|
)
|