From a77ad50345054518c519ae830c6ef1d3e4151450 Mon Sep 17 00:00:00 2001 From: Hay1tsme Date: Tue, 7 Oct 2025 02:05:27 -0400 Subject: [PATCH] Add code from old fork --- bemani/backend/bst/__init__.py | 7 + bemani/backend/bst/base.py | 23 + bemani/backend/bst/beatstream.py | 402 ++++++++ bemani/backend/bst/beatstream2.py | 869 ++++++++++++++++++ bemani/backend/bst/factory.py | 32 + bemani/client/bst/__init__.py | 5 + bemani/client/bst/beatstream2.py | 261 ++++++ bemani/common/constants.py | 15 + bemani/data/triggers.py | 3 +- bemani/frontend/app.py | 17 + bemani/frontend/bst/__init__.py | 8 + bemani/frontend/bst/bst.py | 18 + bemani/frontend/bst/cache.py | 16 + bemani/frontend/bst/endpoints.py | 52 ++ .../static/controllers/bst/scores.react.js | 1 + bemani/utils/config.py | 3 + bemani/utils/frontend.py | 3 + bemani/utils/read.py | 171 ++++ bemani/utils/trafficgen.py | 12 + config/server.yaml | 9 +- 20 files changed, 1922 insertions(+), 5 deletions(-) create mode 100644 bemani/backend/bst/__init__.py create mode 100644 bemani/backend/bst/base.py create mode 100644 bemani/backend/bst/beatstream.py create mode 100644 bemani/backend/bst/beatstream2.py create mode 100644 bemani/backend/bst/factory.py create mode 100644 bemani/client/bst/__init__.py create mode 100644 bemani/client/bst/beatstream2.py create mode 100644 bemani/frontend/bst/__init__.py create mode 100644 bemani/frontend/bst/bst.py create mode 100644 bemani/frontend/bst/cache.py create mode 100644 bemani/frontend/bst/endpoints.py create mode 100644 bemani/frontend/static/controllers/bst/scores.react.js diff --git a/bemani/backend/bst/__init__.py b/bemani/backend/bst/__init__.py new file mode 100644 index 0000000..6aee455 --- /dev/null +++ b/bemani/backend/bst/__init__.py @@ -0,0 +1,7 @@ +from bemani.backend.bst.factory import BSTFactory +from bemani.backend.bst.base import BSTBase + +__all__ = [ + "BSTFactory", + "BSTBase", +] diff --git a/bemani/backend/bst/base.py b/bemani/backend/bst/base.py new file mode 100644 index 0000000..b9cf6c7 --- /dev/null +++ b/bemani/backend/bst/base.py @@ -0,0 +1,23 @@ +from bemani.common import Profile +from bemani.data.types import UserID +from bemani.backend.base import Base +from bemani.backend.core import CoreHandler, CardManagerHandler, PASELIHandler +from bemani.common import GameConstants +from bemani.protocol import Node + +class BSTBase(CoreHandler, CardManagerHandler, PASELIHandler, Base): + game = GameConstants.BST + + # Helper method that enables events based on the server config + def get_events(self) -> Node: + return Node.void('event_ctrl') + + def update_score(self, extid, songid, chartid, loc, points, gauge, + max_combo, grade, medal, fanta_count, great_count, fine_count, miss_count) -> None: + return None + + def unformat_player_profile(self, profile: Node, refid: str, extid: int, userid: UserID) -> Profile: + return None + + def format_player_profile(self, profile: Profile, userid: UserID) -> Node: + return None \ No newline at end of file diff --git a/bemani/backend/bst/beatstream.py b/bemani/backend/bst/beatstream.py new file mode 100644 index 0000000..92c4cc8 --- /dev/null +++ b/bemani/backend/bst/beatstream.py @@ -0,0 +1,402 @@ +import copy +import random +import struct +from typing import Optional, Dict, Any, List, Tuple +import time + +from bemani.backend.bst.base import BSTBase + +from bemani.common import Profile, ValidatedDict, VersionConstants, ID, Time +from bemani.backend.ess import EventLogHandler +from bemani.data import Data, UserID, Score +from bemani.protocol import Node + +class Beatstream(EventLogHandler, BSTBase): + name = "BeatStream" + version = VersionConstants.BEATSTREAM + #TODO: Beatstream 1 support + + GRADE_AAA = 1 + GRADE_AA = 2 + GRADE_A = 3 + GRADE_B = 4 + GRADE_C = 5 + GRADE_D = 6 + + MEDAL_NOPLAY = 0 + MEDAL_FAILED = 1 + MEDAL_SAVED = 2 + MEDAL_CLEAR = 3 + MEDAL_FC = 4 + MEDAL_PERFECT = 5 + + def get_settings(cls) -> Dict[str, Any]: + """ + Return all of our front-end modifiably settings. + """ + return { + 'bools': [ + { + 'name': 'Enable Local Matching', + 'tip': 'Enable local matching between games.', + 'category': 'game_config', + 'setting': 'enable_local_match', + }, + { + 'name': 'Enable Global Matching', + 'tip': 'Enable global matching between games.', + 'category': 'game_config', + 'setting': 'enable_global_match', + }, + ] + } + + def get_events(self) -> Node: + root = super().get_events() + game_config = self.get_game_config() + + # Campaign + data = Node.void('data') + data.add_child(Node.s32('type', 0)) + data.add_child(Node.s32('phase', 18)) + root.add_child(data) + + # Beast crissis + data = Node.void('data') + data.add_child(Node.s32('type', 1)) + data.add_child(Node.s32('phase', 4)) + root.add_child(data) + + # 5th KAC screen on the demo reel + data = Node.void('data') + data.add_child(Node.s32('type', 3)) + data.add_child(Node.s32('phase', 1)) + root.add_child(data) + + # Eamuse app screenshots + data = Node.void('data') + data.add_child(Node.s32('type', 3)) + data.add_child(Node.s32('phase', 2)) + root.add_child(data) + + # Enables continues + data = Node.void('data') + data.add_child(Node.s32('type', 3)) + data.add_child(Node.s32('phase', 3)) + root.add_child(data) + + # Allows 3 stage with paseli + data = Node.void('data') + data.add_child(Node.s32('type', 3)) + data.add_child(Node.s32('phase', 4)) + root.add_child(data) + + # Local matching at start of credit enable + data = Node.void('data') + data.add_child(Node.s32('type', 3)) + data.add_child(Node.s32('phase', 7)) + if game_config.get_bool('enable_local_match'): + root.add_child(data) + + # Controlls floor infection on attract screen ONLY + data = Node.void('data') + data.add_child(Node.s32('type', 3)) + data.add_child(Node.s32('phase', 8)) + root.add_child(data) + + # Global matching during song loading enable + data = Node.void('data') + data.add_child(Node.s32('type', 3)) + data.add_child(Node.s32('phase', 12)) + if game_config.get_bool('enable_global_match'): + root.add_child(data) + + # Unlocks the bemani rockin fes songs + data = Node.void('data') + data.add_child(Node.s32('type', 3)) + data.add_child(Node.s32('phase', 13)) + root.add_child(data) + + # Enables Illil partner addition + data = Node.void('data') + data.add_child(Node.s32('type', 3)) + data.add_child(Node.s32('phase', 16)) + root.add_child(data) + + # Controls notifs when carding in + data = Node.void('data') + data.add_child(Node.s32('type', 4)) + data.add_child(Node.s32('phase', 31)) + root.add_child(data) + + # Courses + # 1 = 1-12, 2 = 13 and 14, 3 = 15, 4 = kami + # any other value disables courses + # need to set 1-4 to enable all courses + for x in range(1,5): + data = Node.void('data') + data.add_child(Node.s32('type', 7)) + data.add_child(Node.s32('phase', x)) + root.add_child(data) + + # Beast Hacker + data = Node.void('data') + data.add_child(Node.s32('type', 8)) + data.add_child(Node.s32('phase', 10)) # Phase 1 - 9, plus unused 10th + root.add_child(data) + + # First play free + data = Node.void('data') + data.add_child(Node.s32('type', 1100)) + data.add_child(Node.s32('phase', 2)) + root.add_child(data) + + return root + + # Helper method to unformat the player profile into a ValidatedDict for the DB + def unformat_player_profile(self, profile: Node) -> Profile: + userid = self.data.local.user.from_extid(self.game, self.version, profile.child_value('account/usrid')) + ret = Profile(self.game, self.version, profile.child_value('account/rid'), profile.child_value('account/usrid')) + + # Account + next_tpc = int(profile.child_value('account/tpc')) + 1 + ret.replace_int('usrid', int(profile.child_value('account/usrid'))) + ret.replace_int('tpc', next_tpc) + ret.replace_int('dpc', int(profile.child_value('account/dpc'))) + ret.replace_int('crd', int(profile.child_value('account/crd'))) + ret.replace_int('brd', int(profile.child_value('account/brd'))) + ret.replace_int('tdc', int(profile.child_value('account/tdc'))) + ret.replace_str('lid', profile.child_value('account/lid')) + ret.replace_int('ver', int(profile.child_value('account/ver'))) + + # Base + ret.replace_str('name', profile.child_value('base/name')) + ret.replace_int('brnk', int(profile.child_value('base/brnk'))) + ret.replace_int('bcnum', int(profile.child_value('base/bcnum'))) + ret.replace_int('lcnum', int(profile.child_value('base/lcnum'))) + ret.replace_int('volt', int(profile.child_value('base/volt'))) + ret.replace_int('gold', int(profile.child_value('base/gold'))) + ret.replace_int('lmid', int(profile.child_value('base/lmid'))) + ret.replace_int('lgrd', int(profile.child_value('base/lgrd'))) + ret.replace_int('lsrt', int(profile.child_value('base/lsrt'))) + ret.replace_int('ltab', int(profile.child_value('base/ltab'))) + ret.replace_int('splv', int(profile.child_value('base/splv'))) + ret.replace_int('pref', int(profile.child_value('base/pref'))) + + # Base2 + ret.replace_int('lcid', int(profile.child_value('base2/lcid'))) + ret.replace_int('hat', int(profile.child_value('base2/hat'))) + + # Items stored as achievements + items = profile.child('item') + if items is not None and int(profile.child_value('account/usrid')) != 0: + for i in items.children: + self.data.local.user.put_achievement(self.game, self.version, userid, i.child_value('id'), + f"item_{i.child_value('type')}", {"type": i.child_value('type'), "param": i.child_value('param'), + "count": i.child_value('count')}) + + # Customize + custom = profile.child_value('customize/custom') + if custom is not None: + customize = [] + for i in custom: + customize.append(i) + ret.replace_int_array('custom', 16, custom) + + # Tips + ret.replace_int('last_tips', profile.child_value('tips/last_tips')) + + # Beast hacker + hacker = profile.child("hacker") + for x in hacker.children: + self.data.local.user.put_achievement(self.game, self.version, userid, x.child_value("id"), "hacker", { + "state0": x.child_value("state0"), "state1": x.child_value("state1"), "state2": x.child_value("state2"), + "state3": x.child_value("state3"), "state4": x.child_value("state4") + }) + return ret + + def handle_info_stagedata_write_request(self, request: Node) -> Node: + userid = request.child_value('user_id') + musicid = request.child_value('select_music_id') + chartid = request.child_value('select_grade') + location = self.get_machine_id() + points = request.child_value('result_score') + gauge = request.child_value('result_clear_gauge') + max_combo = request.child_value('result_max_combo') + grade = request.child_value('result_grade') + medal = request.child_value('result_medal') + fantastic_count = request.child_value('result_fanta') + great_count = request.child_value('result_great') + fine_count = request.child_value('result_fine') + miss_count = request.child_value('result_miss') + + self.update_score(userid, musicid, chartid, location, points, gauge, + max_combo, grade, medal, fantastic_count, great_count, fine_count, miss_count) + + return Node.void('player2') + + def handle_pcb_boot_request(self, request: Node) -> Node: + machine = self.data.local.machine.get_machine(self.config['machine']['pcbid']) + arcade = self.data.local.machine.get_arcade(machine.arcade) + pcb2 = Node.void('pcb') + pcb2.set_attribute('status', '0') + sinfo = Node.void('sinfo') + pcb2.add_child(sinfo) + + sinfo_nm = Node.string('nm', arcade.name) + sinfo_cl_enbl = Node.bool('cl_enbl', False) + sinfo_cl_h = Node.u8('cl_h', 0) + sinfo_cl_m = Node.u8('cl_m', 0) + sinfo_shop_flag = Node.bool('shop_flag', True) + + sinfo.add_child(sinfo_nm) + sinfo.add_child(sinfo_cl_enbl) + sinfo.add_child(sinfo_cl_h) + sinfo.add_child(sinfo_cl_m) + sinfo.add_child(sinfo_shop_flag) + + return pcb2 + + def handle_info_common_request(self, request: Node) -> Node: + info2 = Node.void('info') + info2.set_attribute('status', '0') + + event_ctrl = self.get_events() + info2.add_child(event_ctrl) + + return info2 + + # Called after settings_write, not sure what it does + def handle_info_music_count_read_request(self, request: Node) -> Node: + info2 = Node.void('info2') + record = Node.void('record') + record.add_child(Node.void('rec')) + record.add_child(Node.void('rate')) + info2.add_child(record) + return info2 + + # Called after music_count_read. Might have something to do with song popularity? + def handle_info_music_ranking_read_request(self, Request: Node) -> Node: + info2 = Node.void('info2') + ranking = Node.void('ranking') + ranking.add_child(Node.void('count')) + info2.add_child(ranking) + return info2 + + # First call when somebody cards in, returns the status of a few crossover events + def handle_player_start_request(self, request: Node) -> Node: + userid = self.data.local.user.from_refid(self.game, self.version, request.child_value('rid')) + profile = self.data.local.user.get_profile(self.game, self.version, userid) + player = Node.void('player') + + plytime = Node.s32('plyid', 0) + if profile is not None: + plytime = Node.s32('plyid', profile.get_int("plyid", 1)) + + player.add_child(plytime) + + start_time = Node.u64('start_time', Time.now() * 1000) + player.add_child(start_time) + + return player + + def handle_lobby_entry_request(self, request: Node) -> Node: + lobby2 = Node.void('lobby2') + lobby2.add_child(Node.s32('interval', 120)) + lobby2.add_child(Node.s32('interval_p', 120)) + + global_ip = "".join(str(e) + "." for e in request.child_value('e/ga'))[:-1], + local_ip = "".join(str(e) + "." for e in request.child_value('e/la'))[:-1], + session = self.data.local.lobby.get_play_session_info_by_ip(self.game, self.version, global_ip, local_ip) + userid = 0 + requested_lobby_id = request.child_value('e/eid') + lobby = None + + if userid is not None: + userid = session.get_int("userid") + + if requested_lobby_id > 0: + # Get the detales of the requested lobby + lobby = self.data.local.lobby.get_lobby_by_id(self.game, self.version, requested_lobby_id) + + if lobby is None: + # Make a new lobby + self.data.local.lobby.put_lobby( + self.game, + self.version, + userid, + { + 'ver': request.child_value('e/ver'), + 'mid': request.child_value('e/mid'), + 'rest': request.child_value('e/rest'), + 'uid': request.child_value('e/uid'), + 'mmode': request.child_value('e/mmode'), + 'mg': request.child_value('e/mg'), + 'mopt': request.child_value('e/mopt'), + 'lid': request.child_value('e/lid'), + 'sn': request.child_value('e/sn'), + 'pref': request.child_value('e/pref'), + 'eatime': request.child_value('e/eatime'), + 'ga': request.child_value('e/ga'), + 'gp': request.child_value('e/gp'), + 'la': request.child_value('e/la'), + } + ) + + lobby = self.data.local.lobby.get_lobby(self.game, self.version, userid) + + lobby2.add_child(Node.s32('eid', lobby.get_int('id'))) + e = Node.void('e') + lobby2.add_child(e) + e.add_child(Node.s32('eid', lobby.get_int('id'))) + e.add_child(Node.u8('ver', lobby.get_int('ver'))) + e.add_child(Node.u16('mid', lobby.get_int('mid'))) + e.add_child(Node.u8('rest', lobby.get_int('rest'))) + e.add_child(Node.s32('uid', lobby.get_int('mmode'))) + e.add_child(Node.s32('mmode', lobby.get_int('mmode'))) + e.add_child(Node.s16('mg', lobby.get_int('mg'))) + e.add_child(Node.s32('mopt', lobby.get_int('mopt'))) + e.add_child(Node.string('lid', lobby.get_str('lid'))) + e.add_child(Node.string('sn', lobby.get_str('sn'))) + e.add_child(Node.u8('pref', lobby.get_int('pref'))) + e.add_child(Node.s16('eatime', lobby.get_int('eatime'))) + e.add_child(Node.u8_array('ga', lobby.get_int_array('ga', 4))) + e.add_child(Node.u16('gp', lobby.get_int('gp'))) + e.add_child(Node.u8_array('la', lobby.get_int_array('la', 4))) + + return lobby2 + + def handle_shop_setting_write_request(self, request: Node) -> Node: + shop2 = Node.void('shop2') + #TODO: shop settings saving + return shop2 + + def handle_player_end_request(self, request: Node) -> Node: + self.data.local.lobby.destroy_play_session_info(self.game, self.version, + self.data.local.user.from_refid(self.game, self.version, request.child_value("rid"))) + return Node.void('player2') + + # Called either when carding out or creating a new profile + def handle_player_write_request(self, request: Node) -> Node: + refid = request.child_value('pdata/account/rid') + extid = request.child_value('pdata/account/usrid') + pdata = request.child('pdata') + reply = Node.void('player2') + + profile = self.unformat_player_profile(pdata) + userid = self.data.remote.user.from_refid(self.game, self.version, refid) # Get the userid for the refid + + # The game always wants the extid sent back, so we only have to look it up if it's 0 + if extid == 0: + extid = self.data.local.user.get_extid(self.game, self.version, userid) # Get the extid for the profile we just saved + profile.replace_int('usrid', extid) # Replace the extid in the profile with the one generated by butils + + self.put_profile(userid, profile) # Save the profile + + node_uid = Node.s32('uid', extid) # Send it back as a node + reply.add_child(node_uid) + + return reply + + def handle_info_matching_data_write(self, request: Node) -> Node: + return Node.void("info") \ No newline at end of file diff --git a/bemani/backend/bst/beatstream2.py b/bemani/backend/bst/beatstream2.py new file mode 100644 index 0000000..fdd0c77 --- /dev/null +++ b/bemani/backend/bst/beatstream2.py @@ -0,0 +1,869 @@ +from typing import Dict, Any + +from bemani.common import Profile, ValidatedDict, VersionConstants, ID, Time +from bemani.backend.bst.base import BSTBase +from bemani.common import VersionConstants, Time +from bemani.backend.ess import EventLogHandler +from bemani.common.constants import BroadcastConstants, GameConstants, VersionConstants +from bemani.common.validateddict import ValidatedDict +from bemani.data.types import UserID +from bemani.protocol import Node +from bemani.data import Song + +class Beatstream2(EventLogHandler, BSTBase): + name = "BeatStream アニムトライヴ" + version = VersionConstants.BEATSTREAM_2 + + GRADE_AAA_RED = 0 + GRADE_AAA = 1 + GRADE_AA = 2 + GRADE_A = 3 + GRADE_B = 4 + GRADE_C = 5 + GRADE_D = 6 + + MEDAL_NOPLAY = 0 + MEDAL_FAILED = 1 + MEDAL_SAVED = 2 + MEDAL_CLEAR = 3 + MEDAL_FC = 4 + MEDAL_PERFECT = 5 + + @classmethod + def get_settings(cls) -> Dict[str, Any]: + """ + Return all of our front-end modifiably settings. + """ + return { + 'bools': [ + { + 'name': 'Enable Local Matching', + 'tip': 'Enable local matching between games.', + 'category': 'game_config', + 'setting': 'enable_local_match', + }, + { + 'name': 'Enable Global Matching', + 'tip': 'Enable global matching between games.', + 'category': 'game_config', + 'setting': 'enable_global_match', + }, + { + 'name': 'Force Unlock Museca Crossover', + 'tip': 'Gives the Illil avitar to players even if they haven\'t played Museca 1+1/2', + 'category': 'game_config', + 'setting': 'force_unlock_museca_xover', + }, + { + 'name': 'Force Unlock Floor Infection', + 'tip': 'Force unlocks the Floor Infection Part 20 SDVX 3 crossover songs', + 'category': 'game_config', + 'setting': 'force_unlock_sdvx_xover', + }, + { + 'name': 'Enable Pop\'n Crossover', + 'tip': 'Enables the BeaSt pop\'n tanabata matsuri event', + 'category': 'game_config', + 'setting': 'enable_popn_xover', + }, + { + 'name': 'Enable REFLEC Crossover', + 'tip': 'Enables the HAPPY☆SUMMER CAMPAIGN event', + 'category': 'game_config', + 'setting': 'enable_reflec_xover', + }, + ] + } + + def get_events(self) -> Node: + root = super().get_events() + game_config = self.get_game_config() + + # Campaign + data = Node.void('data') + data.add_child(Node.s32('type', 0)) + data.add_child(Node.s32('phase', 18)) + root.add_child(data) + + # Beast crissis + data = Node.void('data') + data.add_child(Node.s32('type', 1)) + data.add_child(Node.s32('phase', 4)) + root.add_child(data) + + # 5th KAC screen on the demo reel + data = Node.void('data') + data.add_child(Node.s32('type', 3)) + data.add_child(Node.s32('phase', 1)) + root.add_child(data) + + # Eamuse app screenshots + data = Node.void('data') + data.add_child(Node.s32('type', 3)) + data.add_child(Node.s32('phase', 2)) + # Only enable if we have webhooks set up for this game + if self.config.webhooks.discord[self.game]: + root.add_child(data) + + # Enables continues + data = Node.void('data') + data.add_child(Node.s32('type', 3)) + data.add_child(Node.s32('phase', 3)) + root.add_child(data) + + # Allows 3 stage with paseli + data = Node.void('data') + data.add_child(Node.s32('type', 3)) + data.add_child(Node.s32('phase', 4)) + root.add_child(data) + + # Local matching at start of credit enable + data = Node.void('data') + data.add_child(Node.s32('type', 3)) + data.add_child(Node.s32('phase', 7)) + if game_config.get_bool('enable_local_match'): + root.add_child(data) + + # Controlls floor infection on attract screen ONLY + data = Node.void('data') + data.add_child(Node.s32('type', 3)) + data.add_child(Node.s32('phase', 8)) + root.add_child(data) + + # Global matching during song loading enable + data = Node.void('data') + data.add_child(Node.s32('type', 3)) + data.add_child(Node.s32('phase', 12)) + if game_config.get_bool('enable_global_match'): + root.add_child(data) + + # Unlocks the bemani rockin fes songs + data = Node.void('data') + data.add_child(Node.s32('type', 3)) + data.add_child(Node.s32('phase', 13)) + root.add_child(data) + + # Enables Illil partner addition if is_play_museca is True + data = Node.void('data') + data.add_child(Node.s32('type', 3)) + data.add_child(Node.s32('phase', 16)) + root.add_child(data) + + # Controls notifs when carding in + data = Node.void('data') + data.add_child(Node.s32('type', 4)) + data.add_child(Node.s32('phase', 31)) + root.add_child(data) + + # Courses + # 1 = 1-12, 2 = 13 and 14, 3 = 15, 4 = kami + # any other value disables courses + # need to set 1-4 to enable all courses + for x in range(1,5): + data = Node.void('data') + data.add_child(Node.s32('type', 7)) + data.add_child(Node.s32('phase', x)) + root.add_child(data) + + # Beast Hacker + data = Node.void('data') + data.add_child(Node.s32('type', 8)) + data.add_child(Node.s32('phase', 10)) # Phase 1 - 9, plus unused 10th + root.add_child(data) + + # First play free + data = Node.void('data') + data.add_child(Node.s32('type', 1100)) + data.add_child(Node.s32('phase', 2)) + root.add_child(data) + + return root + + def update_score(self, extid, songid, chartid, loc, points, gauge, + max_combo, grade, medal, fanta_count, great_count, fine_count, miss_count) -> None: + butils_userid = self.data.local.user.from_extid(self.game, self.version, extid) + if butils_userid is None: + return None + + old_score = self.data.local.music.get_score(self.game, self.version, butils_userid, songid, chartid) + + if old_score is not None: + new_record = old_score.points < points + new_points = max(old_score.points, points) + new_data = old_score.data + else: + new_record = True + new_points = points + new_data = ValidatedDict() + + new_loc = loc + # Only update the location if it's a new high score + if not new_record: + new_loc = old_score.location + + new_gauge = max(new_data.get_int('gauge'), gauge) + new_data.replace_int('gauge', new_gauge) + + new_max_combo = max(new_data.get_int('max_combo'), max_combo) + new_data.replace_int('max_combo', new_max_combo) + + # Grades get better as their value decreases + if "grade" in new_data: + new_grade = min(new_data.get_int('grade'), grade) + new_data.replace_int('grade', new_grade) + else: + new_data.replace_int('grade', grade) + + new_medal = max(new_data.get_int('medal'), medal) + new_data.replace_int('medal', new_medal) + + # Only replace notecoutns if we upscored + if new_record: + new_data.replace_int('fanta_count', fanta_count) + new_data.replace_int('great_count', great_count) + new_data.replace_int('fine_count', fine_count) + new_data.replace_int('miss_count', miss_count) + + data = { + "gauge": gauge, + "max_combo": max_combo, + "grade": grade, + "medal" : medal, + "fanta_count": fanta_count, + "great_count": great_count, + "fine_count": fine_count, + "miss_count": miss_count + } + + self.data.local.music.put_attempt( + self.game, + self.version, + butils_userid, + songid, + chartid, + loc, + points, + data, + new_record, + Time.now()) + + self.data.local.music.put_score(self.game, + self.version, + butils_userid, + songid, + chartid, + new_loc, + new_points, + new_data, + new_record, + Time.now()) + + # Helper method to formay a player profile as a Node that the game will accept + def format_player_profile(self, profile: Profile, userid: UserID) -> Node: + root = Node.void("player2") + pdata = Node.void('pdata') + root.add_child(pdata) + + account = Node.void('account') + account.add_child(Node.s32('usrid', profile.extid)) + account.add_child(Node.s32('is_takeover', profile.get_int("is_takeover"))) + account.add_child(Node.s32('tpc', profile.get_int("tpc"))) + account.add_child(Node.s32('dpc', profile.get_int("dpc"))) + account.add_child(Node.s32('crd', profile.get_int("crd"))) + account.add_child(Node.s32('brd', profile.get_int("brd"))) + account.add_child(Node.s32('tdc', profile.get_int("tdc"))) + account.add_child(Node.s32('intrvld', profile.get_int("intrvld"))) + account.add_child(Node.s16('ver', profile.get_int("ver"))) + account.add_child(Node.u64('pst', profile.get_int("pst"))) + account.add_child(Node.u64('st', Time.now() * 1000)) + account.add_child(Node.bool('ea', profile.get_int("ea", True))) + pdata.add_child(account) + + base = Node.void('base') + base.add_child(Node.string('name', profile.get_str('name'))) + base.add_child(Node.s8('brnk', profile.get_int('brnk'))) + base.add_child(Node.s8('bcnum', profile.get_int('bcnum'))) + base.add_child(Node.s8('lcnum', profile.get_int('lcnum'))) + base.add_child(Node.s32('volt', profile.get_int('volt'))) + base.add_child(Node.s32('gold', profile.get_int('gold'))) + base.add_child(Node.s32('lmid', profile.get_int('lmid'))) + base.add_child(Node.s8('lgrd', profile.get_int('lgrd'))) + base.add_child(Node.s8('lsrt', profile.get_int('lsrt'))) + base.add_child(Node.s8('ltab', profile.get_int('ltab'))) + base.add_child(Node.s8('splv', profile.get_int('splv'))) + base.add_child(Node.s8('pref', profile.get_int('pref'))) + base.add_child(Node.s32('lcid', profile.get_int('lcid'))) + base.add_child(Node.s32('hat', profile.get_int('hat'))) + pdata.add_child(base) + + survey = Node.void('survey') + survey.add_child(Node.s8('motivate', 0)) # Needs testing + pdata.add_child(survey) + + item = Node.void('item') + hacker = Node.void('hacker') + course = Node.void('course') + play_log = Node.void('play_log') + + achievements = self.data.local.user.get_achievements(self.game, self.version, userid) + for i in achievements: + if i.type[:4] == "item": + info = Node.void('info') + info.add_child(Node.s32('id', i.id)) + info.add_child(Node.s32('type', i.data.get_int('type'))) + info.add_child(Node.s32('param', i.data.get_int('param'))) + info.add_child(Node.s32('count', i.data.get_int('count'))) + item.add_child(info) + + elif i.type == "hacker": + info = Node.void('info') + info.add_child(Node.s32('id', i.id)) + info.add_child(Node.s8('state0', i.data.get_int("state0"))) + info.add_child(Node.s8('state1', i.data.get_int("state1"))) + info.add_child(Node.s8('state2', i.data.get_int("state2"))) + info.add_child(Node.s8('state3', i.data.get_int("state3"))) + info.add_child(Node.s8('state4', i.data.get_int("state4"))) + info.add_child(Node.u64('update_time', i.data.get_int('update_time', Time.now() * 1000))) # update_time is required or the profile will fail + hacker.add_child(info) + + elif i.type == "course": + info = Node.void('record') + info.add_child(Node.s32('course_id', i.data.get_int("course_id"))) + info.add_child(Node.s32('play', i.data.get_int("clear"))) # Play_id? + info.add_child(Node.bool('is_touch', i.data.get_bool("is_touch", False))) # ??? + info.add_child(Node.s32('clear', i.data.get_int("clear"))) # Not sure what it wants here... + info.add_child(Node.s32('gauge', i.data.get_int("gauge"))) + info.add_child(Node.s32('score', i.data.get_int("score"))) + info.add_child(Node.s32('grade', i.data.get_int("grade"))) + info.add_child(Node.s32('medal', i.data.get_int("medal"))) + info.add_child(Node.s32('combo', i.data.get_int("combo"))) + course.add_child(info) + + rate = Node.void('rate') + rate.add_child(Node.s32('course_id', i.data.get_int("course_id"))) + rate.add_child(Node.s32('play_count', i.data.get_int("play_count"))) + rate.add_child(Node.s32('clear_count', i.data.get_int("clear_count"))) + course.add_child(rate) + + elif i.type == "crysis": + crysis = Node.void('crysis') + crysis.add_child(Node.s32('id', i.id)) + crysis.add_child(Node.s8('step', i.data.get_int("step"))) + crysis.add_child(Node.s32('r_gauge', i.data.get_int("r_gauge"))) + crysis.add_child(Node.s8('r_state', i.data.get_int("r_state"))) + play_log.add_child(crysis) + + pdata.add_child(item) + pdata.add_child(course) + pdata.add_child(hacker) + pdata.add_child(play_log) + + customize = Node.void('customize') + customize.add_child(Node.u16_array('custom', profile.get_int_array('custom', 16))) + pdata.add_child(customize) + + tips = Node.void('tips') + tips.add_child(Node.s32('last_tips', profile.get_int('last_tips'))) + pdata.add_child(tips) + + record = Node.void('record') + scores = self.data.local.music.get_scores(self.game, self.version, userid) + for i in scores: + rec = Node.void('rec') + rec.add_child(Node.s32('music_id', i.id)) + rec.add_child(Node.s32('note_level', i.chart)) + rec.add_child(Node.s32('play_count', i.plays)) + rec.add_child(Node.s32('clear_count', i.data.get_int('clear_count'))) + rec.add_child(Node.s32('best_gauge', i.data.get_int('gauge'))) + rec.add_child(Node.s32('best_score', i.points)) + rec.add_child(Node.s32('best_grade', i.data.get_int('grade'))) + rec.add_child(Node.s32('best_medal', i.data.get_int('medal'))) + record.add_child(rec) + pdata.add_child(record) + + return root + + # Helper method to unformat the player profile into a ValidatedDict for the DB + def unformat_player_profile(self, profile: Node, refid: str, extid: int, userid: UserID) -> Profile: + ret = Profile(self.game, self.version, refid, extid) + + # Account + next_tpc = int(profile.child_value('account/tpc')) + 1 + ret.replace_int('is_takeover', int(profile.child_value('account/is_takeover'))) + ret.replace_int('tpc', next_tpc) + ret.replace_int('dpc', int(profile.child_value('account/dpc'))) + ret.replace_int('crd', int(profile.child_value('account/crd'))) + ret.replace_int('brd', int(profile.child_value('account/brd'))) + ret.replace_int('tdc', int(profile.child_value('account/tdc'))) + ret.replace_str('lid', profile.child_value('account/lid')) + ret.replace_int('ver', int(profile.child_value('account/ver'))) + ret.replace_int('st', int(profile.child_value('account/st'))) + + # Base + ret.replace_str('name', profile.child_value('base/name')) + ret.replace_int('brnk', int(profile.child_value('base/brnk'))) + ret.replace_int('bcnum', int(profile.child_value('base/bcnum'))) + ret.replace_int('lcnum', int(profile.child_value('base/lcnum'))) + ret.replace_int('volt', int(profile.child_value('base/volt'))) + ret.replace_int('gold', int(profile.child_value('base/gold'))) + ret.replace_int('lmid', int(profile.child_value('base/lmid'))) + ret.replace_int('lgrd', int(profile.child_value('base/lgrd'))) + ret.replace_int('lsrt', int(profile.child_value('base/lsrt'))) + ret.replace_int('ltab', int(profile.child_value('base/ltab'))) + ret.replace_int('splv', int(profile.child_value('base/splv'))) + ret.replace_int('pref', int(profile.child_value('base/pref'))) + ret.replace_int('lcid', int(profile.child_value('base/lcid'))) + ret.replace_int('hat', int(profile.child_value('base/hat'))) + + # Items stored as achievements + items = profile.child('item') + if items is not None: + for i in items.children: + self.data.local.user.put_achievement(self.game, self.version, userid, i.child_value('id'), + f"item_{i.child_value('type')}", {"type": i.child_value('type'), "param": i.child_value('param'), + "count": i.child_value('count')}) + + # Customize + custom = profile.child_value('customize/custom') + if custom is not None: + customize = [] + for i in custom: + customize.append(i) + ret.replace_int_array('custom', 16, custom) + + # Beast Crysis and multiplayer data + play_log = profile.child('play_log') + + if play_log is not None: + for i in play_log.children: + if i.name == "crysis": + self.data.local.user.put_achievement(self.game, self.version, userid, i.child_value('id'), + "crysis", {"step": i.child_value('step'), "r_gauge": i.child_value('r_gauge'), + "r_state": i.child_value('r_state')}) + + elif i.name == "onmatch": + pass + + # Tips + ret.replace_int('last_tips', profile.child_value('tips/last_tips')) + + # Beast hacker + hacker = profile.child("hacker") + for x in hacker.children: + self.data.local.user.put_achievement(self.game, self.version, userid, x.child_value("id"), "hacker", { + "state0": x.child_value("state0"), "state1": x.child_value("state1"), "state2": x.child_value("state2"), + "state3": x.child_value("state3"), "state4": x.child_value("state4"), 'update_time': Time.now() * 1000 + }) + return ret + + # First call when somebody cards in, returns the status of a few crossover events + def handle_player2_start_request(self, request: Node, is_continue: bool = False) -> Node: + userid = self.data.local.user.from_refid(self.game, self.version, request.child_value('rid')) + player2 = Node.void('player2') + play_id = 0 + game_config = self.get_game_config() + + if userid is not None: + if not is_continue: + self.data.local.lobby.put_play_session_info( + self.game, + self.version, + userid, + # This is needed due to lobby2/entry's lack of user info + { "pcbid": self.config.machine.pcbid } + ) + + profile = self.data.local.user.get_profile(self.game, self.version, userid) + if profile is None: + profile = self.data.local.user.get_profile(self.game, self.version - 1, userid) + + if profile is not None: + play_id = profile.get_int('tpc') + 1 + + # Session stuff, and resend global defaults + player2.add_child(Node.s32('plyid', play_id)) + + start_time = Node.u64('start_time', Time.now() * 1000) + player2.add_child(start_time) + + # Crossover events + # HAPPY☆SUMMER CAMPAIGN possibly? + reflec_collabo = Node.bool('reflec_collabo', game_config.get_bool("enable_reflec_xover")) + player2.add_child(reflec_collabo) + + # BeaSt pop'n tanabata matsuri possibly? + pop_collabo = Node.bool('pop_collabo', game_config.get_bool("enable_popn_xover")) + player2.add_child(pop_collabo) + + # Floor Infection Part 20 + floor_infection = Node.void('floor_infection') + player2.add_child(floor_infection) + fi_event = Node.void('event') + floor_infection.add_child(fi_event) + fi_event.add_child(Node.s32('infection_id', 20)) + + if game_config.get_bool("force_unlock_sdvx_xover"): + # TODO: Test if 7 actually is the magic number, given there's only 3 songs + fi_event.add_child(Node.s32('music_list', 7)) + fi_event.add_child(Node.bool('is_complete', True)) + else: + # TODO: Figure out how to track floor infection lol + fi_event.add_child(Node.s32('music_list', 7)) + fi_event.add_child(Node.bool('is_complete', True)) + + # If you played Museca 1+1/2 at launch you got rewards in BST and other games + museca = Node.void('museca') + player2.add_child(museca) + + if game_config.get_bool("enable_popn_xover"): + museca.add_child(Node.bool('is_play_museca', True)) + else: + m112_profile = self.data.local.user.get_profile(GameConstants.MUSECA, VersionConstants.MUSECA_1_PLUS, userid) + museca.add_child(Node.bool('is_play_museca', m112_profile is not None)) + + return player2 + + # Called when carding in to get the player profile + def handle_player2_read_request(self, request: Node) -> Node: + refid = request.child_value('rid') + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + profile = self.get_profile(userid) + return self.format_player_profile(profile, userid) + + # Called either when carding out or creating a new profile + def handle_player2_write_request(self, request: Node) -> Node: + refid = request.child_value('pdata/account/rid') + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + extid = self.data.local.user.get_extid(self.game, self.version, userid) + pdata = request.child('pdata') + reply = Node.void('player2') + + profile = self.unformat_player_profile(pdata, refid, extid, userid) + + self.put_profile(userid, profile) # Save the profile + + node_uid = Node.s32('uid', extid) # Send the extid back as a node + reply.add_child(node_uid) + + return reply + + # Called whenever some kind of error happens. + def handle_pcb2_error_request(self, request: Node) -> Node: + return Node.void('pcb2') + + # BST2 has to be special and have it's own boot method + def handle_pcb2_boot_request(self, request: Node) -> Node: + shop_id = ID.parse_machine_id(request.child_value('lid')) + pcbid = self.data.local.machine.from_machine_id(shop_id) + + if pcbid is not None: + machine = self.data.local.machine.get_machine(pcbid) + machine_name = machine.name + close = machine.data.get_bool('close') + hour = machine.data.get_int('hour') + minute = machine.data.get_int('minute') + else: + return None + + pcb2 = Node.void('pcb2') + sinfo = Node.void('sinfo') + pcb2.add_child(sinfo) + + sinfo.add_child(Node.string('nm', machine_name)) + sinfo.add_child(Node.bool('cl_enbl', close)) + sinfo.add_child(Node.u8('cl_h', hour)) + sinfo.add_child(Node.u8('cl_m', minute)) + + return pcb2 + + # Send a list of events and phases + def handle_info2_common_request(self, request: Node) -> Node: + info2 = Node.void('info2') + info2.set_attribute('status', '0') + + event_ctrl = self.get_events() + info2.add_child(event_ctrl) + + return info2 + + # Called when a player registeres a new profile when they have an account + def handle_player2_succeed_request(self, request: Node) -> Node: + userid = self.data.local.user.from_refid(self.game, self.version, request.child_value('rid')) + profile = self.data.local.user.get_profile(self.game, self.version - 1, userid) + scores = self.data.local.music.get_scores(self.game, self.version - 1, userid) + achievements = self.data.local.user.get_achievements(self.game, self.version - 1, userid) + player2 = Node.void('player2') + + play = Node.bool('play', profile.get_int('tpc') > 1 if profile else False) + player2.add_child(play) + + data = Node.void('data') + player2.add_child(data) + + name = Node.string('name', profile.get_str('name') if profile else "") + data.add_child(name) + + record = Node.void('record') + hacker = Node.void('hacker') + phantom = Node.void('phantom') + + for score in scores: + rec = Node.void('rec') + rec.add_child(Node.s32('music_id', score.id)) + rec.add_child(Node.s32('note_level', score.chart)) + rec.add_child(Node.s32('play_count', score.plays)) + rec.add_child(Node.s32('clear_count', score.data.get_int('clear_count'))) + rec.add_child(Node.s32('best_gauge', score.data.get_int('gauge'))) + rec.add_child(Node.s32('best_score', score.points)) + rec.add_child(Node.s32('best_grade', score.data.get_int('grade'))) + rec.add_child(Node.s32('best_medal', score.data.get_int('medal'))) + record.add_child(rec) + + for ach in achievements: + if ach.type == "hacker": + info = Node.void('info') + info.add_child(Node.s32('id', ach.id)) + info.add_child(Node.s8('state0', ach.data.get_int("state0"))) + info.add_child(Node.s8('state1', ach.data.get_int("state1"))) + info.add_child(Node.s8('state2', ach.data.get_int("state2"))) + info.add_child(Node.s8('state3', ach.data.get_int("state3"))) + info.add_child(Node.s8('state4', ach.data.get_int("state4"))) + info.add_child(Node.u64('update_time', ach.data.get_int('update_time', Time.now() * 1000))) # update_time is required or the profile will fail + hacker.add_child(info) + + elif ach.type == "phantom": + minfo = Node.void('minfo') + minfo.add_child(Node.s32('mid', ach.data.get_int("mid"))) + minfo.add_child(Node.s64('cbit', ach.data.get_int("cbit"))) + minfo.add_child(Node.bool('clr', ach.data.get_bool("clr"))) + phantom.add_child(info) + + player2.add_child(hacker) + player2.add_child(record) + player2.add_child(phantom) + return player2 + + # Called during boot + def handle_shop2_setting_write_request(self, request: Node) -> Node: + shop2 = Node.void('shop2') + #TODO: shop settings saving + return shop2 + + # Called after settings_write, not sure what it does + def handle_info2_music_count_read_request(self, request: Node) -> Node: + info2 = Node.void('info2') + record = Node.void('record') + record.add_child(Node.void('rec')) + record.add_child(Node.void('rate')) + info2.add_child(record) + return info2 + + # Called after music_count_read. Might have something to do with song popularity? + def handle_info2_music_ranking_read_request(self, Request: Node) -> Node: + info2 = Node.void('info2') + ranking = Node.void('ranking') + ranking.add_child(Node.void('count')) + info2.add_child(ranking) + return info2 + + # Called on card out + def handle_player2_end_request(self, request: Node) -> Node: + self.data.local.lobby.destroy_play_session_info(self.game, self.version, + self.data.local.user.from_refid(self.game, self.version, request.child_value("rid"))) + return Node.void('player2') + + # Called after finishing a song + def handle_player2_stagedata_write_request(self, request: Node) -> Node: + userid = request.child_value('user_id') + musicid = request.child_value('select_music_id') + chartid = request.child_value('select_grade') + location = self.get_machine_id() + points = request.child_value('result_score') + gauge = request.child_value('result_clear_gauge') + max_combo = request.child_value('result_max_combo') + grade = request.child_value('result_grade') + medal = request.child_value('result_medal') + fantastic_count = request.child_value('result_fanta') + great_count = request.child_value('result_great') + fine_count = request.child_value('result_fine') + miss_count = request.child_value('result_miss') + + self.update_score(userid, musicid, chartid, location, points, gauge, + max_combo, grade, medal, fantastic_count, great_count, fine_count, miss_count) + + return Node.void('player2') + + # Called after finishing a song in a course + def handle_player2_course_stage_data_write_request(self, request: Node) -> Node: + userid = request.child_value('user_id') + musicid = request.child_value('select_music_id') + chartid = request.child_value('select_grade') + location = self.get_machine_id() + points = request.child_value('result_score') + gauge = request.child_value('result_clear_gauge') + max_combo = request.child_value('result_max_combo') + grade = request.child_value('result_grade') + medal = request.child_value('result_medal') + fantastic_count = request.child_value('result_fanta') + great_count = request.child_value('result_great') + fine_count = request.child_value('result_fine') + miss_count = request.child_value('result_miss') + + self.update_score(userid, musicid, chartid, location, points, gauge, + max_combo, grade, medal, fantastic_count, great_count, fine_count, miss_count) + + return Node.void('player2') + + # Called after finishing a course + def handle_player2_course_data_write_request(self, request: Node) -> Node: + return Node.void('player2') + + # Called frequently to see who's playing + def handle_lobby2_get_lobby_list_request(self, request: Node) -> Node: + lobby2 = Node.void('lobby2') + lobby2.add_child(Node.s32('interval_sec', 10)) + lobbies = self.data.local.lobby.get_all_lobbies(self.game, self.version) + + if lobbies is not None: + for (user, lobby) in lobbies: + e = Node.void('e') + lobby2.add_child(e) + e.add_child(Node.u16('mid', lobby.get_int('mid'))) + e.add_child(Node.u64('eatime', lobby.get_int('eatime'))) + + return lobby2 + + # Called to destroy a lobby after it's use + def handle_lobby2_delete_request(self, request: Node) -> Node: + self.data.local.lobby.destroy_lobby(request.child_value("eid")) + return Node.void('lobby2') + + # Called when matching starts + def handle_lobby2_entry_request(self, request: Node) -> Node: + lobby2 = Node.void('lobby2') + lobby2.add_child(Node.s32('interval', 120)) + lobby2.add_child(Node.s32('interval_p', 120)) + lobby = None + userid = 0 + + sessions = self.data.local.lobby.get_all_play_session_infos(self.game, self.version) + requested_lobby_id = request.child_value('e/eid') + + # Beatstream sends 0 identifiable user information, so this is how we have to pull the UserID + for usr, sesh in sessions: + if sesh.get_str('pcbid') == self.config.machine.pcbid: + userid = usr + break + + if requested_lobby_id > 0: + # Get the detales of the requested lobby + lobby = self.data.local.lobby.get_lobby(self.game, self.version, requested_lobby_id) + + else: + # Make a new lobby + self.data.local.lobby.put_lobby( + self.game, + self.version, + userid, + { + 'ver': request.child_value('e/ver'), + 'mid': request.child_value('e/mid'), + 'rest': request.child_value('e/rest'), + 'uid': request.child_value('e/uid'), + 'mmode': request.child_value('e/mmode'), + 'mg': request.child_value('e/mg'), + 'mopt': request.child_value('e/mopt'), + 'lid': request.child_value('e/lid'), + 'sn': request.child_value('e/sn'), + 'pref': request.child_value('e/pref'), + 'eatime': request.child_value('e/eatime'), + 'ga': request.child_value('e/ga'), + 'gp': request.child_value('e/gp'), + 'la': request.child_value('e/la'), + } + ) + + # Pull the lobby details back down to get the ID + lobby = self.data.local.lobby.get_lobby(self.game, self.version, userid) + + lobby2.add_child(Node.s32('eid', lobby.get_int('id'))) + e = Node.void('e') + lobby2.add_child(e) + e.add_child(Node.s32('eid', lobby.get_int('id'))) + e.add_child(Node.u8('ver', lobby.get_int('ver'))) + e.add_child(Node.u16('mid', lobby.get_int('mid'))) + e.add_child(Node.u8('rest', lobby.get_int('rest'))) + e.add_child(Node.s32('uid', lobby.get_int('mmode'))) + e.add_child(Node.s32('mmode', lobby.get_int('mmode'))) + e.add_child(Node.s16('mg', lobby.get_int('mg'))) + e.add_child(Node.s32('mopt', lobby.get_int('mopt'))) + e.add_child(Node.string('lid', lobby.get_str('lid'))) + e.add_child(Node.string('sn', lobby.get_str('sn'))) + e.add_child(Node.u8('pref', lobby.get_int('pref'))) + e.add_child(Node.s16('eatime', lobby.get_int('eatime'))) + e.add_child(Node.u8_array('ga', lobby.get_int_array('ga', 4))) + e.add_child(Node.u16('gp', lobby.get_int('gp'))) + e.add_child(Node.u8_array('la', lobby.get_int_array('la', 4))) + + return lobby2 + + # Called when a player tries to continue another credit + def handle_player2_continue_request(self, request: Node) -> Node: + return self.handle_player2_start_request(request, True) + + # Called when a user request an eamuse app screenshot + def handle_info2_result_image_write_request(self, request: Node) -> Node: + song: Song = self.data.local.music.get_song(self.game, self.version, request.child_value("music_id"), + request.child_value("music_level")) + + grades = ["Red AAA", "AAA", "AA", "A", "B", "C", "D"] + medals = ["No Play", "Failed", "Saved", "Cleared", "Full Combo", "Perfect"] + + card_data = { + BroadcastConstants.PLAYER_NAME: request.child_value("player_name"), + BroadcastConstants.SONG_NAME: request.child_value("music_title"), + BroadcastConstants.ARTIST_NAME: request.child_value("artist_name"), + BroadcastConstants.DIFFICULTY: request.child_value("music_level"), + BroadcastConstants.DIFFICULTY_LEVEL: song.data.get_int("difficulty"), + #BroadcastConstants.BEAST_RANK: request.child_value("beast_rank"), + + BroadcastConstants.SCORE: request.child_value("score"), + #BroadcastConstants.BEST_SCORE: request.child_value("best_score"), + BroadcastConstants.GAUGE: float(request.child_value("gauge") / 10), + BroadcastConstants.MEDAL: medals[request.child_value("medal")], + BroadcastConstants.GRADE: grades[request.child_value("grade")], + + BroadcastConstants.MAX_COMBO: request.child_value("max_combo"), + #BroadcastConstants.FANTASTIC: request.child_value("fanta"), + #BroadcastConstants.GREAT: request.child_value("great"), + #BroadcastConstants.FINE: request.child_value("fine"), + #BroadcastConstants.MISS: request.child_value("miss"), + } + + self.data.triggers.broadcast_score(card_data, self.game, song) + return Node.void("info2") + + # Called when matching + def handle_player2_matching_data_load_request(self, request: Node) -> Node: + root = Node.void('player_matching') + data = Node.void('data') + data.add_child(Node.s32('id', 0)) # player id? + data.add_child(Node.bool('fl', False)) # First Local + data.add_child(Node.bool('fo', False)) # First Online + root.add_child(root) + + # Called when saving machine settings + def handle_shop2_info_write_request(self, request: Node) -> Node: + mech = self.data.local.machine.get_machine(self.config.machine.pcbid) + if mech is not None: + mech.data["close"] = request.child_value('sinfo/cl_enbl') + mech.data["hour"] = request.child_value('sinfo/cl_h') + mech.data["minute"] = request.child_value('sinfo/cl_m') + mech.data["pref"] = request.child_value('sinfo/prf') + mech.name = request.child_value('sinfo/nm') + mech.game = self.game + mech.version = self.version + + self.data.local.machine.put_machine(mech) + + return Node.void("shop2") diff --git a/bemani/backend/bst/factory.py b/bemani/backend/bst/factory.py new file mode 100644 index 0000000..7500548 --- /dev/null +++ b/bemani/backend/bst/factory.py @@ -0,0 +1,32 @@ +from typing import Dict, Optional, Any + +from bemani.backend.base import Base, Factory +from bemani.backend.bst.beatstream2 import Beatstream2 +from bemani.backend.bst.beatstream import Beatstream +from bemani.common import Model +from bemani.data import Data + +class BSTFactory(Factory): + + MANAGED_CLASSES = [ + Beatstream2, + ] + + @classmethod + def register_all(cls) -> None: + for game in ['NBT']: + Base.register(game, BSTFactory) + + @classmethod + def create(cls, data: Data, config: Dict[str, Any], model: Model, parentmodel: Optional[Model]=None) -> Optional[Base]: + + if model.gamecode == 'NBT': + if model.version is None: + return None + if model.version <= 2015121600: # Beatstream 1 + return Beatstream(data, config, model) + if model.version <= 2016111400 and model.version > 2015121600: # Beatstream 2 + return Beatstream2(data, config, model) + + # Unknown game version + return None diff --git a/bemani/client/bst/__init__.py b/bemani/client/bst/__init__.py new file mode 100644 index 0000000..d02ff60 --- /dev/null +++ b/bemani/client/bst/__init__.py @@ -0,0 +1,5 @@ +from bemani.client.bst.beatstream2 import Beatstream2Client + +__all__ = [ + "Beatstream2Client", +] diff --git a/bemani/client/bst/beatstream2.py b/bemani/client/bst/beatstream2.py new file mode 100644 index 0000000..5bde1e7 --- /dev/null +++ b/bemani/client/bst/beatstream2.py @@ -0,0 +1,261 @@ +from typing import Optional + +from bemani.client.base import BaseClient +from bemani.common.constants import GameConstants, VersionConstants +from bemani.common.time import Time +from bemani.common.validateddict import Profile +from bemani.data import Song +from bemani.protocol import Node + +class Beatstream2Client(BaseClient): + name = 'TEST' + + def verify_player2_start(self, refid: str) -> int: + call = self.call_node() + + player2 = Node.void("player2") + player2.set_attribute('method', 'start') + player2.add_child(Node.string("rid", refid)) + player2.add_child(Node.u8_array("ga", [192, 168, 1, 2])) + player2.add_child(Node.u16("gp", 1234)) + player2.add_child(Node.u8_array("la", [192, 168, 1, 2])) + + call.add_child(player2) + + resp = self.exchange('', call) + + self.assert_path(resp, "response/player2/plyid") + + return resp.child_value("player2/plyid") + + def verify_player2_read(self, refid: str) -> Profile: + call = self.call_node() + + player2 = Node.void("player2") + player2.set_attribute('method', 'read') + player2.add_child(Node.string("rid", refid)) + player2.add_child(Node.string("lid", "JP-1")) + player2.add_child(Node.s16("ver", 0)) + + call.add_child(player2) + + resp = self.exchange('', call) + + self.assert_path(resp, "response/player2/pdata/account/usrid") + self.assert_path(resp, "response/player2/pdata/base/name") + + profile = resp.child("player2/pdata") + + ret = Profile("bst", 2, refid, profile.child_value('account/usrid')) + + ret.replace_int('usrid', int(profile.child_value('account/usrid'))) + ret.replace_int('is_takeover', int(profile.child_value('account/is_takeover'))) + ret.replace_int('tpc', profile.child_value('account/tpc')) + ret.replace_int('dpc', int(profile.child_value('account/dpc'))) + ret.replace_int('crd', int(profile.child_value('account/crd'))) + ret.replace_int('brd', int(profile.child_value('account/brd'))) + ret.replace_int('tdc', int(profile.child_value('account/tdc'))) + ret.replace_str('lid', profile.child_value('account/lid')) + ret.replace_int('ver', int(profile.child_value('account/ver'))) + ret.replace_int('st', int(profile.child_value('account/st'))) + + # Base + ret.replace_str('name', profile.child_value('base/name')) + ret.replace_int('brnk', int(profile.child_value('base/brnk'))) + ret.replace_int('bcnum', int(profile.child_value('base/bcnum'))) + ret.replace_int('lcnum', int(profile.child_value('base/lcnum'))) + ret.replace_int('volt', int(profile.child_value('base/volt'))) + ret.replace_int('gold', int(profile.child_value('base/gold'))) + ret.replace_int('lmid', int(profile.child_value('base/lmid'))) + ret.replace_int('lgrd', int(profile.child_value('base/lgrd'))) + ret.replace_int('lsrt', int(profile.child_value('base/lsrt'))) + ret.replace_int('ltab', int(profile.child_value('base/ltab'))) + ret.replace_int('splv', int(profile.child_value('base/splv'))) + ret.replace_int('pref', int(profile.child_value('base/pref'))) + ret.replace_int('lcid', int(profile.child_value('base/lcid'))) + ret.replace_int('hat', int(profile.child_value('base/hat'))) + + custom = profile.child_value('customize/custom') + if custom is not None: + customize = [] + for i in custom: + customize.append(i) + ret.replace_int_array('custom', 16, custom) + + + ret.replace_int('last_tips', profile.child_value('tips/last_tips')) + + return ret + + def verify_player2_stagedata_write(self, profile: Profile, song: Song, sid: int) -> None: + call = self.call_node() + player2 = Node.void("player2") + player2.set_attribute('method', 'stagedata_write') + + player2.add_child(Node.s32('play_id', sid)) + player2.add_child(Node.s32('continue_count', 0)) + player2.add_child(Node.s32('stage_no', 0)) + player2.add_child(Node.s32('user_id', profile.get_int('usrid'))) + player2.add_child(Node.string('location_id', "JP-1")) + player2.add_child(Node.s32('select_music_id', song.id)) + player2.add_child(Node.s32('select_grade', song.chart)) + player2.add_child(Node.s32('result_clear_gauge', 1000)) + player2.add_child(Node.s32('result_score', 888392)) + player2.add_child(Node.s32('result_max_combo', 114)) + player2.add_child(Node.s32('result_grade', 0)) + player2.add_child(Node.s32('result_medal', 3)) + player2.add_child(Node.s32('result_fanta', 68)) + player2.add_child(Node.s32('result_great', 35)) + player2.add_child(Node.s32('result_fine', 7)) + player2.add_child(Node.s32('result_miss', 2)) + + call.add_child(player2) + resp = self.exchange('', call) + self.assert_path(resp, "response/player2/@status") + + def verify_player2_write(self, profile: Profile, sid: int) -> None: + call = self.call_node() + + player2 = Node.void("player2") + player2.set_attribute('method', 'write') + + pdata = Node.void('pdata') + player2.add_child(pdata) + + account = Node.void('account') + account.add_child(Node.s32('usrid', profile.get_int("usrid"))) + account.add_child(Node.s32('is_takeover', profile.get_int("is_takeover"))) + account.add_child(Node.s32('plyid', sid)) + account.add_child(Node.s32('continue_cnt', 0)) + account.add_child(Node.s32('tpc', profile.get_int("tpc"))) + account.add_child(Node.s32('dpc', profile.get_int("dpc"))) + account.add_child(Node.s32('crd', profile.get_int("crd"))) + account.add_child(Node.s32('brd', profile.get_int("brd"))) + account.add_child(Node.s32('tdc', profile.get_int("tdc"))) + account.add_child(Node.string('rid', profile.refid)) + account.add_child(Node.string('lid', "JP-1")) + account.add_child(Node.s32('intrvld', profile.get_int("intrvld"))) + account.add_child(Node.s16('ver', profile.get_int("ver"))) + account.add_child(Node.u64('pst', profile.get_int("pst"))) + account.add_child(Node.u64('st', Time.now() * 1000)) + account.add_child(Node.bool('ea', profile.get_int("ea", True))) + pdata.add_child(account) + + base = Node.void('base') + base.add_child(Node.string('name', profile.get_str('name'))) + base.add_child(Node.s8('brnk', profile.get_int('brnk'))) + base.add_child(Node.s8('bcnum', profile.get_int('bcnum'))) + base.add_child(Node.s8('lcnum', profile.get_int('lcnum'))) + base.add_child(Node.s32('volt', profile.get_int('volt'))) + base.add_child(Node.s32('gold', profile.get_int('gold'))) + base.add_child(Node.s32('lmid', profile.get_int('lmid'))) + base.add_child(Node.s8('lgrd', profile.get_int('lgrd'))) + base.add_child(Node.s8('lsrt', profile.get_int('lsrt'))) + base.add_child(Node.s8('ltab', profile.get_int('ltab'))) + base.add_child(Node.s8('splv', profile.get_int('splv'))) + base.add_child(Node.s8('pref', profile.get_int('pref'))) + base.add_child(Node.s32('lcid', profile.get_int('lcid'))) + base.add_child(Node.s32('hat', profile.get_int('hat'))) + pdata.add_child(base) + + pdata.add_child(Node.void("item")) + + customize = Node.void('customize') + customize.add_child(Node.u16_array('custom', profile.get_int_array('custom', 16))) + pdata.add_child(customize) + + tips = Node.void('tips') + tips.add_child(Node.s32('last_tips', profile.get_int('last_tips'))) + pdata.add_child(tips) + + pdata.add_child(Node.void("hacker")) + + play_log = Node.void("play_log") + + crisis = Node.void("crysis") + crisis.add_child(Node.s32("id", 0)) + crisis.add_child(Node.s32("stage_no", 0)) + crisis.add_child(Node.s8("step", 0)) + crisis.add_child(Node.s32("r_gauge", 95)) + crisis.add_child(Node.s8("r_state", 0)) + play_log.add_child(crisis) + + crisis = Node.void("crysis") + crisis.add_child(Node.s32("id", 0)) + crisis.add_child(Node.s32("stage_no", 1)) + crisis.add_child(Node.s8("step", 1)) + crisis.add_child(Node.s32("r_gauge", 192)) + crisis.add_child(Node.s8("r_state", 1)) + play_log.add_child(crisis) + + crisis = Node.void("crysis") + crisis.add_child(Node.s32("id", 0)) + crisis.add_child(Node.s32("stage_no", 2)) + crisis.add_child(Node.s8("step", 1)) + crisis.add_child(Node.s32("r_gauge", 214)) + crisis.add_child(Node.s8("r_state", 0)) + play_log.add_child(crisis) + + pdata.add_child(play_log) + + call.add_child(player2) + + resp = self.exchange('', call) + self.assert_path(resp, "response/player2/uid") + + def verify_info2_result_image_write(self, profile: Profile, song: Song, sid: int) -> None: + call = self.call_node() + info2 = Node.void("info2") + info2.set_attribute('method', 'result_image_write') + + info2.add_child(Node.s32("play_id", sid)) + info2.add_child(Node.s32("continue_no", 0)) + info2.add_child(Node.s32("stage_no", 0)) + info2.add_child(Node.string("ref_id", profile.refid)) + info2.add_child(Node.s32("beast_rank", 0)) + info2.add_child(Node.string("player_name", profile.get_str("name"))) + info2.add_child(Node.s32("music_id", song.id)) + info2.add_child(Node.s32("music_grade", 0)) + info2.add_child(Node.s32("music_level", song.chart)) + info2.add_child(Node.string("music_title", song.name)) + info2.add_child(Node.string("artist_name", song.artist)) + info2.add_child(Node.s32("gauge", 995)) + info2.add_child(Node.s32("grade", 0)) + info2.add_child(Node.s32("score", 820567)) + info2.add_child(Node.s32("best_score", 0)) + info2.add_child(Node.s32("medal", 3)) + info2.add_child(Node.bool("is_new_record", False)) + info2.add_child(Node.s32("fanta", 81)) + info2.add_child(Node.s32("great", 29)) + info2.add_child(Node.s32("fine", 23)) + info2.add_child(Node.s32("miss", 8)) + + call.add_child(info2) + resp = self.exchange('', call) + self.assert_path(resp, "response/info2/@status") + + def verify_player2_end(self, refid: str) -> None: + call = self.call_node() + player2 = Node.void("player2") + player2.set_attribute('method', 'end') + player2.add_child(Node.string("rid", refid)) + call.add_child(player2) + resp = self.exchange('', call) + self.assert_path(resp, "response/player2/@status") + + def verify(self, cardid: Optional[str]) -> None: + # Make sure we can card in properly + refid = self.verify_cardmng_inquire(cardid, "query", True) + sid = self.verify_player2_start(refid) + profile = self.verify_player2_read(refid) + + # Make sure courses, songs, and scorecards save properly + test_song = Song(GameConstants.BST, VersionConstants.BEATSTREAM_2, + 129, 0, "チョコレートスマイル", "ちよこれえとすまいる", "", {}) + + self.verify_info2_result_image_write(profile, test_song, sid) + self.verify_player2_stagedata_write(profile, test_song, sid) + + # Make sure profile saves properly, and game ends gracefully + self.verify_player2_write(profile, sid) + self.verify_player2_end(refid) diff --git a/bemani/common/constants.py b/bemani/common/constants.py index 8ede862..2c200f0 100644 --- a/bemani/common/constants.py +++ b/bemani/common/constants.py @@ -21,6 +21,7 @@ class GameConstants(Enum): POPN_MUSIC = "pnm" REFLEC_BEAT = "reflec" SDVX = "sdvx" + BST = "bst" class VersionConstants: @@ -142,6 +143,9 @@ class VersionConstants: SDVX_GRAVITY_WARS: Final[int] = 3 SDVX_HEAVENLY_HAVEN: Final[int] = 4 + BEATSTREAM: Final[int] = 1 + BEATSTREAM_2: Final[int] = 2 + class APIConstants(Enum): """ @@ -341,6 +345,17 @@ class BroadcastConstants(Enum): COMBO = "Combo" MEDAL = "Medal" + # Added for BST + DIFFICULTY_LEVEL = "Difficulty" + BEAST_RANK = "Beast Rank" + BEST_SCORE = "Best Score" + GAUGE = "Gauge" + MAX_COMBO = "Max Combo" + FANTASTIC = "Fantastic" + GREAT = "Great" + FINE = "Fine" + MISS = "Miss" + class _RegionConstants: """ diff --git a/bemani/data/triggers.py b/bemani/data/triggers.py index b694cdb..165dd8a 100644 --- a/bemani/data/triggers.py +++ b/bemani/data/triggers.py @@ -27,6 +27,7 @@ class Triggers: GameConstants.POPN_MUSIC: "Pop'n Music", GameConstants.REFLEC_BEAT: "Reflec Beat", GameConstants.SDVX: "Sound Voltex", + GameConstants.BST: "BeatStream", }.get(game, "Unknown") def has_broadcast_destination(self, game: GameConstants) -> bool: @@ -43,7 +44,7 @@ class Triggers: self.broadcast_score_discord(data, game, song) def broadcast_score_discord(self, data: Dict[BroadcastConstants, str], game: GameConstants, song: Song) -> None: - if game in {GameConstants.IIDX, GameConstants.POPN_MUSIC}: + if game in {GameConstants.IIDX, GameConstants.POPN_MUSIC, GameConstants.BST}: now = datetime.now() webhook = DiscordWebhook(url=self.config.webhooks.discord[game]) diff --git a/bemani/frontend/app.py b/bemani/frontend/app.py index 10eff99..7014a38 100644 --- a/bemani/frontend/app.py +++ b/bemani/frontend/app.py @@ -856,6 +856,23 @@ def navigation() -> Dict[str, Any]: "gamecode": GameConstants.SDVX.value, }, ) + + if GameConstants.BST in g.config.support: + bst_entries = [] + bst_entries.extend([ + { + 'label': 'Global Scores', + 'uri': url_for('bst_pages.viewnetworkscores'), + } + ]) + pages.append( + { + 'label': 'BeatStream', + 'entries': bst_entries, + 'base_uri': app.blueprints['bst_pages'].url_prefix, + 'gamecode': GameConstants.BST.value, + }, + ) # Admin pages if user.admin: diff --git a/bemani/frontend/bst/__init__.py b/bemani/frontend/bst/__init__.py new file mode 100644 index 0000000..290be0a --- /dev/null +++ b/bemani/frontend/bst/__init__.py @@ -0,0 +1,8 @@ +from bemani.frontend.bst.endpoints import bst_pages +from bemani.frontend.bst.cache import BSTCache + + +__all__ = [ + "BSTCache", + "bst_pages", +] diff --git a/bemani/frontend/bst/bst.py b/bemani/frontend/bst/bst.py new file mode 100644 index 0000000..60d4de6 --- /dev/null +++ b/bemani/frontend/bst/bst.py @@ -0,0 +1,18 @@ +import copy +from typing import Any, Dict, Iterator, List, Tuple + +from bemani.backend.bst import BSTFactory, BSTBase +from bemani.common import Profile, ValidatedDict, GameConstants, VersionConstants +from bemani.data import Attempt, Link, RemoteUser, Score, Song, UserID +from bemani.frontend.base import FrontendBase + +class BSTFrontend(FrontendBase): + game: GameConstants = GameConstants.BST + + def all_games(self) -> Iterator[Tuple[GameConstants, int, str]]: + yield from BSTFactory.all_games() + + def update_name(self, profile: Profile, name: str) -> Profile: + newprofile = copy.deepcopy(profile) + newprofile.replace_str('name', name) + return newprofile \ No newline at end of file diff --git a/bemani/frontend/bst/cache.py b/bemani/frontend/bst/cache.py new file mode 100644 index 0000000..2c03eed --- /dev/null +++ b/bemani/frontend/bst/cache.py @@ -0,0 +1,16 @@ +from flask_caching import Cache # type: ignore + +from bemani.data import Config, Data +from bemani.frontend.app import app +from bemani.frontend.bst.bst import BSTFrontend + +class BSTCache: + + @classmethod + def preload(cls, data: Data, config: Config) -> None: + cache = Cache(app, config={ + 'CACHE_TYPE': 'filesystem', + 'CACHE_DIR': config.cache_dir, + }) + frontend = BSTFrontend(data, config, cache) + frontend.get_all_songs(force_db_load=True) \ No newline at end of file diff --git a/bemani/frontend/bst/endpoints.py b/bemani/frontend/bst/endpoints.py new file mode 100644 index 0000000..7ea1796 --- /dev/null +++ b/bemani/frontend/bst/endpoints.py @@ -0,0 +1,52 @@ +import re +from typing import Any, Dict, List, Optional +from flask import Blueprint, request, Response, url_for, abort + +from bemani.common import ID, GameConstants +from bemani.data import Link, UserID +from bemani.frontend.app import loginrequired, jsonify, render_react +from bemani.frontend.bst.bst import BSTFrontend +from bemani.frontend.templates import templates_location +from bemani.frontend.static import static_location +from bemani.frontend.types import g + +bst_pages = Blueprint( + 'bst_pages', + __name__, + url_prefix=f'/{GameConstants.BST.value}', + template_folder=templates_location, + static_folder=static_location, +) + +@bst_pages.route("/scores") +@loginrequired +def viewnetworkscores() -> Response: + frontend = BSTFrontend(g.data, g.config, g.cache) + network_scores = frontend.get_network_scores(limit=100) + if len(network_scores['attempts']) > 10: + network_scores['attempts'] = frontend.round_to_ten(network_scores['attempts']) + + return render_react( + 'Global DDR Scores', + 'bst/scores.react.js', + { + 'attempts': network_scores['attempts'], + 'songs': frontend.get_all_songs(), + 'players': network_scores['players'], + 'versions': {version: name for (game, version, name) in frontend.all_games()}, + 'shownames': True, + 'shownewrecords': False, + }, + { + 'refresh': url_for('bst_pages.listnetworkscores'), + 'player': url_for('bst_pages.viewplayer', userid=-1), + 'individual_score': url_for('bst_pages.viewtopscores', musicid=-1), + }, + ) + +@bst_pages.route('/scores/list') +@jsonify +@loginrequired +def listnetworkscores() -> Dict[str, Any]: + frontend = BSTFrontend(g.data, g.config, g.cache) + return frontend.get_network_scores() \ No newline at end of file diff --git a/bemani/frontend/static/controllers/bst/scores.react.js b/bemani/frontend/static/controllers/bst/scores.react.js new file mode 100644 index 0000000..f560ed1 --- /dev/null +++ b/bemani/frontend/static/controllers/bst/scores.react.js @@ -0,0 +1 @@ +/*** @jsx React.DOM */ \ No newline at end of file diff --git a/bemani/utils/config.py b/bemani/utils/config.py index 17820f5..2ae25e8 100644 --- a/bemani/utils/config.py +++ b/bemani/utils/config.py @@ -12,6 +12,7 @@ from bemani.backend.sdvx import SoundVoltexFactory from bemani.backend.reflec import ReflecBeatFactory from bemani.backend.museca import MusecaFactory from bemani.backend.mga import MetalGearArcadeFactory +from bemani.backend.bst import BSTFactory from bemani.common import GameConstants, cache from bemani.data import Config, Data @@ -81,3 +82,5 @@ def register_games(config: Config) -> None: MetalGearArcadeFactory.register_all() if GameConstants.DANCE_EVOLUTION in config.support: DanceEvolutionFactory.register_all() + if GameConstants.BST in config.support: + BSTFactory.register_all() diff --git a/bemani/utils/frontend.py b/bemani/utils/frontend.py index 710abf9..0cdfb24 100644 --- a/bemani/utils/frontend.py +++ b/bemani/utils/frontend.py @@ -17,6 +17,7 @@ from bemani.frontend.sdvx import sdvx_pages from bemani.frontend.reflec import reflec_pages from bemani.frontend.museca import museca_pages from bemani.frontend.danevo import danevo_pages +from bemani.frontend.bst import bst_pages from bemani.utils.config import ( load_config as base_load_config, instantiate_cache as base_instantiate_cache, @@ -50,6 +51,8 @@ def register_blueprints() -> None: app.register_blueprint(museca_pages) if GameConstants.DANCE_EVOLUTION in config.support: app.register_blueprint(danevo_pages) + if GameConstants.BST in config.support: + app.register_blueprint(bst_pages) def register_games() -> None: diff --git a/bemani/utils/read.py b/bemani/utils/read.py index cd56df2..a0eb574 100644 --- a/bemani/utils/read.py +++ b/bemani/utils/read.py @@ -6207,6 +6207,168 @@ class ImportDanceEvolution(ImportBase): self.finish_batch() +class ImportBst(ImportBase): + def __init__ ( + self, + config: Config, + version: str, + no_combine: bool, + update: bool + ) -> None: + if version in ['1', '2']: + actual_version = { + '1': VersionConstants.BEATSTREAM, + '2': VersionConstants.BEATSTREAM_2, + }.get(version, -1) + elif version == 'all': + actual_version = None + + if actual_version in [ + None, + VersionConstants.BEATSTREAM, + VersionConstants.BEATSTREAM_2, + ]: + self.charts = [0, 1, 2, 3] + else: + raise Exception("Unsupported Beatstream version! Please use one of the following: 1, 2.") + super().__init__(config, GameConstants.BST, actual_version, no_combine, update) + + def scrape(self, infile:str) -> List[Dict[str, Any]]: + if self.version is None: + raise Exception('Can\'t scrape database for \'all\' version!') + songs = [] + with open(infile, encoding='utf_16_le') as musicdb: + songs = [] + reader = csv.reader(musicdb, delimiter='|', quotechar='"') + for row in reader: + if self.version == VersionConstants.BEATSTREAM: + if 'MusicInfoData' in row[0]: + continue + if 'EOF' in row[0]: + continue + songid = int(row[0]) + name = row[1] + genre = row[3] + bpm_min = float(row[7]) + bpm_max = float(row[8]) + light = (row[9]) + medium = (row[10]) + beast = (row[11]) + nightmare = (row[12]) + artist = row[13] + if self.version == VersionConstants.BEATSTREAM_2: + if 'MusicInfoData' in row[0]: + continue + if 'EOF' in row[0]: + continue + songid = int(row[0]) + name = row[1] + genre = row[4] + bpm_min = float(row[8]) + bpm_max = float(row[9]) + light = row[10] + medium = row[11] + beast = row[12] + nightmare = row[13] + artist = row[14] + song = { + 'id': songid, + 'title': name, + 'artist': artist, + 'genre': genre, + 'bpm_min': bpm_min, + 'bpm_max': bpm_max, + 'difficulty': { + 'light': light, + 'medium': medium, + 'beast': beast, + 'nightmare': nightmare, + }, + } + songs.append(song) + return songs + + def lookup(self, server: str, token: str) -> List[Dict[str, Any]]: + if self.version is None: + raise Exception('Can\'t look up database for \'all\' version!') + + # Grab music info from remote server + music = self.remote_music(server, token) + songs = music.get_all_songs(self.game, self.version) + lut: Dict[int, Dict[str, Any]] = {} + chart_map = { + 0: 'light', + 1: 'medium', + 2: 'beast', + 3: 'nightmare', + } + + # Format it the way we expect + for song in songs: + if song.chart not in chart_map: + # Ignore charts on songs we don't support/care about. + continue + + if song.id not in lut: + lut[song.id] = { + 'id': song.id, + 'title': song.name, + 'artist': song.artist, + 'genre': song.genre, + 'bpm_min': song.data.get_int('bpm_min'), + 'bpm_max': song.data.get_int('bpm_max'), + 'difficulty': { + 'light': 0, + 'medium': 1, + 'beast': 2, + 'nightmare': 3, + }, + } + lut[song.id]['difficulty'][chart_map[song.chart]] = song.data.get_int('difficulty') + + # Reassemble the data + reassembled_songs = [val for _, val in lut.items()] + + + return reassembled_songs + + def import_music_db(self, songs: List[Dict[str, Any]]) -> None: + if self.version is None: + raise Exception('Can\'t import database for \'all\' version!') + + chart_map: Dict[int, str] = { + 0: 'light', + 1: 'medium', + 2: 'beast', + 3: 'nightmare', + } + for song in songs: + songid = song['id'] + + self.start_batch() + for chart in self.charts: + # First, try to find in the DB from another version + old_id = self.get_music_id_for_song(songid, chart) + + # First, try to find in the DB from another version + if self.no_combine or old_id is None: + # Insert original + print(f"New entry for {songid} chart {chart}") + next_id = self.get_next_music_id() + else: + # Insert pointing at same ID so scores transfer + print(f"Reused entry for {songid} chart {chart}") + next_id = old_id + data = { + 'difficulty': song['difficulty'][chart_map[chart]], + 'bpm_min': song['bpm_min'], + 'bpm_max': song['bpm_max'], + } + + self.insert_music_id_for_song(next_id, songid, chart, song['title'], song['artist'], song['genre'], data) + self.finish_batch() + + def main() -> None: parser = argparse.ArgumentParser(description="Import Game Music DB") parser.add_argument( @@ -6465,6 +6627,15 @@ def main() -> None: danevo.import_music_db(songs) danevo.close() + if series == GameConstants.BST: + bst = ImportBst(config, args.version, args.no_combine, args.update) + if args.csv: + songs = bst.scrape(args.csv) + else: + raise Exception('No musicdb.csv provided! Please provide --csv') + bst.import_music_db(songs) + bst.close() + else: raise CLIException("Unsupported game series!") diff --git a/bemani/utils/trafficgen.py b/bemani/utils/trafficgen.py index 0d6adfc..ba61239 100644 --- a/bemani/utils/trafficgen.py +++ b/bemani/utils/trafficgen.py @@ -61,6 +61,7 @@ from bemani.client.reflec import ( from bemani.client.bishi import TheStarBishiBashiClient from bemani.client.mga import MetalGearArcadeClient from bemani.client.danevo import DanceEvolutionClient +from bemani.client.bst.beatstream2 import Beatstream2Client def get_client(proto: ClientProtocol, pcbid: str, game: str, config: Dict[str, Any]) -> BaseClient: @@ -322,6 +323,12 @@ def get_client(proto: ClientProtocol, pcbid: str, game: str, config: Dict[str, A pcbid, config, ) + if game == 'beatstream2': + return Beatstream2Client( + proto, + pcbid, + config + ) raise Exception(f"Unknown game {game}") @@ -559,6 +566,10 @@ def mainloop( "name": "Dance Evolution Arcade", "model": "KDM:J:B:A:2016080100", "avs": "2.15.5 r6251", + },'beatstream2': { + 'name': 'BeatStream 2', + 'model': 'NBT:J:A:A:2016111400', + 'avs': '2.16.7 r7487' }, } if action == "list": @@ -683,6 +694,7 @@ def main() -> None: "reflec-6": "reflec-volzza2", "mga": "metal-gear-arcade", "danevo": "dance-evolution", + 'bst2': 'beatstream2', }.get(game, game) mainloop(args.address, args.port, args.config, action, game, args.cardid, args.verbose) diff --git a/config/server.yaml b/config/server.yaml index 897904f..e42a197 100644 --- a/config/server.yaml +++ b/config/server.yaml @@ -49,10 +49,9 @@ server: # Delete this to disable this support. webhooks: discord: - iidx: - - "https://discord.com/api/webhooks/1232122131321321321/eauihfafaewfhjaveuijaewuivhjawueihoi" - pnm: - - "https://discord.com/api/webhooks/1232122131321321321/eauihfafaewfhjaveuijaewuivhjawueihoi" + iidx: "https://discord.com/api/webhooks/1232122131321321321/eauihfafaewfhjaveuijaewuivhjawueihoi" + pnm: "https://discord.com/api/webhooks/1232122131321321321/eauihfafaewfhjaveuijaewuivhjawueihoi" + bst: "https://discord.com/api/webhooks/1232122131321321321/eauihfafaewfhjaveuijaewuivhjawueihoi" # Assets URLs. These allow for in-game asset rendering on the front end. Delete this to disable asset rendering. assets: @@ -89,6 +88,8 @@ support: reflec: True # SDVX frontend/backend enabled. sdvx: True + # Beatstream frontend/backend enable. + bst: True # Key used to encrypt cookies, should be unique per network instance. secret_key: 'this_is_a_secret_please_change_me'