# 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