# vim: set fileencoding=utf-8 import math import random from typing import Optional, Dict, List, Any, Set, Tuple from typing_extensions import Final from bemani.backend.base import Status from bemani.backend.jubeat.common import ( JubeatDemodataGetHitchartHandler, JubeatDemodataGetNewsHandler, JubeatGamendRegisterHandler, JubeatGametopGetMeetingHandler, JubeatLobbyCheckHandler, JubeatLoggerReportHandler, ) from bemani.backend.jubeat.course import JubeatCourse from bemani.backend.jubeat.base import JubeatBase from bemani.backend.jubeat.saucerfulfill import JubeatSaucerFulfill from bemani.common import Profile, ValidatedDict, VersionConstants, Time from bemani.data import Data, Score, UserID from bemani.protocol import Node class JubeatProp( JubeatDemodataGetHitchartHandler, JubeatDemodataGetNewsHandler, JubeatGamendRegisterHandler, JubeatGametopGetMeetingHandler, JubeatLobbyCheckHandler, JubeatLoggerReportHandler, JubeatCourse, JubeatBase, ): name: str = 'Jubeat Prop' version: int = VersionConstants.JUBEAT_PROP GAME_COURSE_REQUIREMENT_SCORE: Final[int] = 1 GAME_COURSE_REQUIREMENT_FULL_COMBO: Final[int] = 2 GAME_COURSE_REQUIREMENT_PERFECT_PERCENT: Final[int] = 3 GAME_COURSE_RATING_FAILED: Final[int] = 1 GAME_COURSE_RATING_BRONZE: Final[int] = 2 GAME_COURSE_RATING_SILVER: Final[int] = 3 GAME_COURSE_RATING_GOLD: Final[int] = 4 JBOX_EMBLEM_NORMAL: Final[int] = 1 JBOX_EMBLEM_PREMIUM: Final[int] = 2 EVENTS: Dict[int, Dict[str, bool]] = { 5: { 'enabled': False, }, 6: { 'enabled': False, }, 9: { 'enabled': False, }, 14: { 'enabled': False, }, 15: { 'enabled': False, }, 16: { 'enabled': False, }, 17: { 'enabled': False, }, 18: { 'enabled': False, }, 19: { 'enabled': False, }, } def previous_version(self) -> Optional[JubeatBase]: return JubeatSaucerFulfill(self.data, self.config, self.model) @classmethod def __class_to_rank(cls, cur_class: int, cur_subclass: int) -> int: """ Given a class and subclass, return an integer rank for that class. Class mapping is as follows: 1 - Amateur 2 - Regular 3 - Master 4 - Legend Subclass ranges from 1 to 5, except on Legend where it is 1 only. """ if cur_subclass > 5: cur_subclass = 5 if cur_subclass < 1: cur_subclass = 1 if cur_class > 4: cur_class = 4 if cur_class < 1: cur_class = 1 lut = { 1: { 5: 0, 4: 1, 3: 2, 2: 3, 1: 4, }, 2: { 5: 5, 4: 6, 3: 7, 2: 8, 1: 9, }, 3: { 5: 10, 4: 11, 3: 12, 2: 13, 1: 14, }, # Legend only has one sub-class value (1), so to make range checks # easier, just map all 5 possible values to the same integer. 4: { 5: 15, 4: 15, 3: 15, 2: 15, 1: 15, }, } return lut[cur_class][cur_subclass] @classmethod def __rank_to_class(cls, rank: int) -> Tuple[int, int]: """ Given a rank, return a tuple representing class, subclass. This function is the inverse of __class_to_rank. """ if rank < 0: rank = 0 if rank > 15: rank = 15 lut = { 0: (1, 5), 1: (1, 4), 2: (1, 3), 3: (1, 2), 4: (1, 1), 5: (2, 5), 6: (2, 4), 7: (2, 3), 8: (2, 2), 9: (2, 1), 10: (3, 5), 11: (3, 4), 12: (3, 3), 13: (3, 2), 14: (3, 1), 15: (4, 1), } return lut[rank] @classmethod def _increment_class(cls, cur_class: int, cur_subclass: int) -> Tuple[int, int]: """ Given a class and subclass, return a tuple representing the next class/subclass if we were to be promoted. """ return cls.__rank_to_class(cls.__class_to_rank(cur_class, cur_subclass) + 1) @classmethod def _decrement_class(cls, cur_class: int, cur_subclass: int) -> Tuple[int, int]: """ Given a class and subclass, return a tuple representing the previous class/subclass if we were to be demoted. """ return cls.__rank_to_class(cls.__class_to_rank(cur_class, cur_subclass) - 1) @classmethod def _get_league_buckets(cls, scores: List[Tuple[UserID, int]]) -> Tuple[List[UserID], List[UserID], List[UserID]]: """ Given a list of userid, score tuples, return a tuple containing three lists. The first list is the top 30% scorer IDs, the next list is the middle 40% scorer IDs, and the final list is the bottom 30% scorer IDs. """ sorted_scores = sorted(scores, key=lambda x: x[1], reverse=True) # Top 30% get promoted promoted_amount = math.ceil(len(sorted_scores) * 0.3) promotions = [x[0] for x in sorted_scores[:promoted_amount]] rest = sorted_scores[promoted_amount:] # Bottom 30% get demoted (this is bottom 3/7 of the rest) demoted_amount = math.ceil(len(rest) * 0.42) demotions = [x[0] for x in rest[-demoted_amount:]] neutrals = [x[0] for x in rest[:-demoted_amount]] return (promotions, neutrals, demotions) @classmethod def _get_league_scores(cls, data: Data, current_id: int, profiles: List[Tuple[UserID, Profile]]) -> Tuple[List[Tuple[UserID, int]], List[UserID]]: """ Given the current League ID (calculated based on the date range) and a list of all user profiles for this game/version, return a uple containing two lists. The first list should contain tuples where the first integer is a user ID and the second integer is the user's total score for last week's course. The second list is a list of user IDs that did not participate last week but have played this game at some point. """ last_id = current_id - 1 scores = [] absentees = [] for [userid, _player] in profiles: # Look up scores for last week if they played league_score = data.local.user.get_achievement( cls.game, cls.version, userid, last_id, 'league', ) # If they played, grab their total score so we can figure out if we should # promote, demote or leave alone if league_score is not None: scores.append(( userid, league_score['score'][0] + league_score['score'][1] + league_score['score'][2], )) else: absentees.append(userid) return scores, absentees @classmethod def _get_league_absentees(cls, data: Data, current_id: int, absentees: List[UserID]) -> List[UserID]: """ Given a list of user IDs that didn't play for some number of weeks, return a subset of those IDs that have been absent enough weeks to get a demotion. Demotions happen for every two weeks without play. """ delinquents = [] for userid in absentees: # Figure out the last time they played, if its an even boundary # and at least 2 weeks back, demote them (one demotion for every # two weeks not played). last_league_id = 0 for achievement in data.local.user.get_achievements( cls.game, cls.version, userid, ): if achievement.type == 'league': last_league_id = max(achievement.id, last_league_id) if last_league_id != 0: # If they played mid-week two IDs ago, that's not quite # two weeks back, so adjust by one. weeks_different = (current_id - last_league_id) - 1 if weeks_different >= 2 and weeks_different % 2 == 0: # It's been at least two weeks (or four, or six), which means # there have been two weeks since the last time we did this, # demote this person. delinquents.append(userid) return delinquents @classmethod def _modify_profile(cls, data: Data, userid: UserID, direction: str) -> None: """ Given a user ID and a direction (promote or demote), load the user's profile, make the necessary promotion/demotion, and set the profile to notify the user on next play that they have lost/gained rank. If the user still hasn't checked their rank since last time we changed it, make sure they know about multiple promotions/demotions. """ profile = data.local.user.get_profile(cls.game, cls.version, userid) cur_class = profile.get_int('league_class', 1) cur_subclass = profile.get_int('league_subclass', 5) if direction == 'promote': new_class, new_subclass = cls._increment_class(cur_class, cur_subclass) elif direction == 'demote': new_class, new_subclass = cls._decrement_class(cur_class, cur_subclass) else: raise Exception(f'Logic error, unknown direction {direction}!') if new_class != cur_class or new_subclass != cur_subclass: # If they've checked last time, set up the new old class. if profile.get_bool('league_is_checked'): last = profile.get_dict('last') last.replace_int('league_class', cur_class) last.replace_int('league_subclass', cur_subclass) profile.replace_dict('last', last) # We actually changed a level, let the user know! profile.replace_int('league_class', new_class) profile.replace_int('league_subclass', new_subclass) profile.replace_bool('league_is_checked', False) data.local.user.put_profile(cls.game, cls.version, userid, profile) @classmethod def run_scheduled_work(cls, data: Data, config: Dict[str, Any]) -> List[Tuple[str, Dict[str, Any]]]: """ Once a week, insert a new league course. Every day, insert new FC challenge courses. """ events = [] if data.local.network.should_schedule(cls.game, cls.version, 'league_course', 'weekly'): # Generate a new league course list, save it to the DB. start_time, end_time = data.local.network.get_schedule_duration('weekly') all_songs = set(song.id for song in data.local.music.get_all_songs(cls.game, cls.version)) if len(all_songs) >= 3: league_songs = random.sample(all_songs, 3) data.local.game.put_time_sensitive_settings( cls.game, cls.version, 'league', { 'start_time': start_time, 'end_time': end_time, 'music': league_songs, }, ) events.append(( 'jubeat_league_course', { 'version': cls.version, 'songs': league_songs, }, )) # League ID for the current league we just added. leagueid = int(start_time / 604800) # Evaluate player scores on previous courses and find players # that didn't play last week. all_profiles = data.local.user.get_all_profiles(cls.game, cls.version) scores, absentees = cls._get_league_scores(data, leagueid, all_profiles) # Get user IDs to promote, demote and ignore based on scores. promote, ignore, demote = cls._get_league_buckets(scores) demote.extend(cls._get_league_absentees(data, leagueid, absentees)) # Actually modify the profiles so the game knows to tell the user. for userid in promote: cls._modify_profile(data, userid, 'promote') for userid in demote: cls._modify_profile(data, userid, 'demote') # Mark that we did some actual work here. data.local.network.mark_scheduled(cls.game, cls.version, 'league_course', 'weekly') if data.local.network.should_schedule(cls.game, cls.version, 'fc_challenge', 'daily'): # Generate a new list of two FC challenge songs. start_time, end_time = data.local.network.get_schedule_duration('daily') all_songs = set(song.id for song in data.local.music.get_all_songs(cls.game, cls.version)) if len(all_songs) >= 2: daily_songs = random.sample(all_songs, 2) data.local.game.put_time_sensitive_settings( cls.game, cls.version, 'fc_challenge', { 'start_time': start_time, 'end_time': end_time, 'today': daily_songs[0], 'whim': daily_songs[1], }, ) events.append(( 'jubeat_fc_challenge_charts', { 'version': cls.version, 'today': daily_songs[0], 'whim': daily_songs[1], }, )) # Mark that we did some actual work here. data.local.network.mark_scheduled(cls.game, cls.version, 'fc_challenge', 'daily') return events def __get_global_info(self) -> Node: info = Node.void('info') # Event info. Valid event IDs are 5, 6, 9, 14, 15, 16, 17, 18, 19 event_info = Node.void('event_info') info.add_child(event_info) for event in self.EVENTS: evt = Node.void('event') event_info.add_child(evt) evt.set_attribute('type', str(event)) evt.add_child(Node.u8('state', 0x1 if self.EVENTS[event]['enabled'] else 0x0)) # Each of the following three sections should have zero or more child nodes (no # particular name) which look like the following: # # songid # start time? # end time? # # Share music? share_music = Node.void('share_music') info.add_child(share_music) # Bonus music? bonus_music = Node.void('bonus_music') info.add_child(bonus_music) # Only now music? only_now_music = Node.void('only_now_music') info.add_child(only_now_music) # Full combo challenge? entry = self.data.local.game.get_time_sensitive_settings(self.game, self.version, 'fc_challenge') if entry is None: entry = ValidatedDict() fc_challenge = Node.void('fc_challenge') info.add_child(fc_challenge) today = Node.void('today') fc_challenge.add_child(today) today.add_child(Node.s32('music_id', entry.get_int('today', -1))) # Some sort of music DB whitelist info.add_child(Node.s32_array( 'white_music_list', [ -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, ], )) info.add_child(Node.s32_array( 'open_music_list', [ -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, ], )) cabinet_survey = Node.void('cabinet_survey') info.add_child(cabinet_survey) cabinet_survey.add_child(Node.s32('id', -1)) cabinet_survey.add_child(Node.s32('status', 0)) kaitou_bisco = Node.void('kaitou_bisco') info.add_child(kaitou_bisco) kaitou_bisco.add_child(Node.s32('remaining_days', 0)) league = Node.void('league') info.add_child(league) league.add_child(Node.u8('status', 1)) bistro = Node.void('bistro') info.add_child(bistro) bistro.add_child(Node.u16('bistro_id', 0)) jbox = Node.void('jbox') info.add_child(jbox) jbox.add_child(Node.s32('point', 0)) emblem = Node.void('emblem') jbox.add_child(emblem) normal = Node.void('normal') emblem.add_child(normal) premium = Node.void('premium') emblem.add_child(premium) normal.add_child(Node.s16('index', 2)) premium.add_child(Node.s16('index', 1)) return info def handle_shopinfo_regist_request(self, request: Node) -> Node: # Update the name of this cab for admin purposes self.update_machine_name(request.child_value('shop/name')) shopinfo = Node.void('shopinfo') data = Node.void('data') shopinfo.add_child(data) data.add_child(Node.u32('cabid', 1)) data.add_child(Node.string('locationid', 'nowhere')) data.add_child(Node.u8('tax_phase', 1)) facility = Node.void('facility') data.add_child(facility) facility.add_child(Node.u32('exist', 1)) data.add_child(self.__get_global_info()) return shopinfo def handle_gametop_regist_request(self, request: Node) -> Node: data = request.child('data') player = data.child('player') refid = player.child_value('refid') name = player.child_value('name') root = self.new_profile_by_refid(refid, name) return root def handle_gametop_get_pdata_request(self, request: Node) -> Node: data = request.child('data') player = data.child('player') refid = player.child_value('refid') root = self.get_profile_by_refid(refid) if root is None: root = Node.void('gametop') root.set_attribute('status', str(Status.NO_PROFILE)) return root def handle_gametop_get_mdata_request(self, request: Node) -> Node: data = request.child('data') player = data.child('player') extid = player.child_value('jid') mdata_ver = player.child_value('mdata_ver') # Game requests mdata 3 times per profile for some reason if mdata_ver != 1: root = Node.void('gametop') datanode = Node.void('data') root.add_child(datanode) player = Node.void('player') datanode.add_child(player) player.add_child(Node.s32('jid', extid)) playdata = Node.void('mdata_list') player.add_child(playdata) return root root = self.get_scores_by_extid(extid) if root is None: root = Node.void('gametop') root.set_attribute('status', str(Status.NO_PROFILE)) return root def handle_gametop_get_info_request(self, request: Node) -> Node: root = Node.void('gametop') data = Node.void('data') root.add_child(data) data.add_child(self.__get_global_info()) return root def handle_gametop_get_course_request(self, request: Node) -> Node: data = request.child('data') player = data.child('player') extid = player.child_value('jid') gametop = Node.void('gametop') data = Node.void('data') gametop.add_child(data) # Course list available course_list = Node.void('course_list') data.add_child(course_list) validcourses: List[int] = [] courses = self.get_all_courses() courses.extend([ { 'id': 31, 'name': 'Enjoy! The 5th KAC ~ tracks of prop ~', 'level': 5, 'music': [ (60000065, 1), (60000008, 1), (60000001, 1), (60001009, 1), (60000010, 1), ], 'requirements': { self.COURSE_REQUIREMENT_SCORE: [900000, 950000, 980000], self.COURSE_REQUIREMENT_FULL_COMBO: [1, 2, 4], }, }, { 'id': 32, 'name': 'Challenge! The 5th KAC ~ tracks of prop ~', 'level': 7, 'music': [ (60000065, 2), (60000008, 2), (60000001, 2), (60001009, 2), (60000010, 2), ], 'requirements': { self.COURSE_REQUIREMENT_SCORE: [900000, 950000, 980000], }, }, { 'id': 33, 'name': 'The 5th KAC ~ tracks of prop ~', 'level': 10, 'music': [ (60000065, 2), (60000008, 2), (60000001, 2), (60001009, 2), (60000010, 2), ], 'requirements': { self.COURSE_REQUIREMENT_SCORE: [920000, 950000, 980000], }, }, ]) for course in courses: coursenode = Node.void('course') course_list.add_child(coursenode) # Basic course info if course['id'] in validcourses: raise Exception('Cannot have same course ID specified twice!') validcourses.append(course['id']) coursenode.add_child(Node.s32('id', course['id'])) coursenode.add_child(Node.string('name', course['name'])) coursenode.add_child(Node.u8('level', course['level'])) # Translate internal to game def translate_req(internal_req: int) -> int: return { self.COURSE_REQUIREMENT_SCORE: self.GAME_COURSE_REQUIREMENT_SCORE, self.COURSE_REQUIREMENT_FULL_COMBO: self.GAME_COURSE_REQUIREMENT_FULL_COMBO, self.COURSE_REQUIREMENT_PERFECT_PERCENT: self.GAME_COURSE_REQUIREMENT_PERFECT_PERCENT, }.get(internal_req, 0) # Course bronze/silver/gold rules ids = [0] * 3 bronze_values = [0] * 3 silver_values = [0] * 3 gold_values = [0] * 3 slot = 0 for req in course['requirements']: req_values = course['requirements'][req] ids[slot] = translate_req(req) bronze_values[slot] = req_values[0] silver_values[slot] = req_values[1] gold_values[slot] = req_values[2] slot = slot + 1 norma = Node.void('norma') coursenode.add_child(norma) norma.add_child(Node.s32_array('norma_id', ids)) norma.add_child(Node.s32_array('bronze_value', bronze_values)) norma.add_child(Node.s32_array('silver_value', silver_values)) norma.add_child(Node.s32_array('gold_value', gold_values)) # Music list for course music_index = 0 music_list = Node.void('music_list') coursenode.add_child(music_list) for entry in course['music']: music = Node.void('music') music.set_attribute('index', str(music_index)) music_list.add_child(music) music.add_child(Node.s32('music_id', entry[0])) music.add_child(Node.u8('seq', entry[1])) music_index = music_index + 1 # Look up profile so we can load the last course played userid = self.data.remote.user.from_extid(self.game, self.version, extid) profile = self.get_profile(userid) if profile is None: profile = Profile(self.game, self.version, "", extid) # Player scores for courses player_list = Node.void('player_list') data.add_child(player_list) player = Node.void('player') player_list.add_child(player) player.add_child(Node.s32('jid', extid)) result_list = Node.void('result_list') player.add_child(result_list) playercourses = self.get_courses(userid) for courseid in playercourses: if courseid not in validcourses: continue rating = { self.COURSE_RATING_FAILED: self.GAME_COURSE_RATING_FAILED, self.COURSE_RATING_BRONZE: self.GAME_COURSE_RATING_BRONZE, self.COURSE_RATING_SILVER: self.GAME_COURSE_RATING_SILVER, self.COURSE_RATING_GOLD: self.GAME_COURSE_RATING_GOLD, }[playercourses[courseid]['rating']] scores = playercourses[courseid]['scores'] result = Node.void('result') result_list.add_child(result) result.add_child(Node.s32('id', courseid)) result.add_child(Node.u8('rating', rating)) result.add_child(Node.s32_array('score', scores)) # Last course ID data.add_child(Node.s32('last_course_id', profile.get_dict('last').get_int('last_course_id', -1))) return gametop def handle_gametop_get_league_request(self, request: Node) -> Node: data = request.child('data') player = data.child('player') extid = player.child_value('jid') # Look up profile so we can load the last course played userid = self.data.remote.user.from_extid(self.game, self.version, extid) profile = self.get_profile(userid) if profile is None: profile = Profile(self.game, self.version, "", extid) gametop = Node.void('gametop') data = Node.void('data') gametop.add_child(data) league_list = Node.void('league_list') data.add_child(league_list) # Look up the current league charts in the DB entry = self.data.local.game.get_time_sensitive_settings(self.game, self.version, 'league') if entry is not None: # Just get the week number, use that as the ID leagueid = int(entry['start_time'] / 604800) league = Node.void('league') league_list.add_child(league) league.set_attribute('index', '0') league.add_child(Node.s32('id', leagueid)) league.add_child(Node.u64('stime', entry['start_time'] * 1000)) league.add_child(Node.u64('etime', entry['end_time'] * 1000)) music_list = Node.void('music_list') league.add_child(music_list) # We need to know the player class so we can determine what chart to present. current_class = profile.get_int('league_class', 1) song_index = 0 for song in entry['music']: music = Node.void('music') music_list.add_child(music) music.set_attribute('index', str(song_index)) song_index = song_index + 1 music.add_child(Node.s32('music_id', song)) music.add_child(Node.u8('seq', 1 if current_class == 1 else 2)) player_list = Node.void('player_list') league.add_child(player_list) player = Node.void('player') player_list.add_child(player) player.add_child(Node.s32('jid', extid)) result = Node.void('result') player.add_child(result) league_score = self.data.local.user.get_achievement(self.game, self.version, userid, leagueid, 'league') if league_score is None: league_score = ValidatedDict() result.add_child(Node.s32_array('score', league_score.get_int_array('score', 3, [0] * 3))) result.add_child(Node.s8_array('clear', league_score.get_int_array('clear', 3, [0] * 3))) data.add_child(Node.s32('last_class', profile.get_dict('last').get_int('league_class', 1))) data.add_child(Node.s32('last_subclass', profile.get_dict('last').get_int('league_subclass', 5))) data.add_child(Node.bool('is_checked', profile.get_bool('league_is_checked'))) return gametop def format_profile(self, userid: UserID, profile: Profile) -> Node: root = Node.void('gametop') data = Node.void('data') root.add_child(data) # Jubeat Prop appears to allow full event overrides per-player data.add_child(self.__get_global_info()) player = Node.void('player') data.add_child(player) # Basic profile info player.add_child(Node.string('name', profile.get_str('name', 'なし'))) player.add_child(Node.s32('jid', profile.extid)) # Miscelaneous crap player.add_child(Node.s32('session_id', 1)) player.add_child(Node.u64('event_flag', 0)) # Player info and statistics info = Node.void('info') player.add_child(info) info.add_child(Node.s16('jubility', profile.get_int('jubility'))) info.add_child(Node.s16('jubility_yday', profile.get_int('jubility_yday'))) info.add_child(Node.s32('tune_cnt', profile.get_int('tune_cnt'))) info.add_child(Node.s32('save_cnt', profile.get_int('save_cnt'))) info.add_child(Node.s32('saved_cnt', profile.get_int('saved_cnt'))) info.add_child(Node.s32('fc_cnt', profile.get_int('fc_cnt'))) info.add_child(Node.s32('ex_cnt', profile.get_int('ex_cnt'))) info.add_child(Node.s32('clear_cnt', profile.get_int('clear_cnt'))) info.add_child(Node.s32('pf_cnt', profile.get_int('pf_cnt'))) info.add_child(Node.s32('match_cnt', profile.get_int('match_cnt'))) info.add_child(Node.s32('beat_cnt', profile.get_int('beat_cnt'))) info.add_child(Node.s32('mynews_cnt', profile.get_int('mynews_cnt'))) info.add_child(Node.s32('bonus_tune_points', profile.get_int('bonus_tune_points'))) info.add_child(Node.bool('is_bonus_tune_played', profile.get_bool('is_bonus_tune_played'))) # Looks to be set to true when there's an old profile, stops tutorial from # happening on first load. info.add_child(Node.bool('inherit', profile.get_bool('has_old_version') and not profile.get_bool('saved'))) # Not saved, but loaded info.add_child(Node.s32('mtg_entry_cnt', 123)) info.add_child(Node.s32('mtg_hold_cnt', 456)) info.add_child(Node.u8('mtg_result', 10)) # Last played data, for showing cursor and such lastdict = profile.get_dict('last') last = Node.void('last') player.add_child(last) last.add_child(Node.s64('play_time', lastdict.get_int('play_time'))) last.add_child(Node.string('shopname', lastdict.get_str('shopname'))) last.add_child(Node.string('areaname', lastdict.get_str('areaname'))) last.add_child(Node.s8('expert_option', lastdict.get_int('expert_option'))) last.add_child(Node.s8('category', lastdict.get_int('category'))) last.add_child(Node.s8('sort', lastdict.get_int('sort'))) last.add_child(Node.s32('music_id', lastdict.get_int('music_id'))) last.add_child(Node.s8('seq_id', lastdict.get_int('seq_id'))) settings = Node.void('settings') last.add_child(settings) settings.add_child(Node.s8('marker', lastdict.get_int('marker'))) settings.add_child(Node.s8('theme', lastdict.get_int('theme'))) settings.add_child(Node.s16('title', lastdict.get_int('title'))) settings.add_child(Node.s16('parts', lastdict.get_int('parts'))) settings.add_child(Node.s8('rank_sort', lastdict.get_int('rank_sort'))) settings.add_child(Node.s8('combo_disp', lastdict.get_int('combo_disp'))) settings.add_child(Node.s16_array('emblem', lastdict.get_int_array('emblem', 5))) settings.add_child(Node.s8('matching', lastdict.get_int('matching'))) settings.add_child(Node.s8('hazard', lastdict.get_int('hazard'))) settings.add_child(Node.s8('hard', lastdict.get_int('hard'))) # Secret unlocks item = Node.void('item') player.add_child(item) item.add_child(Node.s32_array('music_list', profile.get_int_array('music_list', 32, [-1] * 32))) item.add_child(Node.s32_array('secret_list', profile.get_int_array('secret_list', 32, [-1] * 32))) item.add_child(Node.s16('theme_list', profile.get_int('theme_list', -1))) item.add_child(Node.s32_array('marker_list', profile.get_int_array('marker_list', 2, [-1] * 2))) item.add_child(Node.s32_array('title_list', profile.get_int_array('title_list', 160, [-1] * 160))) item.add_child(Node.s32_array('parts_list', profile.get_int_array('parts_list', 160, [-1] * 160))) item.add_child(Node.s32_array('emblem_list', profile.get_int_array('emblem_list', 96, [-1] * 96))) new = Node.void('new') item.add_child(new) new.add_child(Node.s32_array('secret_list', profile.get_int_array('secret_list_new', 32, [-1] * 32))) new.add_child(Node.s16('theme_list', profile.get_int('theme_list_new', -1))) new.add_child(Node.s32_array('marker_list', profile.get_int_array('marker_list_new', 2, [-1] * 2))) # Sane defaults for unknown/who cares nodes history = Node.void('history') player.add_child(history) history.set_attribute('count', '0') lab_edit_seq = Node.void('lab_edit_seq') player.add_child(lab_edit_seq) lab_edit_seq.set_attribute('count', '0') cabinet_survey = Node.void('cabinet_survey') player.add_child(cabinet_survey) cabinet_survey.add_child(Node.u32('read_flag', 0)) kaitou_bisco = Node.void('kaitou_bisco') player.add_child(kaitou_bisco) kaitou_bisco.add_child(Node.u32('read_flag', profile.get_int('kaitou_bisco_read_flag'))) navi = Node.void('navi') player.add_child(navi) navi.add_child(Node.u32('flag', profile.get_int('navi_flag'))) # Player status for events event_info = Node.void('event_info') player.add_child(event_info) achievements = self.data.local.user.get_achievements(self.game, self.version, userid) for achievement in achievements: if achievement.type == 'event': # There are two significant bits here, 0x1 and 0x2, I think the first # one is whether the event is started, second is if its finished? event = Node.void('event') event_info.add_child(event) event.set_attribute('type', str(achievement.id)) state = 0x0 state = state + 0x2 if achievement.data.get_bool('is_completed') else 0x0 event.add_child(Node.u8('state', state)) # Full combo challenge entry = self.data.local.game.get_time_sensitive_settings(self.game, self.version, 'fc_challenge') if entry is None: entry = ValidatedDict() # Figure out if we've played these songs start_time, end_time = self.data.local.network.get_schedule_duration('daily') today_attempts = self.data.local.music.get_all_attempts(self.game, self.music_version, userid, entry.get_int('today', -1), timelimit=start_time) whim_attempts = self.data.local.music.get_all_attempts(self.game, self.music_version, userid, entry.get_int('whim', -1), timelimit=start_time) fc_challenge = Node.void('fc_challenge') player.add_child(fc_challenge) today = Node.void('today') fc_challenge.add_child(today) today.add_child(Node.s32('music_id', entry.get_int('today', -1))) today.add_child(Node.u8('state', 0x40 if len(today_attempts) > 0 else 0x0)) whim = Node.void('whim') fc_challenge.add_child(whim) whim.add_child(Node.s32('music_id', entry.get_int('whim', -1))) whim.add_child(Node.u8('state', 0x40 if len(whim_attempts) > 0 else 0x0)) # No news, ever. news = Node.void('news') player.add_child(news) news.add_child(Node.s16('checked', 0)) news.add_child(Node.u32('checked_flag', 0)) # Add rivals to profile. rivallist = Node.void('rivallist') player.add_child(rivallist) links = self.data.local.user.get_links(self.game, self.version, userid) rivalcount = 0 for link in links: if link.type != 'rival': continue rprofile = self.get_profile(link.other_userid) if rprofile is None: continue rival = Node.void('rival') rivallist.add_child(rival) rival.add_child(Node.s32('jid', rprofile.extid)) rival.add_child(Node.string('name', rprofile.get_str('name'))) rcareerdict = rprofile.get_dict('career') career = Node.void('career') rival.add_child(career) career.add_child(Node.s16('level', rcareerdict.get_int('level', 1))) league = Node.void('league') rival.add_child(league) league.add_child(Node.bool('is_first_play', rprofile.get_bool('league_is_first_play', True))) league.add_child(Node.s32('class', rprofile.get_int('league_class', 1))) league.add_child(Node.s32('subclass', rprofile.get_int('league_subclass', 5))) # Lazy way of keeping track of rivals, since we can only have 3 # or the game with throw up. rivalcount += 1 if rivalcount >= 3: break rivallist.set_attribute('count', str(rivalcount)) # Nothing in life is free, WTF? free_first_play = Node.void('free_first_play') player.add_child(free_first_play) free_first_play.add_child(Node.bool('is_available', False)) free_first_play.add_child(Node.s32('point', 0)) free_first_play.add_child(Node.s32('point_used', 0)) come_come_jbox = Node.void('come_come_jbox') free_first_play.add_child(come_come_jbox) come_come_jbox.add_child(Node.bool('is_valid', False)) come_come_jbox.add_child(Node.s64('end_time_if_paired', 0)) # JBox stuff jbox = Node.void('jbox') jboxdict = profile.get_dict('jbox') player.add_child(jbox) jbox.add_child(Node.s32('point', jboxdict.get_int('point'))) emblem = Node.void('emblem') jbox.add_child(emblem) normal = Node.void('normal') emblem.add_child(normal) premium = Node.void('premium') emblem.add_child(premium) # Calculate a random index for normal and premium to give to player # as a gatcha. gameitems = self.data.local.game.get_items(self.game, self.version) normalemblems: Set[int] = set() premiumemblems: Set[int] = set() for gameitem in gameitems: if gameitem.type == 'emblem': if gameitem.data.get_int('rarity') in {1, 2, 3}: normalemblems.add(gameitem.id) if gameitem.data.get_int('rarity') in {3, 4, 5}: premiumemblems.add(gameitem.id) # Default to some emblems in case the catalog is not available. normalindex = 2 premiumindex = 1 if normalemblems: normalindex = random.sample(normalemblems, 1)[0] if premiumemblems: premiumindex = random.sample(premiumemblems, 1)[0] normal.add_child(Node.s16('index', normalindex)) premium.add_child(Node.s16('index', premiumindex)) # Career stuff career = Node.void('career') careerdict = profile.get_dict('career') player.add_child(career) career.add_child(Node.s16('level', careerdict.get_int('level', 1))) career.add_child(Node.s32('point', careerdict.get_int('point'))) career.add_child(Node.s32_array('param', careerdict.get_int_array('param', 10, [-1] * 10))) career.add_child(Node.bool('is_unlocked', careerdict.get_bool('is_unlocked'))) # League stuff league = Node.void('league') player.add_child(league) league.add_child(Node.bool('is_first_play', profile.get_bool('league_is_first_play', True))) league.add_child(Node.s32('class', profile.get_int('league_class', 1))) league.add_child(Node.s32('subclass', profile.get_int('league_subclass', 5))) # New Music stuff new_music = Node.void('new_music') player.add_child(new_music) # Emblem list stuff? eapass_privilege = Node.void('eapass_privilege') player.add_child(eapass_privilege) emblem_list = Node.void('emblem_list') eapass_privilege.add_child(emblem_list) # Bonus music stuff? bonus_music = Node.void('bonus_music') player.add_child(bonus_music) bonus_music.add_child(Node.void('music')) bonus_music.add_child(Node.s32('event_id', -1)) bonus_music.add_child(Node.string('till_time', '')) # Bistro stuff is back? bistro = Node.void('bistro') player.add_child(bistro) chef = Node.void('chef') bistro.add_child(chef) chef.add_child(Node.s32('id', 1)) bistro.add_child(Node.s32('carry_over', 0)) route_list = Node.void('route_list') bistro.add_child(route_list) route_list.add_child(Node.u8('route_count', 0)) # If we have routes, they look like this: # # # # # ?? # # # ?? # bistro.add_child(Node.bool('extension', False)) # Gift list, maybe from other players? gift_list = Node.void('gift_list') player.add_child(gift_list) # If we had gifts, they look like this: # # ?? # return root def unformat_profile(self, userid: UserID, request: Node, oldprofile: Profile) -> Profile: newprofile = oldprofile.clone() newprofile.replace_bool('saved', True) data = request.child('data') # Grab system information sysinfo = data.child('info') # Grab player information player = data.child('player') # Grab result information result = data.child('result') # Grab last information. Lots of this will be filled in while grabbing scores last = newprofile.get_dict('last') if sysinfo is not None: last.replace_int('play_time', sysinfo.child_value('time_gameend')) last.replace_str('shopname', sysinfo.child_value('shopname')) last.replace_str('areaname', sysinfo.child_value('areaname')) # Grab player info for echoing back info = player.child('info') if info is not None: newprofile.replace_int('jubility', info.child_value('jubility')) newprofile.replace_int('jubility_yday', info.child_value('jubility_yday')) newprofile.replace_int('tune_cnt', info.child_value('tune_cnt')) newprofile.replace_int('save_cnt', info.child_value('save_cnt')) newprofile.replace_int('saved_cnt', info.child_value('saved_cnt')) newprofile.replace_int('fc_cnt', info.child_value('fc_cnt')) newprofile.replace_int('ex_cnt', info.child_value('ex_cnt')) newprofile.replace_int('pf_cnt', info.child_value('pf_cnt')) newprofile.replace_int('clear_cnt', info.child_value('clear_cnt')) newprofile.replace_int('match_cnt', info.child_value('match_cnt')) newprofile.replace_int('beat_cnt', info.child_value('beat_cnt')) newprofile.replace_int('total_best_score', info.child_value('total_best_score')) newprofile.replace_int('mynews_cnt', info.child_value('mynews_cnt')) newprofile.replace_int('bonus_tune_points', info.child_value('bonus_tune_points')) newprofile.replace_bool('is_bonus_tune_played', info.child_value('is_bonus_tune_played')) # Grab last settings (finally mostly in its own node!) lastnode = player.child('last') if lastnode is not None: last.replace_int('expert_option', lastnode.child_value('expert_option')) last.replace_int('sort', lastnode.child_value('sort')) last.replace_int('category', lastnode.child_value('category')) settings = lastnode.child('settings') if settings is not None: last.replace_int('matching', settings.child_value('matching')) last.replace_int('hazard', settings.child_value('hazard')) last.replace_int('hard', settings.child_value('hard')) last.replace_int('marker', settings.child_value('marker')) last.replace_int('theme', settings.child_value('theme')) last.replace_int('title', settings.child_value('title')) last.replace_int('parts', settings.child_value('parts')) last.replace_int('rank_sort', settings.child_value('rank_sort')) last.replace_int('combo_disp', settings.child_value('combo_disp')) last.replace_int_array('emblem', 5, settings.child_value('emblem')) # Grab unlock progress item = player.child('item') if item is not None: newprofile.replace_int_array('secret_list', 32, item.child_value('secret_list')) newprofile.replace_int_array('title_list', 160, item.child_value('title_list')) newprofile.replace_int('theme_list', item.child_value('theme_list')) newprofile.replace_int_array('marker_list', 2, item.child_value('marker_list')) newprofile.replace_int_array('parts_list', 160, item.child_value('parts_list')) newprofile.replace_int_array('music_list', 32, item.child_value('music_list')) newprofile.replace_int_array('emblem_list', 96, item.child_value('emblem_list')) newitem = item.child('new') if newitem is not None: newprofile.replace_int_array('secret_list_new', 32, newitem.child_value('secret_list')) newprofile.replace_int('theme_list_new', newitem.child_value('theme_list')) newprofile.replace_int_array('marker_list_new', 2, newitem.child_value('marker_list')) # Career progression career = player.child('career') careerdict = newprofile.get_dict('career') if career is not None: careerdict.replace_int('level', career.child_value('level')) careerdict.replace_int('point', career.child_value('point')) careerdict.replace_int_array('param', 10, career.child_value('param')) careerdict.replace_bool('is_unlocked', career.child_value('is_unlocked')) newprofile.replace_dict('career', careerdict) # jbox stuff jbox = player.child('jbox') jboxdict = newprofile.get_dict('jbox') if jbox is not None: jboxdict.replace_int('point', jbox.child_value('point')) emblemtype = jbox.child_value('emblem/type') index = jbox.child_value('emblem/index') if emblemtype == self.JBOX_EMBLEM_NORMAL: jboxdict.replace_int('normal_index', index) elif emblemtype == self.JBOX_EMBLEM_PREMIUM: jboxdict.replace_int('premium_index', index) newprofile.replace_dict('jbox', jboxdict) # event stuff event_info = player.child('event_info') if event_info is not None: for child in event_info.children: try: eventid = int(child.attribute('type')) except TypeError: # Event is empty continue is_completed = child.child_value('is_completed') # Figure out if we should update the rating/scores or not oldevent = self.data.local.user.get_achievement( self.game, self.version, userid, eventid, 'event', ) if oldevent is None: # Create a new event structure for this oldevent = ValidatedDict() oldevent.replace_bool('is_completed', is_completed) # Save it as an achievement self.data.local.user.put_achievement( self.game, self.version, userid, eventid, 'event', oldevent, ) # A whole bunch of miscelaneous shit newprofile.replace_int('navi_flag', player.child_value('navi/flag')) newprofile.replace_int('kaitou_bisco_read_flag', player.child_value('kaitou_bisco/read_flag')) # Get timestamps for played songs timestamps: Dict[int, int] = {} history = player.child('history') if history is not None: for tune in history.children: if tune.name != 'tune': continue entry = int(tune.attribute('log_id')) ts = int(tune.child_value('timestamp') / 1000) timestamps[entry] = ts # Grab scores and save those if result is not None: for tune in result.children: if tune.name != 'tune': continue result = tune.child('player') entry = int(tune.attribute('id')) songid = tune.child_value('music') timestamp = timestamps.get(entry, Time.now()) chart = int(result.child('score').attribute('seq')) points = result.child_value('score') flags = int(result.child('score').attribute('clear')) combo = int(result.child('score').attribute('combo')) ghost = result.child_value('mbar') # Miscelaneous last data for echoing to profile get last.replace_int('music_id', songid) last.replace_int('seq_id', chart) mapping = { self.GAME_FLAG_BIT_CLEARED: self.PLAY_MEDAL_CLEARED, self.GAME_FLAG_BIT_FULL_COMBO: self.PLAY_MEDAL_FULL_COMBO, self.GAME_FLAG_BIT_EXCELLENT: self.PLAY_MEDAL_EXCELLENT, self.GAME_FLAG_BIT_NEARLY_FULL_COMBO: self.PLAY_MEDAL_NEARLY_FULL_COMBO, self.GAME_FLAG_BIT_NEARLY_EXCELLENT: self.PLAY_MEDAL_NEARLY_EXCELLENT, } # Figure out the highest medal based on bits passed in medal = self.PLAY_MEDAL_FAILED for bit in mapping: if flags & bit > 0: medal = max(medal, mapping[bit]) self.update_score(userid, timestamp, songid, chart, points, medal, combo, ghost) # If this was a course save, grab and save that info too course = player.child('course') if course is not None: courseid = course.child_value('course_id') rating = { self.GAME_COURSE_RATING_FAILED: self.COURSE_RATING_FAILED, self.GAME_COURSE_RATING_BRONZE: self.COURSE_RATING_BRONZE, self.GAME_COURSE_RATING_SILVER: self.COURSE_RATING_SILVER, self.GAME_COURSE_RATING_GOLD: self.COURSE_RATING_GOLD, }[course.child_value('rating')] scores = [0] * 5 for music in course.children: if music.name != 'music': continue index = int(music.attribute('index')) scores[index] = music.child_value('score') # Save course itself self.save_course(userid, courseid, rating, scores) # Save the last course ID last.replace_int('last_course_id', courseid) # If this was a league save, grab and save that info too league = player.child('league') if league is not None: leagueid = league.child_value('league_id') newprofile.replace_bool('league_is_checked', league.child_value('is_checked')) newprofile.replace_bool('league_is_first_play', league.child_value('is_first_play')) # Extract scores score = [0] * 3 clear = [0] * 3 for music in league.children: if music.name != 'music': continue index = int(music.attribute('index')) scorenode = music.child('score') clear[index] = int(scorenode.attribute('clear')) score[index] = scorenode.value # Update score if it is higher oldleague = self.data.local.user.get_achievement( self.game, self.version, userid, leagueid, 'league', ) if oldleague is None: oldleague = ValidatedDict() oldscore = oldleague.get_int_array('score', 3) if sum(oldscore) < sum(score): self.data.local.user.put_achievement( self.game, self.version, userid, leagueid, 'league', {'score': score, 'clear': clear}, ) # Save back last information gleaned from results newprofile.replace_dict('last', last) # Keep track of play statistics self.update_play_statistics(userid) return newprofile def format_scores(self, userid: UserID, profile: Profile, scores: List[Score]) -> Node: root = Node.void('gametop') datanode = Node.void('data') root.add_child(datanode) player = Node.void('player') datanode.add_child(player) player.add_child(Node.s32('jid', profile.extid)) playdata = Node.void('mdata_list') player.add_child(playdata) music = ValidatedDict() for score in scores: data = music.get_dict(str(score.id)) play_cnt = data.get_int_array('play_cnt', 3) clear_cnt = data.get_int_array('clear_cnt', 3) clear_flags = data.get_int_array('clear_flags', 3) fc_cnt = data.get_int_array('fc_cnt', 3) ex_cnt = data.get_int_array('ex_cnt', 3) points = data.get_int_array('points', 3) # Replace data for this chart type play_cnt[score.chart] = score.plays clear_cnt[score.chart] = score.data.get_int('clear_count') fc_cnt[score.chart] = score.data.get_int('full_combo_count') ex_cnt[score.chart] = score.data.get_int('excellent_count') points[score.chart] = score.points # Format the clear flags clear_flags[score.chart] = self.GAME_FLAG_BIT_PLAYED if score.data.get_int('clear_count') > 0: clear_flags[score.chart] |= self.GAME_FLAG_BIT_CLEARED if score.data.get_int('full_combo_count') > 0: clear_flags[score.chart] |= self.GAME_FLAG_BIT_FULL_COMBO if score.data.get_int('excellent_count') > 0: clear_flags[score.chart] |= self.GAME_FLAG_BIT_EXCELLENT # Save chart data back data.replace_int_array('play_cnt', 3, play_cnt) data.replace_int_array('clear_cnt', 3, clear_cnt) data.replace_int_array('clear_flags', 3, clear_flags) data.replace_int_array('fc_cnt', 3, fc_cnt) data.replace_int_array('ex_cnt', 3, ex_cnt) data.replace_int_array('points', 3, points) # Update the ghost (untyped) ghost = data.get('ghost', [None, None, None]) ghost[score.chart] = score.data.get('ghost') data['ghost'] = ghost # Save it back music.replace_dict(str(score.id), data) for scoreid in music: scoredata = music[scoreid] musicdata = Node.void('musicdata') playdata.add_child(musicdata) musicdata.set_attribute('music_id', scoreid) musicdata.add_child(Node.s32_array('play_cnt', scoredata.get_int_array('play_cnt', 3))) musicdata.add_child(Node.s32_array('clear_cnt', scoredata.get_int_array('clear_cnt', 3))) musicdata.add_child(Node.s32_array('fc_cnt', scoredata.get_int_array('fc_cnt', 3))) musicdata.add_child(Node.s32_array('ex_cnt', scoredata.get_int_array('ex_cnt', 3))) musicdata.add_child(Node.s32_array('score', scoredata.get_int_array('points', 3))) musicdata.add_child(Node.s8_array('clear', scoredata.get_int_array('clear_flags', 3))) ghosts = scoredata.get('ghost', [None, None, None]) for i in range(len(ghosts)): ghost = ghosts[i] if ghost is None: continue bar = Node.u8_array('bar', ghost) musicdata.add_child(bar) bar.set_attribute('seq', str(i)) return root