From b85a65204fdf28ace1d91b45a53c460acf8ac569 Mon Sep 17 00:00:00 2001 From: Dniel97 Date: Wed, 10 May 2023 21:32:35 +0200 Subject: [PATCH 01/49] chuni: added SUN support, matchmaking, fixed bugs, added docs - Added CHUNITHM SUN support - Added first matchmaking support with CPU spawning and messages - Fixed wrong `next_idx` calculations - Added `startDate` to events to spawn the correct items - Fixed login bonus per version - Added information to docs --- core/data/schema/versions/SDBT_3_rollback.sql | 30 +++ core/data/schema/versions/SDBT_4_upgrade.sql | 29 +++ docs/game_specific_info.md | 75 +++++--- example_config/chuni.yaml | 3 + readme.md | 21 +- titles/chuni/__init__.py | 2 +- titles/chuni/base.py | 104 ++++++---- titles/chuni/const.py | 28 +-- titles/chuni/index.py | 28 +-- titles/chuni/new.py | 180 +++++++++++++++++- titles/chuni/newplus.py | 2 +- titles/chuni/schema/item.py | 142 +++++++++++++- titles/chuni/schema/profile.py | 27 +-- titles/chuni/schema/score.py | 4 +- titles/chuni/schema/static.py | 47 +++-- titles/chuni/sun.py | 37 ++++ titles/mai2/const.py | 6 +- 17 files changed, 626 insertions(+), 139 deletions(-) create mode 100644 core/data/schema/versions/SDBT_3_rollback.sql create mode 100644 core/data/schema/versions/SDBT_4_upgrade.sql create mode 100644 titles/chuni/sun.py diff --git a/core/data/schema/versions/SDBT_3_rollback.sql b/core/data/schema/versions/SDBT_3_rollback.sql new file mode 100644 index 0000000..ff78c54 --- /dev/null +++ b/core/data/schema/versions/SDBT_3_rollback.sql @@ -0,0 +1,30 @@ +SET FOREIGN_KEY_CHECKS = 0; + +ALTER TABLE chuni_score_playlog + DROP COLUMN regionId, + DROP COLUMN machineType; + +ALTER TABLE chuni_static_events + DROP COLUMN startDate; + +ALTER TABLE chuni_profile_data + DROP COLUMN rankUpChallengeResults; + +ALTER TABLE chuni_static_login_bonus + DROP FOREIGN KEY chuni_static_login_bonus_ibfk_1; + +ALTER TABLE chuni_static_login_bonus_preset + DROP PRIMARY KEY; + +ALTER TABLE chuni_static_login_bonus_preset + CHANGE COLUMN presetId id INT NOT NULL; +ALTER TABLE chuni_static_login_bonus_preset + ADD PRIMARY KEY(id); +ALTER TABLE chuni_static_login_bonus_preset + ADD CONSTRAINT chuni_static_login_bonus_preset_uk UNIQUE(id, version); + +ALTER TABLE chuni_static_login_bonus + ADD CONSTRAINT chuni_static_login_bonus_ibfk_1 FOREIGN KEY(presetId) + REFERENCES chuni_static_login_bonus_preset(id) ON UPDATE CASCADE ON DELETE CASCADE; + +SET FOREIGN_KEY_CHECKS = 1; \ No newline at end of file diff --git a/core/data/schema/versions/SDBT_4_upgrade.sql b/core/data/schema/versions/SDBT_4_upgrade.sql new file mode 100644 index 0000000..984447e --- /dev/null +++ b/core/data/schema/versions/SDBT_4_upgrade.sql @@ -0,0 +1,29 @@ +SET FOREIGN_KEY_CHECKS = 0; + +ALTER TABLE chuni_score_playlog + ADD COLUMN regionId INT, + ADD COLUMN machineType INT; + +ALTER TABLE chuni_static_events + ADD COLUMN startDate TIMESTAMP NOT NULL DEFAULT current_timestamp(); + +ALTER TABLE chuni_profile_data + ADD COLUMN rankUpChallengeResults JSON; + +ALTER TABLE chuni_static_login_bonus + DROP FOREIGN KEY chuni_static_login_bonus_ibfk_1; + +ALTER TABLE chuni_static_login_bonus_preset + CHANGE COLUMN id presetId INT NOT NULL; +ALTER TABLE chuni_static_login_bonus_preset + DROP PRIMARY KEY; +ALTER TABLE chuni_static_login_bonus_preset + DROP INDEX chuni_static_login_bonus_preset_uk; +ALTER TABLE chuni_static_login_bonus_preset + ADD CONSTRAINT chuni_static_login_bonus_preset_pk PRIMARY KEY (presetId, version); + +ALTER TABLE chuni_static_login_bonus + ADD CONSTRAINT chuni_static_login_bonus_ibfk_1 FOREIGN KEY (presetId, version) + REFERENCES chuni_static_login_bonus_preset(presetId, version) ON UPDATE CASCADE ON DELETE CASCADE; + +SET FOREIGN_KEY_CHECKS = 1; \ No newline at end of file diff --git a/docs/game_specific_info.md b/docs/game_specific_info.md index f1b334e..694885b 100644 --- a/docs/game_specific_info.md +++ b/docs/game_specific_info.md @@ -9,7 +9,7 @@ using the megaime database. Clean installations always create the latest databas # Table of content - [Supported Games](#supported-games) - - [Chunithm](#chunithm) + - [CHUNITHM](#chunithm) - [crossbeats REV.](#crossbeats-rev) - [maimai DX](#maimai-dx) - [O.N.G.E.K.I.](#o-n-g-e-k-i) @@ -21,30 +21,31 @@ using the megaime database. Clean installations always create the latest databas Games listed below have been tested and confirmed working. -## Chunithm +## CHUNITHM ### SDBT -| Version ID | Version Name | -|------------|--------------------| -| 0 | Chunithm | -| 1 | Chunithm+ | -| 2 | Chunithm Air | -| 3 | Chunithm Air + | -| 4 | Chunithm Star | -| 5 | Chunithm Star + | -| 6 | Chunithm Amazon | -| 7 | Chunithm Amazon + | -| 8 | Chunithm Crystal | -| 9 | Chunithm Crystal + | -| 10 | Chunithm Paradise | +| Version ID | Version Name | +|------------|-----------------------| +| 0 | CHUNITHM | +| 1 | CHUNITHM PLUS | +| 2 | CHUNITHM AIR | +| 3 | CHUNITHM AIR PLUS | +| 4 | CHUNITHM STAR | +| 5 | CHUNITHM STAR PLUS | +| 6 | CHUNITHM AMAZON | +| 7 | CHUNITHM AMAZON PLUS | +| 8 | CHUNITHM CRYSTAL | +| 9 | CHUNITHM CRYSTAL PLUS | +| 10 | CHUNITHM PARADISE | ### SDHD/SDBT -| Version ID | Version Name | -|------------|-----------------| -| 11 | Chunithm New!! | -| 12 | Chunithm New!!+ | +| Version ID | Version Name | +|------------|---------------------| +| 11 | CHUNITHM NEW!! | +| 12 | CHUNITHM NEW PLUS!! | +| 13 | CHUNITHM SUN | ### Importer @@ -60,13 +61,33 @@ The importer for Chunithm will import: Events, Music, Charge Items and Avatar Ac ### Database upgrade Always make sure your database (tables) are up-to-date, to do so go to the `core/data/schema/versions` folder and see -which version is the latest, f.e. `SDBT_3_upgrade.sql`. In order to upgrade to version 3 in this case you need to +which version is the latest, f.e. `SDBT_4_upgrade.sql`. In order to upgrade to version 4 in this case you need to perform all previous updates as well: ```shell python dbutils.py --game SDBT upgrade ``` +### Online Battle + +**Only matchmaking (with your imaginary friends) is supported! Online Battle does not (yet?) work!** + +The first person to start the Online Battle (now called host) will create a "matching room" with a given `roomId`, after that max 3 other people can join the created room. +Non used slots during the matchmaking will be filled with CPUs after the timer runs out. +As soon as a new member will join the room the timer will jump back to 60 secs again. +Sending those 4 messages to all other users is also working properly. +In order to use the Online Battle every user needs the same ICF, same rom version and same data version! +If a room is full a new room will be created if another user starts an Online Battle. +After a failed Online Battle the room will be deleted. The host is used for the timer countdown, so if the connection failes to the host the timer will stop and could create a "frozen" state. + +#### Information/Problems: + +- Online Battle uses UDP hole punching and opens port 50201? +- `reflectorUri` seems related to that? +- Timer countdown should be handled globally and not by one user +- Game can freeze or can crash if someone (especially the host) leaves the matchmaking + + ## crossbeats REV. ### SDCA @@ -111,9 +132,9 @@ Config file is located in `config/cxb.yaml`. | 1 | maimai DX PLUS | | 2 | maimai DX Splash | | 3 | maimai DX Splash PLUS | -| 4 | maimai DX Universe | -| 5 | maimai DX Universe PLUS | -| 6 | maimai DX Festival | +| 4 | maimai DX UNiVERSE | +| 5 | maimai DX UNiVERSE PLUS | +| 6 | maimai DX FESTiVAL | ### Importer @@ -238,13 +259,13 @@ python dbutils.py --game SDDT upgrade ### Support status * Card Maker 1.34: - * Chunithm New!!: Yes - * maimai DX Universe: Yes + * CHUNITHM NEW!!: Yes + * maimai DX UNiVERSE: Yes * O.N.G.E.K.I. Bright: Yes * Card Maker 1.35: - * Chunithm New!!+: Yes - * maimai DX Universe PLUS: Yes + * CHUNITHM SUN: Yes (NEW PLUS!! up to A032) + * maimai DX FESTiVAL: Yes (up to A35) (UNiVERSE PLUS up to A031) * O.N.G.E.K.I. Bright Memory: Yes diff --git a/example_config/chuni.yaml b/example_config/chuni.yaml index bbac976..59db51e 100644 --- a/example_config/chuni.yaml +++ b/example_config/chuni.yaml @@ -15,6 +15,9 @@ version: 12: rom: 2.05.00 data: 2.05.00 + 13: + rom: 2.10.00 + data: 2.10.00 crypto: encrypted_only: False \ No newline at end of file diff --git a/readme.md b/readme.md index ec25191..ee407cd 100644 --- a/readme.md +++ b/readme.md @@ -3,30 +3,31 @@ A network service emulator for games running SEGA'S ALL.NET service, and similar # Supported games Games listed below have been tested and confirmed working. Only game versions older then the version currently active in arcades, or games versions that have not recieved a major update in over one year, are supported. -+ Chunithm - + All versions up to New!! Plus -+ Crossbeats Rev ++ CHUNITHM + + All versions up to SUN + ++ crossbeats REV. + All versions + omnimix + maimai DX - + All versions up to Festival + + All versions up to FESTiVAL -+ Hatsune Miku Arcade ++ Hatsune Miku: Project DIVA Arcade + All versions + Card Maker - + 1.34.xx - + 1.35.xx + + 1.34 + + 1.35 -+ Ongeki ++ O.N.G.E.K.I. + All versions up to Bright Memory -+ Wacca ++ WACCA + Lily R + Reverse -+ Pokken ++ POKKÉN TOURNAMENT + Final Online ## Requirements diff --git a/titles/chuni/__init__.py b/titles/chuni/__init__.py index 89cd4f5..0c3cc79 100644 --- a/titles/chuni/__init__.py +++ b/titles/chuni/__init__.py @@ -7,4 +7,4 @@ index = ChuniServlet database = ChuniData reader = ChuniReader game_codes = [ChuniConstants.GAME_CODE, ChuniConstants.GAME_CODE_NEW] -current_schema_version = 3 +current_schema_version = 4 diff --git a/titles/chuni/base.py b/titles/chuni/base.py index 0eaabff..689c2fe 100644 --- a/titles/chuni/base.py +++ b/titles/chuni/base.py @@ -44,13 +44,15 @@ class ChuniBase: # check if a user already has some pogress and if not add the # login bonus entry user_login_bonus = self.data.item.get_login_bonus( - user_id, self.version, preset["id"] + user_id, self.version, preset["presetId"] ) if user_login_bonus is None: - self.data.item.put_login_bonus(user_id, self.version, preset["id"]) + self.data.item.put_login_bonus( + user_id, self.version, preset["presetId"] + ) # yeah i'm lazy user_login_bonus = self.data.item.get_login_bonus( - user_id, self.version, preset["id"] + user_id, self.version, preset["presetId"] ) # skip the login bonus entirely if its already finished @@ -66,13 +68,13 @@ class ChuniBase: last_update_date = datetime.now() all_login_boni = self.data.static.get_login_bonus( - self.version, preset["id"] + self.version, preset["presetId"] ) # skip the current bonus preset if no boni were found if all_login_boni is None or len(all_login_boni) < 1: self.logger.warn( - f"No bonus entries found for bonus preset {preset['id']}" + f"No bonus entries found for bonus preset {preset['presetId']}" ) continue @@ -83,14 +85,14 @@ class ChuniBase: if bonus_count > max_needed_days: # assume that all login preset ids under 3000 needs to be # looped, like 30 and 40 are looped, 40 does not work? - if preset["id"] < 3000: + if preset["presetId"] < 3000: bonus_count = 1 else: is_finished = True # grab the item for the corresponding day login_item = self.data.static.get_login_bonus_by_required_days( - self.version, preset["id"], bonus_count + self.version, preset["presetId"], bonus_count ) if login_item is not None: # now add the present to the database so the @@ -108,7 +110,7 @@ class ChuniBase: self.data.item.put_login_bonus( user_id, self.version, - preset["id"], + preset["presetId"], bonusCount=bonus_count, lastUpdateDate=last_update_date, isWatched=False, @@ -156,12 +158,18 @@ class ChuniBase: event_list = [] for evt_row in game_events: - tmp = {} - tmp["id"] = evt_row["eventId"] - tmp["type"] = evt_row["type"] - tmp["startDate"] = "2017-12-05 07:00:00.0" - tmp["endDate"] = "2099-12-31 00:00:00.0" - event_list.append(tmp) + event_list.append( + { + "id": evt_row["eventId"], + "type": evt_row["type"], + # actually use the startDate from the import so it + # properly shows all the events when new ones are imported + "startDate": datetime.strftime( + evt_row["startDate"], "%Y-%m-%d %H:%M:%S" + ), + "endDate": "2099-12-31 00:00:00", + } + ) return { "type": data["type"], @@ -228,29 +236,36 @@ class ChuniBase: def handle_get_user_character_api_request(self, data: Dict) -> Dict: characters = self.data.item.get_characters(data["userId"]) if characters is None: - return {} - next_idx = -1 + return { + "userId": data["userId"], + "length": 0, + "nextIndex": -1, + "userCharacterList": [], + } - characterList = [] - for x in range(int(data["nextIndex"]), len(characters)): + character_list = [] + next_idx = int(data["nextIndex"]) + max_ct = int(data["maxCount"]) + + for x in range(next_idx, len(characters)): tmp = characters[x]._asdict() tmp.pop("user") tmp.pop("id") - characterList.append(tmp) + character_list.append(tmp) - if len(characterList) >= int(data["maxCount"]): + if len(character_list) >= max_ct: break - if len(characterList) >= int(data["maxCount"]) and len(characters) > int( - data["maxCount"] - ) + int(data["nextIndex"]): - next_idx = int(data["maxCount"]) + int(data["nextIndex"]) + 1 + if len(characters) >= next_idx + max_ct: + next_idx += max_ct + else: + next_idx = -1 return { "userId": data["userId"], - "length": len(characterList), + "length": len(character_list), "nextIndex": next_idx, - "userCharacterList": characterList, + "userCharacterList": character_list, } def handle_get_user_charge_api_request(self, data: Dict) -> Dict: @@ -292,8 +307,8 @@ class ChuniBase: if len(user_course_list) >= max_ct: break - if len(user_course_list) >= max_ct: - next_idx = next_idx + max_ct + if len(user_course_list) >= next_idx + max_ct: + next_idx += max_ct else: next_idx = -1 @@ -347,12 +362,23 @@ class ChuniBase: } def handle_get_user_favorite_item_api_request(self, data: Dict) -> Dict: + user_fav_item_list = [] + + # still needs to be implemented on WebUI + # 1: Music, 3: Character + fav_list = self.data.item.get_all_favorites( + data["userId"], self.version, fav_kind=int(data["kind"]) + ) + if fav_list is not None: + for fav in fav_list: + user_fav_item_list.append({"id": fav["favId"]}) + return { "userId": data["userId"], - "length": 0, + "length": len(user_fav_item_list), "kind": data["kind"], "nextIndex": -1, - "userFavoriteItemList": [], + "userFavoriteItemList": user_fav_item_list, } def handle_get_user_favorite_music_api_request(self, data: Dict) -> Dict: @@ -387,13 +413,13 @@ class ChuniBase: xout = kind * 10000000000 + next_idx + len(items) if len(items) < int(data["maxCount"]): - nextIndex = 0 + next_idx = 0 else: - nextIndex = xout + next_idx = xout return { "userId": data["userId"], - "nextIndex": nextIndex, + "nextIndex": next_idx, "itemKind": kind, "length": len(items), "userItemList": items, @@ -452,6 +478,7 @@ class ChuniBase: "nextIndex": -1, "userMusicList": [], # 240 } + song_list = [] next_idx = int(data["nextIndex"]) max_ct = int(data["maxCount"]) @@ -474,10 +501,10 @@ class ChuniBase: if len(song_list) >= max_ct: break - if len(song_list) >= max_ct: + if len(song_list) >= next_idx + max_ct: next_idx += max_ct else: - next_idx = 0 + next_idx = -1 return { "userId": data["userId"], @@ -623,12 +650,15 @@ class ChuniBase: self.data.profile.put_profile_data( user_id, self.version, upsert["userData"][0] ) + if "userDataEx" in upsert: self.data.profile.put_profile_data_ex( user_id, self.version, upsert["userDataEx"][0] ) + if "userGameOption" in upsert: self.data.profile.put_profile_option(user_id, upsert["userGameOption"][0]) + if "userGameOptionEx" in upsert: self.data.profile.put_profile_option_ex( user_id, upsert["userGameOptionEx"][0] @@ -672,6 +702,10 @@ class ChuniBase: if "userPlaylogList" in upsert: for playlog in upsert["userPlaylogList"]: + # convert the player names to utf-8 + playlog["playedUserName1"] = self.read_wtf8(playlog["playedUserName1"]) + playlog["playedUserName2"] = self.read_wtf8(playlog["playedUserName2"]) + playlog["playedUserName3"] = self.read_wtf8(playlog["playedUserName3"]) self.data.score.put_playlog(user_id, playlog) if "userTeamPoint" in upsert: diff --git a/titles/chuni/const.py b/titles/chuni/const.py index 6ab3cc3..b3a4cb5 100644 --- a/titles/chuni/const.py +++ b/titles/chuni/const.py @@ -17,21 +17,23 @@ class ChuniConstants: VER_CHUNITHM_PARADISE = 10 VER_CHUNITHM_NEW = 11 VER_CHUNITHM_NEW_PLUS = 12 + VER_CHUNITHM_SUN = 13 VERSION_NAMES = [ - "Chunithm", - "Chunithm+", - "Chunithm Air", - "Chunithm Air+", - "Chunithm Star", - "Chunithm Star+", - "Chunithm Amazon", - "Chunithm Amazon+", - "Chunithm Crystal", - "Chunithm Crystal+", - "Chunithm Paradise", - "Chunithm New!!", - "Chunithm New!!+", + "CHUNITHM", + "CHUNITHM PLUS", + "CHUNITHM AIR", + "CHUNITHM AIR PLUS", + "CHUNITHM STAR", + "CHUNITHM STAR PLUS", + "CHUNITHM AMAZON", + "CHUNITHM AMAZON PLUS", + "CHUNITHM CRYSTAL", + "CHUNITHM CRYSTAL PLUS", + "CHUNITHM PARADISE", + "CHUNITHM NEW!!", + "CHUNITHM NEW PLUS!!", + "CHUNITHM SUN" ] @classmethod diff --git a/titles/chuni/index.py b/titles/chuni/index.py index a7545ba..811840a 100644 --- a/titles/chuni/index.py +++ b/titles/chuni/index.py @@ -29,6 +29,7 @@ from titles.chuni.crystalplus import ChuniCrystalPlus from titles.chuni.paradise import ChuniParadise from titles.chuni.new import ChuniNew from titles.chuni.newplus import ChuniNewPlus +from titles.chuni.sun import ChuniSun class ChuniServlet: @@ -55,6 +56,7 @@ class ChuniServlet: ChuniParadise, ChuniNew, ChuniNewPlus, + ChuniSun, ] self.logger = logging.getLogger("chuni") @@ -145,30 +147,32 @@ class ChuniServlet: if version < 105: # 1.0 internal_ver = ChuniConstants.VER_CHUNITHM - elif version >= 105 and version < 110: # Plus + elif version >= 105 and version < 110: # PLUS internal_ver = ChuniConstants.VER_CHUNITHM_PLUS - elif version >= 110 and version < 115: # Air + elif version >= 110 and version < 115: # AIR internal_ver = ChuniConstants.VER_CHUNITHM_AIR - elif version >= 115 and version < 120: # Air Plus + elif version >= 115 and version < 120: # AIR PLUS internal_ver = ChuniConstants.VER_CHUNITHM_AIR_PLUS - elif version >= 120 and version < 125: # Star + elif version >= 120 and version < 125: # STAR internal_ver = ChuniConstants.VER_CHUNITHM_STAR - elif version >= 125 and version < 130: # Star Plus + elif version >= 125 and version < 130: # STAR PLUS internal_ver = ChuniConstants.VER_CHUNITHM_STAR_PLUS - elif version >= 130 and version < 135: # Amazon + elif version >= 130 and version < 135: # AMAZON internal_ver = ChuniConstants.VER_CHUNITHM_AMAZON - elif version >= 135 and version < 140: # Amazon Plus + elif version >= 135 and version < 140: # AMAZON PLUS internal_ver = ChuniConstants.VER_CHUNITHM_AMAZON_PLUS - elif version >= 140 and version < 145: # Crystal + elif version >= 140 and version < 145: # CRYSTAL internal_ver = ChuniConstants.VER_CHUNITHM_CRYSTAL - elif version >= 145 and version < 150: # Crystal Plus + elif version >= 145 and version < 150: # CRYSTAL PLUS internal_ver = ChuniConstants.VER_CHUNITHM_CRYSTAL_PLUS - elif version >= 150 and version < 200: # Paradise + elif version >= 150 and version < 200: # PARADISE internal_ver = ChuniConstants.VER_CHUNITHM_PARADISE - elif version >= 200 and version < 205: # New + elif version >= 200 and version < 205: # NEW!! internal_ver = ChuniConstants.VER_CHUNITHM_NEW - elif version >= 205 and version < 210: # New Plus + elif version >= 205 and version < 210: # NEW PLUS!! internal_ver = ChuniConstants.VER_CHUNITHM_NEW_PLUS + elif version >= 210: # SUN + internal_ver = ChuniConstants.VER_CHUNITHM_SUN if all(c in string.hexdigits for c in endpoint) and len(endpoint) == 32: # If we get a 32 character long hex string, it's a hash and we're diff --git a/titles/chuni/new.py b/titles/chuni/new.py index 67b6fcc..40dee9b 100644 --- a/titles/chuni/new.py +++ b/titles/chuni/new.py @@ -23,41 +23,44 @@ class ChuniNew(ChuniBase): self.version = ChuniConstants.VER_CHUNITHM_NEW def handle_get_game_setting_api_request(self, data: Dict) -> Dict: + # use UTC time and convert it to JST time by adding +9 + # matching therefore starts one hour before and lasts for 8 hours match_start = datetime.strftime( - datetime.now() - timedelta(hours=10), self.date_time_format + datetime.utcnow() + timedelta(hours=8), self.date_time_format ) match_end = datetime.strftime( - datetime.now() + timedelta(hours=10), self.date_time_format + datetime.utcnow() + timedelta(hours=16), self.date_time_format ) reboot_start = datetime.strftime( - datetime.now() - timedelta(hours=11), self.date_time_format + datetime.utcnow() + timedelta(hours=6), self.date_time_format ) reboot_end = datetime.strftime( - datetime.now() - timedelta(hours=10), self.date_time_format + datetime.utcnow() + timedelta(hours=7), self.date_time_format ) return { "gameSetting": { - "isMaintenance": "false", + "isMaintenance": False, "requestInterval": 10, "rebootStartTime": reboot_start, "rebootEndTime": reboot_end, - "isBackgroundDistribute": "false", + "isBackgroundDistribute": False, "maxCountCharacter": 300, "maxCountItem": 300, "maxCountMusic": 300, "matchStartTime": match_start, "matchEndTime": match_end, - "matchTimeLimit": 99, + "matchTimeLimit": 60, "matchErrorLimit": 9999, "romVersion": self.game_cfg.version.version(self.version)["rom"], "dataVersion": self.game_cfg.version.version(self.version)["data"], "matchingUri": f"http://{self.core_cfg.title.hostname}:{self.core_cfg.title.port}/SDHD/200/ChuniServlet/", "matchingUriX": f"http://{self.core_cfg.title.hostname}:{self.core_cfg.title.port}/SDHD/200/ChuniServlet/", + # might be really important for online battle to connect the cabs via UDP port 50201 "udpHolePunchUri": f"http://{self.core_cfg.title.hostname}:{self.core_cfg.title.port}/SDHD/200/ChuniServlet/", "reflectorUri": f"http://{self.core_cfg.title.hostname}:{self.core_cfg.title.port}/SDHD/200/ChuniServlet/", }, - "isDumpUpload": "false", - "isAou": "false", + "isDumpUpload": False, + "isAou": False, } def handle_remove_token_api_request(self, data: Dict) -> Dict: @@ -468,3 +471,162 @@ class ChuniNew(ChuniBase): self.data.item.put_user_print_state(user_id, id=order_id, hasCompleted=True) return {"returnCode": "1", "apiName": "CMUpsertUserPrintCancelApi"} + + def handle_ping_request(self, data: Dict) -> Dict: + # matchmaking ping request + return {"returnCode": "1"} + + def handle_begin_matching_api_request(self, data: Dict) -> Dict: + room_id = 1 + # check if there is a free matching room + matching_room = self.data.item.get_oldest_free_matching(self.version) + + if matching_room is None: + # grab the latest roomId and add 1 for the new room + newest_matching = self.data.item.get_newest_matching(self.version) + if newest_matching is not None: + room_id = newest_matching["roomId"] + 1 + + # fix userName WTF8 + new_member = data["matchingMemberInfo"] + new_member["userName"] = self.read_wtf8(new_member["userName"]) + + # create the new room with room_id and the current user id (host) + # user id is required for the countdown later on + self.data.item.put_matching( + self.version, room_id, [new_member], user_id=new_member["userId"] + ) + + # get the newly created matching room + matching_room = self.data.item.get_matching(self.version, room_id) + else: + # a room already exists, so just add the new member to it + matching_member_list = matching_room["matchingMemberInfoList"] + # fix userName WTF8 + new_member = data["matchingMemberInfo"] + new_member["userName"] = self.read_wtf8(new_member["userName"]) + matching_member_list.append(new_member) + + # add the updated room to the database, make sure to set isFull correctly! + self.data.item.put_matching( + self.version, + matching_room["roomId"], + matching_member_list, + user_id=matching_room["user"], + is_full=True if len(matching_member_list) >= 4 else False, + ) + + matching_wait = { + "isFinish": False, + "restMSec": matching_room["restMSec"], # in sec + "pollingInterval": 1, # in sec + "matchingMemberInfoList": matching_room["matchingMemberInfoList"], + } + + return {"roomId": 1, "matchingWaitState": matching_wait} + + def handle_end_matching_api_request(self, data: Dict) -> Dict: + matching_room = self.data.item.get_matching(self.version, data["roomId"]) + members = matching_room["matchingMemberInfoList"] + + # only set the host user to role 1 every other to 0? + role_list = [ + {"role": 1} if m["userId"] == matching_room["user"] else {"role": 0} + for m in members + ] + + self.data.item.put_matching( + self.version, + matching_room["roomId"], + members, + user_id=matching_room["user"], + rest_sec=0, # make sure to always set 0 + is_full=True, # and full, so no one can join + ) + + return { + "matchingResult": 1, # needs to be 1 for successful matching + "matchingMemberInfoList": members, + # no idea, maybe to differentiate between CPUs and real players? + "matchingMemberRoleList": role_list, + # TCP/UDP connection? + "reflectorUri": f"{self.core_cfg.title.hostname}", + } + + def handle_remove_matching_member_api_request(self, data: Dict) -> Dict: + # get all matching rooms, because Chuni only returns the userId + # not the actual roomId + matching_rooms = self.data.item.get_all_matchings(self.version) + if matching_rooms is None: + return {"returnCode": "1"} + + for room in matching_rooms: + old_members = room["matchingMemberInfoList"] + new_members = [m for m in old_members if m["userId"] != data["userId"]] + + # if nothing changed go to the next room + if len(old_members) == len(new_members): + continue + + # if the last user got removed, delete the matching room + if len(new_members) <= 0: + self.data.item.delete_matching(self.version, room["roomId"]) + else: + # remove the user from the room + self.data.item.put_matching( + self.version, + room["roomId"], + new_members, + user_id=room["user"], + rest_sec=room["restMSec"], + ) + + return {"returnCode": "1"} + + def handle_get_matching_state_api_request(self, data: Dict) -> Dict: + polling_interval = 1 + # get the current active room + matching_room = self.data.item.get_matching(self.version, data["roomId"]) + members = matching_room["matchingMemberInfoList"] + rest_sec = matching_room["restMSec"] + + # grab the current member + current_member = data["matchingMemberInfo"] + + # only the host user can decrease the countdown + if matching_room["user"] == int(current_member["userId"]): + # cap the restMSec to 0 + if rest_sec > 0: + rest_sec -= polling_interval + else: + rest_sec = 0 + + # update the members in order to recieve messages + for i, member in enumerate(members): + if member["userId"] == current_member["userId"]: + # replace the old user data with the current user data, + # also parse WTF-8 everytime + current_member["userName"] = self.read_wtf8(current_member["userName"]) + members[i] = current_member + + self.data.item.put_matching( + self.version, + data["roomId"], + members, + rest_sec=rest_sec, + user_id=matching_room["user"], + ) + + # only add the other members to the list + diff_members = [m for m in members if m["userId"] != current_member["userId"]] + + matching_wait = { + # makes no difference? Always use False? + "isFinish": True if rest_sec == 0 else False, + "restMSec": rest_sec, + "pollingInterval": polling_interval, + # the current user needs to be the first one? + "matchingMemberInfoList": [current_member] + diff_members, + } + + return {"matchingWaitState": matching_wait} diff --git a/titles/chuni/newplus.py b/titles/chuni/newplus.py index 4faf47a..bbe1419 100644 --- a/titles/chuni/newplus.py +++ b/titles/chuni/newplus.py @@ -36,6 +36,6 @@ class ChuniNewPlus(ChuniNew): def handle_cm_get_user_preview_api_request(self, data: Dict) -> Dict: user_data = super().handle_cm_get_user_preview_api_request(data) - # hardcode lastDataVersion for CardMaker 1.35 + # hardcode lastDataVersion for CardMaker 1.35 A028 user_data["lastDataVersion"] = "2.05.00" return user_data diff --git a/titles/chuni/schema/item.py b/titles/chuni/schema/item.py index 4ffcf93..94c4fd8 100644 --- a/titles/chuni/schema/item.py +++ b/titles/chuni/schema/item.py @@ -1,5 +1,12 @@ from typing import Dict, List, Optional -from sqlalchemy import Table, Column, UniqueConstraint, PrimaryKeyConstraint, and_ +from sqlalchemy import ( + Table, + Column, + UniqueConstraint, + PrimaryKeyConstraint, + and_, + delete, +) from sqlalchemy.types import Integer, String, TIMESTAMP, Boolean, JSON from sqlalchemy.engine.base import Connection from sqlalchemy.schema import ForeignKey @@ -203,8 +210,141 @@ login_bonus = Table( mysql_charset="utf8mb4", ) +favorite = Table( + "chuni_item_favorite", + metadata, + Column("id", Integer, primary_key=True, nullable=False), + Column( + "user", + ForeignKey("aime_user.id", ondelete="cascade", onupdate="cascade"), + nullable=False, + ), + Column("version", Integer, nullable=False), + Column("favId", Integer, nullable=False), + Column("favKind", Integer, nullable=False, server_default="1"), + UniqueConstraint("version", "user", "favId", name="chuni_item_favorite_uk"), + mysql_charset="utf8mb4", +) + +matching = Table( + "chuni_item_matching", + metadata, + Column("roomId", Integer, nullable=False), + Column( + "user", + ForeignKey("aime_user.id", ondelete="cascade", onupdate="cascade"), + nullable=False, + ), + Column("version", Integer, nullable=False), + Column("restMSec", Integer, nullable=False, server_default="60"), + Column("isFull", Boolean, nullable=False, server_default="0"), + PrimaryKeyConstraint("roomId", "version", name="chuni_item_matching_pk"), + Column("matchingMemberInfoList", JSON, nullable=False), + mysql_charset="utf8mb4", +) + class ChuniItemData(BaseData): + def get_oldest_free_matching(self, version: int) -> Optional[Row]: + sql = matching.select( + and_( + matching.c.version == version, + matching.c.isFull == False + ) + ).order_by(matching.c.roomId.asc()) + + result = self.execute(sql) + if result is None: + return None + return result.fetchone() + + def get_newest_matching(self, version: int) -> Optional[Row]: + sql = matching.select( + and_( + matching.c.version == version + ) + ).order_by(matching.c.roomId.desc()) + + result = self.execute(sql) + if result is None: + return None + return result.fetchone() + + def get_all_matchings(self, version: int) -> Optional[List[Row]]: + sql = matching.select( + and_( + matching.c.version == version + ) + ) + + result = self.execute(sql) + if result is None: + return None + return result.fetchall() + + def get_matching(self, version: int, room_id: int) -> Optional[Row]: + sql = matching.select( + and_(matching.c.version == version, matching.c.roomId == room_id) + ) + + result = self.execute(sql) + if result is None: + return None + return result.fetchone() + + def put_matching( + self, + version: int, + room_id: int, + matching_member_info_list: list, + user_id: int = None, + rest_sec: int = 60, + is_full: bool = False + ) -> Optional[int]: + sql = insert(matching).values( + roomId=room_id, + version=version, + restMSec=rest_sec, + user=user_id, + isFull=is_full, + matchingMemberInfoList=matching_member_info_list, + ) + + conflict = sql.on_duplicate_key_update( + restMSec=rest_sec, matchingMemberInfoList=matching_member_info_list + ) + + result = self.execute(conflict) + if result is None: + return None + return result.lastrowid + + def delete_matching(self, version: int, room_id: int): + sql = delete(matching).where( + and_(matching.c.roomId == room_id, matching.c.version == version) + ) + + result = self.execute(sql) + if result is None: + return None + return result.lastrowid + + def get_all_favorites( + self, user_id: int, version: int, fav_kind: int = 1 + ) -> Optional[List[Row]]: + sql = favorite.select( + and_( + favorite.c.version == version, + favorite.c.user == user_id, + favorite.c.favKind == fav_kind, + ) + ) + + result = self.execute(sql) + if result is None: + return None + return result.fetchall() + def put_login_bonus( self, user_id: int, version: int, preset_id: int, **login_bonus_data ) -> Optional[int]: diff --git a/titles/chuni/schema/profile.py b/titles/chuni/schema/profile.py index e35769c..f8edc33 100644 --- a/titles/chuni/schema/profile.py +++ b/titles/chuni/schema/profile.py @@ -89,8 +89,6 @@ profile = Table( Integer, ForeignKey("chuni_profile_team.id", ondelete="SET NULL", onupdate="SET NULL"), ), - Column("avatarBack", Integer, server_default="0"), - Column("avatarFace", Integer, server_default="0"), Column("eliteRankPoint", Integer, server_default="0"), Column("stockedGridCount", Integer, server_default="0"), Column("netBattleLoseCount", Integer, server_default="0"), @@ -98,10 +96,8 @@ profile = Table( Column("netBattle4thCount", Integer, server_default="0"), Column("overPowerRate", Integer, server_default="0"), Column("battleRewardStatus", Integer, server_default="0"), - Column("avatarPoint", Integer, server_default="0"), Column("netBattle1stCount", Integer, server_default="0"), Column("charaIllustId", Integer, server_default="0"), - Column("avatarItem", Integer, server_default="0"), Column("userNameEx", String(8), server_default=""), Column("netBattleWinCount", Integer, server_default="0"), Column("netBattleCorrection", Integer, server_default="0"), @@ -112,7 +108,6 @@ profile = Table( Column("netBattle3rdCount", Integer, server_default="0"), Column("netBattleConsecutiveWinCount", Integer, server_default="0"), Column("overPowerLowerRank", Integer, server_default="0"), - Column("avatarWear", Integer, server_default="0"), Column("classEmblemBase", Integer, server_default="0"), Column("battleRankPoint", Integer, server_default="0"), Column("netBattle2ndCount", Integer, server_default="0"), @@ -120,13 +115,19 @@ profile = Table( Column("skillId", Integer, server_default="0"), Column("lastCountryCode", String(5), server_default="JPN"), Column("isNetBattleHost", Boolean, server_default="0"), - Column("avatarFront", Integer, server_default="0"), - Column("avatarSkin", Integer, server_default="0"), Column("battleRewardCount", Integer, server_default="0"), Column("battleRewardIndex", Integer, server_default="0"), Column("netBattlePlayCount", Integer, server_default="0"), Column("exMapLoopCount", Integer, server_default="0"), Column("netBattleEndState", Integer, server_default="0"), + Column("rankUpChallengeResults", JSON), + Column("avatarBack", Integer, server_default="0"), + Column("avatarFace", Integer, server_default="0"), + Column("avatarPoint", Integer, server_default="0"), + Column("avatarItem", Integer, server_default="0"), + Column("avatarWear", Integer, server_default="0"), + Column("avatarFront", Integer, server_default="0"), + Column("avatarSkin", Integer, server_default="0"), Column("avatarHead", Integer, server_default="0"), UniqueConstraint("user", "version", name="chuni_profile_profile_uk"), mysql_charset="utf8mb4", @@ -417,8 +418,8 @@ class ChuniProfileData(BaseData): sql = ( select([profile, option]) .join(option, profile.c.user == option.c.user) - .filter(and_(profile.c.user == aime_id, profile.c.version == version)) - ) + .filter(and_(profile.c.user == aime_id, profile.c.version <= version)) + ).order_by(profile.c.version.desc()) result = self.execute(sql) if result is None: @@ -429,9 +430,9 @@ class ChuniProfileData(BaseData): sql = select(profile).where( and_( profile.c.user == aime_id, - profile.c.version == version, + profile.c.version <= version, ) - ) + ).order_by(profile.c.version.desc()) result = self.execute(sql) if result is None: @@ -461,9 +462,9 @@ class ChuniProfileData(BaseData): sql = select(profile_ex).where( and_( profile_ex.c.user == aime_id, - profile_ex.c.version == version, + profile_ex.c.version <= version, ) - ) + ).order_by(profile_ex.c.version.desc()) result = self.execute(sql) if result is None: diff --git a/titles/chuni/schema/score.py b/titles/chuni/schema/score.py index 6a94813..203aa11 100644 --- a/titles/chuni/schema/score.py +++ b/titles/chuni/schema/score.py @@ -134,7 +134,9 @@ playlog = Table( Column("charaIllustId", Integer), Column("romVersion", String(255)), Column("judgeHeaven", Integer), - mysql_charset="utf8mb4", + Column("regionId", Integer), + Column("machineType", Integer), + mysql_charset="utf8mb4" ) diff --git a/titles/chuni/schema/static.py b/titles/chuni/schema/static.py index 4537518..85d0397 100644 --- a/titles/chuni/schema/static.py +++ b/titles/chuni/schema/static.py @@ -1,11 +1,19 @@ from typing import Dict, List, Optional -from sqlalchemy import Table, Column, UniqueConstraint, PrimaryKeyConstraint, and_ +from sqlalchemy import ( + ForeignKeyConstraint, + Table, + Column, + UniqueConstraint, + PrimaryKeyConstraint, + and_, +) from sqlalchemy.types import Integer, String, TIMESTAMP, Boolean, JSON, Float from sqlalchemy.engine.base import Connection from sqlalchemy.engine import Row from sqlalchemy.schema import ForeignKey from sqlalchemy.sql import func, select from sqlalchemy.dialects.mysql import insert +from datetime import datetime from core.data.schema import BaseData, metadata @@ -17,6 +25,7 @@ events = Table( Column("eventId", Integer), Column("type", Integer), Column("name", String(255)), + Column("startDate", TIMESTAMP, server_default=func.now()), Column("enabled", Boolean, server_default="1"), UniqueConstraint("version", "eventId", name="chuni_static_events_uk"), mysql_charset="utf8mb4", @@ -125,11 +134,13 @@ gacha_cards = Table( login_bonus_preset = Table( "chuni_static_login_bonus_preset", metadata, - Column("id", Integer, primary_key=True, nullable=False), + Column("presetId", Integer, nullable=False), Column("version", Integer, nullable=False), Column("presetName", String(255), nullable=False), Column("isEnabled", Boolean, server_default="1"), - UniqueConstraint("version", "id", name="chuni_static_login_bonus_preset_uk"), + PrimaryKeyConstraint( + "presetId", "version", name="chuni_static_login_bonus_preset_pk" + ), mysql_charset="utf8mb4", ) @@ -138,15 +149,7 @@ login_bonus = Table( metadata, Column("id", Integer, primary_key=True, nullable=False), Column("version", Integer, nullable=False), - Column( - "presetId", - ForeignKey( - "chuni_static_login_bonus_preset.id", - ondelete="cascade", - onupdate="cascade", - ), - nullable=False, - ), + Column("presetId", Integer, nullable=False), Column("loginBonusId", Integer, nullable=False), Column("loginBonusName", String(255), nullable=False), Column("presentId", Integer, nullable=False), @@ -157,6 +160,16 @@ login_bonus = Table( UniqueConstraint( "version", "presetId", "loginBonusId", name="chuni_static_login_bonus_uk" ), + ForeignKeyConstraint( + ["presetId", "version"], + [ + "chuni_static_login_bonus_preset.presetId", + "chuni_static_login_bonus_preset.version", + ], + onupdate="CASCADE", + ondelete="CASCADE", + name="chuni_static_login_bonus_ibfk_1", + ), mysql_charset="utf8mb4", ) @@ -236,7 +249,7 @@ class ChuniStaticData(BaseData): self, version: int, preset_id: int, preset_name: str, is_enabled: bool ) -> Optional[int]: sql = insert(login_bonus_preset).values( - id=preset_id, + presetId=preset_id, version=version, presetName=preset_name, isEnabled=is_enabled, @@ -416,6 +429,14 @@ class ChuniStaticData(BaseData): return None return result.fetchall() + def get_music(self, version: int) -> Optional[List[Row]]: + sql = music.select(music.c.version <= version) + + result = self.execute(sql) + if result is None: + return None + return result.fetchall() + def get_music_chart( self, version: int, song_id: int, chart_id: int ) -> Optional[List[Row]]: diff --git a/titles/chuni/sun.py b/titles/chuni/sun.py new file mode 100644 index 0000000..b56fa29 --- /dev/null +++ b/titles/chuni/sun.py @@ -0,0 +1,37 @@ +from typing import Dict, Any + +from core.config import CoreConfig +from titles.chuni.newplus import ChuniNewPlus +from titles.chuni.const import ChuniConstants +from titles.chuni.config import ChuniConfig + + +class ChuniSun(ChuniNewPlus): + def __init__(self, core_cfg: CoreConfig, game_cfg: ChuniConfig) -> None: + super().__init__(core_cfg, game_cfg) + self.version = ChuniConstants.VER_CHUNITHM_SUN + + def handle_get_game_setting_api_request(self, data: Dict) -> Dict: + ret = super().handle_get_game_setting_api_request(data) + ret["gameSetting"]["romVersion"] = self.game_cfg.version.version(self.version)["rom"] + ret["gameSetting"]["dataVersion"] = self.game_cfg.version.version(self.version)["data"] + ret["gameSetting"][ + "matchingUri" + ] = f"http://{self.core_cfg.title.hostname}:{self.core_cfg.title.port}/SDHD/210/ChuniServlet/" + ret["gameSetting"][ + "matchingUriX" + ] = f"http://{self.core_cfg.title.hostname}:{self.core_cfg.title.port}/SDHD/210/ChuniServlet/" + ret["gameSetting"][ + "udpHolePunchUri" + ] = f"http://{self.core_cfg.title.hostname}:{self.core_cfg.title.port}/SDHD/210/ChuniServlet/" + ret["gameSetting"][ + "reflectorUri" + ] = f"http://{self.core_cfg.title.hostname}:{self.core_cfg.title.port}/SDHD/210/ChuniServlet/" + return ret + + def handle_cm_get_user_preview_api_request(self, data: Dict) -> Dict: + user_data = super().handle_cm_get_user_preview_api_request(data) + + # hardcode lastDataVersion for CardMaker 1.35 A032 + user_data["lastDataVersion"] = "2.10.00" + return user_data diff --git a/titles/mai2/const.py b/titles/mai2/const.py index dcc7e29..42e4349 100644 --- a/titles/mai2/const.py +++ b/titles/mai2/const.py @@ -37,9 +37,9 @@ class Mai2Constants: "maimai DX PLUS", "maimai DX Splash", "maimai DX Splash PLUS", - "maimai DX Universe", - "maimai DX Universe PLUS", - "maimai DX Festival", + "maimai DX UNiVERSE", + "maimai DX UNiVERSE PLUS", + "maimai DX FESTiVAL", ) @classmethod From f959236af00286b98be2666f0b7ee6f03fb97e55 Mon Sep 17 00:00:00 2001 From: Raymonf Date: Wed, 10 May 2023 17:05:11 -0400 Subject: [PATCH 02/49] SUN encryption support --- titles/chuni/index.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/titles/chuni/index.py b/titles/chuni/index.py index 811840a..5d185e9 100644 --- a/titles/chuni/index.py +++ b/titles/chuni/index.py @@ -98,15 +98,18 @@ class ChuniServlet: ] for method in method_list: method_fixed = inflection.camelize(method)[6:-7] + # number of iterations was changed to 70 in SUN + iter_count = 70 if version >= ChuniConstants.VER_CHUNITHM_SUN else 44 hash = PBKDF2( method_fixed, bytes.fromhex(keys[2]), 128, - count=44, + count=iter_count, hmac_hash_module=SHA1, ) - self.hash_table[version][hash.hex()] = method_fixed + hashed_name = hash.hex()[:32] # truncate unused bytes like the game does + self.hash_table[version][hashed_name] = method_fixed self.logger.debug( f"Hashed v{version} method {method_fixed} with {bytes.fromhex(keys[2])} to get {hash.hex()}" From 0dce7e7849ac13c9b2a777caf9367697661af429 Mon Sep 17 00:00:00 2001 From: Dniel97 Date: Thu, 11 May 2023 15:33:29 +0200 Subject: [PATCH 03/49] docs: fixed opt typo --- docs/game_specific_info.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/game_specific_info.md b/docs/game_specific_info.md index 694885b..5f04f93 100644 --- a/docs/game_specific_info.md +++ b/docs/game_specific_info.md @@ -265,7 +265,7 @@ python dbutils.py --game SDDT upgrade * Card Maker 1.35: * CHUNITHM SUN: Yes (NEW PLUS!! up to A032) - * maimai DX FESTiVAL: Yes (up to A35) (UNiVERSE PLUS up to A031) + * maimai DX FESTiVAL: Yes (up to A035) (UNiVERSE PLUS up to A031) * O.N.G.E.K.I. Bright Memory: Yes From 97892d6a7d1c10754ad0e92c91daf5028aed8661 Mon Sep 17 00:00:00 2001 From: Kevin Trocolli Date: Thu, 18 May 2023 21:20:28 -0400 Subject: [PATCH 04/49] idz: try-catch for userdb request decryption --- titles/idz/userdb.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/titles/idz/userdb.py b/titles/idz/userdb.py index 2f70ba4..2ac765e 100644 --- a/titles/idz/userdb.py +++ b/titles/idz/userdb.py @@ -83,7 +83,13 @@ class IDZUserDBProtocol(Protocol): def dataReceived(self, data: bytes) -> None: self.logger.debug(f"Receive data {data.hex()}") crypt = AES.new(self.static_key, AES.MODE_ECB) - data_dec = crypt.decrypt(data) + + try: + data_dec = crypt.decrypt(data) + + except Exception as e: + self.logger.error(f"Failed to decrypt UserDB request from {self.transport.getPeer().host} because {e} - {data.hex()}") + self.logger.debug(f"Decrypt data {data_dec.hex()}") magic = struct.unpack_from(" Date: Sat, 20 May 2023 15:32:02 -0400 Subject: [PATCH 05/49] frontend: user page fixes, add card display --- core/data/schema/user.py | 3 +++ core/frontend.py | 19 ++++++++++++++++--- core/frontend/user/index.jinja | 29 ++++++++++++++++++++++++++++- core/frontend/widgets/topbar.jinja | 2 +- titles/pokken/frontend.py | 8 +++++++- titles/wacca/frontend.py | 7 ++++++- 6 files changed, 61 insertions(+), 7 deletions(-) diff --git a/core/data/schema/user.py b/core/data/schema/user.py index 98663d1..6a95005 100644 --- a/core/data/schema/user.py +++ b/core/data/schema/user.py @@ -79,6 +79,9 @@ class UserData(BaseData): if usr["password"] is None: return False + + if passwd is None or not passwd: + return False return bcrypt.checkpw(passwd, usr["password"].encode()) diff --git a/core/frontend.py b/core/frontend.py index c992e76..9eb30e6 100644 --- a/core/frontend.py +++ b/core/frontend.py @@ -182,7 +182,7 @@ class FE_Gate(FE_Base): access_code: str = request.args[b"access_code"][0].decode() username: str = request.args[b"username"][0] email: str = request.args[b"email"][0].decode() - passwd: str = request.args[b"passwd"][0] + passwd: bytes = request.args[b"passwd"][0] uid = self.data.card.get_user_id_from_card(access_code) if uid is None: @@ -197,7 +197,7 @@ class FE_Gate(FE_Base): if result is None: return redirectTo(b"/gate?e=3", request) - if not self.data.user.check_password(uid, passwd.encode()): + if not self.data.user.check_password(uid, passwd): return redirectTo(b"/gate", request) return redirectTo(b"/user", request) @@ -227,9 +227,22 @@ class FE_User(FE_Base): usr_sesh = IUserSession(sesh) if usr_sesh.userId == 0: return redirectTo(b"/gate", request) + + cards = self.data.card.get_user_cards(usr_sesh.userId) + user = self.data.user.get_user(usr_sesh.userId) + card_data = [] + for c in cards: + if c['is_locked']: + status = 'Locked' + elif c['is_banned']: + status = 'Banned' + else: + status = 'Active' + + card_data.append({'access_code': c['access_code'], 'status': status}) return template.render( - title=f"{self.core_config.server.name} | Account", sesh=vars(usr_sesh) + title=f"{self.core_config.server.name} | Account", sesh=vars(usr_sesh), cards=card_data, username=user['username'] ).encode("utf-16") diff --git a/core/frontend/user/index.jinja b/core/frontend/user/index.jinja index eabdd18..2911e67 100644 --- a/core/frontend/user/index.jinja +++ b/core/frontend/user/index.jinja @@ -1,4 +1,31 @@ {% extends "core/frontend/index.jinja" %} {% block content %} -

testing

+

Management for {{ username }}

+

Cards

+
    +{% for c in cards %} +
  • {{ c.access_code }}: {{ c.status }}
  • +{% endfor %} +
+ + {% endblock content %} \ No newline at end of file diff --git a/core/frontend/widgets/topbar.jinja b/core/frontend/widgets/topbar.jinja index d196361..fb63ebe 100644 --- a/core/frontend/widgets/topbar.jinja +++ b/core/frontend/widgets/topbar.jinja @@ -4,7 +4,7 @@
  {% for game in game_list %} -   +   {% endfor %}
diff --git a/titles/pokken/frontend.py b/titles/pokken/frontend.py index e4e8947..af344dc 100644 --- a/titles/pokken/frontend.py +++ b/titles/pokken/frontend.py @@ -2,8 +2,9 @@ import yaml import jinja2 from twisted.web.http import Request from os import path +from twisted.web.server import Session -from core.frontend import FE_Base +from core.frontend import FE_Base, IUserSession from core.config import CoreConfig from .database import PokkenData from .config import PokkenConfig @@ -27,7 +28,12 @@ class PokkenFrontend(FE_Base): template = self.environment.get_template( "titles/pokken/frontend/pokken_index.jinja" ) + + sesh: Session = request.getSession() + usr_sesh = IUserSession(sesh) + return template.render( title=f"{self.core_config.server.name} | {self.nav_name}", game_list=self.environment.globals["game_list"], + sesh=vars(usr_sesh) ).encode("utf-16") diff --git a/titles/wacca/frontend.py b/titles/wacca/frontend.py index 69ab1ee..cc40644 100644 --- a/titles/wacca/frontend.py +++ b/titles/wacca/frontend.py @@ -2,8 +2,9 @@ import yaml import jinja2 from twisted.web.http import Request from os import path +from twisted.web.server import Session -from core.frontend import FE_Base +from core.frontend import FE_Base, IUserSession from core.config import CoreConfig from titles.wacca.database import WaccaData from titles.wacca.config import WaccaConfig @@ -27,7 +28,11 @@ class WaccaFrontend(FE_Base): template = self.environment.get_template( "titles/wacca/frontend/wacca_index.jinja" ) + sesh: Session = request.getSession() + usr_sesh = IUserSession(sesh) + return template.render( title=f"{self.core_config.server.name} | {self.nav_name}", game_list=self.environment.globals["game_list"], + sesh=vars(usr_sesh) ).encode("utf-16") From 5ddfb88182d0b25275ecb8d99621bd4a75a2696f Mon Sep 17 00:00:00 2001 From: Hay1tsme Date: Mon, 22 May 2023 12:24:16 -0400 Subject: [PATCH 06/49] wacca: fix user/music/unlock error when using tickets. --- titles/wacca/handlers/user_music.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/titles/wacca/handlers/user_music.py b/titles/wacca/handlers/user_music.py index a8c80bf..26c2167 100644 --- a/titles/wacca/handlers/user_music.py +++ b/titles/wacca/handlers/user_music.py @@ -93,13 +93,10 @@ class UserMusicUnlockRequest(BaseRequest): class UserMusicUnlockResponse(BaseResponse): - def __init__(self, current_wp: int = 0, tickets_remaining: List = []) -> None: + def __init__(self, current_wp: int = 0, tickets_remaining: List[TicketItem] = []) -> None: super().__init__() self.wp = current_wp - self.tickets: List[TicketItem] = [] - - for ticket in tickets_remaining: - self.tickets.append(TicketItem(ticket[0], ticket[1], ticket[2])) + self.tickets = tickets_remaining def make(self) -> Dict: tickets = [] From b9fd4f294d0f5662132a69ce16eb731125543be7 Mon Sep 17 00:00:00 2001 From: Hay1tsme Date: Mon, 22 May 2023 12:33:43 -0400 Subject: [PATCH 07/49] wacca: fix type mismatch in user/music/unlock --- titles/wacca/base.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/titles/wacca/base.py b/titles/wacca/base.py index ada40c6..eeafe4c 100644 --- a/titles/wacca/base.py +++ b/titles/wacca/base.py @@ -624,10 +624,10 @@ class WaccaBase: current_wp = profile["wp"] tickets = self.data.item.get_tickets(user_id) - new_tickets = [] + new_tickets: List[TicketItem] = [] for ticket in tickets: - new_tickets.append([ticket["id"], ticket["ticket_id"], 9999999999]) + new_tickets.append(TicketItem(ticket["id"], ticket["ticket_id"], 9999999999)) for item in req.itemsUsed: if ( @@ -645,11 +645,11 @@ class WaccaBase: and not self.game_config.mods.infinite_tickets ): for x in range(len(new_tickets)): - if new_tickets[x][1] == item.itemId: + if new_tickets[x].ticketId == item.itemId: self.logger.debug( - f"Remove ticket ID {new_tickets[x][0]} type {new_tickets[x][1]} from {user_id}" + f"Remove ticket ID {new_tickets[x].userTicketId} type {new_tickets[x].ticketId} from {user_id}" ) - self.data.item.spend_ticket(new_tickets[x][0]) + self.data.item.spend_ticket(new_tickets[x].userTicketId) new_tickets.pop(x) break From 7ed294e9f7f4235004879e79905472bc8b1b0ec1 Mon Sep 17 00:00:00 2001 From: Kevin Trocolli Date: Wed, 24 May 2023 01:08:53 -0400 Subject: [PATCH 08/49] delivery: remove period from version --- core/allnet.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/core/allnet.py b/core/allnet.py index ab435e7..d54b333 100644 --- a/core/allnet.py +++ b/core/allnet.py @@ -204,12 +204,12 @@ class AllnetServlet: else: # TODO: Keychip check if path.exists( - f"{self.config.allnet.update_cfg_folder}/{req.game_id}-{req.ver}-app.ini" + f"{self.config.allnet.update_cfg_folder}/{req.game_id}-{req.ver.replace('.', '')}-app.ini" ): resp.uri = f"http://{self.config.title.hostname}:{self.config.title.port}/dl/ini/{req.game_id}-{req.ver.replace('.', '')}-app.ini" if path.exists( - f"{self.config.allnet.update_cfg_folder}/{req.game_id}-{req.ver}-opt.ini" + f"{self.config.allnet.update_cfg_folder}/{req.game_id}-{req.ver.replace('.', '')}-opt.ini" ): resp.uri += f"|http://{self.config.title.hostname}:{self.config.title.port}/dl/ini/{req.game_id}-{req.ver.replace('.', '')}-opt.ini" From 72594fef315a10aa089aca9a20c4e43a4949abf6 Mon Sep 17 00:00:00 2001 From: Midorica Date: Fri, 26 May 2023 13:45:20 -0400 Subject: [PATCH 09/49] adding partial Sword Art Online Arcade support --- example_config/sao.yaml | 6 + titles/sao/__init__.py | 10 + titles/sao/base.py | 226 ++++ titles/sao/config.py | 47 + titles/sao/const.py | 15 + titles/sao/database.py | 12 + titles/sao/handlers/__init__.py | 1 + titles/sao/handlers/base.py | 1698 +++++++++++++++++++++++++++++++ titles/sao/index.py | 117 +++ titles/sao/read.py | 230 +++++ titles/sao/schema/__init__.py | 2 + titles/sao/schema/profile.py | 48 + titles/sao/schema/static.py | 297 ++++++ 13 files changed, 2709 insertions(+) create mode 100644 example_config/sao.yaml create mode 100644 titles/sao/__init__.py create mode 100644 titles/sao/base.py create mode 100644 titles/sao/config.py create mode 100644 titles/sao/const.py create mode 100644 titles/sao/database.py create mode 100644 titles/sao/handlers/__init__.py create mode 100644 titles/sao/handlers/base.py create mode 100644 titles/sao/index.py create mode 100644 titles/sao/read.py create mode 100644 titles/sao/schema/__init__.py create mode 100644 titles/sao/schema/profile.py create mode 100644 titles/sao/schema/static.py diff --git a/example_config/sao.yaml b/example_config/sao.yaml new file mode 100644 index 0000000..7e3ecf2 --- /dev/null +++ b/example_config/sao.yaml @@ -0,0 +1,6 @@ +server: + hostname: "localhost" + enable: True + loglevel: "info" + port: 9000 + auto_register: True \ No newline at end of file diff --git a/titles/sao/__init__.py b/titles/sao/__init__.py new file mode 100644 index 0000000..15a46f9 --- /dev/null +++ b/titles/sao/__init__.py @@ -0,0 +1,10 @@ +from .index import SaoServlet +from .const import SaoConstants +from .database import SaoData +from .read import SaoReader + +index = SaoServlet +database = SaoData +reader = SaoReader +game_codes = [SaoConstants.GAME_CODE] +current_schema_version = 1 diff --git a/titles/sao/base.py b/titles/sao/base.py new file mode 100644 index 0000000..67a2d6b --- /dev/null +++ b/titles/sao/base.py @@ -0,0 +1,226 @@ +from datetime import datetime, timedelta +import json, logging +from typing import Any, Dict +import random +import struct + +from core.data import Data +from core import CoreConfig +from .config import SaoConfig +from .database import SaoData +from titles.sao.handlers.base import * + +class SaoBase: + def __init__(self, core_cfg: CoreConfig, game_cfg: SaoConfig) -> None: + self.core_cfg = core_cfg + self.game_cfg = game_cfg + self.core_data = Data(core_cfg) + self.game_data = SaoData(core_cfg) + self.version = 0 + self.logger = logging.getLogger("sao") + + def handle_noop(self, request: Any) -> bytes: + sao_request = request + + sao_id = int(sao_request[:4],16) + 1 + + ret = struct.pack("!HHIIIIIIb", sao_id, 0, 0, 5, 1, 1, 5, 0x01000000, 0).hex() + return bytes.fromhex(ret) + + def handle_c122(self, request: Any) -> bytes: + #common/get_maintenance_info + + resp = SaoGetMaintResponse(int.from_bytes(bytes.fromhex(request[:4]), "big")+1) + return resp.make() + + def handle_c12e(self, request: Any) -> bytes: + #common/ac_cabinet_boot_notification + resp = SaoCommonAcCabinetBootNotificationResponse(int.from_bytes(bytes.fromhex(request[:4]), "big")+1) + return resp.make() + + def handle_c100(self, request: Any) -> bytes: + #common/get_app_versions + resp = SaoCommonGetAppVersionsRequest(int.from_bytes(bytes.fromhex(request[:4]), "big")+1) + return resp.make() + + def handle_c102(self, request: Any) -> bytes: + #common/master_data_version_check + resp = SaoMasterDataVersionCheckResponse(int.from_bytes(bytes.fromhex(request[:4]), "big")+1) + return resp.make() + + def handle_c10a(self, request: Any) -> bytes: + #common/paying_play_start + resp = SaoCommonPayingPlayStartRequest(int.from_bytes(bytes.fromhex(request[:4]), "big")+1) + return resp.make() + + def handle_ca02(self, request: Any) -> bytes: + #quest_multi_play_room/get_quest_scene_multi_play_photon_server + resp = SaoGetQuestSceneMultiPlayPhotonServerResponse(int.from_bytes(bytes.fromhex(request[:4]), "big")+1) + return resp.make() + + def handle_c11e(self, request: Any) -> bytes: + #common/get_auth_card_data + + #Check authentication + access_code = bytes.fromhex(request[188:268]).decode("utf-16le") + user_id = self.core_data.card.get_user_id_from_card( access_code ) + + if not user_id: + user_id = self.core_data.user.create_user() #works + card_id = self.core_data.card.create_card(user_id, access_code) + + if card_id is None: + user_id = -1 + self.logger.error("Failed to register card!") + + profile_id = self.game_data.profile.create_profile(user_id) + + self.logger.info(f"User Authenticated: { access_code } | { user_id }") + + #Grab values from profile + profile_data = self.game_data.profile.get_profile(user_id) + + if user_id and not profile_data: + profile_id = self.game_data.profile.create_profile(user_id) + profile_data = self.game_data.profile.get_profile(user_id) + + resp = SaoGetAuthCardDataResponse(int.from_bytes(bytes.fromhex(request[:4]), "big")+1, profile_data) + return resp.make() + + def handle_c40c(self, request: Any) -> bytes: + #home/check_ac_login_bonus + resp = SaoHomeCheckAcLoginBonusResponse(int.from_bytes(bytes.fromhex(request[:4]), "big")+1) + return resp.make() + + def handle_c104(self, request: Any) -> bytes: + #common/login + access_code = bytes.fromhex(request[228:308]).decode("utf-16le") + user_id = self.core_data.card.get_user_id_from_card( access_code ) + profile_data = self.game_data.profile.get_profile(user_id) + + resp = SaoCommonLoginResponse(int.from_bytes(bytes.fromhex(request[:4]), "big")+1, profile_data) + return resp.make() + + def handle_c404(self, request: Any) -> bytes: + #home/check_comeback_event + resp = SaoCheckComebackEventRequest(int.from_bytes(bytes.fromhex(request[:4]), "big")+1) + return resp.make() + + def handle_c000(self, request: Any) -> bytes: + #ticket/ticket + resp = SaoTicketResponse(int.from_bytes(bytes.fromhex(request[:4]), "big")+1) + return resp.make() + + def handle_c500(self, request: Any) -> bytes: + #user_info/get_user_basic_data + user_id = bytes.fromhex(request[88:112]).decode("utf-16le") + profile_data = self.game_data.profile.get_profile(user_id) + + resp = SaoGetUserBasicDataResponse(int.from_bytes(bytes.fromhex(request[:4]), "big")+1, profile_data) + return resp.make() + + def handle_c600(self, request: Any) -> bytes: + #have_object/get_hero_log_user_data_list + heroIdsData = self.game_data.static.get_hero_ids(0, True) + + resp = SaoGetHeroLogUserDataListResponse(int.from_bytes(bytes.fromhex(request[:4]), "big")+1, heroIdsData) + return resp.make() + + def handle_c602(self, request: Any) -> bytes: + #have_object/get_equipment_user_data_list + equipmentIdsData = self.game_data.static.get_equipment_ids(0, True) + + resp = SaoGetEquipmentUserDataListResponse(int.from_bytes(bytes.fromhex(request[:4]), "big")+1, equipmentIdsData) + return resp.make() + + def handle_c604(self, request: Any) -> bytes: + #have_object/get_item_user_data_list + itemIdsData = self.game_data.static.get_item_ids(0, True) + + resp = SaoGetItemUserDataListResponse(int.from_bytes(bytes.fromhex(request[:4]), "big")+1, itemIdsData) + return resp.make() + + def handle_c606(self, request: Any) -> bytes: + #have_object/get_support_log_user_data_list + supportIdsData = self.game_data.static.get_support_log_ids(0, True) + + resp = SaoGetSupportLogUserDataListResponse(int.from_bytes(bytes.fromhex(request[:4]), "big")+1, supportIdsData) + return resp.make() + + def handle_c800(self, request: Any) -> bytes: + #custom/get_title_user_data_list + titleIdsData = self.game_data.static.get_title_ids(0, True) + + resp = SaoGetTitleUserDataListResponse(int.from_bytes(bytes.fromhex(request[:4]), "big")+1, titleIdsData) + return resp.make() + + def handle_c608(self, request: Any) -> bytes: + #have_object/get_episode_append_data_list + user_id = bytes.fromhex(request[88:112]).decode("utf-16le") + profile_data = self.game_data.profile.get_profile(user_id) + + resp = SaoGetEpisodeAppendDataListResponse(int.from_bytes(bytes.fromhex(request[:4]), "big")+1, profile_data) + return resp.make() + + def handle_c804(self, request: Any) -> bytes: + #custom/get_party_data_list + resp = SaoGetPartyDataListResponse(int.from_bytes(bytes.fromhex(request[:4]), "big")+1) + return resp.make() + + def handle_c902(self, request: Any) -> bytes: # for whatever reason, having all entries empty or filled changes nothing + #quest/get_quest_scene_prev_scan_profile_card + resp = SaoGetQuestScenePrevScanProfileCardResponse(int.from_bytes(bytes.fromhex(request[:4]), "big")+1) + return resp.make() + + def handle_c124(self, request: Any) -> bytes: + #common/get_resource_path_info + resp = SaoGetResourcePathInfoResponse(int.from_bytes(bytes.fromhex(request[:4]), "big")+1) + return resp.make() + + def handle_c900(self, request: Any) -> bytes: + #quest/get_quest_scene_user_data_list // QuestScene.csv + questIdsData = self.game_data.static.get_quests_ids(0, True) + resp = SaoGetQuestSceneUserDataListResponse(int.from_bytes(bytes.fromhex(request[:4]), "big")+1, questIdsData) + return resp.make() + + def handle_c400(self, request: Any) -> bytes: + #home/check_yui_medal_get_condition + resp = SaoCheckYuiMedalGetConditionResponse(int.from_bytes(bytes.fromhex(request[:4]), "big")+1) + return resp.make() + + def handle_c402(self, request: Any) -> bytes: + #home/get_yui_medal_bonus_user_data + resp = SaoGetYuiMedalBonusUserDataResponse(int.from_bytes(bytes.fromhex(request[:4]), "big")+1) + return resp.make() + + def handle_c40a(self, request: Any) -> bytes: + #home/check_profile_card_used_reward + resp = SaoCheckProfileCardUsedRewardResponse(int.from_bytes(bytes.fromhex(request[:4]), "big")+1) + return resp.make() + + def handle_c904(self, request: Any) -> bytes: + #quest/episode_play_start + user_id = bytes.fromhex(request[100:124]).decode("utf-16le") + profile_data = self.game_data.profile.get_profile(user_id) + + resp = SaoEpisodePlayStartResponse(int.from_bytes(bytes.fromhex(request[:4]), "big")+1, profile_data) + return resp.make() + + def handle_c908(self, request: Any) -> bytes: # function not working yet, tired of this + #quest/episode_play_end + resp = SaoEpisodePlayEndResponse(int.from_bytes(bytes.fromhex(request[:4]), "big")+1) + return resp.make() + + def handle_c914(self, request: Any) -> bytes: + #quest/trial_tower_play_start + user_id = bytes.fromhex(request[100:124]).decode("utf-16le") + floor_id = int(request[130:132], 16) # not required but nice to know + profile_data = self.game_data.profile.get_profile(user_id) + + resp = SaoEpisodePlayStartResponse(int.from_bytes(bytes.fromhex(request[:4]), "big")+1, profile_data) + return resp.make() + + def handle_c90a(self, request: Any) -> bytes: #should be tweaked for proper item unlock + #quest/episode_play_end_unanalyzed_log_fixed + resp = SaoEpisodePlayEndUnanalyzedLogFixedResponse(int.from_bytes(bytes.fromhex(request[:4]), "big")+1) + return resp.make() diff --git a/titles/sao/config.py b/titles/sao/config.py new file mode 100644 index 0000000..7b9a2d5 --- /dev/null +++ b/titles/sao/config.py @@ -0,0 +1,47 @@ +from core.config import CoreConfig + + +class SaoServerConfig: + def __init__(self, parent_config: "SaoConfig"): + self.__config = parent_config + + @property + def hostname(self) -> str: + return CoreConfig.get_config_field( + self.__config, "sao", "server", "hostname", default="localhost" + ) + + @property + def enable(self) -> bool: + return CoreConfig.get_config_field( + self.__config, "sao", "server", "enable", default=True + ) + + @property + def loglevel(self) -> int: + return CoreConfig.str_to_loglevel( + CoreConfig.get_config_field( + self.__config, "sao", "server", "loglevel", default="info" + ) + ) + + @property + def port(self) -> int: + return CoreConfig.get_config_field( + self.__config, "sao", "server", "port", default=9000 + ) + + @property + def auto_register(self) -> bool: + """ + Automatically register users in `aime_user` on first carding in with sao + if they don't exist already. Set to false to display an error instead. + """ + return CoreConfig.get_config_field( + self.__config, "sao", "server", "auto_register", default=True + ) + + +class SaoConfig(dict): + def __init__(self) -> None: + self.server = SaoServerConfig(self) diff --git a/titles/sao/const.py b/titles/sao/const.py new file mode 100644 index 0000000..8bdea0f --- /dev/null +++ b/titles/sao/const.py @@ -0,0 +1,15 @@ +from enum import Enum + + +class SaoConstants: + GAME_CODE = "SDEW" + + CONFIG_NAME = "sao.yaml" + + VER_SAO = 0 + + VERSION_NAMES = ("Sword Art Online Arcade") + + @classmethod + def game_ver_to_string(cls, ver: int): + return cls.VERSION_NAMES[ver] diff --git a/titles/sao/database.py b/titles/sao/database.py new file mode 100644 index 0000000..463440d --- /dev/null +++ b/titles/sao/database.py @@ -0,0 +1,12 @@ +from core.data import Data +from core.config import CoreConfig + +from .schema import * + + +class SaoData(Data): + def __init__(self, cfg: CoreConfig) -> None: + super().__init__(cfg) + + self.profile = SaoProfileData(cfg, self.session) + self.static = SaoStaticData(cfg, self.session) \ No newline at end of file diff --git a/titles/sao/handlers/__init__.py b/titles/sao/handlers/__init__.py new file mode 100644 index 0000000..90a6b4e --- /dev/null +++ b/titles/sao/handlers/__init__.py @@ -0,0 +1 @@ +from titles.sao.handlers.base import * \ No newline at end of file diff --git a/titles/sao/handlers/base.py b/titles/sao/handlers/base.py new file mode 100644 index 0000000..9048517 --- /dev/null +++ b/titles/sao/handlers/base.py @@ -0,0 +1,1698 @@ +import struct +from datetime import datetime +from construct import * +import sys + +class SaoBaseRequest: + def __init__(self, data: bytes) -> None: + self.cmd = struct.unpack_from("!H", bytes)[0] + # TODO: The rest of the request header + +class SaoBaseResponse: + def __init__(self, cmd_id: int) -> None: + self.cmd = cmd_id + self.err_status = 0 + self.error_type = 0 + self.vendor_id = 5 + self.game_id = 1 + self.version_id = 1 + self.length = 1 + + def make(self) -> bytes: + return struct.pack("!HHIIIII", self.cmd, self.err_status, self.error_type, self.vendor_id, self.game_id, self.version_id, self.length) + +class SaoNoopResponse(SaoBaseResponse): + def __init__(self, cmd: int) -> None: + super().__init__(cmd) + self.result = 1 + self.length = 5 + + def make(self) -> bytes: + return super().make() + struct.pack("!bI", self.result, 0) + +class SaoGetMaintRequest(SaoBaseRequest): + def __init__(self, data: bytes) -> None: + super().__init__(data) + # TODO: The rest of the mait info request + +class SaoGetMaintResponse(SaoBaseResponse): + def __init__(self, cmd) -> None: + super().__init__(cmd) + self.result = 1 + self.maint_begin = datetime.fromtimestamp(0) + self.maint_begin_int_ct = 6 + self.maint_end = datetime.fromtimestamp(0) + self.maint_end_int_ct = 6 + self.dt_format = "%Y%m%d%H%M%S" + + def make(self) -> bytes: + maint_begin_list = [x for x in datetime.strftime(self.maint_begin, self.dt_format)] + maint_end_list = [x for x in datetime.strftime(self.maint_end, self.dt_format)] + self.maint_begin_int_ct = len(maint_begin_list) * 2 + self.maint_end_int_ct = len(maint_end_list) * 2 + + maint_begin_bytes = b"" + maint_end_bytes = b"" + + for x in maint_begin_list: + maint_begin_bytes += struct.pack(" None: + super().__init__(data) + +class SaoCommonAcCabinetBootNotificationResponse(SaoBaseResponse): + def __init__(self, cmd) -> None: + super().__init__(cmd) + self.result = 1 + + def make(self) -> bytes: + # create a resp struct + resp_struct = Struct( + "result" / Int8ul, # result is either 0 or 1 + ) + + resp_data = resp_struct.build(dict( + result=self.result, + )) + + self.length = len(resp_data) + return super().make() + resp_data + +class SaoMasterDataVersionCheckRequest(SaoBaseRequest): + def __init__(self, data: bytes) -> None: + super().__init__(data) + +class SaoMasterDataVersionCheckResponse(SaoBaseResponse): + def __init__(self, cmd) -> None: + super().__init__(cmd) + self.result = 1 + self.update_flag = 0 + self.data_version = 100 + + def make(self) -> bytes: + # create a resp struct + resp_struct = Struct( + "result" / Int8ul, # result is either 0 or 1 + "update_flag" / Int8ul, # result is either 0 or 1 + "data_version" / Int32ub, + ) + + resp_data = resp_struct.build(dict( + result=self.result, + update_flag=self.update_flag, + data_version=self.data_version, + )) + + self.length = len(resp_data) + return super().make() + resp_data + +class SaoCommonGetAppVersionsRequest(SaoBaseRequest): + def __init__(self, data: bytes) -> None: + super().__init__(data) + +class SaoCommonGetAppVersionsRequest(SaoBaseResponse): + def __init__(self, cmd) -> None: + super().__init__(cmd) + self.result = 1 + self.data_list_size = 1 # Number of arrays + + self.version_app_id = 1 + self.applying_start_date = "20230520193000" + + def make(self) -> bytes: + # create a resp struct + resp_struct = Struct( + "result" / Int8ul, # result is either 0 or 1 + "data_list_size" / Int32ub, + + "version_app_id" / Int32ub, + "applying_start_date_size" / Int32ub, # big endian + "applying_start_date" / Int16ul[len(self.applying_start_date)], + ) + + resp_data = resp_struct.build(dict( + result=self.result, + data_list_size=self.data_list_size, + + version_app_id=self.version_app_id, + applying_start_date_size=len(self.applying_start_date) * 2, + applying_start_date=[ord(x) for x in self.applying_start_date], + )) + + self.length = len(resp_data) + return super().make() + resp_data + +class SaoCommonPayingPlayStartRequest(SaoBaseRequest): + def __init__(self, data: bytes) -> None: + super().__init__(data) + +class SaoCommonPayingPlayStartRequest(SaoBaseResponse): + def __init__(self, cmd) -> None: + super().__init__(cmd) + self.result = 1 + self.paying_session_id = "1" + + def make(self) -> bytes: + # create a resp struct + resp_struct = Struct( + "result" / Int8ul, # result is either 0 or 1 + "paying_session_id_size" / Int32ub, # big endian + "paying_session_id" / Int16ul[len(self.paying_session_id)], + ) + + resp_data = resp_struct.build(dict( + result=self.result, + paying_session_id_size=len(self.paying_session_id) * 2, + paying_session_id=[ord(x) for x in self.paying_session_id], + )) + + self.length = len(resp_data) + return super().make() + resp_data + +class SaoGetAuthCardDataRequest(SaoBaseRequest): + def __init__(self, data: bytes) -> None: + super().__init__(data) + +class SaoGetAuthCardDataResponse(SaoBaseResponse): #GssSite.dll / GssSiteSystem / GameConnectProt / public class get_auth_card_data_R : GameConnect.GssProtocolBase + def __init__(self, cmd, profile_data) -> None: + super().__init__(cmd) + + self.result = 1 + self.unused_card_flag = "" + self.first_play_flag = 0 + self.tutorial_complete_flag = 1 + self.nick_name = profile_data['nick_name'] # nick_name field #4 + self.personal_id = str(profile_data['user']) + + def make(self) -> bytes: + # create a resp struct + resp_struct = Struct( + "result" / Int8ul, # result is either 0 or 1 + "unused_card_flag_size" / Int32ub, # big endian + "unused_card_flag" / Int16ul[len(self.unused_card_flag)], + "first_play_flag" / Int8ul, # result is either 0 or 1 + "tutorial_complete_flag" / Int8ul, # result is either 0 or 1 + "nick_name_size" / Int32ub, # big endian + "nick_name" / Int16ul[len(self.nick_name)], + "personal_id_size" / Int32ub, # big endian + "personal_id" / Int16ul[len(self.personal_id)] + ) + + resp_data = resp_struct.build(dict( + result=self.result, + unused_card_flag_size=len(self.unused_card_flag) * 2, + unused_card_flag=[ord(x) for x in self.unused_card_flag], + first_play_flag=self.first_play_flag, + tutorial_complete_flag=self.tutorial_complete_flag, + nick_name_size=len(self.nick_name) * 2, + nick_name=[ord(x) for x in self.nick_name], + personal_id_size=len(self.personal_id) * 2, + personal_id=[ord(x) for x in self.personal_id] + )) + + self.length = len(resp_data) + return super().make() + resp_data + +class SaoHomeCheckAcLoginBonusRequest(SaoBaseRequest): + def __init__(self, data: bytes) -> None: + super().__init__(data) + +class SaoHomeCheckAcLoginBonusResponse(SaoBaseResponse): + def __init__(self, cmd) -> None: + super().__init__(cmd) + self.result = 1 + self.reward_get_flag = 1 + self.get_ac_login_bonus_id_list_size = 2 # Array + + self.get_ac_login_bonus_id_1 = 1 # "2020年7月9日~(アニメ&リコリス記念)" + self.get_ac_login_bonus_id_2 = 2 # "2020年10月6日~(秋のデビュー&カムバックCP)" + + def make(self) -> bytes: + # create a resp struct + resp_struct = Struct( + "result" / Int8ul, # result is either 0 or 1 + "reward_get_flag" / Int8ul, # result is either 0 or 1 + "get_ac_login_bonus_id_list_size" / Int32ub, + + "get_ac_login_bonus_id_1" / Int32ub, + "get_ac_login_bonus_id_2" / Int32ub, + ) + + resp_data = resp_struct.build(dict( + result=self.result, + reward_get_flag=self.reward_get_flag, + get_ac_login_bonus_id_list_size=self.get_ac_login_bonus_id_list_size, + + get_ac_login_bonus_id_1=self.get_ac_login_bonus_id_1, + get_ac_login_bonus_id_2=self.get_ac_login_bonus_id_2, + )) + + self.length = len(resp_data) + return super().make() + resp_data + +class SaoGetQuestSceneMultiPlayPhotonServerRequest(SaoBaseRequest): + def __init__(self, data: bytes) -> None: + super().__init__(data) + +class SaoGetQuestSceneMultiPlayPhotonServerResponse(SaoBaseResponse): + def __init__(self, cmd) -> None: + super().__init__(cmd) + self.result = 1 + self.application_id = "7df3a2f6-d69d-4073-aafe-810ee61e1cea" + + def make(self) -> bytes: + # create a resp struct + resp_struct = Struct( + "result" / Int8ul, # result is either 0 or 1 + "application_id_size" / Int32ub, # big endian + "application_id" / Int16ul[len(self.application_id)], + ) + + resp_data = resp_struct.build(dict( + result=self.result, + application_id_size=len(self.application_id) * 2, + application_id=[ord(x) for x in self.application_id], + )) + + self.length = len(resp_data) + return super().make() + resp_data + +class SaoTicketRequest(SaoBaseRequest): + def __init__(self, data: bytes) -> None: + super().__init__(data) + +class SaoTicketResponse(SaoBaseResponse): + def __init__(self, cmd) -> None: + super().__init__(cmd) + self.result = "1" + self.ticket_id = "9" #up to 18 + + def make(self) -> bytes: + # create a resp struct + resp_struct = Struct( + "result_size" / Int32ub, # big endian + "result" / Int16ul[len(self.result)], + "ticket_id_size" / Int32ub, # big endian + "ticket_id" / Int16ul[len(self.result)], + ) + + resp_data = resp_struct.build(dict( + result_size=len(self.result) * 2, + result=[ord(x) for x in self.result], + ticket_id_size=len(self.ticket_id) * 2, + ticket_id=[ord(x) for x in self.ticket_id], + )) + + self.length = len(resp_data) + return super().make() + resp_data + +class SaoCommonLoginRequest(SaoBaseRequest): + def __init__(self, data: bytes) -> None: + super().__init__(data) + +class SaoCommonLoginResponse(SaoBaseResponse): + def __init__(self, cmd, profile_data) -> None: + super().__init__(cmd) + self.result = 1 + self.user_id = str(profile_data['user']) + self.first_play_flag = 0 + self.grantable_free_ticket_flag = 1 + self.login_reward_vp = 99 + self.today_paying_flag = 1 + + def make(self) -> bytes: + # create a resp struct + ''' + bool = Int8ul + short = Int16ub + int = Int32ub + ''' + resp_struct = Struct( + "result" / Int8ul, # result is either 0 or 1 + "user_id_size" / Int32ub, # big endian + "user_id" / Int16ul[len(self.user_id)], + "first_play_flag" / Int8ul, # result is either 0 or 1 + "grantable_free_ticket_flag" / Int8ul, # result is either 0 or 1 + "login_reward_vp" / Int16ub, + "today_paying_flag" / Int8ul, # result is either 0 or 1 + ) + + resp_data = resp_struct.build(dict( + result=self.result, + user_id_size=len(self.user_id) * 2, + user_id=[ord(x) for x in self.user_id], + first_play_flag=self.first_play_flag, + grantable_free_ticket_flag=self.grantable_free_ticket_flag, + login_reward_vp=self.login_reward_vp, + today_paying_flag=self.today_paying_flag, + )) + + self.length = len(resp_data) + return super().make() + resp_data + +class SaoCheckComebackEventRequest(SaoBaseRequest): + def __init__(self, data: bytes) -> None: + super().__init__(data) + +class SaoCheckComebackEventRequest(SaoBaseResponse): + def __init__(self, cmd) -> None: + super().__init__(cmd) + self.result = 1 + self.get_flag_ = 1 + self.get_comeback_event_id_list = "" # Array of events apparently + + def make(self) -> bytes: + # create a resp struct + resp_struct = Struct( + "result" / Int8ul, # result is either 0 or 1 + "get_flag_" / Int8ul, # result is either 0 or 1 + "get_comeback_event_id_list_size" / Int32ub, # big endian + "get_comeback_event_id_list" / Int16ul[len(self.get_comeback_event_id_list)], + ) + + resp_data = resp_struct.build(dict( + result=self.result, + get_flag_=self.get_flag_, + get_comeback_event_id_list_size=len(self.get_comeback_event_id_list) * 2, + get_comeback_event_id_list=[ord(x) for x in self.get_comeback_event_id_list], + )) + + self.length = len(resp_data) + return super().make() + resp_data + +class SaoGetUserBasicDataRequest(SaoBaseRequest): + def __init__(self, data: bytes) -> None: + super().__init__(data) + +class SaoGetUserBasicDataResponse(SaoBaseResponse): + def __init__(self, cmd, profile_data) -> None: + super().__init__(cmd) + self.result = 1 + self.user_basic_data_size = 1 # Number of arrays + self.user_type = profile_data['user_type'] + self.nick_name = profile_data['nick_name'] + self.rank_num = profile_data['rank_num'] + self.rank_exp = profile_data['rank_exp'] + self.own_col = profile_data['own_col'] + self.own_vp = profile_data['own_vp'] + self.own_yui_medal = profile_data['own_yui_medal'] + self.setting_title_id = profile_data['setting_title_id'] + self.favorite_user_hero_log_id = "" + self.favorite_user_support_log_id = "" + self.my_store_id = "1" + self.my_store_name = "ARTEMiS" + self.user_reg_date = "20230101120000" + + def make(self) -> bytes: + # create a resp struct + ''' + bool = Int8ul + short = Int16ub + int = Int32ub + ''' + resp_struct = Struct( + "result" / Int8ul, # result is either 0 or 1 + "user_basic_data_size" / Int32ub, + + "user_type" / Int16ub, + "nick_name_size" / Int32ub, # big endian + "nick_name" / Int16ul[len(self.nick_name)], + "rank_num" / Int16ub, + "rank_exp" / Int32ub, + "own_col" / Int32ub, + "own_vp" / Int32ub, + "own_yui_medal" / Int32ub, + "setting_title_id" / Int32ub, + "favorite_user_hero_log_id_size" / Int32ub, # big endian + "favorite_user_hero_log_id" / Int16ul[len(str(self.favorite_user_hero_log_id))], + "favorite_user_support_log_id_size" / Int32ub, # big endian + "favorite_user_support_log_id" / Int16ul[len(str(self.favorite_user_support_log_id))], + "my_store_id_size" / Int32ub, # big endian + "my_store_id" / Int16ul[len(str(self.my_store_id))], + "my_store_name_size" / Int32ub, # big endian + "my_store_name" / Int16ul[len(str(self.my_store_name))], + "user_reg_date_size" / Int32ub, # big endian + "user_reg_date" / Int16ul[len(self.user_reg_date)] + + ) + + resp_data = resp_struct.build(dict( + result=self.result, + user_basic_data_size=self.user_basic_data_size, + + user_type=self.user_type, + nick_name_size=len(self.nick_name) * 2, + nick_name=[ord(x) for x in self.nick_name], + rank_num=self.rank_num, + rank_exp=self.rank_exp, + own_col=self.own_col, + own_vp=self.own_vp, + own_yui_medal=self.own_yui_medal, + setting_title_id=self.setting_title_id, + favorite_user_hero_log_id_size=len(self.favorite_user_hero_log_id) * 2, + favorite_user_hero_log_id=[ord(x) for x in str(self.favorite_user_hero_log_id)], + favorite_user_support_log_id_size=len(self.favorite_user_support_log_id) * 2, + favorite_user_support_log_id=[ord(x) for x in str(self.favorite_user_support_log_id)], + my_store_id_size=len(self.my_store_id) * 2, + my_store_id=[ord(x) for x in str(self.my_store_id)], + my_store_name_size=len(self.my_store_name) * 2, + my_store_name=[ord(x) for x in str(self.my_store_name)], + user_reg_date_size=len(self.user_reg_date) * 2, + user_reg_date=[ord(x) for x in self.user_reg_date], + )) + + self.length = len(resp_data) + return super().make() + resp_data + +class SaoGetHeroLogUserDataListRequest(SaoBaseRequest): + def __init__(self, data: bytes) -> None: + super().__init__(data) + +class SaoGetHeroLogUserDataListResponse(SaoBaseResponse): + def __init__(self, cmd, heroIdsData) -> None: + super().__init__(cmd) + self.result = 1 + + #print(heroIdsData) + #print(list(map(str,heroIdsData))) + + # hero_log_user_data_list + self.user_hero_log_id = list(map(str,heroIdsData)) #str + self.hero_log_id = heroIdsData #int + self.log_level = 10 #short + self.max_log_level_extended_num = 10 #short + self.log_exp = 1000 #int + self.possible_awakening_flag = 0 #byte + self.awakening_stage = 0 #short + self.awakening_exp = 0 #int + self.skill_slot_correction_value = 0 #byte + self.last_set_skill_slot1_skill_id = 0 #short + self.last_set_skill_slot2_skill_id = 0 #short + self.last_set_skill_slot3_skill_id = 0 #short + self.last_set_skill_slot4_skill_id = 0 #short + self.last_set_skill_slot5_skill_id = 0 #short + self.property1_property_id = 0 #int + self.property1_value1 = 0 #int + self.property1_value2 = 0 #int + self.property2_property_id = 0 #int + self.property2_value1 = 0 #int + self.property2_value2 = 0 #int + self.property3_property_id = 0 #int + self.property3_value1 = 0 #int + self.property3_value2 = 0 #int + self.property4_property_id = 0 #int + self.property4_value1 = 0 #int + self.property4_value2 = 0 #int + self.converted_card_num = 0 #short + self.shop_purchase_flag = 1 #byte + self.protect_flag = 0 #byte + self.get_date = "20230101120000" #str + + def make(self) -> bytes: + #new stuff + + hero_log_user_data_list_struct = Struct( + "user_hero_log_id_size" / Int32ub, # big endian + "user_hero_log_id" / Int16ul[9], #string + "hero_log_id" / Int32ub, #int + "log_level" / Int16ub, #short + "max_log_level_extended_num" / Int16ub, #short + "log_exp" / Int32ub, #int + "possible_awakening_flag" / Int8ul, # result is either 0 or 1 + "awakening_stage" / Int16ub, #short + "awakening_exp" / Int32ub, #int + "skill_slot_correction_value" / Int8ul, # result is either 0 or 1 + "last_set_skill_slot1_skill_id" / Int16ub, #short + "last_set_skill_slot2_skill_id" / Int16ub, #short + "last_set_skill_slot3_skill_id" / Int16ub, #short + "last_set_skill_slot4_skill_id" / Int16ub, #short + "last_set_skill_slot5_skill_id" / Int16ub, #short + "property1_property_id" / Int32ub, + "property1_value1" / Int32ub, + "property1_value2" / Int32ub, + "property2_property_id" / Int32ub, + "property2_value1" / Int32ub, + "property2_value2" / Int32ub, + "property3_property_id" / Int32ub, + "property3_value1" / Int32ub, + "property3_value2" / Int32ub, + "property4_property_id" / Int32ub, + "property4_value1" / Int32ub, + "property4_value2" / Int32ub, + "converted_card_num" / Int16ub, + "shop_purchase_flag" / Int8ul, # result is either 0 or 1 + "protect_flag" / Int8ul, # result is either 0 or 1 + "get_date_size" / Int32ub, # big endian + "get_date" / Int16ul[len(self.get_date)], + ) + + # create a resp struct + resp_struct = Struct( + "result" / Int8ul, # result is either 0 or 1 + "hero_log_user_data_list_size" / Rebuild(Int32ub, len_(this.hero_log_user_data_list)), # big endian + "hero_log_user_data_list" / Array(this.hero_log_user_data_list_size, hero_log_user_data_list_struct), + ) + + resp_data = resp_struct.parse(resp_struct.build(dict( + result=self.result, + hero_log_user_data_list_size=0, + hero_log_user_data_list=[], + ))) + + for i in range(len(self.hero_log_id)): + hero_data = dict( + user_hero_log_id_size=len(self.user_hero_log_id[i]) * 2, + user_hero_log_id=[ord(x) for x in self.user_hero_log_id[i]], + hero_log_id=self.hero_log_id[i], + log_level=self.log_level, + max_log_level_extended_num=self.max_log_level_extended_num, + log_exp=self.log_exp, + possible_awakening_flag=self.possible_awakening_flag, + awakening_stage=self.awakening_stage, + awakening_exp=self.awakening_exp, + skill_slot_correction_value=self.skill_slot_correction_value, + last_set_skill_slot1_skill_id=self.last_set_skill_slot1_skill_id, + last_set_skill_slot2_skill_id=self.last_set_skill_slot2_skill_id, + last_set_skill_slot3_skill_id=self.last_set_skill_slot3_skill_id, + last_set_skill_slot4_skill_id=self.last_set_skill_slot4_skill_id, + last_set_skill_slot5_skill_id=self.last_set_skill_slot5_skill_id, + property1_property_id=self.property1_property_id, + property1_value1=self.property1_value1, + property1_value2=self.property1_value2, + property2_property_id=self.property2_property_id, + property2_value1=self.property2_value1, + property2_value2=self.property2_value2, + property3_property_id=self.property3_property_id, + property3_value1=self.property3_value1, + property3_value2=self.property3_value2, + property4_property_id=self.property4_property_id, + property4_value1=self.property4_value1, + property4_value2=self.property4_value2, + converted_card_num=self.converted_card_num, + shop_purchase_flag=self.shop_purchase_flag, + protect_flag=self.protect_flag, + get_date_size=len(self.get_date) * 2, + get_date=[ord(x) for x in self.get_date], + + ) + + resp_data.hero_log_user_data_list.append(hero_data) + + # finally, rebuild the resp_data + resp_data = resp_struct.build(resp_data) + + self.length = len(resp_data) + return super().make() + resp_data + +class SaoGetEquipmentUserDataListRequest(SaoBaseRequest): + def __init__(self, data: bytes) -> None: + super().__init__(data) + +class SaoGetEquipmentUserDataListResponse(SaoBaseResponse): + def __init__(self, cmd, equipmentIdsData) -> None: + super().__init__(cmd) + self.result = 1 + + # equipment_user_data_list + self.user_equipment_id = list(map(str,equipmentIdsData)) #str + self.equipment_id = equipmentIdsData #int + self.enhancement_value = 10 #short + self.max_enhancement_value_extended_num = 10 #short + self.enhancement_exp = 1000 #int + self.possible_awakening_flag = 0 #byte + self.awakening_stage = 0 #short + self.awakening_exp = 0 #int + self.property1_property_id = 0 #int + self.property1_value1 = 0 #int + self.property1_value2 = 0 #int + self.property2_property_id = 0 #int + self.property2_value1 = 0 #int + self.property2_value2 = 0 #int + self.property3_property_id = 0 #int + self.property3_value1 = 0 #int + self.property3_value2 = 0 #int + self.property4_property_id = 0 #int + self.property4_value1 = 0 #int + self.property4_value2 = 0 #int + self.converted_card_num = 1 #short + self.shop_purchase_flag = 1 #byte + self.protect_flag = 0 #byte + self.get_date = "20230101120000" #str + + def make(self) -> bytes: + + equipment_user_data_list_struct = Struct( + "user_equipment_id_size" / Int32ub, # big endian + "user_equipment_id" / Int16ul[9], #string + "equipment_id" / Int32ub, #int + "enhancement_value" / Int16ub, #short + "max_enhancement_value_extended_num" / Int16ub, #short + "enhancement_exp" / Int32ub, #int + "possible_awakening_flag" / Int8ul, # result is either 0 or 1 + "awakening_stage" / Int16ub, #short + "awakening_exp" / Int32ub, #int + "property1_property_id" / Int32ub, + "property1_value1" / Int32ub, + "property1_value2" / Int32ub, + "property2_property_id" / Int32ub, + "property2_value1" / Int32ub, + "property2_value2" / Int32ub, + "property3_property_id" / Int32ub, + "property3_value1" / Int32ub, + "property3_value2" / Int32ub, + "property4_property_id" / Int32ub, + "property4_value1" / Int32ub, + "property4_value2" / Int32ub, + "converted_card_num" / Int16ub, + "shop_purchase_flag" / Int8ul, # result is either 0 or 1 + "protect_flag" / Int8ul, # result is either 0 or 1 + "get_date_size" / Int32ub, # big endian + "get_date" / Int16ul[len(self.get_date)], + ) + + # create a resp struct + resp_struct = Struct( + "result" / Int8ul, # result is either 0 or 1 + "equipment_user_data_list_size" / Rebuild(Int32ub, len_(this.equipment_user_data_list)), # big endian + "equipment_user_data_list" / Array(this.equipment_user_data_list_size, equipment_user_data_list_struct), + ) + + resp_data = resp_struct.parse(resp_struct.build(dict( + result=self.result, + equipment_user_data_list_size=0, + equipment_user_data_list=[], + ))) + + for i in range(len(self.equipment_id)): + equipment_data = dict( + user_equipment_id_size=len(self.user_equipment_id[i]) * 2, + user_equipment_id=[ord(x) for x in self.user_equipment_id[i]], + equipment_id=self.equipment_id[i], + enhancement_value=self.enhancement_value, + max_enhancement_value_extended_num=self.max_enhancement_value_extended_num, + enhancement_exp=self.enhancement_exp, + possible_awakening_flag=self.possible_awakening_flag, + awakening_stage=self.awakening_stage, + awakening_exp=self.awakening_exp, + property1_property_id=self.property1_property_id, + property1_value1=self.property1_value1, + property1_value2=self.property1_value2, + property2_property_id=self.property2_property_id, + property2_value1=self.property2_value1, + property2_value2=self.property2_value2, + property3_property_id=self.property3_property_id, + property3_value1=self.property3_value1, + property3_value2=self.property3_value2, + property4_property_id=self.property4_property_id, + property4_value1=self.property4_value1, + property4_value2=self.property4_value2, + converted_card_num=self.converted_card_num, + shop_purchase_flag=self.shop_purchase_flag, + protect_flag=self.protect_flag, + get_date_size=len(self.get_date) * 2, + get_date=[ord(x) for x in self.get_date], + + ) + + resp_data.equipment_user_data_list.append(equipment_data) + + # finally, rebuild the resp_data + resp_data = resp_struct.build(resp_data) + + self.length = len(resp_data) + return super().make() + resp_data + +class SaoGetItemUserDataListRequest(SaoBaseRequest): + def __init__(self, data: bytes) -> None: + super().__init__(data) + +class SaoGetItemUserDataListResponse(SaoBaseResponse): + def __init__(self, cmd, itemIdsData) -> None: + super().__init__(cmd) + self.result = 1 + + # item_user_data_list + self.user_item_id = list(map(str,itemIdsData)) #str + self.item_id = itemIdsData #int + self.protect_flag = 0 #byte + self.get_date = "20230101120000" #str + + def make(self) -> bytes: + #new stuff + + item_user_data_list_struct = Struct( + "user_item_id_size" / Int32ub, # big endian + "user_item_id" / Int16ul[6], #string but this will not work with 10000 IDs... only with 6 digits + "item_id" / Int32ub, #int + "protect_flag" / Int8ul, # result is either 0 or 1 + "get_date_size" / Int32ub, # big endian + "get_date" / Int16ul[len(self.get_date)], + ) + + # create a resp struct + resp_struct = Struct( + "result" / Int8ul, # result is either 0 or 1 + "item_user_data_list_size" / Rebuild(Int32ub, len_(this.item_user_data_list)), # big endian + "item_user_data_list" / Array(this.item_user_data_list_size, item_user_data_list_struct), + ) + + resp_data = resp_struct.parse(resp_struct.build(dict( + result=self.result, + item_user_data_list_size=0, + item_user_data_list=[], + ))) + + for i in range(len(self.item_id)): + item_data = dict( + user_item_id_size=len(self.user_item_id[i]) * 2, + user_item_id=[ord(x) for x in self.user_item_id[i]], + item_id=self.item_id[i], + protect_flag=self.protect_flag, + get_date_size=len(self.get_date) * 2, + get_date=[ord(x) for x in self.get_date], + + ) + + resp_data.item_user_data_list.append(item_data) + + # finally, rebuild the resp_data + resp_data = resp_struct.build(resp_data) + + self.length = len(resp_data) + return super().make() + resp_data + +class SaoGetSupportLogUserDataListRequest(SaoBaseRequest): + def __init__(self, data: bytes) -> None: + super().__init__(data) + +class SaoGetSupportLogUserDataListResponse(SaoBaseResponse): + def __init__(self, cmd, supportIdsData) -> None: + super().__init__(cmd) + self.result = 1 + + # support_log_user_data_list + self.user_support_log_id = list(map(str,supportIdsData)) #str + self.support_log_id = supportIdsData #int + self.possible_awakening_flag = 0 + self.awakening_stage = 0 + self.awakening_exp = 0 + self.converted_card_num = 0 + self.shop_purchase_flag = 0 + self.protect_flag = 0 #byte + self.get_date = "20230101120000" #str + + def make(self) -> bytes: + support_log_user_data_list_struct = Struct( + "user_support_log_id_size" / Int32ub, # big endian + "user_support_log_id" / Int16ul[9], + "support_log_id" / Int32ub, #int + "possible_awakening_flag" / Int8ul, # result is either 0 or 1 + "awakening_stage" / Int16ub, #short + "awakening_exp" / Int32ub, # int + "converted_card_num" / Int16ub, #short + "shop_purchase_flag" / Int8ul, # result is either 0 or 1 + "protect_flag" / Int8ul, # result is either 0 or 1 + "get_date_size" / Int32ub, # big endian + "get_date" / Int16ul[len(self.get_date)], + ) + + # create a resp struct + resp_struct = Struct( + "result" / Int8ul, # result is either 0 or 1 + "support_log_user_data_list_size" / Rebuild(Int32ub, len_(this.support_log_user_data_list)), # big endian + "support_log_user_data_list" / Array(this.support_log_user_data_list_size, support_log_user_data_list_struct), + ) + + resp_data = resp_struct.parse(resp_struct.build(dict( + result=self.result, + support_log_user_data_list_size=0, + support_log_user_data_list=[], + ))) + + for i in range(len(self.support_log_id)): + support_data = dict( + user_support_log_id_size=len(self.user_support_log_id[i]) * 2, + user_support_log_id=[ord(x) for x in self.user_support_log_id[i]], + support_log_id=self.support_log_id[i], + possible_awakening_flag=self.possible_awakening_flag, + awakening_stage=self.awakening_stage, + awakening_exp=self.awakening_exp, + converted_card_num=self.converted_card_num, + shop_purchase_flag=self.shop_purchase_flag, + protect_flag=self.protect_flag, + get_date_size=len(self.get_date) * 2, + get_date=[ord(x) for x in self.get_date], + + ) + + resp_data.support_log_user_data_list.append(support_data) + + resp_data = resp_struct.build(resp_data) + + self.length = len(resp_data) + return super().make() + resp_data + +class SaoGetTitleUserDataListRequest(SaoBaseRequest): + def __init__(self, data: bytes) -> None: + super().__init__(data) + +class SaoGetTitleUserDataListResponse(SaoBaseResponse): + def __init__(self, cmd, titleIdsData) -> None: + super().__init__(cmd) + self.result = 1 + + # title_user_data_list + self.user_title_id = list(map(str,titleIdsData)) #str + self.title_id = titleIdsData #int + + def make(self) -> bytes: + title_user_data_list_struct = Struct( + "user_title_id_size" / Int32ub, # big endian + "user_title_id" / Int16ul[6], #string + "title_id" / Int32ub, #int + ) + + # create a resp struct + resp_struct = Struct( + "result" / Int8ul, # result is either 0 or 1 + "title_user_data_list_size" / Rebuild(Int32ub, len_(this.title_user_data_list)), # big endian + "title_user_data_list" / Array(this.title_user_data_list_size, title_user_data_list_struct), + ) + + resp_data = resp_struct.parse(resp_struct.build(dict( + result=self.result, + title_user_data_list_size=0, + title_user_data_list=[], + ))) + + for i in range(len(self.title_id)): + title_data = dict( + user_title_id_size=len(self.user_title_id[i]) * 2, + user_title_id=[ord(x) for x in self.user_title_id[i]], + title_id=self.title_id[i], + ) + + resp_data.title_user_data_list.append(title_data) + + # finally, rebuild the resp_data + resp_data = resp_struct.build(resp_data) + + self.length = len(resp_data) + return super().make() + resp_data + +class SaoGetEpisodeAppendDataListRequest(SaoBaseRequest): + def __init__(self, data: bytes) -> None: + super().__init__(data) + +class SaoGetEpisodeAppendDataListResponse(SaoBaseResponse): + def __init__(self, cmd, profile_data) -> None: + super().__init__(cmd) + self.length = None + self.result = 1 + + self.user_episode_append_id_list = ["10001", "10002", "10003", "10004", "10005"] + self.user_id_list = [str(profile_data["user"]), str(profile_data["user"]), str(profile_data["user"]), str(profile_data["user"]), str(profile_data["user"])] + self.episode_append_id_list = [10001, 10002, 10003, 10004, 10005] + self.own_num_list = [3, 3, 3, 3 ,3] + + def make(self) -> bytes: + episode_data_struct = Struct( + "user_episode_append_id_size" / Int32ub, # big endian + "user_episode_append_id" / Int16ul[5], #forced to match the user_episode_append_id_list index which is always 5 chars for the episode ids + "user_id_size" / Int32ub, # big endian + "user_id" / Int16ul[6], # has to be exactly 6 chars in the user field... MANDATORY + "episode_append_id" / Int32ub, + "own_num" / Int32ub, + ) + + # create a resp struct + resp_struct = Struct( + "result" / Int8ul, # result is either 0 or 1 + "episode_append_data_list_size" / Rebuild(Int32ub, len_(this.episode_append_data_list)), # big endian + "episode_append_data_list" / Array(this.episode_append_data_list_size, episode_data_struct), + ) + + # really dump to parse the build resp, but that creates a new object + # and is nicer to twork with + resp_data = resp_struct.parse(resp_struct.build(dict( + result=self.result, + episode_append_data_list_size=0, + episode_append_data_list=[], + ))) + + if len(self.user_episode_append_id_list) != len(self.user_id_list) != len(self.episode_append_id_list) != len(self.own_num_list): + raise ValueError("all lists must be of the same length") + + for i in range(len(self.user_id_list)): + # add the episode_data_struct to the resp_struct.episode_append_data_list + resp_data.episode_append_data_list.append(dict( + user_episode_append_id_size=len(self.user_episode_append_id_list[i]) * 2, + user_episode_append_id=[ord(x) for x in self.user_episode_append_id_list[i]], + user_id_size=len(self.user_id_list[i]) * 2, + user_id=[ord(x) for x in self.user_id_list[i]], + episode_append_id=self.episode_append_id_list[i], + own_num=self.own_num_list[i], + )) + + # finally, rebuild the resp_data + resp_data = resp_struct.build(resp_data) + + self.length = len(resp_data) + return super().make() + resp_data + +class SaoGetPartyDataListRequest(SaoBaseRequest): + def __init__(self, data: bytes) -> None: + super().__init__(data) + +class SaoGetPartyDataListResponse(SaoBaseResponse): # Default party + def __init__(self, cmd) -> None: + super().__init__(cmd) + self.result = 1 + self.party_data_list_size = 1 # Number of arrays + + self.user_party_id = "0" + self.team_no = 0 + self.party_team_data_list_size = 3 # Number of arrays + + self.user_party_team_id_1 = "0" + self.arrangement_num_1 = 0 + self.user_hero_log_id_1 = "101000010" + self.main_weapon_user_equipment_id_1 = "101000016" + self.sub_equipment_user_equipment_id_1 = "0" + self.skill_slot1_skill_id_1 = 30086 + self.skill_slot2_skill_id_1 = 1001 + self.skill_slot3_skill_id_1 = 1002 + self.skill_slot4_skill_id_1 = 1003 + self.skill_slot5_skill_id_1 = 1005 + + self.user_party_team_id_2 = "0" + self.arrangement_num_2 = 0 + self.user_hero_log_id_2 = "102000010" + self.main_weapon_user_equipment_id_2 = "103000006" + self.sub_equipment_user_equipment_id_2 = "0" + self.skill_slot1_skill_id_2 = 30086 + self.skill_slot2_skill_id_2 = 1001 + self.skill_slot3_skill_id_2 = 1002 + self.skill_slot4_skill_id_2 = 1003 + self.skill_slot5_skill_id_2 = 1005 + + self.user_party_team_id_3 = "0" + self.arrangement_num_3 = 0 + self.user_hero_log_id_3 = "103000010" + self.main_weapon_user_equipment_id_3 = "112000009" + self.sub_equipment_user_equipment_id_3 = "0" + self.skill_slot1_skill_id_3 = 30086 + self.skill_slot2_skill_id_3 = 1001 + self.skill_slot3_skill_id_3 = 1002 + self.skill_slot4_skill_id_3 = 1003 + self.skill_slot5_skill_id_3 = 1005 + + def make(self) -> bytes: + # create a resp struct + resp_struct = Struct( + "result" / Int8ul, # result is either 0 or 1 + "party_data_list_size" / Int32ub, # big endian + + "user_party_id_size" / Int32ub, # big endian + "user_party_id" / Int16ul[len(self.user_party_id)], + "team_no" / Int8ul, # result is either 0 or 1 + "party_team_data_list_size" / Int32ub, # big endian + + "user_party_team_id_1_size" / Int32ub, # big endian + "user_party_team_id_1" / Int16ul[len(self.user_party_team_id_1)], + "arrangement_num_1" / Int8ul, # big endian + "user_hero_log_id_1_size" / Int32ub, # big endian + "user_hero_log_id_1" / Int16ul[len(self.user_hero_log_id_1)], + "main_weapon_user_equipment_id_1_size" / Int32ub, # big endian + "main_weapon_user_equipment_id_1" / Int16ul[len(self.main_weapon_user_equipment_id_1)], + "sub_equipment_user_equipment_id_1_size" / Int32ub, # big endian + "sub_equipment_user_equipment_id_1" / Int16ul[len(self.sub_equipment_user_equipment_id_1)], + "skill_slot1_skill_id_1" / Int32ub, + "skill_slot2_skill_id_1" / Int32ub, + "skill_slot3_skill_id_1" / Int32ub, + "skill_slot4_skill_id_1" / Int32ub, + "skill_slot5_skill_id_1" / Int32ub, + + "user_party_team_id_2_size" / Int32ub, # big endian + "user_party_team_id_2" / Int16ul[len(self.user_party_team_id_2)], + "arrangement_num_2" / Int8ul, # result is either 0 or 1 + "user_hero_log_id_2_size" / Int32ub, # big endian + "user_hero_log_id_2" / Int16ul[len(self.user_hero_log_id_2)], + "main_weapon_user_equipment_id_2_size" / Int32ub, # big endian + "main_weapon_user_equipment_id_2" / Int16ul[len(self.main_weapon_user_equipment_id_2)], + "sub_equipment_user_equipment_id_2_size" / Int32ub, # big endian + "sub_equipment_user_equipment_id_2" / Int16ul[len(self.sub_equipment_user_equipment_id_2)], + "skill_slot1_skill_id_2" / Int32ub, + "skill_slot2_skill_id_2" / Int32ub, + "skill_slot3_skill_id_2" / Int32ub, + "skill_slot4_skill_id_2" / Int32ub, + "skill_slot5_skill_id_2" / Int32ub, + + "user_party_team_id_3_size" / Int32ub, # big endian + "user_party_team_id_3" / Int16ul[len(self.user_party_team_id_3)], + "arrangement_num_3" / Int8ul, # result is either 0 or 1 + "user_hero_log_id_3_size" / Int32ub, # big endian + "user_hero_log_id_3" / Int16ul[len(self.user_hero_log_id_3)], + "main_weapon_user_equipment_id_3_size" / Int32ub, # big endian + "main_weapon_user_equipment_id_3" / Int16ul[len(self.main_weapon_user_equipment_id_3)], + "sub_equipment_user_equipment_id_3_size" / Int32ub, # big endian + "sub_equipment_user_equipment_id_3" / Int16ul[len(self.sub_equipment_user_equipment_id_3)], + "skill_slot1_skill_id_3" / Int32ub, + "skill_slot2_skill_id_3" / Int32ub, + "skill_slot3_skill_id_3" / Int32ub, + "skill_slot4_skill_id_3" / Int32ub, + "skill_slot5_skill_id_3" / Int32ub, + + ) + + resp_data = resp_struct.build(dict( + result=self.result, + party_data_list_size=self.party_data_list_size, + + user_party_id_size=len(self.user_party_id) * 2, + user_party_id=[ord(x) for x in self.user_party_id], + team_no=self.team_no, + party_team_data_list_size=self.party_team_data_list_size, + + user_party_team_id_1_size=len(self.user_party_team_id_1) * 2, + user_party_team_id_1=[ord(x) for x in self.user_party_team_id_1], + arrangement_num_1=self.arrangement_num_1, + user_hero_log_id_1_size=len(self.user_hero_log_id_1) * 2, + user_hero_log_id_1=[ord(x) for x in self.user_hero_log_id_1], + main_weapon_user_equipment_id_1_size=len(self.main_weapon_user_equipment_id_1) * 2, + main_weapon_user_equipment_id_1=[ord(x) for x in self.main_weapon_user_equipment_id_1], + sub_equipment_user_equipment_id_1_size=len(self.sub_equipment_user_equipment_id_1) * 2, + sub_equipment_user_equipment_id_1=[ord(x) for x in self.sub_equipment_user_equipment_id_1], + skill_slot1_skill_id_1=self.skill_slot1_skill_id_1, + skill_slot2_skill_id_1=self.skill_slot2_skill_id_1, + skill_slot3_skill_id_1=self.skill_slot3_skill_id_1, + skill_slot4_skill_id_1=self.skill_slot4_skill_id_1, + skill_slot5_skill_id_1=self.skill_slot5_skill_id_1, + + user_party_team_id_2_size=len(self.user_party_team_id_2) * 2, + user_party_team_id_2=[ord(x) for x in self.user_party_team_id_2], + arrangement_num_2=self.arrangement_num_2, + user_hero_log_id_2_size=len(self.user_hero_log_id_2) * 2, + user_hero_log_id_2=[ord(x) for x in self.user_hero_log_id_2], + main_weapon_user_equipment_id_2_size=len(self.main_weapon_user_equipment_id_2) * 2, + main_weapon_user_equipment_id_2=[ord(x) for x in self.main_weapon_user_equipment_id_2], + sub_equipment_user_equipment_id_2_size=len(self.sub_equipment_user_equipment_id_2) * 2, + sub_equipment_user_equipment_id_2=[ord(x) for x in self.sub_equipment_user_equipment_id_2], + skill_slot1_skill_id_2=self.skill_slot1_skill_id_2, + skill_slot2_skill_id_2=self.skill_slot2_skill_id_2, + skill_slot3_skill_id_2=self.skill_slot3_skill_id_2, + skill_slot4_skill_id_2=self.skill_slot4_skill_id_2, + skill_slot5_skill_id_2=self.skill_slot5_skill_id_2, + + user_party_team_id_3_size=len(self.user_party_team_id_3) * 2, + user_party_team_id_3=[ord(x) for x in self.user_party_team_id_3], + arrangement_num_3=self.arrangement_num_3, + user_hero_log_id_3_size=len(self.user_hero_log_id_3) * 2, + user_hero_log_id_3=[ord(x) for x in self.user_hero_log_id_3], + main_weapon_user_equipment_id_3_size=len(self.main_weapon_user_equipment_id_3) * 2, + main_weapon_user_equipment_id_3=[ord(x) for x in self.main_weapon_user_equipment_id_3], + sub_equipment_user_equipment_id_3_size=len(self.sub_equipment_user_equipment_id_3) * 2, + sub_equipment_user_equipment_id_3=[ord(x) for x in self.sub_equipment_user_equipment_id_3], + skill_slot1_skill_id_3=self.skill_slot1_skill_id_3, + skill_slot2_skill_id_3=self.skill_slot2_skill_id_3, + skill_slot3_skill_id_3=self.skill_slot3_skill_id_3, + skill_slot4_skill_id_3=self.skill_slot4_skill_id_3, + skill_slot5_skill_id_3=self.skill_slot5_skill_id_3, + )) + + self.length = len(resp_data) + return super().make() + resp_data + +class SaoGetQuestScenePrevScanProfileCardRequest(SaoBaseRequest): + def __init__(self, data: bytes) -> None: + super().__init__(data) + +class SaoGetQuestScenePrevScanProfileCardResponse(SaoBaseResponse): + def __init__(self, cmd) -> None: + super().__init__(cmd) + self.result = 1 + self.profile_card_data = 1 # number of arrays + + self.profile_card_code = "" + self.nick_name = "" + + def make(self) -> bytes: + # create a resp struct + resp_struct = Struct( + "result" / Int8ul, # result is either 0 or 1 + "profile_card_data" / Int32ub, # big endian + + "profile_card_code_size" / Int32ub, # big endian + "profile_card_code" / Int16ul[len(self.profile_card_code)], + "nick_name_size" / Int32ub, # big endian + "nick_name" / Int16ul[len(self.nick_name)], + "rank_num" / Int16ub, #short + "setting_title_id" / Int32ub, # int + "skill_id" / Int16ub, # short + "hero_log_hero_log_id" / Int32ub, # int + "hero_log_log_level" / Int16ub, # short + "hero_log_awakening_stage" / Int16ub, # short + "hero_log_property1_property_id" / Int32ub, # int + "hero_log_property1_value1" / Int32ub, # int + "hero_log_property1_value2" / Int32ub, # int + "hero_log_property2_property_id" / Int32ub, # int + "hero_log_property2_value1" / Int32ub, # int + "hero_log_property2_value2" / Int32ub, # int + "hero_log_property3_property_id" / Int32ub, # int + "hero_log_property3_value1" / Int32ub, # int + "hero_log_property3_value2" / Int32ub, # int + "hero_log_property4_property_id" / Int32ub, # int + "hero_log_property4_value1" / Int32ub, # int + "hero_log_property4_value2" / Int32ub, # int + "main_weapon_equipment_id" / Int32ub, # int + "main_weapon_enhancement_value" / Int16ub, # short + "main_weapon_awakening_stage" / Int16ub, # short + "main_weapon_property1_property_id" / Int32ub, # int + "main_weapon_property1_value1" / Int32ub, # int + "main_weapon_property1_value2" / Int32ub, # int + "main_weapon_property2_property_id" / Int32ub, # int + "main_weapon_property2_value1" / Int32ub, # int + "main_weapon_property2_value2" / Int32ub, # int + "main_weapon_property3_property_id" / Int32ub, # int + "main_weapon_property3_value1" / Int32ub, # int + "main_weapon_property3_value2" / Int32ub, # int + "main_weapon_property4_property_id" / Int32ub, # int + "main_weapon_property4_value1" / Int32ub, # int + "main_weapon_property4_value2" / Int32ub, # int + "sub_equipment_equipment_id" / Int32ub, # int + "sub_equipment_enhancement_value" / Int16ub, # short + "sub_equipment_awakening_stage" / Int16ub, # short + "sub_equipment_property1_property_id" / Int32ub, # int + "sub_equipment_property1_value1" / Int32ub, # int + "sub_equipment_property1_value2" / Int32ub, # int + "sub_equipment_property2_property_id" / Int32ub, # int + "sub_equipment_property2_value1" / Int32ub, # int + "sub_equipment_property2_value2" / Int32ub, # int + "sub_equipment_property3_property_id" / Int32ub, # int + "sub_equipment_property3_value1" / Int32ub, # int + "sub_equipment_property3_value2" / Int32ub, # int + "sub_equipment_property4_property_id" / Int32ub, # int + "sub_equipment_property4_value1" / Int32ub, # int + "sub_equipment_property4_value2" / Int32ub, # int + "holographic_flag" / Int8ul, # result is either 0 or 1 + ) + + resp_data = resp_struct.build(dict( + result=self.result, + profile_card_data=self.profile_card_data, + + profile_card_code_size=len(self.profile_card_code) * 2, + profile_card_code=[ord(x) for x in self.profile_card_code], + nick_name_size=len(self.nick_name) * 2, + nick_name=[ord(x) for x in self.nick_name], + rank_num=0, + setting_title_id=0, + skill_id=0, + hero_log_hero_log_id=0, + hero_log_log_level=0, + hero_log_awakening_stage=0, + hero_log_property1_property_id=0, + hero_log_property1_value1=0, + hero_log_property1_value2=0, + hero_log_property2_property_id=0, + hero_log_property2_value1=0, + hero_log_property2_value2=0, + hero_log_property3_property_id=0, + hero_log_property3_value1=0, + hero_log_property3_value2=0, + hero_log_property4_property_id=0, + hero_log_property4_value1=0, + hero_log_property4_value2=0, + main_weapon_equipment_id=0, + main_weapon_enhancement_value=0, + main_weapon_awakening_stage=0, + main_weapon_property1_property_id=0, + main_weapon_property1_value1=0, + main_weapon_property1_value2=0, + main_weapon_property2_property_id=0, + main_weapon_property2_value1=0, + main_weapon_property2_value2=0, + main_weapon_property3_property_id=0, + main_weapon_property3_value1=0, + main_weapon_property3_value2=0, + main_weapon_property4_property_id=0, + main_weapon_property4_value1=0, + main_weapon_property4_value2=0, + sub_equipment_equipment_id=0, + sub_equipment_enhancement_value=0, + sub_equipment_awakening_stage=0, + sub_equipment_property1_property_id=0, + sub_equipment_property1_value1=0, + sub_equipment_property1_value2=0, + sub_equipment_property2_property_id=0, + sub_equipment_property2_value1=0, + sub_equipment_property2_value2=0, + sub_equipment_property3_property_id=0, + sub_equipment_property3_value1=0, + sub_equipment_property3_value2=0, + sub_equipment_property4_property_id=0, + sub_equipment_property4_value1=0, + sub_equipment_property4_value2=0, + holographic_flag=0, + + )) + + self.length = len(resp_data) + return super().make() + resp_data + +class SaoGetResourcePathInfoRequest(SaoBaseRequest): + def __init__(self, data: bytes) -> None: + super().__init__(data) + +class SaoGetResourcePathInfoResponse(SaoBaseResponse): + def __init__(self, cmd) -> None: + super().__init__(cmd) + self.result = 1 + self.resource_base_url = "http://localhost:9000/SDEW/100/" + self.gasha_base_dir = "a" + self.ad_base_dir = "b" + self.event_base_dir = "c" + + def make(self) -> bytes: + # create a resp struct + resp_struct = Struct( + "result" / Int8ul, # result is either 0 or 1 + "resource_base_url_size" / Int32ub, # big endian + "resource_base_url" / Int16ul[len(self.resource_base_url)], + "gasha_base_dir_size" / Int32ub, # big endian + "gasha_base_dir" / Int16ul[len(self.gasha_base_dir)], + "ad_base_dir_size" / Int32ub, # big endian + "ad_base_dir" / Int16ul[len(self.ad_base_dir)], + "event_base_dir_size" / Int32ub, # big endian + "event_base_dir" / Int16ul[len(self.event_base_dir)], + ) + + resp_data = resp_struct.build(dict( + result=self.result, + resource_base_url_size=len(self.resource_base_url) * 2, + resource_base_url=[ord(x) for x in self.resource_base_url], + gasha_base_dir_size=len(self.gasha_base_dir) * 2, + gasha_base_dir=[ord(x) for x in self.gasha_base_dir], + ad_base_dir_size=len(self.ad_base_dir) * 2, + ad_base_dir=[ord(x) for x in self.ad_base_dir], + event_base_dir_size=len(self.event_base_dir) * 2, + event_base_dir=[ord(x) for x in self.event_base_dir], + )) + + self.length = len(resp_data) + return super().make() + resp_data + +class SaoEpisodePlayStartRequest(SaoBaseRequest): + def __init__(self, data: bytes) -> None: + super().__init__(data) + +class SaoEpisodePlayStartResponse(SaoBaseResponse): + def __init__(self, cmd, profile_data) -> None: + super().__init__(cmd) + self.result = 1 + self.play_start_response_data_size = 1 # Number of arrays (minimum 1 mandatory) + self.multi_play_start_response_data_size = 0 # Number of arrays (set 0 due to single play) + + self.appearance_player_trace_data_list_size = 1 + + self.user_quest_scene_player_trace_id = "1003" + self.nick_name = profile_data["nick_name"] + + def make(self) -> bytes: + # create a resp struct + resp_struct = Struct( + "result" / Int8ul, # result is either 0 or 1 + "play_start_response_data_size" / Int32ub, + "multi_play_start_response_data_size" / Int32ub, + + "appearance_player_trace_data_list_size" / Int32ub, + + "user_quest_scene_player_trace_id_size" / Int32ub, # big endian + "user_quest_scene_player_trace_id" / Int16ul[len(self.user_quest_scene_player_trace_id)], + "nick_name_size" / Int32ub, # big endian + "nick_name" / Int16ul[len(self.nick_name)], + ) + + resp_data = resp_struct.build(dict( + result=self.result, + play_start_response_data_size=self.play_start_response_data_size, + multi_play_start_response_data_size=self.multi_play_start_response_data_size, + + appearance_player_trace_data_list_size=self.appearance_player_trace_data_list_size, + + user_quest_scene_player_trace_id_size=len(self.user_quest_scene_player_trace_id) * 2, + user_quest_scene_player_trace_id=[ord(x) for x in self.user_quest_scene_player_trace_id], + nick_name_size=len(self.nick_name) * 2, + nick_name=[ord(x) for x in self.nick_name], + )) + + self.length = len(resp_data) + return super().make() + resp_data + +class SaoEpisodePlayEndRequest(SaoBaseRequest): + def __init__(self, data: bytes) -> None: + super().__init__(data) + +class SaoEpisodePlayEndResponse(SaoBaseResponse): + def __init__(self, cmd) -> None: + super().__init__(cmd) + self.result = 1 + self.play_end_response_data_size = 1 # Number of arrays + self.multi_play_end_response_data_size = 1 # Unused on solo play + + self.dummy_1 = 0 + self.dummy_2 = 0 + self.dummy_3 = 0 + + self.rarity_up_occurrence_flag = 0 + self.adventure_ex_area_occurrences_flag = 0 + self.ex_bonus_data_list_size = 1 # Number of arrays + self.play_end_player_trace_reward_data_list_size = 0 # Number of arrays + + self.ex_bonus_table_id = 0 # ExBonusTable.csv values, dont care for now + self.achievement_status = 1 + + self.common_reward_data_size = 1 # Number of arrays + + self.common_reward_type = 0 # dummy values from 2,101000000,1 from RewardTable.csv + self.common_reward_id = 0 + self.common_reward_num = 0 + + def make(self) -> bytes: + # create a resp struct + resp_struct = Struct( + "result" / Int8ul, # result is either 0 or 1 + "play_end_response_data_size" / Int32ub, # big endian + + "rarity_up_occurrence_flag" / Int8ul, # result is either 0 or 1 + "adventure_ex_area_occurrences_flag" / Int8ul, # result is either 0 or 1 + "ex_bonus_data_list_size" / Int32ub, # big endian + "play_end_player_trace_reward_data_list_size" / Int32ub, # big endian + + # ex_bonus_data_list + "ex_bonus_table_id" / Int32ub, + "achievement_status" / Int8ul, # result is either 0 or 1 + + # play_end_player_trace_reward_data_list + "common_reward_data_size" / Int32ub, + + # common_reward_data + "common_reward_type" / Int16ub, # short + "common_reward_id" / Int32ub, + "common_reward_num" / Int32ub, + + "multi_play_end_response_data_size" / Int32ub, # big endian + + # multi_play_end_response_data + "dummy_1" / Int8ul, # result is either 0 or 1 + "dummy_2" / Int8ul, # result is either 0 or 1 + "dummy_3" / Int8ul, # result is either 0 or 1 + ) + + resp_data = resp_struct.build(dict( + result=self.result, + play_end_response_data_size=self.play_end_response_data_size, + + rarity_up_occurrence_flag=self.rarity_up_occurrence_flag, + adventure_ex_area_occurrences_flag=self.adventure_ex_area_occurrences_flag, + ex_bonus_data_list_size=self.ex_bonus_data_list_size, + play_end_player_trace_reward_data_list_size=self.play_end_player_trace_reward_data_list_size, + + ex_bonus_table_id=self.ex_bonus_table_id, + achievement_status=self.achievement_status, + + common_reward_data_size=self.common_reward_data_size, + + common_reward_type=self.common_reward_type, + common_reward_id=self.common_reward_id, + common_reward_num=self.common_reward_num, + + multi_play_end_response_data_size=self.multi_play_end_response_data_size, + + dummy_1=self.dummy_1, + dummy_2=self.dummy_2, + dummy_3=self.dummy_3, + )) + + self.length = len(resp_data) + return super().make() + resp_data + +class SaoEpisodePlayEndUnanalyzedLogFixedRequest(SaoBaseRequest): + def __init__(self, data: bytes) -> None: + super().__init__(data) + +class SaoEpisodePlayEndUnanalyzedLogFixedResponse(SaoBaseResponse): + def __init__(self, cmd) -> None: + super().__init__(cmd) + self.result = 1 + self.play_end_unanalyzed_log_reward_data_list_size = 1 # Number of arrays + + self.unanalyzed_log_grade_id = 3 # RewardTable.csv + self.common_reward_data_size = 1 + + self.common_reward_type_1 = 1 + self.common_reward_id_1 = 102000070 + self.common_reward_num_1 = 1 + + def make(self) -> bytes: + # create a resp struct + resp_struct = Struct( + "result" / Int8ul, # result is either 0 or 1 + "play_end_unanalyzed_log_reward_data_list_size" / Int32ub, # big endian + + "unanalyzed_log_grade_id" / Int32ub, + "common_reward_data_size" / Int32ub, + + "common_reward_type_1" / Int16ub, + "common_reward_id_1" / Int32ub, + "common_reward_num_1" / Int32ub, + ) + + resp_data = resp_struct.build(dict( + result=self.result, + play_end_unanalyzed_log_reward_data_list_size=self.play_end_unanalyzed_log_reward_data_list_size, + + unanalyzed_log_grade_id=self.unanalyzed_log_grade_id, + common_reward_data_size=self.common_reward_data_size, + + common_reward_type_1=self.common_reward_type_1, + common_reward_id_1=self.common_reward_id_1, + common_reward_num_1=self.common_reward_num_1, + )) + + self.length = len(resp_data) + return super().make() + resp_data + +class SaoGetQuestSceneUserDataListRequest(SaoBaseRequest): + def __init__(self, data: bytes) -> None: + super().__init__(data) + +class SaoGetQuestSceneUserDataListResponse(SaoBaseResponse): + def __init__(self, cmd, questIdsData) -> None: + super().__init__(cmd) + self.result = 1 + + # quest_scene_user_data_list_size + self.quest_type = [1] * len(questIdsData) + self.quest_scene_id = questIdsData + self.clear_flag = [1] * len(questIdsData) + + # quest_scene_best_score_user_data + self.clear_time = 300 + self.combo_num = 0 + self.total_damage = "0" + self.concurrent_destroying_num = 1 + + # quest_scene_ex_bonus_user_data_list + self.achievement_flag = [[1, 1, 1],[1, 1, 1]] + self.ex_bonus_table_id = [[1, 2, 3],[4, 5, 6]] + + def make(self) -> bytes: + #new stuff + quest_scene_ex_bonus_user_data_list_struct = Struct( + "achievement_flag" / Int32ub, # big endian + "ex_bonus_table_id" / Int32ub, # big endian + ) + + quest_scene_best_score_user_data_struct = Struct( + "clear_time" / Int32ub, # big endian + "combo_num" / Int32ub, # big endian + "total_damage_size" / Int32ub, # big endian + "total_damage" / Int16ul[len(self.total_damage)], + "concurrent_destroying_num" / Int16ub, + ) + + quest_scene_user_data_list_struct = Struct( + "quest_type" / Int8ul, # result is either 0 or 1 + "quest_scene_id" / Int16ub, #short + "clear_flag" / Int8ul, # result is either 0 or 1 + "quest_scene_best_score_user_data_size" / Rebuild(Int32ub, len_(this.quest_scene_best_score_user_data)), # big endian + "quest_scene_best_score_user_data" / Array(this.quest_scene_best_score_user_data_size, quest_scene_best_score_user_data_struct), + "quest_scene_ex_bonus_user_data_list_size" / Rebuild(Int32ub, len_(this.quest_scene_ex_bonus_user_data_list)), # big endian + "quest_scene_ex_bonus_user_data_list" / Array(this.quest_scene_ex_bonus_user_data_list_size, quest_scene_ex_bonus_user_data_list_struct), + ) + + # create a resp struct + resp_struct = Struct( + "result" / Int8ul, # result is either 0 or 1 + "quest_scene_user_data_list_size" / Rebuild(Int32ub, len_(this.quest_scene_user_data_list)), # big endian + "quest_scene_user_data_list" / Array(this.quest_scene_user_data_list_size, quest_scene_user_data_list_struct), + ) + + resp_data = resp_struct.parse(resp_struct.build(dict( + result=self.result, + quest_scene_user_data_list_size=0, + quest_scene_user_data_list=[], + ))) + + for i in range(len(self.quest_scene_id)): + quest_data = dict( + quest_type=self.quest_type[i], + quest_scene_id=self.quest_scene_id[i], + clear_flag=self.clear_flag[i], + + quest_scene_best_score_user_data_size=0, + quest_scene_best_score_user_data=[], + quest_scene_ex_bonus_user_data_list_size=0, + quest_scene_ex_bonus_user_data_list=[], + ) + + quest_data["quest_scene_best_score_user_data"].append(dict( + clear_time=self.clear_time, + combo_num=self.combo_num, + total_damage_size=len(self.total_damage) * 2, + total_damage=[ord(x) for x in self.total_damage], + concurrent_destroying_num=self.concurrent_destroying_num, + )) + + ''' + quest_data["quest_scene_ex_bonus_user_data_list"].append(dict( + ex_bonus_table_id=self.ex_bonus_table_id[i], + achievement_flag=self.achievement_flag[i], + )) + ''' + + resp_data.quest_scene_user_data_list.append(quest_data) + + # finally, rebuild the resp_data + resp_data = resp_struct.build(resp_data) + + self.length = len(resp_data) + return super().make() + resp_data + +class SaoCheckYuiMedalGetConditionRequest(SaoBaseRequest): + def __init__(self, data: bytes) -> None: + super().__init__(data) + +class SaoCheckYuiMedalGetConditionResponse(SaoBaseResponse): + def __init__(self, cmd) -> None: + super().__init__(cmd) + self.result = 1 + self.get_flag = 1 + self.elapsed_days = 1 + self.get_yui_medal_num = 1 + + def make(self) -> bytes: + # create a resp struct + resp_struct = Struct( + "result" / Int8ul, # result is either 0 or 1 + "get_flag" / Int8ul, # result is either 0 or 1 + "elapsed_days" / Int16ub, #short + "get_yui_medal_num" / Int16ub, #short + ) + + resp_data = resp_struct.build(dict( + result=self.result, + get_flag=self.get_flag, + elapsed_days=self.elapsed_days, + get_yui_medal_num=self.get_yui_medal_num, + )) + + self.length = len(resp_data) + return super().make() + resp_data + +class SaoGetYuiMedalBonusUserDataRequest(SaoBaseRequest): + def __init__(self, data: bytes) -> None: + super().__init__(data) + +class SaoGetYuiMedalBonusUserDataResponse(SaoBaseResponse): + def __init__(self, cmd) -> None: + super().__init__(cmd) + self.result = 1 + self.data_size = 1 # number of arrays + + self.elapsed_days = 1 + self.loop_num = 1 + self.last_check_date = "20230520193000" + self.last_get_date = "20230520193000" + + def make(self) -> bytes: + # create a resp struct + resp_struct = Struct( + "result" / Int8ul, # result is either 0 or 1 + "data_size" / Int32ub, # big endian + + "elapsed_days" / Int32ub, # big endian + "loop_num" / Int32ub, # big endian + "last_check_date_size" / Int32ub, # big endian + "last_check_date" / Int16ul[len(self.last_check_date)], + "last_get_date_size" / Int32ub, # big endian + "last_get_date" / Int16ul[len(self.last_get_date)], + ) + + resp_data = resp_struct.build(dict( + result=self.result, + data_size=self.data_size, + + elapsed_days=self.elapsed_days, + loop_num=self.loop_num, + last_check_date_size=len(self.last_check_date) * 2, + last_check_date=[ord(x) for x in self.last_check_date], + last_get_date_size=len(self.last_get_date) * 2, + last_get_date=[ord(x) for x in self.last_get_date], + + )) + + self.length = len(resp_data) + return super().make() + resp_data + +class SaoCheckProfileCardUsedRewardRequest(SaoBaseRequest): + def __init__(self, data: bytes) -> None: + super().__init__(data) + +class SaoCheckProfileCardUsedRewardResponse(SaoBaseResponse): + def __init__(self, cmd) -> None: + super().__init__(cmd) + self.result = 1 + self.get_flag = 1 + self.used_num = 0 + self.get_vp = 1 + + def make(self) -> bytes: + # create a resp struct + resp_struct = Struct( + "result" / Int8ul, # result is either 0 or 1 + "get_flag" / Int8ul, # result is either 0 or 1 + "used_num" / Int32ub, # big endian + "get_vp" / Int32ub, # big endian + ) + + resp_data = resp_struct.build(dict( + result=self.result, + get_flag=self.get_flag, + used_num=self.used_num, + get_vp=self.get_vp, + + )) + + self.length = len(resp_data) + return super().make() + resp_data \ No newline at end of file diff --git a/titles/sao/index.py b/titles/sao/index.py new file mode 100644 index 0000000..2c903ce --- /dev/null +++ b/titles/sao/index.py @@ -0,0 +1,117 @@ +from typing import Tuple +from twisted.web.http import Request +from twisted.web import resource +import json, ast +from datetime import datetime +import yaml +import logging, coloredlogs +from logging.handlers import TimedRotatingFileHandler +import inflection +from os import path + +from core import CoreConfig, Utils +from titles.sao.config import SaoConfig +from titles.sao.const import SaoConstants +from titles.sao.base import SaoBase +from titles.sao.handlers.base import * + + +class SaoServlet(resource.Resource): + def __init__(self, core_cfg: CoreConfig, cfg_dir: str) -> None: + self.isLeaf = True + self.core_cfg = core_cfg + self.config_dir = cfg_dir + self.game_cfg = SaoConfig() + if path.exists(f"{cfg_dir}/sao.yaml"): + self.game_cfg.update(yaml.safe_load(open(f"{cfg_dir}/sao.yaml"))) + + self.logger = logging.getLogger("sao") + if not hasattr(self.logger, "inited"): + log_fmt_str = "[%(asctime)s] SAO | %(levelname)s | %(message)s" + log_fmt = logging.Formatter(log_fmt_str) + fileHandler = TimedRotatingFileHandler( + "{0}/{1}.log".format(self.core_cfg.server.log_dir, "sao"), + encoding="utf8", + when="d", + backupCount=10, + ) + + fileHandler.setFormatter(log_fmt) + + consoleHandler = logging.StreamHandler() + consoleHandler.setFormatter(log_fmt) + + self.logger.addHandler(fileHandler) + self.logger.addHandler(consoleHandler) + + self.logger.setLevel(self.game_cfg.server.loglevel) + coloredlogs.install( + level=self.game_cfg.server.loglevel, logger=self.logger, fmt=log_fmt_str + ) + self.logger.inited = True + + self.base = SaoBase(core_cfg, self.game_cfg) + + @classmethod + def get_allnet_info( + cls, game_code: str, core_cfg: CoreConfig, cfg_dir: str + ) -> Tuple[bool, str, str]: + game_cfg = SaoConfig() + + if path.exists(f"{cfg_dir}/{SaoConstants.CONFIG_NAME}"): + game_cfg.update( + yaml.safe_load(open(f"{cfg_dir}/{SaoConstants.CONFIG_NAME}")) + ) + + if not game_cfg.server.enable: + return (False, "", "") + + return ( + True, + f"http://{game_cfg.server.hostname}:{game_cfg.server.port}/{game_code}/$v/", + f"{game_cfg.server.hostname}/SDEW/$v/", + ) + + @classmethod + def get_mucha_info( + cls, core_cfg: CoreConfig, cfg_dir: str + ) -> Tuple[bool, str, str]: + game_cfg = SaoConfig() + + if path.exists(f"{cfg_dir}/{SaoConstants.CONFIG_NAME}"): + game_cfg.update( + yaml.safe_load(open(f"{cfg_dir}/{SaoConstants.CONFIG_NAME}")) + ) + + if not game_cfg.server.enable: + return (False, "") + + return (True, "SAO1") + + def setup(self) -> None: + pass + + def render_POST( + self, request: Request, version: int = 0, endpoints: str = "" + ) -> bytes: + req_url = request.uri.decode() + if req_url == "/matching": + self.logger.info("Matching request") + + request.responseHeaders.addRawHeader(b"content-type", b"text/html; charset=utf-8") + + sao_request = request.content.getvalue().hex() + #sao_request = sao_request[:32] + + handler = getattr(self.base, f"handle_{sao_request[:4]}", None) + if handler is None: + self.logger.info(f"Generic Handler for {req_url} - {sao_request[:4]}") + #self.logger.debug(f"Request: {request.content.getvalue().hex()}") + resp = SaoNoopResponse(int.from_bytes(bytes.fromhex(sao_request[:4]), "big")+1) + self.logger.debug(f"Response: {resp.make().hex()}") + return resp.make() + + self.logger.info(f"Handler {req_url} - {sao_request[:4]} request") + self.logger.debug(f"Request: {request.content.getvalue().hex()}") + self.logger.debug(f"Response: {handler(sao_request).hex()}") + return handler(sao_request) \ No newline at end of file diff --git a/titles/sao/read.py b/titles/sao/read.py new file mode 100644 index 0000000..5fc9804 --- /dev/null +++ b/titles/sao/read.py @@ -0,0 +1,230 @@ +from typing import Optional, Dict, List +from os import walk, path +import urllib +import csv + +from read import BaseReader +from core.config import CoreConfig +from titles.sao.database import SaoData +from titles.sao.const import SaoConstants + + +class SaoReader(BaseReader): + def __init__( + self, + config: CoreConfig, + version: int, + bin_arg: Optional[str], + opt_arg: Optional[str], + extra: Optional[str], + ) -> None: + super().__init__(config, version, bin_arg, opt_arg, extra) + self.data = SaoData(config) + + try: + self.logger.info( + f"Start importer for {SaoConstants.game_ver_to_string(version)}" + ) + except IndexError: + self.logger.error(f"Invalid project SAO version {version}") + exit(1) + + def read(self) -> None: + pull_bin_ram = True + + if not path.exists(f"{self.bin_dir}"): + self.logger.warn(f"Couldn't find csv file in {self.bin_dir}, skipping") + pull_bin_ram = False + + if pull_bin_ram: + self.read_csv(f"{self.bin_dir}") + + def read_csv(self, bin_dir: str) -> None: + self.logger.info(f"Read csv from {bin_dir}") + + self.logger.info("Now reading QuestScene.csv") + try: + fullPath = bin_dir + "/QuestScene.csv" + with open(fullPath, encoding="UTF-8") as fp: + reader = csv.DictReader(fp) + for row in reader: + questSceneId = row["QuestSceneId"] + sortNo = row["SortNo"] + name = row["Name"] + enabled = True + + self.logger.info(f"Added quest {questSceneId} | Name: {name}") + + try: + self.data.static.put_quest( + questSceneId, + 0, + sortNo, + name, + enabled + ) + except Exception as err: + print(err) + except: + self.logger.warn(f"Couldn't read csv file in {self.bin_dir}, skipping") + + self.logger.info("Now reading HeroLog.csv") + try: + fullPath = bin_dir + "/HeroLog.csv" + with open(fullPath, encoding="UTF-8") as fp: + reader = csv.DictReader(fp) + for row in reader: + heroLogId = row["HeroLogId"] + name = row["Name"] + nickname = row["Nickname"] + rarity = row["Rarity"] + skillTableSubId = row["SkillTableSubId"] + awakeningExp = row["AwakeningExp"] + flavorText = row["FlavorText"] + enabled = True + + self.logger.info(f"Added hero {heroLogId} | Name: {name}") + + try: + self.data.static.put_hero( + 0, + heroLogId, + name, + nickname, + rarity, + skillTableSubId, + awakeningExp, + flavorText, + enabled + ) + except Exception as err: + print(err) + except: + self.logger.warn(f"Couldn't read csv file in {self.bin_dir}, skipping") + + self.logger.info("Now reading Equipment.csv") + try: + fullPath = bin_dir + "/Equipment.csv" + with open(fullPath, encoding="UTF-8") as fp: + reader = csv.DictReader(fp) + for row in reader: + equipmentId = row["EquipmentId"] + equipmentType = row["EquipmentType"] + weaponTypeId = row["WeaponTypeId"] + name = row["Name"] + rarity = row["Rarity"] + flavorText = row["FlavorText"] + enabled = True + + self.logger.info(f"Added equipment {equipmentId} | Name: {name}") + + try: + self.data.static.put_equipment( + 0, + equipmentId, + name, + equipmentType, + weaponTypeId, + rarity, + flavorText, + enabled + ) + except Exception as err: + print(err) + except: + self.logger.warn(f"Couldn't read csv file in {self.bin_dir}, skipping") + + self.logger.info("Now reading Item.csv") + try: + fullPath = bin_dir + "/Item.csv" + with open(fullPath, encoding="UTF-8") as fp: + reader = csv.DictReader(fp) + for row in reader: + itemId = row["ItemId"] + itemTypeId = row["ItemTypeId"] + name = row["Name"] + rarity = row["Rarity"] + flavorText = row["FlavorText"] + enabled = True + + self.logger.info(f"Added item {itemId} | Name: {name}") + + try: + self.data.static.put_item( + 0, + itemId, + name, + itemTypeId, + rarity, + flavorText, + enabled + ) + except Exception as err: + print(err) + except: + self.logger.warn(f"Couldn't read csv file in {self.bin_dir}, skipping") + + self.logger.info("Now reading SupportLog.csv") + try: + fullPath = bin_dir + "/SupportLog.csv" + with open(fullPath, encoding="UTF-8") as fp: + reader = csv.DictReader(fp) + for row in reader: + supportLogId = row["SupportLogId"] + charaId = row["CharaId"] + name = row["Name"] + rarity = row["Rarity"] + salePrice = row["SalePrice"] + skillName = row["SkillName"] + enabled = True + + self.logger.info(f"Added support log {supportLogId} | Name: {name}") + + try: + self.data.static.put_support_log( + 0, + supportLogId, + charaId, + name, + rarity, + salePrice, + skillName, + enabled + ) + except Exception as err: + print(err) + except: + self.logger.warn(f"Couldn't read csv file in {self.bin_dir}, skipping") + + self.logger.info("Now reading Title.csv") + try: + fullPath = bin_dir + "/Title.csv" + with open(fullPath, encoding="UTF-8") as fp: + reader = csv.DictReader(fp) + for row in reader: + titleId = row["TitleId"] + displayName = row["DisplayName"] + requirement = row["Requirement"] + rank = row["Rank"] + imageFilePath = row["ImageFilePath"] + enabled = True + + self.logger.info(f"Added title {titleId} | Name: {displayName}") + + if len(titleId) > 5: + try: + self.data.static.put_title( + 0, + titleId, + displayName, + requirement, + rank, + imageFilePath, + enabled + ) + except Exception as err: + print(err) + elif len(titleId) < 6: # current server code cannot have multiple lengths for the id + continue + except: + self.logger.warn(f"Couldn't read csv file in {self.bin_dir}, skipping") diff --git a/titles/sao/schema/__init__.py b/titles/sao/schema/__init__.py new file mode 100644 index 0000000..b4fede2 --- /dev/null +++ b/titles/sao/schema/__init__.py @@ -0,0 +1,2 @@ +from .profile import SaoProfileData +from .static import SaoStaticData \ No newline at end of file diff --git a/titles/sao/schema/profile.py b/titles/sao/schema/profile.py new file mode 100644 index 0000000..6ae60b1 --- /dev/null +++ b/titles/sao/schema/profile.py @@ -0,0 +1,48 @@ +from typing import Optional, Dict, List +from sqlalchemy import Table, Column, UniqueConstraint, PrimaryKeyConstraint, and_, case +from sqlalchemy.types import Integer, String, TIMESTAMP, Boolean, JSON +from sqlalchemy.schema import ForeignKey +from sqlalchemy.sql import func, select, update, delete +from sqlalchemy.engine import Row +from sqlalchemy.dialects.mysql import insert + +from core.data.schema import BaseData, metadata +from ..const import SaoConstants + +profile = Table( + "sao_profile", + metadata, + Column("id", Integer, primary_key=True, nullable=False), + Column( + "user", + ForeignKey("aime_user.id", ondelete="cascade", onupdate="cascade"), + nullable=False, + unique=True, + ), + Column("user_type", Integer, server_default="1"), + Column("nick_name", String(16), server_default="PLAYER"), + Column("rank_num", Integer, server_default="1"), + Column("rank_exp", Integer, server_default="0"), + Column("own_col", Integer, server_default="0"), + Column("own_vp", Integer, server_default="0"), + Column("own_yui_medal", Integer, server_default="0"), + Column("setting_title_id", Integer, server_default="20005"), +) + +class SaoProfileData(BaseData): + def create_profile(self, user_id: int) -> Optional[int]: + sql = insert(profile).values(user=user_id) + conflict = sql.on_duplicate_key_update(user=user_id) + + result = self.execute(conflict) + if result is None: + self.logger.error(f"Failed to create SAO profile for user {user_id}!") + return None + return result.lastrowid + + def get_profile(self, user_id: int) -> Optional[Row]: + sql = profile.select(profile.c.user == user_id) + result = self.execute(sql) + if result is None: + return None + return result.fetchone() \ No newline at end of file diff --git a/titles/sao/schema/static.py b/titles/sao/schema/static.py new file mode 100644 index 0000000..9e96aaf --- /dev/null +++ b/titles/sao/schema/static.py @@ -0,0 +1,297 @@ +from typing import Dict, List, Optional +from sqlalchemy import Table, Column, UniqueConstraint, PrimaryKeyConstraint, and_ +from sqlalchemy.types import Integer, String, TIMESTAMP, Boolean, JSON, Float +from sqlalchemy.engine.base import Connection +from sqlalchemy.engine import Row +from sqlalchemy.schema import ForeignKey +from sqlalchemy.sql import func, select +from sqlalchemy.dialects.mysql import insert + +from core.data.schema import BaseData, metadata + +quest = Table( + "sao_static_quest", + metadata, + Column("id", Integer, primary_key=True, nullable=False), + Column("version", Integer), + Column("questSceneId", Integer), + Column("sortNo", Integer), + Column("name", String(255)), + Column("enabled", Boolean), + UniqueConstraint( + "version", "questSceneId", name="sao_static_quest_uk" + ), + mysql_charset="utf8mb4", +) + +hero = Table( + "sao_static_hero_list", + metadata, + Column("id", Integer, primary_key=True, nullable=False), + Column("version", Integer), + Column("heroLogId", Integer), + Column("name", String(255)), + Column("nickname", String(255)), + Column("rarity", Integer), + Column("skillTableSubId", Integer), + Column("awakeningExp", Integer), + Column("flavorText", String(255)), + Column("enabled", Boolean), + UniqueConstraint( + "version", "heroLogId", name="sao_static_hero_list_uk" + ), + mysql_charset="utf8mb4", +) + +equipment = Table( + "sao_static_equipment_list", + metadata, + Column("id", Integer, primary_key=True, nullable=False), + Column("version", Integer), + Column("equipmentId", Integer), + Column("equipmentType", Integer), + Column("weaponTypeId", Integer), + Column("name", String(255)), + Column("rarity", Integer), + Column("flavorText", String(255)), + Column("enabled", Boolean), + UniqueConstraint( + "version", "equipmentId", name="sao_static_equipment_list_uk" + ), + mysql_charset="utf8mb4", +) + +item = Table( + "sao_static_item_list", + metadata, + Column("id", Integer, primary_key=True, nullable=False), + Column("version", Integer), + Column("itemId", Integer), + Column("itemTypeId", Integer), + Column("name", String(255)), + Column("rarity", Integer), + Column("flavorText", String(255)), + Column("enabled", Boolean), + UniqueConstraint( + "version", "itemId", name="sao_static_item_list_uk" + ), + mysql_charset="utf8mb4", +) + +support = Table( + "sao_static_support_log_list", + metadata, + Column("id", Integer, primary_key=True, nullable=False), + Column("version", Integer), + Column("supportLogId", Integer), + Column("charaId", Integer), + Column("name", String(255)), + Column("rarity", Integer), + Column("salePrice", Integer), + Column("skillName", String(255)), + Column("enabled", Boolean), + UniqueConstraint( + "version", "supportLogId", name="sao_static_support_log_list_uk" + ), + mysql_charset="utf8mb4", +) + +title = Table( + "sao_static_title_list", + metadata, + Column("id", Integer, primary_key=True, nullable=False), + Column("version", Integer), + Column("titleId", Integer), + Column("displayName", String(255)), + Column("requirement", Integer), + Column("rank", Integer), + Column("imageFilePath", String(255)), + Column("enabled", Boolean), + UniqueConstraint( + "version", "titleId", name="sao_static_title_list_uk" + ), + mysql_charset="utf8mb4", +) + +class SaoStaticData(BaseData): + def put_quest( self, questSceneId: int, version: int, sortNo: int, name: str, enabled: bool ) -> Optional[int]: + sql = insert(quest).values( + questSceneId=questSceneId, + version=version, + sortNo=sortNo, + name=name, + tutorial=tutorial, + ) + + conflict = sql.on_duplicate_key_update( + name=name, questSceneId=questSceneId, version=version + ) + + result = self.execute(conflict) + if result is None: + return None + return result.lastrowid + + def put_hero( self, version: int, heroLogId: int, name: str, nickname: str, rarity: int, skillTableSubId: int, awakeningExp: int, flavorText: str, enabled: bool ) -> Optional[int]: + sql = insert(hero).values( + version=version, + heroLogId=heroLogId, + name=name, + nickname=nickname, + rarity=rarity, + skillTableSubId=skillTableSubId, + awakeningExp=awakeningExp, + flavorText=flavorText, + enabled=enabled + ) + + conflict = sql.on_duplicate_key_update( + name=name, heroLogId=heroLogId + ) + + result = self.execute(conflict) + if result is None: + return None + return result.lastrowid + + def put_equipment( self, version: int, equipmentId: int, name: str, equipmentType: int, weaponTypeId:int, rarity: int, flavorText: str, enabled: bool ) -> Optional[int]: + sql = insert(equipment).values( + version=version, + equipmentId=equipmentId, + name=name, + equipmentType=equipmentType, + weaponTypeId=weaponTypeId, + rarity=rarity, + flavorText=flavorText, + enabled=enabled + ) + + conflict = sql.on_duplicate_key_update( + name=name, equipmentId=equipmentId + ) + + result = self.execute(conflict) + if result is None: + return None + return result.lastrowid + + def put_item( self, version: int, itemId: int, name: str, itemTypeId: int, rarity: int, flavorText: str, enabled: bool ) -> Optional[int]: + sql = insert(item).values( + version=version, + itemId=itemId, + name=name, + itemTypeId=itemTypeId, + rarity=rarity, + flavorText=flavorText, + enabled=enabled + ) + + conflict = sql.on_duplicate_key_update( + name=name, itemId=itemId + ) + + result = self.execute(conflict) + if result is None: + return None + return result.lastrowid + + def put_support_log( self, version: int, supportLogId: int, charaId: int, name: str, rarity: int, salePrice: int, skillName: str, enabled: bool ) -> Optional[int]: + sql = insert(support).values( + version=version, + supportLogId=supportLogId, + charaId=charaId, + name=name, + rarity=rarity, + salePrice=salePrice, + skillName=skillName, + enabled=enabled + ) + + conflict = sql.on_duplicate_key_update( + name=name, supportLogId=supportLogId + ) + + result = self.execute(conflict) + if result is None: + return None + return result.lastrowid + + def put_title( self, version: int, titleId: int, displayName: str, requirement: int, rank: int, imageFilePath: str, enabled: bool ) -> Optional[int]: + sql = insert(title).values( + version=version, + titleId=titleId, + displayName=displayName, + requirement=requirement, + rank=rank, + imageFilePath=imageFilePath, + enabled=enabled + ) + + conflict = sql.on_duplicate_key_update( + displayName=displayName, titleId=titleId + ) + + result = self.execute(conflict) + if result is None: + return None + return result.lastrowid + + def get_quests_ids(self, version: int, enabled: bool) -> Optional[List[Dict]]: + sql = quest.select(quest.c.version == version and quest.c.enabled == enabled).order_by( + quest.c.questSceneId.asc() + ) + + result = self.execute(sql) + if result is None: + return None + return [list[2] for list in result.fetchall()] + + def get_hero_ids(self, version: int, enabled: bool) -> Optional[List[Dict]]: + sql = hero.select(hero.c.version == version and hero.c.enabled == enabled).order_by( + hero.c.heroLogId.asc() + ) + + result = self.execute(sql) + if result is None: + return None + return [list[2] for list in result.fetchall()] + + def get_equipment_ids(self, version: int, enabled: bool) -> Optional[List[Dict]]: + sql = equipment.select(equipment.c.version == version and equipment.c.enabled == enabled).order_by( + equipment.c.equipmentId.asc() + ) + + result = self.execute(sql) + if result is None: + return None + return [list[2] for list in result.fetchall()] + + def get_item_ids(self, version: int, enabled: bool) -> Optional[List[Dict]]: + sql = item.select(item.c.version == version and item.c.enabled == enabled).order_by( + item.c.itemId.asc() + ) + + result = self.execute(sql) + if result is None: + return None + return [list[2] for list in result.fetchall()] + + def get_support_log_ids(self, version: int, enabled: bool) -> Optional[List[Dict]]: + sql = support.select(support.c.version == version and support.c.enabled == enabled).order_by( + support.c.supportLogId.asc() + ) + + result = self.execute(sql) + if result is None: + return None + return [list[2] for list in result.fetchall()] + + def get_title_ids(self, version: int, enabled: bool) -> Optional[List[Dict]]: + sql = title.select(title.c.version == version and title.c.enabled == enabled).order_by( + title.c.titleId.asc() + ) + + result = self.execute(sql) + if result is None: + return None + return [list[2] for list in result.fetchall()] \ No newline at end of file From cab1d6814a055161f8572794e4f95036f18857ba Mon Sep 17 00:00:00 2001 From: Midorica Date: Fri, 26 May 2023 13:57:16 -0400 Subject: [PATCH 10/49] added game specifics for SAO --- docs/game_specific_info.md | 45 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/docs/game_specific_info.md b/docs/game_specific_info.md index 5f04f93..a02e7bc 100644 --- a/docs/game_specific_info.md +++ b/docs/game_specific_info.md @@ -15,6 +15,7 @@ using the megaime database. Clean installations always create the latest databas - [O.N.G.E.K.I.](#o-n-g-e-k-i) - [Card Maker](#card-maker) - [WACCA](#wacca) + - [Sword Art Online Arcade](#sao) # Supported Games @@ -365,3 +366,47 @@ Always make sure your database (tables) are up-to-date, to do so go to the `core ```shell python dbutils.py --game SDFE upgrade ``` + +## SAO + +### SDWS + +| Version ID | Version Name | +|------------|---------------| +| 0 | SAO | + + +### Importer + +In order to use the importer locate your game installation folder and execute: + +```shell +python read.py --series SDEW --version --binfolder /path/to/game/extractedassets +``` + +The importer for SAO will import all items, heroes, support skills and titles data. + +### Config + +Config file is located in `config/sao.yaml`. + +| Option | Info | +|--------------------|-----------------------------------------------------------------------------| +| `hostname` | Changes the server listening address for Mucha | +| `port` | Changes the listing port | +| `auto_register` | Allows the game to handle the automatic registration of new cards | + + +### Database upgrade + +Always make sure your database (tables) are up-to-date, to do so go to the `core/data/schema/versions` folder and see which version is the latest, f.e. `SDEW_1_upgrade.sql`. In order to upgrade to version 3 in this case you need to perform all previous updates as well: + +```shell +python dbutils.py --game SDEW upgrade +``` + +### Credits for SAO support: + +Midorica - Limited Network Support +Dniel97 - Helping with network base +tungnotpunk - Source \ No newline at end of file From 049dc40a8b672ce23865135710ec3232e1679c5d Mon Sep 17 00:00:00 2001 From: Midorica Date: Fri, 26 May 2023 14:04:49 -0400 Subject: [PATCH 11/49] small typo of the documentation --- docs/game_specific_info.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/game_specific_info.md b/docs/game_specific_info.md index a02e7bc..af2afbe 100644 --- a/docs/game_specific_info.md +++ b/docs/game_specific_info.md @@ -369,7 +369,7 @@ python dbutils.py --game SDFE upgrade ## SAO -### SDWS +### SDEW | Version ID | Version Name | |------------|---------------| From 05dee87a9a3a537dc2869edcc2aeec0c96966bd3 Mon Sep 17 00:00:00 2001 From: Kevin Trocolli Date: Fri, 26 May 2023 21:41:16 -0400 Subject: [PATCH 12/49] allnet: update default values, add debug log for unknown but allowed auths --- core/allnet.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/core/allnet.py b/core/allnet.py index d54b333..32ae177 100644 --- a/core/allnet.py +++ b/core/allnet.py @@ -112,6 +112,8 @@ class AllnetServlet: ) resp.uri = f"http://{self.config.title.hostname}:{self.config.title.port}/{req.game_id}/{req.ver.replace('.', '')}/" resp.host = f"{self.config.title.hostname}:{self.config.title.port}" + + self.logger.debug(f"Allnet response: {vars(resp)}") return self.dict_to_http_form_string([vars(resp)]) resp.uri, resp.host = self.uri_registry[req.game_id] @@ -410,8 +412,8 @@ class AllnetPowerOnResponse3: self.uri = "" self.host = "" self.place_id = "123" - self.name = "" - self.nickname = "" + self.name = "ARTEMiS" + self.nickname = "ARTEMiS" self.region0 = "1" self.region_name0 = "W" self.region_name1 = "" @@ -434,8 +436,8 @@ class AllnetPowerOnResponse2: self.uri = "" self.host = "" self.place_id = "123" - self.name = "Test" - self.nickname = "Test123" + self.name = "ARTEMiS" + self.nickname = "ARTEMiS" self.region0 = "1" self.region_name0 = "W" self.region_name1 = "X" From 84cb786bdef048362c82896642ba23d86805235e Mon Sep 17 00:00:00 2001 From: Midorica Date: Mon, 29 May 2023 11:10:32 -0400 Subject: [PATCH 13/49] Adding some structs for SAO for later use --- docs/game_specific_info.md | 6 +- titles/sao/base.py | 173 ++++++++++++++++++++++++++++++++++++- 2 files changed, 174 insertions(+), 5 deletions(-) diff --git a/docs/game_specific_info.md b/docs/game_specific_info.md index af2afbe..5361ce9 100644 --- a/docs/game_specific_info.md +++ b/docs/game_specific_info.md @@ -407,6 +407,6 @@ python dbutils.py --game SDEW upgrade ### Credits for SAO support: -Midorica - Limited Network Support -Dniel97 - Helping with network base -tungnotpunk - Source \ No newline at end of file +- Midorica - Limited Network Support +- Dniel97 - Helping with network base +- tungnotpunk - Source \ No newline at end of file diff --git a/titles/sao/base.py b/titles/sao/base.py index 67a2d6b..1e1b02b 100644 --- a/titles/sao/base.py +++ b/titles/sao/base.py @@ -197,17 +197,186 @@ class SaoBase: #home/check_profile_card_used_reward resp = SaoCheckProfileCardUsedRewardResponse(int.from_bytes(bytes.fromhex(request[:4]), "big")+1) return resp.make() + + def handle_c806(self, request: Any) -> bytes: + #custom/change_party + req = bytes.fromhex(request)[24:] + + req_struct = Struct( + Padding(20), + "ticket_id" / Bytes(1), # needs to be parsed as an int + Padding(1), + "user_id_size" / Rebuild(Int32ub, len_(this.user_id) * 2), # calculates the length of the user_id + "user_id" / PaddedString(this.user_id_size, "utf_16_le"), # user_id is a (zero) padded string + "act_type" / Int8ub, # play_mode is a byte + Padding(3), + "party_data_list_length" / Rebuild(Int8ub, len_(this.party_data_list)), # party_data_list is a byte, + "party_data_list" / Array(this.party_data_list_length, Struct( + "user_party_id_size" / Rebuild(Int32ub, len_(this.user_party_id) * 2), # calculates the length of the user_party_id + "user_party_id" / PaddedString(this.user_party_id_size, "utf_16_le"), # user_party_id is a (zero) padded string + "team_no" / Int8ub, # team_no is a byte + Padding(3), + "party_team_data_list_length" / Rebuild(Int8ub, len_(this.party_team_data_list)), # party_team_data_list is a byte + "party_team_data_list" / Array(this.party_team_data_list_length, Struct( + "user_party_team_id_size" / Rebuild(Int32ub, len_(this.user_party_team_id) * 2), # calculates the length of the user_party_team_id + "user_party_team_id" / PaddedString(this.user_party_team_id_size, "utf_16_le"), # user_party_team_id is a (zero) padded string + "arrangement_num" / Int8ub, # arrangement_num is a byte + "user_hero_log_id_size" / Rebuild(Int32ub, len_(this.user_hero_log_id) * 2), # calculates the length of the user_hero_log_id + "user_hero_log_id" / PaddedString(this.user_hero_log_id_size, "utf_16_le"), # user_hero_log_id is a (zero) padded string + "main_weapon_user_equipment_id_size" / Rebuild(Int32ub, len_(this.main_weapon_user_equipment_id) * 2), # calculates the length of the main_weapon_user_equipment_id + "main_weapon_user_equipment_id" / PaddedString(this.main_weapon_user_equipment_id_size, "utf_16_le"), # main_weapon_user_equipment_id is a (zero) padded string + "sub_equipment_user_equipment_id_size" / Rebuild(Int32ub, len_(this.sub_equipment_user_equipment_id) * 2), # calculates the length of the sub_equipment_user_equipment_id + "sub_equipment_user_equipment_id" / PaddedString(this.sub_equipment_user_equipment_id_size, "utf_16_le"), # sub_equipment_user_equipment_id is a (zero) padded string + "skill_slot1_skill_id" / Int32ub, # skill_slot1_skill_id is a int, + "skill_slot2_skill_id" / Int32ub, # skill_slot2_skill_id is a int, + "skill_slot3_skill_id" / Int32ub, # skill_slot3_skill_id is a int, + "skill_slot4_skill_id" / Int32ub, # skill_slot4_skill_id is a int, + "skill_slot5_skill_id" / Int32ub, # skill_slot5_skill_id is a int, + )), + )), + + ) + + req_data = req_struct.parse(req) + + #self.logger.info(f"User Team Data: { req_data }") + + resp = SaoNoopResponse(int.from_bytes(bytes.fromhex(request[:4]), "big")+1) + return resp.make() def handle_c904(self, request: Any) -> bytes: #quest/episode_play_start - user_id = bytes.fromhex(request[100:124]).decode("utf-16le") + + req = bytes.fromhex(request)[24:] + + req_struct = Struct( + Padding(20), + "ticket_id" / Bytes(1), # needs to be parsed as an int + Padding(1), + "user_id_size" / Rebuild(Int32ub, len_(this.user_id) * 2), # calculates the length of the user_id + "user_id" / PaddedString(this.user_id_size, "utf_16_le"), # user_id is a (zero) padded string + "episode_id" / Int32ub, # episode_id is a int, + "play_mode" / Int8ub, # play_mode is a byte + Padding(3), + "play_start_request_data_length" / Rebuild(Int8ub, len_(this.play_start_request_data)), # play_start_request_data_length is a byte, + "play_start_request_data" / Array(this.play_start_request_data_length, Struct( + "user_party_id_size" / Rebuild(Int32ub, len_(this.user_party_id) * 2), # calculates the length of the user_party_id + "user_party_id" / PaddedString(this.user_party_id_size, "utf_16_le"), # user_party_id is a (zero) padded string + "appoint_leader_resource_card_code_size" / Rebuild(Int32ub, len_(this.appoint_leader_resource_card_code) * 2), # calculates the length of the total_damage + "appoint_leader_resource_card_code" / PaddedString(this.appoint_leader_resource_card_code_size, "utf_16_le"), # total_damage is a (zero) padded string + "use_profile_card_code_size" / Rebuild(Int32ub, len_(this.use_profile_card_code) * 2), # calculates the length of the total_damage + "use_profile_card_code" / PaddedString(this.use_profile_card_code_size, "utf_16_le"), # use_profile_card_code is a (zero) padded string + "quest_drop_boost_apply_flag" / Int8ub, # quest_drop_boost_apply_flag is a byte + )), + + ) + + req_data = req_struct.parse(req) + + user_id = req_data.user_id profile_data = self.game_data.profile.get_profile(user_id) resp = SaoEpisodePlayStartResponse(int.from_bytes(bytes.fromhex(request[:4]), "big")+1, profile_data) return resp.make() - def handle_c908(self, request: Any) -> bytes: # function not working yet, tired of this + def handle_c908(self, request: Any) -> bytes: #quest/episode_play_end + + req = bytes.fromhex(request)[24:] + + req_struct = Struct( + Padding(20), + "ticket_id" / Bytes(1), # needs to be parsed as an int + Padding(1), + "user_id_size" / Rebuild(Int32ub, len_(this.user_id) * 2), # calculates the length of the user_id + "user_id" / PaddedString(this.user_id_size, "utf_16_le"), # user_id is a (zero) padded string + Padding(2), + "episode_id" / Int16ub, # episode_id is a short, + Padding(3), + "play_end_request_data" / Int8ub, # play_end_request_data is a byte + Padding(1), + "play_result_flag" / Int8ub, # play_result_flag is a byte + Padding(2), + "base_get_data_length" / Rebuild(Int8ub, len_(this.base_get_data)), # base_get_data_length is a byte, + "base_get_data" / Array(this.base_get_data_length, Struct( + "get_hero_log_exp" / Int32ub, # get_hero_log_exp is an int + "get_col" / Int32ub, # get_num is a short + )), + Padding(3), + "get_player_trace_data_list_length" / Rebuild(Int8ub, len_(this.get_player_trace_data_list)), # get_player_trace_data_list_length is a byte + "get_player_trace_data_list" / Array(this.get_player_trace_data_list_length, Struct( + "user_quest_scene_player_trace_id" / Int32ub, # user_quest_scene_player_trace_id is an int + )), + Padding(3), + "get_rare_drop_data_list_length" / Rebuild(Int8ub, len_(this.get_rare_drop_data_list)), # get_rare_drop_data_list_length is a byte + "get_rare_drop_data_list" / Array(this.get_rare_drop_data_list_length, Struct( + "quest_rare_drop_id" / Int32ub, # quest_rare_drop_id is an int + )), + Padding(3), + "get_special_rare_drop_data_list_length" / Rebuild(Int8ub, len_(this.get_special_rare_drop_data_list)), # get_special_rare_drop_data_list_length is a byte + "get_special_rare_drop_data_list" / Array(this.get_special_rare_drop_data_list_length, Struct( + "quest_special_rare_drop_id" / Int32ub, # quest_special_rare_drop_id is an int + )), + Padding(3), + "get_unanalyzed_log_tmp_reward_data_list_length" / Rebuild(Int8ub, len_(this.get_unanalyzed_log_tmp_reward_data_list)), # get_unanalyzed_log_tmp_reward_data_list_length is a byte + "get_unanalyzed_log_tmp_reward_data_list" / Array(this.get_unanalyzed_log_tmp_reward_data_list_length, Struct( + "unanalyzed_log_grade_id" / Int32ub, # unanalyzed_log_grade_id is an int, + )), + Padding(3), + "get_event_item_data_list_length" / Rebuild(Int8ub, len_(this.get_event_item_data_list)), # get_event_item_data_list_length is a byte, + "get_event_item_data_list" / Array(this.get_event_item_data_list_length, Struct( + "event_item_id" / Int32ub, # event_item_id is an int + "get_num" / Int16ub, # get_num is a short + )), + Padding(3), + "discovery_enemy_data_list_length" / Rebuild(Int8ub, len_(this.discovery_enemy_data_list)), # discovery_enemy_data_list_length is a byte + "discovery_enemy_data_list" / Array(this.discovery_enemy_data_list_length, Struct( + "enemy_kind_id" / Int32ub, # enemy_kind_id is an int + "destroy_num" / Int16ub, # destroy_num is a short + )), + Padding(3), + "destroy_boss_data_list_length" / Rebuild(Int8ub, len_(this.destroy_boss_data_list)), # destroy_boss_data_list_length is a byte + "destroy_boss_data_list" / Array(this.destroy_boss_data_list_length, Struct( + "boss_type" / Int8ub, # boss_type is a byte + "enemy_kind_id" / Int32ub, # enemy_kind_id is an int + "destroy_num" / Int16ub, # destroy_num is a short + )), + Padding(3), + "mission_data_list_length" / Rebuild(Int8ub, len_(this.mission_data_list)), # mission_data_list_length is a byte + "mission_data_list" / Array(this.mission_data_list_length, Struct( + "mission_id" / Int32ub, # enemy_kind_id is an int + "clear_flag" / Int8ub, # boss_type is a byte + "mission_difficulty_id" / Int16ub, # destroy_num is a short + )), + Padding(3), + "score_data_length" / Rebuild(Int8ub, len_(this.score_data)), # score_data_length is a byte + "score_data" / Array(this.score_data_length, Struct( + "clear_time" / Int32ub, # clear_time is an int + "combo_num" / Int32ub, # boss_type is a int + "total_damage_size" / Rebuild(Int32ub, len_(this.total_damage) * 2), # calculates the length of the total_damage + "total_damage" / PaddedString(this.total_damage_size, "utf_16_le"), # total_damage is a (zero) padded string + "concurrent_destroying_num" / Int16ub, # concurrent_destroying_num is a short + "reaching_skill_level" / Int16ub, # reaching_skill_level is a short + "ko_chara_num" / Int8ub, # ko_chara_num is a byte + "acceleration_invocation_num" / Int16ub, # acceleration_invocation_num is a short + "boss_destroying_num" / Int16ub, # boss_destroying_num is a short + "synchro_skill_used_flag" / Int8ub, # synchro_skill_used_flag is a byte + "used_friend_skill_id" / Int32ub, # used_friend_skill_id is an int + "friend_skill_used_flag" / Int8ub, # friend_skill_used_flag is a byte + "continue_cnt" / Int16ub, # continue_cnt is a short + "total_loss_num" / Int16ub, # total_loss_num is a short + )), + + ) + + req_data = req_struct.parse(req) + + #self.logger.info(f"User Get Col Data: { req_data.get_col }") + #self.logger.info(f"User Hero Log Exp Data: { req_data.get_hero_log_exp }") + #self.logger.info(f"User Score Data: { req_data.score_data[0] }") + #self.logger.info(f"User Discovery Enemy Data: { req_data.discovery_enemy_data_list }") + #self.logger.info(f"User Mission Data: { req_data.mission_data_list }") + resp = SaoEpisodePlayEndResponse(int.from_bytes(bytes.fromhex(request[:4]), "big")+1) return resp.make() From d8af7be4a49b8d0d3fd5c456c0ed5925b8c333b7 Mon Sep 17 00:00:00 2001 From: Midorica Date: Mon, 29 May 2023 16:51:41 -0400 Subject: [PATCH 14/49] Adding SAO item table and adding party saving --- titles/sao/base.py | 77 ++++++++++++---- titles/sao/handlers/base.py | 128 +++++++++++++++------------ titles/sao/schema/__init__.py | 3 +- titles/sao/schema/item.py | 161 ++++++++++++++++++++++++++++++++++ 4 files changed, 299 insertions(+), 70 deletions(-) create mode 100644 titles/sao/schema/item.py diff --git a/titles/sao/base.py b/titles/sao/base.py index 1e1b02b..685d330 100644 --- a/titles/sao/base.py +++ b/titles/sao/base.py @@ -73,7 +73,12 @@ class SaoBase: user_id = -1 self.logger.error("Failed to register card!") + # Create profile with 3 basic heroes profile_id = self.game_data.profile.create_profile(user_id) + self.game_data.item.put_hero_log(user_id, 101000010, 1, 0, 101000016, 0, 30086, 1001, 1002, 1003, 1005) + self.game_data.item.put_hero_log(user_id, 102000010, 1, 0, 103000006, 0, 30086, 1001, 1002, 1003, 1005) + self.game_data.item.put_hero_log(user_id, 103000010, 1, 0, 112000009, 0, 30086, 1001, 1002, 1003, 1005) + self.game_data.item.put_hero_party(user_id, 0, 101000010, 102000010, 103000010) self.logger.info(f"User Authenticated: { access_code } | { user_id }") @@ -121,9 +126,19 @@ class SaoBase: def handle_c600(self, request: Any) -> bytes: #have_object/get_hero_log_user_data_list - heroIdsData = self.game_data.static.get_hero_ids(0, True) + req = bytes.fromhex(request)[24:] + req_struct = Struct( + Padding(16), + "user_id_size" / Rebuild(Int32ub, len_(this.user_id) * 2), # calculates the length of the user_id + "user_id" / PaddedString(this.user_id_size, "utf_16_le"), # user_id is a (zero) padded string + + ) + req_data = req_struct.parse(req) + user_id = req_data.user_id + + hero_data = self.game_data.item.get_hero_logs(user_id) - resp = SaoGetHeroLogUserDataListResponse(int.from_bytes(bytes.fromhex(request[:4]), "big")+1, heroIdsData) + resp = SaoGetHeroLogUserDataListResponse(int.from_bytes(bytes.fromhex(request[:4]), "big")+1, hero_data) return resp.make() def handle_c602(self, request: Any) -> bytes: @@ -164,7 +179,23 @@ class SaoBase: def handle_c804(self, request: Any) -> bytes: #custom/get_party_data_list - resp = SaoGetPartyDataListResponse(int.from_bytes(bytes.fromhex(request[:4]), "big")+1) + + req = bytes.fromhex(request)[24:] + req_struct = Struct( + Padding(16), + "user_id_size" / Rebuild(Int32ub, len_(this.user_id) * 2), # calculates the length of the user_id + "user_id" / PaddedString(this.user_id_size, "utf_16_le"), # user_id is a (zero) padded string + + ) + req_data = req_struct.parse(req) + user_id = req_data.user_id + + hero_party = self.game_data.item.get_hero_party(user_id, 0) + hero1_data = self.game_data.item.get_hero_log(user_id, hero_party[3]) + hero2_data = self.game_data.item.get_hero_log(user_id, hero_party[4]) + hero3_data = self.game_data.item.get_hero_log(user_id, hero_party[5]) + + resp = SaoGetPartyDataListResponse(int.from_bytes(bytes.fromhex(request[:4]), "big")+1, hero1_data, hero2_data, hero3_data) return resp.make() def handle_c902(self, request: Any) -> bytes: # for whatever reason, having all entries empty or filled changes nothing @@ -197,7 +228,7 @@ class SaoBase: #home/check_profile_card_used_reward resp = SaoCheckProfileCardUsedRewardResponse(int.from_bytes(bytes.fromhex(request[:4]), "big")+1) return resp.make() - + def handle_c806(self, request: Any) -> bytes: #custom/change_party req = bytes.fromhex(request)[24:] @@ -228,18 +259,40 @@ class SaoBase: "sub_equipment_user_equipment_id_size" / Rebuild(Int32ub, len_(this.sub_equipment_user_equipment_id) * 2), # calculates the length of the sub_equipment_user_equipment_id "sub_equipment_user_equipment_id" / PaddedString(this.sub_equipment_user_equipment_id_size, "utf_16_le"), # sub_equipment_user_equipment_id is a (zero) padded string "skill_slot1_skill_id" / Int32ub, # skill_slot1_skill_id is a int, - "skill_slot2_skill_id" / Int32ub, # skill_slot2_skill_id is a int, - "skill_slot3_skill_id" / Int32ub, # skill_slot3_skill_id is a int, - "skill_slot4_skill_id" / Int32ub, # skill_slot4_skill_id is a int, - "skill_slot5_skill_id" / Int32ub, # skill_slot5_skill_id is a int, + "skill_slot2_skill_id" / Int32ub, # skill_slot1_skill_id is a int, + "skill_slot3_skill_id" / Int32ub, # skill_slot1_skill_id is a int, + "skill_slot4_skill_id" / Int32ub, # skill_slot1_skill_id is a int, + "skill_slot5_skill_id" / Int32ub, # skill_slot1_skill_id is a int, )), )), ) req_data = req_struct.parse(req) + user_id = req_data.user_id - #self.logger.info(f"User Team Data: { req_data }") + for party_team in req_data.party_data_list[0].party_team_data_list: + hero_data = self.game_data.item.get_hero_log(user_id, party_team["user_hero_log_id"]) + hero_level = 1 + hero_exp = 0 + + if hero_data: + hero_level = hero_data["log_level"] + hero_exp = hero_data["log_exp"] + + self.game_data.item.put_hero_log( + user_id, + party_team["user_hero_log_id"], + hero_level, + hero_exp, + party_team["main_weapon_user_equipment_id"], + party_team["sub_equipment_user_equipment_id"], + party_team["skill_slot1_skill_id"], + party_team["skill_slot2_skill_id"], + party_team["skill_slot3_skill_id"], + party_team["skill_slot4_skill_id"], + party_team["skill_slot5_skill_id"] + ) resp = SaoNoopResponse(int.from_bytes(bytes.fromhex(request[:4]), "big")+1) return resp.make() @@ -371,12 +424,6 @@ class SaoBase: req_data = req_struct.parse(req) - #self.logger.info(f"User Get Col Data: { req_data.get_col }") - #self.logger.info(f"User Hero Log Exp Data: { req_data.get_hero_log_exp }") - #self.logger.info(f"User Score Data: { req_data.score_data[0] }") - #self.logger.info(f"User Discovery Enemy Data: { req_data.discovery_enemy_data_list }") - #self.logger.info(f"User Mission Data: { req_data.mission_data_list }") - resp = SaoEpisodePlayEndResponse(int.from_bytes(bytes.fromhex(request[:4]), "big")+1) return resp.make() diff --git a/titles/sao/handlers/base.py b/titles/sao/handlers/base.py index 9048517..3bd6d9c 100644 --- a/titles/sao/handlers/base.py +++ b/titles/sao/handlers/base.py @@ -478,28 +478,49 @@ class SaoGetHeroLogUserDataListRequest(SaoBaseRequest): super().__init__(data) class SaoGetHeroLogUserDataListResponse(SaoBaseResponse): - def __init__(self, cmd, heroIdsData) -> None: + def __init__(self, cmd, hero_data) -> None: super().__init__(cmd) self.result = 1 - #print(heroIdsData) - #print(list(map(str,heroIdsData))) + self.user_hero_log_id = [] + self.log_level = [] + self.max_log_level_extended_num = [] + self.log_exp = [] + self.last_set_skill_slot1_skill_id = [] + self.last_set_skill_slot2_skill_id = [] + self.last_set_skill_slot3_skill_id = [] + self.last_set_skill_slot4_skill_id = [] + self.last_set_skill_slot5_skill_id = [] + + for i in range(len(hero_data)): + self.user_hero_log_id.append(hero_data[i][2]) + self.log_level.append(hero_data[i][3]) + self.max_log_level_extended_num.append(hero_data[i][3]) + self.log_exp.append(hero_data[i][4]) + self.last_set_skill_slot1_skill_id.append(hero_data[i][7]) + self.last_set_skill_slot2_skill_id.append(hero_data[i][8]) + self.last_set_skill_slot3_skill_id.append(hero_data[i][9]) + self.last_set_skill_slot4_skill_id.append(hero_data[i][10]) + self.last_set_skill_slot5_skill_id.append(hero_data[i][11]) + + #print(self.user_hero_log_id) + #print(list(map(str,self.user_hero_log_id))) # hero_log_user_data_list - self.user_hero_log_id = list(map(str,heroIdsData)) #str - self.hero_log_id = heroIdsData #int - self.log_level = 10 #short - self.max_log_level_extended_num = 10 #short - self.log_exp = 1000 #int + self.user_hero_log_id = list(map(str,self.user_hero_log_id)) #str + self.hero_log_id = list(map(int,self.user_hero_log_id)) #int + self.log_level = list(map(int,self.log_level)) #short + self.max_log_level_extended_num = list(map(int,self.log_level)) #short + self.log_exp = list(map(int,self.log_level)) #int self.possible_awakening_flag = 0 #byte self.awakening_stage = 0 #short self.awakening_exp = 0 #int self.skill_slot_correction_value = 0 #byte - self.last_set_skill_slot1_skill_id = 0 #short - self.last_set_skill_slot2_skill_id = 0 #short - self.last_set_skill_slot3_skill_id = 0 #short - self.last_set_skill_slot4_skill_id = 0 #short - self.last_set_skill_slot5_skill_id = 0 #short + self.last_set_skill_slot1_skill_id = list(map(int,self.last_set_skill_slot1_skill_id)) #short + self.last_set_skill_slot2_skill_id = list(map(int,self.last_set_skill_slot2_skill_id)) #short + self.last_set_skill_slot3_skill_id = list(map(int,self.last_set_skill_slot3_skill_id)) #short + self.last_set_skill_slot4_skill_id = list(map(int,self.last_set_skill_slot4_skill_id)) #short + self.last_set_skill_slot5_skill_id = list(map(int,self.last_set_skill_slot5_skill_id)) #short self.property1_property_id = 0 #int self.property1_value1 = 0 #int self.property1_value2 = 0 #int @@ -573,18 +594,18 @@ class SaoGetHeroLogUserDataListResponse(SaoBaseResponse): user_hero_log_id_size=len(self.user_hero_log_id[i]) * 2, user_hero_log_id=[ord(x) for x in self.user_hero_log_id[i]], hero_log_id=self.hero_log_id[i], - log_level=self.log_level, - max_log_level_extended_num=self.max_log_level_extended_num, - log_exp=self.log_exp, + log_level=self.log_level[i], + max_log_level_extended_num=self.max_log_level_extended_num[i], + log_exp=self.log_exp[i], possible_awakening_flag=self.possible_awakening_flag, awakening_stage=self.awakening_stage, awakening_exp=self.awakening_exp, skill_slot_correction_value=self.skill_slot_correction_value, - last_set_skill_slot1_skill_id=self.last_set_skill_slot1_skill_id, - last_set_skill_slot2_skill_id=self.last_set_skill_slot2_skill_id, - last_set_skill_slot3_skill_id=self.last_set_skill_slot3_skill_id, - last_set_skill_slot4_skill_id=self.last_set_skill_slot4_skill_id, - last_set_skill_slot5_skill_id=self.last_set_skill_slot5_skill_id, + last_set_skill_slot1_skill_id=self.last_set_skill_slot1_skill_id[i], + last_set_skill_slot2_skill_id=self.last_set_skill_slot2_skill_id[i], + last_set_skill_slot3_skill_id=self.last_set_skill_slot3_skill_id[i], + last_set_skill_slot4_skill_id=self.last_set_skill_slot4_skill_id[i], + last_set_skill_slot5_skill_id=self.last_set_skill_slot5_skill_id[i], property1_property_id=self.property1_property_id, property1_value1=self.property1_value1, property1_value2=self.property1_value2, @@ -926,10 +947,10 @@ class SaoGetEpisodeAppendDataListResponse(SaoBaseResponse): def make(self) -> bytes: episode_data_struct = Struct( - "user_episode_append_id_size" / Int32ub, # big endian - "user_episode_append_id" / Int16ul[5], #forced to match the user_episode_append_id_list index which is always 5 chars for the episode ids - "user_id_size" / Int32ub, # big endian - "user_id" / Int16ul[6], # has to be exactly 6 chars in the user field... MANDATORY + "user_episode_append_id_size" / Rebuild(Int32ub, len_(this.user_episode_append_id) * 2), # calculates the length of the user_episode_append_id + "user_episode_append_id" / PaddedString(this.user_episode_append_id_size, "utf_16_le"), # user_episode_append_id is a (zero) padded string + "user_id_size" / Rebuild(Int32ub, len_(this.user_id) * 2), # calculates the length of the user_id + "user_id" / PaddedString(this.user_id_size, "utf_16_le"), # user_id is a (zero) padded string "episode_append_id" / Int32ub, "own_num" / Int32ub, ) @@ -955,10 +976,8 @@ class SaoGetEpisodeAppendDataListResponse(SaoBaseResponse): for i in range(len(self.user_id_list)): # add the episode_data_struct to the resp_struct.episode_append_data_list resp_data.episode_append_data_list.append(dict( - user_episode_append_id_size=len(self.user_episode_append_id_list[i]) * 2, - user_episode_append_id=[ord(x) for x in self.user_episode_append_id_list[i]], - user_id_size=len(self.user_id_list[i]) * 2, - user_id=[ord(x) for x in self.user_id_list[i]], + user_episode_append_id=self.user_episode_append_id_list[i], + user_id=self.user_id_list[i], episode_append_id=self.episode_append_id_list[i], own_num=self.own_num_list[i], )) @@ -974,8 +993,9 @@ class SaoGetPartyDataListRequest(SaoBaseRequest): super().__init__(data) class SaoGetPartyDataListResponse(SaoBaseResponse): # Default party - def __init__(self, cmd) -> None: + def __init__(self, cmd, hero1_data, hero2_data, hero3_data) -> None: super().__init__(cmd) + self.result = 1 self.party_data_list_size = 1 # Number of arrays @@ -985,36 +1005,36 @@ class SaoGetPartyDataListResponse(SaoBaseResponse): # Default party self.user_party_team_id_1 = "0" self.arrangement_num_1 = 0 - self.user_hero_log_id_1 = "101000010" - self.main_weapon_user_equipment_id_1 = "101000016" - self.sub_equipment_user_equipment_id_1 = "0" - self.skill_slot1_skill_id_1 = 30086 - self.skill_slot2_skill_id_1 = 1001 - self.skill_slot3_skill_id_1 = 1002 - self.skill_slot4_skill_id_1 = 1003 - self.skill_slot5_skill_id_1 = 1005 + self.user_hero_log_id_1 = str(hero1_data[2]) + self.main_weapon_user_equipment_id_1 = str(hero1_data[5]) + self.sub_equipment_user_equipment_id_1 = str(hero1_data[6]) + self.skill_slot1_skill_id_1 = hero1_data[7] + self.skill_slot2_skill_id_1 = hero1_data[8] + self.skill_slot3_skill_id_1 = hero1_data[9] + self.skill_slot4_skill_id_1 = hero1_data[10] + self.skill_slot5_skill_id_1 = hero1_data[11] self.user_party_team_id_2 = "0" self.arrangement_num_2 = 0 - self.user_hero_log_id_2 = "102000010" - self.main_weapon_user_equipment_id_2 = "103000006" - self.sub_equipment_user_equipment_id_2 = "0" - self.skill_slot1_skill_id_2 = 30086 - self.skill_slot2_skill_id_2 = 1001 - self.skill_slot3_skill_id_2 = 1002 - self.skill_slot4_skill_id_2 = 1003 - self.skill_slot5_skill_id_2 = 1005 + self.user_hero_log_id_2 = str(hero2_data[2]) + self.main_weapon_user_equipment_id_2 = str(hero2_data[5]) + self.sub_equipment_user_equipment_id_2 = str(hero2_data[6]) + self.skill_slot1_skill_id_2 = hero2_data[7] + self.skill_slot2_skill_id_2 = hero2_data[8] + self.skill_slot3_skill_id_2 = hero2_data[9] + self.skill_slot4_skill_id_2 = hero2_data[10] + self.skill_slot5_skill_id_2 = hero2_data[11] self.user_party_team_id_3 = "0" self.arrangement_num_3 = 0 - self.user_hero_log_id_3 = "103000010" - self.main_weapon_user_equipment_id_3 = "112000009" - self.sub_equipment_user_equipment_id_3 = "0" - self.skill_slot1_skill_id_3 = 30086 - self.skill_slot2_skill_id_3 = 1001 - self.skill_slot3_skill_id_3 = 1002 - self.skill_slot4_skill_id_3 = 1003 - self.skill_slot5_skill_id_3 = 1005 + self.user_hero_log_id_3 = str(hero3_data[2]) + self.main_weapon_user_equipment_id_3 = str(hero3_data[5]) + self.sub_equipment_user_equipment_id_3 = str(hero3_data[6]) + self.skill_slot1_skill_id_3 = hero3_data[7] + self.skill_slot2_skill_id_3 = hero3_data[8] + self.skill_slot3_skill_id_3 = hero3_data[9] + self.skill_slot4_skill_id_3 = hero3_data[10] + self.skill_slot5_skill_id_3 = hero3_data[11] def make(self) -> bytes: # create a resp struct diff --git a/titles/sao/schema/__init__.py b/titles/sao/schema/__init__.py index b4fede2..3e75fc0 100644 --- a/titles/sao/schema/__init__.py +++ b/titles/sao/schema/__init__.py @@ -1,2 +1,3 @@ from .profile import SaoProfileData -from .static import SaoStaticData \ No newline at end of file +from .static import SaoStaticData +from .item import SaoItemData \ No newline at end of file diff --git a/titles/sao/schema/item.py b/titles/sao/schema/item.py new file mode 100644 index 0000000..a0d5123 --- /dev/null +++ b/titles/sao/schema/item.py @@ -0,0 +1,161 @@ +from typing import Optional, Dict, List +from sqlalchemy import Table, Column, UniqueConstraint, PrimaryKeyConstraint, and_, case +from sqlalchemy.types import Integer, String, TIMESTAMP, Boolean +from sqlalchemy.schema import ForeignKey +from sqlalchemy.sql import func, select, update, delete +from sqlalchemy.engine import Row +from sqlalchemy.dialects.mysql import insert + +from core.data.schema import BaseData, metadata + +hero_log_data = Table( + "sao_hero_log_data", + metadata, + Column("id", Integer, primary_key=True, nullable=False), + Column( + "user", + ForeignKey("aime_user.id", ondelete="cascade", onupdate="cascade"), + nullable=False, + ), + Column("user_hero_log_id", Integer, nullable=False), + Column("log_level", Integer, nullable=False), + Column("log_exp", Integer, nullable=False), + Column("main_weapon", Integer, nullable=False), + Column("sub_equipment", Integer, nullable=False), + Column("skill_slot1_skill_id", Integer, nullable=False), + Column("skill_slot2_skill_id", Integer, nullable=False), + Column("skill_slot3_skill_id", Integer, nullable=False), + Column("skill_slot4_skill_id", Integer, nullable=False), + Column("skill_slot5_skill_id", Integer, nullable=False), + Column("get_date", TIMESTAMP, nullable=False, server_default=func.now()), + UniqueConstraint("user", "user_hero_log_id", name="sao_hero_log_data_uk"), + mysql_charset="utf8mb4", +) + +hero_party = Table( + "sao_hero_party", + metadata, + Column("id", Integer, primary_key=True, nullable=False), + Column( + "user", + ForeignKey("aime_user.id", ondelete="cascade", onupdate="cascade"), + nullable=False, + ), + Column("user_party_team_id", Integer, nullable=False), + Column("user_hero_log_id_1", Integer, nullable=False), + Column("user_hero_log_id_2", Integer, nullable=False), + Column("user_hero_log_id_3", Integer, nullable=False), + UniqueConstraint("user", "user_party_team_id", name="sao_hero_party_uk"), + mysql_charset="utf8mb4", +) + +class SaoItemData(BaseData): + def put_hero_log(self, user_id: int, user_hero_log_id: int, log_level: int, log_exp: int, main_weapon: int, sub_equipment: int, skill_slot1_skill_id: int, skill_slot2_skill_id: int, skill_slot3_skill_id: int, skill_slot4_skill_id: int, skill_slot5_skill_id: int) -> Optional[int]: + sql = insert(hero_log_data).values( + user=user_id, + user_hero_log_id=user_hero_log_id, + log_level=log_level, + log_exp=log_exp, + main_weapon=main_weapon, + sub_equipment=sub_equipment, + skill_slot1_skill_id=skill_slot1_skill_id, + skill_slot2_skill_id=skill_slot2_skill_id, + skill_slot3_skill_id=skill_slot3_skill_id, + skill_slot4_skill_id=skill_slot4_skill_id, + skill_slot5_skill_id=skill_slot5_skill_id, + ) + + conflict = sql.on_duplicate_key_update( + log_level=hero_log_data.c.log_level, + log_exp=hero_log_data.c.log_exp, + main_weapon=hero_log_data.c.main_weapon, + sub_equipment=hero_log_data.c.sub_equipment, + skill_slot1_skill_id=hero_log_data.c.skill_slot1_skill_id, + skill_slot2_skill_id=hero_log_data.c.skill_slot2_skill_id, + skill_slot3_skill_id=hero_log_data.c.skill_slot3_skill_id, + skill_slot4_skill_id=hero_log_data.c.skill_slot4_skill_id, + skill_slot5_skill_id=hero_log_data.c.skill_slot5_skill_id, + ) + + result = self.execute(conflict) + if result is None: + self.logger.error( + f"{__name__} failed to insert hero! user: {user_id}, user_hero_log_id: {user_hero_log_id}" + ) + return None + + return result.lastrowid + + def put_hero_party(self, user_id: int, user_party_team_id: int, user_hero_log_id_1: int, user_hero_log_id_2: int, user_hero_log_id_3: int) -> Optional[int]: + sql = insert(hero_party).values( + user=user_id, + user_party_team_id=user_party_team_id, + user_hero_log_id_1=user_hero_log_id_1, + user_hero_log_id_2=user_hero_log_id_2, + user_hero_log_id_3=user_hero_log_id_3, + ) + + conflict = sql.on_duplicate_key_update( + user_hero_log_id_1=hero_party.c.user_hero_log_id_1, + user_hero_log_id_2=hero_party.c.user_hero_log_id_2, + user_hero_log_id_3=hero_party.c.user_hero_log_id_3, + ) + + result = self.execute(conflict) + if result is None: + self.logger.error( + f"{__name__} failed to insert hero party! user: {user_id}, user_party_team_id: {user_party_team_id}" + ) + return None + + return result.lastrowid + + def get_hero_log( + self, user_id: int, user_hero_log_id: int = None + ) -> Optional[List[Row]]: + """ + A catch-all hero lookup given a profile and user_party_team_id and ID specifiers + """ + sql = hero_log_data.select( + and_( + hero_log_data.c.user == user_id, + hero_log_data.c.user_hero_log_id == user_hero_log_id if user_hero_log_id is not None else True, + ) + ) + + result = self.execute(sql) + if result is None: + return None + return result.fetchone() + + def get_hero_logs( + self, user_id: int + ) -> Optional[List[Row]]: + """ + A catch-all hero lookup given a profile and user_party_team_id and ID specifiers + """ + sql = hero_log_data.select( + and_( + hero_log_data.c.user == user_id, + ) + ) + + result = self.execute(sql) + if result is None: + return None + return result.fetchall() + + def get_hero_party( + self, user_id: int, user_party_team_id: int = None + ) -> Optional[List[Row]]: + sql = hero_party.select( + and_( + hero_party.c.user == user_id, + hero_party.c.user_party_team_id == user_party_team_id if user_party_team_id is not None else True, + ) + ) + + result = self.execute(sql) + if result is None: + return None + return result.fetchone() \ No newline at end of file From 2b4ac0638997e11559756aa19a3816d478cabfc2 Mon Sep 17 00:00:00 2001 From: Midorica Date: Mon, 29 May 2023 19:21:26 -0400 Subject: [PATCH 15/49] adding more profile & hero saving stuff to SAO --- titles/sao/base.py | 56 ++++++++++++++++++++++++++- titles/sao/schema/item.py | 75 ++++++++++++++++++++++++++++++------ titles/sao/schema/profile.py | 32 +++++++++++++++ 3 files changed, 150 insertions(+), 13 deletions(-) diff --git a/titles/sao/base.py b/titles/sao/base.py index 685d330..609c5ac 100644 --- a/titles/sao/base.py +++ b/titles/sao/base.py @@ -329,10 +329,18 @@ class SaoBase: user_id = req_data.user_id profile_data = self.game_data.profile.get_profile(user_id) + self.game_data.item.create_session( + user_id, + int(req_data.play_start_request_data[0].user_party_id), + req_data.episode_id, + req_data.play_mode, + req_data.play_start_request_data[0].quest_drop_boost_apply_flag + ) + resp = SaoEpisodePlayStartResponse(int.from_bytes(bytes.fromhex(request[:4]), "big")+1, profile_data) return resp.make() - def handle_c908(self, request: Any) -> bytes: + def handle_c908(self, request: Any) -> bytes: # Level calculation missing for the profile and heroes #quest/episode_play_end req = bytes.fromhex(request)[24:] @@ -424,6 +432,52 @@ class SaoBase: req_data = req_struct.parse(req) + # Update the profile + profile = self.game_data.profile.get_profile(req_data.user_id) + + exp = int(profile["rank_exp"]) + 100 #always 100 extra exp for some reason + col = int(profile["own_col"]) + int(req_data.base_get_data[0].get_col) + + updated_profile = self.game_data.profile.put_profile( + req_data.user_id, + profile["user_type"], + profile["nick_name"], + profile["rank_num"], + exp, + col, + profile["own_vp"], + profile["own_yui_medal"], + profile["setting_title_id"] + ) + + # Update heroes from the used party + play_session = self.game_data.item.get_session(req_data.user_id) + session_party = self.game_data.item.get_hero_party(req_data.user_id, play_session["user_party_team_id"]) + + hero_list = [] + hero_list.append(session_party["user_hero_log_id_1"]) + hero_list.append(session_party["user_hero_log_id_2"]) + hero_list.append(session_party["user_hero_log_id_3"]) + + for i in range(0,len(hero_list)): + hero_data = self.game_data.item.get_hero_log(req_data.user_id, hero_list[i]) + + log_exp = int(hero_data["log_exp"]) + int(req_data.base_get_data[0].get_hero_log_exp) + + self.game_data.item.put_hero_log( + req_data.user_id, + hero_data["user_hero_log_id"], + hero_data["log_level"], + log_exp, + hero_data["main_weapon"], + hero_data["sub_equipment"], + hero_data["skill_slot1_skill_id"], + hero_data["skill_slot2_skill_id"], + hero_data["skill_slot3_skill_id"], + hero_data["skill_slot4_skill_id"], + hero_data["skill_slot5_skill_id"] + ) + resp = SaoEpisodePlayEndResponse(int.from_bytes(bytes.fromhex(request[:4]), "big")+1) return resp.make() diff --git a/titles/sao/schema/item.py b/titles/sao/schema/item.py index a0d5123..f6d030e 100644 --- a/titles/sao/schema/item.py +++ b/titles/sao/schema/item.py @@ -49,7 +49,42 @@ hero_party = Table( mysql_charset="utf8mb4", ) +sessions = Table( + "sao_play_sessions", + metadata, + Column("id", Integer, primary_key=True, nullable=False), + Column( + "user", + ForeignKey("aime_user.id", ondelete="cascade", onupdate="cascade"), + nullable=False, + ), + Column("user_party_team_id", Integer, nullable=False), + Column("episode_id", Integer, nullable=False), + Column("play_mode", Integer, nullable=False), + Column("quest_drop_boost_apply_flag", Integer, nullable=False), + Column("play_date", TIMESTAMP, nullable=False, server_default=func.now()), + UniqueConstraint("user", "user_party_team_id", "play_date", name="sao_play_sessions_uk"), + mysql_charset="utf8mb4", +) + class SaoItemData(BaseData): + def create_session(self, user_id: int, user_party_team_id: int, episode_id: int, play_mode: int, quest_drop_boost_apply_flag: int) -> Optional[int]: + sql = insert(sessions).values( + user=user_id, + user_party_team_id=user_party_team_id, + episode_id=episode_id, + play_mode=play_mode, + quest_drop_boost_apply_flag=quest_drop_boost_apply_flag + ) + + conflict = sql.on_duplicate_key_update(user=user_id) + + result = self.execute(conflict) + if result is None: + self.logger.error(f"Failed to create SAO session for user {user_id}!") + return None + return result.lastrowid + def put_hero_log(self, user_id: int, user_hero_log_id: int, log_level: int, log_exp: int, main_weapon: int, sub_equipment: int, skill_slot1_skill_id: int, skill_slot2_skill_id: int, skill_slot3_skill_id: int, skill_slot4_skill_id: int, skill_slot5_skill_id: int) -> Optional[int]: sql = insert(hero_log_data).values( user=user_id, @@ -66,15 +101,15 @@ class SaoItemData(BaseData): ) conflict = sql.on_duplicate_key_update( - log_level=hero_log_data.c.log_level, - log_exp=hero_log_data.c.log_exp, - main_weapon=hero_log_data.c.main_weapon, - sub_equipment=hero_log_data.c.sub_equipment, - skill_slot1_skill_id=hero_log_data.c.skill_slot1_skill_id, - skill_slot2_skill_id=hero_log_data.c.skill_slot2_skill_id, - skill_slot3_skill_id=hero_log_data.c.skill_slot3_skill_id, - skill_slot4_skill_id=hero_log_data.c.skill_slot4_skill_id, - skill_slot5_skill_id=hero_log_data.c.skill_slot5_skill_id, + log_level=log_level, + log_exp=log_exp, + main_weapon=main_weapon, + sub_equipment=sub_equipment, + skill_slot1_skill_id=skill_slot1_skill_id, + skill_slot2_skill_id=skill_slot2_skill_id, + skill_slot3_skill_id=skill_slot3_skill_id, + skill_slot4_skill_id=skill_slot4_skill_id, + skill_slot5_skill_id=skill_slot5_skill_id, ) result = self.execute(conflict) @@ -96,9 +131,9 @@ class SaoItemData(BaseData): ) conflict = sql.on_duplicate_key_update( - user_hero_log_id_1=hero_party.c.user_hero_log_id_1, - user_hero_log_id_2=hero_party.c.user_hero_log_id_2, - user_hero_log_id_3=hero_party.c.user_hero_log_id_3, + user_hero_log_id_1=user_hero_log_id_1, + user_hero_log_id_2=user_hero_log_id_2, + user_hero_log_id_3=user_hero_log_id_3, ) result = self.execute(conflict) @@ -155,6 +190,22 @@ class SaoItemData(BaseData): ) ) + result = self.execute(sql) + if result is None: + return None + return result.fetchone() + + def get_session( + self, user_id: int = None + ) -> Optional[List[Row]]: + sql = sessions.select( + and_( + sessions.c.user == user_id, + ) + ).order_by( + sessions.c.play_date.asc() + ) + result = self.execute(sql) if result is None: return None diff --git a/titles/sao/schema/profile.py b/titles/sao/schema/profile.py index 6ae60b1..b125717 100644 --- a/titles/sao/schema/profile.py +++ b/titles/sao/schema/profile.py @@ -40,6 +40,38 @@ class SaoProfileData(BaseData): return None return result.lastrowid + def put_profile(self, user_id: int, user_type: int, nick_name: str, rank_num: int, rank_exp: int, own_col: int, own_vp: int, own_yui_medal: int, setting_title_id: int) -> Optional[int]: + sql = insert(profile).values( + user=user_id, + user_type=user_type, + nick_name=nick_name, + rank_num=rank_num, + rank_exp=rank_exp, + own_col=own_col, + own_vp=own_vp, + own_yui_medal=own_yui_medal, + setting_title_id=setting_title_id + ) + + conflict = sql.on_duplicate_key_update( + rank_num=rank_num, + rank_exp=rank_exp, + own_col=own_col, + own_vp=own_vp, + own_yui_medal=own_yui_medal, + setting_title_id=setting_title_id + ) + + result = self.execute(conflict) + if result is None: + self.logger.error( + f"{__name__} failed to insert profile! user: {user_id}" + ) + return None + + print(result.lastrowid) + return result.lastrowid + def get_profile(self, user_id: int) -> Optional[Row]: sql = profile.select(profile.c.user == user_id) result = self.execute(sql) From a2fe11d654553cdee1276b2c3e68ee436594d099 Mon Sep 17 00:00:00 2001 From: Midorica Date: Mon, 29 May 2023 20:57:02 -0400 Subject: [PATCH 16/49] Fixing level calculation saving & loading on SAO --- titles/sao/base.py | 22 ++++++++++++++++++++-- titles/sao/data/HeroLogLevel.csv | Bin 0 -> 1249 bytes titles/sao/data/PlayerRank.csv | Bin 0 -> 3548 bytes titles/sao/handlers/base.py | 29 +++++++++++++++++++++++++---- 4 files changed, 45 insertions(+), 6 deletions(-) create mode 100644 titles/sao/data/HeroLogLevel.csv create mode 100644 titles/sao/data/PlayerRank.csv diff --git a/titles/sao/base.py b/titles/sao/base.py index 609c5ac..9626647 100644 --- a/titles/sao/base.py +++ b/titles/sao/base.py @@ -3,6 +3,7 @@ import json, logging from typing import Any, Dict import random import struct +import csv from core.data import Data from core import CoreConfig @@ -29,7 +30,6 @@ class SaoBase: def handle_c122(self, request: Any) -> bytes: #common/get_maintenance_info - resp = SaoGetMaintResponse(int.from_bytes(bytes.fromhex(request[:4]), "big")+1) return resp.make() @@ -438,11 +438,29 @@ class SaoBase: exp = int(profile["rank_exp"]) + 100 #always 100 extra exp for some reason col = int(profile["own_col"]) + int(req_data.base_get_data[0].get_col) + # Calculate level based off experience and the CSV list + with open(r'titles/sao/data/PlayerRank.csv') as csv_file: + csv_reader = csv.reader(csv_file, delimiter=',') + line_count = 0 + data = [] + rowf = False + for row in csv_reader: + if rowf==False: + rowf=True + else: + data.append(row) + + for i in range(0,len(data)): + if exp>=int(data[i][1]) and expmC8Iij@ON6%*EoS*NTKeUCLV zYL7K`<vd#t#6aw7qw zvKSdymDvO$Rm~LAvzd{o>dGAA)u{y}s(VXFc9snt73%{^h=`g`=qfaPk=(lHq}ggV zC(oVFjf_`+?$F(*0UI5guntI#COVXYsmx*URb7R{yO*Gx zT3A6nHAg`~H8V*?H6KYybqBeqvYI>vVbx12qGQu2y{eXu?Cvv=tM4VUG0lziLVOQo z_q-=^SLVI2TYp=QUJsI+%I#{K(ruw{$L=XNbu}9DO<@hGIX9Ixn!59f=IAiqq|VNF zo+G)x?8D4+|KRqtjk7%M^Ik58Wyux4lZl1PE{CPB7{2+{LA=Iy|DQv3-zOCK?NH;l ZLz&+Wm3})E`|VKgw?oNa4*w08^ACea@AV}NG|c$pFjTk z&maH%@%wMT{M-KZ-#>r+{Kx)y;a4?OkQpHFTh&fvFU?8>KUvwmA)Ab;Do>bGM%%&_CyK6-j~!Xf-?cAlQPUF~qU zryV}*g-d)l2_#;BeBpNb=Mg|`{x~6|s~;zbk+J&Auy{KfPZ`nk|83?m8>M^XJ zJa1rw&*CjMu6AJKX~!{d+HuW$+87~K?wx2gQq9@TQ<%?mp3=WL%vYW_Cz^%`pSjFW zo;5RZ1uFeyDi1B-PTU8_OO+y`RqwnGorn0Pwm*`SKl2w%GSPQT5rku z&K;*we`)uS^DL*&al6QZSBIP{@-yv))BLum1MMIm4aMU}k{%z%BU$ftBxb;*0b zY5bCSSNxaEyWG5_et4dH$^DRfm*h`raIrQO`o-Gn8QsO&+Bz3&rNhJ8mYff3$Mf5V zwL{c6`LK4S#fwUl;16qWr4Nrfh#=zIC^qTwvfA_k<<_+=*6ylhBB%Hy3gYmrYU+)$ zn>xEU3U4W6G*Xmf27rPTWVR~(j@9zJH_RSYes9>l5(k+n(V6b_#DX-TB`H{n9p^h} z7+nI93mdL{^u8!y>D~LLg!w$bKRin$5(Z!r7@R}t@7fC}#!-)Vr~IQFVlS{5S3N_9 zDSXvQ2N<)J&mdz~--8L8h@!o;!chHnz%l3deA?lw74Vo_J=DR+th%OdEL{#CK*+qM z6%d)Pas)DLG*;;|jxGN_Ut8n*&7q(mtEWdh_r3h&wRF5X$55VE)BuVKBX<7+2LmR0BTMvt$@v`e(T_d z6v-ceb4KM9Ov#fDg6=G> z_O`p#d$c1^soDla_g2mC2VzPqVDxD0JRcmrA~F37e5$7cNvpI9DDBEoVo5~qXWM(~ z*q&sHyo6Z6rbg>&0WVs?83$M_5upd6vR-3PFsj{(A}H0K$kKyT{3-4r6?s-82dm(g zlnPo=@wEs#9uW=fu=qA}Fw0wIBdFzFWbDB$kBcg(u9*n89^FFvM-UB}tMJzUNJeZTJhw0lSMD8_bq-4n9GFq? zH77u`K9AyYaMtJ3J>~F>luJZ{XW{BVhiH8j@g1b0q3%E~d=^(CI#BDYy4;}}cYyNa zV6E?Pny?X*#gd2)*y4W3I)LIV>IPtd;ErgzyB)SsdlHe^g2fcA9J*0uDmRaL=-dh+ zMj|Rg8pKp|xl+=D7>sUR2OhK0U0*wRaO*xTq=^>Ba z;K8fz1u!h?T5wQDDoZz5NA|u8E|s>_!5+g?`yLSUQ~Ty8MyTxhFd9=-ALULAQshJ; z#w>***$W_@#jD_AqC#+m5JMHlYJmr+s}SHGqLhe$4;@Ng$j6OZ!h=2^5p}o&`v|b? z06#8U(ZHb}aH<4@KW@EBJ^bS=9LNLkfU;Bw5RehE4gi6^sG$4|2Kt(%8xZ8KS35ft zKv3e1nF5PBkDnWE`m0 zgol1F zlAekOCNdriTv%sONCV%@9!wY7!;k@96gEs`DiI)^8IGx1=)_58l&^>|armhGz{KHo zoj6PcVw79B0}~m>OGKDBMq!-+p2ZgM35-SEo6b}>Catam@BrB)6-*>=ssrL>@pRw( E4NDV+VgLXD literal 0 HcmV?d00001 diff --git a/titles/sao/handlers/base.py b/titles/sao/handlers/base.py index 3bd6d9c..0c74182 100644 --- a/titles/sao/handlers/base.py +++ b/titles/sao/handlers/base.py @@ -2,6 +2,7 @@ import struct from datetime import datetime from construct import * import sys +import csv class SaoBaseRequest: def __init__(self, data: bytes) -> None: @@ -493,9 +494,29 @@ class SaoGetHeroLogUserDataListResponse(SaoBaseResponse): self.last_set_skill_slot5_skill_id = [] for i in range(len(hero_data)): + + # Calculate level based off experience and the CSV list + with open(r'titles/sao/data/HeroLogLevel.csv') as csv_file: + csv_reader = csv.reader(csv_file, delimiter=',') + line_count = 0 + data = [] + rowf = False + for row in csv_reader: + if rowf==False: + rowf=True + else: + data.append(row) + + exp = hero_data[i][4] + + for e in range(0,len(data)): + if exp>=int(data[e][1]) and exp Date: Tue, 30 May 2023 12:14:18 +0200 Subject: [PATCH 17/49] cm: Added individual Card Maker version and maimai DX passes working --- core/data/schema/versions/SDEZ_4_rollback.sql | 3 ++ core/data/schema/versions/SDEZ_5_upgrade.sql | 3 ++ docs/game_specific_info.md | 53 +++++++++++++++---- example_config/cardmaker.yaml | 10 ++++ readme.md | 2 +- titles/cm/base.py | 38 ++++++++++--- titles/cm/cm135.py | 22 +------- titles/cm/config.py | 16 ++++++ titles/cm/const.py | 2 +- titles/cm/index.py | 4 +- titles/mai2/__init__.py | 2 +- titles/mai2/schema/item.py | 12 +++-- titles/mai2/universe.py | 34 ++++++++++-- 13 files changed, 150 insertions(+), 51 deletions(-) create mode 100644 core/data/schema/versions/SDEZ_4_rollback.sql create mode 100644 core/data/schema/versions/SDEZ_5_upgrade.sql diff --git a/core/data/schema/versions/SDEZ_4_rollback.sql b/core/data/schema/versions/SDEZ_4_rollback.sql new file mode 100644 index 0000000..b8be7b3 --- /dev/null +++ b/core/data/schema/versions/SDEZ_4_rollback.sql @@ -0,0 +1,3 @@ +ALTER TABLE mai2_item_card + CHANGE COLUMN startDate startDate TIMESTAMP DEFAULT "2018-01-01 00:00:00.0", + CHANGE COLUMN endDate endDate TIMESTAMP DEFAULT "2038-01-01 00:00:00.0"; \ No newline at end of file diff --git a/core/data/schema/versions/SDEZ_5_upgrade.sql b/core/data/schema/versions/SDEZ_5_upgrade.sql new file mode 100644 index 0000000..cc4912f --- /dev/null +++ b/core/data/schema/versions/SDEZ_5_upgrade.sql @@ -0,0 +1,3 @@ +ALTER TABLE mai2_item_card + CHANGE COLUMN startDate startDate TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CHANGE COLUMN endDate endDate TIMESTAMP NOT NULL; \ No newline at end of file diff --git a/docs/game_specific_info.md b/docs/game_specific_info.md index 5361ce9..aa0a39f 100644 --- a/docs/game_specific_info.md +++ b/docs/game_specific_info.md @@ -253,13 +253,13 @@ python dbutils.py --game SDDT upgrade | Version ID | Version Name | |------------|-----------------| -| 0 | Card Maker 1.34 | +| 0 | Card Maker 1.30 | | 1 | Card Maker 1.35 | ### Support status -* Card Maker 1.34: +* Card Maker 1.30: * CHUNITHM NEW!!: Yes * maimai DX UNiVERSE: Yes * O.N.G.E.K.I. Bright: Yes @@ -285,19 +285,46 @@ python read.py --series SDED --version --binfolder titles/cm/cm_dat python read.py --series SDDT --version --binfolder /path/to/game/folder --optfolder /path/to/game/option/folder ``` -Also make sure to import all maimai and Chunithm data as well: +Also make sure to import all maimai DX and CHUNITHM data as well: ```shell python read.py --series SDED --version --binfolder /path/to/cardmaker/CardMaker_Data ``` -The importer for Card Maker will import all required Gachas (Banners) and cards (for maimai/Chunithm) and the hardcoded +The importer for Card Maker will import all required Gachas (Banners) and cards (for maimai DX/CHUNITHM) and the hardcoded Cards for each Gacha (O.N.G.E.K.I. only). **NOTE: Without executing the importer Card Maker WILL NOT work!** -### O.N.G.E.K.I. Gachas +### Config setup + +Make sure to update your `config/cardmaker.yaml` with the correct version for each game. To get the current version required to run a specific game, open every opt (Axxx) folder descending until you find all three folders: + +- `MU3`: O.N.G.E.K.I. +- `MAI`: maimai DX +- `CHU`: CHUNITHM + +Inside each folder is a `DataConfig.xml` file, for example: + +`MU3/DataConfig.xml`: +```xml + + 1 + 35 + 3 + +``` + +Now update your `config/cardmaker.yaml` with the correct version number, for example: + +```yaml +version: + 1: # Card Maker 1.35 + ongeki: 1.35.03 +``` + +### O.N.G.E.K.I. Gacha "無料ガチャ" can only pull from the free cards with the following probabilities: 94%: R, 5% SR and 1% chance of getting an SSR card @@ -310,20 +337,24 @@ and 3% chance of getting an SSR card All other (limited) gachas can pull from every card added to ongeki_static_cards but with the promoted cards (click on the green button under the banner) having a 10 times higher chance to get pulled -### Chunithm Gachas +### CHUNITHM -All cards in Chunithm (basically just the characters) have the same rarity to it just pulls randomly from all cards +All cards in CHUNITHM (basically just the characters) have the same rarity to it just pulls randomly from all cards from a given gacha but made sure you cannot pull the same card twice in the same 5 times gacha roll. +### maimai DX + +Printed maimai DX cards: Freedom (`cardTypeId=6`) or Gold Pass (`cardTypeId=4`) can now be selected during the login process. You can only have ONE Freedom and ONE Gold Pass active at a given time. The cards will expire after 15 days. + +Thanks GetzeAvenue for the `selectedCardList` rarity hint! + ### Notes -Card Maker 1.34 will only load an O.N.G.E.K.I. Bright profile (1.30). Card Maker 1.35 will only load an O.N.G.E.K.I. +Card Maker 1.30-1.34 will only load an O.N.G.E.K.I. Bright profile (1.30). Card Maker 1.35+ will only load an O.N.G.E.K.I. Bright Memory profile (1.35). -The gachas inside the `ongeki.yaml` will make sure only the right gacha ids for the right CM version will be loaded. +The gachas inside the `config/ongeki.yaml` will make sure only the right gacha ids for the right CM version will be loaded. Gacha IDs up to 1140 will be loaded for CM 1.34 and all gachas will be loaded for CM 1.35. -**NOTE: There is currently no way to load/use the (printed) maimai DX cards!** - ## WACCA ### SDFE diff --git a/example_config/cardmaker.yaml b/example_config/cardmaker.yaml index a04dda5..fb17756 100644 --- a/example_config/cardmaker.yaml +++ b/example_config/cardmaker.yaml @@ -1,3 +1,13 @@ server: enable: True loglevel: "info" + +version: + 0: + ongeki: 1.30.01 + chuni: 2.00.00 + maimai: 1.20.00 + 1: + ongeki: 1.35.03 + chuni: 2.10.00 + maimai: 1.30.00 \ No newline at end of file diff --git a/readme.md b/readme.md index ee407cd..f114bb0 100644 --- a/readme.md +++ b/readme.md @@ -17,7 +17,7 @@ Games listed below have been tested and confirmed working. Only game versions ol + All versions + Card Maker - + 1.34 + + 1.30 + 1.35 + O.N.G.E.K.I. diff --git a/titles/cm/base.py b/titles/cm/base.py index ff38489..dae6ecb 100644 --- a/titles/cm/base.py +++ b/titles/cm/base.py @@ -23,19 +23,40 @@ class CardMakerBase: self.game = CardMakerConstants.GAME_CODE self.version = CardMakerConstants.VER_CARD_MAKER + @staticmethod + def _parse_int_ver(version: str) -> str: + return version.replace(".", "")[:3] + def handle_get_game_connect_api_request(self, data: Dict) -> Dict: if self.core_cfg.server.is_develop: uri = f"http://{self.core_cfg.title.hostname}:{self.core_cfg.title.port}" else: uri = f"http://{self.core_cfg.title.hostname}" - # CHUNITHM = 0, maimai = 1, ONGEKI = 2 + # grab the dict with all games version numbers from user config + games_ver = self.game_cfg.version.version(self.version) + return { "length": 3, "gameConnectList": [ - {"modelKind": 0, "type": 1, "titleUri": f"{uri}/SDHD/200/"}, - {"modelKind": 1, "type": 1, "titleUri": f"{uri}/SDEZ/120/"}, - {"modelKind": 2, "type": 1, "titleUri": f"{uri}/SDDT/130/"}, + # CHUNITHM + { + "modelKind": 0, + "type": 1, + "titleUri": f"{uri}/SDHD/{self._parse_int_ver(games_ver['chuni'])}/", + }, + # maimai DX + { + "modelKind": 1, + "type": 1, + "titleUri": f"{uri}/SDEZ/{self._parse_int_ver(games_ver['maimai'])}/", + }, + # ONGEKI + { + "modelKind": 2, + "type": 1, + "titleUri": f"{uri}/SDDT/{self._parse_int_ver(games_ver['ongeki'])}/", + }, ], } @@ -47,12 +68,15 @@ class CardMakerBase: datetime.now() + timedelta(hours=4), self.date_time_format ) + # grab the dict with all games version numbers from user config + games_ver = self.game_cfg.version.version(self.version) + return { "gameSetting": { "dataVersion": "1.30.00", - "ongekiCmVersion": "1.30.01", - "chuniCmVersion": "2.00.00", - "maimaiCmVersion": "1.20.00", + "ongekiCmVersion": games_ver["ongeki"], + "chuniCmVersion": games_ver["chuni"], + "maimaiCmVersion": games_ver["maimai"], "requestInterval": 10, "rebootStartTime": reboot_start, "rebootEndTime": reboot_end, diff --git a/titles/cm/cm135.py b/titles/cm/cm135.py index 782f07a..e134974 100644 --- a/titles/cm/cm135.py +++ b/titles/cm/cm135.py @@ -1,8 +1,4 @@ -from datetime import date, datetime, timedelta -from typing import Any, Dict, List -import json -import logging -from enum import Enum +from typing import Dict from core.config import CoreConfig from core.data.cache import cached @@ -16,23 +12,7 @@ class CardMaker135(CardMakerBase): super().__init__(core_cfg, game_cfg) self.version = CardMakerConstants.VER_CARD_MAKER_135 - def handle_get_game_connect_api_request(self, data: Dict) -> Dict: - ret = super().handle_get_game_connect_api_request(data) - if self.core_cfg.server.is_develop: - uri = f"http://{self.core_cfg.title.hostname}:{self.core_cfg.title.port}" - else: - uri = f"http://{self.core_cfg.title.hostname}" - - ret["gameConnectList"][0]["titleUri"] = f"{uri}/SDHD/205/" - ret["gameConnectList"][1]["titleUri"] = f"{uri}/SDEZ/125/" - ret["gameConnectList"][2]["titleUri"] = f"{uri}/SDDT/135/" - - return ret - def handle_get_game_setting_api_request(self, data: Dict) -> Dict: ret = super().handle_get_game_setting_api_request(data) ret["gameSetting"]["dataVersion"] = "1.35.00" - ret["gameSetting"]["ongekiCmVersion"] = "1.35.03" - ret["gameSetting"]["chuniCmVersion"] = "2.05.00" - ret["gameSetting"]["maimaiCmVersion"] = "1.25.00" return ret diff --git a/titles/cm/config.py b/titles/cm/config.py index ea96ca1..8bb23ec 100644 --- a/titles/cm/config.py +++ b/titles/cm/config.py @@ -1,3 +1,4 @@ +from typing import Dict from core.config import CoreConfig @@ -20,6 +21,21 @@ class CardMakerServerConfig: ) +class CardMakerVersionConfig: + def __init__(self, parent_config: "CardMakerConfig") -> None: + self.__config = parent_config + + def version(self, version: int) -> Dict: + """ + in the form of: + 1: {"ongeki": 1.30.01, "chuni": 2.00.00, "maimai": 1.20.00} + """ + return CoreConfig.get_config_field( + self.__config, "cardmaker", "version", default={} + )[version] + + class CardMakerConfig(dict): def __init__(self) -> None: self.server = CardMakerServerConfig(self) + self.version = CardMakerVersionConfig(self) diff --git a/titles/cm/const.py b/titles/cm/const.py index 09f289e..5bb6d1f 100644 --- a/titles/cm/const.py +++ b/titles/cm/const.py @@ -6,7 +6,7 @@ class CardMakerConstants: VER_CARD_MAKER = 0 VER_CARD_MAKER_135 = 1 - VERSION_NAMES = ("Card Maker 1.34", "Card Maker 1.35") + VERSION_NAMES = ("Card Maker 1.30", "Card Maker 1.35") @classmethod def game_ver_to_string(cls, ver: int): diff --git a/titles/cm/index.py b/titles/cm/index.py index 74d3a0d..3bde49c 100644 --- a/titles/cm/index.py +++ b/titles/cm/index.py @@ -30,7 +30,7 @@ class CardMakerServlet: self.versions = [ CardMakerBase(core_cfg, self.game_cfg), - CardMaker135(core_cfg, self.game_cfg), + CardMaker135(core_cfg, self.game_cfg) ] self.logger = logging.getLogger("cardmaker") @@ -89,7 +89,7 @@ class CardMakerServlet: if version >= 130 and version < 135: # Card Maker internal_ver = CardMakerConstants.VER_CARD_MAKER - elif version >= 135 and version < 136: # Card Maker 1.35 + elif version >= 135 and version < 140: # Card Maker 1.35 internal_ver = CardMakerConstants.VER_CARD_MAKER_135 if all(c in string.hexdigits for c in endpoint) and len(endpoint) == 32: diff --git a/titles/mai2/__init__.py b/titles/mai2/__init__.py index 810eac9..c9d7db2 100644 --- a/titles/mai2/__init__.py +++ b/titles/mai2/__init__.py @@ -7,4 +7,4 @@ index = Mai2Servlet database = Mai2Data reader = Mai2Reader game_codes = [Mai2Constants.GAME_CODE] -current_schema_version = 4 +current_schema_version = 5 diff --git a/titles/mai2/schema/item.py b/titles/mai2/schema/item.py index 6280bbb..6b70ed1 100644 --- a/titles/mai2/schema/item.py +++ b/titles/mai2/schema/item.py @@ -39,8 +39,8 @@ card = Table( Column("cardTypeId", Integer, nullable=False), Column("charaId", Integer, nullable=False), Column("mapId", Integer, nullable=False), - Column("startDate", TIMESTAMP, server_default="2018-01-01 00:00:00.0"), - Column("endDate", TIMESTAMP, server_default="2038-01-01 00:00:00.0"), + Column("startDate", TIMESTAMP, nullable=False, server_default=func.now()), + Column("endDate", TIMESTAMP, nullable=False), UniqueConstraint("user", "cardId", "cardTypeId", name="mai2_item_card_uk"), mysql_charset="utf8mb4", ) @@ -444,6 +444,8 @@ class Mai2ItemData(BaseData): card_kind: int, chara_id: int, map_id: int, + start_date: datetime, + end_date: datetime, ) -> Optional[Row]: sql = insert(card).values( user=user_id, @@ -451,9 +453,13 @@ class Mai2ItemData(BaseData): cardTypeId=card_kind, charaId=chara_id, mapId=map_id, + startDate=start_date, + endDate=end_date, ) - conflict = sql.on_duplicate_key_update(charaId=chara_id, mapId=map_id) + conflict = sql.on_duplicate_key_update( + charaId=chara_id, mapId=map_id, startDate=start_date, endDate=end_date + ) result = self.execute(conflict) if result is None: diff --git a/titles/mai2/universe.py b/titles/mai2/universe.py index 56b3e8f..7cbd159 100644 --- a/titles/mai2/universe.py +++ b/titles/mai2/universe.py @@ -104,8 +104,12 @@ class Mai2Universe(Mai2Base): tmp.pop("id") tmp.pop("user") - tmp["startDate"] = datetime.strftime(tmp["startDate"], "%Y-%m-%d %H:%M:%S") - tmp["endDate"] = datetime.strftime(tmp["endDate"], "%Y-%m-%d %H:%M:%S") + tmp["startDate"] = datetime.strftime( + tmp["startDate"], Mai2Constants.DATE_TIME_FORMAT + ) + tmp["endDate"] = datetime.strftime( + tmp["endDate"], Mai2Constants.DATE_TIME_FORMAT + ) card_list.append(tmp) return { @@ -154,6 +158,10 @@ class Mai2Universe(Mai2Base): # set a random card serial number serial_id = "".join([str(randint(0, 9)) for _ in range(20)]) + # calculate start and end date of the card + start_date = datetime.utcnow() + end_date = datetime.utcnow() + timedelta(days=15) + user_card = upsert["userCard"] self.data.item.put_card( user_id, @@ -161,8 +169,26 @@ class Mai2Universe(Mai2Base): user_card["cardTypeId"], user_card["charaId"], user_card["mapId"], + # add the correct start date and also the end date in 15 days + start_date, + end_date, ) + # get the profile extend to save the new bought card + extend = self.data.profile.get_profile_extend(user_id, self.version) + if extend: + extend = extend._asdict() + # parse the selectedCardList + # 6 = Freedom Pass, 4 = Gold Pass (cardTypeId) + selected_cards: list = extend["selectedCardList"] + + # if no pass is already added, add the corresponding pass + if not user_card["cardTypeId"] in selected_cards: + selected_cards.insert(0, user_card["cardTypeId"]) + + extend["selectedCardList"] = selected_cards + self.data.profile.put_profile_extend(user_id, self.version, extend) + # properly format userPrintDetail for the database upsert.pop("userCard") upsert.pop("serialId") @@ -174,8 +200,8 @@ class Mai2Universe(Mai2Base): "returnCode": 1, "orderId": 0, "serialId": serial_id, - "startDate": "2018-01-01 00:00:00", - "endDate": "2038-01-01 00:00:00", + "startDate": datetime.strftime(start_date, Mai2Constants.DATE_TIME_FORMAT), + "endDate": datetime.strftime(end_date, Mai2Constants.DATE_TIME_FORMAT), } def handle_cm_upsert_user_printlog_api_request(self, data: Dict) -> Dict: From e466ddce551dcc664017a4b3fdd110ed57e7d93a Mon Sep 17 00:00:00 2001 From: Midorica Date: Tue, 30 May 2023 14:29:50 -0400 Subject: [PATCH 18/49] Adding SAO rewards saving for heroes --- titles/sao/base.py | 25 +++++++++++++++++++++++-- titles/sao/data/RewardTable.csv | Bin 0 -> 289223 bytes titles/sao/index.py | 1 - titles/sao/schema/static.py | 8 ++++++++ 4 files changed, 31 insertions(+), 3 deletions(-) create mode 100644 titles/sao/data/RewardTable.csv diff --git a/titles/sao/base.py b/titles/sao/base.py index 9626647..62fa367 100644 --- a/titles/sao/base.py +++ b/titles/sao/base.py @@ -3,7 +3,9 @@ import json, logging from typing import Any, Dict import random import struct -import csv +from csv import * +from random import choice +import random as rand from core.data import Data from core import CoreConfig @@ -495,6 +497,25 @@ class SaoBase: hero_data["skill_slot4_skill_id"], hero_data["skill_slot5_skill_id"] ) + + # Generate random hero(es) based off the response + for a in range(0,req_data.get_unanalyzed_log_tmp_reward_data_list_length): + + with open('titles/sao/data/RewardTable.csv', 'r') as f: + keys_unanalyzed = next(f).strip().split(',') + data_unanalyzed = list(DictReader(f, fieldnames=keys_unanalyzed)) + + randomized_unanalyzed_id = choice(data_unanalyzed) + while int(randomized_unanalyzed_id['UnanalyzedLogGradeId']) != req_data.get_unanalyzed_log_tmp_reward_data_list[a].unanalyzed_log_grade_id: + randomized_unanalyzed_id = choice(data_unanalyzed) + + heroList = self.game_data.static.get_hero_id(randomized_unanalyzed_id['CommonRewardId']) + if heroList: + self.game_data.item.put_hero_log(req_data.user_id, randomized_unanalyzed_id['CommonRewardId'], 1, 0, 101000016, 0, 30086, 1001, 1002, 0, 0) + + # Item and Equipments saving will be done later here + + # Send response resp = SaoEpisodePlayEndResponse(int.from_bytes(bytes.fromhex(request[:4]), "big")+1) return resp.make() @@ -511,4 +532,4 @@ class SaoBase: def handle_c90a(self, request: Any) -> bytes: #should be tweaked for proper item unlock #quest/episode_play_end_unanalyzed_log_fixed resp = SaoEpisodePlayEndUnanalyzedLogFixedResponse(int.from_bytes(bytes.fromhex(request[:4]), "big")+1) - return resp.make() + return resp.make() \ No newline at end of file diff --git a/titles/sao/data/RewardTable.csv b/titles/sao/data/RewardTable.csv new file mode 100644 index 0000000000000000000000000000000000000000..acce5bd0194312efd2fa0198fe506936d4931b14 GIT binary patch literal 289223 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

h*?f^ri|X=o^_TemiEk?A%(_T?_hTr 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

_p3 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 literal 0 HcmV?d00001 diff --git a/titles/sao/index.py b/titles/sao/index.py index 2c903ce..b74e500 100644 --- a/titles/sao/index.py +++ b/titles/sao/index.py @@ -113,5 +113,4 @@ class SaoServlet(resource.Resource): self.logger.info(f"Handler {req_url} - {sao_request[:4]} request") self.logger.debug(f"Request: {request.content.getvalue().hex()}") - self.logger.debug(f"Response: {handler(sao_request).hex()}") return handler(sao_request) \ No newline at end of file diff --git a/titles/sao/schema/static.py b/titles/sao/schema/static.py index 9e96aaf..ca000b8 100644 --- a/titles/sao/schema/static.py +++ b/titles/sao/schema/static.py @@ -246,6 +246,14 @@ class SaoStaticData(BaseData): return None return [list[2] for list in result.fetchall()] + def get_hero_id(self, heroLogId: int) -> Optional[Dict]: + sql = hero.select(hero.c.heroLogId == heroLogId) + + result = self.execute(sql) + if result is None: + return None + return result.fetchone() + def get_hero_ids(self, version: int, enabled: bool) -> Optional[List[Dict]]: sql = hero.select(hero.c.version == version and hero.c.enabled == enabled).order_by( hero.c.heroLogId.asc() From bf6d126f8a8809bcda127f5850a53a7b8f9a0829 Mon Sep 17 00:00:00 2001 From: Midorica Date: Tue, 30 May 2023 18:03:52 -0400 Subject: [PATCH 19/49] Equipments saving for SAO now completed --- readme.md | 3 ++ titles/sao/base.py | 26 ++++++++--- titles/sao/data/EquipmentLevel.csv | Bin 0 -> 1211 bytes titles/sao/handlers/base.py | 68 ++++++++++++++++++++++------- titles/sao/schema/item.py | 67 +++++++++++++++++++++++++++- titles/sao/schema/static.py | 8 ++++ 6 files changed, 150 insertions(+), 22 deletions(-) create mode 100644 titles/sao/data/EquipmentLevel.csv diff --git a/readme.md b/readme.md index ee407cd..f16010e 100644 --- a/readme.md +++ b/readme.md @@ -30,6 +30,9 @@ Games listed below have been tested and confirmed working. Only game versions ol + POKKÉN TOURNAMENT + Final Online ++ Sword Art Online Arcade (partial support) + + Final + ## Requirements - python 3 (tested working with 3.9 and 3.10, other versions YMMV) - pip diff --git a/titles/sao/base.py b/titles/sao/base.py index 62fa367..543f488 100644 --- a/titles/sao/base.py +++ b/titles/sao/base.py @@ -81,6 +81,9 @@ class SaoBase: self.game_data.item.put_hero_log(user_id, 102000010, 1, 0, 103000006, 0, 30086, 1001, 1002, 1003, 1005) self.game_data.item.put_hero_log(user_id, 103000010, 1, 0, 112000009, 0, 30086, 1001, 1002, 1003, 1005) self.game_data.item.put_hero_party(user_id, 0, 101000010, 102000010, 103000010) + self.game_data.item.put_equipment_data(user_id, 101000016, 1, 200, 0, 0, 0) + self.game_data.item.put_equipment_data(user_id, 103000006, 1, 200, 0, 0, 0) + self.game_data.item.put_equipment_data(user_id, 112000009, 1, 200, 0, 0, 0) self.logger.info(f"User Authenticated: { access_code } | { user_id }") @@ -145,9 +148,19 @@ class SaoBase: def handle_c602(self, request: Any) -> bytes: #have_object/get_equipment_user_data_list - equipmentIdsData = self.game_data.static.get_equipment_ids(0, True) - - resp = SaoGetEquipmentUserDataListResponse(int.from_bytes(bytes.fromhex(request[:4]), "big")+1, equipmentIdsData) + req = bytes.fromhex(request)[24:] + req_struct = Struct( + Padding(16), + "user_id_size" / Rebuild(Int32ub, len_(this.user_id) * 2), # calculates the length of the user_id + "user_id" / PaddedString(this.user_id_size, "utf_16_le"), # user_id is a (zero) padded string + + ) + req_data = req_struct.parse(req) + user_id = req_data.user_id + + equipment_data = self.game_data.item.get_user_equipments(user_id) + + resp = SaoGetEquipmentUserDataListResponse(int.from_bytes(bytes.fromhex(request[:4]), "big")+1, equipment_data) return resp.make() def handle_c604(self, request: Any) -> bytes: @@ -510,10 +523,11 @@ class SaoBase: randomized_unanalyzed_id = choice(data_unanalyzed) heroList = self.game_data.static.get_hero_id(randomized_unanalyzed_id['CommonRewardId']) + equipmentList = self.game_data.static.get_equipment_id(randomized_unanalyzed_id['CommonRewardId']) if heroList: self.game_data.item.put_hero_log(req_data.user_id, randomized_unanalyzed_id['CommonRewardId'], 1, 0, 101000016, 0, 30086, 1001, 1002, 0, 0) - - # Item and Equipments saving will be done later here + if equipmentList: + self.game_data.item.put_equipment_data(req_data.user_id, randomized_unanalyzed_id['CommonRewardId'], 1, 200, 0, 0, 0) # Send response @@ -532,4 +546,4 @@ class SaoBase: def handle_c90a(self, request: Any) -> bytes: #should be tweaked for proper item unlock #quest/episode_play_end_unanalyzed_log_fixed resp = SaoEpisodePlayEndUnanalyzedLogFixedResponse(int.from_bytes(bytes.fromhex(request[:4]), "big")+1) - return resp.make() \ No newline at end of file + return resp.make() diff --git a/titles/sao/data/EquipmentLevel.csv b/titles/sao/data/EquipmentLevel.csv new file mode 100644 index 0000000000000000000000000000000000000000..803bf7ecc486d9815da867869925ceaa34b35706 GIT binary patch literal 1211 zcmX|>OKucF5C!)>%UjepKL_vviIvYlFOUEk1c>AFDj$?LU6YEb=-S%u-@iWJ zetvv@KVLq4!rQ~ikLUHz`%A}Y4KD-hTqf*X7UEnsdOKG`FUZl03iQGfy|~`aCImZo zE3yxaLi@xhwl5698&j}h3U*AvfhjmK3m)y<7k3^VtKh*qEqF|1@Bftxb^N_6}|^o#qY^gePJ)I^bICd!_A1QxS3HMH$$rA zW=gd*I=C?%Mu%z|9m;8RsHf4Pphky^8XZb%bf~G(p{PcOsyZFKWjdS=b#*!v*6C1L zr$cF-4z+bU6xZocU8h5NoeuR4I`}f^2s%_a=uqOILydzDMGiVtIp|R4phKO54uuXn zR66NU>ZC)hlMcmBI#fI9Q0}Bdy^{_FPdZdQ=}_{dL(N%_G0z{5SEJ^=K(sO_kQ4^zaRbrs$1IY literal 0 HcmV?d00001 diff --git a/titles/sao/handlers/base.py b/titles/sao/handlers/base.py index 0c74182..8ed8ba0 100644 --- a/titles/sao/handlers/base.py +++ b/titles/sao/handlers/base.py @@ -660,19 +660,57 @@ class SaoGetEquipmentUserDataListRequest(SaoBaseRequest): super().__init__(data) class SaoGetEquipmentUserDataListResponse(SaoBaseResponse): - def __init__(self, cmd, equipmentIdsData) -> None: + def __init__(self, cmd, equipment_data) -> None: super().__init__(cmd) self.result = 1 + + self.user_equipment_id = [] + self.enhancement_value = [] + self.max_enhancement_value_extended_num = [] + self.enhancement_exp = [] + self.awakening_stage = [] + self.awakening_exp = [] + self.possible_awakening_flag = [] + equipment_level = 0 + + for i in range(len(equipment_data)): + + # Calculate level based off experience and the CSV list + with open(r'titles/sao/data/EquipmentLevel.csv') as csv_file: + csv_reader = csv.reader(csv_file, delimiter=',') + line_count = 0 + data = [] + rowf = False + for row in csv_reader: + if rowf==False: + rowf=True + else: + data.append(row) + + exp = equipment_data[i][4] + + for e in range(0,len(data)): + if exp>=int(data[e][1]) and exp Optional[int]: + sql = insert(equipment_data).values( + user=user_id, + equipment_id=equipment_id, + enhancement_value=enhancement_value, + enhancement_exp=enhancement_exp, + awakening_exp=awakening_exp, + awakening_stage=awakening_stage, + possible_awakening_flag=possible_awakening_flag, + ) + + conflict = sql.on_duplicate_key_update( + enhancement_value=enhancement_value, + enhancement_exp=enhancement_exp, + awakening_exp=awakening_exp, + awakening_stage=awakening_stage, + possible_awakening_flag=possible_awakening_flag, + ) + + result = self.execute(conflict) + if result is None: + self.logger.error( + f"{__name__} failed to insert equipment! user: {user_id}, equipment_id: {equipment_id}" + ) + return None + + return result.lastrowid def put_hero_log(self, user_id: int, user_hero_log_id: int, log_level: int, log_exp: int, main_weapon: int, sub_equipment: int, skill_slot1_skill_id: int, skill_slot2_skill_id: int, skill_slot3_skill_id: int, skill_slot4_skill_id: int, skill_slot5_skill_id: int) -> Optional[int]: sql = insert(hero_log_data).values( @@ -144,6 +192,23 @@ class SaoItemData(BaseData): return None return result.lastrowid + + def get_user_equipments( + self, user_id: int + ) -> Optional[List[Row]]: + """ + A catch-all equipments lookup given a profile + """ + sql = equipment_data.select( + and_( + equipment_data.c.user == user_id, + ) + ) + + result = self.execute(sql) + if result is None: + return None + return result.fetchall() def get_hero_log( self, user_id: int, user_hero_log_id: int = None @@ -167,7 +232,7 @@ class SaoItemData(BaseData): self, user_id: int ) -> Optional[List[Row]]: """ - A catch-all hero lookup given a profile and user_party_team_id and ID specifiers + A catch-all hero lookup given a profile """ sql = hero_log_data.select( and_( diff --git a/titles/sao/schema/static.py b/titles/sao/schema/static.py index ca000b8..2635b5f 100644 --- a/titles/sao/schema/static.py +++ b/titles/sao/schema/static.py @@ -264,6 +264,14 @@ class SaoStaticData(BaseData): return None return [list[2] for list in result.fetchall()] + def get_equipment_id(self, equipmentId: int) -> Optional[Dict]: + sql = equipment.select(equipment.c.equipmentId == equipmentId) + + result = self.execute(sql) + if result is None: + return None + return result.fetchone() + def get_equipment_ids(self, version: int, enabled: bool) -> Optional[List[Dict]]: sql = equipment.select(equipment.c.version == version and equipment.c.enabled == enabled).order_by( equipment.c.equipmentId.asc() From 5c3f812caf6c6c187fb5344e6783d86f65dca281 Mon Sep 17 00:00:00 2001 From: Kevin Trocolli Date: Tue, 30 May 2023 21:30:34 -0400 Subject: [PATCH 20/49] cxb: fix missing parameters on render_POST --- titles/cxb/index.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/titles/cxb/index.py b/titles/cxb/index.py index 0c38d55..0ef8667 100644 --- a/titles/cxb/index.py +++ b/titles/cxb/index.py @@ -103,7 +103,7 @@ class CxbServlet(resource.Resource): else: self.logger.info(f"Ready on port {self.game_cfg.server.port}") - def render_POST(self, request: Request): + def render_POST(self, request: Request, version: int, endpoint: str): version = 0 internal_ver = 0 func_to_find = "" From 2418abaccec7e83d8c532e5cf335c767d3a760bd Mon Sep 17 00:00:00 2001 From: Kevin Trocolli Date: Tue, 30 May 2023 21:31:09 -0400 Subject: [PATCH 21/49] title: convert version to int to match POST endpoint --- core/title.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/title.py b/core/title.py index 7a0a99b..ab53773 100644 --- a/core/title.py +++ b/core/title.py @@ -84,7 +84,7 @@ class TitleServlet: request.setResponseCode(405) return b"" - return index.render_GET(request, endpoints["version"], endpoints["endpoint"]) + return index.render_GET(request, int(endpoints["version"]), endpoints["endpoint"]) def render_POST(self, request: Request, endpoints: dict) -> bytes: code = endpoints["game"] From 37d24b3b4dec072ead98dcdd4e1b3c606ee11856 Mon Sep 17 00:00:00 2001 From: Kevin Trocolli Date: Tue, 30 May 2023 21:32:27 -0400 Subject: [PATCH 22/49] mucha: now respects log level set in core.yaml --- core/mucha.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/core/mucha.py b/core/mucha.py index a90ab53..eecae3a 100644 --- a/core/mucha.py +++ b/core/mucha.py @@ -33,8 +33,8 @@ class MuchaServlet: self.logger.addHandler(fileHandler) self.logger.addHandler(consoleHandler) - self.logger.setLevel(logging.INFO) - coloredlogs.install(level=logging.INFO, logger=self.logger, fmt=log_fmt_str) + self.logger.setLevel(cfg.mucha.loglevel) + coloredlogs.install(level=cfg.mucha.loglevel, logger=self.logger, fmt=log_fmt_str) all_titles = Utils.get_all_titles() From 20865dc4950f7f464da065c94eabcb218aac165d Mon Sep 17 00:00:00 2001 From: Kevin Trocolli Date: Tue, 30 May 2023 21:45:37 -0400 Subject: [PATCH 23/49] allnet: add logging --- core/allnet.py | 1 + 1 file changed, 1 insertion(+) diff --git a/core/allnet.py b/core/allnet.py index 32ae177..bd2dfaf 100644 --- a/core/allnet.py +++ b/core/allnet.py @@ -225,6 +225,7 @@ class AllnetServlet: req_file = match["file"].replace("%0A", "") if path.exists(f"{self.config.allnet.update_cfg_folder}/{req_file}"): + self.logger.info(f"Request for DL INI file {req_file} successful") return open( f"{self.config.allnet.update_cfg_folder}/{req_file}", "rb" ).read() From ac9e71ee2f1a74a5b9171d851b2e5594854c8518 Mon Sep 17 00:00:00 2001 From: Kevin Trocolli Date: Tue, 30 May 2023 21:46:26 -0400 Subject: [PATCH 24/49] hotfix allnet logging --- core/allnet.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/allnet.py b/core/allnet.py index bd2dfaf..160f818 100644 --- a/core/allnet.py +++ b/core/allnet.py @@ -225,7 +225,7 @@ class AllnetServlet: req_file = match["file"].replace("%0A", "") if path.exists(f"{self.config.allnet.update_cfg_folder}/{req_file}"): - self.logger.info(f"Request for DL INI file {req_file} successful") + self.logger.info(f"Request for DL INI file {req_file} from {Utils.get_ip_addr(request)} successful") return open( f"{self.config.allnet.update_cfg_folder}/{req_file}", "rb" ).read() From db77e61b7987ff1b0e824e3fa4729750daf27aaa Mon Sep 17 00:00:00 2001 From: Kevin Trocolli Date: Tue, 30 May 2023 21:52:21 -0400 Subject: [PATCH 25/49] allnet: add event logging --- core/allnet.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/core/allnet.py b/core/allnet.py index 160f818..8af120b 100644 --- a/core/allnet.py +++ b/core/allnet.py @@ -216,6 +216,7 @@ class AllnetServlet: resp.uri += f"|http://{self.config.title.hostname}:{self.config.title.port}/dl/ini/{req.game_id}-{req.ver.replace('.', '')}-opt.ini" self.logger.debug(f"Sending download uri {resp.uri}") + self.data.base.log_event("allnet", "DLORDER_REQ_SUCCESS", logging.INFO, f"{Utils.get_ip_addr(request)} requested DL Order for {req.serial} {req.game_id} v{req.ver}") return self.dict_to_http_form_string([vars(resp)]) def handle_dlorder_ini(self, request: Request, match: Dict) -> bytes: @@ -226,6 +227,7 @@ class AllnetServlet: if path.exists(f"{self.config.allnet.update_cfg_folder}/{req_file}"): self.logger.info(f"Request for DL INI file {req_file} from {Utils.get_ip_addr(request)} successful") + self.data.base.log_event("allnet", "DLORDER_INI_SENT", logging.INFO, f"{Utils.get_ip_addr(request)} successfully recieved {req_file}") return open( f"{self.config.allnet.update_cfg_folder}/{req_file}", "rb" ).read() From cf6cfdbd3bb97dbb461a4d7a57a93cccba5b0682 Mon Sep 17 00:00:00 2001 From: Midorica Date: Wed, 31 May 2023 21:58:30 -0400 Subject: [PATCH 26/49] adding partial synthetize system for SAO --- titles/sao/base.py | 71 +++++++++++++++++++ titles/sao/handlers/base.py | 137 ++++++++++++++++++++++++++++++++++++ titles/sao/schema/item.py | 37 +++++++++- titles/sao/schema/static.py | 8 +++ 4 files changed, 252 insertions(+), 1 deletion(-) diff --git a/titles/sao/base.py b/titles/sao/base.py index 543f488..ac1b69c 100644 --- a/titles/sao/base.py +++ b/titles/sao/base.py @@ -244,6 +244,77 @@ class SaoBase: resp = SaoCheckProfileCardUsedRewardResponse(int.from_bytes(bytes.fromhex(request[:4]), "big")+1) return resp.make() + def handle_c816(self, request: Any) -> bytes: # not fully done yet + #custom/synthesize_enhancement_equipment + req = bytes.fromhex(request)[24:] + + req_struct = Struct( + Padding(20), + "ticket_id" / Bytes(1), # needs to be parsed as an int + Padding(1), + "user_id_size" / Rebuild(Int32ub, len_(this.user_id) * 2), # calculates the length of the user_id + "user_id" / PaddedString(this.user_id_size, "utf_16_le"), # user_id is a (zero) padded string + "origin_user_equipment_id_size" / Rebuild(Int32ub, len_(this.origin_user_equipment_id) * 2), # calculates the length of the origin_user_equipment_id + "origin_user_equipment_id" / PaddedString(this.origin_user_equipment_id_size, "utf_16_le"), # origin_user_equipment_id is a (zero) padded string + Padding(3), + "material_common_reward_user_data_list_length" / Rebuild(Int8ub, len_(this.material_common_reward_user_data_list)), # material_common_reward_user_data_list is a byte, + "material_common_reward_user_data_list" / Array(this.material_common_reward_user_data_list_length, Struct( + "common_reward_type" / Int16ub, # team_no is a byte + "user_common_reward_id_size" / Rebuild(Int32ub, len_(this.user_common_reward_id) * 2), # calculates the length of the user_common_reward_id + "user_common_reward_id" / PaddedString(this.user_common_reward_id_size, "utf_16_le"), # user_common_reward_id is a (zero) padded string + )), + ) + + req_data = req_struct.parse(req) + + user_id = req_data.user_id + synthesize_equipment_data = self.game_data.item.get_user_equipment(req_data.user_id, req_data.origin_user_equipment_id) + + for i in range(0,req_data.material_common_reward_user_data_list_length): + + itemList = self.game_data.static.get_item_id(req_data.material_common_reward_user_data_list[i].user_common_reward_id) + heroList = self.game_data.static.get_hero_id(req_data.material_common_reward_user_data_list[i].user_common_reward_id) + equipmentList = self.game_data.static.get_equipment_id(req_data.material_common_reward_user_data_list[i].user_common_reward_id) + + if itemList: + equipment_exp = 2000 + int(synthesize_equipment_data["enhancement_exp"]) + # Then delete the used item, function for items progression is not done yet... + + if equipmentList: + equipment_data = self.game_data.item.get_user_equipment(req_data.user_id, req_data.material_common_reward_user_data_list[i].user_common_reward_id) + equipment_exp = int(equipment_data["enhancement_exp"]) + int(synthesize_equipment_data["enhancement_exp"]) + self.game_data.item.remove_equipment(req_data.user_id, req_data.material_common_reward_user_data_list[i].user_common_reward_id) + + if heroList: + hero_data = self.game_data.item.get_hero_log(req_data.user_id, req_data.material_common_reward_user_data_list[i].user_common_reward_id) + equipment_exp = int(hero_data["log_exp"]) + int(synthesize_equipment_data["enhancement_exp"]) + self.game_data.item.remove_hero_log(req_data.user_id, req_data.material_common_reward_user_data_list[i].user_common_reward_id) + + self.game_data.item.put_equipment_data(req_data.user_id, int(req_data.origin_user_equipment_id), synthesize_equipment_data["enhancement_value"], equipment_exp, 0, 0, 0) + + profile = self.game_data.profile.get_profile(req_data.user_id) + new_col = int(profile["own_col"]) - 100 + + # Update profile + + self.game_data.profile.put_profile( + req_data.user_id, + profile["user_type"], + profile["nick_name"], + profile["rank_num"], + profile["rank_exp"], + new_col, + profile["own_vp"], + profile["own_yui_medal"], + profile["setting_title_id"] + ) + + # Load the item again to push to the response handler + synthesize_equipment_data = self.game_data.item.get_user_equipment(req_data.user_id, req_data.origin_user_equipment_id) + + resp = SaoSynthesizeEnhancementEquipmentResponse(int.from_bytes(bytes.fromhex(request[:4]), "big")+1, synthesize_equipment_data) + return resp.make() + def handle_c806(self, request: Any) -> bytes: #custom/change_party req = bytes.fromhex(request)[24:] diff --git a/titles/sao/handlers/base.py b/titles/sao/handlers/base.py index 8ed8ba0..4949143 100644 --- a/titles/sao/handlers/base.py +++ b/titles/sao/handlers/base.py @@ -1773,5 +1773,142 @@ class SaoCheckProfileCardUsedRewardResponse(SaoBaseResponse): )) + self.length = len(resp_data) + return super().make() + resp_data + +class SaoSynthesizeEnhancementEquipment(SaoBaseRequest): + def __init__(self, data: bytes) -> None: + super().__init__(data) + +class SaoSynthesizeEnhancementEquipmentResponse(SaoBaseResponse): + def __init__(self, cmd, synthesize_equipment_data) -> None: + super().__init__(cmd) + self.result = 1 + equipment_level = 0 + + # Calculate level based off experience and the CSV list + with open(r'titles/sao/data/EquipmentLevel.csv') as csv_file: + csv_reader = csv.reader(csv_file, delimiter=',') + line_count = 0 + data = [] + rowf = False + for row in csv_reader: + if rowf==False: + rowf=True + else: + data.append(row) + + exp = synthesize_equipment_data[4] + + for e in range(0,len(data)): + if exp>=int(data[e][1]) and exp bytes: + + after_equipment_user_data_struct = Struct( + "user_equipment_id_size" / Int32ub, # big endian + "user_equipment_id" / Int16ul[9], #string + "equipment_id" / Int32ub, #int + "enhancement_value" / Int16ub, #short + "max_enhancement_value_extended_num" / Int16ub, #short + "enhancement_exp" / Int32ub, #int + "possible_awakening_flag" / Int8ul, # result is either 0 or 1 + "awakening_stage" / Int16ub, #short + "awakening_exp" / Int32ub, #int + "property1_property_id" / Int32ub, + "property1_value1" / Int32ub, + "property1_value2" / Int32ub, + "property2_property_id" / Int32ub, + "property2_value1" / Int32ub, + "property2_value2" / Int32ub, + "property3_property_id" / Int32ub, + "property3_value1" / Int32ub, + "property3_value2" / Int32ub, + "property4_property_id" / Int32ub, + "property4_value1" / Int32ub, + "property4_value2" / Int32ub, + "converted_card_num" / Int16ub, + "shop_purchase_flag" / Int8ul, # result is either 0 or 1 + "protect_flag" / Int8ul, # result is either 0 or 1 + "get_date_size" / Int32ub, # big endian + "get_date" / Int16ul[len(self.get_date)], + ) + + # create a resp struct + resp_struct = Struct( + "result" / Int8ul, # result is either 0 or 1 + "after_equipment_user_data_size" / Rebuild(Int32ub, len_(this.after_equipment_user_data)), # big endian + "after_equipment_user_data" / Array(this.after_equipment_user_data_size, after_equipment_user_data_struct), + ) + + resp_data = resp_struct.parse(resp_struct.build(dict( + result=self.result, + after_equipment_user_data_size=0, + after_equipment_user_data=[], + ))) + + synthesize_equipment_data = dict( + user_equipment_id_size=len(self.user_equipment_id) * 2, + user_equipment_id=[ord(x) for x in self.user_equipment_id], + equipment_id=self.equipment_id, + enhancement_value=self.enhancement_value, + max_enhancement_value_extended_num=self.max_enhancement_value_extended_num, + enhancement_exp=self.enhancement_exp, + possible_awakening_flag=self.possible_awakening_flag, + awakening_stage=self.awakening_stage, + awakening_exp=self.awakening_exp, + property1_property_id=self.property1_property_id, + property1_value1=self.property1_value1, + property1_value2=self.property1_value2, + property2_property_id=self.property2_property_id, + property2_value1=self.property2_value1, + property2_value2=self.property2_value2, + property3_property_id=self.property3_property_id, + property3_value1=self.property3_value1, + property3_value2=self.property3_value2, + property4_property_id=self.property4_property_id, + property4_value1=self.property4_value1, + property4_value2=self.property4_value2, + converted_card_num=self.converted_card_num, + shop_purchase_flag=self.shop_purchase_flag, + protect_flag=self.protect_flag, + get_date_size=len(self.get_date) * 2, + get_date=[ord(x) for x in self.get_date], + + ) + + resp_data.after_equipment_user_data.append(synthesize_equipment_data) + + # finally, rebuild the resp_data + resp_data = resp_struct.build(resp_data) + self.length = len(resp_data) return super().make() + resp_data \ No newline at end of file diff --git a/titles/sao/schema/item.py b/titles/sao/schema/item.py index e1cb207..0e3d86c 100644 --- a/titles/sao/schema/item.py +++ b/titles/sao/schema/item.py @@ -192,6 +192,14 @@ class SaoItemData(BaseData): return None return result.lastrowid + + def get_user_equipment(self, user_id: int, equipment_id: int) -> Optional[Dict]: + sql = equipment_data.select(equipment_data.c.user == user_id and equipment_data.c.equipment_id == equipment_id) + + result = self.execute(sql) + if result is None: + return None + return result.fetchone() def get_user_equipments( self, user_id: int @@ -274,4 +282,31 @@ class SaoItemData(BaseData): result = self.execute(sql) if result is None: return None - return result.fetchone() \ No newline at end of file + return result.fetchone() + + def remove_hero_log(self, user_id: int, user_hero_log_id: int) -> None: + sql = hero_log_data.delete( + and_( + hero_log_data.c.user == user_id, + hero_log_data.c.user_hero_log_id == user_hero_log_id, + ) + ) + + result = self.execute(sql) + if result is None: + self.logger.error( + f"{__name__} failed to remove hero log! profile: {user_id}, user_hero_log_id: {user_hero_log_id}" + ) + return None + + def remove_equipment(self, user_id: int, equipment_id: int) -> None: + sql = equipment_data.delete( + and_(equipment_data.c.user == user_id, equipment_data.c.equipment_id == equipment_id) + ) + + result = self.execute(sql) + if result is None: + self.logger.error( + f"{__name__} failed to remove equipment! profile: {user_id}, equipment_id: {equipment_id}" + ) + return None \ No newline at end of file diff --git a/titles/sao/schema/static.py b/titles/sao/schema/static.py index 2635b5f..7323fc8 100644 --- a/titles/sao/schema/static.py +++ b/titles/sao/schema/static.py @@ -281,6 +281,14 @@ class SaoStaticData(BaseData): if result is None: return None return [list[2] for list in result.fetchall()] + + def get_item_id(self, itemId: int) -> Optional[Dict]: + sql = item.select(item.c.itemId == itemId) + + result = self.execute(sql) + if result is None: + return None + return result.fetchone() def get_item_ids(self, version: int, enabled: bool) -> Optional[List[Dict]]: sql = item.select(item.c.version == version and item.c.enabled == enabled).order_by( From 3bd03c592edbf22fed8cc3f1f75075bbb0649878 Mon Sep 17 00:00:00 2001 From: Midorica Date: Thu, 1 Jun 2023 13:19:48 -0400 Subject: [PATCH 27/49] Item progression and synthesize of hero and equipment done --- docs/game_specific_info.md | 6 ++ titles/sao/base.py | 107 +++++++++++++++++++++-- titles/sao/handlers/base.py | 166 +++++++++++++++++++++++++++++++++++- titles/sao/schema/item.py | 63 ++++++++++++++ 4 files changed, 334 insertions(+), 8 deletions(-) diff --git a/docs/game_specific_info.md b/docs/game_specific_info.md index aa0a39f..15efe7c 100644 --- a/docs/game_specific_info.md +++ b/docs/game_specific_info.md @@ -436,6 +436,12 @@ Always make sure your database (tables) are up-to-date, to do so go to the `core python dbutils.py --game SDEW upgrade ``` +### Notes +- Stages are currently force unlocked, no progression +- Co-Op (matching) is not supported +- Shop is not functionnal +- Player title is currently static and cannot be changed in-game + ### Credits for SAO support: - Midorica - Limited Network Support diff --git a/titles/sao/base.py b/titles/sao/base.py index ac1b69c..982e256 100644 --- a/titles/sao/base.py +++ b/titles/sao/base.py @@ -165,9 +165,21 @@ class SaoBase: def handle_c604(self, request: Any) -> bytes: #have_object/get_item_user_data_list - itemIdsData = self.game_data.static.get_item_ids(0, True) - - resp = SaoGetItemUserDataListResponse(int.from_bytes(bytes.fromhex(request[:4]), "big")+1, itemIdsData) + #itemIdsData = self.game_data.static.get_item_ids(0, True) + req = bytes.fromhex(request)[24:] + req_struct = Struct( + Padding(16), + "user_id_size" / Rebuild(Int32ub, len_(this.user_id) * 2), # calculates the length of the user_id + "user_id" / PaddedString(this.user_id_size, "utf_16_le"), # user_id is a (zero) padded string + + ) + req_data = req_struct.parse(req) + user_id = req_data.user_id + + item_data = self.game_data.item.get_user_items(user_id) + + resp = SaoGetItemUserDataListResponse(int.from_bytes(bytes.fromhex(request[:4]), "big")+1, item_data) + #resp = SaoNoopResponse(int.from_bytes(bytes.fromhex(request[:4]), "big")+1) return resp.make() def handle_c606(self, request: Any) -> bytes: @@ -244,7 +256,89 @@ class SaoBase: resp = SaoCheckProfileCardUsedRewardResponse(int.from_bytes(bytes.fromhex(request[:4]), "big")+1) return resp.make() - def handle_c816(self, request: Any) -> bytes: # not fully done yet + def handle_c814(self, request: Any) -> bytes: + #custom/synthesize_enhancement_hero_log + req = bytes.fromhex(request)[24:] + + req_struct = Struct( + Padding(20), + "ticket_id" / Bytes(1), # needs to be parsed as an int + Padding(1), + "user_id_size" / Rebuild(Int32ub, len_(this.user_id) * 2), # calculates the length of the user_id + "user_id" / PaddedString(this.user_id_size, "utf_16_le"), # user_id is a (zero) padded string + "origin_user_hero_log_id_size" / Rebuild(Int32ub, len_(this.origin_user_hero_log_id) * 2), # calculates the length of the origin_user_hero_log_id + "origin_user_hero_log_id" / PaddedString(this.origin_user_hero_log_id_size, "utf_16_le"), # origin_user_hero_log_id is a (zero) padded string + Padding(3), + "material_common_reward_user_data_list_length" / Rebuild(Int8ub, len_(this.material_common_reward_user_data_list)), # material_common_reward_user_data_list is a byte, + "material_common_reward_user_data_list" / Array(this.material_common_reward_user_data_list_length, Struct( + "common_reward_type" / Int16ub, # team_no is a byte + "user_common_reward_id_size" / Rebuild(Int32ub, len_(this.user_common_reward_id) * 2), # calculates the length of the user_common_reward_id + "user_common_reward_id" / PaddedString(this.user_common_reward_id_size, "utf_16_le"), # user_common_reward_id is a (zero) padded string + )), + ) + + req_data = req_struct.parse(req) + user_id = req_data.user_id + synthesize_hero_log_data = self.game_data.item.get_hero_log(req_data.user_id, req_data.origin_user_hero_log_id) + + for i in range(0,req_data.material_common_reward_user_data_list_length): + + itemList = self.game_data.static.get_item_id(req_data.material_common_reward_user_data_list[i].user_common_reward_id) + heroList = self.game_data.static.get_hero_id(req_data.material_common_reward_user_data_list[i].user_common_reward_id) + equipmentList = self.game_data.static.get_equipment_id(req_data.material_common_reward_user_data_list[i].user_common_reward_id) + + if itemList: + hero_exp = 2000 + int(synthesize_hero_log_data["log_exp"]) + self.game_data.item.remove_item(req_data.user_id, req_data.material_common_reward_user_data_list[i].user_common_reward_id) + + if equipmentList: + equipment_data = self.game_data.item.get_user_equipment(req_data.user_id, req_data.material_common_reward_user_data_list[i].user_common_reward_id) + hero_exp = int(equipment_data["enhancement_exp"]) + int(synthesize_hero_log_data["log_exp"]) + self.game_data.item.remove_equipment(req_data.user_id, req_data.material_common_reward_user_data_list[i].user_common_reward_id) + + if heroList: + hero_data = self.game_data.item.get_hero_log(req_data.user_id, req_data.material_common_reward_user_data_list[i].user_common_reward_id) + hero_exp = int(hero_data["log_exp"]) + int(synthesize_hero_log_data["log_exp"]) + self.game_data.item.remove_hero_log(req_data.user_id, req_data.material_common_reward_user_data_list[i].user_common_reward_id) + + self.game_data.item.put_hero_log( + user_id, + int(req_data.origin_user_hero_log_id), + synthesize_hero_log_data["log_level"], + hero_exp, + synthesize_hero_log_data["main_weapon"], + synthesize_hero_log_data["sub_equipment"], + synthesize_hero_log_data["skill_slot1_skill_id"], + synthesize_hero_log_data["skill_slot2_skill_id"], + synthesize_hero_log_data["skill_slot3_skill_id"], + synthesize_hero_log_data["skill_slot4_skill_id"], + synthesize_hero_log_data["skill_slot5_skill_id"] + ) + + profile = self.game_data.profile.get_profile(req_data.user_id) + new_col = int(profile["own_col"]) - 100 + + # Update profile + + self.game_data.profile.put_profile( + req_data.user_id, + profile["user_type"], + profile["nick_name"], + profile["rank_num"], + profile["rank_exp"], + new_col, + profile["own_vp"], + profile["own_yui_medal"], + profile["setting_title_id"] + ) + + # Load the item again to push to the response handler + synthesize_hero_log_data = self.game_data.item.get_hero_log(req_data.user_id, req_data.origin_user_hero_log_id) + + resp = SaoSynthesizeEnhancementHeroLogResponse(int.from_bytes(bytes.fromhex(request[:4]), "big")+1, synthesize_hero_log_data) + return resp.make() + + def handle_c816(self, request: Any) -> bytes: #custom/synthesize_enhancement_equipment req = bytes.fromhex(request)[24:] @@ -278,7 +372,7 @@ class SaoBase: if itemList: equipment_exp = 2000 + int(synthesize_equipment_data["enhancement_exp"]) - # Then delete the used item, function for items progression is not done yet... + self.game_data.item.remove_item(req_data.user_id, req_data.material_common_reward_user_data_list[i].user_common_reward_id) if equipmentList: equipment_data = self.game_data.item.get_user_equipment(req_data.user_id, req_data.material_common_reward_user_data_list[i].user_common_reward_id) @@ -595,10 +689,13 @@ class SaoBase: heroList = self.game_data.static.get_hero_id(randomized_unanalyzed_id['CommonRewardId']) equipmentList = self.game_data.static.get_equipment_id(randomized_unanalyzed_id['CommonRewardId']) + itemList = self.game_data.static.get_item_id(randomized_unanalyzed_id['CommonRewardId']) if heroList: self.game_data.item.put_hero_log(req_data.user_id, randomized_unanalyzed_id['CommonRewardId'], 1, 0, 101000016, 0, 30086, 1001, 1002, 0, 0) if equipmentList: self.game_data.item.put_equipment_data(req_data.user_id, randomized_unanalyzed_id['CommonRewardId'], 1, 200, 0, 0, 0) + if itemList: + self.game_data.item.put_item(req_data.user_id, randomized_unanalyzed_id['CommonRewardId']) # Send response diff --git a/titles/sao/handlers/base.py b/titles/sao/handlers/base.py index 4949143..91b30fe 100644 --- a/titles/sao/handlers/base.py +++ b/titles/sao/handlers/base.py @@ -816,13 +816,18 @@ class SaoGetItemUserDataListRequest(SaoBaseRequest): super().__init__(data) class SaoGetItemUserDataListResponse(SaoBaseResponse): - def __init__(self, cmd, itemIdsData) -> None: + def __init__(self, cmd, item_data) -> None: super().__init__(cmd) self.result = 1 + self.user_item_id = [] + + for i in range(len(item_data)): + self.user_item_id.append(item_data[i][2]) + # item_user_data_list - self.user_item_id = list(map(str,itemIdsData)) #str - self.item_id = itemIdsData #int + self.user_item_id = list(map(str,self.user_item_id)) #str + self.item_id = list(map(int,self.user_item_id)) #int self.protect_flag = 0 #byte self.get_date = "20230101120000" #str @@ -1776,6 +1781,161 @@ class SaoCheckProfileCardUsedRewardResponse(SaoBaseResponse): self.length = len(resp_data) return super().make() + resp_data +class SaoSynthesizeEnhancementHeroLogRequest(SaoBaseRequest): + def __init__(self, data: bytes) -> None: + super().__init__(data) + +class SaoSynthesizeEnhancementHeroLogResponse(SaoBaseResponse): + def __init__(self, cmd, hero_data) -> None: + super().__init__(cmd) + self.result = 1 + + # Calculate level based off experience and the CSV list + with open(r'titles/sao/data/HeroLogLevel.csv') as csv_file: + csv_reader = csv.reader(csv_file, delimiter=',') + line_count = 0 + data = [] + rowf = False + for row in csv_reader: + if rowf==False: + rowf=True + else: + data.append(row) + + exp = hero_data[4] + + for e in range(0,len(data)): + if exp>=int(data[e][1]) and exp bytes: + #new stuff + + hero_log_user_data_list_struct = Struct( + "user_hero_log_id_size" / Int32ub, # big endian + "user_hero_log_id" / Int16ul[9], #string + "hero_log_id" / Int32ub, #int + "log_level" / Int16ub, #short + "max_log_level_extended_num" / Int16ub, #short + "log_exp" / Int32ub, #int + "possible_awakening_flag" / Int8ul, # result is either 0 or 1 + "awakening_stage" / Int16ub, #short + "awakening_exp" / Int32ub, #int + "skill_slot_correction_value" / Int8ul, # result is either 0 or 1 + "last_set_skill_slot1_skill_id" / Int16ub, #short + "last_set_skill_slot2_skill_id" / Int16ub, #short + "last_set_skill_slot3_skill_id" / Int16ub, #short + "last_set_skill_slot4_skill_id" / Int16ub, #short + "last_set_skill_slot5_skill_id" / Int16ub, #short + "property1_property_id" / Int32ub, + "property1_value1" / Int32ub, + "property1_value2" / Int32ub, + "property2_property_id" / Int32ub, + "property2_value1" / Int32ub, + "property2_value2" / Int32ub, + "property3_property_id" / Int32ub, + "property3_value1" / Int32ub, + "property3_value2" / Int32ub, + "property4_property_id" / Int32ub, + "property4_value1" / Int32ub, + "property4_value2" / Int32ub, + "converted_card_num" / Int16ub, + "shop_purchase_flag" / Int8ul, # result is either 0 or 1 + "protect_flag" / Int8ul, # result is either 0 or 1 + "get_date_size" / Int32ub, # big endian + "get_date" / Int16ul[len(self.get_date)], + ) + + # create a resp struct + resp_struct = Struct( + "result" / Int8ul, # result is either 0 or 1 + "hero_log_user_data_list_size" / Rebuild(Int32ub, len_(this.hero_log_user_data_list)), # big endian + "hero_log_user_data_list" / Array(this.hero_log_user_data_list_size, hero_log_user_data_list_struct), + ) + + resp_data = resp_struct.parse(resp_struct.build(dict( + result=self.result, + hero_log_user_data_list_size=0, + hero_log_user_data_list=[], + ))) + + hero_data = dict( + user_hero_log_id_size=len(self.user_hero_log_id) * 2, + user_hero_log_id=[ord(x) for x in self.user_hero_log_id], + hero_log_id=self.hero_log_id, + log_level=self.log_level, + max_log_level_extended_num=self.max_log_level_extended_num, + log_exp=self.log_exp, + possible_awakening_flag=self.possible_awakening_flag, + awakening_stage=self.awakening_stage, + awakening_exp=self.awakening_exp, + skill_slot_correction_value=self.skill_slot_correction_value, + last_set_skill_slot1_skill_id=self.last_set_skill_slot1_skill_id, + last_set_skill_slot2_skill_id=self.last_set_skill_slot2_skill_id, + last_set_skill_slot3_skill_id=self.last_set_skill_slot3_skill_id, + last_set_skill_slot4_skill_id=self.last_set_skill_slot4_skill_id, + last_set_skill_slot5_skill_id=self.last_set_skill_slot5_skill_id, + property1_property_id=self.property1_property_id, + property1_value1=self.property1_value1, + property1_value2=self.property1_value2, + property2_property_id=self.property2_property_id, + property2_value1=self.property2_value1, + property2_value2=self.property2_value2, + property3_property_id=self.property3_property_id, + property3_value1=self.property3_value1, + property3_value2=self.property3_value2, + property4_property_id=self.property4_property_id, + property4_value1=self.property4_value1, + property4_value2=self.property4_value2, + converted_card_num=self.converted_card_num, + shop_purchase_flag=self.shop_purchase_flag, + protect_flag=self.protect_flag, + get_date_size=len(self.get_date) * 2, + get_date=[ord(x) for x in self.get_date], + + ) + + resp_data.hero_log_user_data_list.append(hero_data) + + # finally, rebuild the resp_data + resp_data = resp_struct.build(resp_data) + + self.length = len(resp_data) + return super().make() + resp_data + class SaoSynthesizeEnhancementEquipment(SaoBaseRequest): def __init__(self, data: bytes) -> None: super().__init__(data) diff --git a/titles/sao/schema/item.py b/titles/sao/schema/item.py index 0e3d86c..858267e 100644 --- a/titles/sao/schema/item.py +++ b/titles/sao/schema/item.py @@ -28,6 +28,21 @@ equipment_data = Table( mysql_charset="utf8mb4", ) +item_data = Table( + "sao_item_data", + metadata, + Column("id", Integer, primary_key=True, nullable=False), + Column( + "user", + ForeignKey("aime_user.id", ondelete="cascade", onupdate="cascade"), + nullable=False, + ), + Column("item_id", Integer, nullable=False), + Column("get_date", TIMESTAMP, nullable=False, server_default=func.now()), + UniqueConstraint("user", "item_id", name="sao_item_data_uk"), + mysql_charset="utf8mb4", +) + hero_log_data = Table( "sao_hero_log_data", metadata, @@ -104,6 +119,25 @@ class SaoItemData(BaseData): self.logger.error(f"Failed to create SAO session for user {user_id}!") return None return result.lastrowid + + def put_item(self, user_id: int, item_id: int) -> Optional[int]: + sql = insert(item_data).values( + user=user_id, + item_id=item_id, + ) + + conflict = sql.on_duplicate_key_update( + item_id=item_id, + ) + + result = self.execute(conflict) + if result is None: + self.logger.error( + f"{__name__} failed to insert item! user: {user_id}, item_id: {item_id}" + ) + return None + + return result.lastrowid def put_equipment_data(self, user_id: int, equipment_id: int, enhancement_value: int, enhancement_exp: int, awakening_exp: int, awakening_stage: int, possible_awakening_flag: int) -> Optional[int]: sql = insert(equipment_data).values( @@ -218,6 +252,23 @@ class SaoItemData(BaseData): return None return result.fetchall() + def get_user_items( + self, user_id: int + ) -> Optional[List[Row]]: + """ + A catch-all items lookup given a profile + """ + sql = item_data.select( + and_( + item_data.c.user == user_id, + ) + ) + + result = self.execute(sql) + if result is None: + return None + return result.fetchall() + def get_hero_log( self, user_id: int, user_hero_log_id: int = None ) -> Optional[List[Row]]: @@ -309,4 +360,16 @@ class SaoItemData(BaseData): self.logger.error( f"{__name__} failed to remove equipment! profile: {user_id}, equipment_id: {equipment_id}" ) + return None + + def remove_item(self, user_id: int, item_id: int) -> None: + sql = item_data.delete( + and_(item_data.c.user == user_id, item_data.c.item_id == item_id) + ) + + result = self.execute(sql) + if result is None: + self.logger.error( + f"{__name__} failed to remove item! profile: {user_id}, item_id: {item_id}" + ) return None \ No newline at end of file From 84fc002cdbb882a547d17fb4b91c3e48f54332a2 Mon Sep 17 00:00:00 2001 From: Midorica Date: Fri, 2 Jun 2023 13:53:49 -0400 Subject: [PATCH 28/49] Adding trial tower support for SAO --- titles/sao/base.py | 186 ++++++++++++++++++++++++++++++++++++ titles/sao/handlers/base.py | 126 ++++++++++++++++++++++++ 2 files changed, 312 insertions(+) diff --git a/titles/sao/base.py b/titles/sao/base.py index 982e256..ef3879a 100644 --- a/titles/sao/base.py +++ b/titles/sao/base.py @@ -711,7 +711,193 @@ class SaoBase: resp = SaoEpisodePlayStartResponse(int.from_bytes(bytes.fromhex(request[:4]), "big")+1, profile_data) return resp.make() + def handle_c918(self, request: Any) -> bytes: + #quest/trial_tower_play_end + req = bytes.fromhex(request)[24:] + + req_struct = Struct( + Padding(20), + "ticket_id" / Bytes(1), # needs to be parsed as an int + Padding(1), + "user_id_size" / Rebuild(Int32ub, len_(this.user_id) * 2), # calculates the length of the user_id + "user_id" / PaddedString(this.user_id_size, "utf_16_le"), # user_id is a (zero) padded string + Padding(2), + "trial_tower_id" / Int16ub, # trial_tower_id is a short, + Padding(3), + "play_end_request_data" / Int8ub, # play_end_request_data is a byte + Padding(1), + "play_result_flag" / Int8ub, # play_result_flag is a byte + Padding(2), + "base_get_data_length" / Rebuild(Int8ub, len_(this.base_get_data)), # base_get_data_length is a byte, + "base_get_data" / Array(this.base_get_data_length, Struct( + "get_hero_log_exp" / Int32ub, # get_hero_log_exp is an int + "get_col" / Int32ub, # get_num is a short + )), + Padding(3), + "get_player_trace_data_list_length" / Rebuild(Int8ub, len_(this.get_player_trace_data_list)), # get_player_trace_data_list_length is a byte + "get_player_trace_data_list" / Array(this.get_player_trace_data_list_length, Struct( + "user_quest_scene_player_trace_id" / Int32ub, # user_quest_scene_player_trace_id is an int + )), + Padding(3), + "get_rare_drop_data_list_length" / Rebuild(Int8ub, len_(this.get_rare_drop_data_list)), # get_rare_drop_data_list_length is a byte + "get_rare_drop_data_list" / Array(this.get_rare_drop_data_list_length, Struct( + "quest_rare_drop_id" / Int32ub, # quest_rare_drop_id is an int + )), + Padding(3), + "get_special_rare_drop_data_list_length" / Rebuild(Int8ub, len_(this.get_special_rare_drop_data_list)), # get_special_rare_drop_data_list_length is a byte + "get_special_rare_drop_data_list" / Array(this.get_special_rare_drop_data_list_length, Struct( + "quest_special_rare_drop_id" / Int32ub, # quest_special_rare_drop_id is an int + )), + Padding(3), + "get_unanalyzed_log_tmp_reward_data_list_length" / Rebuild(Int8ub, len_(this.get_unanalyzed_log_tmp_reward_data_list)), # get_unanalyzed_log_tmp_reward_data_list_length is a byte + "get_unanalyzed_log_tmp_reward_data_list" / Array(this.get_unanalyzed_log_tmp_reward_data_list_length, Struct( + "unanalyzed_log_grade_id" / Int32ub, # unanalyzed_log_grade_id is an int, + )), + Padding(3), + "get_event_item_data_list_length" / Rebuild(Int8ub, len_(this.get_event_item_data_list)), # get_event_item_data_list_length is a byte, + "get_event_item_data_list" / Array(this.get_event_item_data_list_length, Struct( + "event_item_id" / Int32ub, # event_item_id is an int + "get_num" / Int16ub, # get_num is a short + )), + Padding(3), + "discovery_enemy_data_list_length" / Rebuild(Int8ub, len_(this.discovery_enemy_data_list)), # discovery_enemy_data_list_length is a byte + "discovery_enemy_data_list" / Array(this.discovery_enemy_data_list_length, Struct( + "enemy_kind_id" / Int32ub, # enemy_kind_id is an int + "destroy_num" / Int16ub, # destroy_num is a short + )), + Padding(3), + "destroy_boss_data_list_length" / Rebuild(Int8ub, len_(this.destroy_boss_data_list)), # destroy_boss_data_list_length is a byte + "destroy_boss_data_list" / Array(this.destroy_boss_data_list_length, Struct( + "boss_type" / Int8ub, # boss_type is a byte + "enemy_kind_id" / Int32ub, # enemy_kind_id is an int + "destroy_num" / Int16ub, # destroy_num is a short + )), + Padding(3), + "mission_data_list_length" / Rebuild(Int8ub, len_(this.mission_data_list)), # mission_data_list_length is a byte + "mission_data_list" / Array(this.mission_data_list_length, Struct( + "mission_id" / Int32ub, # enemy_kind_id is an int + "clear_flag" / Int8ub, # boss_type is a byte + "mission_difficulty_id" / Int16ub, # destroy_num is a short + )), + Padding(3), + "score_data_length" / Rebuild(Int8ub, len_(this.score_data)), # score_data_length is a byte + "score_data" / Array(this.score_data_length, Struct( + "clear_time" / Int32ub, # clear_time is an int + "combo_num" / Int32ub, # boss_type is a int + "total_damage_size" / Rebuild(Int32ub, len_(this.total_damage) * 2), # calculates the length of the total_damage + "total_damage" / PaddedString(this.total_damage_size, "utf_16_le"), # total_damage is a (zero) padded string + "concurrent_destroying_num" / Int16ub, # concurrent_destroying_num is a short + "reaching_skill_level" / Int16ub, # reaching_skill_level is a short + "ko_chara_num" / Int8ub, # ko_chara_num is a byte + "acceleration_invocation_num" / Int16ub, # acceleration_invocation_num is a short + "boss_destroying_num" / Int16ub, # boss_destroying_num is a short + "synchro_skill_used_flag" / Int8ub, # synchro_skill_used_flag is a byte + "used_friend_skill_id" / Int32ub, # used_friend_skill_id is an int + "friend_skill_used_flag" / Int8ub, # friend_skill_used_flag is a byte + "continue_cnt" / Int16ub, # continue_cnt is a short + "total_loss_num" / Int16ub, # total_loss_num is a short + )), + + ) + + req_data = req_struct.parse(req) + + # Update the profile + profile = self.game_data.profile.get_profile(req_data.user_id) + + exp = int(profile["rank_exp"]) + 100 #always 100 extra exp for some reason + col = int(profile["own_col"]) + int(req_data.base_get_data[0].get_col) + + # Calculate level based off experience and the CSV list + with open(r'titles/sao/data/PlayerRank.csv') as csv_file: + csv_reader = csv.reader(csv_file, delimiter=',') + line_count = 0 + data = [] + rowf = False + for row in csv_reader: + if rowf==False: + rowf=True + else: + data.append(row) + + for i in range(0,len(data)): + if exp>=int(data[i][1]) and exp bytes: #should be tweaked for proper item unlock #quest/episode_play_end_unanalyzed_log_fixed resp = SaoEpisodePlayEndUnanalyzedLogFixedResponse(int.from_bytes(bytes.fromhex(request[:4]), "big")+1) return resp.make() + + def handle_c91a(self, request: Any) -> bytes: # handler is identical to the episode + #quest/trial_tower_play_end_unanalyzed_log_fixed + resp = SaoEpisodePlayEndUnanalyzedLogFixedResponse(int.from_bytes(bytes.fromhex(request[:4]), "big")+1) + return resp.make() diff --git a/titles/sao/handlers/base.py b/titles/sao/handlers/base.py index 91b30fe..a9d336a 100644 --- a/titles/sao/handlers/base.py +++ b/titles/sao/handlers/base.py @@ -1530,6 +1530,132 @@ class SaoEpisodePlayEndResponse(SaoBaseResponse): self.length = len(resp_data) return super().make() + resp_data +class SaoTrialTowerPlayEndRequest(SaoBaseRequest): + def __init__(self, data: bytes) -> None: + super().__init__(data) + +class SaoTrialTowerPlayEndResponse(SaoBaseResponse): + def __init__(self, cmd) -> None: + super().__init__(cmd) + self.result = 1 + self.play_end_response_data_size = 1 # Number of arrays + self.multi_play_end_response_data_size = 1 # Unused on solo play + self.trial_tower_play_end_updated_notification_data_size = 1 # Number of arrays + self.treasure_hunt_play_end_response_data_size = 1 # Number of arrays + + self.dummy_1 = 0 + self.dummy_2 = 0 + self.dummy_3 = 0 + + self.rarity_up_occurrence_flag = 0 + self.adventure_ex_area_occurrences_flag = 0 + self.ex_bonus_data_list_size = 1 # Number of arrays + self.play_end_player_trace_reward_data_list_size = 0 # Number of arrays + + self.ex_bonus_table_id = 0 # ExBonusTable.csv values, dont care for now + self.achievement_status = 1 + + self.common_reward_data_size = 1 # Number of arrays + + self.common_reward_type = 0 # dummy values from 2,101000000,1 from RewardTable.csv + self.common_reward_id = 0 + self.common_reward_num = 0 + + self.store_best_score_clear_time_flag = 0 + self.store_best_score_combo_num_flag = 0 + self.store_best_score_total_damage_flag = 0 + self.store_best_score_concurrent_destroying_num_flag = 0 + self.store_reaching_trial_tower_rank = 0 + + self.get_event_point = 0 + self.total_event_point = 0 + + def make(self) -> bytes: + # create a resp struct + resp_struct = Struct( + "result" / Int8ul, # result is either 0 or 1 + "play_end_response_data_size" / Int32ub, # big endian + + "rarity_up_occurrence_flag" / Int8ul, # result is either 0 or 1 + "adventure_ex_area_occurrences_flag" / Int8ul, # result is either 0 or 1 + "ex_bonus_data_list_size" / Int32ub, # big endian + "play_end_player_trace_reward_data_list_size" / Int32ub, # big endian + + # ex_bonus_data_list + "ex_bonus_table_id" / Int32ub, + "achievement_status" / Int8ul, # result is either 0 or 1 + + # play_end_player_trace_reward_data_list + "common_reward_data_size" / Int32ub, + + # common_reward_data + "common_reward_type" / Int16ub, # short + "common_reward_id" / Int32ub, + "common_reward_num" / Int32ub, + + "multi_play_end_response_data_size" / Int32ub, # big endian + + # multi_play_end_response_data + "dummy_1" / Int8ul, # result is either 0 or 1 + "dummy_2" / Int8ul, # result is either 0 or 1 + "dummy_3" / Int8ul, # result is either 0 or 1 + + "trial_tower_play_end_updated_notification_data_size" / Int32ub, # big endian + + #trial_tower_play_end_updated_notification_data + "store_best_score_clear_time_flag" / Int8ul, # result is either 0 or 1 + "store_best_score_combo_num_flag" / Int8ul, # result is either 0 or 1 + "store_best_score_total_damage_flag" / Int8ul, # result is either 0 or 1 + "store_best_score_concurrent_destroying_num_flag" / Int8ul, # result is either 0 or 1 + "store_reaching_trial_tower_rank" / Int32ub, + + "treasure_hunt_play_end_response_data_size" / Int32ub, # big endian + + #treasure_hunt_play_end_response_data + "get_event_point" / Int32ub, + "total_event_point" / Int32ub, + ) + + resp_data = resp_struct.build(dict( + result=self.result, + play_end_response_data_size=self.play_end_response_data_size, + + rarity_up_occurrence_flag=self.rarity_up_occurrence_flag, + adventure_ex_area_occurrences_flag=self.adventure_ex_area_occurrences_flag, + ex_bonus_data_list_size=self.ex_bonus_data_list_size, + play_end_player_trace_reward_data_list_size=self.play_end_player_trace_reward_data_list_size, + + ex_bonus_table_id=self.ex_bonus_table_id, + achievement_status=self.achievement_status, + + common_reward_data_size=self.common_reward_data_size, + + common_reward_type=self.common_reward_type, + common_reward_id=self.common_reward_id, + common_reward_num=self.common_reward_num, + + multi_play_end_response_data_size=self.multi_play_end_response_data_size, + + dummy_1=self.dummy_1, + dummy_2=self.dummy_2, + dummy_3=self.dummy_3, + + trial_tower_play_end_updated_notification_data_size=self.trial_tower_play_end_updated_notification_data_size, + store_best_score_clear_time_flag=self.store_best_score_clear_time_flag, + store_best_score_combo_num_flag=self.store_best_score_combo_num_flag, + store_best_score_total_damage_flag=self.store_best_score_total_damage_flag, + store_best_score_concurrent_destroying_num_flag=self.store_best_score_concurrent_destroying_num_flag, + store_reaching_trial_tower_rank=self.store_reaching_trial_tower_rank, + + treasure_hunt_play_end_response_data_size=self.treasure_hunt_play_end_response_data_size, + + get_event_point=self.get_event_point, + total_event_point=self.total_event_point, + )) + + self.length = len(resp_data) + return super().make() + resp_data + class SaoEpisodePlayEndUnanalyzedLogFixedRequest(SaoBaseRequest): def __init__(self, data: bytes) -> None: super().__init__(data) From a0b25e2b7b2cc687e5066ef69a1c4b4d4199c167 Mon Sep 17 00:00:00 2001 From: Midorica Date: Sat, 3 Jun 2023 11:42:50 -0400 Subject: [PATCH 29/49] Adding rare drops saving to SAO --- titles/sao/base.py | 33 ++++++++++++++++++++++++++++++- titles/sao/read.py | 24 +++++++++++++++++++++++ titles/sao/schema/static.py | 39 +++++++++++++++++++++++++++++++++++++ 3 files changed, 95 insertions(+), 1 deletion(-) diff --git a/titles/sao/base.py b/titles/sao/base.py index ef3879a..098b9c3 100644 --- a/titles/sao/base.py +++ b/titles/sao/base.py @@ -675,6 +675,22 @@ class SaoBase: hero_data["skill_slot4_skill_id"], hero_data["skill_slot5_skill_id"] ) + + # Grab the rare loot from the table, match it with the right item and then push to the player profile + for r in range(0,req_data.get_rare_drop_data_list_length): + rewardList = self.game_data.static.get_rare_drop_id(int(req_data.get_rare_drop_data_list[r].quest_rare_drop_id)) + commonRewardId = rewardList["commonRewardId"] + + heroList = self.game_data.static.get_hero_id(commonRewardId) + equipmentList = self.game_data.static.get_equipment_id(commonRewardId) + itemList = self.game_data.static.get_item_id(commonRewardId) + + if heroList: + self.game_data.item.put_hero_log(req_data.user_id, commonRewardId, 1, 0, 101000016, 0, 30086, 1001, 1002, 0, 0) + if equipmentList: + self.game_data.item.put_equipment_data(req_data.user_id, commonRewardId, 1, 200, 0, 0, 0) + if itemList: + self.game_data.item.put_item(req_data.user_id, commonRewardId) # Generate random hero(es) based off the response for a in range(0,req_data.get_unanalyzed_log_tmp_reward_data_list_length): @@ -825,7 +841,6 @@ class SaoBase: player_level = int(data[i][0]) break - # Update profile updated_profile = self.game_data.profile.put_profile( req_data.user_id, profile["user_type"], @@ -866,6 +881,22 @@ class SaoBase: hero_data["skill_slot5_skill_id"] ) + # Grab the rare loot from the table, match it with the right item and then push to the player profile + for r in range(0,req_data.get_rare_drop_data_list_length): + rewardList = self.game_data.static.get_rare_drop_id(int(req_data.get_rare_drop_data_list[r].quest_rare_drop_id)) + commonRewardId = rewardList["commonRewardId"] + + heroList = self.game_data.static.get_hero_id(commonRewardId) + equipmentList = self.game_data.static.get_equipment_id(commonRewardId) + itemList = self.game_data.static.get_item_id(commonRewardId) + + if heroList: + self.game_data.item.put_hero_log(req_data.user_id, commonRewardId, 1, 0, 101000016, 0, 30086, 1001, 1002, 0, 0) + if equipmentList: + self.game_data.item.put_equipment_data(req_data.user_id, commonRewardId, 1, 200, 0, 0, 0) + if itemList: + self.game_data.item.put_item(req_data.user_id, commonRewardId) + # Generate random hero(es) based off the response for a in range(0,req_data.get_unanalyzed_log_tmp_reward_data_list_length): diff --git a/titles/sao/read.py b/titles/sao/read.py index 5fc9804..d70c275 100644 --- a/titles/sao/read.py +++ b/titles/sao/read.py @@ -228,3 +228,27 @@ class SaoReader(BaseReader): continue except: self.logger.warn(f"Couldn't read csv file in {self.bin_dir}, skipping") + + self.logger.info("Now reading RareDropTable.csv") + try: + fullPath = bin_dir + "/RareDropTable.csv" + with open(fullPath, encoding="UTF-8") as fp: + reader = csv.DictReader(fp) + for row in reader: + questRareDropId = row["QuestRareDropId"] + commonRewardId = row["CommonRewardId"] + enabled = True + + self.logger.info(f"Added rare drop {questRareDropId} | Reward: {commonRewardId}") + + try: + self.data.static.put_rare_drop( + 0, + questRareDropId, + commonRewardId, + enabled + ) + except Exception as err: + print(err) + except: + self.logger.warn(f"Couldn't read csv file in {self.bin_dir}, skipping") diff --git a/titles/sao/schema/static.py b/titles/sao/schema/static.py index 7323fc8..670e3b2 100644 --- a/titles/sao/schema/static.py +++ b/titles/sao/schema/static.py @@ -96,6 +96,20 @@ support = Table( mysql_charset="utf8mb4", ) +rare_drop = Table( + "sao_static_rare_drop_list", + metadata, + Column("id", Integer, primary_key=True, nullable=False), + Column("version", Integer), + Column("questRareDropId", Integer), + Column("commonRewardId", Integer), + Column("enabled", Boolean), + UniqueConstraint( + "version", "questRareDropId", "commonRewardId", name="sao_static_rare_drop_list_uk" + ), + mysql_charset="utf8mb4", +) + title = Table( "sao_static_title_list", metadata, @@ -215,6 +229,23 @@ class SaoStaticData(BaseData): if result is None: return None return result.lastrowid + + def put_rare_drop( self, version: int, questRareDropId: int, commonRewardId: int, enabled: bool ) -> Optional[int]: + sql = insert(rare_drop).values( + version=version, + questRareDropId=questRareDropId, + commonRewardId=commonRewardId, + enabled=enabled, + ) + + conflict = sql.on_duplicate_key_update( + questRareDropId=questRareDropId, commonRewardId=commonRewardId, version=version + ) + + result = self.execute(conflict) + if result is None: + return None + return result.lastrowid def put_title( self, version: int, titleId: int, displayName: str, requirement: int, rank: int, imageFilePath: str, enabled: bool ) -> Optional[int]: sql = insert(title).values( @@ -289,6 +320,14 @@ class SaoStaticData(BaseData): if result is None: return None return result.fetchone() + + def get_rare_drop_id(self, questRareDropId: int) -> Optional[Dict]: + sql = rare_drop.select(rare_drop.c.questRareDropId == questRareDropId) + + result = self.execute(sql) + if result is None: + return None + return result.fetchone() def get_item_ids(self, version: int, enabled: bool) -> Optional[List[Dict]]: sql = item.select(item.c.version == version and item.c.enabled == enabled).order_by( From 5ca16f206733e1fb0c5233895c53fdf161aa34df Mon Sep 17 00:00:00 2001 From: Kevin Trocolli Date: Tue, 13 Jun 2023 22:07:48 -0400 Subject: [PATCH 30/49] mai2: fix GetUserMusicApi pagination --- titles/mai2/base.py | 31 ++++++++++++++++++++----------- titles/mai2/schema/score.py | 2 ++ 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/titles/mai2/base.py b/titles/mai2/base.py index 44ec60d..1bba918 100644 --- a/titles/mai2/base.py +++ b/titles/mai2/base.py @@ -646,21 +646,30 @@ class Mai2Base: return {"userId": data["userId"], "length": 0, "userRegionList": []} def handle_get_user_music_api_request(self, data: Dict) -> Dict: - songs = self.data.score.get_best_scores(data["userId"]) + songs = self.data.score.get_best_scores(data.get("userId", 0)) + if songs is None: + return { + "userId": data["userId"], + "nextIndex": 0, + "userMusicList": [], + } + music_detail_list = [] - next_index = 0 + next_index = data.get("nextIndex", 0) + max_ct = data.get("maxCount", 50) + upper_lim = next_index + max_ct + num_user_songs = len(songs) - if songs is not None: - for song in songs: - tmp = song._asdict() - tmp.pop("id") - tmp.pop("user") - music_detail_list.append(tmp) + for x in range(next_index, upper_lim): + if num_user_songs >= x: + break - if len(music_detail_list) == data["maxCount"]: - next_index = data["maxCount"] + data["nextIndex"] - break + tmp = songs[x]._asdict() + tmp.pop("id") + tmp.pop("user") + music_detail_list.append(tmp) + next_index = 0 if len(music_detail_list) < max_ct else upper_lim return { "userId": data["userId"], "nextIndex": next_index, diff --git a/titles/mai2/schema/score.py b/titles/mai2/schema/score.py index 0f7f239..85dff16 100644 --- a/titles/mai2/schema/score.py +++ b/titles/mai2/schema/score.py @@ -7,6 +7,7 @@ from sqlalchemy.engine import Row from sqlalchemy.dialects.mysql import insert from core.data.schema import BaseData, metadata +from core.data import cached best_score = Table( "mai2_score_best", @@ -190,6 +191,7 @@ class Mai2ScoreData(BaseData): return None return result.lastrowid + @cached(2) def get_best_scores(self, user_id: int, song_id: int = None) -> Optional[List[Row]]: sql = best_score.select( and_( From 5a35b1c82396855aa6beb31cdcc879f33fd70e60 Mon Sep 17 00:00:00 2001 From: Kevin Trocolli Date: Tue, 13 Jun 2023 22:10:35 -0400 Subject: [PATCH 31/49] mai2: GetUserMusicApi hotfix --- titles/mai2/base.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/titles/mai2/base.py b/titles/mai2/base.py index 1bba918..13ea2df 100644 --- a/titles/mai2/base.py +++ b/titles/mai2/base.py @@ -660,6 +660,7 @@ class Mai2Base: upper_lim = next_index + max_ct num_user_songs = len(songs) + for x in range(next_index, upper_lim): if num_user_songs >= x: break @@ -669,7 +670,7 @@ class Mai2Base: tmp.pop("user") music_detail_list.append(tmp) - next_index = 0 if len(music_detail_list) < max_ct else upper_lim + next_index = 0 if len(music_detail_list) < max_ct or num_user_songs == upper_lim else upper_lim return { "userId": data["userId"], "nextIndex": next_index, From f56332141e838a92349c449d057c7162db198d4c Mon Sep 17 00:00:00 2001 From: Kevin Trocolli Date: Tue, 13 Jun 2023 22:16:30 -0400 Subject: [PATCH 32/49] mai2: fix old server (finale isn't ready yet) --- titles/mai2/base.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/titles/mai2/base.py b/titles/mai2/base.py index 13ea2df..6d14bce 100644 --- a/titles/mai2/base.py +++ b/titles/mai2/base.py @@ -18,10 +18,10 @@ class Mai2Base: self.logger = logging.getLogger("mai2") if self.core_config.server.is_develop and self.core_config.title.port > 0: - self.old_server = f"http://{self.core_config.title.hostname}:{self.core_config.title.port}/SDEY/100/" + self.old_server = f"http://{self.core_config.title.hostname}:{self.core_config.title.port}/SDEZ/100/" else: - self.old_server = f"http://{self.core_config.title.hostname}/SDEY/100/" + self.old_server = f"http://{self.core_config.title.hostname}/SDEZ/100/" def handle_get_game_setting_api_request(self, data: Dict): # TODO: See if making this epoch 0 breaks things From 65686fb615d94536bfc0d7922fe44c222f00f9b3 Mon Sep 17 00:00:00 2001 From: Kevin Trocolli Date: Tue, 13 Jun 2023 22:35:09 -0400 Subject: [PATCH 33/49] mai2: add loggin to handle_get_user_music_api_request --- titles/mai2/base.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/titles/mai2/base.py b/titles/mai2/base.py index 6d14bce..6ad36d7 100644 --- a/titles/mai2/base.py +++ b/titles/mai2/base.py @@ -646,21 +646,27 @@ class Mai2Base: return {"userId": data["userId"], "length": 0, "userRegionList": []} def handle_get_user_music_api_request(self, data: Dict) -> Dict: - songs = self.data.score.get_best_scores(data.get("userId", 0)) + user_id = data.get("userId", 0) + next_index = data.get("nextIndex", 0) + max_ct = data.get("maxCount", 50) + upper_lim = next_index + max_ct + music_detail_list = [] + + if user_id <= 0: + self.logger.warn("handle_get_user_music_api_request: Could not find userid in data, or userId is 0") + return {} + + songs = self.data.score.get_best_scores(user_id) if songs is None: + self.logger.debug("handle_get_user_music_api_request: get_best_scores returned None!") return { "userId": data["userId"], "nextIndex": 0, "userMusicList": [], } - music_detail_list = [] - next_index = data.get("nextIndex", 0) - max_ct = data.get("maxCount", 50) - upper_lim = next_index + max_ct num_user_songs = len(songs) - for x in range(next_index, upper_lim): if num_user_songs >= x: break @@ -671,6 +677,7 @@ class Mai2Base: music_detail_list.append(tmp) next_index = 0 if len(music_detail_list) < max_ct or num_user_songs == upper_lim else upper_lim + self.logger.info(f"Send songs {next_index}-{upper_lim} ({len(music_detail_list)}) out of {num_user_songs} for user {user_id} (next idx {next_index})") return { "userId": data["userId"], "nextIndex": next_index, From 1b2f5e3709739061a740427bd1912a327bc9cbe1 Mon Sep 17 00:00:00 2001 From: Kevin Trocolli Date: Tue, 13 Jun 2023 22:50:57 -0400 Subject: [PATCH 34/49] mai2: fix logic in handle_get_user_music_api_request --- titles/mai2/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/titles/mai2/base.py b/titles/mai2/base.py index 6ad36d7..6f32518 100644 --- a/titles/mai2/base.py +++ b/titles/mai2/base.py @@ -668,7 +668,7 @@ class Mai2Base: num_user_songs = len(songs) for x in range(next_index, upper_lim): - if num_user_songs >= x: + if num_user_songs <= x: break tmp = songs[x]._asdict() From b12938bcd83dcddf8eee7588939c13fb7c72b628 Mon Sep 17 00:00:00 2001 From: Kevin Trocolli Date: Wed, 14 Jun 2023 03:00:52 -0400 Subject: [PATCH 35/49] pokken: add partial profile save logic --- titles/pokken/base.py | 56 +++++++++++++- titles/pokken/const.py | 12 +-- titles/pokken/schema/item.py | 14 +++- titles/pokken/schema/profile.py | 126 ++++++++++++++++++++++++++++---- 4 files changed, 184 insertions(+), 24 deletions(-) diff --git a/titles/pokken/base.py b/titles/pokken/base.py index 40e6444..3ec4475 100644 --- a/titles/pokken/base.py +++ b/titles/pokken/base.py @@ -1,6 +1,6 @@ from datetime import datetime, timedelta import json, logging -from typing import Any, Dict +from typing import Any, Dict, List import random from core.data import Data @@ -274,6 +274,60 @@ class PokkenBase: res.result = 1 res.type = jackal_pb2.MessageType.SAVE_USER + req = request.save_user + user_id = req.banapass_id + + tut_flgs: List[int] = [] + ach_flgs: List[int] = [] + evt_flgs: List[int] = [] + evt_params: List[int] = [] + + get_rank_pts: int = req.get_trainer_rank_point if req.get_trainer_rank_point else 0 + get_money: int = req.get_money + get_score_pts: int = req.get_score_point if req.get_score_point else 0 + grade_max: int = req.grade_max_num + extra_counter: int = req.extra_counter + evt_reward_get_flg: int = req.event_reward_get_flag + num_continues: int = req.continue_num + total_play_days: int = req.total_play_days + awake_num: int = req.awake_num # ? + use_support_ct: int = req.use_support_num + beat_num: int = req.beat_num # ? + evt_state: int = req.event_state + aid_skill: int = req.aid_skill + last_evt: int = req.last_play_event_id + + battle = req.battle_data + mon = req.pokemon_data + + self.data.profile.update_support_team(user_id, 1, req.support_set_1[0], req.support_set_1[1]) + self.data.profile.update_support_team(user_id, 2, req.support_set_2[0], req.support_set_2[1]) + self.data.profile.update_support_team(user_id, 3, req.support_set_3[0], req.support_set_3[1]) + + if req.trainer_name_pending: # we're saving for the first time + self.data.profile.set_profile_name(user_id, req.trainer_name_pending, req.avatar_gender if req.avatar_gender else None) + + for tut_flg in req.tutorial_progress_flag: + tut_flgs.append(tut_flg) + + self.data.profile.update_profile_tutorial_flags(user_id, tut_flgs) + + for ach_flg in req.achievement_flag: + ach_flgs.append(ach_flg) + + self.data.profile.update_profile_tutorial_flags(user_id, ach_flg) + + for evt_flg in req.event_achievement_flag: + evt_flgs.append(evt_flg) + + for evt_param in req.event_achievement_param: + evt_params.append(evt_param) + + self.data.profile.update_profile_event(user_id, evt_state, evt_flgs, evt_params, ) + + for reward in req.reward_data: + self.data.item.add_reward(user_id, reward.get_category_id, reward.get_content_id, reward.get_type_id) + return res.SerializeToString() def handle_save_ingame_log(self, data: jackal_pb2.Request) -> bytes: diff --git a/titles/pokken/const.py b/titles/pokken/const.py index 2eb5357..e7ffdd8 100644 --- a/titles/pokken/const.py +++ b/titles/pokken/const.py @@ -11,14 +11,14 @@ class PokkenConstants: VERSION_NAMES = "Pokken Tournament" class BATTLE_TYPE(Enum): - BATTLE_TYPE_TUTORIAL = 1 - BATTLE_TYPE_AI = 2 - BATTLE_TYPE_LAN = 3 - BATTLE_TYPE_WAN = 4 + TUTORIAL = 1 + AI = 2 + LAN = 3 + WAN = 4 class BATTLE_RESULT(Enum): - BATTLE_RESULT_WIN = 1 - BATTLE_RESULT_LOSS = 2 + WIN = 1 + LOSS = 2 @classmethod def game_ver_to_string(cls, ver: int): diff --git a/titles/pokken/schema/item.py b/titles/pokken/schema/item.py index 4919ea0..32bff2a 100644 --- a/titles/pokken/schema/item.py +++ b/titles/pokken/schema/item.py @@ -31,4 +31,16 @@ class PokkenItemData(BaseData): Items obtained as rewards """ - pass + def add_reward(self, user_id: int, category: int, content: int, item_type: int) -> Optional[int]: + sql = insert(item).values( + user=user_id, + category=category, + content=content, + type=item_type, + ) + + result = self.execute(sql) + if result is None: + self.logger.warn(f"Failed to insert reward for user {user_id}: {category}-{content}-{item_type}") + return None + return result.lastrowid diff --git a/titles/pokken/schema/profile.py b/titles/pokken/schema/profile.py index 8e536f1..94e15e8 100644 --- a/titles/pokken/schema/profile.py +++ b/titles/pokken/schema/profile.py @@ -1,4 +1,4 @@ -from typing import Optional, Dict, List +from typing import Optional, Dict, List, Union from sqlalchemy import Table, Column, UniqueConstraint, PrimaryKeyConstraint, and_, case from sqlalchemy.types import Integer, String, TIMESTAMP, Boolean, JSON from sqlalchemy.schema import ForeignKey @@ -125,7 +125,7 @@ pokemon_data = Table( Column("win_vs_lan", Integer), Column("battle_num_vs_cpu", Integer), # 2 Column("win_cpu", Integer), - Column("battle_all_num_tutorial", Integer), + Column("battle_all_num_tutorial", Integer), # ??? Column("battle_num_tutorial", Integer), # 1? Column("bp_point_atk", Integer), Column("bp_point_res", Integer), @@ -147,11 +147,10 @@ class PokkenProfileData(BaseData): return None return result.lastrowid - def set_profile_name(self, user_id: int, new_name: str) -> None: - sql = ( - update(profile) - .where(profile.c.user == user_id) - .values(trainer_name=new_name) + def set_profile_name(self, user_id: int, new_name: str, gender: Union[int, None] = None) -> None: + sql = update(profile).where(profile.c.user == user_id).values( + trainer_name=new_name, + avatar_gender=gender if gender is not None else profile.c.avatar_gender ) result = self.execute(sql) if result is None: @@ -159,8 +158,38 @@ class PokkenProfileData(BaseData): f"Failed to update pokken profile name for user {user_id}!" ) - def update_profile_tutorial_flags(self, user_id: int, tutorial_flags: Dict) -> None: - pass + def update_profile_tutorial_flags(self, user_id: int, tutorial_flags: List) -> None: + sql = update(profile).where(profile.c.user == user_id).values( + tutorial_progress_flag=tutorial_flags, + ) + result = self.execute(sql) + if result is None: + self.logger.error( + f"Failed to update pokken profile tutorial flags for user {user_id}!" + ) + + def update_profile_achievement_flags(self, user_id: int, achievement_flags: List) -> None: + sql = update(profile).where(profile.c.user == user_id).values( + achievement_flag=achievement_flags, + ) + result = self.execute(sql) + if result is None: + self.logger.error( + f"Failed to update pokken profile achievement flags for user {user_id}!" + ) + + def update_profile_event(self, user_id: int, event_state: List, event_flags: List[int], event_param: List[int], last_evt: int = None) -> None: + sql = update(profile).where(profile.c.user == user_id).values( + event_state=event_state, + event_achievement_flag=event_flags, + event_achievement_param=event_param, + last_play_event_id=last_evt if last_evt is not None else profile.c.last_play_event_id, + ) + result = self.execute(sql) + if result is None: + self.logger.error( + f"Failed to update pokken profile event state for user {user_id}!" + ) def add_profile_points( self, user_id: int, rank_pts: int, money: int, score_pts: int @@ -174,18 +203,53 @@ class PokkenProfileData(BaseData): return None return result.fetchone() - def put_pokemon_data( + def put_pokemon( self, user_id: int, pokemon_id: int, illust_no: int, - get_exp: int, atk: int, res: int, defe: int, - sp: int, + sp: int ) -> Optional[int]: - pass + sql = insert(pokemon_data).values( + user=user_id, + char_id=pokemon_id, + illustration_book_no=illust_no, + bp_point_atk=atk, + bp_point_res=res, + bp_point_defe=defe, + bp_point_sp=sp, + ) + + conflict = sql.on_duplicate_key_update( + illustration_book_no=illust_no, + bp_point_atk=atk, + bp_point_res=res, + bp_point_defe=defe, + bp_point_sp=sp, + ) + + result = self.execute(conflict) + if result is None: + self.logger.warn(f"Failed to insert pokemon ID {pokemon_id} for user {user_id}") + return None + return result.lastrowid + + def add_pokemon_xp( + self, + user_id: int, + pokemon_id: int, + xp: int + ) -> None: + sql = update(pokemon_data).where(and_(pokemon_data.c.user==user_id, pokemon_data.c.char_id==pokemon_id)).values( + pokemon_exp=pokemon_data.c.pokemon_exp + xp + ) + + result = self.execute(sql) + if result is None: + self.logger.warn(f"Failed to add {xp} XP to pokemon ID {pokemon_id} for user {user_id}") def get_pokemon_data(self, user_id: int, pokemon_id: int) -> Optional[Row]: pass @@ -193,13 +257,29 @@ class PokkenProfileData(BaseData): def get_all_pokemon_data(self, user_id: int) -> Optional[List[Row]]: pass - def put_results( - self, user_id: int, pokemon_id: int, match_type: int, match_result: int + def put_pokemon_battle_result( + self, user_id: int, pokemon_id: int, match_type: PokkenConstants.BATTLE_TYPE, match_result: PokkenConstants.BATTLE_RESULT ) -> None: """ Records the match stats (type and win/loss) for the pokemon and profile """ - pass + sql = update(pokemon_data).where(and_(pokemon_data.c.user==user_id, pokemon_data.c.char_id==pokemon_id)).values( + battle_num_tutorial=pokemon_data.c.battle_num_tutorial + 1 if match_type==PokkenConstants.BATTLE_TYPE.TUTORIAL else pokemon_data.c.battle_num_tutorial, + battle_all_num_tutorial=pokemon_data.c.battle_all_num_tutorial + 1 if match_type==PokkenConstants.BATTLE_TYPE.TUTORIAL else pokemon_data.c.battle_all_num_tutorial, + + battle_num_vs_cpu=pokemon_data.c.battle_num_vs_cpu + 1 if match_type==PokkenConstants.BATTLE_TYPE.AI else pokemon_data.c.battle_num_vs_cpu, + win_cpu=pokemon_data.c.win_cpu + 1 if match_type==PokkenConstants.BATTLE_TYPE.AI and match_result==PokkenConstants.BATTLE_RESULT.WIN else pokemon_data.c.win_cpu, + + battle_num_vs_lan=pokemon_data.c.battle_num_vs_lan + 1 if match_type==PokkenConstants.BATTLE_TYPE.LAN else pokemon_data.c.battle_num_vs_lan, + win_vs_lan=pokemon_data.c.win_vs_lan + 1 if match_type==PokkenConstants.BATTLE_TYPE.LAN and match_result==PokkenConstants.BATTLE_RESULT.WIN else pokemon_data.c.win_vs_lan, + + battle_num_vs_wan=pokemon_data.c.battle_num_vs_wan + 1 if match_type==PokkenConstants.BATTLE_TYPE.WAN else pokemon_data.c.battle_num_vs_wan, + win_vs_wan=pokemon_data.c.win_vs_wan + 1 if match_type==PokkenConstants.BATTLE_TYPE.WAN and match_result==PokkenConstants.BATTLE_RESULT.WIN else pokemon_data.c.win_vs_wan, + ) + + result = self.execute(sql) + if result is None: + self.logger.warn(f"Failed to record match stats for user {user_id}'s pokemon {pokemon_id} (type {match_type.name} | result {match_result.name})") def put_stats( self, @@ -215,3 +295,17 @@ class PokkenProfileData(BaseData): Records profile stats """ pass + + def update_support_team(self, user_id: int, support_id: int, support1: int = 4294967295, support2: int = 4294967295) -> None: + sql = update(profile).where(profile.c.user==user_id).values( + support_set_1_1=support1 if support_id == 1 else profile.c.support_set_1_1, + support_set_1_2=support2 if support_id == 1 else profile.c.support_set_1_2, + support_set_2_1=support1 if support_id == 2 else profile.c.support_set_2_1, + support_set_2_2=support2 if support_id == 2 else profile.c.support_set_2_2, + support_set_3_1=support1 if support_id == 3 else profile.c.support_set_3_1, + support_set_3_2=support2 if support_id == 3 else profile.c.support_set_3_2, + ) + + result = self.execute(sql) + if result is None: + self.logger.warn(f"Failed to update support team {support_id} for user {user_id}") From 3c385f505be8a0a99d78ef0054bc504c127d99e9 Mon Sep 17 00:00:00 2001 From: Kevin Trocolli Date: Fri, 23 Jun 2023 00:30:25 -0400 Subject: [PATCH 36/49] pokken: add requirement for autobahn, add stun, turn and admission servers --- requirements.txt | Bin 199 -> 208 bytes titles/pokken/base.py | 21 +++++++++-- titles/pokken/index.py | 26 +++++++++++--- titles/pokken/services.py | 72 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 112 insertions(+), 7 deletions(-) create mode 100644 titles/pokken/services.py diff --git a/requirements.txt b/requirements.txt index ebb17c930875bf3d4871fcb2f1b13f10661002bc..6d3772868f1fe6c0e26f39c9ed3df82af25ab4b8 100644 GIT binary patch delta 16 XcmX@kc!6=kan8ijlKiB^j65y?ItB(- delta 6 Ncmcb>c${& Dict: """ "sessionId":"12345678", + "A":{ + "pcb_id": data["data"]["must"]["pcb_id"], + "gip": client_ip + }, + "list":[] + """ + return { + "data": { + "sessionId":"12345678", "A":{ "pcb_id": data["data"]["must"]["pcb_id"], "gip": client_ip }, "list":[] - """ - return {} + } + } def handle_matching_stop_matching( self, data: Dict = {}, client_ip: str = "127.0.0.1" ) -> Dict: return {} + + def handle_admission_joinsession(self, data: Dict, req_ip: str = "127.0.0.1") -> Dict: + self.logger.info(f"Admission: JoinSession from {req_ip}") + return { + 'data': { + "id": 123 + } + } \ No newline at end of file diff --git a/titles/pokken/index.py b/titles/pokken/index.py index bccdcaf..1140d41 100644 --- a/titles/pokken/index.py +++ b/titles/pokken/index.py @@ -1,6 +1,8 @@ from typing import Tuple from twisted.web.http import Request from twisted.web import resource +from twisted.internet import reactor, endpoints +from autobahn.twisted.websocket import WebSocketServerFactory import json, ast from datetime import datetime import yaml @@ -11,10 +13,11 @@ from os import path from google.protobuf.message import DecodeError from core import CoreConfig, Utils -from titles.pokken.config import PokkenConfig -from titles.pokken.base import PokkenBase -from titles.pokken.const import PokkenConstants -from titles.pokken.proto import jackal_pb2 +from .config import PokkenConfig +from .base import PokkenBase +from .const import PokkenConstants +from .proto import jackal_pb2 +from .services import PokkenStunProtocol, PokkenAdmissionFactory, PokkenAdmissionProtocol class PokkenServlet(resource.Resource): @@ -91,7 +94,20 @@ class PokkenServlet(resource.Resource): def setup(self) -> None: # TODO: Setup stun, turn (UDP) and admission (WSS) servers - pass + reactor.listenUDP( + self.game_cfg.server.port_stun, PokkenStunProtocol(self.core_cfg, self.game_cfg, "Stun") + ) + + reactor.listenUDP( + self.game_cfg.server.port_turn, PokkenStunProtocol(self.core_cfg, self.game_cfg, "Turn") + ) + + factory = WebSocketServerFactory(f"ws://{self.game_cfg.server.hostname}:{self.game_cfg.server.port_admission}") + factory.protocol = PokkenAdmissionProtocol + + reactor.listenTCP( + self.game_cfg.server.port_admission, PokkenAdmissionFactory(self.core_cfg, self.game_cfg) + ) def render_POST( self, request: Request, version: int = 0, endpoints: str = "" diff --git a/titles/pokken/services.py b/titles/pokken/services.py new file mode 100644 index 0000000..ac12b72 --- /dev/null +++ b/titles/pokken/services.py @@ -0,0 +1,72 @@ +from twisted.internet.interfaces import IAddress +from twisted.internet.protocol import DatagramProtocol +from twisted.internet.protocol import Protocol +from autobahn.twisted.websocket import WebSocketServerProtocol, WebSocketServerFactory +from datetime import datetime +import logging +import json + +from core.config import CoreConfig +from .config import PokkenConfig +from .base import PokkenBase + +class PokkenStunProtocol(DatagramProtocol): + def __init__(self, cfg: CoreConfig, game_cfg: PokkenConfig, type: str) -> None: + super().__init__() + self.core_config = cfg + self.game_config = game_cfg + self.logger = logging.getLogger("pokken") + self.server_type = type + + def datagramReceived(self, data, addr): + self.logger.debug( + f"{self.server_type} from from {addr[0]}:{addr[1]} -> {self.transport.getHost().port} - {data.hex()}" + ) + self.transport.write(data, addr) + +# 474554202f20485454502f312e310d0a436f6e6e656374696f6e3a20557067726164650d0a486f73743a207469746c65732e6861793174732e6d653a393030330d0a5365632d576562536f636b65742d4b65793a204f4a6b6d522f376b646d6953326573483548783776413d3d0d0a5365632d576562536f636b65742d56657273696f6e3a2031330d0a557067726164653a20776562736f636b65740d0a557365722d4167656e743a20576562536f636b65742b2b2f302e332e300d0a0d0a +class PokkenAdmissionProtocol(WebSocketServerProtocol): + def __init__(self, cfg: CoreConfig, game_cfg: PokkenConfig): + super().__init__() + self.core_config = cfg + self.game_config = game_cfg + self.logger = logging.getLogger("pokken") + + self.base = PokkenBase(cfg, game_cfg) + + def onMessage(self, payload, isBinary: bool) -> None: + msg = json.loads(payload) + self.logger.debug(f"WebSocket from from {self.transport.getPeer().host}:{self.transport.getPeer().port} -> {self.transport.getHost().port} - {msg}") + + handler = getattr(self.base, f"handle_admission_{msg['api'].lower()}") + resp = handler(msg, self.transport.getPeer().host) + + if "type" not in resp: + resp['type'] = "res" + if "data" not in resp: + resp['data'] = {} + if "api" not in resp: + resp['api'] = msg["api"] + if "result" not in resp: + resp['result'] = 'true' + + self.logger.debug(f"Websocket response: {resp}") + self.sendMessage(json.dumps(resp).encode(), isBinary) + +# 0001002c2112a442334a0506a62efa71477dcd698022002872655455524e2053796e6320436c69656e7420302e33202d20524643353338392f7475726e2d3132 +class PokkenAdmissionFactory(WebSocketServerFactory): + protocol = PokkenAdmissionProtocol + + def __init__( + self, + cfg: CoreConfig, + game_cfg: PokkenConfig + ) -> None: + self.core_config = cfg + self.game_config = game_cfg + super().__init__(f"ws://{self.game_config.server.hostname}:{self.game_config.server.port_admission}") + + def buildProtocol(self, addr: IAddress) -> Protocol: + p = self.protocol(self.core_config, self.game_config) + p.factory = self + return p From 1d10e798a5e1eb78a4ba53c7e06b3c4be0f097d6 Mon Sep 17 00:00:00 2001 From: Midorica Date: Sat, 24 Jun 2023 12:29:28 -0400 Subject: [PATCH 37/49] Fixed few issues for SAO & removed static hex ranges --- titles/sao/base.py | 105 +++++++++++++++++++++++++++++++++++++++------ 1 file changed, 93 insertions(+), 12 deletions(-) diff --git a/titles/sao/base.py b/titles/sao/base.py index 098b9c3..b4a71a4 100644 --- a/titles/sao/base.py +++ b/titles/sao/base.py @@ -62,9 +62,26 @@ class SaoBase: def handle_c11e(self, request: Any) -> bytes: #common/get_auth_card_data + req = bytes.fromhex(request)[24:] + + req_struct = Struct( + Padding(16), + "cabinet_type" / Int8ub, # cabinet_type is a byte + "auth_type" / Int8ub, # auth_type is a byte + "store_id_size" / Rebuild(Int32ub, len_(this.store_id) * 2), # calculates the length of the store_id + "store_id" / PaddedString(this.store_id_size, "utf_16_le"), # store_id is a (zero) padded string + "serial_no_size" / Rebuild(Int32ub, len_(this.serial_no) * 2), # calculates the length of the serial_no + "serial_no" / PaddedString(this.serial_no_size, "utf_16_le"), # serial_no is a (zero) padded string + "access_code_size" / Rebuild(Int32ub, len_(this.access_code) * 2), # calculates the length of the access_code + "access_code" / PaddedString(this.access_code_size, "utf_16_le"), # access_code is a (zero) padded string + "chip_id_size" / Rebuild(Int32ub, len_(this.chip_id) * 2), # calculates the length of the chip_id + "chip_id" / PaddedString(this.chip_id_size, "utf_16_le"), # chip_id is a (zero) padded string + ) + + req_data = req_struct.parse(req) + access_code = req_data.access_code #Check authentication - access_code = bytes.fromhex(request[188:268]).decode("utf-16le") user_id = self.core_data.card.get_user_id_from_card( access_code ) if not user_id: @@ -92,6 +109,14 @@ class SaoBase: if user_id and not profile_data: profile_id = self.game_data.profile.create_profile(user_id) + self.game_data.item.put_hero_log(user_id, 101000010, 1, 0, 101000016, 0, 30086, 1001, 1002, 1003, 1005) + self.game_data.item.put_hero_log(user_id, 102000010, 1, 0, 103000006, 0, 30086, 1001, 1002, 1003, 1005) + self.game_data.item.put_hero_log(user_id, 103000010, 1, 0, 112000009, 0, 30086, 1001, 1002, 1003, 1005) + self.game_data.item.put_hero_party(user_id, 0, 101000010, 102000010, 103000010) + self.game_data.item.put_equipment_data(user_id, 101000016, 1, 200, 0, 0, 0) + self.game_data.item.put_equipment_data(user_id, 103000006, 1, 200, 0, 0, 0) + self.game_data.item.put_equipment_data(user_id, 112000009, 1, 200, 0, 0, 0) + profile_data = self.game_data.profile.get_profile(user_id) resp = SaoGetAuthCardDataResponse(int.from_bytes(bytes.fromhex(request[:4]), "big")+1, profile_data) @@ -104,7 +129,28 @@ class SaoBase: def handle_c104(self, request: Any) -> bytes: #common/login - access_code = bytes.fromhex(request[228:308]).decode("utf-16le") + req = bytes.fromhex(request)[24:] + + req_struct = Struct( + Padding(16), + "cabinet_type" / Int8ub, # cabinet_type is a byte + "auth_type" / Int8ub, # auth_type is a byte + "store_id_size" / Rebuild(Int32ub, len_(this.store_id) * 2), # calculates the length of the store_id + "store_id" / PaddedString(this.store_id_size, "utf_16_le"), # store_id is a (zero) padded string + "store_name_size" / Rebuild(Int32ub, len_(this.store_name) * 2), # calculates the length of the store_name + "store_name" / PaddedString(this.store_name_size, "utf_16_le"), # store_name is a (zero) padded string + "serial_no_size" / Rebuild(Int32ub, len_(this.serial_no) * 2), # calculates the length of the serial_no + "serial_no" / PaddedString(this.serial_no_size, "utf_16_le"), # serial_no is a (zero) padded string + "access_code_size" / Rebuild(Int32ub, len_(this.access_code) * 2), # calculates the length of the access_code + "access_code" / PaddedString(this.access_code_size, "utf_16_le"), # access_code is a (zero) padded string + "chip_id_size" / Rebuild(Int32ub, len_(this.chip_id) * 2), # calculates the length of the chip_id + "chip_id" / PaddedString(this.chip_id_size, "utf_16_le"), # chip_id is a (zero) padded string + "free_ticket_distribution_target_flag" / Int8ub, # free_ticket_distribution_target_flag is a byte + ) + + req_data = req_struct.parse(req) + access_code = req_data.access_code + user_id = self.core_data.card.get_user_id_from_card( access_code ) profile_data = self.game_data.profile.get_profile(user_id) @@ -123,7 +169,17 @@ class SaoBase: def handle_c500(self, request: Any) -> bytes: #user_info/get_user_basic_data - user_id = bytes.fromhex(request[88:112]).decode("utf-16le") + req = bytes.fromhex(request)[24:] + + req_struct = Struct( + Padding(16), + "user_id_size" / Rebuild(Int32ub, len_(this.user_id) * 2), # calculates the length of the user_id + "user_id" / PaddedString(this.user_id_size, "utf_16_le"), # user_id is a (zero) padded string + ) + + req_data = req_struct.parse(req) + user_id = req_data.user_id + profile_data = self.game_data.profile.get_profile(user_id) resp = SaoGetUserBasicDataResponse(int.from_bytes(bytes.fromhex(request[:4]), "big")+1, profile_data) @@ -132,6 +188,7 @@ class SaoBase: def handle_c600(self, request: Any) -> bytes: #have_object/get_hero_log_user_data_list req = bytes.fromhex(request)[24:] + req_struct = Struct( Padding(16), "user_id_size" / Rebuild(Int32ub, len_(this.user_id) * 2), # calculates the length of the user_id @@ -149,6 +206,7 @@ class SaoBase: def handle_c602(self, request: Any) -> bytes: #have_object/get_equipment_user_data_list req = bytes.fromhex(request)[24:] + req_struct = Struct( Padding(16), "user_id_size" / Rebuild(Int32ub, len_(this.user_id) * 2), # calculates the length of the user_id @@ -165,8 +223,8 @@ class SaoBase: def handle_c604(self, request: Any) -> bytes: #have_object/get_item_user_data_list - #itemIdsData = self.game_data.static.get_item_ids(0, True) req = bytes.fromhex(request)[24:] + req_struct = Struct( Padding(16), "user_id_size" / Rebuild(Int32ub, len_(this.user_id) * 2), # calculates the length of the user_id @@ -179,7 +237,6 @@ class SaoBase: item_data = self.game_data.item.get_user_items(user_id) resp = SaoGetItemUserDataListResponse(int.from_bytes(bytes.fromhex(request[:4]), "big")+1, item_data) - #resp = SaoNoopResponse(int.from_bytes(bytes.fromhex(request[:4]), "big")+1) return resp.make() def handle_c606(self, request: Any) -> bytes: @@ -198,7 +255,17 @@ class SaoBase: def handle_c608(self, request: Any) -> bytes: #have_object/get_episode_append_data_list - user_id = bytes.fromhex(request[88:112]).decode("utf-16le") + req = bytes.fromhex(request)[24:] + + req_struct = Struct( + Padding(16), + "user_id_size" / Rebuild(Int32ub, len_(this.user_id) * 2), # calculates the length of the user_id + "user_id" / PaddedString(this.user_id_size, "utf_16_le"), # user_id is a (zero) padded string + ) + + req_data = req_struct.parse(req) + user_id = req_data.user_id + profile_data = self.game_data.profile.get_profile(user_id) resp = SaoGetEpisodeAppendDataListResponse(int.from_bytes(bytes.fromhex(request[:4]), "big")+1, profile_data) @@ -206,8 +273,8 @@ class SaoBase: def handle_c804(self, request: Any) -> bytes: #custom/get_party_data_list - req = bytes.fromhex(request)[24:] + req_struct = Struct( Padding(16), "user_id_size" / Rebuild(Int32ub, len_(this.user_id) * 2), # calculates the length of the user_id @@ -479,7 +546,6 @@ class SaoBase: def handle_c904(self, request: Any) -> bytes: #quest/episode_play_start - req = bytes.fromhex(request)[24:] req_struct = Struct( @@ -718,10 +784,25 @@ class SaoBase: resp = SaoEpisodePlayEndResponse(int.from_bytes(bytes.fromhex(request[:4]), "big")+1) return resp.make() - def handle_c914(self, request: Any) -> bytes: + def handle_c914(self, request: Any) -> bytes: # TBD #quest/trial_tower_play_start - user_id = bytes.fromhex(request[100:124]).decode("utf-16le") - floor_id = int(request[130:132], 16) # not required but nice to know + req = bytes.fromhex(request)[24:] + + req_struct = Struct( + Padding(16), + "ticket_id_size" / Rebuild(Int32ub, len_(this.ticket_id) * 2), # calculates the length of the ticket_id + "ticket_id" / PaddedString(this.ticket_id_size, "utf_16_le"), # ticket_id is a (zero) padded string + "user_id_size" / Rebuild(Int32ub, len_(this.user_id) * 2), # calculates the length of the user_id + "user_id" / PaddedString(this.user_id_size, "utf_16_le"), # user_id is a (zero) padded string + "trial_tower_id" / Int32ub, # trial_tower_id is an int + "play_mode" / Int8ub, # play_mode is a byte + + ) + + req_data = req_struct.parse(req) + + user_id = req_data.user_id + floor_id = req_data.trial_tower_id profile_data = self.game_data.profile.get_profile(user_id) resp = SaoEpisodePlayStartResponse(int.from_bytes(bytes.fromhex(request[:4]), "big")+1, profile_data) @@ -931,4 +1012,4 @@ class SaoBase: def handle_c91a(self, request: Any) -> bytes: # handler is identical to the episode #quest/trial_tower_play_end_unanalyzed_log_fixed resp = SaoEpisodePlayEndUnanalyzedLogFixedResponse(int.from_bytes(bytes.fromhex(request[:4]), "big")+1) - return resp.make() + return resp.make() \ No newline at end of file From 858b101a36102702afcf29d405d39a0bd5a770fe Mon Sep 17 00:00:00 2001 From: Kevin Trocolli Date: Sat, 24 Jun 2023 13:14:40 -0400 Subject: [PATCH 38/49] dbutils: add command to show versions --- core/data/database.py | 5 +++++ dbutils.py | 3 +++ 2 files changed, 8 insertions(+) diff --git a/core/data/database.py b/core/data/database.py index 719d05e..ffbefc0 100644 --- a/core/data/database.py +++ b/core/data/database.py @@ -333,3 +333,8 @@ class Data: if not failed: self.base.set_schema_ver(latest_ver, game) + + def show_versions(self) -> None: + all_game_versions = self.base.get_all_schema_vers() + for ver in all_game_versions: + self.logger.info(f"{ver['game']} -> v{ver['version']}") diff --git a/dbutils.py b/dbutils.py index d959232..caae9d8 100644 --- a/dbutils.py +++ b/dbutils.py @@ -84,5 +84,8 @@ if __name__ == "__main__": elif args.action == "cleanup": data.delete_hanging_users() + + elif args.action == "version": + data.show_versions() data.logger.info("Done") From 402e7534692cd534445cbcffeddcb1c8002194f5 Mon Sep 17 00:00:00 2001 From: Kevin Trocolli Date: Sat, 24 Jun 2023 15:09:38 -0400 Subject: [PATCH 39/49] wacca: fix tabbing error in util_put_items --- titles/wacca/base.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/titles/wacca/base.py b/titles/wacca/base.py index eeafe4c..cca6aa5 100644 --- a/titles/wacca/base.py +++ b/titles/wacca/base.py @@ -1074,17 +1074,17 @@ class WaccaBase: old_score = self.data.score.get_best_score( user_id, item.itemId, item.quantity ) - if not old_score: - self.data.score.put_best_score( - user_id, - item.itemId, - item.quantity, - 0, - [0] * 5, - [0] * 13, - 0, - 0, - ) + if not old_score: + self.data.score.put_best_score( + user_id, + item.itemId, + item.quantity, + 0, + [0] * 5, + [0] * 13, + 0, + 0, + ) if item.quantity == 0: item.quantity = WaccaConstants.Difficulty.HARD.value From d5bff0e8918d6ea95aa023d931f59cf9df4bb88e Mon Sep 17 00:00:00 2001 From: Midorica Date: Sat, 24 Jun 2023 18:48:48 -0400 Subject: [PATCH 40/49] Stage progression done for SAO --- docs/game_specific_info.md | 2 +- titles/sao/base.py | 63 +++++++++++++++++++++------- titles/sao/handlers/base.py | 59 ++++++++++++++++---------- titles/sao/schema/item.py | 83 +++++++++++++++++++++++++++++++++++++ 4 files changed, 168 insertions(+), 39 deletions(-) diff --git a/docs/game_specific_info.md b/docs/game_specific_info.md index 15efe7c..52b33a0 100644 --- a/docs/game_specific_info.md +++ b/docs/game_specific_info.md @@ -437,7 +437,7 @@ python dbutils.py --game SDEW upgrade ``` ### Notes -- Stages are currently force unlocked, no progression +- Tower Quests currently force unlocked, no progression - Co-Op (matching) is not supported - Shop is not functionnal - Player title is currently static and cannot be changed in-game diff --git a/titles/sao/base.py b/titles/sao/base.py index b4a71a4..7019dca 100644 --- a/titles/sao/base.py +++ b/titles/sao/base.py @@ -101,6 +101,10 @@ class SaoBase: self.game_data.item.put_equipment_data(user_id, 101000016, 1, 200, 0, 0, 0) self.game_data.item.put_equipment_data(user_id, 103000006, 1, 200, 0, 0, 0) self.game_data.item.put_equipment_data(user_id, 112000009, 1, 200, 0, 0, 0) + self.game_data.item.put_player_quest(user_id, 1001, True, 300, 0, 0, 1) + + # Force the tutorial stage to be completed due to potential crash in-game + self.logger.info(f"User Authenticated: { access_code } | { user_id }") @@ -116,6 +120,10 @@ class SaoBase: self.game_data.item.put_equipment_data(user_id, 101000016, 1, 200, 0, 0, 0) self.game_data.item.put_equipment_data(user_id, 103000006, 1, 200, 0, 0, 0) self.game_data.item.put_equipment_data(user_id, 112000009, 1, 200, 0, 0, 0) + self.game_data.item.put_player_quest(user_id, 1001, True, 300, 0, 0, 1) + + # Force the tutorial stage to be completed due to potential crash in-game + profile_data = self.game_data.profile.get_profile(user_id) @@ -304,8 +312,20 @@ class SaoBase: def handle_c900(self, request: Any) -> bytes: #quest/get_quest_scene_user_data_list // QuestScene.csv - questIdsData = self.game_data.static.get_quests_ids(0, True) - resp = SaoGetQuestSceneUserDataListResponse(int.from_bytes(bytes.fromhex(request[:4]), "big")+1, questIdsData) + req = bytes.fromhex(request)[24:] + + req_struct = Struct( + Padding(16), + "user_id_size" / Rebuild(Int32ub, len_(this.user_id) * 2), # calculates the length of the user_id + "user_id" / PaddedString(this.user_id_size, "utf_16_le"), # user_id is a (zero) padded string + + ) + req_data = req_struct.parse(req) + user_id = req_data.user_id + + quest_data = self.game_data.item.get_quest_logs(user_id) + + resp = SaoGetQuestSceneUserDataListResponse(int.from_bytes(bytes.fromhex(request[:4]), "big")+1, quest_data) return resp.make() def handle_c400(self, request: Any) -> bytes: @@ -678,8 +698,21 @@ class SaoBase: req_data = req_struct.parse(req) + # Add stage progression to database + user_id = req_data.user_id + episode_id = req_data.episode_id + quest_clear_flag = bool(req_data.score_data[0].boss_destroying_num) + clear_time = req_data.score_data[0].clear_time + combo_num = req_data.score_data[0].combo_num + total_damage = req_data.score_data[0].total_damage + concurrent_destroying_num = req_data.score_data[0].concurrent_destroying_num + + if quest_clear_flag is True: + # Save stage progression - to be revised to avoid saving worse score + self.game_data.item.put_player_quest(user_id, episode_id, quest_clear_flag, clear_time, combo_num, total_damage, concurrent_destroying_num) + # Update the profile - profile = self.game_data.profile.get_profile(req_data.user_id) + profile = self.game_data.profile.get_profile(user_id) exp = int(profile["rank_exp"]) + 100 #always 100 extra exp for some reason col = int(profile["own_col"]) + int(req_data.base_get_data[0].get_col) @@ -703,7 +736,7 @@ class SaoBase: # Update profile updated_profile = self.game_data.profile.put_profile( - req_data.user_id, + user_id, profile["user_type"], profile["nick_name"], player_level, @@ -715,8 +748,8 @@ class SaoBase: ) # Update heroes from the used party - play_session = self.game_data.item.get_session(req_data.user_id) - session_party = self.game_data.item.get_hero_party(req_data.user_id, play_session["user_party_team_id"]) + play_session = self.game_data.item.get_session(user_id) + session_party = self.game_data.item.get_hero_party(user_id, play_session["user_party_team_id"]) hero_list = [] hero_list.append(session_party["user_hero_log_id_1"]) @@ -724,12 +757,12 @@ class SaoBase: hero_list.append(session_party["user_hero_log_id_3"]) for i in range(0,len(hero_list)): - hero_data = self.game_data.item.get_hero_log(req_data.user_id, hero_list[i]) + hero_data = self.game_data.item.get_hero_log(user_id, hero_list[i]) log_exp = int(hero_data["log_exp"]) + int(req_data.base_get_data[0].get_hero_log_exp) self.game_data.item.put_hero_log( - req_data.user_id, + user_id, hero_data["user_hero_log_id"], hero_data["log_level"], log_exp, @@ -752,11 +785,11 @@ class SaoBase: itemList = self.game_data.static.get_item_id(commonRewardId) if heroList: - self.game_data.item.put_hero_log(req_data.user_id, commonRewardId, 1, 0, 101000016, 0, 30086, 1001, 1002, 0, 0) + self.game_data.item.put_hero_log(user_id, commonRewardId, 1, 0, 101000016, 0, 30086, 1001, 1002, 0, 0) if equipmentList: - self.game_data.item.put_equipment_data(req_data.user_id, commonRewardId, 1, 200, 0, 0, 0) + self.game_data.item.put_equipment_data(user_id, commonRewardId, 1, 200, 0, 0, 0) if itemList: - self.game_data.item.put_item(req_data.user_id, commonRewardId) + self.game_data.item.put_item(user_id, commonRewardId) # Generate random hero(es) based off the response for a in range(0,req_data.get_unanalyzed_log_tmp_reward_data_list_length): @@ -773,11 +806,11 @@ class SaoBase: equipmentList = self.game_data.static.get_equipment_id(randomized_unanalyzed_id['CommonRewardId']) itemList = self.game_data.static.get_item_id(randomized_unanalyzed_id['CommonRewardId']) if heroList: - self.game_data.item.put_hero_log(req_data.user_id, randomized_unanalyzed_id['CommonRewardId'], 1, 0, 101000016, 0, 30086, 1001, 1002, 0, 0) + self.game_data.item.put_hero_log(user_id, randomized_unanalyzed_id['CommonRewardId'], 1, 0, 101000016, 0, 30086, 1001, 1002, 0, 0) if equipmentList: - self.game_data.item.put_equipment_data(req_data.user_id, randomized_unanalyzed_id['CommonRewardId'], 1, 200, 0, 0, 0) + self.game_data.item.put_equipment_data(user_id, randomized_unanalyzed_id['CommonRewardId'], 1, 200, 0, 0, 0) if itemList: - self.game_data.item.put_item(req_data.user_id, randomized_unanalyzed_id['CommonRewardId']) + self.game_data.item.put_item(user_id, randomized_unanalyzed_id['CommonRewardId']) # Send response @@ -1012,4 +1045,4 @@ class SaoBase: def handle_c91a(self, request: Any) -> bytes: # handler is identical to the episode #quest/trial_tower_play_end_unanalyzed_log_fixed resp = SaoEpisodePlayEndUnanalyzedLogFixedResponse(int.from_bytes(bytes.fromhex(request[:4]), "big")+1) - return resp.make() \ No newline at end of file + return resp.make() diff --git a/titles/sao/handlers/base.py b/titles/sao/handlers/base.py index a9d336a..eea9850 100644 --- a/titles/sao/handlers/base.py +++ b/titles/sao/handlers/base.py @@ -1707,24 +1707,43 @@ class SaoGetQuestSceneUserDataListRequest(SaoBaseRequest): super().__init__(data) class SaoGetQuestSceneUserDataListResponse(SaoBaseResponse): - def __init__(self, cmd, questIdsData) -> None: + def __init__(self, cmd, quest_data) -> None: super().__init__(cmd) self.result = 1 # quest_scene_user_data_list_size - self.quest_type = [1] * len(questIdsData) - self.quest_scene_id = questIdsData - self.clear_flag = [1] * len(questIdsData) + self.quest_type = [] + self.quest_scene_id = [] + self.clear_flag = [] # quest_scene_best_score_user_data - self.clear_time = 300 - self.combo_num = 0 - self.total_damage = "0" - self.concurrent_destroying_num = 1 + self.clear_time = [] + self.combo_num = [] + self.total_damage = [] #string + self.concurrent_destroying_num = [] + + for i in range(len(quest_data)): + self.quest_type.append(1) + self.quest_scene_id.append(quest_data[i][2]) + self.clear_flag.append(int(quest_data[i][3])) + + self.clear_time.append(quest_data[i][4]) + self.combo_num.append(quest_data[i][5]) + self.total_damage.append(0) #totally absurd but Int16ul[1] is a big problem due to different lenghts... + self.concurrent_destroying_num.append(quest_data[i][7]) # quest_scene_ex_bonus_user_data_list self.achievement_flag = [[1, 1, 1],[1, 1, 1]] self.ex_bonus_table_id = [[1, 2, 3],[4, 5, 6]] + + + self.quest_type = list(map(int,self.quest_type)) #int + self.quest_scene_id = list(map(int,self.quest_scene_id)) #int + self.clear_flag = list(map(int,self.clear_flag)) #int + self.clear_time = list(map(int,self.clear_time)) #int + self.combo_num = list(map(int,self.combo_num)) #int + self.total_damage = list(map(str,self.total_damage)) #string + self.concurrent_destroying_num = list(map(int,self.combo_num)) #int def make(self) -> bytes: #new stuff @@ -1737,7 +1756,7 @@ class SaoGetQuestSceneUserDataListResponse(SaoBaseResponse): "clear_time" / Int32ub, # big endian "combo_num" / Int32ub, # big endian "total_damage_size" / Int32ub, # big endian - "total_damage" / Int16ul[len(self.total_damage)], + "total_damage" / Int16ul[1], "concurrent_destroying_num" / Int16ub, ) @@ -1765,7 +1784,7 @@ class SaoGetQuestSceneUserDataListResponse(SaoBaseResponse): ))) for i in range(len(self.quest_scene_id)): - quest_data = dict( + quest_resp_data = dict( quest_type=self.quest_type[i], quest_scene_id=self.quest_scene_id[i], clear_flag=self.clear_flag[i], @@ -1776,22 +1795,16 @@ class SaoGetQuestSceneUserDataListResponse(SaoBaseResponse): quest_scene_ex_bonus_user_data_list=[], ) - quest_data["quest_scene_best_score_user_data"].append(dict( - clear_time=self.clear_time, - combo_num=self.combo_num, - total_damage_size=len(self.total_damage) * 2, - total_damage=[ord(x) for x in self.total_damage], - concurrent_destroying_num=self.concurrent_destroying_num, + quest_resp_data["quest_scene_best_score_user_data"].append(dict( + clear_time=self.clear_time[i], + combo_num=self.combo_num[i], + total_damage_size=len(self.total_damage[i]) * 2, + total_damage=[ord(x) for x in self.total_damage[i]], + concurrent_destroying_num=self.concurrent_destroying_num[i], )) - ''' - quest_data["quest_scene_ex_bonus_user_data_list"].append(dict( - ex_bonus_table_id=self.ex_bonus_table_id[i], - achievement_flag=self.achievement_flag[i], - )) - ''' - resp_data.quest_scene_user_data_list.append(quest_data) + resp_data.quest_scene_user_data_list.append(quest_resp_data) # finally, rebuild the resp_data resp_data = resp_struct.build(resp_data) diff --git a/titles/sao/schema/item.py b/titles/sao/schema/item.py index 858267e..8b46723 100644 --- a/titles/sao/schema/item.py +++ b/titles/sao/schema/item.py @@ -84,6 +84,26 @@ hero_party = Table( mysql_charset="utf8mb4", ) +quest = Table( + "sao_player_quest", + metadata, + Column("id", Integer, primary_key=True, nullable=False), + Column( + "user", + ForeignKey("aime_user.id", ondelete="cascade", onupdate="cascade"), + nullable=False, + ), + Column("episode_id", Integer, nullable=False), + Column("quest_clear_flag", Boolean, nullable=False), + Column("clear_time", Integer, nullable=False), + Column("combo_num", Integer, nullable=False), + Column("total_damage", Integer, nullable=False), + Column("concurrent_destroying_num", Integer, nullable=False), + Column("play_date", TIMESTAMP, nullable=False, server_default=func.now()), + UniqueConstraint("user", "episode_id", name="sao_player_quest_uk"), + mysql_charset="utf8mb4", +) + sessions = Table( "sao_play_sessions", metadata, @@ -227,6 +247,34 @@ class SaoItemData(BaseData): return result.lastrowid + def put_player_quest(self, user_id: int, episode_id: int, quest_clear_flag: bool, clear_time: int, combo_num: int, total_damage: int, concurrent_destroying_num: int) -> Optional[int]: + sql = insert(quest).values( + user=user_id, + episode_id=episode_id, + quest_clear_flag=quest_clear_flag, + clear_time=clear_time, + combo_num=combo_num, + total_damage=total_damage, + concurrent_destroying_num=concurrent_destroying_num + ) + + conflict = sql.on_duplicate_key_update( + quest_clear_flag=quest_clear_flag, + clear_time=clear_time, + combo_num=combo_num, + total_damage=total_damage, + concurrent_destroying_num=concurrent_destroying_num + ) + + result = self.execute(conflict) + if result is None: + self.logger.error( + f"{__name__} failed to insert quest! user: {user_id}, episode_id: {episode_id}" + ) + return None + + return result.lastrowid + def get_user_equipment(self, user_id: int, equipment_id: int) -> Optional[Dict]: sql = equipment_data.select(equipment_data.c.user == user_id and equipment_data.c.equipment_id == equipment_id) @@ -319,6 +367,41 @@ class SaoItemData(BaseData): return None return result.fetchone() + def get_quest_log( + self, user_id: int, episode_id: int = None + ) -> Optional[List[Row]]: + """ + A catch-all quest lookup given a profile and episode_id + """ + sql = quest.select( + and_( + quest.c.user == user_id, + quest.c.episode_id == episode_id if episode_id is not None else True, + ) + ) + + result = self.execute(sql) + if result is None: + return None + return result.fetchone() + + def get_quest_logs( + self, user_id: int + ) -> Optional[List[Row]]: + """ + A catch-all quest lookup given a profile + """ + sql = quest.select( + and_( + quest.c.user == user_id, + ) + ) + + result = self.execute(sql) + if result is None: + return None + return result.fetchall() + def get_session( self, user_id: int = None ) -> Optional[List[Row]]: From 391edd3354e420c08f5e643e64779c556c9af81f Mon Sep 17 00:00:00 2001 From: Midorica Date: Sat, 24 Jun 2023 20:09:37 -0400 Subject: [PATCH 41/49] Tower progression now working for SAO --- docs/game_specific_info.md | 1 - titles/sao/base.py | 51 ++++++++++++++++++++++++++++---------- 2 files changed, 38 insertions(+), 14 deletions(-) diff --git a/docs/game_specific_info.md b/docs/game_specific_info.md index 52b33a0..d2ae6d8 100644 --- a/docs/game_specific_info.md +++ b/docs/game_specific_info.md @@ -437,7 +437,6 @@ python dbutils.py --game SDEW upgrade ``` ### Notes -- Tower Quests currently force unlocked, no progression - Co-Op (matching) is not supported - Shop is not functionnal - Player title is currently static and cannot be changed in-game diff --git a/titles/sao/base.py b/titles/sao/base.py index 7019dca..d1750c6 100644 --- a/titles/sao/base.py +++ b/titles/sao/base.py @@ -817,7 +817,7 @@ class SaoBase: resp = SaoEpisodePlayEndResponse(int.from_bytes(bytes.fromhex(request[:4]), "big")+1) return resp.make() - def handle_c914(self, request: Any) -> bytes: # TBD + def handle_c914(self, request: Any) -> bytes: #quest/trial_tower_play_start req = bytes.fromhex(request)[24:] @@ -932,8 +932,33 @@ class SaoBase: req_data = req_struct.parse(req) + # Add tower progression to database + user_id = req_data.user_id + trial_tower_id = req_data.trial_tower_id + quest_clear_flag = bool(req_data.score_data[0].boss_destroying_num) + clear_time = req_data.score_data[0].clear_time + combo_num = req_data.score_data[0].combo_num + total_damage = req_data.score_data[0].total_damage + concurrent_destroying_num = req_data.score_data[0].concurrent_destroying_num + + if quest_clear_flag is True: + # Save tower progression - to be revised to avoid saving worse score + if trial_tower_id == 10: + trial_tower_id = 10001 + elif trial_tower_id == 20: + trial_tower_id = 10002 + elif trial_tower_id == 30: + trial_tower_id = 10003 + elif trial_tower_id == 40: + trial_tower_id = 10004 + elif trial_tower_id == 50: + trial_tower_id = 10005 + else: + trial_tower_id = trial_tower_id + 3000 + self.game_data.item.put_player_quest(user_id, episode_id, quest_clear_flag, clear_time, combo_num, total_damage, concurrent_destroying_num) + # Update the profile - profile = self.game_data.profile.get_profile(req_data.user_id) + profile = self.game_data.profile.get_profile(user_id) exp = int(profile["rank_exp"]) + 100 #always 100 extra exp for some reason col = int(profile["own_col"]) + int(req_data.base_get_data[0].get_col) @@ -956,7 +981,7 @@ class SaoBase: break updated_profile = self.game_data.profile.put_profile( - req_data.user_id, + user_id, profile["user_type"], profile["nick_name"], player_level, @@ -968,8 +993,8 @@ class SaoBase: ) # Update heroes from the used party - play_session = self.game_data.item.get_session(req_data.user_id) - session_party = self.game_data.item.get_hero_party(req_data.user_id, play_session["user_party_team_id"]) + play_session = self.game_data.item.get_session(user_id) + session_party = self.game_data.item.get_hero_party(user_id, play_session["user_party_team_id"]) hero_list = [] hero_list.append(session_party["user_hero_log_id_1"]) @@ -977,12 +1002,12 @@ class SaoBase: hero_list.append(session_party["user_hero_log_id_3"]) for i in range(0,len(hero_list)): - hero_data = self.game_data.item.get_hero_log(req_data.user_id, hero_list[i]) + hero_data = self.game_data.item.get_hero_log(user_id, hero_list[i]) log_exp = int(hero_data["log_exp"]) + int(req_data.base_get_data[0].get_hero_log_exp) self.game_data.item.put_hero_log( - req_data.user_id, + user_id, hero_data["user_hero_log_id"], hero_data["log_level"], log_exp, @@ -1005,11 +1030,11 @@ class SaoBase: itemList = self.game_data.static.get_item_id(commonRewardId) if heroList: - self.game_data.item.put_hero_log(req_data.user_id, commonRewardId, 1, 0, 101000016, 0, 30086, 1001, 1002, 0, 0) + self.game_data.item.put_hero_log(user_id, commonRewardId, 1, 0, 101000016, 0, 30086, 1001, 1002, 0, 0) if equipmentList: - self.game_data.item.put_equipment_data(req_data.user_id, commonRewardId, 1, 200, 0, 0, 0) + self.game_data.item.put_equipment_data(user_id, commonRewardId, 1, 200, 0, 0, 0) if itemList: - self.game_data.item.put_item(req_data.user_id, commonRewardId) + self.game_data.item.put_item(user_id, commonRewardId) # Generate random hero(es) based off the response for a in range(0,req_data.get_unanalyzed_log_tmp_reward_data_list_length): @@ -1026,11 +1051,11 @@ class SaoBase: equipmentList = self.game_data.static.get_equipment_id(randomized_unanalyzed_id['CommonRewardId']) itemList = self.game_data.static.get_item_id(randomized_unanalyzed_id['CommonRewardId']) if heroList: - self.game_data.item.put_hero_log(req_data.user_id, randomized_unanalyzed_id['CommonRewardId'], 1, 0, 101000016, 0, 30086, 1001, 1002, 0, 0) + self.game_data.item.put_hero_log(user_id, randomized_unanalyzed_id['CommonRewardId'], 1, 0, 101000016, 0, 30086, 1001, 1002, 0, 0) if equipmentList: - self.game_data.item.put_equipment_data(req_data.user_id, randomized_unanalyzed_id['CommonRewardId'], 1, 200, 0, 0, 0) + self.game_data.item.put_equipment_data(user_id, randomized_unanalyzed_id['CommonRewardId'], 1, 200, 0, 0, 0) if itemList: - self.game_data.item.put_item(req_data.user_id, randomized_unanalyzed_id['CommonRewardId']) + self.game_data.item.put_item(user_id, randomized_unanalyzed_id['CommonRewardId']) # Send response From 01b5282899c07ed0628fe269ae670b19de7d0248 Mon Sep 17 00:00:00 2001 From: Midorica Date: Sat, 24 Jun 2023 20:31:14 -0400 Subject: [PATCH 42/49] small fix about next tower stage progression for SAO --- titles/sao/base.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/titles/sao/base.py b/titles/sao/base.py index d1750c6..2ae672d 100644 --- a/titles/sao/base.py +++ b/titles/sao/base.py @@ -935,6 +935,7 @@ class SaoBase: # Add tower progression to database user_id = req_data.user_id trial_tower_id = req_data.trial_tower_id + next_tower_id = 0 quest_clear_flag = bool(req_data.score_data[0].boss_destroying_num) clear_time = req_data.score_data[0].clear_time combo_num = req_data.score_data[0].combo_num @@ -945,17 +946,23 @@ class SaoBase: # Save tower progression - to be revised to avoid saving worse score if trial_tower_id == 10: trial_tower_id = 10001 + next_tower_id = 3011 elif trial_tower_id == 20: trial_tower_id = 10002 + next_tower_id = 3021 elif trial_tower_id == 30: trial_tower_id = 10003 + next_tower_id = 3031 elif trial_tower_id == 40: trial_tower_id = 10004 + next_tower_id = 3041 elif trial_tower_id == 50: trial_tower_id = 10005 + next_tower_id = 3051 else: trial_tower_id = trial_tower_id + 3000 - self.game_data.item.put_player_quest(user_id, episode_id, quest_clear_flag, clear_time, combo_num, total_damage, concurrent_destroying_num) + self.game_data.item.put_player_quest(user_id, trial_tower_id, quest_clear_flag, clear_time, combo_num, total_damage, concurrent_destroying_num) + self.game_data.item.put_player_quest(user_id, next_tower_id, 0, 0, 0, 0, 0) # Update the profile profile = self.game_data.profile.get_profile(user_id) From 571b92d0cdd14c8eb5bd3a18b4005aab5d23b69e Mon Sep 17 00:00:00 2001 From: Midorica Date: Sat, 24 Jun 2023 20:33:30 -0400 Subject: [PATCH 43/49] forgot one line, see previous commit --- titles/sao/base.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/titles/sao/base.py b/titles/sao/base.py index 2ae672d..49e8f9b 100644 --- a/titles/sao/base.py +++ b/titles/sao/base.py @@ -961,6 +961,8 @@ class SaoBase: next_tower_id = 3051 else: trial_tower_id = trial_tower_id + 3000 + next_tower_id = trial_tower_id + 1 + self.game_data.item.put_player_quest(user_id, trial_tower_id, quest_clear_flag, clear_time, combo_num, total_damage, concurrent_destroying_num) self.game_data.item.put_player_quest(user_id, next_tower_id, 0, 0, 0, 0, 0) From 08ebb5c90706979c6d7c3afa9d1ac7769b933e24 Mon Sep 17 00:00:00 2001 From: Midorica Date: Sat, 24 Jun 2023 20:42:00 -0400 Subject: [PATCH 44/49] another quick fix for SAO tower stage --- titles/sao/base.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/titles/sao/base.py b/titles/sao/base.py index 49e8f9b..c2266e8 100644 --- a/titles/sao/base.py +++ b/titles/sao/base.py @@ -944,18 +944,28 @@ class SaoBase: if quest_clear_flag is True: # Save tower progression - to be revised to avoid saving worse score - if trial_tower_id == 10: + if trial_tower_id == 9: + next_tower_id = 10001 + elif trial_tower_id == 10: trial_tower_id = 10001 next_tower_id = 3011 + elif trial_tower_id == 19: + next_tower_id = 10002 elif trial_tower_id == 20: trial_tower_id = 10002 next_tower_id = 3021 + elif trial_tower_id == 29: + next_tower_id = 10003 elif trial_tower_id == 30: trial_tower_id = 10003 next_tower_id = 3031 + elif trial_tower_id == 39: + next_tower_id = 10004 elif trial_tower_id == 40: trial_tower_id = 10004 next_tower_id = 3041 + elif trial_tower_id == 49: + next_tower_id = 10005 elif trial_tower_id == 50: trial_tower_id = 10005 next_tower_id = 3051 From ec9ad1ebb0099aa735c5ab6243beb1803fb9310a Mon Sep 17 00:00:00 2001 From: Midorica Date: Sun, 25 Jun 2023 00:08:50 -0400 Subject: [PATCH 45/49] fixing stage progression for SAO --- titles/sao/base.py | 38 +++++++++++++++++++++++++++++++++++--- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/titles/sao/base.py b/titles/sao/base.py index c2266e8..c2a855b 100644 --- a/titles/sao/base.py +++ b/titles/sao/base.py @@ -709,6 +709,15 @@ class SaoBase: if quest_clear_flag is True: # Save stage progression - to be revised to avoid saving worse score + + # Reference Episode.csv but Chapter 3,4 and 5 reports id 0 + if episode_id > 10000 and episode_id < 11000: + episode_id = episode_id - 9000 + elif episode_id > 20000 and episode_id < 21000: + episode_id = episode_id - 19000 + elif episode_id > 30000 and episode_id < 31000: + episode_id = episode_id - 29000 + self.game_data.item.put_player_quest(user_id, episode_id, quest_clear_flag, clear_time, combo_num, total_damage, concurrent_destroying_num) # Update the profile @@ -829,7 +838,17 @@ class SaoBase: "user_id" / PaddedString(this.user_id_size, "utf_16_le"), # user_id is a (zero) padded string "trial_tower_id" / Int32ub, # trial_tower_id is an int "play_mode" / Int8ub, # play_mode is a byte - + Padding(3), + "play_start_request_data_length" / Rebuild(Int8ub, len_(this.play_start_request_data)), # play_start_request_data_length is a byte, + "play_start_request_data" / Array(this.play_start_request_data_length, Struct( + "user_party_id_size" / Rebuild(Int32ub, len_(this.user_party_id) * 2), # calculates the length of the user_party_id + "user_party_id" / PaddedString(this.user_party_id_size, "utf_16_le"), # user_party_id is a (zero) padded string + "appoint_leader_resource_card_code_size" / Rebuild(Int32ub, len_(this.appoint_leader_resource_card_code) * 2), # calculates the length of the total_damage + "appoint_leader_resource_card_code" / PaddedString(this.appoint_leader_resource_card_code_size, "utf_16_le"), # total_damage is a (zero) padded string + "use_profile_card_code_size" / Rebuild(Int32ub, len_(this.use_profile_card_code) * 2), # calculates the length of the total_damage + "use_profile_card_code" / PaddedString(this.use_profile_card_code_size, "utf_16_le"), # use_profile_card_code is a (zero) padded string + "quest_drop_boost_apply_flag" / Int8ub, # quest_drop_boost_apply_flag is a byte + )), ) req_data = req_struct.parse(req) @@ -838,6 +857,14 @@ class SaoBase: floor_id = req_data.trial_tower_id profile_data = self.game_data.profile.get_profile(user_id) + self.game_data.item.create_session( + user_id, + int(req_data.play_start_request_data[0].user_party_id), + req_data.trial_tower_id, + req_data.play_mode, + req_data.play_start_request_data[0].quest_drop_boost_apply_flag + ) + resp = SaoEpisodePlayStartResponse(int.from_bytes(bytes.fromhex(request[:4]), "big")+1, profile_data) return resp.make() @@ -972,9 +999,14 @@ class SaoBase: else: trial_tower_id = trial_tower_id + 3000 next_tower_id = trial_tower_id + 1 - + self.game_data.item.put_player_quest(user_id, trial_tower_id, quest_clear_flag, clear_time, combo_num, total_damage, concurrent_destroying_num) - self.game_data.item.put_player_quest(user_id, next_tower_id, 0, 0, 0, 0, 0) + + # Check if next stage is already done + checkQuest = self.game_data.item.get_quest_log(user_id, next_tower_id) + if not checkQuest: + if next_tower_id != 3101: + self.game_data.item.put_player_quest(user_id, next_tower_id, 0, 0, 0, 0, 0) # Update the profile profile = self.game_data.profile.get_profile(user_id) From 514f786e2d21e549c3a386b2a3932c3a74226005 Mon Sep 17 00:00:00 2001 From: Kevin Trocolli Date: Sun, 25 Jun 2023 01:09:23 -0400 Subject: [PATCH 46/49] pokken: Switch to using external STUN server --- example_config/pokken.yaml | 13 +++++--- titles/pokken/base.py | 33 +++++++++--------- titles/pokken/config.py | 68 ++++++++++++++++++++++++-------------- titles/pokken/index.py | 29 ++++++---------- titles/pokken/services.py | 40 ++++++++++------------ 5 files changed, 97 insertions(+), 86 deletions(-) diff --git a/example_config/pokken.yaml b/example_config/pokken.yaml index 225e980..423d9d3 100644 --- a/example_config/pokken.yaml +++ b/example_config/pokken.yaml @@ -2,8 +2,11 @@ server: hostname: "localhost" enable: True loglevel: "info" - port: 9000 - port_stun: 9001 - port_turn: 9002 - port_admission: 9003 - auto_register: True \ No newline at end of file + auto_register: True + enable_matching: False + stun_server_host: "stunserver.stunprotocol.org" + stun_server_port: 3478 + +ports: + game: 9000 + admission: 9001 diff --git a/titles/pokken/base.py b/titles/pokken/base.py index 306532d..50bc760 100644 --- a/titles/pokken/base.py +++ b/titles/pokken/base.py @@ -44,19 +44,19 @@ class PokkenBase: biwa_setting = { "MatchingServer": { "host": f"https://{self.game_cfg.server.hostname}", - "port": self.game_cfg.server.port, + "port": self.game_cfg.ports.game, "url": "/SDAK/100/matching", }, "StunServer": { - "addr": self.game_cfg.server.hostname, - "port": self.game_cfg.server.port_stun, + "addr": self.game_cfg.server.stun_server_host, + "port": self.game_cfg.server.stun_server_port, }, "TurnServer": { - "addr": self.game_cfg.server.hostname, - "port": self.game_cfg.server.port_turn, + "addr": self.game_cfg.server.stun_server_host, + "port": self.game_cfg.server.stun_server_port, }, - "AdmissionUrl": f"ws://{self.game_cfg.server.hostname}:{self.game_cfg.server.port_admission}", - "locationId": 123, + "AdmissionUrl": f"ws://{self.game_cfg.server.hostname}:{self.game_cfg.ports.admission}", + "locationId": 123, # FIXME: Get arcade's ID from the database "logfilename": "JackalMatchingLibrary.log", "biwalogfilename": "./biwa.log", } @@ -94,6 +94,7 @@ class PokkenBase: res.type = jackal_pb2.MessageType.LOAD_CLIENT_SETTINGS settings = jackal_pb2.LoadClientSettingsResponseData() + # TODO: Make configurable settings.money_magnification = 1 settings.continue_bonus_exp = 100 settings.continue_fight_money = 100 @@ -356,12 +357,11 @@ class PokkenBase: self, data: Dict = {}, client_ip: str = "127.0.0.1" ) -> Dict: """ - "sessionId":"12345678", - "A":{ - "pcb_id": data["data"]["must"]["pcb_id"], - "gip": client_ip - }, - "list":[] + "sessionId":"12345678", + "A":{ + "pcb_id": data["data"]["must"]["pcb_id"], + "gip": client_ip + }, """ return { "data": { @@ -379,10 +379,13 @@ class PokkenBase: ) -> Dict: return {} + def handle_admission_noop(self, data: Dict, req_ip: str = "127.0.0.1") -> Dict: + return {} + def handle_admission_joinsession(self, data: Dict, req_ip: str = "127.0.0.1") -> Dict: self.logger.info(f"Admission: JoinSession from {req_ip}") return { 'data': { - "id": 123 + "id": 12345678 } - } \ No newline at end of file + } diff --git a/titles/pokken/config.py b/titles/pokken/config.py index 84da8d2..d3741af 100644 --- a/titles/pokken/config.py +++ b/titles/pokken/config.py @@ -25,30 +25,6 @@ class PokkenServerConfig: ) ) - @property - def port(self) -> int: - return CoreConfig.get_config_field( - self.__config, "pokken", "server", "port", default=9000 - ) - - @property - def port_stun(self) -> int: - return CoreConfig.get_config_field( - self.__config, "pokken", "server", "port_stun", default=9001 - ) - - @property - def port_turn(self) -> int: - return CoreConfig.get_config_field( - self.__config, "pokken", "server", "port_turn", default=9002 - ) - - @property - def port_admission(self) -> int: - return CoreConfig.get_config_field( - self.__config, "pokken", "server", "port_admission", default=9003 - ) - @property def auto_register(self) -> bool: """ @@ -59,7 +35,51 @@ class PokkenServerConfig: self.__config, "pokken", "server", "auto_register", default=True ) + @property + def enable_matching(self) -> bool: + """ + If global matching should happen + """ + return CoreConfig.get_config_field( + self.__config, "pokken", "server", "enable_matching", default=False + ) + + @property + def stun_server_host(self) -> str: + """ + Hostname of the EXTERNAL stun server the game should connect to. This is not handled by artemis. + """ + return CoreConfig.get_config_field( + self.__config, "pokken", "server", "stun_server_host", default="stunserver.stunprotocol.org" + ) + + @property + def stun_server_port(self) -> int: + """ + Port of the EXTERNAL stun server the game should connect to. This is not handled by artemis. + """ + return CoreConfig.get_config_field( + self.__config, "pokken", "server", "stun_server_port", default=3478 + ) + +class PokkenPortsConfig: + def __init__(self, parent_config: "PokkenConfig"): + self.__config = parent_config + + @property + def game(self) -> int: + return CoreConfig.get_config_field( + self.__config, "pokken", "ports", "game", default=9000 + ) + + @property + def admission(self) -> int: + return CoreConfig.get_config_field( + self.__config, "pokken", "ports", "admission", default=9001 + ) + class PokkenConfig(dict): def __init__(self) -> None: self.server = PokkenServerConfig(self) + self.ports = PokkenPortsConfig(self) diff --git a/titles/pokken/index.py b/titles/pokken/index.py index 1140d41..3ccb3fc 100644 --- a/titles/pokken/index.py +++ b/titles/pokken/index.py @@ -1,8 +1,7 @@ from typing import Tuple from twisted.web.http import Request from twisted.web import resource -from twisted.internet import reactor, endpoints -from autobahn.twisted.websocket import WebSocketServerFactory +from twisted.internet import reactor import json, ast from datetime import datetime import yaml @@ -17,7 +16,7 @@ from .config import PokkenConfig from .base import PokkenBase from .const import PokkenConstants from .proto import jackal_pb2 -from .services import PokkenStunProtocol, PokkenAdmissionFactory, PokkenAdmissionProtocol +from .services import PokkenAdmissionFactory class PokkenServlet(resource.Resource): @@ -72,7 +71,7 @@ class PokkenServlet(resource.Resource): return ( True, - f"https://{game_cfg.server.hostname}:{game_cfg.server.port}/{game_code}/$v/", + f"https://{game_cfg.server.hostname}:{game_cfg.ports.game}/{game_code}/$v/", f"{game_cfg.server.hostname}/SDAK/$v/", ) @@ -93,21 +92,10 @@ class PokkenServlet(resource.Resource): return (True, "PKF1") def setup(self) -> None: - # TODO: Setup stun, turn (UDP) and admission (WSS) servers - reactor.listenUDP( - self.game_cfg.server.port_stun, PokkenStunProtocol(self.core_cfg, self.game_cfg, "Stun") - ) - - reactor.listenUDP( - self.game_cfg.server.port_turn, PokkenStunProtocol(self.core_cfg, self.game_cfg, "Turn") - ) - - factory = WebSocketServerFactory(f"ws://{self.game_cfg.server.hostname}:{self.game_cfg.server.port_admission}") - factory.protocol = PokkenAdmissionProtocol - - reactor.listenTCP( - self.game_cfg.server.port_admission, PokkenAdmissionFactory(self.core_cfg, self.game_cfg) - ) + if self.game_cfg.server.enable_matching: + reactor.listenTCP( + self.game_cfg.ports.admission, PokkenAdmissionFactory(self.core_cfg, self.game_cfg) + ) def render_POST( self, request: Request, version: int = 0, endpoints: str = "" @@ -144,6 +132,9 @@ class PokkenServlet(resource.Resource): return ret def handle_matching(self, request: Request) -> bytes: + if not self.game_cfg.server.enable_matching: + return b"" + content = request.content.getvalue() client_ip = Utils.get_ip_addr(request) diff --git a/titles/pokken/services.py b/titles/pokken/services.py index ac12b72..952c232 100644 --- a/titles/pokken/services.py +++ b/titles/pokken/services.py @@ -1,8 +1,8 @@ from twisted.internet.interfaces import IAddress -from twisted.internet.protocol import DatagramProtocol from twisted.internet.protocol import Protocol from autobahn.twisted.websocket import WebSocketServerProtocol, WebSocketServerFactory -from datetime import datetime +from autobahn.websocket.types import ConnectionRequest +from typing import Dict import logging import json @@ -10,21 +10,6 @@ from core.config import CoreConfig from .config import PokkenConfig from .base import PokkenBase -class PokkenStunProtocol(DatagramProtocol): - def __init__(self, cfg: CoreConfig, game_cfg: PokkenConfig, type: str) -> None: - super().__init__() - self.core_config = cfg - self.game_config = game_cfg - self.logger = logging.getLogger("pokken") - self.server_type = type - - def datagramReceived(self, data, addr): - self.logger.debug( - f"{self.server_type} from from {addr[0]}:{addr[1]} -> {self.transport.getHost().port} - {data.hex()}" - ) - self.transport.write(data, addr) - -# 474554202f20485454502f312e310d0a436f6e6e656374696f6e3a20557067726164650d0a486f73743a207469746c65732e6861793174732e6d653a393030330d0a5365632d576562536f636b65742d4b65793a204f4a6b6d522f376b646d6953326573483548783776413d3d0d0a5365632d576562536f636b65742d56657273696f6e3a2031330d0a557067726164653a20776562736f636b65740d0a557365722d4167656e743a20576562536f636b65742b2b2f302e332e300d0a0d0a class PokkenAdmissionProtocol(WebSocketServerProtocol): def __init__(self, cfg: CoreConfig, game_cfg: PokkenConfig): super().__init__() @@ -33,27 +18,36 @@ class PokkenAdmissionProtocol(WebSocketServerProtocol): self.logger = logging.getLogger("pokken") self.base = PokkenBase(cfg, game_cfg) + + def onConnect(self, request: ConnectionRequest) -> None: + self.logger.debug(f"Admission: Connection from {request.peer}") + + def onClose(self, wasClean: bool, code: int, reason: str) -> None: + self.logger.debug(f"Admission: Connection with {self.transport.getPeer().host} closed {'cleanly ' if wasClean else ''}with code {code} - {reason}") def onMessage(self, payload, isBinary: bool) -> None: - msg = json.loads(payload) - self.logger.debug(f"WebSocket from from {self.transport.getPeer().host}:{self.transport.getPeer().port} -> {self.transport.getHost().port} - {msg}") + msg: Dict = json.loads(payload) + self.logger.debug(f"Admission: Message from {self.transport.getPeer().host}:{self.transport.getPeer().port} - {msg}") - handler = getattr(self.base, f"handle_admission_{msg['api'].lower()}") + api = msg.get("api", "noop") + handler = getattr(self.base, f"handle_admission_{api.lower()}") resp = handler(msg, self.transport.getPeer().host) + + if resp is None: + resp = {} if "type" not in resp: resp['type'] = "res" if "data" not in resp: resp['data'] = {} if "api" not in resp: - resp['api'] = msg["api"] + resp['api'] = api if "result" not in resp: resp['result'] = 'true' self.logger.debug(f"Websocket response: {resp}") self.sendMessage(json.dumps(resp).encode(), isBinary) -# 0001002c2112a442334a0506a62efa71477dcd698022002872655455524e2053796e6320436c69656e7420302e33202d20524643353338392f7475726e2d3132 class PokkenAdmissionFactory(WebSocketServerFactory): protocol = PokkenAdmissionProtocol @@ -64,7 +58,7 @@ class PokkenAdmissionFactory(WebSocketServerFactory): ) -> None: self.core_config = cfg self.game_config = game_cfg - super().__init__(f"ws://{self.game_config.server.hostname}:{self.game_config.server.port_admission}") + super().__init__(f"ws://{self.game_config.server.hostname}:{self.game_config.ports.admission}") def buildProtocol(self, addr: IAddress) -> Protocol: p = self.protocol(self.core_config, self.game_config) From aae4afe7b885cc585f031e42500d9d772f616353 Mon Sep 17 00:00:00 2001 From: Midorica Date: Sun, 25 Jun 2023 11:59:17 -0400 Subject: [PATCH 47/49] Adding debug logging to SAO --- titles/sao/index.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/titles/sao/index.py b/titles/sao/index.py index b74e500..69a3db5 100644 --- a/titles/sao/index.py +++ b/titles/sao/index.py @@ -97,20 +97,20 @@ class SaoServlet(resource.Resource): req_url = request.uri.decode() if req_url == "/matching": self.logger.info("Matching request") - + request.responseHeaders.addRawHeader(b"content-type", b"text/html; charset=utf-8") sao_request = request.content.getvalue().hex() - #sao_request = sao_request[:32] handler = getattr(self.base, f"handle_{sao_request[:4]}", None) if handler is None: self.logger.info(f"Generic Handler for {req_url} - {sao_request[:4]}") - #self.logger.debug(f"Request: {request.content.getvalue().hex()}") + self.logger.debug(f"Request: {request.content.getvalue().hex()}") resp = SaoNoopResponse(int.from_bytes(bytes.fromhex(sao_request[:4]), "big")+1) self.logger.debug(f"Response: {resp.make().hex()}") return resp.make() self.logger.info(f"Handler {req_url} - {sao_request[:4]} request") self.logger.debug(f"Request: {request.content.getvalue().hex()}") + self.logger.debug(f"Response: {handler(sao_request).hex()}") return handler(sao_request) \ No newline at end of file From 17508f09b2f8eee67a4c7d6576515813aa349705 Mon Sep 17 00:00:00 2001 From: Midorica Date: Sun, 25 Jun 2023 13:47:31 -0400 Subject: [PATCH 48/49] fixed episode VP saving & hero level in DB for SAO --- titles/sao/base.py | 52 +++++++++++++++++++++++++++++++++++++++------- 1 file changed, 44 insertions(+), 8 deletions(-) diff --git a/titles/sao/base.py b/titles/sao/base.py index c2a855b..a519401 100644 --- a/titles/sao/base.py +++ b/titles/sao/base.py @@ -707,6 +707,11 @@ class SaoBase: total_damage = req_data.score_data[0].total_damage concurrent_destroying_num = req_data.score_data[0].concurrent_destroying_num + profile = self.game_data.profile.get_profile(user_id) + vp = int(profile["own_vp"]) + exp = int(profile["rank_exp"]) + 100 #always 100 extra exp for some reason + col = int(profile["own_col"]) + int(req_data.base_get_data[0].get_col) + if quest_clear_flag is True: # Save stage progression - to be revised to avoid saving worse score @@ -720,11 +725,8 @@ class SaoBase: self.game_data.item.put_player_quest(user_id, episode_id, quest_clear_flag, clear_time, combo_num, total_damage, concurrent_destroying_num) - # Update the profile - profile = self.game_data.profile.get_profile(user_id) - - exp = int(profile["rank_exp"]) + 100 #always 100 extra exp for some reason - col = int(profile["own_col"]) + int(req_data.base_get_data[0].get_col) + vp = int(profile["own_vp"]) + 10 #always 10 VP per cleared stage + # Calculate level based off experience and the CSV list with open(r'titles/sao/data/PlayerRank.csv') as csv_file: @@ -751,7 +753,7 @@ class SaoBase: player_level, exp, col, - profile["own_vp"], + vp, profile["own_yui_medal"], profile["setting_title_id"] ) @@ -770,10 +772,27 @@ class SaoBase: log_exp = int(hero_data["log_exp"]) + int(req_data.base_get_data[0].get_hero_log_exp) + # Calculate hero level based off experience and the CSV list + with open(r'titles/sao/data/HeroLogLevel.csv') as csv_file: + csv_reader = csv.reader(csv_file, delimiter=',') + line_count = 0 + data = [] + rowf = False + for row in csv_reader: + if rowf==False: + rowf=True + else: + data.append(row) + + for e in range(0,len(data)): + if log_exp>=int(data[e][1]) and log_exp=int(data[e][1]) and log_exp Date: Sun, 25 Jun 2023 14:40:34 -0400 Subject: [PATCH 49/49] fixing hero party saving for SAO --- titles/sao/base.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/titles/sao/base.py b/titles/sao/base.py index a519401..ece4654 100644 --- a/titles/sao/base.py +++ b/titles/sao/base.py @@ -537,6 +537,7 @@ class SaoBase: req_data = req_struct.parse(req) user_id = req_data.user_id + party_hero_list = [] for party_team in req_data.party_data_list[0].party_team_data_list: hero_data = self.game_data.item.get_hero_log(user_id, party_team["user_hero_log_id"]) @@ -561,6 +562,10 @@ class SaoBase: party_team["skill_slot5_skill_id"] ) + party_hero_list.append(party_team["user_hero_log_id"]) + + self.game_data.item.put_hero_party(user_id, req_data.party_data_list[0].party_team_data_list[0].user_party_team_id, party_hero_list[0], party_hero_list[1], party_hero_list[2]) + resp = SaoNoopResponse(int.from_bytes(bytes.fromhex(request[:4]), "big")+1) return resp.make()