350 lines
12 KiB
Python
350 lines
12 KiB
Python
# vim: set fileencoding=utf-8
|
|
from typing import Optional, List
|
|
from typing_extensions import Final
|
|
|
|
from bemani.backend.base import Base
|
|
from bemani.backend.core import CoreHandler, CardManagerHandler, PASELIHandler
|
|
from bemani.common import Model, Profile, ValidatedDict, GameConstants, DBConstants, Time
|
|
from bemani.data import Config, Data, Score, UserID, ScoreSaveException
|
|
from bemani.protocol import Node
|
|
|
|
|
|
class DDRBase(CoreHandler, CardManagerHandler, PASELIHandler, Base):
|
|
"""
|
|
Base game class for all DDR versions. Handles common functionality for getting
|
|
profiles based on refid, creating new profiles, looking up and saving scores.
|
|
"""
|
|
|
|
game: GameConstants = GameConstants.DDR
|
|
|
|
HALO_NONE: Final[int] = DBConstants.DDR_HALO_NONE
|
|
HALO_GOOD_FULL_COMBO: Final[int] = DBConstants.DDR_HALO_GOOD_FULL_COMBO
|
|
HALO_GREAT_FULL_COMBO: Final[int] = DBConstants.DDR_HALO_GREAT_FULL_COMBO
|
|
HALO_PERFECT_FULL_COMBO: Final[int] = DBConstants.DDR_HALO_PERFECT_FULL_COMBO
|
|
HALO_MARVELOUS_FULL_COMBO: Final[int] = DBConstants.DDR_HALO_MARVELOUS_FULL_COMBO
|
|
|
|
RANK_E: Final[int] = DBConstants.DDR_RANK_E
|
|
RANK_D: Final[int] = DBConstants.DDR_RANK_D
|
|
RANK_D_PLUS: Final[int] = DBConstants.DDR_RANK_D_PLUS
|
|
RANK_C_MINUS: Final[int] = DBConstants.DDR_RANK_C_MINUS
|
|
RANK_C: Final[int] = DBConstants.DDR_RANK_C
|
|
RANK_C_PLUS: Final[int] = DBConstants.DDR_RANK_C_PLUS
|
|
RANK_B_MINUS: Final[int] = DBConstants.DDR_RANK_B_MINUS
|
|
RANK_B: Final[int] = DBConstants.DDR_RANK_B
|
|
RANK_B_PLUS: Final[int] = DBConstants.DDR_RANK_B_PLUS
|
|
RANK_A_MINUS: Final[int] = DBConstants.DDR_RANK_A_MINUS
|
|
RANK_A: Final[int] = DBConstants.DDR_RANK_A
|
|
RANK_A_PLUS: Final[int] = DBConstants.DDR_RANK_A_PLUS
|
|
RANK_AA_MINUS: Final[int] = DBConstants.DDR_RANK_AA_MINUS
|
|
RANK_AA: Final[int] = DBConstants.DDR_RANK_AA
|
|
RANK_AA_PLUS: Final[int] = DBConstants.DDR_RANK_AA_PLUS
|
|
RANK_AAA: Final[int] = DBConstants.DDR_RANK_AAA
|
|
|
|
# These constants must agree with read.py for importing charts from the game.
|
|
CHART_SINGLE_BEGINNER: Final[int] = 0
|
|
CHART_SINGLE_BASIC: Final[int] = 1
|
|
CHART_SINGLE_DIFFICULT: Final[int] = 2
|
|
CHART_SINGLE_EXPERT: Final[int] = 3
|
|
CHART_SINGLE_CHALLENGE: Final[int] = 4
|
|
CHART_DOUBLE_BEGINNER: Final[int] = 5
|
|
CHART_DOUBLE_BASIC: Final[int] = 6
|
|
CHART_DOUBLE_DIFFICULT: Final[int] = 7
|
|
CHART_DOUBLE_EXPERT: Final[int] = 8
|
|
CHART_DOUBLE_CHALLENGE: Final[int] = 9
|
|
|
|
# Return the local2 service so that DDR Ace will send certain packets.
|
|
extra_services: List[str] = [
|
|
'local2',
|
|
]
|
|
|
|
def __init__(self, data: Data, config: Config, model: Model) -> None:
|
|
super().__init__(data, config, model)
|
|
if model.rev == 'X':
|
|
self.omnimix = True
|
|
else:
|
|
self.omnimix = False
|
|
|
|
@property
|
|
def music_version(self) -> int:
|
|
if self.omnimix:
|
|
return DBConstants.OMNIMIX_VERSION_BUMP + self.version
|
|
return self.version
|
|
|
|
def game_to_db_rank(self, game_rank: int) -> int:
|
|
"""
|
|
Given a game's rank constant, return the rank as defined above.
|
|
"""
|
|
raise Exception('Implement in sub-class!')
|
|
|
|
def db_to_game_rank(self, db_rank: int) -> int:
|
|
"""
|
|
Given a rank as defined above, return the game's rank constant.
|
|
"""
|
|
raise Exception('Implement in sub-class!')
|
|
|
|
def game_to_db_chart(self, game_chart: int) -> int:
|
|
"""
|
|
Given a game's chart for a song, return the chart as defined above.
|
|
"""
|
|
raise Exception('Implement in sub-class!')
|
|
|
|
def db_to_game_chart(self, db_chart: int) -> int:
|
|
"""
|
|
Given a chart as defined above, return the game's chart constant.
|
|
"""
|
|
raise Exception('Implement in sub-class!')
|
|
|
|
def game_to_db_halo(self, game_halo: int) -> int:
|
|
"""
|
|
Given a game's halo constant, return the halo as defined above.
|
|
"""
|
|
raise Exception('Implement in sub-class!')
|
|
|
|
def db_to_game_halo(self, db_halo: int) -> int:
|
|
"""
|
|
Given a halo as defined above, return the game's halo constant.
|
|
"""
|
|
raise Exception('Implement in sub-class!')
|
|
|
|
def previous_version(self) -> Optional['DDRBase']:
|
|
"""
|
|
Returns the previous version of the game, based on this game. Should
|
|
be overridden.
|
|
"""
|
|
return None
|
|
|
|
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 format_scores(self, userid: UserID, profile: Profile, scores: List[Score]) -> Node:
|
|
"""
|
|
Base handler for a score list. Given a userid, profile and a score list,
|
|
return a Node representing a score list. 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_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], area: Optional[int]) -> None:
|
|
"""
|
|
Given a RefID and a name/area, create a new profile.
|
|
"""
|
|
if refid is None:
|
|
return
|
|
userid = self.data.remote.user.from_refid(self.game, self.version, refid)
|
|
if userid is None:
|
|
return
|
|
|
|
defaultprofile = Profile(
|
|
self.game,
|
|
self.version,
|
|
refid,
|
|
0,
|
|
{
|
|
'name': name,
|
|
'area': area,
|
|
},
|
|
)
|
|
self.put_profile(userid, defaultprofile)
|
|
|
|
def put_profile_by_refid(self, refid: Optional[str], request: Node) -> None:
|
|
"""
|
|
Given a RefID and a request node, unformat the profile and save it.
|
|
"""
|
|
if refid is None:
|
|
return
|
|
userid = self.data.remote.user.from_refid(self.game, self.version, refid)
|
|
if userid is None:
|
|
return
|
|
|
|
oldprofile = self.get_profile(userid)
|
|
newprofile = self.unformat_profile(userid, request, oldprofile)
|
|
if newprofile is not None:
|
|
self.put_profile(userid, newprofile)
|
|
|
|
def update_score(
|
|
self,
|
|
userid: Optional[UserID],
|
|
songid: int,
|
|
chart: int,
|
|
points: int,
|
|
rank: int,
|
|
halo: int,
|
|
combo: int,
|
|
trace: Optional[List[int]]=None,
|
|
ghost: Optional[str]=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 DDR series can expect
|
|
the same attributes in a score.
|
|
"""
|
|
if chart not in [
|
|
self.CHART_SINGLE_BEGINNER,
|
|
self.CHART_SINGLE_BASIC,
|
|
self.CHART_SINGLE_DIFFICULT,
|
|
self.CHART_SINGLE_EXPERT,
|
|
self.CHART_SINGLE_CHALLENGE,
|
|
self.CHART_DOUBLE_BEGINNER,
|
|
self.CHART_DOUBLE_BASIC,
|
|
self.CHART_DOUBLE_DIFFICULT,
|
|
self.CHART_DOUBLE_EXPERT,
|
|
self.CHART_DOUBLE_CHALLENGE,
|
|
]:
|
|
raise Exception(f'Invalid chart {chart}')
|
|
if halo not in [
|
|
self.HALO_NONE,
|
|
self.HALO_GOOD_FULL_COMBO,
|
|
self.HALO_GREAT_FULL_COMBO,
|
|
self.HALO_PERFECT_FULL_COMBO,
|
|
self.HALO_MARVELOUS_FULL_COMBO,
|
|
]:
|
|
raise Exception(f'Invalid halo {halo}')
|
|
if rank not in [
|
|
self.RANK_E,
|
|
self.RANK_D,
|
|
self.RANK_D_PLUS,
|
|
self.RANK_C_MINUS,
|
|
self.RANK_C,
|
|
self.RANK_C_PLUS,
|
|
self.RANK_B_MINUS,
|
|
self.RANK_B,
|
|
self.RANK_B_PLUS,
|
|
self.RANK_A_MINUS,
|
|
self.RANK_A,
|
|
self.RANK_A_PLUS,
|
|
self.RANK_AA_MINUS,
|
|
self.RANK_AA,
|
|
self.RANK_AA_PLUS,
|
|
self.RANK_AAA,
|
|
]:
|
|
raise Exception(f'Invalid rank {rank}')
|
|
|
|
if userid is not None:
|
|
oldscore = self.data.local.music.get_score(
|
|
self.game,
|
|
self.music_version,
|
|
userid,
|
|
songid,
|
|
chart,
|
|
)
|
|
else:
|
|
oldscore = None
|
|
|
|
# Score history is verbatum, instead of highest score
|
|
now = Time.now()
|
|
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
|
|
|
|
# Save combo
|
|
history.replace_int('combo', combo)
|
|
scoredata.replace_int('combo', max(scoredata.get_int('combo'), combo))
|
|
|
|
# Save halo
|
|
history.replace_int('halo', halo)
|
|
scoredata.replace_int('halo', max(scoredata.get_int('halo'), halo))
|
|
|
|
# Save rank
|
|
history.replace_int('rank', rank)
|
|
scoredata.replace_int('rank', max(scoredata.get_int('rank'), rank))
|
|
|
|
# Save ghost steps
|
|
if trace is not None:
|
|
history.replace_int_array('trace', len(trace), trace)
|
|
if raised:
|
|
scoredata.replace_int_array('trace', len(trace), trace)
|
|
if ghost is not None:
|
|
history.replace_str('ghost', ghost)
|
|
if raised:
|
|
scoredata.replace_str('ghost', ghost)
|
|
|
|
# Look up where this score was earned
|
|
lid = self.get_machine_id()
|
|
|
|
# DDR sometimes happens to send all songs that were played by a player
|
|
# at the end of the round. It sends timestamps for the songs, but as of
|
|
# Colette they were identical for each song in the round. So, if a user
|
|
# plays the same song/chart# more than once in a round, we will end up
|
|
# failing to store the attempt since we don't allow two of the same
|
|
# attempt at the same time for the same user and song/chart. So, bump
|
|
# the timestamp by one second and retry well past the maximum number of
|
|
# songs.
|
|
for bump in range(10):
|
|
timestamp = now + bump
|
|
|
|
if userid is not None:
|
|
# Write the new score back
|
|
self.data.local.music.put_score(
|
|
self.game,
|
|
self.music_version,
|
|
userid,
|
|
songid,
|
|
chart,
|
|
lid,
|
|
points,
|
|
scoredata,
|
|
highscore,
|
|
timestamp=timestamp,
|
|
)
|
|
|
|
try:
|
|
# Save the history of this score too
|
|
self.data.local.music.put_attempt(
|
|
self.game,
|
|
self.music_version,
|
|
userid,
|
|
songid,
|
|
chart,
|
|
lid,
|
|
oldpoints,
|
|
history,
|
|
raised,
|
|
timestamp=timestamp,
|
|
)
|
|
except ScoreSaveException:
|
|
# Try again one second in the future
|
|
continue
|
|
|
|
# We saved successfully
|
|
break
|