diff --git a/TODO.md b/TODO.md index f776609..b2a2584 100644 --- a/TODO.md +++ b/TODO.md @@ -2,7 +2,6 @@ Things that I have not gotten around to doing. - IIDX favorites viewer and editor for frontend. The data is all available in profile, but the interface was never built. - DDR calorie tracker and workout stats. Again, the data is all available but the interface was never built. - - Rivals support for Pop'n Music series. - Lobby for all games except Reflec Beat. Reflec is the only game with lobby support right now, but this should be fairly easy to add to other games since the backend infra is all there. Correct global IP detection is even built-in to the server and passed forward if you are using a proxy. - Prettify the frontend. Its a bit utilitarian right now, aside from some minor color flare. - Make the frontend work better on mobile. It works well enough, but it could be a much better experience. diff --git a/bemani/backend/popn/eclale.py b/bemani/backend/popn/eclale.py index ec59cb5..e965b3c 100644 --- a/bemani/backend/popn/eclale.py +++ b/bemani/backend/popn/eclale.py @@ -1,13 +1,13 @@ # vim: set fileencoding=utf-8 import binascii import copy -from typing import Dict, Optional +from typing import Dict, List, Optional from bemani.backend.popn.base import PopnMusicBase from bemani.backend.popn.lapistoria import PopnMusicLapistoria from bemani.common import Time, ValidatedDict, VersionConstants -from bemani.data import UserID +from bemani.data import UserID, Link from bemani.protocol import Node @@ -306,6 +306,93 @@ class PopnMusicEclale(PopnMusicBase): return root + def handle_player23_friend_request(self, request: Node) -> Node: + refid = request.attribute('ref_id') + no = int(request.attribute('no', '-1')) + + root = Node.void('player23') + 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, ValidatedDict] = {} + 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.get_int('extid')))) # Eclale 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 + + 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('clearmedal', 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])) + + return root + def handle_player23_write_music_request(self, request: Node) -> Node: refid = request.child_value('ref_id') @@ -443,8 +530,19 @@ class PopnMusicEclale(PopnMusicBase): account.add_child(Node.s16_array('my_best', most_played)) account.add_child(Node.s16_array('latest_music', last_played)) - # TODO: Hook up rivals for Pop'n music? - account.add_child(Node.u8('active_fr_num', 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)) # player statistics statistics = self.get_play_statistics(userid) diff --git a/bemani/backend/popn/fantasia.py b/bemani/backend/popn/fantasia.py index e79cdc7..33d5292 100644 --- a/bemani/backend/popn/fantasia.py +++ b/bemani/backend/popn/fantasia.py @@ -1,13 +1,13 @@ # vim: set fileencoding=utf-8 import copy -from typing import Optional +from typing import Dict, List, Optional from bemani.backend.popn.base import PopnMusicBase from bemani.backend.popn.tunestreet import PopnMusicTuneStreet from bemani.backend.base import Status from bemani.common import ValidatedDict, VersionConstants, Time, ID -from bemani.data import Score, UserID +from bemani.data import Score, Link, UserID from bemani.protocol import Node @@ -130,11 +130,24 @@ class PopnMusicFantasia(PopnMusicBase): today_count = statistics.get_int('today_plays', 0) else: today_count = 0 - base.add_child(Node.u8('active_fr_num', 0)) # TODO: Hook up rivals code? base.add_child(Node.s32('total_play_cnt', statistics.get_int('total_plays', 0))) base.add_child(Node.s16('today_play_cnt', today_count)) base.add_child(Node.s16('consecutive_days', statistics.get_int('consecutive_days', 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 + base.add_child(Node.u8('active_fr_num', rivalcount)) + last_played = [x[0] for x in self.data.local.music.get_last_played(self.game, self.version, userid, 3)] most_played = [x[0] for x in self.data.local.music.get_most_played(self.game, self.version, userid, 20)] while len(last_played) < 3: @@ -418,6 +431,86 @@ class PopnMusicFantasia(PopnMusicBase): return root + elif method == 'friend': + refid = request.attribute('ref_id') + root = Node.void('playerdata') + + # 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.set_attribute('status', str(Status.NO_PROFILE)) + return root + + # Grab the links that we care about. + links = self.data.local.user.get_links(self.game, self.version, userid) + profiles: Dict[UserID, ValidatedDict] = {} + 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) + + for rival in links[:2]: + rivalid = rival.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) + + # 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('open', 1)) + + # Set up some sane defaults. + friend.add_child(Node.string('name', rivalprofile.get_str('name', 'なし'))) + friend.add_child(Node.string('g_pm_id', ID.format_extid(rivalprofile.get_int('extid')))) + friend.add_child(Node.s16('chara', rivalprofile.get_int('chara', -1))) + + # Perform hiscore/medal conversion. + hiscore_array = [0] * int((((self.GAME_MAX_MUSIC_ID * 4) * 17) + 7) / 8) + clear_medal = [0] * self.GAME_MAX_MUSIC_ID + for score in scores: + if score.id > self.GAME_MAX_MUSIC_ID: + continue + + # 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 + + points = score.points + clear_medal[score.id] = clear_medal[score.id] | self.__format_medal_for_score(score) + + hiscore_index = (score.id * 4) + { + self.CHART_TYPE_EASY: self.GAME_CHART_TYPE_EASY_POSITION, + self.CHART_TYPE_NORMAL: self.GAME_CHART_TYPE_NORMAL_POSITION, + self.CHART_TYPE_HYPER: self.GAME_CHART_TYPE_HYPER_POSITION, + self.CHART_TYPE_EX: self.GAME_CHART_TYPE_EX_POSITION, + }[score.chart] + hiscore_byte_pos = int((hiscore_index * 17) / 8) + hiscore_bit_pos = int((hiscore_index * 17) % 8) + hiscore_value = points << hiscore_bit_pos + hiscore_array[hiscore_byte_pos] = hiscore_array[hiscore_byte_pos] | (hiscore_value & 0xFF) + hiscore_array[hiscore_byte_pos + 1] = hiscore_array[hiscore_byte_pos + 1] | ((hiscore_value >> 8) & 0xFF) + hiscore_array[hiscore_byte_pos + 2] = hiscore_array[hiscore_byte_pos + 2] | ((hiscore_value >> 16) & 0xFF) + + hiscore = bytes(hiscore_array) + friend.add_child(Node.u16_array('clear_medal', clear_medal)) + friend.add_child(Node.binary('hiscore', hiscore)) + + return root + # Invalid method return None @@ -458,3 +551,7 @@ class PopnMusicFantasia(PopnMusicBase): # Invalid method return None + + def handle_lobby_request(self, request: Node) -> Optional[Node]: + # Stub out the entire lobby service + return Node.void('lobby') diff --git a/bemani/backend/popn/lapistoria.py b/bemani/backend/popn/lapistoria.py index 6e15319..370ca9f 100644 --- a/bemani/backend/popn/lapistoria.py +++ b/bemani/backend/popn/lapistoria.py @@ -1,13 +1,13 @@ # vim: set fileencoding=utf-8 import copy -from typing import Optional +from typing import Dict, List, Optional from bemani.backend.popn.base import PopnMusicBase from bemani.backend.popn.sunnypark import PopnMusicSunnyPark from bemani.backend.base import Status from bemani.common import ValidatedDict, VersionConstants, Time, ID -from bemani.data import UserID +from bemani.data import UserID, Link from bemani.protocol import Node @@ -176,6 +176,93 @@ class PopnMusicLapistoria(PopnMusicBase): return root + elif method == 'friend': + refid = request.attribute('ref_id') + no = int(request.attribute('no', '-1')) + + root = Node.void('player22') + 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, ValidatedDict] = {} + 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', ID.format_extid(rivalprofile.get_int('extid')))) + 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 + + 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('clearmedal', 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])) + + return root + elif method == 'conversion': refid = request.child_value('ref_id') name = request.child_value('name') @@ -263,13 +350,26 @@ class PopnMusicLapistoria(PopnMusicBase): today_count = statistics.get_int('today_plays', 0) else: today_count = 0 - account.add_child(Node.u8('active_fr_num', 0)) # TODO: Hook up rivals code? account.add_child(Node.s16('total_play_cnt', statistics.get_int('total_plays', 0))) account.add_child(Node.s16('today_play_cnt', today_count)) account.add_child(Node.s16('consecutive_days', statistics.get_int('consecutive_days', 0))) account.add_child(Node.s16('total_days', statistics.get_int('total_days', 0))) 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)) + # Add scores section last_played = [x[0] for x in self.data.local.music.get_last_played(self.game, self.version, userid, 5)] most_played = [x[0] for x in self.data.local.music.get_most_played(self.game, self.version, userid, 10)] diff --git a/bemani/backend/popn/sunnypark.py b/bemani/backend/popn/sunnypark.py index 3a5ac8e..80ef8f2 100644 --- a/bemani/backend/popn/sunnypark.py +++ b/bemani/backend/popn/sunnypark.py @@ -1,13 +1,13 @@ # vim: set fileencoding=utf-8 import copy -from typing import Optional +from typing import Dict, List, Optional from bemani.backend.popn.base import PopnMusicBase from bemani.backend.popn.fantasia import PopnMusicFantasia from bemani.backend.base import Status from bemani.common import ValidatedDict, VersionConstants, Time, ID -from bemani.data import UserID +from bemani.data import UserID, Link from bemani.protocol import Node @@ -91,11 +91,24 @@ class PopnMusicSunnyPark(PopnMusicBase): today_count = statistics.get_int('today_plays', 0) else: today_count = 0 - base.add_child(Node.u8('active_fr_num', 0)) # TODO: Hook up rivals code? base.add_child(Node.s32('total_play_cnt', statistics.get_int('total_plays', 0))) base.add_child(Node.s16('today_play_cnt', today_count)) base.add_child(Node.s16('consecutive_days', statistics.get_int('consecutive_days', 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 + base.add_child(Node.u8('active_fr_num', rivalcount)) + last_played = [x[0] for x in self.data.local.music.get_last_played(self.game, self.version, userid, 3)] most_played = [x[0] for x in self.data.local.music.get_most_played(self.game, self.version, userid, 20)] while len(last_played) < 3: @@ -135,7 +148,13 @@ class PopnMusicSunnyPark(PopnMusicBase): 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')] - clear_medal[score.id] = clear_medal[score.id] | (medal << (score.chart * 4)) + medal_pos = { + self.CHART_TYPE_EASY: self.GAME_CHART_TYPE_EASY_POSITION, + self.CHART_TYPE_NORMAL: self.GAME_CHART_TYPE_NORMAL_POSITION, + self.CHART_TYPE_HYPER: self.GAME_CHART_TYPE_HYPER_POSITION, + self.CHART_TYPE_EX: self.GAME_CHART_TYPE_EX_POSITION, + }[score.chart] + clear_medal[score.id] = clear_medal[score.id] | (medal << (medal_pos * 4)) hiscore_index = (score.id * 4) + { self.CHART_TYPE_EASY: self.GAME_CHART_TYPE_EASY_POSITION, @@ -525,5 +544,117 @@ class PopnMusicSunnyPark(PopnMusicBase): return root + elif method == 'friend': + refid = request.attribute('ref_id') + root = Node.void('playerdata') + + # 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.set_attribute('status', str(Status.NO_PROFILE)) + return root + + # Grab the links that we care about. + links = self.data.local.user.get_links(self.game, self.version, userid) + profiles: Dict[UserID, ValidatedDict] = {} + 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) + + for rival in links[:2]: + rivalid = rival.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) + + # 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('open', 1)) + + # Set up some sane defaults. + friend.add_child(Node.string('name', rivalprofile.get_str('name', 'なし'))) + friend.add_child(Node.string('g_pm_id', ID.format_extid(rivalprofile.get_int('extid')))) + friend.add_child(Node.s16('chara', rivalprofile.get_int('chara', -1))) + + # Set up player avatar. + avatar_dict = rivalprofile.get_dict('avatar') + friend.add_child(Node.u8('hair', avatar_dict.get_int('hair', 0))) + friend.add_child(Node.u8('face', avatar_dict.get_int('face', 0))) + friend.add_child(Node.u8('body', avatar_dict.get_int('body', 0))) + friend.add_child(Node.u8('effect', avatar_dict.get_int('effect', 0))) + friend.add_child(Node.u8('object', avatar_dict.get_int('object', 0))) + friend.add_child(Node.u8_array('comment', avatar_dict.get_int_array('comment', 2))) + + # Perform hiscore/medal conversion. + hiscore_array = [0] * int((((self.GAME_MAX_MUSIC_ID * 4) * 17) + 7) / 8) + clear_medal = [0] * self.GAME_MAX_MUSIC_ID + for score in scores: + if score.id > self.GAME_MAX_MUSIC_ID: + continue + + # 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 + + points = score.points + rivalprofile.get_int('extid') % 100 + medal = { + 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_CIRCLE_CLEARED, # Map approximately + 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')] + medal_pos = { + self.CHART_TYPE_EASY: self.GAME_CHART_TYPE_EASY_POSITION, + self.CHART_TYPE_NORMAL: self.GAME_CHART_TYPE_NORMAL_POSITION, + self.CHART_TYPE_HYPER: self.GAME_CHART_TYPE_HYPER_POSITION, + self.CHART_TYPE_EX: self.GAME_CHART_TYPE_EX_POSITION, + }[score.chart] + clear_medal[score.id] = clear_medal[score.id] | (medal << (medal_pos * 4)) + + hiscore_index = (score.id * 4) + { + self.CHART_TYPE_EASY: self.GAME_CHART_TYPE_EASY_POSITION, + self.CHART_TYPE_NORMAL: self.GAME_CHART_TYPE_NORMAL_POSITION, + self.CHART_TYPE_HYPER: self.GAME_CHART_TYPE_HYPER_POSITION, + self.CHART_TYPE_EX: self.GAME_CHART_TYPE_EX_POSITION, + }[score.chart] + hiscore_byte_pos = int((hiscore_index * 17) / 8) + hiscore_bit_pos = int((hiscore_index * 17) % 8) + hiscore_value = points << hiscore_bit_pos + hiscore_array[hiscore_byte_pos] = hiscore_array[hiscore_byte_pos] | (hiscore_value & 0xFF) + hiscore_array[hiscore_byte_pos + 1] = hiscore_array[hiscore_byte_pos + 1] | ((hiscore_value >> 8) & 0xFF) + hiscore_array[hiscore_byte_pos + 2] = hiscore_array[hiscore_byte_pos + 2] | ((hiscore_value >> 16) & 0xFF) + + hiscore = bytes(hiscore_array) + friend.add_child(Node.u16_array('clear_medal', clear_medal)) + friend.add_child(Node.binary('hiscore', hiscore)) + + return root + # Invalid method return None + + def handle_lobby_request(self, request: Node) -> Optional[Node]: + # Stub out the entire lobby service + return Node.void('lobby') diff --git a/bemani/backend/popn/tunestreet.py b/bemani/backend/popn/tunestreet.py index 8305968..6a13f01 100644 --- a/bemani/backend/popn/tunestreet.py +++ b/bemani/backend/popn/tunestreet.py @@ -135,6 +135,9 @@ class PopnMusicTuneStreet(PopnMusicBase): binary_profile[63] = (profile.get_int('music') >> 8) & 0xFF binary_profile[64] = profile.get_int('sheet') & 0xFF binary_profile[65] = profile.get_int('category') & 0xFF + # This might be the count of friends, since Tune Street *does* support + # rivals. However, I can no longer get it running on my cabinet or locally + # so there's no way for me to test. binary_profile[67] = profile.get_int('medal_and_friend') & 0xFF # Format Scores diff --git a/bemani/backend/popn/usaneko.py b/bemani/backend/popn/usaneko.py index d52830d..09cffe9 100644 --- a/bemani/backend/popn/usaneko.py +++ b/bemani/backend/popn/usaneko.py @@ -7,7 +7,7 @@ from typing import Any, Dict, List, Optional, Tuple from bemani.backend.popn.base import PopnMusicBase from bemani.backend.popn.eclale import PopnMusicEclale from bemani.common import Time, ID, ValidatedDict, VersionConstants, Parallel -from bemani.data import Data, UserID, Achievement +from bemani.data import Data, UserID, Achievement, Link from bemani.protocol import Node @@ -496,6 +496,108 @@ class PopnMusicUsaNeko(PopnMusicBase): 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, ValidatedDict] = {} + 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.get_int('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 + + 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) @@ -751,8 +853,19 @@ class PopnMusicUsaNeko(PopnMusicBase): account.add_child(Node.s16('total_days', statistics.get_int('total_days', 0))) account.add_child(Node.s16('interval_day', 0)) - # TODO: Hook up rivals for Pop'n Music? - account.add_child(Node.u8('active_fr_num', 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') diff --git a/bemani/frontend/popn/endpoints.py b/bemani/frontend/popn/endpoints.py index 84fd92f..a86a9b2 100644 --- a/bemani/frontend/popn/endpoints.py +++ b/bemani/frontend/popn/endpoints.py @@ -3,7 +3,7 @@ import re from typing import Any, Dict from flask import Blueprint, request, Response, url_for, abort, g # type: ignore -from bemani.common import ID, GameConstants +from bemani.common import ID, GameConstants, VersionConstants from bemani.data import UserID from bemani.frontend.app import loginrequired, jsonify, render_react from bemani.frontend.popn.popn import PopnMusicFrontend @@ -361,12 +361,18 @@ def updatename() -> Dict[str, Any]: def viewrivals() -> Response: frontend = PopnMusicFrontend(g.data, g.config, g.cache) rivals, playerinfo = frontend.get_rivals(g.userID) + + # There is no support for Rivals in Tune Street. + if VersionConstants.POPN_MUSIC_TUNE_STREET in rivals: + del rivals[VersionConstants.POPN_MUSIC_TUNE_STREET] + return render_react( 'Pop\'n Music Rivals', 'popn/rivals.react.js', { 'userid': str(g.userID), 'rivals': rivals, + 'max_active_rivals': frontend.max_active_rivals, 'players': playerinfo, 'versions': {version: name for (game, version, name) in frontend.all_games()}, }, @@ -387,6 +393,10 @@ def listrivals() -> Dict[str, Any]: frontend = PopnMusicFrontend(g.data, g.config, g.cache) rivals, playerinfo = frontend.get_rivals(g.userID) + # There is no support for Rivals in Tune Street. + if VersionConstants.POPN_MUSIC_TUNE_STREET in rivals: + del rivals[VersionConstants.POPN_MUSIC_TUNE_STREET] + return { 'rivals': rivals, 'players': playerinfo, diff --git a/bemani/frontend/popn/popn.py b/bemani/frontend/popn/popn.py index 0e43302..afc24e6 100644 --- a/bemani/frontend/popn/popn.py +++ b/bemani/frontend/popn/popn.py @@ -2,7 +2,7 @@ from typing import Any, Dict, Iterator, Tuple from bemani.backend.popn import PopnMusicFactory, PopnMusicBase -from bemani.common import ValidatedDict, GameConstants +from bemani.common import ValidatedDict, GameConstants, VersionConstants from bemani.data import Attempt, Score, Song, UserID from bemani.frontend.base import FrontendBase @@ -20,6 +20,17 @@ class PopnMusicFrontend(FrontendBase): valid_rival_types = ['rival'] + max_active_rivals = { + # Technically there is support for Rivals in Tune Street but I + # couldn't get it booting anymore to test. + VersionConstants.POPN_MUSIC_TUNE_STREET: 0, + VersionConstants.POPN_MUSIC_FANTASIA: 2, + VersionConstants.POPN_MUSIC_SUNNY_PARK: 2, + VersionConstants.POPN_MUSIC_LAPISTORIA: 4, + VersionConstants.POPN_MUSIC_ECLALE: 4, + VersionConstants.POPN_MUSIC_USANEKO: 4, + } + def all_games(self) -> Iterator[Tuple[str, int, str]]: yield from PopnMusicFactory.all_games() diff --git a/bemani/frontend/sdvx/endpoints.py b/bemani/frontend/sdvx/endpoints.py index c3629d3..35c7fe5 100644 --- a/bemani/frontend/sdvx/endpoints.py +++ b/bemani/frontend/sdvx/endpoints.py @@ -3,7 +3,7 @@ import re from typing import Any, Dict from flask import Blueprint, request, Response, url_for, abort, g # type: ignore -from bemani.common import ID, GameConstants +from bemani.common import ID, GameConstants, VersionConstants from bemani.data import UserID from bemani.frontend.app import loginrequired, jsonify, render_react from bemani.frontend.sdvx.sdvx import SoundVoltexFrontend @@ -355,10 +355,10 @@ def viewrivals() -> Response: rivals, playerinfo = frontend.get_rivals(g.userID) # There were no rivals in SDVX 1 or 2. - if 1 in rivals: - del rivals[1] - if 2 in rivals: - del rivals[2] + if VersionConstants.SDVX_BOOTH in rivals: + del rivals[VersionConstants.SDVX_BOOTH] + if VersionConstants.SDVX_INFINITE_INFECTION in rivals: + del rivals[VersionConstants.SDVX_INFINITE_INFECTION] return render_react( 'SDVX Rivals', @@ -386,6 +386,12 @@ def listrivals() -> Dict[str, Any]: frontend = SoundVoltexFrontend(g.data, g.config, g.cache) rivals, playerinfo = frontend.get_rivals(g.userID) + # There were no rivals in SDVX 1 or 2. + if VersionConstants.SDVX_BOOTH in rivals: + del rivals[VersionConstants.SDVX_BOOTH] + if VersionConstants.SDVX_INFINITE_INFECTION in rivals: + del rivals[VersionConstants.SDVX_INFINITE_INFECTION] + return { 'rivals': rivals, 'players': playerinfo, diff --git a/bemani/frontend/static/controllers/popn/rivals.react.js b/bemani/frontend/static/controllers/popn/rivals.react.js index 2ff32c3..ba99086 100644 --- a/bemani/frontend/static/controllers/popn/rivals.react.js +++ b/bemani/frontend/static/controllers/popn/rivals.react.js @@ -128,7 +128,7 @@ var rivals_view = React.createClass({ } } - if (count >= 4) { avail = false; } + if (count >= window.max_active_rivals[this.state.version]) { avail = false; } return (