From 351a55a9349b4d02f0081ace783ee1653cda7881 Mon Sep 17 00:00:00 2001 From: Jennifer Taylor Date: Mon, 6 Sep 2021 19:28:18 +0000 Subject: [PATCH] Split out UsaNeko/Peace into common abstract base class and two implementations. --- bemani/backend/popn/common.py | 1326 ++++++++++++++++++++++++++++++++ bemani/backend/popn/peace.py | 3 +- bemani/backend/popn/usaneko.py | 1316 +------------------------------ bemani/client/popn/__init__.py | 2 + bemani/client/popn/peace.py | 669 ++++++++++++++++ bemani/utils/trafficgen.py | 3 +- verifytraffic | 1 + 7 files changed, 2006 insertions(+), 1314 deletions(-) create mode 100644 bemani/backend/popn/common.py create mode 100644 bemani/client/popn/peace.py diff --git a/bemani/backend/popn/common.py b/bemani/backend/popn/common.py new file mode 100644 index 0000000..6d4b094 --- /dev/null +++ b/bemani/backend/popn/common.py @@ -0,0 +1,1326 @@ +# vim: set fileencoding=utf-8 +from abc import ABCMeta, abstractmethod +import binascii +import copy +import random +from typing import Any, Dict, List, Optional, Tuple +from typing_extensions import Final + +from bemani.backend.popn.base import PopnMusicBase +from bemani.common import Time, ID, Profile, ValidatedDict, Parallel +from bemani.data import Data, UserID, Achievement, Link +from bemani.protocol import Node + + +class PopnMusicModernBase(PopnMusicBase, metaclass=ABCMeta): + + # Chart type, as returned from the game + GAME_CHART_TYPE_EASY: Final[int] = 0 + GAME_CHART_TYPE_NORMAL: Final[int] = 1 + GAME_CHART_TYPE_HYPER: Final[int] = 2 + GAME_CHART_TYPE_EX: Final[int] = 3 + + # Medal type, as returned from the game + GAME_PLAY_MEDAL_CIRCLE_FAILED: Final[int] = 1 + GAME_PLAY_MEDAL_DIAMOND_FAILED: Final[int] = 2 + GAME_PLAY_MEDAL_STAR_FAILED: Final[int] = 3 + GAME_PLAY_MEDAL_EASY_CLEAR: Final[int] = 4 + GAME_PLAY_MEDAL_CIRCLE_CLEARED: Final[int] = 5 + GAME_PLAY_MEDAL_DIAMOND_CLEARED: Final[int] = 6 + GAME_PLAY_MEDAL_STAR_CLEARED: Final[int] = 7 + GAME_PLAY_MEDAL_CIRCLE_FULL_COMBO: Final[int] = 8 + GAME_PLAY_MEDAL_DIAMOND_FULL_COMBO: Final[int] = 9 + GAME_PLAY_MEDAL_STAR_FULL_COMBO: Final[int] = 10 + GAME_PLAY_MEDAL_PERFECT: Final[int] = 11 + + # Rank type, as returned from the game + GAME_PLAY_RANK_E: Final[int] = 1 + GAME_PLAY_RANK_D: Final[int] = 2 + GAME_PLAY_RANK_C: Final[int] = 3 + GAME_PLAY_RANK_B: Final[int] = 4 + GAME_PLAY_RANK_A: Final[int] = 5 + GAME_PLAY_RANK_AA: Final[int] = 6 + GAME_PLAY_RANK_AAA: Final[int] = 7 + GAME_PLAY_RANK_S: Final[int] = 8 + + # Biggest ID in the music DB + GAME_MAX_MUSIC_ID: int + + def extra_services(self) -> List[str]: + """ + Return the local2 and lobby2 service so that Pop'n Music 24 will + send game packets. + """ + return [ + 'local2', + 'lobby2', + ] + + @classmethod + def run_scheduled_work(cls, data: Data, config: Dict[str, Any]) -> List[Tuple[str, Dict[str, Any]]]: + """ + Once a week, insert a new course. + """ + events = [] + if data.local.network.should_schedule(cls.game, cls.version, 'course', 'weekly'): + # Generate a new course list, save it to the DB. + start_time, end_time = data.local.network.get_schedule_duration('weekly') + all_songs = [song.id for song in data.local.music.get_all_songs(cls.game, cls.version)] + if all_songs: + course_song = random.choice(all_songs) + data.local.game.put_time_sensitive_settings( + cls.game, + cls.version, + 'course', + { + 'start_time': start_time, + 'end_time': end_time, + 'music': course_song, + }, + ) + events.append(( + 'pnm_course', + { + 'version': cls.version, + 'song': course_song, + }, + )) + + # Mark that we did some actual work here. + data.local.network.mark_scheduled(cls.game, cls.version, 'course', 'weekly') + return events + + def __score_to_rank(self, score: int) -> int: + if score < 50000: + return self.GAME_PLAY_RANK_E + if score < 62000: + return self.GAME_PLAY_RANK_D + if score < 72000: + return self.GAME_PLAY_RANK_C + if score < 82000: + return self.GAME_PLAY_RANK_B + if score < 90000: + return self.GAME_PLAY_RANK_A + if score < 95000: + return self.GAME_PLAY_RANK_AA + if score < 98000: + return self.GAME_PLAY_RANK_AAA + return self.GAME_PLAY_RANK_S + + def handle_lobby24_request(self, request: Node) -> Node: + # Stub out the entire lobby24 service + return Node.void('lobby24') + + def handle_pcb24_error_request(self, request: Node) -> Node: + return Node.void('pcb24') + + def handle_pcb24_boot_request(self, request: Node) -> Node: + return Node.void('pcb24') + + def handle_pcb24_write_request(self, request: Node) -> Node: + # Update the name of this cab for admin purposes + self.update_machine_name(request.child_value('pcb_setting/name')) + return Node.void('pcb24') + + @abstractmethod + def get_phases(self) -> Dict[int, int]: + ... + + def __construct_common_info(self, root: Node) -> None: + for phaseid, phase_value in self.get_phases().items(): + phase = Node.void('phase') + root.add_child(phase) + phase.add_child(Node.s16('event_id', phaseid)) + phase.add_child(Node.s16('phase', phase_value)) + + # Gather course information and course ranking for users. + course_infos, achievements, profiles = Parallel.execute([ + lambda: self.data.local.game.get_all_time_sensitive_settings(self.game, self.version, 'course'), + lambda: self.data.local.user.get_all_achievements(self.game, self.version), + lambda: self.data.local.user.get_all_profiles(self.game, self.version), + ]) + # Sort courses by newest to oldest so we can grab the newest 256. + course_infos = sorted( + course_infos, + key=lambda c: c['start_time'], + reverse=True, + ) + # Sort achievements within course ID from best to worst ranking. + achievements_by_course_id: Dict[int, Dict[str, List[Tuple[UserID, Achievement]]]] = {} + type_to_chart_lut: Dict[str, str] = { + f'course_{self.GAME_CHART_TYPE_EASY}': "loc_ranking_e", + f'course_{self.GAME_CHART_TYPE_NORMAL}': "loc_ranking_n", + f'course_{self.GAME_CHART_TYPE_HYPER}': "loc_ranking_h", + f'course_{self.GAME_CHART_TYPE_EX}': "loc_ranking_ex", + } + for uid, ach in achievements: + if ach.type[:7] != 'course_': + continue + if ach.id not in achievements_by_course_id: + achievements_by_course_id[ach.id] = { + "loc_ranking_e": [], + "loc_ranking_n": [], + "loc_ranking_h": [], + "loc_ranking_ex": [], + } + achievements_by_course_id[ach.id][type_to_chart_lut[ach.type]].append((uid, ach)) + for courseid in achievements_by_course_id: + for chart in ["loc_ranking_e", "loc_ranking_n", "loc_ranking_h", "loc_ranking_ex"]: + achievements_by_course_id[courseid][chart] = sorted( + achievements_by_course_id[courseid][chart], + key=lambda uid_and_ach: uid_and_ach[1].data.get_int('score'), + reverse=True, + ) + + # Cache of userID to profile + userid_to_profile: Dict[UserID, Profile] = {uid: profile for (uid, profile) in profiles} + + # Course ranking info for the last 256 courses + for course_info in course_infos[:256]: + course_id = int(course_info['start_time'] / 604800) + course_rankings = achievements_by_course_id.get(course_id, {}) + + ranking_info = Node.void('ranking_info') + root.add_child(ranking_info) + ranking_info.add_child(Node.s16('course_id', course_id)) + ranking_info.add_child(Node.u64('start_date', course_info['start_time'] * 1000)) + ranking_info.add_child(Node.u64('end_date', course_info['end_time'] * 1000)) + ranking_info.add_child(Node.s32('music_id', course_info['music'])) + + # Top 20 rankings for each particular chart. + for name in ["loc_ranking_e", "loc_ranking_n", "loc_ranking_h", "loc_ranking_ex"]: + chart_rankings = course_rankings.get(name, []) + + for pos, (uid, ach) in enumerate(chart_rankings[:20]): + profile = userid_to_profile.get(uid, Profile(self.game, self.version, "", 0)) + + subnode = Node.void(name) + ranking_info.add_child(subnode) + subnode.add_child(Node.s16('rank', pos + 1)) + subnode.add_child(Node.string('name', profile.get_str('name'))) + subnode.add_child(Node.s16('chara_num', profile.get_int('chara', -1))) + subnode.add_child(Node.s32('total_score', ach.data.get_int('score'))) + subnode.add_child(Node.u8('clear_type', ach.data.get_int('clear_type'))) + subnode.add_child(Node.u8('clear_rank', ach.data.get_int('clear_rank'))) + + for area_id in range(0, 16): + area = Node.void('area') + root.add_child(area) + area.add_child(Node.s16('area_id', area_id)) + area.add_child(Node.u64('end_date', 0)) + area.add_child(Node.s16('medal_id', area_id)) + area.add_child(Node.bool('is_limit', False)) + + for choco_id in range(0, 5): + choco = Node.void('choco') + root.add_child(choco) + choco.add_child(Node.s16('choco_id', choco_id)) + choco.add_child(Node.s32('param', -1)) + + # Set up goods, educated guess here. + for goods_id in range(97): + if goods_id < 15: + price = 30 + elif goods_id < 30: + price = 40 + elif goods_id < 45: + price = 60 + elif goods_id < 60: + price = 80 + else: + price = 200 + goods = Node.void('goods') + root.add_child(goods) + goods.add_child(Node.s32('item_id', goods_id + 1)) + goods.add_child(Node.s16('item_type', 3)) + goods.add_child(Node.s32('price', price)) + goods.add_child(Node.s16('goods_type', 0)) + + # Ignoring NAVIfes node, we don't set these. + # fes = Node.void('fes') + # fes.add_child(Node.s16('fes_id', -1)) + # fes.add_child(Node.s32('gauge_count', -1)) + # fes.add_child(Node.s32_array('gauge', [-1, -1, -1, -1, -1, -1])) + # fes.add_child(Node.s32_array('music', [-1, -1, -1, -1, -1, -1])) + # fes.add_child(Node.s16('r', -1)) + # fes.add_child(Node.s16('g', -1)) + # fes.add_child(Node.s16('b', -1)) + # fes.add_child(Node.s16('poster', -1)) + + # Calculate most popular characters + profiles = self.data.remote.user.get_all_profiles(self.game, self.version) + charas: Dict[int, int] = {} + for (_userid, profile) in profiles: + chara = profile.get_int('chara', -1) + if chara <= 0: + continue + if chara not in charas: + charas[chara] = 1 + else: + charas[chara] = charas[chara] + 1 + + # Order a typle by most popular character to least popular character + charamap = sorted( + [(c, charas[c]) for c in charas], + key=lambda c: c[1], + reverse=True, + ) + + # Top 20 Popular characters + for rank, (charaid, _usecount) in enumerate(charamap[:20]): + popular = Node.void('popular') + root.add_child(popular) + popular.add_child(Node.s16('rank', rank + 1)) + popular.add_child(Node.s16('chara_num', charaid)) + + # Top 500 Popular music + for (songid, _plays) in self.data.local.music.get_hit_chart(self.game, self.version, 500): + popular_music = Node.void('popular_music') + root.add_child(popular_music) + popular_music.add_child(Node.s16('music_num', songid)) + + # Ignoring recommended music, we don't set this + # recommend = Node.void('recommend') + # root.add_child(recommend) + # recommend.add_child(Node.s32_array('music_no', [-1] * 30)) + + # Ignoring mission points, we don't set these. + # mission_point = Node.void('mission_point') + # mission_point.add_child(Node.s32('point', -1)) + # mission_point.add_child(Node.s32('bonus_point', -1)) + + # Ignoring medals, we don't set these. + # medal = Node.void('medal') + # medal.add_child(Node.s16('medal_id', -1)) + # medal.add_child(Node.s16('percent', -1)) + + # Ignoring chara ranking, we don't set these. + # chara_ranking = Node.void('chara_ranking') + # chara_ranking.add_child(Node.s32('rank', -1)) + # chara_ranking.add_child(Node.s32('kind_id', -1)) + # chara_ranking.add_child(Node.s32('point', -1)) + # chara_ranking.add_child(Node.s32('month', -1)) + + def handle_info24_common_request(self, root: Node) -> Node: + root = Node.void('info24') + self.__construct_common_info(root) + return root + + def handle_player24_new_request(self, request: Node) -> Node: + refid = request.child_value('ref_id') + name = request.child_value('name') + root = self.new_profile_by_refid(refid, name) + if root is None: + root = Node.void('player24') + root.add_child(Node.s8('result', 2)) + return root + + def handle_player24_conversion_request(self, request: Node) -> Node: + refid = request.child_value('ref_id') + name = request.child_value('name') + chara = request.child_value('chara') + achievements: List[Achievement] = [] + for node in request.children: + if node.name == 'item': + itemid = node.child_value('id') + itemtype = node.child_value('type') + param = node.child_value('param') + is_new = node.child_value('is_new') + get_time = node.child_value('get_time') + + achievements.append( + Achievement( + itemid, + f'item_{itemtype}', + 0, + { + 'param': param, + 'is_new': is_new, + 'get_time': get_time, + }, + ) + ) + root = self.new_profile_by_refid(refid, name, chara, achievements=achievements) + if root is None: + root = Node.void('player24') + root.add_child(Node.s8('result', 2)) + return root + + def handle_player24_read_request(self, request: Node) -> Node: + refid = request.child_value('ref_id') + root = self.get_profile_by_refid(refid, self.OLD_PROFILE_FALLTHROUGH) + if root is None: + root = Node.void('player24') + root.add_child(Node.s8('result', 2)) + return root + + def handle_player24_write_request(self, request: Node) -> Node: + refid = request.child_value('ref_id') + + if refid is not None: + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + else: + userid = None + + if userid is not None: + oldprofile = self.get_profile(userid) or Profile(self.game, self.version, refid, 0) + newprofile = self.unformat_profile(userid, request, oldprofile) + + if newprofile is not None: + self.put_profile(userid, newprofile) + + return Node.void('player24') + + def handle_player24_update_ranking_request(self, request: Node) -> Node: + refid = request.child_value('ref_id') + root = Node.void('player24') + + if refid is not None: + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + else: + userid = None + + if userid is not None: + course_id = request.child_value('course_id') + chart = request.child_value('sheet_num') + score = request.child_value('total_score') + clear_type = request.child_value('clear_type') + clear_rank = request.child_value('clear_rank') + prefecture = request.child_value('pref') + loc_id = ID.parse_machine_id(request.child_value('location_id')) + course_type = f"course_{chart}" + + old_course = self.data.local.user.get_achievement( + self.game, + self.version, + userid, + course_id, + course_type, + ) + if old_course is None: + old_course = ValidatedDict() + + new_course = ValidatedDict({ + 'score': max(score, old_course.get_int('score')), + 'clear_type': max(clear_type, old_course.get_int('clear_type')), + 'clear_rank': max(clear_rank, old_course.get_int('clear_rank')), + 'pref': prefecture, + 'lid': loc_id, + 'count': old_course.get_int('count') + 1, + }) + + self.data.local.user.put_achievement( + self.game, + self.version, + userid, + course_id, + course_type, + new_course, + ) + + # Handle fetching all scores + uids_and_courses, profile = Parallel.execute([ + lambda: self.data.local.user.get_all_achievements(self.game, self.version), + lambda: self.get_profile(userid) or Profile(self.game, self.version, "", 0) + ]) + + # Grab a sorted list of all scores for this course and chart + global_uids_and_courses = sorted( + [(uid, ach) for (uid, ach) in uids_and_courses if ach.type == course_type and ach.id == course_id], + key=lambda uid_and_course: uid_and_course[1].data.get_int('score'), + reverse=True, + ) + # Grab smaller lists that contain only sorted for our prefecture/location + pref_uids_and_courses = [(uid, ach) for (uid, ach) in global_uids_and_courses if ach.data.get_int('pref') == prefecture] + loc_uids_and_courses = [(uid, ach) for (uid, ach) in global_uids_and_courses if ach.data.get_int('lid') == loc_id] + + def _get_rank(uac: List[Tuple[UserID, Achievement]]) -> Optional[int]: + for rank, (uid, _) in enumerate(uac): + if uid == userid: + return rank + 1 + return None + + for nodename, ranklist in [ + ("all_ranking", global_uids_and_courses), + ("pref_ranking", pref_uids_and_courses), + ("location_ranking", loc_uids_and_courses), + ]: + # Grab the rank, bail if we don't have any answer since the game doesn't + # require a response. + rank = _get_rank(ranklist) + if rank is None: + continue + + # Send back the data for this ranking. + node = Node.void(nodename) + root.add_child(node) + node.add_child(Node.string("name", profile.get_str('name', 'なし'))) + node.add_child(Node.s16("chara_num", profile.get_int('chara', -1))) + node.add_child(Node.s32("total_score", new_course.get_int('score'))) + node.add_child(Node.u8("clear_type", new_course.get_int('clear_type'))) + node.add_child(Node.u8("clear_rank", new_course.get_int('clear_rank'))) + node.add_child(Node.s16("player_count", len(ranklist))) + node.add_child(Node.s16("player_rank", rank)) + + return root + + def handle_player24_friend_request(self, request: Node) -> Node: + refid = request.attribute('ref_id') + no = int(request.attribute('no', '-1')) + + root = Node.void('player24') + if no < 0: + root.add_child(Node.s8('result', 2)) + return root + + # Look up our own user ID based on the RefID provided. + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + if userid is None: + root.add_child(Node.s8('result', 2)) + return root + + # Grab the links that we care about. + links = self.data.local.user.get_links(self.game, self.version, userid) + profiles: Dict[UserID, Profile] = {} + rivals: List[Link] = [] + for link in links: + if link.type != 'rival': + continue + + other_profile = self.get_profile(link.other_userid) + if other_profile is None: + continue + profiles[link.other_userid] = other_profile + rivals.append(link) + + # Somehow requested an invalid profile. + if no >= len(rivals): + root.add_child(Node.s8('result', 2)) + return root + rivalid = links[no].other_userid + rivalprofile = profiles[rivalid] + scores = self.data.remote.music.get_scores(self.game, self.version, rivalid) + + # First, output general profile info. + friend = Node.void('friend') + root.add_child(friend) + friend.add_child(Node.s16('no', no)) + friend.add_child(Node.string('g_pm_id', self.format_extid(rivalprofile.extid))) # UsaNeko formats on its own + friend.add_child(Node.string('name', rivalprofile.get_str('name', 'なし'))) + friend.add_child(Node.s16('chara', rivalprofile.get_int('chara', -1))) + # This might be for having non-active or non-confirmed friends, but setting to 0 makes the + # ranking numbers disappear and the player icon show a questionmark. + friend.add_child(Node.s8('is_open', 1)) + + for score in scores: + # Skip any scores for chart types we don't support + if score.chart not in [ + self.CHART_TYPE_EASY, + self.CHART_TYPE_NORMAL, + self.CHART_TYPE_HYPER, + self.CHART_TYPE_EX, + ]: + continue + if score.data.get_int('medal') == self.PLAY_MEDAL_NO_PLAY: + continue + + points = score.points + medal = score.data.get_int('medal') + + music = Node.void('music') + friend.add_child(music) + music.set_attribute('music_num', str(score.id)) + music.set_attribute('sheet_num', str({ + self.CHART_TYPE_EASY: self.GAME_CHART_TYPE_EASY, + self.CHART_TYPE_NORMAL: self.GAME_CHART_TYPE_NORMAL, + self.CHART_TYPE_HYPER: self.GAME_CHART_TYPE_HYPER, + self.CHART_TYPE_EX: self.GAME_CHART_TYPE_EX, + }[score.chart])) + music.set_attribute('score', str(points)) + music.set_attribute('clearrank', str(self.__score_to_rank(score.points))) + music.set_attribute('cleartype', str({ + self.PLAY_MEDAL_CIRCLE_FAILED: self.GAME_PLAY_MEDAL_CIRCLE_FAILED, + self.PLAY_MEDAL_DIAMOND_FAILED: self.GAME_PLAY_MEDAL_DIAMOND_FAILED, + self.PLAY_MEDAL_STAR_FAILED: self.GAME_PLAY_MEDAL_STAR_FAILED, + self.PLAY_MEDAL_EASY_CLEAR: self.GAME_PLAY_MEDAL_EASY_CLEAR, + self.PLAY_MEDAL_CIRCLE_CLEARED: self.GAME_PLAY_MEDAL_CIRCLE_CLEARED, + self.PLAY_MEDAL_DIAMOND_CLEARED: self.GAME_PLAY_MEDAL_DIAMOND_CLEARED, + self.PLAY_MEDAL_STAR_CLEARED: self.GAME_PLAY_MEDAL_STAR_CLEARED, + self.PLAY_MEDAL_CIRCLE_FULL_COMBO: self.GAME_PLAY_MEDAL_CIRCLE_FULL_COMBO, + self.PLAY_MEDAL_DIAMOND_FULL_COMBO: self.GAME_PLAY_MEDAL_DIAMOND_FULL_COMBO, + self.PLAY_MEDAL_STAR_FULL_COMBO: self.GAME_PLAY_MEDAL_STAR_FULL_COMBO, + self.PLAY_MEDAL_PERFECT: self.GAME_PLAY_MEDAL_PERFECT, + }[medal])) + + achievements = self.data.local.user.get_achievements(self.game, self.version, rivalid) + for achievement in achievements: + if achievement.type[:7] == 'course_': + sheet = int(achievement.type[7:]) + + course_data = Node.void('course_data') + root.add_child(course_data) + course_data.add_child(Node.s16('course_id', achievement.id)) + course_data.add_child(Node.u8('clear_type', achievement.data.get_int('clear_type'))) + course_data.add_child(Node.u8('clear_rank', achievement.data.get_int('clear_rank'))) + course_data.add_child(Node.s32('total_score', achievement.data.get_int('score'))) + course_data.add_child(Node.s32('update_count', achievement.data.get_int('count'))) + course_data.add_child(Node.u8('sheet_num', sheet)) + + return root + + def handle_player24_read_score_request(self, request: Node) -> Node: + refid = request.child_value('ref_id') + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + if userid is None: + return Node.void('player24') + + root = Node.void('player24') + scores = self.data.remote.music.get_scores(self.game, self.version, userid) + for score in scores: + # Skip any scores for chart types we don't support + if score.chart not in [ + self.CHART_TYPE_EASY, + self.CHART_TYPE_NORMAL, + self.CHART_TYPE_HYPER, + self.CHART_TYPE_EX, + ]: + continue + if score.data.get_int('medal') == self.PLAY_MEDAL_NO_PLAY: + continue + + music = Node.void('music') + root.add_child(music) + music.add_child(Node.s16('music_num', score.id)) + music.add_child(Node.u8('sheet_num', { + self.CHART_TYPE_EASY: self.GAME_CHART_TYPE_EASY, + self.CHART_TYPE_NORMAL: self.GAME_CHART_TYPE_NORMAL, + self.CHART_TYPE_HYPER: self.GAME_CHART_TYPE_HYPER, + self.CHART_TYPE_EX: self.GAME_CHART_TYPE_EX, + }[score.chart])) + music.add_child(Node.s32('score', score.points)) + music.add_child(Node.u8('clear_type', { + self.PLAY_MEDAL_CIRCLE_FAILED: self.GAME_PLAY_MEDAL_CIRCLE_FAILED, + self.PLAY_MEDAL_DIAMOND_FAILED: self.GAME_PLAY_MEDAL_DIAMOND_FAILED, + self.PLAY_MEDAL_STAR_FAILED: self.GAME_PLAY_MEDAL_STAR_FAILED, + self.PLAY_MEDAL_EASY_CLEAR: self.GAME_PLAY_MEDAL_EASY_CLEAR, + self.PLAY_MEDAL_CIRCLE_CLEARED: self.GAME_PLAY_MEDAL_CIRCLE_CLEARED, + self.PLAY_MEDAL_DIAMOND_CLEARED: self.GAME_PLAY_MEDAL_DIAMOND_CLEARED, + self.PLAY_MEDAL_STAR_CLEARED: self.GAME_PLAY_MEDAL_STAR_CLEARED, + self.PLAY_MEDAL_CIRCLE_FULL_COMBO: self.GAME_PLAY_MEDAL_CIRCLE_FULL_COMBO, + self.PLAY_MEDAL_DIAMOND_FULL_COMBO: self.GAME_PLAY_MEDAL_DIAMOND_FULL_COMBO, + self.PLAY_MEDAL_STAR_FULL_COMBO: self.GAME_PLAY_MEDAL_STAR_FULL_COMBO, + self.PLAY_MEDAL_PERFECT: self.GAME_PLAY_MEDAL_PERFECT, + }[score.data.get_int('medal')])) + music.add_child(Node.u8('clear_rank', self.__score_to_rank(score.points))) + music.add_child(Node.s16('cnt', score.plays)) + + return root + + def handle_player24_write_music_request(self, request: Node) -> Node: + refid = request.child_value('ref_id') + + root = Node.void('player24') + if refid is None: + return root + + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + if userid is None: + return root + + songid = request.child_value('music_num') + chart = { + self.GAME_CHART_TYPE_EASY: self.CHART_TYPE_EASY, + self.GAME_CHART_TYPE_NORMAL: self.CHART_TYPE_NORMAL, + self.GAME_CHART_TYPE_HYPER: self.CHART_TYPE_HYPER, + self.GAME_CHART_TYPE_EX: self.CHART_TYPE_EX, + }[request.child_value('sheet_num')] + medal = request.child_value('clear_type') + points = request.child_value('score') + combo = request.child_value('combo') + stats = { + 'cool': request.child_value('cool'), + 'great': request.child_value('great'), + 'good': request.child_value('good'), + 'bad': request.child_value('bad') + } + medal = { + self.GAME_PLAY_MEDAL_CIRCLE_FAILED: self.PLAY_MEDAL_CIRCLE_FAILED, + self.GAME_PLAY_MEDAL_DIAMOND_FAILED: self.PLAY_MEDAL_DIAMOND_FAILED, + self.GAME_PLAY_MEDAL_STAR_FAILED: self.PLAY_MEDAL_STAR_FAILED, + self.GAME_PLAY_MEDAL_EASY_CLEAR: self.PLAY_MEDAL_EASY_CLEAR, + self.GAME_PLAY_MEDAL_CIRCLE_CLEARED: self.PLAY_MEDAL_CIRCLE_CLEARED, + self.GAME_PLAY_MEDAL_DIAMOND_CLEARED: self.PLAY_MEDAL_DIAMOND_CLEARED, + self.GAME_PLAY_MEDAL_STAR_CLEARED: self.PLAY_MEDAL_STAR_CLEARED, + self.GAME_PLAY_MEDAL_CIRCLE_FULL_COMBO: self.PLAY_MEDAL_CIRCLE_FULL_COMBO, + self.GAME_PLAY_MEDAL_DIAMOND_FULL_COMBO: self.PLAY_MEDAL_DIAMOND_FULL_COMBO, + self.GAME_PLAY_MEDAL_STAR_FULL_COMBO: self.PLAY_MEDAL_STAR_FULL_COMBO, + self.GAME_PLAY_MEDAL_PERFECT: self.PLAY_MEDAL_PERFECT, + }[medal] + self.update_score(userid, songid, chart, points, medal, combo=combo, stats=stats) + + if request.child_value('is_image_store') == 1: + self.broadcast_score(userid, songid, chart, medal, points, combo, stats) + + return root + + def handle_player24_start_request(self, request: Node) -> Node: + root = Node.void('player24') + root.add_child(Node.s32('play_id', 0)) + self.__construct_common_info(root) + return root + + def handle_player24_logout_request(self, request: Node) -> Node: + return Node.void('player24') + + def handle_player24_buy_request(self, request: Node) -> Node: + refid = request.child_value('ref_id') + + if refid is not None: + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + else: + userid = None + + if userid is not None: + itemid = request.child_value('id') + itemtype = request.child_value('type') + itemparam = request.child_value('param') + + price = request.child_value('price') + lumina = request.child_value('lumina') + + if lumina >= price: + # Update player lumina balance + profile = self.get_profile(userid) or Profile(self.game, self.version, refid, 0) + profile.replace_int('player_point', lumina - price) + self.put_profile(userid, profile) + + # Grant the object + self.data.local.user.put_achievement( + self.game, + self.version, + userid, + itemid, + f'item_{itemtype}', + { + 'param': itemparam, + 'is_new': True, + }, + ) + + return Node.void('player24') + + def format_conversion(self, userid: UserID, profile: Profile) -> Node: + root = Node.void('player24') + root.add_child(Node.string('name', profile.get_str('name', 'なし'))) + root.add_child(Node.s16('chara', profile.get_int('chara', -1))) + root.add_child(Node.s8('con_type', 0)) + root.add_child(Node.s8('result', 1)) + + # Scores + scores = self.data.remote.music.get_scores(self.game, self.version, userid) + for score in scores: + # Skip any scores for chart types we don't support + if score.chart not in [ + self.CHART_TYPE_EASY, + self.CHART_TYPE_NORMAL, + self.CHART_TYPE_HYPER, + self.CHART_TYPE_EX, + ]: + continue + if score.data.get_int('medal') == self.PLAY_MEDAL_NO_PLAY: + continue + + music = Node.void('music') + root.add_child(music) + music.add_child(Node.s16('music_num', score.id)) + music.add_child(Node.u8('sheet_num', { + self.CHART_TYPE_EASY: self.GAME_CHART_TYPE_EASY, + self.CHART_TYPE_NORMAL: self.GAME_CHART_TYPE_NORMAL, + self.CHART_TYPE_HYPER: self.GAME_CHART_TYPE_HYPER, + self.CHART_TYPE_EX: self.GAME_CHART_TYPE_EX, + }[score.chart])) + music.add_child(Node.s32('score', score.points)) + music.add_child(Node.u8('clear_type', { + self.PLAY_MEDAL_CIRCLE_FAILED: self.GAME_PLAY_MEDAL_CIRCLE_FAILED, + self.PLAY_MEDAL_DIAMOND_FAILED: self.GAME_PLAY_MEDAL_DIAMOND_FAILED, + self.PLAY_MEDAL_STAR_FAILED: self.GAME_PLAY_MEDAL_STAR_FAILED, + self.PLAY_MEDAL_EASY_CLEAR: self.GAME_PLAY_MEDAL_EASY_CLEAR, + self.PLAY_MEDAL_CIRCLE_CLEARED: self.GAME_PLAY_MEDAL_CIRCLE_CLEARED, + self.PLAY_MEDAL_DIAMOND_CLEARED: self.GAME_PLAY_MEDAL_DIAMOND_CLEARED, + self.PLAY_MEDAL_STAR_CLEARED: self.GAME_PLAY_MEDAL_STAR_CLEARED, + self.PLAY_MEDAL_CIRCLE_FULL_COMBO: self.GAME_PLAY_MEDAL_CIRCLE_FULL_COMBO, + self.PLAY_MEDAL_DIAMOND_FULL_COMBO: self.GAME_PLAY_MEDAL_DIAMOND_FULL_COMBO, + self.PLAY_MEDAL_STAR_FULL_COMBO: self.GAME_PLAY_MEDAL_STAR_FULL_COMBO, + self.PLAY_MEDAL_PERFECT: self.GAME_PLAY_MEDAL_PERFECT, + }[score.data.get_int('medal')])) + music.add_child(Node.u8('clear_rank', self.__score_to_rank(score.points))) + music.add_child(Node.s16('cnt', score.plays)) + + return root + + def format_extid(self, extid: int) -> str: + data = str(extid) + crc = abs(binascii.crc32(data.encode('ascii'))) % 10000 + return f'{data}{crc:04d}' + + def format_profile(self, userid: UserID, profile: Profile) -> Node: + root = Node.void('player24') + + # Mark this as a current profile + root.add_child(Node.s8('result', 0)) + + # Basic account info + account = Node.void('account') + root.add_child(account) + account.add_child(Node.string('g_pm_id', self.format_extid(profile.extid))) + account.add_child(Node.string('name', profile.get_str('name', 'なし'))) + account.add_child(Node.s16('area_id', profile.get_int('area_id'))) + account.add_child(Node.s16('use_navi', profile.get_int('use_navi'))) + account.add_child(Node.s16('read_news', profile.get_int('read_news'))) + account.add_child(Node.s16_array('nice', profile.get_int_array('nice', 30, [-1] * 30))) + account.add_child(Node.s16_array('favorite_chara', profile.get_int_array('favorite_chara', 20, [-1] * 20))) + account.add_child(Node.s16_array('special_area', profile.get_int_array('special_area', 8, [-1] * 8))) + account.add_child(Node.s16_array('chocolate_charalist', profile.get_int_array('chocolate_charalist', 5, [-1] * 5))) + account.add_child(Node.s32('chocolate_sp_chara', profile.get_int('chocolate_sp_chara', -1))) + account.add_child(Node.s32('chocolate_pass_cnt', profile.get_int('chocolate_pass_cnt'))) + account.add_child(Node.s32('chocolate_hon_cnt', profile.get_int('chocolate_hon_cnt'))) + account.add_child(Node.s16_array('teacher_setting', profile.get_int_array('teacher_setting', 10, [-1] * 10))) + account.add_child(Node.bool('welcom_pack', False)) # Set to true to grant extra stage no matter what. + account.add_child(Node.s32('ranking_node', profile.get_int('ranking_node'))) + account.add_child(Node.s32('chara_ranking_kind_id', profile.get_int('chara_ranking_kind_id'))) + account.add_child(Node.s8('navi_evolution_flg', profile.get_int('navi_evolution_flg'))) + account.add_child(Node.s32('ranking_news_last_no', profile.get_int('ranking_news_last_no'))) + account.add_child(Node.s32('power_point', profile.get_int('power_point'))) + account.add_child(Node.s32('player_point', profile.get_int('player_point', 300))) + account.add_child(Node.s32_array('power_point_list', profile.get_int_array('power_point_list', 20, [-1] * 20))) + + # Tutorial handling is all sorts of crazy in UsaNeko. the tutorial flag + # is split into two values. The game uses the flag modulo 100 for navigation + # tutorial progress, and the flag divided by 100 for the hold note tutorial. + # The hold note tutorial will activate the first time you choose a song with + # hold notes in it, regardless of whether you say yes/no. The total times you + # have ever played Pop'n Music also factors in, as the game will only attempt + # to offer you the basic "how to play" tutorial screen and song on the playthrough + # attempt where the "total_play_cnt" value is 1. The game expects this to be 1-based, + # and if you set it to 0 for the first playthorough then it will play a mandatory + # cursed tutorial stage on the second profile load using the chart of your last + # played song and keysounds of system menu entries. Valid values for each of the + # two tutorial values is as follows: + # + # Lower values: + # 0 - Brand new profile and user has not been prompted to choose any tutorials. + # Prompts the user for the nagivation tutorial. If the user selects "no" then + # moves the tutorial state to "1" at the end of the round. If the user selects + # "yes" then moves the tutorial state to "3" immediately and starts the navigation + # tutorial. If the total play count for this user is "1" when this value is hit, + # the game will offer a basic "how to play" tutorial that can be played or skipped. + # 1 - Prompt the user on the mode select screen asking them if they want to see + # the navigation tutorial. If the user selects "no" then moves the tutorial state + # to "2" after the round. If the user selects "yes" then moves the tutorial state + # to "3" immediately. If the total play count for this user is "1" when this value + # is hit, then the game will bug out and play the hold note tutorial and then crash. + # 2 - Prompt the user on the mode select screen asking them if they want to see + # the navigation tutorial. If the user selects "no" then moves the tutorial state + # to "8" immediately. If the user selects "yes" then moves the tutorial state + # to "3" immediately. If the total play count for this user is "1" when this value + # is hit, then the game will bug out and play the hold note tutorial and then crash. + # 3 - Display some tutorial elements on most screens, and then advance the tutorial + # state to "4" on profile save. + # 4 - Display some tutorial elements on most screens, and then advance the tutorial + # state to "5" on profile save. + # 5 - Display some tutorial elements on most screens, and then prompt user with a + # repeat tutorial question. If the user selects "no" then moves the tutorial + # state to "8". If the user selects "yes" then moves the tutorial state to "3". + # 6 - Do nothing, display nothing, but advance the tutorial state to "7" at the + # end of the game. It seems that nothing requests this state. + # 7 - Display guide information prompt on the option select screen. Game moves + # this to "8" after this tutorial has been displayed. It appears that there is + # code to go to this state instead of "8" when selecting "no" on the navigation + # tutorial prompt but only when the total play count is "1". That crashes the game + # as documented above, so it is not clear how this state was ever reachable. + # 8 - Do not display any more tutorial stuff, this is a terminal state. + # + # Upper values: + # 0 - Brand new profile and user has not been asked for the above navigation tutorial + # or shown an optional "how to play" tutorial. The game will advance this to "1" + # after going through the mode and character select screens, but only if the total + # play count is "1". + # 1 - Hold note tutorial has not been activated yet and will be displayed when + # the player chooses a song with hold notes. Game moves this to "2" after this + # tutorial has been activated. + # 2 - Hold note tutorial was displayed to the user, but the mini-tutorial showing + # the hold note indicator that pops up after the hold note tutorial has not + # been displayed yet. Presumably this is just in case you play a hold note + # song on your last stage. Game moves this to "3" after this tutorial has been + # displayed. + # 3 - All hold note tutorials are finished, this is a terminal state. + statistics = self.get_play_statistics(userid) + account.add_child(Node.s16('tutorial', profile.get_int('tutorial', 100 if statistics.total_plays > 1 else 0))) + + # Stuff we never change + account.add_child(Node.s8('staff', 0)) + account.add_child(Node.s16('item_type', 0)) + account.add_child(Node.s16('item_id', 0)) + account.add_child(Node.s8('is_conv', 0)) + account.add_child(Node.s16_array('license_data', [-1] * 20)) + + # Song statistics + last_played = [x[0] for x in self.data.local.music.get_last_played(self.game, self.version, userid, 10)] + most_played = [x[0] for x in self.data.local.music.get_most_played(self.game, self.version, userid, 20)] + while len(last_played) < 10: + last_played.append(-1) + while len(most_played) < 20: + most_played.append(-1) + + account.add_child(Node.s16_array('my_best', most_played)) + account.add_child(Node.s16_array('latest_music', last_played)) + + # Player statistics + account.add_child(Node.s16('total_play_cnt', statistics.total_plays)) + account.add_child(Node.s16('today_play_cnt', statistics.today_plays)) + account.add_child(Node.s16('consecutive_days', statistics.consecutive_days)) + account.add_child(Node.s16('total_days', statistics.total_days)) + account.add_child(Node.s16('interval_day', 0)) + + # Number of rivals that are active for this version. + links = self.data.local.user.get_links(self.game, self.version, userid) + rivalcount = 0 + for link in links: + if link.type != 'rival': + continue + + if not self.has_profile(link.other_userid): + continue + + # This profile is valid. + rivalcount += 1 + account.add_child(Node.u8('active_fr_num', rivalcount)) + + # eAmuse account link + eaappli = Node.void('eaappli') + root.add_child(eaappli) + eaappli.add_child(Node.s8('relation', 1)) + + # Player info + info = Node.void('info') + root.add_child(info) + info.add_child(Node.u16('ep', profile.get_int('ep'))) + + # Player config + config = Node.void('config') + root.add_child(config) + config.add_child(Node.u8('mode', profile.get_int('mode'))) + config.add_child(Node.s16('chara', profile.get_int('chara', -1))) + config.add_child(Node.s16('music', profile.get_int('music', -1))) + config.add_child(Node.u8('sheet', profile.get_int('sheet'))) + config.add_child(Node.s8('category', profile.get_int('category', -1))) + config.add_child(Node.s8('sub_category', profile.get_int('sub_category', -1))) + config.add_child(Node.s8('chara_category', profile.get_int('chara_category', -1))) + config.add_child(Node.s16('course_id', profile.get_int('course_id', -1))) + config.add_child(Node.s8('course_folder', profile.get_int('course_folder', -1))) + config.add_child(Node.s8('ms_banner_disp', profile.get_int('ms_banner_disp', -1))) + config.add_child(Node.s8('ms_down_info', profile.get_int('ms_down_info', -1))) + config.add_child(Node.s8('ms_side_info', profile.get_int('ms_side_info', -1))) + config.add_child(Node.s8('ms_raise_type', profile.get_int('ms_raise_type', -1))) + config.add_child(Node.s8('ms_rnd_type', profile.get_int('ms_rnd_type', -1))) + config.add_child(Node.s8('banner_sort', profile.get_int('banner_sort', -1))) + + # Player options + option = Node.void('option') + option_dict = profile.get_dict('option') + root.add_child(option) + option.add_child(Node.s16('hispeed', option_dict.get_int('hispeed'))) + option.add_child(Node.u8('popkun', option_dict.get_int('popkun'))) + option.add_child(Node.bool('hidden', option_dict.get_bool('hidden'))) + option.add_child(Node.s16('hidden_rate', option_dict.get_int('hidden_rate'))) + option.add_child(Node.bool('sudden', option_dict.get_bool('sudden'))) + option.add_child(Node.s16('sudden_rate', option_dict.get_int('sudden_rate'))) + option.add_child(Node.s8('randmir', option_dict.get_int('randmir'))) + option.add_child(Node.s8('gauge_type', option_dict.get_int('gauge_type'))) + option.add_child(Node.u8('ojama_0', option_dict.get_int('ojama_0'))) + option.add_child(Node.u8('ojama_1', option_dict.get_int('ojama_1'))) + option.add_child(Node.bool('forever_0', option_dict.get_bool('forever_0'))) + option.add_child(Node.bool('forever_1', option_dict.get_bool('forever_1'))) + option.add_child(Node.bool('full_setting', option_dict.get_bool('full_setting'))) + option.add_child(Node.u8('judge', option_dict.get_int('judge'))) + option.add_child(Node.s8('guide_se', option_dict.get_int('guide_se'))) + + # Player custom category + custom_cate = Node.void('custom_cate') + root.add_child(custom_cate) + custom_cate.add_child(Node.s8('valid', 0)) + custom_cate.add_child(Node.s8('lv_min', -1)) + custom_cate.add_child(Node.s8('lv_max', -1)) + custom_cate.add_child(Node.s8('medal_min', -1)) + custom_cate.add_child(Node.s8('medal_max', -1)) + custom_cate.add_child(Node.s8('friend_no', -1)) + custom_cate.add_child(Node.s8('score_flg', -1)) + + # Navi data + navi_data = Node.void('navi_data') + root.add_child(navi_data) + if 'navi_points' in profile: + navi_data.add_child(Node.s32_array('raisePoint', profile.get_int_array('navi_points', 5))) + + # Set up achievements + achievements = self.data.local.user.get_achievements(self.game, self.version, userid) + for achievement in achievements: + if achievement.type[:5] == 'item_': + itemtype = int(achievement.type[5:]) + param = achievement.data.get_int('param') + is_new = achievement.data.get_bool('is_new') + get_time = achievement.data.get_int('get_time') + + item = Node.void('item') + root.add_child(item) + # Item type can be 0-6 inclusive and is the type of the unlock/item. + # Item 0 is music unlocks. In this case, the id is the song ID according + # to the game. Unclear what the param is supposed to be, but i've seen + # seen 8 and 0. Might be what chart is available? + # + # Item limits are as follows: + # 0: 1704 - ID is the music ID that the player purchased/unlocked. + # 1: 2201 + # 2: 3 + # 3: 97 - ID points at a character part that can be purchased on the character screen. + # 4: 1 + # 5: 1 + # 6: 60 + item.add_child(Node.u8('type', itemtype)) + item.add_child(Node.u16('id', achievement.id)) + item.add_child(Node.u16('param', param)) + item.add_child(Node.bool('is_new', is_new)) + item.add_child(Node.u64('get_time', get_time)) + + elif achievement.type == 'chara': + friendship = achievement.data.get_int('friendship') + + chara = Node.void('chara_param') + root.add_child(chara) + chara.add_child(Node.u16('chara_id', achievement.id)) + chara.add_child(Node.u16('friendship', friendship)) + + elif achievement.type == 'navi': + # There should only be 12 of these. + friendship = achievement.data.get_int('friendship') + + # This relies on the above Navi data section to ensure the navi_param + # node is created. + navi_param = Node.void('navi_param') + navi_data.add_child(navi_param) + navi_param.add_child(Node.u16('navi_id', achievement.id)) + navi_param.add_child(Node.s32('friendship', friendship)) + + elif achievement.type == 'area': + # There should only be 16 of these. + index = achievement.data.get_int('index') + points = achievement.data.get_int('points') + cleared = achievement.data.get_bool('cleared') + diary = achievement.data.get_int('diary') + + area = Node.void('area') + root.add_child(area) + area.add_child(Node.u32('area_id', achievement.id)) + area.add_child(Node.u8('chapter_index', index)) + area.add_child(Node.u16('gauge_point', points)) + area.add_child(Node.bool('is_cleared', cleared)) + area.add_child(Node.u32('diary', diary)) + + elif achievement.type[:7] == 'course_': + sheet = int(achievement.type[7:]) + + course_data = Node.void('course_data') + root.add_child(course_data) + course_data.add_child(Node.s16('course_id', achievement.id)) + course_data.add_child(Node.u8('clear_type', achievement.data.get_int('clear_type'))) + course_data.add_child(Node.u8('clear_rank', achievement.data.get_int('clear_rank'))) + course_data.add_child(Node.s32('total_score', achievement.data.get_int('score'))) + course_data.add_child(Node.s32('update_count', achievement.data.get_int('count'))) + course_data.add_child(Node.u8('sheet_num', sheet)) + + elif achievement.type == 'fes': + index = achievement.data.get_int('index') + points = achievement.data.get_int('points') + cleared = achievement.data.get_bool('cleared') + + fes = Node.void('fes') + root.add_child(fes) + fes.add_child(Node.u32('fes_id', achievement.id)) + fes.add_child(Node.u8('chapter_index', index)) + fes.add_child(Node.u16('gauge_point', points)) + fes.add_child(Node.bool('is_cleared', cleared)) + + # Handle daily mission. Note that we should be presenting 3 random IDs + # in the range of 1-228 inclusive, and presenting three new ones per day. + achievements = self.data.local.user.get_time_based_achievements( + self.game, + self.version, + userid, + since=Time.beginning_of_today(), + until=Time.end_of_today(), + ) + achievements = sorted(achievements, key=lambda a: a.timestamp) + daily_missions: Dict[int, ValidatedDict] = {} + + # Find the newest version of each daily mission completion, + # since we've sorted by time above. If we haven't started for + # today, the defaults will be set after this loop so we at least + # give the game the right ID. + for achievement in achievements: + if achievement.type == 'mission': + daily_missions[achievement.id] = achievement.data + + while len(daily_missions) < 3: + new_id = random.randint(1, 228) + if new_id not in daily_missions: + daily_missions[new_id] = ValidatedDict() + + for i, (daily_id, data) in enumerate(daily_missions.items()): + if i >= 3: + break + + points = data.get_int('points') + complete = data.get_int('complete') + + mission = Node.void('mission') + root.add_child(mission) + mission.add_child(Node.u32('mission_id', daily_id)) + mission.add_child(Node.u32('gauge_point', points)) + mission.add_child(Node.u32('mission_comp', complete)) + + # Player netvs section + netvs = Node.void('netvs') + root.add_child(netvs) + netvs.add_child(Node.s16_array('record', [0] * 6)) + netvs.add_child(Node.string('dialog', '')) + netvs.add_child(Node.string('dialog', '')) + netvs.add_child(Node.string('dialog', '')) + netvs.add_child(Node.string('dialog', '')) + netvs.add_child(Node.string('dialog', '')) + netvs.add_child(Node.string('dialog', '')) + netvs.add_child(Node.s8_array('ojama_condition', [0] * 74)) + netvs.add_child(Node.s8_array('set_ojama', [0] * 3)) + netvs.add_child(Node.s8_array('set_recommend', [0] * 3)) + netvs.add_child(Node.u32('netvs_play_cnt', 0)) + + # Character customizations + customize = Node.void('customize') + root.add_child(customize) + customize.add_child(Node.u16('effect_left', profile.get_int('effect_left'))) + customize.add_child(Node.u16('effect_center', profile.get_int('effect_center'))) + customize.add_child(Node.u16('effect_right', profile.get_int('effect_right'))) + customize.add_child(Node.u16('hukidashi', profile.get_int('hukidashi'))) + customize.add_child(Node.u16('comment_1', profile.get_int('comment_1'))) + customize.add_child(Node.u16('comment_2', profile.get_int('comment_2'))) + + # Stamp stuff + stamp = Node.void('stamp') + root.add_child(stamp) + stamp.add_child(Node.s16('stamp_id', profile.get_int('stamp_id'))) + stamp.add_child(Node.s16('cnt', profile.get_int('stamp_cnt'))) + + return root + + def unformat_profile(self, userid: UserID, request: Node, oldprofile: Profile) -> Profile: + newprofile = copy.deepcopy(oldprofile) + + account = request.child('account') + if account is not None: + newprofile.replace_int('tutorial', account.child_value('tutorial')) + newprofile.replace_int('read_news', account.child_value('read_news')) + newprofile.replace_int('area_id', account.child_value('area_id')) + newprofile.replace_int('use_navi', account.child_value('use_navi')) + newprofile.replace_int('ranking_node', account.child_value('ranking_node')) + newprofile.replace_int('chara_ranking_kind_id', account.child_value('chara_ranking_kind_id')) + newprofile.replace_int('navi_evolution_flg', account.child_value('navi_evolution_flg')) + newprofile.replace_int('ranking_news_last_no', account.child_value('ranking_news_last_no')) + newprofile.replace_int('power_point', account.child_value('power_point')) + newprofile.replace_int('player_point', account.child_value('player_point')) + + newprofile.replace_int_array('nice', 30, account.child_value('nice')) + newprofile.replace_int_array('favorite_chara', 20, account.child_value('favorite_chara')) + newprofile.replace_int_array('special_area', 8, account.child_value('special_area')) + newprofile.replace_int_array('chocolate_charalist', 5, account.child_value('chocolate_charalist')) + newprofile.replace_int('chocolate_sp_chara', account.child_value('chocolate_sp_chara')) + newprofile.replace_int('chocolate_pass_cnt', account.child_value('chocolate_pass_cnt')) + newprofile.replace_int('chocolate_hon_cnt', account.child_value('chocolate_hon_cnt')) + newprofile.replace_int('chocolate_giri_cnt', account.child_value('chocolate_giri_cnt')) + newprofile.replace_int('chocolate_kokyu_cnt', account.child_value('chocolate_kokyu_cnt')) + newprofile.replace_int_array('teacher_setting', 10, account.child_value('teacher_setting')) + newprofile.replace_int_array('power_point_list', 20, account.child_value('power_point_list')) + + info = request.child('info') + if info is not None: + newprofile.replace_int('ep', info.child_value('ep')) + + stamp = request.child('stamp') + if stamp is not None: + newprofile.replace_int('stamp_id', stamp.child_value('stamp_id')) + newprofile.replace_int('stamp_cnt', stamp.child_value('cnt')) + + config = request.child('config') + if config is not None: + newprofile.replace_int('mode', config.child_value('mode')) + newprofile.replace_int('chara', config.child_value('chara')) + newprofile.replace_int('music', config.child_value('music')) + newprofile.replace_int('sheet', config.child_value('sheet')) + newprofile.replace_int('category', config.child_value('category')) + newprofile.replace_int('sub_category', config.child_value('sub_category')) + newprofile.replace_int('chara_category', config.child_value('chara_category')) + newprofile.replace_int('course_id', config.child_value('course_id')) + newprofile.replace_int('course_folder', config.child_value('course_folder')) + newprofile.replace_int('ms_banner_disp', config.child_value('ms_banner_disp')) + newprofile.replace_int('ms_down_info', config.child_value('ms_down_info')) + newprofile.replace_int('ms_side_info', config.child_value('ms_side_info')) + newprofile.replace_int('ms_raise_type', config.child_value('ms_raise_type')) + newprofile.replace_int('ms_rnd_type', config.child_value('ms_rnd_type')) + newprofile.replace_int('banner_sort', config.child_value('banner_sort')) + + option_dict = newprofile.get_dict('option') + option = request.child('option') + if option is not None: + option_dict.replace_int('hispeed', option.child_value('hispeed')) + option_dict.replace_int('popkun', option.child_value('popkun')) + option_dict.replace_bool('hidden', option.child_value('hidden')) + option_dict.replace_int('hidden_rate', option.child_value('hidden_rate')) + option_dict.replace_bool('sudden', option.child_value('sudden')) + option_dict.replace_int('sudden_rate', option.child_value('sudden_rate')) + option_dict.replace_int('randmir', option.child_value('randmir')) + option_dict.replace_int('gauge_type', option.child_value('gauge_type')) + option_dict.replace_int('ojama_0', option.child_value('ojama_0')) + option_dict.replace_int('ojama_1', option.child_value('ojama_1')) + option_dict.replace_bool('forever_0', option.child_value('forever_0')) + option_dict.replace_bool('forever_1', option.child_value('forever_1')) + option_dict.replace_bool('full_setting', option.child_value('full_setting')) + option_dict.replace_int('judge', option.child_value('judge')) + option_dict.replace_int('guide_se', option.child_value('guide_se')) + newprofile.replace_dict('option', option_dict) + + customize = request.child('customize') + if customize is not None: + newprofile.replace_int('effect_left', customize.child_value('effect_left')) + newprofile.replace_int('effect_center', customize.child_value('effect_center')) + newprofile.replace_int('effect_right', customize.child_value('effect_right')) + newprofile.replace_int('hukidashi', customize.child_value('hukidashi')) + newprofile.replace_int('comment_1', customize.child_value('comment_1')) + newprofile.replace_int('comment_2', customize.child_value('comment_2')) + + navi_data = request.child('navi_data') + if navi_data is not None: + newprofile.replace_int_array('navi_points', 5, navi_data.child_value('raisePoint')) + + # Extract navi achievements + for node in navi_data.children: + if node.name == 'navi_param': + navi_id = node.child_value('navi_id') + friendship = node.child_value('friendship') + + self.data.local.user.put_achievement( + self.game, + self.version, + userid, + navi_id, + 'navi', + { + 'friendship': friendship, + }, + ) + + # Extract achievements + for node in request.children: + if node.name == 'item': + itemid = node.child_value('id') + itemtype = node.child_value('type') + param = node.child_value('param') + is_new = node.child_value('is_new') + get_time = node.child_value('get_time') + + self.data.local.user.put_achievement( + self.game, + self.version, + userid, + itemid, + f'item_{itemtype}', + { + 'param': param, + 'is_new': is_new, + 'get_time': get_time, + }, + ) + + elif node.name == 'chara_param': + charaid = node.child_value('chara_id') + friendship = node.child_value('friendship') + + self.data.local.user.put_achievement( + self.game, + self.version, + userid, + charaid, + 'chara', + { + 'friendship': friendship, + }, + ) + + elif node.name == 'area': + area_id = node.child_value('area_id') + index = node.child_value('chapter_index') + points = node.child_value('gauge_point') + cleared = node.child_value('is_cleared') + diary = node.child_value('diary') + + self.data.local.user.put_achievement( + self.game, + self.version, + userid, + area_id, + 'area', + { + 'index': index, + 'points': points, + 'cleared': cleared, + 'diary': diary, + }, + ) + + elif node.name == 'mission': + # If you don't send the right values on login, then + # the game sends 0 for mission_id three times. Skip + # those values since they're bogus. + mission_id = node.child_value('mission_id') + if mission_id > 0: + points = node.child_value('gauge_point') + complete = node.child_value('mission_comp') + + self.data.local.user.put_time_based_achievement( + self.game, + self.version, + userid, + mission_id, + 'mission', + { + 'points': points, + 'complete': complete, + }, + ) + + # Unlock NAVI-kun and Kenshi Yonezu after one play + for songid in [1592, 1608]: + self.data.local.user.put_achievement( + self.game, + self.version, + userid, + songid, + 'item_0', + { + 'param': 0xF, + 'is_new': False, + 'get_time': Time.now(), + }, + ) + + # Keep track of play statistics + self.update_play_statistics(userid) + + return newprofile diff --git a/bemani/backend/popn/peace.py b/bemani/backend/popn/peace.py index bad1682..041d7fe 100644 --- a/bemani/backend/popn/peace.py +++ b/bemani/backend/popn/peace.py @@ -2,11 +2,12 @@ from typing import Dict from bemani.backend.popn.base import PopnMusicBase +from bemani.backend.popn.common import PopnMusicModernBase from bemani.backend.popn.usaneko import PopnMusicUsaNeko from bemani.common import VersionConstants -class PopnMusicPeace(PopnMusicUsaNeko): +class PopnMusicPeace(PopnMusicModernBase): name = "Pop'n Music peace" version = VersionConstants.POPN_MUSIC_PEACE diff --git a/bemani/backend/popn/usaneko.py b/bemani/backend/popn/usaneko.py index 828e456..e761f7f 100644 --- a/bemani/backend/popn/usaneko.py +++ b/bemani/backend/popn/usaneko.py @@ -1,132 +1,23 @@ # vim: set fileencoding=utf-8 -import binascii -import copy -import random -from typing import Any, Dict, List, Optional, Tuple +from typing import Dict from bemani.backend.popn.base import PopnMusicBase +from bemani.backend.popn.common import PopnMusicModernBase from bemani.backend.popn.eclale import PopnMusicEclale -from bemani.common import Time, ID, Profile, ValidatedDict, VersionConstants, Parallel -from bemani.data import Data, UserID, Achievement, Link -from bemani.protocol import Node +from bemani.common import VersionConstants -class PopnMusicUsaNeko(PopnMusicBase): +class PopnMusicUsaNeko(PopnMusicModernBase): name = "Pop'n Music うさぎと猫と少年の夢" version = VersionConstants.POPN_MUSIC_USANEKO - # Chart type, as returned from the game - GAME_CHART_TYPE_EASY = 0 - GAME_CHART_TYPE_NORMAL = 1 - GAME_CHART_TYPE_HYPER = 2 - GAME_CHART_TYPE_EX = 3 - - # Medal type, as returned from the game - GAME_PLAY_MEDAL_CIRCLE_FAILED = 1 - GAME_PLAY_MEDAL_DIAMOND_FAILED = 2 - GAME_PLAY_MEDAL_STAR_FAILED = 3 - GAME_PLAY_MEDAL_EASY_CLEAR = 4 - GAME_PLAY_MEDAL_CIRCLE_CLEARED = 5 - GAME_PLAY_MEDAL_DIAMOND_CLEARED = 6 - GAME_PLAY_MEDAL_STAR_CLEARED = 7 - GAME_PLAY_MEDAL_CIRCLE_FULL_COMBO = 8 - GAME_PLAY_MEDAL_DIAMOND_FULL_COMBO = 9 - GAME_PLAY_MEDAL_STAR_FULL_COMBO = 10 - GAME_PLAY_MEDAL_PERFECT = 11 - - # Rank type, as returned from the game - GAME_PLAY_RANK_E = 1 - GAME_PLAY_RANK_D = 2 - GAME_PLAY_RANK_C = 3 - GAME_PLAY_RANK_B = 4 - GAME_PLAY_RANK_A = 5 - GAME_PLAY_RANK_AA = 6 - GAME_PLAY_RANK_AAA = 7 - GAME_PLAY_RANK_S = 8 - # Biggest ID in the music DB GAME_MAX_MUSIC_ID = 1704 def previous_version(self) -> PopnMusicBase: return PopnMusicEclale(self.data, self.config, self.model) - def extra_services(self) -> List[str]: - """ - Return the local2 and lobby2 service so that Pop'n Music 24 will - send game packets. - """ - return [ - 'local2', - 'lobby2', - ] - - @classmethod - def run_scheduled_work(cls, data: Data, config: Dict[str, Any]) -> List[Tuple[str, Dict[str, Any]]]: - """ - Once a week, insert a new course. - """ - events = [] - if data.local.network.should_schedule(cls.game, cls.version, 'course', 'weekly'): - # Generate a new course list, save it to the DB. - start_time, end_time = data.local.network.get_schedule_duration('weekly') - all_songs = [song.id for song in data.local.music.get_all_songs(cls.game, cls.version)] - if all_songs: - course_song = random.choice(all_songs) - data.local.game.put_time_sensitive_settings( - cls.game, - cls.version, - 'course', - { - 'start_time': start_time, - 'end_time': end_time, - 'music': course_song, - }, - ) - events.append(( - 'pnm_course', - { - 'version': cls.version, - 'song': course_song, - }, - )) - - # Mark that we did some actual work here. - data.local.network.mark_scheduled(cls.game, cls.version, 'course', 'weekly') - return events - - def __score_to_rank(self, score: int) -> int: - if score < 50000: - return self.GAME_PLAY_RANK_E - if score < 62000: - return self.GAME_PLAY_RANK_D - if score < 72000: - return self.GAME_PLAY_RANK_C - if score < 82000: - return self.GAME_PLAY_RANK_B - if score < 90000: - return self.GAME_PLAY_RANK_A - if score < 95000: - return self.GAME_PLAY_RANK_AA - if score < 98000: - return self.GAME_PLAY_RANK_AAA - return self.GAME_PLAY_RANK_S - - def handle_lobby24_request(self, request: Node) -> Node: - # Stub out the entire lobby24 service - return Node.void('lobby24') - - def handle_pcb24_error_request(self, request: Node) -> Node: - return Node.void('pcb24') - - def handle_pcb24_boot_request(self, request: Node) -> Node: - return Node.void('pcb24') - - def handle_pcb24_write_request(self, request: Node) -> Node: - # Update the name of this cab for admin purposes - self.update_machine_name(request.child_value('pcb_setting/name')) - return Node.void('pcb24') - def get_phases(self) -> Dict[int, int]: # Event phases # TODO: Hook event mode settings up to the front end. @@ -160,1202 +51,3 @@ class PopnMusicUsaNeko(PopnMusicBase): # Enable Pop'n Peace preview song (0-1) 13: 1, } - - def __construct_common_info(self, root: Node) -> None: - for phaseid, phase_value in self.get_phases().items(): - phase = Node.void('phase') - root.add_child(phase) - phase.add_child(Node.s16('event_id', phaseid)) - phase.add_child(Node.s16('phase', phase_value)) - - # Gather course information and course ranking for users. - course_infos, achievements, profiles = Parallel.execute([ - lambda: self.data.local.game.get_all_time_sensitive_settings(self.game, self.version, 'course'), - lambda: self.data.local.user.get_all_achievements(self.game, self.version), - lambda: self.data.local.user.get_all_profiles(self.game, self.version), - ]) - # Sort courses by newest to oldest so we can grab the newest 256. - course_infos = sorted( - course_infos, - key=lambda c: c['start_time'], - reverse=True, - ) - # Sort achievements within course ID from best to worst ranking. - achievements_by_course_id: Dict[int, Dict[str, List[Tuple[UserID, Achievement]]]] = {} - type_to_chart_lut: Dict[str, str] = { - f'course_{self.GAME_CHART_TYPE_EASY}': "loc_ranking_e", - f'course_{self.GAME_CHART_TYPE_NORMAL}': "loc_ranking_n", - f'course_{self.GAME_CHART_TYPE_HYPER}': "loc_ranking_h", - f'course_{self.GAME_CHART_TYPE_EX}': "loc_ranking_ex", - } - for uid, ach in achievements: - if ach.type[:7] != 'course_': - continue - if ach.id not in achievements_by_course_id: - achievements_by_course_id[ach.id] = { - "loc_ranking_e": [], - "loc_ranking_n": [], - "loc_ranking_h": [], - "loc_ranking_ex": [], - } - achievements_by_course_id[ach.id][type_to_chart_lut[ach.type]].append((uid, ach)) - for courseid in achievements_by_course_id: - for chart in ["loc_ranking_e", "loc_ranking_n", "loc_ranking_h", "loc_ranking_ex"]: - achievements_by_course_id[courseid][chart] = sorted( - achievements_by_course_id[courseid][chart], - key=lambda uid_and_ach: uid_and_ach[1].data.get_int('score'), - reverse=True, - ) - - # Cache of userID to profile - userid_to_profile: Dict[UserID, Profile] = {uid: profile for (uid, profile) in profiles} - - # Course ranking info for the last 256 courses - for course_info in course_infos[:256]: - course_id = int(course_info['start_time'] / 604800) - course_rankings = achievements_by_course_id.get(course_id, {}) - - ranking_info = Node.void('ranking_info') - root.add_child(ranking_info) - ranking_info.add_child(Node.s16('course_id', course_id)) - ranking_info.add_child(Node.u64('start_date', course_info['start_time'] * 1000)) - ranking_info.add_child(Node.u64('end_date', course_info['end_time'] * 1000)) - ranking_info.add_child(Node.s32('music_id', course_info['music'])) - - # Top 20 rankings for each particular chart. - for name in ["loc_ranking_e", "loc_ranking_n", "loc_ranking_h", "loc_ranking_ex"]: - chart_rankings = course_rankings.get(name, []) - - for pos, (uid, ach) in enumerate(chart_rankings[:20]): - profile = userid_to_profile.get(uid, Profile(self.game, self.version, "", 0)) - - subnode = Node.void(name) - ranking_info.add_child(subnode) - subnode.add_child(Node.s16('rank', pos + 1)) - subnode.add_child(Node.string('name', profile.get_str('name'))) - subnode.add_child(Node.s16('chara_num', profile.get_int('chara', -1))) - subnode.add_child(Node.s32('total_score', ach.data.get_int('score'))) - subnode.add_child(Node.u8('clear_type', ach.data.get_int('clear_type'))) - subnode.add_child(Node.u8('clear_rank', ach.data.get_int('clear_rank'))) - - for area_id in range(0, 16): - area = Node.void('area') - root.add_child(area) - area.add_child(Node.s16('area_id', area_id)) - area.add_child(Node.u64('end_date', 0)) - area.add_child(Node.s16('medal_id', area_id)) - area.add_child(Node.bool('is_limit', False)) - - for choco_id in range(0, 5): - choco = Node.void('choco') - root.add_child(choco) - choco.add_child(Node.s16('choco_id', choco_id)) - choco.add_child(Node.s32('param', -1)) - - # Set up goods, educated guess here. - for goods_id in range(97): - if goods_id < 15: - price = 30 - elif goods_id < 30: - price = 40 - elif goods_id < 45: - price = 60 - elif goods_id < 60: - price = 80 - else: - price = 200 - goods = Node.void('goods') - root.add_child(goods) - goods.add_child(Node.s32('item_id', goods_id + 1)) - goods.add_child(Node.s16('item_type', 3)) - goods.add_child(Node.s32('price', price)) - goods.add_child(Node.s16('goods_type', 0)) - - # Ignoring NAVIfes node, we don't set these. - # fes = Node.void('fes') - # fes.add_child(Node.s16('fes_id', -1)) - # fes.add_child(Node.s32('gauge_count', -1)) - # fes.add_child(Node.s32_array('gauge', [-1, -1, -1, -1, -1, -1])) - # fes.add_child(Node.s32_array('music', [-1, -1, -1, -1, -1, -1])) - # fes.add_child(Node.s16('r', -1)) - # fes.add_child(Node.s16('g', -1)) - # fes.add_child(Node.s16('b', -1)) - # fes.add_child(Node.s16('poster', -1)) - - # Calculate most popular characters - profiles = self.data.remote.user.get_all_profiles(self.game, self.version) - charas: Dict[int, int] = {} - for (_userid, profile) in profiles: - chara = profile.get_int('chara', -1) - if chara <= 0: - continue - if chara not in charas: - charas[chara] = 1 - else: - charas[chara] = charas[chara] + 1 - - # Order a typle by most popular character to least popular character - charamap = sorted( - [(c, charas[c]) for c in charas], - key=lambda c: c[1], - reverse=True, - ) - - # Top 20 Popular characters - for rank, (charaid, _usecount) in enumerate(charamap[:20]): - popular = Node.void('popular') - root.add_child(popular) - popular.add_child(Node.s16('rank', rank + 1)) - popular.add_child(Node.s16('chara_num', charaid)) - - # Top 500 Popular music - for (songid, _plays) in self.data.local.music.get_hit_chart(self.game, self.version, 500): - popular_music = Node.void('popular_music') - root.add_child(popular_music) - popular_music.add_child(Node.s16('music_num', songid)) - - # Ignoring recommended music, we don't set this - # recommend = Node.void('recommend') - # root.add_child(recommend) - # recommend.add_child(Node.s32_array('music_no', [-1] * 30)) - - # Ignoring mission points, we don't set these. - # mission_point = Node.void('mission_point') - # mission_point.add_child(Node.s32('point', -1)) - # mission_point.add_child(Node.s32('bonus_point', -1)) - - # Ignoring medals, we don't set these. - # medal = Node.void('medal') - # medal.add_child(Node.s16('medal_id', -1)) - # medal.add_child(Node.s16('percent', -1)) - - # Ignoring chara ranking, we don't set these. - # chara_ranking = Node.void('chara_ranking') - # chara_ranking.add_child(Node.s32('rank', -1)) - # chara_ranking.add_child(Node.s32('kind_id', -1)) - # chara_ranking.add_child(Node.s32('point', -1)) - # chara_ranking.add_child(Node.s32('month', -1)) - - def handle_info24_common_request(self, root: Node) -> Node: - root = Node.void('info24') - self.__construct_common_info(root) - return root - - def handle_player24_new_request(self, request: Node) -> Node: - refid = request.child_value('ref_id') - name = request.child_value('name') - root = self.new_profile_by_refid(refid, name) - if root is None: - root = Node.void('player24') - root.add_child(Node.s8('result', 2)) - return root - - def handle_player24_conversion_request(self, request: Node) -> Node: - refid = request.child_value('ref_id') - name = request.child_value('name') - chara = request.child_value('chara') - achievements: List[Achievement] = [] - for node in request.children: - if node.name == 'item': - itemid = node.child_value('id') - itemtype = node.child_value('type') - param = node.child_value('param') - is_new = node.child_value('is_new') - get_time = node.child_value('get_time') - - achievements.append( - Achievement( - itemid, - f'item_{itemtype}', - 0, - { - 'param': param, - 'is_new': is_new, - 'get_time': get_time, - }, - ) - ) - root = self.new_profile_by_refid(refid, name, chara, achievements=achievements) - if root is None: - root = Node.void('player24') - root.add_child(Node.s8('result', 2)) - return root - - def handle_player24_read_request(self, request: Node) -> Node: - refid = request.child_value('ref_id') - root = self.get_profile_by_refid(refid, self.OLD_PROFILE_FALLTHROUGH) - if root is None: - root = Node.void('player24') - root.add_child(Node.s8('result', 2)) - return root - - def handle_player24_write_request(self, request: Node) -> Node: - refid = request.child_value('ref_id') - - if refid is not None: - userid = self.data.remote.user.from_refid(self.game, self.version, refid) - else: - userid = None - - if userid is not None: - oldprofile = self.get_profile(userid) or Profile(self.game, self.version, refid, 0) - newprofile = self.unformat_profile(userid, request, oldprofile) - - if newprofile is not None: - self.put_profile(userid, newprofile) - - return Node.void('player24') - - def handle_player24_update_ranking_request(self, request: Node) -> Node: - refid = request.child_value('ref_id') - root = Node.void('player24') - - if refid is not None: - userid = self.data.remote.user.from_refid(self.game, self.version, refid) - else: - userid = None - - if userid is not None: - course_id = request.child_value('course_id') - chart = request.child_value('sheet_num') - score = request.child_value('total_score') - clear_type = request.child_value('clear_type') - clear_rank = request.child_value('clear_rank') - prefecture = request.child_value('pref') - loc_id = ID.parse_machine_id(request.child_value('location_id')) - course_type = f"course_{chart}" - - old_course = self.data.local.user.get_achievement( - self.game, - self.version, - userid, - course_id, - course_type, - ) - if old_course is None: - old_course = ValidatedDict() - - new_course = ValidatedDict({ - 'score': max(score, old_course.get_int('score')), - 'clear_type': max(clear_type, old_course.get_int('clear_type')), - 'clear_rank': max(clear_rank, old_course.get_int('clear_rank')), - 'pref': prefecture, - 'lid': loc_id, - 'count': old_course.get_int('count') + 1, - }) - - self.data.local.user.put_achievement( - self.game, - self.version, - userid, - course_id, - course_type, - new_course, - ) - - # Handle fetching all scores - uids_and_courses, profile = Parallel.execute([ - lambda: self.data.local.user.get_all_achievements(self.game, self.version), - lambda: self.get_profile(userid) or Profile(self.game, self.version, "", 0) - ]) - - # Grab a sorted list of all scores for this course and chart - global_uids_and_courses = sorted( - [(uid, ach) for (uid, ach) in uids_and_courses if ach.type == course_type and ach.id == course_id], - key=lambda uid_and_course: uid_and_course[1].data.get_int('score'), - reverse=True, - ) - # Grab smaller lists that contain only sorted for our prefecture/location - pref_uids_and_courses = [(uid, ach) for (uid, ach) in global_uids_and_courses if ach.data.get_int('pref') == prefecture] - loc_uids_and_courses = [(uid, ach) for (uid, ach) in global_uids_and_courses if ach.data.get_int('lid') == loc_id] - - def _get_rank(uac: List[Tuple[UserID, Achievement]]) -> Optional[int]: - for rank, (uid, _) in enumerate(uac): - if uid == userid: - return rank + 1 - return None - - for nodename, ranklist in [ - ("all_ranking", global_uids_and_courses), - ("pref_ranking", pref_uids_and_courses), - ("location_ranking", loc_uids_and_courses), - ]: - # Grab the rank, bail if we don't have any answer since the game doesn't - # require a response. - rank = _get_rank(ranklist) - if rank is None: - continue - - # Send back the data for this ranking. - node = Node.void(nodename) - root.add_child(node) - node.add_child(Node.string("name", profile.get_str('name', 'なし'))) - node.add_child(Node.s16("chara_num", profile.get_int('chara', -1))) - node.add_child(Node.s32("total_score", new_course.get_int('score'))) - node.add_child(Node.u8("clear_type", new_course.get_int('clear_type'))) - node.add_child(Node.u8("clear_rank", new_course.get_int('clear_rank'))) - node.add_child(Node.s16("player_count", len(ranklist))) - node.add_child(Node.s16("player_rank", rank)) - - return root - - def handle_player24_friend_request(self, request: Node) -> Node: - refid = request.attribute('ref_id') - no = int(request.attribute('no', '-1')) - - root = Node.void('player24') - if no < 0: - root.add_child(Node.s8('result', 2)) - return root - - # Look up our own user ID based on the RefID provided. - userid = self.data.remote.user.from_refid(self.game, self.version, refid) - if userid is None: - root.add_child(Node.s8('result', 2)) - return root - - # Grab the links that we care about. - links = self.data.local.user.get_links(self.game, self.version, userid) - profiles: Dict[UserID, Profile] = {} - rivals: List[Link] = [] - for link in links: - if link.type != 'rival': - continue - - other_profile = self.get_profile(link.other_userid) - if other_profile is None: - continue - profiles[link.other_userid] = other_profile - rivals.append(link) - - # Somehow requested an invalid profile. - if no >= len(rivals): - root.add_child(Node.s8('result', 2)) - return root - rivalid = links[no].other_userid - rivalprofile = profiles[rivalid] - scores = self.data.remote.music.get_scores(self.game, self.version, rivalid) - - # First, output general profile info. - friend = Node.void('friend') - root.add_child(friend) - friend.add_child(Node.s16('no', no)) - friend.add_child(Node.string('g_pm_id', self.format_extid(rivalprofile.extid))) # UsaNeko formats on its own - friend.add_child(Node.string('name', rivalprofile.get_str('name', 'なし'))) - friend.add_child(Node.s16('chara', rivalprofile.get_int('chara', -1))) - # This might be for having non-active or non-confirmed friends, but setting to 0 makes the - # ranking numbers disappear and the player icon show a questionmark. - friend.add_child(Node.s8('is_open', 1)) - - for score in scores: - # Skip any scores for chart types we don't support - if score.chart not in [ - self.CHART_TYPE_EASY, - self.CHART_TYPE_NORMAL, - self.CHART_TYPE_HYPER, - self.CHART_TYPE_EX, - ]: - continue - if score.data.get_int('medal') == self.PLAY_MEDAL_NO_PLAY: - continue - - points = score.points - medal = score.data.get_int('medal') - - music = Node.void('music') - friend.add_child(music) - music.set_attribute('music_num', str(score.id)) - music.set_attribute('sheet_num', str({ - self.CHART_TYPE_EASY: self.GAME_CHART_TYPE_EASY, - self.CHART_TYPE_NORMAL: self.GAME_CHART_TYPE_NORMAL, - self.CHART_TYPE_HYPER: self.GAME_CHART_TYPE_HYPER, - self.CHART_TYPE_EX: self.GAME_CHART_TYPE_EX, - }[score.chart])) - music.set_attribute('score', str(points)) - music.set_attribute('clearrank', str(self.__score_to_rank(score.points))) - music.set_attribute('cleartype', str({ - self.PLAY_MEDAL_CIRCLE_FAILED: self.GAME_PLAY_MEDAL_CIRCLE_FAILED, - self.PLAY_MEDAL_DIAMOND_FAILED: self.GAME_PLAY_MEDAL_DIAMOND_FAILED, - self.PLAY_MEDAL_STAR_FAILED: self.GAME_PLAY_MEDAL_STAR_FAILED, - self.PLAY_MEDAL_EASY_CLEAR: self.GAME_PLAY_MEDAL_EASY_CLEAR, - self.PLAY_MEDAL_CIRCLE_CLEARED: self.GAME_PLAY_MEDAL_CIRCLE_CLEARED, - self.PLAY_MEDAL_DIAMOND_CLEARED: self.GAME_PLAY_MEDAL_DIAMOND_CLEARED, - self.PLAY_MEDAL_STAR_CLEARED: self.GAME_PLAY_MEDAL_STAR_CLEARED, - self.PLAY_MEDAL_CIRCLE_FULL_COMBO: self.GAME_PLAY_MEDAL_CIRCLE_FULL_COMBO, - self.PLAY_MEDAL_DIAMOND_FULL_COMBO: self.GAME_PLAY_MEDAL_DIAMOND_FULL_COMBO, - self.PLAY_MEDAL_STAR_FULL_COMBO: self.GAME_PLAY_MEDAL_STAR_FULL_COMBO, - self.PLAY_MEDAL_PERFECT: self.GAME_PLAY_MEDAL_PERFECT, - }[medal])) - - achievements = self.data.local.user.get_achievements(self.game, self.version, rivalid) - for achievement in achievements: - if achievement.type[:7] == 'course_': - sheet = int(achievement.type[7:]) - - course_data = Node.void('course_data') - root.add_child(course_data) - course_data.add_child(Node.s16('course_id', achievement.id)) - course_data.add_child(Node.u8('clear_type', achievement.data.get_int('clear_type'))) - course_data.add_child(Node.u8('clear_rank', achievement.data.get_int('clear_rank'))) - course_data.add_child(Node.s32('total_score', achievement.data.get_int('score'))) - course_data.add_child(Node.s32('update_count', achievement.data.get_int('count'))) - course_data.add_child(Node.u8('sheet_num', sheet)) - - return root - - def handle_player24_read_score_request(self, request: Node) -> Node: - refid = request.child_value('ref_id') - userid = self.data.remote.user.from_refid(self.game, self.version, refid) - if userid is None: - return Node.void('player24') - - root = Node.void('player24') - scores = self.data.remote.music.get_scores(self.game, self.version, userid) - for score in scores: - # Skip any scores for chart types we don't support - if score.chart not in [ - self.CHART_TYPE_EASY, - self.CHART_TYPE_NORMAL, - self.CHART_TYPE_HYPER, - self.CHART_TYPE_EX, - ]: - continue - if score.data.get_int('medal') == self.PLAY_MEDAL_NO_PLAY: - continue - - music = Node.void('music') - root.add_child(music) - music.add_child(Node.s16('music_num', score.id)) - music.add_child(Node.u8('sheet_num', { - self.CHART_TYPE_EASY: self.GAME_CHART_TYPE_EASY, - self.CHART_TYPE_NORMAL: self.GAME_CHART_TYPE_NORMAL, - self.CHART_TYPE_HYPER: self.GAME_CHART_TYPE_HYPER, - self.CHART_TYPE_EX: self.GAME_CHART_TYPE_EX, - }[score.chart])) - music.add_child(Node.s32('score', score.points)) - music.add_child(Node.u8('clear_type', { - self.PLAY_MEDAL_CIRCLE_FAILED: self.GAME_PLAY_MEDAL_CIRCLE_FAILED, - self.PLAY_MEDAL_DIAMOND_FAILED: self.GAME_PLAY_MEDAL_DIAMOND_FAILED, - self.PLAY_MEDAL_STAR_FAILED: self.GAME_PLAY_MEDAL_STAR_FAILED, - self.PLAY_MEDAL_EASY_CLEAR: self.GAME_PLAY_MEDAL_EASY_CLEAR, - self.PLAY_MEDAL_CIRCLE_CLEARED: self.GAME_PLAY_MEDAL_CIRCLE_CLEARED, - self.PLAY_MEDAL_DIAMOND_CLEARED: self.GAME_PLAY_MEDAL_DIAMOND_CLEARED, - self.PLAY_MEDAL_STAR_CLEARED: self.GAME_PLAY_MEDAL_STAR_CLEARED, - self.PLAY_MEDAL_CIRCLE_FULL_COMBO: self.GAME_PLAY_MEDAL_CIRCLE_FULL_COMBO, - self.PLAY_MEDAL_DIAMOND_FULL_COMBO: self.GAME_PLAY_MEDAL_DIAMOND_FULL_COMBO, - self.PLAY_MEDAL_STAR_FULL_COMBO: self.GAME_PLAY_MEDAL_STAR_FULL_COMBO, - self.PLAY_MEDAL_PERFECT: self.GAME_PLAY_MEDAL_PERFECT, - }[score.data.get_int('medal')])) - music.add_child(Node.u8('clear_rank', self.__score_to_rank(score.points))) - music.add_child(Node.s16('cnt', score.plays)) - - return root - - def handle_player24_write_music_request(self, request: Node) -> Node: - refid = request.child_value('ref_id') - - root = Node.void('player24') - if refid is None: - return root - - userid = self.data.remote.user.from_refid(self.game, self.version, refid) - if userid is None: - return root - - songid = request.child_value('music_num') - chart = { - self.GAME_CHART_TYPE_EASY: self.CHART_TYPE_EASY, - self.GAME_CHART_TYPE_NORMAL: self.CHART_TYPE_NORMAL, - self.GAME_CHART_TYPE_HYPER: self.CHART_TYPE_HYPER, - self.GAME_CHART_TYPE_EX: self.CHART_TYPE_EX, - }[request.child_value('sheet_num')] - medal = request.child_value('clear_type') - points = request.child_value('score') - combo = request.child_value('combo') - stats = { - 'cool': request.child_value('cool'), - 'great': request.child_value('great'), - 'good': request.child_value('good'), - 'bad': request.child_value('bad') - } - medal = { - self.GAME_PLAY_MEDAL_CIRCLE_FAILED: self.PLAY_MEDAL_CIRCLE_FAILED, - self.GAME_PLAY_MEDAL_DIAMOND_FAILED: self.PLAY_MEDAL_DIAMOND_FAILED, - self.GAME_PLAY_MEDAL_STAR_FAILED: self.PLAY_MEDAL_STAR_FAILED, - self.GAME_PLAY_MEDAL_EASY_CLEAR: self.PLAY_MEDAL_EASY_CLEAR, - self.GAME_PLAY_MEDAL_CIRCLE_CLEARED: self.PLAY_MEDAL_CIRCLE_CLEARED, - self.GAME_PLAY_MEDAL_DIAMOND_CLEARED: self.PLAY_MEDAL_DIAMOND_CLEARED, - self.GAME_PLAY_MEDAL_STAR_CLEARED: self.PLAY_MEDAL_STAR_CLEARED, - self.GAME_PLAY_MEDAL_CIRCLE_FULL_COMBO: self.PLAY_MEDAL_CIRCLE_FULL_COMBO, - self.GAME_PLAY_MEDAL_DIAMOND_FULL_COMBO: self.PLAY_MEDAL_DIAMOND_FULL_COMBO, - self.GAME_PLAY_MEDAL_STAR_FULL_COMBO: self.PLAY_MEDAL_STAR_FULL_COMBO, - self.GAME_PLAY_MEDAL_PERFECT: self.PLAY_MEDAL_PERFECT, - }[medal] - self.update_score(userid, songid, chart, points, medal, combo=combo, stats=stats) - - if request.child_value('is_image_store') == 1: - self.broadcast_score(userid, songid, chart, medal, points, combo, stats) - - return root - - def handle_player24_start_request(self, request: Node) -> Node: - root = Node.void('player24') - root.add_child(Node.s32('play_id', 0)) - self.__construct_common_info(root) - return root - - def handle_player24_logout_request(self, request: Node) -> Node: - return Node.void('player24') - - def handle_player24_buy_request(self, request: Node) -> Node: - refid = request.child_value('ref_id') - - if refid is not None: - userid = self.data.remote.user.from_refid(self.game, self.version, refid) - else: - userid = None - - if userid is not None: - itemid = request.child_value('id') - itemtype = request.child_value('type') - itemparam = request.child_value('param') - - price = request.child_value('price') - lumina = request.child_value('lumina') - - if lumina >= price: - # Update player lumina balance - profile = self.get_profile(userid) or Profile(self.game, self.version, refid, 0) - profile.replace_int('player_point', lumina - price) - self.put_profile(userid, profile) - - # Grant the object - self.data.local.user.put_achievement( - self.game, - self.version, - userid, - itemid, - f'item_{itemtype}', - { - 'param': itemparam, - 'is_new': True, - }, - ) - - return Node.void('player24') - - def format_conversion(self, userid: UserID, profile: Profile) -> Node: - root = Node.void('player24') - root.add_child(Node.string('name', profile.get_str('name', 'なし'))) - root.add_child(Node.s16('chara', profile.get_int('chara', -1))) - root.add_child(Node.s8('con_type', 0)) - root.add_child(Node.s8('result', 1)) - - # Scores - scores = self.data.remote.music.get_scores(self.game, self.version, userid) - for score in scores: - # Skip any scores for chart types we don't support - if score.chart not in [ - self.CHART_TYPE_EASY, - self.CHART_TYPE_NORMAL, - self.CHART_TYPE_HYPER, - self.CHART_TYPE_EX, - ]: - continue - if score.data.get_int('medal') == self.PLAY_MEDAL_NO_PLAY: - continue - - music = Node.void('music') - root.add_child(music) - music.add_child(Node.s16('music_num', score.id)) - music.add_child(Node.u8('sheet_num', { - self.CHART_TYPE_EASY: self.GAME_CHART_TYPE_EASY, - self.CHART_TYPE_NORMAL: self.GAME_CHART_TYPE_NORMAL, - self.CHART_TYPE_HYPER: self.GAME_CHART_TYPE_HYPER, - self.CHART_TYPE_EX: self.GAME_CHART_TYPE_EX, - }[score.chart])) - music.add_child(Node.s32('score', score.points)) - music.add_child(Node.u8('clear_type', { - self.PLAY_MEDAL_CIRCLE_FAILED: self.GAME_PLAY_MEDAL_CIRCLE_FAILED, - self.PLAY_MEDAL_DIAMOND_FAILED: self.GAME_PLAY_MEDAL_DIAMOND_FAILED, - self.PLAY_MEDAL_STAR_FAILED: self.GAME_PLAY_MEDAL_STAR_FAILED, - self.PLAY_MEDAL_EASY_CLEAR: self.GAME_PLAY_MEDAL_EASY_CLEAR, - self.PLAY_MEDAL_CIRCLE_CLEARED: self.GAME_PLAY_MEDAL_CIRCLE_CLEARED, - self.PLAY_MEDAL_DIAMOND_CLEARED: self.GAME_PLAY_MEDAL_DIAMOND_CLEARED, - self.PLAY_MEDAL_STAR_CLEARED: self.GAME_PLAY_MEDAL_STAR_CLEARED, - self.PLAY_MEDAL_CIRCLE_FULL_COMBO: self.GAME_PLAY_MEDAL_CIRCLE_FULL_COMBO, - self.PLAY_MEDAL_DIAMOND_FULL_COMBO: self.GAME_PLAY_MEDAL_DIAMOND_FULL_COMBO, - self.PLAY_MEDAL_STAR_FULL_COMBO: self.GAME_PLAY_MEDAL_STAR_FULL_COMBO, - self.PLAY_MEDAL_PERFECT: self.GAME_PLAY_MEDAL_PERFECT, - }[score.data.get_int('medal')])) - music.add_child(Node.u8('clear_rank', self.__score_to_rank(score.points))) - music.add_child(Node.s16('cnt', score.plays)) - - return root - - def format_extid(self, extid: int) -> str: - data = str(extid) - crc = abs(binascii.crc32(data.encode('ascii'))) % 10000 - return f'{data}{crc:04d}' - - def format_profile(self, userid: UserID, profile: Profile) -> Node: - root = Node.void('player24') - - # Mark this as a current profile - root.add_child(Node.s8('result', 0)) - - # Basic account info - account = Node.void('account') - root.add_child(account) - account.add_child(Node.string('g_pm_id', self.format_extid(profile.extid))) - account.add_child(Node.string('name', profile.get_str('name', 'なし'))) - account.add_child(Node.s16('area_id', profile.get_int('area_id'))) - account.add_child(Node.s16('use_navi', profile.get_int('use_navi'))) - account.add_child(Node.s16('read_news', profile.get_int('read_news'))) - account.add_child(Node.s16_array('nice', profile.get_int_array('nice', 30, [-1] * 30))) - account.add_child(Node.s16_array('favorite_chara', profile.get_int_array('favorite_chara', 20, [-1] * 20))) - account.add_child(Node.s16_array('special_area', profile.get_int_array('special_area', 8, [-1] * 8))) - account.add_child(Node.s16_array('chocolate_charalist', profile.get_int_array('chocolate_charalist', 5, [-1] * 5))) - account.add_child(Node.s32('chocolate_sp_chara', profile.get_int('chocolate_sp_chara', -1))) - account.add_child(Node.s32('chocolate_pass_cnt', profile.get_int('chocolate_pass_cnt'))) - account.add_child(Node.s32('chocolate_hon_cnt', profile.get_int('chocolate_hon_cnt'))) - account.add_child(Node.s16_array('teacher_setting', profile.get_int_array('teacher_setting', 10, [-1] * 10))) - account.add_child(Node.bool('welcom_pack', False)) # Set to true to grant extra stage no matter what. - account.add_child(Node.s32('ranking_node', profile.get_int('ranking_node'))) - account.add_child(Node.s32('chara_ranking_kind_id', profile.get_int('chara_ranking_kind_id'))) - account.add_child(Node.s8('navi_evolution_flg', profile.get_int('navi_evolution_flg'))) - account.add_child(Node.s32('ranking_news_last_no', profile.get_int('ranking_news_last_no'))) - account.add_child(Node.s32('power_point', profile.get_int('power_point'))) - account.add_child(Node.s32('player_point', profile.get_int('player_point', 300))) - account.add_child(Node.s32_array('power_point_list', profile.get_int_array('power_point_list', 20, [-1] * 20))) - - # Tutorial handling is all sorts of crazy in UsaNeko. the tutorial flag - # is split into two values. The game uses the flag modulo 100 for navigation - # tutorial progress, and the flag divided by 100 for the hold note tutorial. - # The hold note tutorial will activate the first time you choose a song with - # hold notes in it, regardless of whether you say yes/no. The total times you - # have ever played Pop'n Music also factors in, as the game will only attempt - # to offer you the basic "how to play" tutorial screen and song on the playthrough - # attempt where the "total_play_cnt" value is 1. The game expects this to be 1-based, - # and if you set it to 0 for the first playthorough then it will play a mandatory - # cursed tutorial stage on the second profile load using the chart of your last - # played song and keysounds of system menu entries. Valid values for each of the - # two tutorial values is as follows: - # - # Lower values: - # 0 - Brand new profile and user has not been prompted to choose any tutorials. - # Prompts the user for the nagivation tutorial. If the user selects "no" then - # moves the tutorial state to "1" at the end of the round. If the user selects - # "yes" then moves the tutorial state to "3" immediately and starts the navigation - # tutorial. If the total play count for this user is "1" when this value is hit, - # the game will offer a basic "how to play" tutorial that can be played or skipped. - # 1 - Prompt the user on the mode select screen asking them if they want to see - # the navigation tutorial. If the user selects "no" then moves the tutorial state - # to "2" after the round. If the user selects "yes" then moves the tutorial state - # to "3" immediately. If the total play count for this user is "1" when this value - # is hit, then the game will bug out and play the hold note tutorial and then crash. - # 2 - Prompt the user on the mode select screen asking them if they want to see - # the navigation tutorial. If the user selects "no" then moves the tutorial state - # to "8" immediately. If the user selects "yes" then moves the tutorial state - # to "3" immediately. If the total play count for this user is "1" when this value - # is hit, then the game will bug out and play the hold note tutorial and then crash. - # 3 - Display some tutorial elements on most screens, and then advance the tutorial - # state to "4" on profile save. - # 4 - Display some tutorial elements on most screens, and then advance the tutorial - # state to "5" on profile save. - # 5 - Display some tutorial elements on most screens, and then prompt user with a - # repeat tutorial question. If the user selects "no" then moves the tutorial - # state to "8". If the user selects "yes" then moves the tutorial state to "3". - # 6 - Do nothing, display nothing, but advance the tutorial state to "7" at the - # end of the game. It seems that nothing requests this state. - # 7 - Display guide information prompt on the option select screen. Game moves - # this to "8" after this tutorial has been displayed. It appears that there is - # code to go to this state instead of "8" when selecting "no" on the navigation - # tutorial prompt but only when the total play count is "1". That crashes the game - # as documented above, so it is not clear how this state was ever reachable. - # 8 - Do not display any more tutorial stuff, this is a terminal state. - # - # Upper values: - # 0 - Brand new profile and user has not been asked for the above navigation tutorial - # or shown an optional "how to play" tutorial. The game will advance this to "1" - # after going through the mode and character select screens, but only if the total - # play count is "1". - # 1 - Hold note tutorial has not been activated yet and will be displayed when - # the player chooses a song with hold notes. Game moves this to "2" after this - # tutorial has been activated. - # 2 - Hold note tutorial was displayed to the user, but the mini-tutorial showing - # the hold note indicator that pops up after the hold note tutorial has not - # been displayed yet. Presumably this is just in case you play a hold note - # song on your last stage. Game moves this to "3" after this tutorial has been - # displayed. - # 3 - All hold note tutorials are finished, this is a terminal state. - statistics = self.get_play_statistics(userid) - account.add_child(Node.s16('tutorial', profile.get_int('tutorial', 100 if statistics.total_plays > 1 else 0))) - - # Stuff we never change - account.add_child(Node.s8('staff', 0)) - account.add_child(Node.s16('item_type', 0)) - account.add_child(Node.s16('item_id', 0)) - account.add_child(Node.s8('is_conv', 0)) - account.add_child(Node.s16_array('license_data', [-1] * 20)) - - # Song statistics - last_played = [x[0] for x in self.data.local.music.get_last_played(self.game, self.version, userid, 10)] - most_played = [x[0] for x in self.data.local.music.get_most_played(self.game, self.version, userid, 20)] - while len(last_played) < 10: - last_played.append(-1) - while len(most_played) < 20: - most_played.append(-1) - - account.add_child(Node.s16_array('my_best', most_played)) - account.add_child(Node.s16_array('latest_music', last_played)) - - # Player statistics - account.add_child(Node.s16('total_play_cnt', statistics.total_plays)) - account.add_child(Node.s16('today_play_cnt', statistics.today_plays)) - account.add_child(Node.s16('consecutive_days', statistics.consecutive_days)) - account.add_child(Node.s16('total_days', statistics.total_days)) - account.add_child(Node.s16('interval_day', 0)) - - # Number of rivals that are active for this version. - links = self.data.local.user.get_links(self.game, self.version, userid) - rivalcount = 0 - for link in links: - if link.type != 'rival': - continue - - if not self.has_profile(link.other_userid): - continue - - # This profile is valid. - rivalcount += 1 - account.add_child(Node.u8('active_fr_num', rivalcount)) - - # eAmuse account link - eaappli = Node.void('eaappli') - root.add_child(eaappli) - eaappli.add_child(Node.s8('relation', 1)) - - # Player info - info = Node.void('info') - root.add_child(info) - info.add_child(Node.u16('ep', profile.get_int('ep'))) - - # Player config - config = Node.void('config') - root.add_child(config) - config.add_child(Node.u8('mode', profile.get_int('mode'))) - config.add_child(Node.s16('chara', profile.get_int('chara', -1))) - config.add_child(Node.s16('music', profile.get_int('music', -1))) - config.add_child(Node.u8('sheet', profile.get_int('sheet'))) - config.add_child(Node.s8('category', profile.get_int('category', -1))) - config.add_child(Node.s8('sub_category', profile.get_int('sub_category', -1))) - config.add_child(Node.s8('chara_category', profile.get_int('chara_category', -1))) - config.add_child(Node.s16('course_id', profile.get_int('course_id', -1))) - config.add_child(Node.s8('course_folder', profile.get_int('course_folder', -1))) - config.add_child(Node.s8('ms_banner_disp', profile.get_int('ms_banner_disp', -1))) - config.add_child(Node.s8('ms_down_info', profile.get_int('ms_down_info', -1))) - config.add_child(Node.s8('ms_side_info', profile.get_int('ms_side_info', -1))) - config.add_child(Node.s8('ms_raise_type', profile.get_int('ms_raise_type', -1))) - config.add_child(Node.s8('ms_rnd_type', profile.get_int('ms_rnd_type', -1))) - config.add_child(Node.s8('banner_sort', profile.get_int('banner_sort', -1))) - - # Player options - option = Node.void('option') - option_dict = profile.get_dict('option') - root.add_child(option) - option.add_child(Node.s16('hispeed', option_dict.get_int('hispeed'))) - option.add_child(Node.u8('popkun', option_dict.get_int('popkun'))) - option.add_child(Node.bool('hidden', option_dict.get_bool('hidden'))) - option.add_child(Node.s16('hidden_rate', option_dict.get_int('hidden_rate'))) - option.add_child(Node.bool('sudden', option_dict.get_bool('sudden'))) - option.add_child(Node.s16('sudden_rate', option_dict.get_int('sudden_rate'))) - option.add_child(Node.s8('randmir', option_dict.get_int('randmir'))) - option.add_child(Node.s8('gauge_type', option_dict.get_int('gauge_type'))) - option.add_child(Node.u8('ojama_0', option_dict.get_int('ojama_0'))) - option.add_child(Node.u8('ojama_1', option_dict.get_int('ojama_1'))) - option.add_child(Node.bool('forever_0', option_dict.get_bool('forever_0'))) - option.add_child(Node.bool('forever_1', option_dict.get_bool('forever_1'))) - option.add_child(Node.bool('full_setting', option_dict.get_bool('full_setting'))) - option.add_child(Node.u8('judge', option_dict.get_int('judge'))) - option.add_child(Node.s8('guide_se', option_dict.get_int('guide_se'))) - - # Player custom category - custom_cate = Node.void('custom_cate') - root.add_child(custom_cate) - custom_cate.add_child(Node.s8('valid', 0)) - custom_cate.add_child(Node.s8('lv_min', -1)) - custom_cate.add_child(Node.s8('lv_max', -1)) - custom_cate.add_child(Node.s8('medal_min', -1)) - custom_cate.add_child(Node.s8('medal_max', -1)) - custom_cate.add_child(Node.s8('friend_no', -1)) - custom_cate.add_child(Node.s8('score_flg', -1)) - - # Navi data - navi_data = Node.void('navi_data') - root.add_child(navi_data) - if 'navi_points' in profile: - navi_data.add_child(Node.s32_array('raisePoint', profile.get_int_array('navi_points', 5))) - - # Set up achievements - achievements = self.data.local.user.get_achievements(self.game, self.version, userid) - for achievement in achievements: - if achievement.type[:5] == 'item_': - itemtype = int(achievement.type[5:]) - param = achievement.data.get_int('param') - is_new = achievement.data.get_bool('is_new') - get_time = achievement.data.get_int('get_time') - - item = Node.void('item') - root.add_child(item) - # Item type can be 0-6 inclusive and is the type of the unlock/item. - # Item 0 is music unlocks. In this case, the id is the song ID according - # to the game. Unclear what the param is supposed to be, but i've seen - # seen 8 and 0. Might be what chart is available? - # - # Item limits are as follows: - # 0: 1704 - ID is the music ID that the player purchased/unlocked. - # 1: 2201 - # 2: 3 - # 3: 97 - ID points at a character part that can be purchased on the character screen. - # 4: 1 - # 5: 1 - # 6: 60 - item.add_child(Node.u8('type', itemtype)) - item.add_child(Node.u16('id', achievement.id)) - item.add_child(Node.u16('param', param)) - item.add_child(Node.bool('is_new', is_new)) - item.add_child(Node.u64('get_time', get_time)) - - elif achievement.type == 'chara': - friendship = achievement.data.get_int('friendship') - - chara = Node.void('chara_param') - root.add_child(chara) - chara.add_child(Node.u16('chara_id', achievement.id)) - chara.add_child(Node.u16('friendship', friendship)) - - elif achievement.type == 'navi': - # There should only be 12 of these. - friendship = achievement.data.get_int('friendship') - - # This relies on the above Navi data section to ensure the navi_param - # node is created. - navi_param = Node.void('navi_param') - navi_data.add_child(navi_param) - navi_param.add_child(Node.u16('navi_id', achievement.id)) - navi_param.add_child(Node.s32('friendship', friendship)) - - elif achievement.type == 'area': - # There should only be 16 of these. - index = achievement.data.get_int('index') - points = achievement.data.get_int('points') - cleared = achievement.data.get_bool('cleared') - diary = achievement.data.get_int('diary') - - area = Node.void('area') - root.add_child(area) - area.add_child(Node.u32('area_id', achievement.id)) - area.add_child(Node.u8('chapter_index', index)) - area.add_child(Node.u16('gauge_point', points)) - area.add_child(Node.bool('is_cleared', cleared)) - area.add_child(Node.u32('diary', diary)) - - elif achievement.type[:7] == 'course_': - sheet = int(achievement.type[7:]) - - course_data = Node.void('course_data') - root.add_child(course_data) - course_data.add_child(Node.s16('course_id', achievement.id)) - course_data.add_child(Node.u8('clear_type', achievement.data.get_int('clear_type'))) - course_data.add_child(Node.u8('clear_rank', achievement.data.get_int('clear_rank'))) - course_data.add_child(Node.s32('total_score', achievement.data.get_int('score'))) - course_data.add_child(Node.s32('update_count', achievement.data.get_int('count'))) - course_data.add_child(Node.u8('sheet_num', sheet)) - - elif achievement.type == 'fes': - index = achievement.data.get_int('index') - points = achievement.data.get_int('points') - cleared = achievement.data.get_bool('cleared') - - fes = Node.void('fes') - root.add_child(fes) - fes.add_child(Node.u32('fes_id', achievement.id)) - fes.add_child(Node.u8('chapter_index', index)) - fes.add_child(Node.u16('gauge_point', points)) - fes.add_child(Node.bool('is_cleared', cleared)) - - # Handle daily mission. Note that we should be presenting 3 random IDs - # in the range of 1-228 inclusive, and presenting three new ones per day. - achievements = self.data.local.user.get_time_based_achievements( - self.game, - self.version, - userid, - since=Time.beginning_of_today(), - until=Time.end_of_today(), - ) - achievements = sorted(achievements, key=lambda a: a.timestamp) - daily_missions: Dict[int, ValidatedDict] = {} - - # Find the newest version of each daily mission completion, - # since we've sorted by time above. If we haven't started for - # today, the defaults will be set after this loop so we at least - # give the game the right ID. - for achievement in achievements: - if achievement.type == 'mission': - daily_missions[achievement.id] = achievement.data - - while len(daily_missions) < 3: - new_id = random.randint(1, 228) - if new_id not in daily_missions: - daily_missions[new_id] = ValidatedDict() - - for i, (daily_id, data) in enumerate(daily_missions.items()): - if i >= 3: - break - - points = data.get_int('points') - complete = data.get_int('complete') - - mission = Node.void('mission') - root.add_child(mission) - mission.add_child(Node.u32('mission_id', daily_id)) - mission.add_child(Node.u32('gauge_point', points)) - mission.add_child(Node.u32('mission_comp', complete)) - - # Player netvs section - netvs = Node.void('netvs') - root.add_child(netvs) - netvs.add_child(Node.s16_array('record', [0] * 6)) - netvs.add_child(Node.string('dialog', '')) - netvs.add_child(Node.string('dialog', '')) - netvs.add_child(Node.string('dialog', '')) - netvs.add_child(Node.string('dialog', '')) - netvs.add_child(Node.string('dialog', '')) - netvs.add_child(Node.string('dialog', '')) - netvs.add_child(Node.s8_array('ojama_condition', [0] * 74)) - netvs.add_child(Node.s8_array('set_ojama', [0] * 3)) - netvs.add_child(Node.s8_array('set_recommend', [0] * 3)) - netvs.add_child(Node.u32('netvs_play_cnt', 0)) - - # Character customizations - customize = Node.void('customize') - root.add_child(customize) - customize.add_child(Node.u16('effect_left', profile.get_int('effect_left'))) - customize.add_child(Node.u16('effect_center', profile.get_int('effect_center'))) - customize.add_child(Node.u16('effect_right', profile.get_int('effect_right'))) - customize.add_child(Node.u16('hukidashi', profile.get_int('hukidashi'))) - customize.add_child(Node.u16('comment_1', profile.get_int('comment_1'))) - customize.add_child(Node.u16('comment_2', profile.get_int('comment_2'))) - - # Stamp stuff - stamp = Node.void('stamp') - root.add_child(stamp) - stamp.add_child(Node.s16('stamp_id', profile.get_int('stamp_id'))) - stamp.add_child(Node.s16('cnt', profile.get_int('stamp_cnt'))) - - return root - - def unformat_profile(self, userid: UserID, request: Node, oldprofile: Profile) -> Profile: - newprofile = copy.deepcopy(oldprofile) - - account = request.child('account') - if account is not None: - newprofile.replace_int('tutorial', account.child_value('tutorial')) - newprofile.replace_int('read_news', account.child_value('read_news')) - newprofile.replace_int('area_id', account.child_value('area_id')) - newprofile.replace_int('use_navi', account.child_value('use_navi')) - newprofile.replace_int('ranking_node', account.child_value('ranking_node')) - newprofile.replace_int('chara_ranking_kind_id', account.child_value('chara_ranking_kind_id')) - newprofile.replace_int('navi_evolution_flg', account.child_value('navi_evolution_flg')) - newprofile.replace_int('ranking_news_last_no', account.child_value('ranking_news_last_no')) - newprofile.replace_int('power_point', account.child_value('power_point')) - newprofile.replace_int('player_point', account.child_value('player_point')) - - newprofile.replace_int_array('nice', 30, account.child_value('nice')) - newprofile.replace_int_array('favorite_chara', 20, account.child_value('favorite_chara')) - newprofile.replace_int_array('special_area', 8, account.child_value('special_area')) - newprofile.replace_int_array('chocolate_charalist', 5, account.child_value('chocolate_charalist')) - newprofile.replace_int('chocolate_sp_chara', account.child_value('chocolate_sp_chara')) - newprofile.replace_int('chocolate_pass_cnt', account.child_value('chocolate_pass_cnt')) - newprofile.replace_int('chocolate_hon_cnt', account.child_value('chocolate_hon_cnt')) - newprofile.replace_int('chocolate_giri_cnt', account.child_value('chocolate_giri_cnt')) - newprofile.replace_int('chocolate_kokyu_cnt', account.child_value('chocolate_kokyu_cnt')) - newprofile.replace_int_array('teacher_setting', 10, account.child_value('teacher_setting')) - newprofile.replace_int_array('power_point_list', 20, account.child_value('power_point_list')) - - info = request.child('info') - if info is not None: - newprofile.replace_int('ep', info.child_value('ep')) - - stamp = request.child('stamp') - if stamp is not None: - newprofile.replace_int('stamp_id', stamp.child_value('stamp_id')) - newprofile.replace_int('stamp_cnt', stamp.child_value('cnt')) - - config = request.child('config') - if config is not None: - newprofile.replace_int('mode', config.child_value('mode')) - newprofile.replace_int('chara', config.child_value('chara')) - newprofile.replace_int('music', config.child_value('music')) - newprofile.replace_int('sheet', config.child_value('sheet')) - newprofile.replace_int('category', config.child_value('category')) - newprofile.replace_int('sub_category', config.child_value('sub_category')) - newprofile.replace_int('chara_category', config.child_value('chara_category')) - newprofile.replace_int('course_id', config.child_value('course_id')) - newprofile.replace_int('course_folder', config.child_value('course_folder')) - newprofile.replace_int('ms_banner_disp', config.child_value('ms_banner_disp')) - newprofile.replace_int('ms_down_info', config.child_value('ms_down_info')) - newprofile.replace_int('ms_side_info', config.child_value('ms_side_info')) - newprofile.replace_int('ms_raise_type', config.child_value('ms_raise_type')) - newprofile.replace_int('ms_rnd_type', config.child_value('ms_rnd_type')) - newprofile.replace_int('banner_sort', config.child_value('banner_sort')) - - option_dict = newprofile.get_dict('option') - option = request.child('option') - if option is not None: - option_dict.replace_int('hispeed', option.child_value('hispeed')) - option_dict.replace_int('popkun', option.child_value('popkun')) - option_dict.replace_bool('hidden', option.child_value('hidden')) - option_dict.replace_int('hidden_rate', option.child_value('hidden_rate')) - option_dict.replace_bool('sudden', option.child_value('sudden')) - option_dict.replace_int('sudden_rate', option.child_value('sudden_rate')) - option_dict.replace_int('randmir', option.child_value('randmir')) - option_dict.replace_int('gauge_type', option.child_value('gauge_type')) - option_dict.replace_int('ojama_0', option.child_value('ojama_0')) - option_dict.replace_int('ojama_1', option.child_value('ojama_1')) - option_dict.replace_bool('forever_0', option.child_value('forever_0')) - option_dict.replace_bool('forever_1', option.child_value('forever_1')) - option_dict.replace_bool('full_setting', option.child_value('full_setting')) - option_dict.replace_int('judge', option.child_value('judge')) - option_dict.replace_int('guide_se', option.child_value('guide_se')) - newprofile.replace_dict('option', option_dict) - - customize = request.child('customize') - if customize is not None: - newprofile.replace_int('effect_left', customize.child_value('effect_left')) - newprofile.replace_int('effect_center', customize.child_value('effect_center')) - newprofile.replace_int('effect_right', customize.child_value('effect_right')) - newprofile.replace_int('hukidashi', customize.child_value('hukidashi')) - newprofile.replace_int('comment_1', customize.child_value('comment_1')) - newprofile.replace_int('comment_2', customize.child_value('comment_2')) - - navi_data = request.child('navi_data') - if navi_data is not None: - newprofile.replace_int_array('navi_points', 5, navi_data.child_value('raisePoint')) - - # Extract navi achievements - for node in navi_data.children: - if node.name == 'navi_param': - navi_id = node.child_value('navi_id') - friendship = node.child_value('friendship') - - self.data.local.user.put_achievement( - self.game, - self.version, - userid, - navi_id, - 'navi', - { - 'friendship': friendship, - }, - ) - - # Extract achievements - for node in request.children: - if node.name == 'item': - itemid = node.child_value('id') - itemtype = node.child_value('type') - param = node.child_value('param') - is_new = node.child_value('is_new') - get_time = node.child_value('get_time') - - self.data.local.user.put_achievement( - self.game, - self.version, - userid, - itemid, - f'item_{itemtype}', - { - 'param': param, - 'is_new': is_new, - 'get_time': get_time, - }, - ) - - elif node.name == 'chara_param': - charaid = node.child_value('chara_id') - friendship = node.child_value('friendship') - - self.data.local.user.put_achievement( - self.game, - self.version, - userid, - charaid, - 'chara', - { - 'friendship': friendship, - }, - ) - - elif node.name == 'area': - area_id = node.child_value('area_id') - index = node.child_value('chapter_index') - points = node.child_value('gauge_point') - cleared = node.child_value('is_cleared') - diary = node.child_value('diary') - - self.data.local.user.put_achievement( - self.game, - self.version, - userid, - area_id, - 'area', - { - 'index': index, - 'points': points, - 'cleared': cleared, - 'diary': diary, - }, - ) - - elif node.name == 'mission': - # If you don't send the right values on login, then - # the game sends 0 for mission_id three times. Skip - # those values since they're bogus. - mission_id = node.child_value('mission_id') - if mission_id > 0: - points = node.child_value('gauge_point') - complete = node.child_value('mission_comp') - - self.data.local.user.put_time_based_achievement( - self.game, - self.version, - userid, - mission_id, - 'mission', - { - 'points': points, - 'complete': complete, - }, - ) - - # Unlock NAVI-kun and Kenshi Yonezu after one play - for songid in [1592, 1608]: - self.data.local.user.put_achievement( - self.game, - self.version, - userid, - songid, - 'item_0', - { - 'param': 0xF, - 'is_new': False, - 'get_time': Time.now(), - }, - ) - - # Keep track of play statistics - self.update_play_statistics(userid) - - return newprofile diff --git a/bemani/client/popn/__init__.py b/bemani/client/popn/__init__.py index 8a95aea..2e90bb9 100644 --- a/bemani/client/popn/__init__.py +++ b/bemani/client/popn/__init__.py @@ -4,6 +4,7 @@ from bemani.client.popn.sunnypark import PopnMusicSunnyParkClient from bemani.client.popn.lapistoria import PopnMusicLapistoriaClient from bemani.client.popn.eclale import PopnMusicEclaleClient from bemani.client.popn.usaneko import PopnMusicUsaNekoClient +from bemani.client.popn.peace import PopnMusicPeaceClient __all__ = [ @@ -13,4 +14,5 @@ __all__ = [ "PopnMusicLapistoriaClient", "PopnMusicEclaleClient", "PopnMusicUsaNekoClient", + "PopnMusicPeaceClient", ] diff --git a/bemani/client/popn/peace.py b/bemani/client/popn/peace.py new file mode 100644 index 0000000..393d124 --- /dev/null +++ b/bemani/client/popn/peace.py @@ -0,0 +1,669 @@ +import random +import time +from typing import Any, Dict, Optional + +from bemani.client.base import BaseClient +from bemani.protocol import Node + + +class PopnMusicPeaceClient(BaseClient): + NAME = 'TEST' + + def verify_pcb24_boot(self, loc: str) -> None: + call = self.call_node() + + # Construct node + pcb24 = Node.void('pcb24') + call.add_child(pcb24) + pcb24.set_attribute('method', 'boot') + pcb24.add_child(Node.string('loc_id', loc)) + pcb24.add_child(Node.u8('loc_type', 0)) + pcb24.add_child(Node.string('loc_name', '')) + pcb24.add_child(Node.string('country', 'US')) + pcb24.add_child(Node.string('region', '.')) + pcb24.add_child(Node.s16('pref', 51)) + pcb24.add_child(Node.string('customer', '')) + pcb24.add_child(Node.string('company', '')) + pcb24.add_child(Node.ipv4('gip', '127.0.0.1')) + pcb24.add_child(Node.u16('gp', 10011)) + pcb24.add_child(Node.string('rom_number', 'M39-JB-G01')) + pcb24.add_child(Node.u64('c_drive', 10028228608)) + pcb24.add_child(Node.u64('d_drive', 47945170944)) + pcb24.add_child(Node.u64('e_drive', 10394677248)) + pcb24.add_child(Node.string('etc', '')) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/pcb24/@status") + + def __verify_common(self, root: str, resp: Node) -> None: + self.assert_path(resp, f"response/{root}/phase/event_id") + self.assert_path(resp, f"response/{root}/phase/phase") + self.assert_path(resp, f"response/{root}/area/area_id") + self.assert_path(resp, f"response/{root}/area/end_date") + self.assert_path(resp, f"response/{root}/area/medal_id") + self.assert_path(resp, f"response/{root}/area/is_limit") + self.assert_path(resp, f"response/{root}/choco/choco_id") + self.assert_path(resp, f"response/{root}/choco/param") + self.assert_path(resp, f"response/{root}/goods/item_id") + self.assert_path(resp, f"response/{root}/goods/item_type") + self.assert_path(resp, f"response/{root}/goods/price") + self.assert_path(resp, f"response/{root}/goods/goods_type") + + def verify_info24_common(self, loc: str) -> None: + call = self.call_node() + + # Construct node + info24 = Node.void('info24') + call.add_child(info24) + info24.set_attribute('loc_id', loc) + info24.set_attribute('method', 'common') + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.__verify_common('info24', resp) + + def verify_lobby24_getlist(self, loc: str) -> None: + call = self.call_node() + + # Construct node + lobby24 = Node.void('lobby24') + call.add_child(lobby24) + lobby24.set_attribute('method', 'getList') + lobby24.add_child(Node.string('location_id', loc)) + lobby24.add_child(Node.u8('net_version', 63)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/lobby24/@status") + + def __verify_profile(self, resp: Node) -> None: + self.assert_path(resp, "response/player24/account/name") + self.assert_path(resp, "response/player24/account/g_pm_id") + self.assert_path(resp, "response/player24/account/tutorial") + self.assert_path(resp, "response/player24/account/area_id") + self.assert_path(resp, "response/player24/account/use_navi") + self.assert_path(resp, "response/player24/account/read_news") + self.assert_path(resp, "response/player24/account/nice") + self.assert_path(resp, "response/player24/account/favorite_chara") + self.assert_path(resp, "response/player24/account/special_area") + self.assert_path(resp, "response/player24/account/chocolate_charalist") + self.assert_path(resp, "response/player24/account/chocolate_sp_chara") + self.assert_path(resp, "response/player24/account/chocolate_pass_cnt") + self.assert_path(resp, "response/player24/account/chocolate_hon_cnt") + self.assert_path(resp, "response/player24/account/teacher_setting") + self.assert_path(resp, "response/player24/account/welcom_pack") + self.assert_path(resp, "response/player24/account/ranking_node") + self.assert_path(resp, "response/player24/account/chara_ranking_kind_id") + self.assert_path(resp, "response/player24/account/navi_evolution_flg") + self.assert_path(resp, "response/player24/account/ranking_news_last_no") + self.assert_path(resp, "response/player24/account/power_point") + self.assert_path(resp, "response/player24/account/player_point") + self.assert_path(resp, "response/player24/account/power_point_list") + self.assert_path(resp, "response/player24/account/staff") + self.assert_path(resp, "response/player24/account/item_type") + self.assert_path(resp, "response/player24/account/item_id") + self.assert_path(resp, "response/player24/account/is_conv") + self.assert_path(resp, "response/player24/account/license_data") + self.assert_path(resp, "response/player24/account/my_best") + self.assert_path(resp, "response/player24/account/latest_music") + self.assert_path(resp, "response/player24/account/total_play_cnt") + self.assert_path(resp, "response/player24/account/today_play_cnt") + self.assert_path(resp, "response/player24/account/consecutive_days") + self.assert_path(resp, "response/player24/account/total_days") + self.assert_path(resp, "response/player24/account/interval_day") + self.assert_path(resp, "response/player24/account/active_fr_num") + self.assert_path(resp, "response/player24/eaappli/relation") + self.assert_path(resp, "response/player24/info/ep") + self.assert_path(resp, "response/player24/config") + self.assert_path(resp, "response/player24/option") + self.assert_path(resp, "response/player24/custom_cate") + self.assert_path(resp, "response/player24/navi_data") + self.assert_path(resp, "response/player24/mission/mission_id") + self.assert_path(resp, "response/player24/mission/gauge_point") + self.assert_path(resp, "response/player24/mission/mission_comp") + self.assert_path(resp, "response/player24/netvs") + self.assert_path(resp, "response/player24/customize") + self.assert_path(resp, "response/player24/stamp/stamp_id") + self.assert_path(resp, "response/player24/stamp/cnt") + + def verify_player24_read(self, ref_id: str, msg_type: str) -> Dict[str, Dict[int, Dict[str, int]]]: + call = self.call_node() + + # Construct node + player24 = Node.void('player24') + call.add_child(player24) + player24.set_attribute('method', 'read') + + player24.add_child(Node.string('ref_id', ref_id)) + player24.add_child(Node.s8('pref', 51)) + + # Swap with server + resp = self.exchange('', call) + + if msg_type == 'new': + # Verify that response is correct + self.assert_path(resp, "response/player24/result") + status = resp.child_value('player24/result') + if status != 2: + raise Exception(f'Reference ID \'{ref_id}\' returned invalid status \'{status}\'') + + return { + 'items': {}, + 'characters': {}, + 'points': {}, + } + elif msg_type == 'query': + # Verify that the response is correct + self.__verify_profile(resp) + + self.assert_path(resp, "response/player24/result") + status = resp.child_value('player24/result') + if status != 0: + raise Exception(f'Reference ID \'{ref_id}\' returned invalid status \'{status}\'') + name = resp.child_value('player24/account/name') + if name != self.NAME: + raise Exception(f'Invalid name \'{name}\' returned for Ref ID \'{ref_id}\'') + + # Medals and items + items: Dict[int, Dict[str, int]] = {} + charas: Dict[int, Dict[str, int]] = {} + courses: Dict[int, Dict[str, int]] = {} + for obj in resp.child('player24').children: + if obj.name == 'item': + items[obj.child_value('id')] = { + 'type': obj.child_value('type'), + 'param': obj.child_value('param'), + } + elif obj.name == 'chara_param': + charas[obj.child_value('chara_id')] = { + 'friendship': obj.child_value('friendship'), + } + elif obj.name == 'course_data': + courses[obj.child_value('course_id')] = { + 'clear_type': obj.child_value('clear_type'), + 'clear_rank': obj.child_value('clear_rank'), + 'total_score': obj.child_value('total_score'), + 'count': obj.child_value('update_count'), + 'sheet_num': obj.child_value('sheet_num'), + } + + return { + 'items': items, + 'characters': charas, + 'courses': courses, + 'points': {0: {'points': resp.child_value('player24/account/player_point')}}, + } + else: + raise Exception(f'Unrecognized message type \'{msg_type}\'') + + def verify_player24_read_score(self, ref_id: str) -> Dict[str, Dict[int, Dict[int, int]]]: + call = self.call_node() + + # Construct node + player24 = Node.void('player24') + call.add_child(player24) + player24.set_attribute('method', 'read_score') + + player24.add_child(Node.string('ref_id', ref_id)) + player24.add_child(Node.s8('pref', 51)) + + # Swap with server + resp = self.exchange('', call) + + # Verify defaults + self.assert_path(resp, "response/player24/@status") + + # Grab scores + scores: Dict[int, Dict[int, int]] = {} + medals: Dict[int, Dict[int, int]] = {} + ranks: Dict[int, Dict[int, int]] = {} + for child in resp.child('player24').children: + if child.name != 'music': + continue + + musicid = child.child_value('music_num') + chart = child.child_value('sheet_num') + score = child.child_value('score') + medal = child.child_value('clear_type') + rank = child.child_value('clear_rank') + + if musicid not in scores: + scores[musicid] = {} + if musicid not in medals: + medals[musicid] = {} + if musicid not in ranks: + ranks[musicid] = {} + + scores[musicid][chart] = score + medals[musicid][chart] = medal + ranks[musicid][chart] = rank + + return { + 'scores': scores, + 'medals': medals, + 'ranks': ranks, + } + + def verify_player24_start(self, ref_id: str, loc: str) -> None: + call = self.call_node() + + # Construct node + player24 = Node.void('player24') + call.add_child(player24) + player24.set_attribute('loc_id', loc) + player24.set_attribute('ref_id', ref_id) + player24.set_attribute('method', 'start') + player24.set_attribute('start_type', '0') + pcb_card = Node.void('pcb_card') + player24.add_child(pcb_card) + pcb_card.add_child(Node.s8('card_enable', 1)) + pcb_card.add_child(Node.s8('card_soldout', 0)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.__verify_common('player24', resp) + + def verify_player24_update_ranking(self, ref_id: str, loc: str) -> None: + call = self.call_node() + + # Construct node + player24 = Node.void('player24') + call.add_child(player24) + player24.set_attribute('method', 'update_ranking') + player24.add_child(Node.s16('pref', 51)) + player24.add_child(Node.string('location_id', loc)) + player24.add_child(Node.string('ref_id', ref_id)) + player24.add_child(Node.string('name', self.NAME)) + player24.add_child(Node.s16('chara_num', 1)) + player24.add_child(Node.s16('course_id', 12345)) + player24.add_child(Node.s32('total_score', 86000)) + player24.add_child(Node.s16('music_num', 1375)) + player24.add_child(Node.u8('sheet_num', 2)) + player24.add_child(Node.u8('clear_type', 7)) + player24.add_child(Node.u8('clear_rank', 5)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/player24/all_ranking/name") + self.assert_path(resp, "response/player24/all_ranking/chara_num") + self.assert_path(resp, "response/player24/all_ranking/total_score") + self.assert_path(resp, "response/player24/all_ranking/clear_type") + self.assert_path(resp, "response/player24/all_ranking/clear_rank") + self.assert_path(resp, "response/player24/all_ranking/player_count") + self.assert_path(resp, "response/player24/all_ranking/player_rank") + + def verify_player24_logout(self, ref_id: str) -> None: + call = self.call_node() + + # Construct node + player24 = Node.void('player24') + call.add_child(player24) + player24.set_attribute('ref_id', ref_id) + player24.set_attribute('method', 'logout') + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/player24/@status") + + def verify_player24_write( + self, + ref_id: str, + item: Optional[Dict[str, int]]=None, + character: Optional[Dict[str, int]]=None, + ) -> None: + call = self.call_node() + + # Construct node + player24 = Node.void('player24') + call.add_child(player24) + player24.set_attribute('method', 'write') + player24.add_child(Node.string('ref_id', ref_id)) + + # Add required children + config = Node.void('config') + player24.add_child(config) + config.add_child(Node.s16('chara', 1543)) + + if item is not None: + itemnode = Node.void('item') + player24.add_child(itemnode) + itemnode.add_child(Node.u8('type', item['type'])) + itemnode.add_child(Node.u16('id', item['id'])) + itemnode.add_child(Node.u16('param', item['param'])) + itemnode.add_child(Node.bool('is_new', False)) + itemnode.add_child(Node.u64('get_time', 0)) + + if character is not None: + chara_param = Node.void('chara_param') + player24.add_child(chara_param) + chara_param.add_child(Node.u16('chara_id', character['id'])) + chara_param.add_child(Node.u16('friendship', character['friendship'])) + + # Swap with server + resp = self.exchange('', call) + self.assert_path(resp, "response/player24/@status") + + def verify_player24_buy(self, ref_id: str, item: Dict[str, int]) -> None: + call = self.call_node() + + # Construct node + player24 = Node.void('player24') + call.add_child(player24) + player24.set_attribute('method', 'buy') + player24.add_child(Node.s32('play_id', 0)) + player24.add_child(Node.string('ref_id', ref_id)) + player24.add_child(Node.u16('id', item['id'])) + player24.add_child(Node.u8('type', item['type'])) + player24.add_child(Node.u16('param', item['param'])) + player24.add_child(Node.s32('lumina', item['points'])) + player24.add_child(Node.u16('price', item['price'])) + + # Swap with server + resp = self.exchange('', call) + self.assert_path(resp, "response/player24/@status") + + def verify_player24_write_music(self, ref_id: str, score: Dict[str, Any]) -> None: + call = self.call_node() + + # Construct node + player24 = Node.void('player24') + call.add_child(player24) + player24.set_attribute('method', 'write_music') + player24.add_child(Node.string('ref_id', ref_id)) + player24.add_child(Node.string('data_id', ref_id)) + player24.add_child(Node.string('name', self.NAME)) + player24.add_child(Node.u8('stage', 0)) + player24.add_child(Node.s16('music_num', score['id'])) + player24.add_child(Node.u8('sheet_num', score['chart'])) + player24.add_child(Node.u8('clear_type', score['medal'])) + player24.add_child(Node.s32('score', score['score'])) + player24.add_child(Node.s16('combo', 0)) + player24.add_child(Node.s16('cool', 0)) + player24.add_child(Node.s16('great', 0)) + player24.add_child(Node.s16('good', 0)) + player24.add_child(Node.s16('bad', 0)) + + # Swap with server + resp = self.exchange('', call) + self.assert_path(resp, "response/player24/@status") + + def verify_player24_new(self, ref_id: str) -> None: + call = self.call_node() + + # Construct node + player24 = Node.void('player24') + call.add_child(player24) + player24.set_attribute('method', 'new') + + player24.add_child(Node.string('ref_id', ref_id)) + player24.add_child(Node.string('name', self.NAME)) + player24.add_child(Node.s8('pref', 51)) + + # Swap with server + resp = self.exchange('', call) + + # Verify nodes + self.__verify_profile(resp) + + def verify(self, cardid: Optional[str]) -> None: + # Verify boot sequence is okay + self.verify_services_get( + expected_services=[ + 'pcbtracker', + 'pcbevent', + 'local', + 'message', + 'facility', + 'cardmng', + 'package', + 'posevent', + 'pkglist', + 'dlstatus', + 'eacoin', + 'lobby', + 'ntp', + 'keepalive' + ] + ) + paseli_enabled = self.verify_pcbtracker_alive() + self.verify_message_get() + self.verify_package_list() + location = self.verify_facility_get() + self.verify_pcbevent_put() + self.verify_pcb24_boot(location) + self.verify_info24_common(location) + self.verify_lobby24_getlist(location) + + # Verify card registration and profile lookup + if cardid is not None: + card = cardid + else: + card = self.random_card() + print(f"Generated random card ID {card} for use.") + + if cardid is None: + self.verify_cardmng_inquire(card, msg_type='unregistered', paseli_enabled=paseli_enabled) + ref_id = self.verify_cardmng_getrefid(card) + if len(ref_id) != 16: + raise Exception(f'Invalid refid \'{ref_id}\' returned when registering card') + if ref_id != self.verify_cardmng_inquire(card, msg_type='new', paseli_enabled=paseli_enabled): + raise Exception(f'Invalid refid \'{ref_id}\' returned when querying card') + self.verify_player24_read(ref_id, msg_type='new') + self.verify_player24_new(ref_id) + else: + print("Skipping new card checks for existing card") + ref_id = self.verify_cardmng_inquire(card, msg_type='query', paseli_enabled=paseli_enabled) + + # Verify pin handling and return card handling + self.verify_cardmng_authpass(ref_id, correct=True) + self.verify_cardmng_authpass(ref_id, correct=False) + if ref_id != self.verify_cardmng_inquire(card, msg_type='query', paseli_enabled=paseli_enabled): + raise Exception(f'Invalid refid \'{ref_id}\' returned when querying card') + + # Verify proper handling of basic stuff + self.verify_player24_read(ref_id, msg_type='query') + self.verify_player24_start(ref_id, location) + self.verify_player24_write(ref_id) + self.verify_player24_logout(ref_id) + + if cardid is None: + # Verify unlocks/story mode work + unlocks = self.verify_player24_read(ref_id, msg_type='query') + for item in unlocks['items']: + if item in [1592, 1608]: + # Song unlocks after one play + continue + raise Exception('Got nonzero items count on a new card!') + for _ in unlocks['characters']: + raise Exception('Got nonzero characters count on a new card!') + for _ in unlocks['courses']: + raise Exception('Got nonzero course count on a new card!') + if unlocks['points'][0]['points'] != 300: + raise Exception('Got wrong default value for points on a new card!') + + self.verify_player24_write(ref_id, item={'id': 4, 'type': 2, 'param': 69}) + unlocks = self.verify_player24_read(ref_id, msg_type='query') + if 4 not in unlocks['items']: + raise Exception('Expecting to see item ID 4 in items!') + if unlocks['items'][4]['type'] != 2: + raise Exception('Expecting to see item ID 4 to have type 2 in items!') + if unlocks['items'][4]['param'] != 69: + raise Exception('Expecting to see item ID 4 to have param 69 in items!') + + self.verify_player24_write(ref_id, character={'id': 5, 'friendship': 420}) + unlocks = self.verify_player24_read(ref_id, msg_type='query') + if 5 not in unlocks['characters']: + raise Exception('Expecting to see chara ID 5 in characters!') + if unlocks['characters'][5]['friendship'] != 420: + raise Exception('Expecting to see chara ID 5 to have type 2 in characters!') + + # Verify purchases work + self.verify_player24_buy(ref_id, item={'id': 6, 'type': 3, 'param': 8, 'points': 400, 'price': 250}) + unlocks = self.verify_player24_read(ref_id, msg_type='query') + if 6 not in unlocks['items']: + raise Exception('Expecting to see item ID 6 in items!') + if unlocks['items'][6]['type'] != 3: + raise Exception('Expecting to see item ID 6 to have type 3 in items!') + if unlocks['items'][6]['param'] != 8: + raise Exception('Expecting to see item ID 6 to have param 8 in items!') + if unlocks['points'][0]['points'] != 150: + raise Exception(f'Got wrong value for points {unlocks["points"][0]["points"]} after purchase!') + + # Verify course handling + self.verify_player24_update_ranking(ref_id, location) + unlocks = self.verify_player24_read(ref_id, msg_type='query') + if 12345 not in unlocks['courses']: + raise Exception('Expecting to see course ID 12345 in courses!') + if unlocks['courses'][12345]['clear_type'] != 7: + raise Exception('Expecting to see item ID 12345 to have clear_type 7 in courses!') + if unlocks['courses'][12345]['clear_rank'] != 5: + raise Exception('Expecting to see item ID 12345 to have clear_rank 5 in courses!') + if unlocks['courses'][12345]['total_score'] != 86000: + raise Exception('Expecting to see item ID 12345 to have total_score 86000 in courses!') + if unlocks['courses'][12345]['count'] != 1: + raise Exception('Expecting to see item ID 12345 to have count 1 in courses!') + if unlocks['courses'][12345]['sheet_num'] != 2: + raise Exception('Expecting to see item ID 12345 to have sheet_num 2 in courses!') + + # Verify score handling + scores = self.verify_player24_read_score(ref_id) + for _ in scores['medals']: + raise Exception('Got nonzero medals count on a new card!') + for _ in scores['scores']: + raise Exception('Got nonzero scores count on a new card!') + + for phase in [1, 2]: + if phase == 1: + dummyscores = [ + # An okay score on a chart + { + 'id': 987, + 'chart': 2, + 'medal': 5, + 'score': 76543, + }, + # A good score on an easier chart of the same song + { + 'id': 987, + 'chart': 0, + 'medal': 6, + 'score': 99999, + }, + # A bad score on a hard chart + { + 'id': 741, + 'chart': 3, + 'medal': 2, + 'score': 45000, + }, + # A terrible score on an easy chart + { + 'id': 742, + 'chart': 1, + 'medal': 2, + 'score': 1, + }, + ] + # Random score to add in + songid = random.randint(907, 950) + chartid = random.randint(0, 3) + score = random.randint(0, 100000) + medal = random.randint(1, 11) + dummyscores.append({ + 'id': songid, + 'chart': chartid, + 'medal': medal, + 'score': score, + }) + if phase == 2: + dummyscores = [ + # A better score on the same chart + { + 'id': 987, + 'chart': 2, + 'medal': 6, + 'score': 98765, + }, + # A worse score on another same chart + { + 'id': 987, + 'chart': 0, + 'medal': 3, + 'score': 12345, + 'expected_score': 99999, + 'expected_medal': 6, + }, + ] + + for dummyscore in dummyscores: + self.verify_player24_write_music(ref_id, dummyscore) + scores = self.verify_player24_read_score(ref_id) + for expected in dummyscores: + newscore = scores['scores'][expected['id']][expected['chart']] + newmedal = scores['medals'][expected['id']][expected['chart']] + newrank = scores['ranks'][expected['id']][expected['chart']] + + if 'expected_score' in expected: + expected_score = expected['expected_score'] + else: + expected_score = expected['score'] + if 'expected_medal' in expected: + expected_medal = expected['expected_medal'] + else: + expected_medal = expected['medal'] + + if newscore < 50000: + expected_rank = 1 + elif newscore < 62000: + expected_rank = 2 + elif newscore < 72000: + expected_rank = 3 + elif newscore < 82000: + expected_rank = 4 + elif newscore < 90000: + expected_rank = 5 + elif newscore < 95000: + expected_rank = 6 + elif newscore < 98000: + expected_rank = 7 + else: + expected_rank = 8 + + if newscore != expected_score: + raise Exception(f'Expected a score of \'{expected_score}\' for song \'{expected["id"]}\' chart \'{expected["chart"]}\' but got score \'{newscore}\'') + if newmedal != expected_medal: + raise Exception(f'Expected a medal of \'{expected_medal}\' for song \'{expected["id"]}\' chart \'{expected["chart"]}\' but got medal \'{newmedal}\'') + if newrank != expected_rank: + raise Exception(f'Expected a rank of \'{expected_rank}\' for song \'{expected["id"]}\' chart \'{expected["chart"]}\' but got rank \'{newrank}\'') + + # Sleep so we don't end up putting in score history on the same second + time.sleep(1) + else: + print("Skipping score checks for existing card") + + # Verify paseli handling + if paseli_enabled: + print("PASELI enabled for this PCBID, executing PASELI checks") + else: + print("PASELI disabled for this PCBID, skipping PASELI checks") + return + + sessid, balance = self.verify_eacoin_checkin(card) + if balance == 0: + print("Skipping PASELI consume check because card has 0 balance") + else: + self.verify_eacoin_consume(sessid, balance, random.randint(0, balance)) + self.verify_eacoin_checkout(sessid) diff --git a/bemani/utils/trafficgen.py b/bemani/utils/trafficgen.py index e029cdc..f652e61 100644 --- a/bemani/utils/trafficgen.py +++ b/bemani/utils/trafficgen.py @@ -27,6 +27,7 @@ from bemani.client.popn import ( PopnMusicLapistoriaClient, PopnMusicEclaleClient, PopnMusicUsaNekoClient, + PopnMusicPeaceClient, ) from bemani.client.ddr import ( DDRX2Client, @@ -96,7 +97,7 @@ def get_client(proto: ClientProtocol, pcbid: str, game: str, config: Dict[str, A config, ) if game == 'pnm-peace': - return PopnMusicUsaNekoClient( + return PopnMusicPeaceClient( proto, pcbid, config, diff --git a/verifytraffic b/verifytraffic index b6c135f..9e32d6e 100755 --- a/verifytraffic +++ b/verifytraffic @@ -9,6 +9,7 @@ declare -a arr=( "pnm-22" "pnm-23" "pnm-24" + "pnm-25" "iidx-20" "iidx-21" "iidx-22"