diff --git a/.gitignore b/.gitignore index 825729f..b5a0e6e 100644 --- a/.gitignore +++ b/.gitignore @@ -158,5 +158,6 @@ cert/* !cert/server.pem config/* deliver/* +*.gz dbdump-*.json \ No newline at end of file diff --git a/changelog.md b/changelog.md new file mode 100644 index 0000000..e1b5642 --- /dev/null +++ b/changelog.md @@ -0,0 +1,49 @@ +# Changelog +Documenting updates to ARTEMiS, to be updated every time the master branch is pushed to. + +## 2023042300 +### Wacca ++ Time free now works properly ++ Fix reverse gate mission causing a fatal error ++ Other misc. fixes ++ Latest DB: 5 + +### Pokken ++ Added preliminary support + + Nothing saves currently, but the game will boot and function properly. + +### Initial D Zero ++ Added preliminary support + + Nothing saves currently, but the game will boot and function for the most part. + +### Mai2 ++ Added support for Festival ++ Lasted DB Version: 4 + +### Ongeki ++ Misc fixes ++ Lasted DB Version: 4 + +### Diva ++ Misc fixes ++ Lasted DB Version: 4 + +### Chuni ++ Fix network encryption ++ Add `handle_remove_token_api_request` for event mode + +### Allnet ++ Added download order support + + It is up to the sysop to provide the INI file, and host the files. + + ONLY for use with cabs. It's not checked currently, which it's why it's default disabled + + YMMV, use at your own risk ++ When running develop mode, games that are not recognised will still be able to authenticate. + +### Database ++ Add autoupgrade command + + Invoke to automatically upgrade all schemas to their latest versions + ++ `version` arg no longer required, leave it blank to update the game schema to latest if it isn't already + +### Misc ++ Update example nginx config file diff --git a/core/allnet.py b/core/allnet.py index 0d4fbf7..119f0ae 100644 --- a/core/allnet.py +++ b/core/allnet.py @@ -10,6 +10,7 @@ from Crypto.PublicKey import RSA from Crypto.Hash import SHA from Crypto.Signature import PKCS1_v1_5 from time import strptime +from os import path from core.config import CoreConfig from core.utils import Utils @@ -55,7 +56,7 @@ class AllnetServlet: self.logger.error("No games detected!") for _, mod in plugins.items(): - if hasattr(mod.index, "get_allnet_info"): + if hasattr(mod, "index") and hasattr(mod.index, "get_allnet_info"): for code in mod.game_codes: enabled, uri, host = mod.index.get_allnet_info( code, self.config, self.config_folder @@ -104,9 +105,11 @@ class AllnetServlet: resp.stat = 0 return self.dict_to_http_form_string([vars(resp)]) - + else: - self.logger.info(f"Allowed unknown game {req.game_id} v{req.ver} to authenticate from {request_ip} due to 'is_develop' being enabled. S/N: {req.serial}") + self.logger.info( + f"Allowed unknown game {req.game_id} v{req.ver} to authenticate from {request_ip} due to 'is_develop' being enabled. S/N: {req.serial}" + ) 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}" return self.dict_to_http_form_string([vars(resp)]) @@ -188,15 +191,51 @@ class AllnetServlet: self.logger.error(e) return b"" - self.logger.info(f"DownloadOrder from {request_ip} -> {req.game_id} v{req.ver} serial {req.serial}") + self.logger.info( + f"DownloadOrder from {request_ip} -> {req.game_id} v{req.ver} serial {req.serial}" + ) resp = AllnetDownloadOrderResponse() - - if not self.config.allnet.allow_online_updates: + + if ( + not self.config.allnet.allow_online_updates + or not self.config.allnet.update_cfg_folder + ): return self.dict_to_http_form_string([vars(resp)]) - else: # TODO: Actual dlorder response + else: # TODO: Keychip check + if path.exists( + f"{self.config.allnet.update_cfg_folder}/{req.game_id}-{req.ver}-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" + ): + 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}") return self.dict_to_http_form_string([vars(resp)]) + def handle_dlorder_ini(self, request: Request, match: Dict) -> bytes: + if "file" not in match: + return b"" + + req_file = match["file"].replace("%0A", "") + + if path.exists(f"{self.config.allnet.update_cfg_folder}/{req_file}"): + return open( + f"{self.config.allnet.update_cfg_folder}/{req_file}", "rb" + ).read() + + self.logger.info(f"DL INI File {req_file} not found") + return b"" + + def handle_dlorder_report(self, request: Request, match: Dict) -> bytes: + self.logger.info( + f"DLI Report from {Utils.get_ip_addr(request)}: {request.content.getvalue()}" + ) + return b"" + def handle_billing_request(self, request: Request, _: Dict): req_dict = self.billing_req_to_dict(request.content.getvalue()) request_ip = Utils.get_ip_addr(request) @@ -419,7 +458,7 @@ class AllnetDownloadOrderRequest: class AllnetDownloadOrderResponse: - def __init__(self, stat: int = 1, serial: str = "", uri: str = "null") -> None: + def __init__(self, stat: int = 1, serial: str = "", uri: str = "") -> None: self.stat = stat self.serial = serial self.uri = uri diff --git a/core/config.py b/core/config.py index 9e152c0..3fb0dbe 100644 --- a/core/config.py +++ b/core/config.py @@ -188,6 +188,12 @@ class AllnetConfig: self.__config, "core", "allnet", "allow_online_updates", default=False ) + @property + def update_cfg_folder(self) -> str: + return CoreConfig.get_config_field( + self.__config, "core", "allnet", "update_cfg_folder", default="" + ) + class BillingConfig: def __init__(self, parent_config: "CoreConfig") -> None: diff --git a/core/data/database.py b/core/data/database.py index 07fe79e..719d05e 100644 --- a/core/data/database.py +++ b/core/data/database.py @@ -1,5 +1,5 @@ import logging, coloredlogs -from typing import Optional +from typing import Optional, Dict, List from sqlalchemy.orm import scoped_session, sessionmaker from sqlalchemy.exc import SQLAlchemyError from sqlalchemy import create_engine @@ -32,7 +32,7 @@ class Data: self.arcade = ArcadeData(self.config, self.session) self.card = CardData(self.config, self.session) self.base = BaseData(self.config, self.session) - self.schema_ver_latest = 4 + self.current_schema_version = 4 log_fmt_str = "[%(asctime)s] %(levelname)s | Database | %(message)s" log_fmt = logging.Formatter(log_fmt_str) @@ -71,7 +71,9 @@ class Data: games = Utils.get_all_titles() for game_dir, game_mod in games.items(): try: - if hasattr(game_mod, "database") and hasattr(game_mod, "current_schema_version"): + if hasattr(game_mod, "database") and hasattr( + game_mod, "current_schema_version" + ): game_mod.database(self.config) metadata.create_all(self.__engine.connect()) @@ -84,8 +86,8 @@ class Data: f"Could not load database schema from {game_dir} - {e}" ) - self.logger.info(f"Setting base_schema_ver to {self.schema_ver_latest}") - self.base.set_schema_ver(self.schema_ver_latest) + self.logger.info(f"Setting base_schema_ver to {self.current_schema_version}") + self.base.set_schema_ver(self.current_schema_version) self.logger.info( f"Setting user auto_incrememnt to {self.config.database.user_table_autoincrement_start}" @@ -129,9 +131,32 @@ class Data: self.create_database() - def migrate_database(self, game: str, version: int, action: str) -> None: + def migrate_database(self, game: str, version: Optional[int], action: str) -> None: old_ver = self.base.get_schema_ver(game) sql = "" + if version is None: + if not game == "CORE": + titles = Utils.get_all_titles() + + for folder, mod in titles.items(): + if not mod.game_codes[0] == game: + continue + + if hasattr(mod, "current_schema_version"): + version = mod.current_schema_version + + else: + self.logger.warn( + f"current_schema_version not found for {folder}" + ) + + else: + version = self.current_schema_version + + if version is None: + self.logger.warn( + f"Could not determine latest version for {game}, please specify --version" + ) if old_ver is None: self.logger.error( @@ -166,7 +191,7 @@ class Data: if result is None: self.logger.error("Error execuing sql script!") return None - + else: for x in range(old_ver, version, -1): if not os.path.exists( @@ -263,17 +288,48 @@ class Data: self.user.delete_user(user["id"]) def autoupgrade(self) -> None: - all_games = self.base.get_all_schema_vers() - if all_games is None: + all_game_versions = self.base.get_all_schema_vers() + if all_game_versions is None: self.logger.warn("Failed to get schema versions") - - for x in all_games: + return + + all_games = Utils.get_all_titles() + all_games_list: Dict[str, int] = {} + for _, mod in all_games.items(): + if hasattr(mod, "current_schema_version"): + all_games_list[mod.game_codes[0]] = mod.current_schema_version + + for x in all_game_versions: + failed = False game = x["game"].upper() - update_ver = 1 - for y in range(2, 100): + update_ver = int(x["version"]) + latest_ver = all_games_list.get(game, 1) + if game == "CORE": + latest_ver = self.current_schema_version + + if update_ver == latest_ver: + self.logger.info(f"{game} is already latest version") + continue + + for y in range(update_ver + 1, latest_ver + 1): if os.path.exists(f"core/data/schema/versions/{game}_{y}_upgrade.sql"): - update_ver = y + with open( + f"core/data/schema/versions/{game}_{y}_upgrade.sql", + "r", + encoding="utf-8", + ) as f: + sql = f.read() + + result = self.base.execute(sql) + if result is None: + self.logger.error( + f"Error execuing sql script for game {game} v{y}!" + ) + failed = True + break else: - break - - self.migrate_database(game, update_ver, "upgrade") \ No newline at end of file + self.logger.warning(f"Could not find script {game}_{y}_upgrade.sql") + failed = True + + if not failed: + self.base.set_schema_ver(latest_ver, game) diff --git a/core/data/schema/base.py b/core/data/schema/base.py index f77a9aa..7957301 100644 --- a/core/data/schema/base.py +++ b/core/data/schema/base.py @@ -47,7 +47,7 @@ class BaseData: res = None try: - self.logger.info(f"SQL Execute: {''.join(str(sql).splitlines())} || {opts}") + self.logger.info(f"SQL Execute: {''.join(str(sql).splitlines())}") res = self.conn.execute(text(sql), opts) except SQLAlchemyError as e: @@ -81,7 +81,7 @@ class BaseData: Generate a random 5-7 digit id """ return randrange(10000, 9999999) - + def get_all_schema_vers(self) -> Optional[List[Row]]: sql = select(schema_ver) diff --git a/core/data/schema/versions/SBZV_4_rollback.sql b/core/data/schema/versions/SBZV_4_rollback.sql new file mode 100644 index 0000000..f56327e --- /dev/null +++ b/core/data/schema/versions/SBZV_4_rollback.sql @@ -0,0 +1,9 @@ +ALTER TABLE diva_profile + DROP cnp_cid, + DROP cnp_val, + DROP cnp_rr, + DROP cnp_sp, + DROP btn_se_eqp, + DROP sld_se_eqp, + DROP chn_sld_se_eqp, + DROP sldr_tch_se_eqp; \ No newline at end of file diff --git a/core/data/schema/versions/SBZV_5_upgrade.sql b/core/data/schema/versions/SBZV_5_upgrade.sql new file mode 100644 index 0000000..7e29f7b --- /dev/null +++ b/core/data/schema/versions/SBZV_5_upgrade.sql @@ -0,0 +1,9 @@ +ALTER TABLE diva_profile + ADD cnp_cid INT NOT NULL DEFAULT -1, + ADD cnp_val INT NOT NULL DEFAULT -1, + ADD cnp_rr INT NOT NULL DEFAULT -1, + ADD cnp_sp VARCHAR(255) NOT NULL DEFAULT "", + ADD btn_se_eqp INT NOT NULL DEFAULT -1, + ADD sld_se_eqp INT NOT NULL DEFAULT -1, + ADD chn_sld_se_eqp INT NOT NULL DEFAULT -1, + ADD sldr_tch_se_eqp INT NOT NULL DEFAULT -1; \ No newline at end of file diff --git a/core/data/schema/versions/SDEZ_3_rollback.sql b/core/data/schema/versions/SDEZ_3_rollback.sql new file mode 100644 index 0000000..79ca098 --- /dev/null +++ b/core/data/schema/versions/SDEZ_3_rollback.sql @@ -0,0 +1,31 @@ +ALTER TABLE mai2_profile_option +DROP COLUMN tapSe; + +ALTER TABLE mai2_score_best +DROP COLUMN extNum1; + +ALTER TABLE mai2_profile_extend +DROP COLUMN playStatusSetting; + +ALTER TABLE mai2_playlog +DROP COLUMN extNum4; + +ALTER TABLE mai2_static_event +DROP COLUMN startDate; + +ALTER TABLE mai2_item_map +CHANGE COLUMN mapId map_id INT NOT NULL, +CHANGE COLUMN isLock is_lock BOOLEAN NOT NULL DEFAULT 0, +CHANGE COLUMN isClear is_clear BOOLEAN NOT NULL DEFAULT 0, +CHANGE COLUMN isComplete is_complete BOOLEAN NOT NULL DEFAULT 0; + +ALTER TABLE mai2_item_friend_season_ranking +CHANGE COLUMN seasonId season_id INT NOT NULL, +CHANGE COLUMN rewardGet reward_get BOOLEAN NOT NULL, +CHANGE COLUMN userName user_name VARCHAR(8) NOT NULL, +CHANGE COLUMN recordDate record_date VARCHAR(255) NOT NULL; + +ALTER TABLE mai2_item_login_bonus +CHANGE COLUMN bonusId bonus_id INT NOT NULL, +CHANGE COLUMN isCurrent is_current BOOLEAN NOT NULL DEFAULT 0, +CHANGE COLUMN isComplete is_complete BOOLEAN NOT NULL DEFAULT 0; \ No newline at end of file diff --git a/core/data/schema/versions/SDEZ_4_upgrade.sql b/core/data/schema/versions/SDEZ_4_upgrade.sql new file mode 100644 index 0000000..a670d6a --- /dev/null +++ b/core/data/schema/versions/SDEZ_4_upgrade.sql @@ -0,0 +1,31 @@ +ALTER TABLE mai2_profile_option +ADD COLUMN tapSe INT NOT NULL DEFAULT 0 AFTER tapDesign; + +ALTER TABLE mai2_score_best +ADD COLUMN extNum1 INT NOT NULL DEFAULT 0; + +ALTER TABLE mai2_profile_extend +ADD COLUMN playStatusSetting INT NOT NULL DEFAULT 0; + +ALTER TABLE mai2_playlog +ADD COLUMN extNum4 INT NOT NULL DEFAULT 0; + +ALTER TABLE mai2_static_event +ADD COLUMN startDate TIMESTAMP NOT NULL DEFAULT current_timestamp(); + +ALTER TABLE mai2_item_map +CHANGE COLUMN map_id mapId INT NOT NULL, +CHANGE COLUMN is_lock isLock BOOLEAN NOT NULL DEFAULT 0, +CHANGE COLUMN is_clear isClear BOOLEAN NOT NULL DEFAULT 0, +CHANGE COLUMN is_complete isComplete BOOLEAN NOT NULL DEFAULT 0; + +ALTER TABLE mai2_item_friend_season_ranking +CHANGE COLUMN season_id seasonId INT NOT NULL, +CHANGE COLUMN reward_get rewardGet BOOLEAN NOT NULL, +CHANGE COLUMN user_name userName VARCHAR(8) NOT NULL, +CHANGE COLUMN record_date recordDate TIMESTAMP NOT NULL; + +ALTER TABLE mai2_item_login_bonus +CHANGE COLUMN bonus_id bonusId INT NOT NULL, +CHANGE COLUMN is_current isCurrent BOOLEAN NOT NULL DEFAULT 0, +CHANGE COLUMN is_complete isComplete BOOLEAN NOT NULL DEFAULT 0; diff --git a/core/data/schema/versions/SDFE_3_rollback.sql b/core/data/schema/versions/SDFE_3_rollback.sql new file mode 100644 index 0000000..6d1d2b8 --- /dev/null +++ b/core/data/schema/versions/SDFE_3_rollback.sql @@ -0,0 +1,2 @@ +SET FOREIGN_KEY_CHECKS=0; +SET FOREIGN_KEY_CHECKS=1; diff --git a/core/data/schema/versions/SDFE_4_rollback.sql b/core/data/schema/versions/SDFE_4_rollback.sql new file mode 100644 index 0000000..417c227 --- /dev/null +++ b/core/data/schema/versions/SDFE_4_rollback.sql @@ -0,0 +1 @@ +ALTER TABLE wacca_profile DROP COLUMN playcount_time_free; \ No newline at end of file diff --git a/core/data/schema/versions/SDFE_4_upgrade.sql b/core/data/schema/versions/SDFE_4_upgrade.sql new file mode 100644 index 0000000..3f2cc9a --- /dev/null +++ b/core/data/schema/versions/SDFE_4_upgrade.sql @@ -0,0 +1 @@ +DELETE FROM wacca_item WHERE type=17 AND item_id=312002; \ No newline at end of file diff --git a/core/data/schema/versions/SDFE_5_upgrade.sql b/core/data/schema/versions/SDFE_5_upgrade.sql new file mode 100644 index 0000000..a9795bf --- /dev/null +++ b/core/data/schema/versions/SDFE_5_upgrade.sql @@ -0,0 +1 @@ +ALTER TABLE wacca_profile ADD playcount_time_free int(11) DEFAULT 0 NULL AFTER playcount_stageup; \ No newline at end of file diff --git a/core/frontend.py b/core/frontend.py index 127b174..c992e76 100644 --- a/core/frontend.py +++ b/core/frontend.py @@ -71,8 +71,11 @@ class FrontendServlet(resource.Resource): game_fe = game_mod.frontend(cfg, self.environment, config_dir) self.game_list.append({"url": game_dir, "name": game_fe.nav_name}) fe_game.putChild(game_dir.encode(), game_fe) - except: - raise + + except Exception as e: + self.logger.error( + f"Failed to import frontend from {game_dir} because {e}" + ) self.environment.globals["game_list"] = self.game_list self.putChild(b"gate", FE_Gate(cfg, self.environment)) diff --git a/core/mucha.py b/core/mucha.py index 9dfef03..a90ab53 100644 --- a/core/mucha.py +++ b/core/mucha.py @@ -46,9 +46,7 @@ class MuchaServlet: if enabled: self.mucha_registry.append(game_cd) - self.logger.info( - f"Serving {len(self.mucha_registry)} games" - ) + self.logger.info(f"Serving {len(self.mucha_registry)} games") def handle_boardauth(self, request: Request, _: Dict) -> bytes: req_dict = self.mucha_preprocess(request.content.getvalue()) @@ -62,9 +60,7 @@ class MuchaServlet: req = MuchaAuthRequest(req_dict) self.logger.debug(f"Mucha request {vars(req)}") - self.logger.info( - f"Boardauth request from {client_ip} for {req.gameVer}" - ) + self.logger.info(f"Boardauth request from {client_ip} for {req.gameVer}") if req.gameCd not in self.mucha_registry: self.logger.warn(f"Unknown gameCd {req.gameCd}") @@ -92,9 +88,7 @@ class MuchaServlet: req = MuchaUpdateRequest(req_dict) self.logger.debug(f"Mucha request {vars(req)}") - self.logger.info( - f"Updatecheck request from {client_ip} for {req.gameVer}" - ) + self.logger.info(f"Updatecheck request from {client_ip} for {req.gameVer}") if req.gameCd not in self.mucha_registry: self.logger.warn(f"Unknown gameCd {req.gameCd}") diff --git a/core/utils.py b/core/utils.py index d18289e..f364785 100644 --- a/core/utils.py +++ b/core/utils.py @@ -16,13 +16,20 @@ class Utils: if not dir.startswith("__"): try: mod = importlib.import_module(f"titles.{dir}") - ret[dir] = mod + if hasattr(mod, "game_codes") and hasattr( + mod, "index" + ): # Minimum required to function + ret[dir] = mod except ImportError as e: logging.getLogger("core").error(f"get_all_titles: {dir} - {e}") raise return ret - + @classmethod def get_ip_addr(cls, req: Request) -> str: - return req.getAllHeaders()[b"x-forwarded-for"].decode() if b"x-forwarded-for" in req.getAllHeaders() else req.getClientAddress().host + return ( + req.getAllHeaders()[b"x-forwarded-for"].decode() + if b"x-forwarded-for" in req.getAllHeaders() + else req.getClientAddress().host + ) diff --git a/dbutils.py b/dbutils.py index 176c67e..d959232 100644 --- a/dbutils.py +++ b/dbutils.py @@ -1,5 +1,6 @@ import yaml import argparse +import logging from core.config import CoreConfig from core.data import Data from os import path, mkdir, access, W_OK @@ -32,11 +33,13 @@ if __name__ == "__main__": cfg = CoreConfig() if path.exists(f"{args.config}/core.yaml"): - cfg.update(yaml.safe_load(open(f"{args.config}/core.yaml"))) - + cfg_dict = yaml.safe_load(open(f"{args.config}/core.yaml")) + cfg_dict.get("database", {})["loglevel"] = "info" + cfg.update(cfg_dict) + if not path.exists(cfg.server.log_dir): mkdir(cfg.server.log_dir) - + if not access(cfg.server.log_dir, W_OK): print( f"Log directory {cfg.server.log_dir} NOT writable, please check permissions" @@ -44,7 +47,6 @@ if __name__ == "__main__": exit(1) data = Data(cfg) - if args.action == "create": data.create_database() @@ -54,15 +56,22 @@ if __name__ == "__main__": elif args.action == "upgrade" or args.action == "rollback": if args.version is None: - data.logger.error("Must set game and version to migrate to") - exit(0) + data.logger.warn("No version set, upgrading to latest") if args.game is None: - data.logger.info("No game set, upgrading core schema") - data.migrate_database("CORE", int(args.version), args.action) + data.logger.warn("No game set, upgrading core schema") + data.migrate_database( + "CORE", + int(args.version) if args.version is not None else None, + args.action, + ) else: - data.migrate_database(args.game, int(args.version), args.action) + data.migrate_database( + args.game, + int(args.version) if args.version is not None else None, + args.action, + ) elif args.action == "autoupgrade": data.autoupgrade() diff --git a/docs/game_specific_info.md b/docs/game_specific_info.md index b09d61e..f1b334e 100644 --- a/docs/game_specific_info.md +++ b/docs/game_specific_info.md @@ -64,8 +64,7 @@ which version is the latest, f.e. `SDBT_3_upgrade.sql`. In order to upgrade to v perform all previous updates as well: ```shell -python dbutils.py --game SDBT --version 2 upgrade -python dbutils.py --game SDBT --version 3 upgrade +python dbutils.py --game SDBT upgrade ``` ## crossbeats REV. @@ -114,6 +113,7 @@ Config file is located in `config/cxb.yaml`. | 3 | maimai DX Splash PLUS | | 4 | maimai DX Universe | | 5 | maimai DX Universe PLUS | +| 6 | maimai DX Festival | ### Importer @@ -126,14 +126,14 @@ python read.py --series SDEZ --version --binfolder /path/to/game/fo The importer for maimai DX will import Events, Music and Tickets. **NOTE: It is required to use the importer because the game will -crash without it!** +crash without Events!** ### 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. `SDEZ_2_upgrade.sql`. In order to upgrade to version 2 in this case you need to perform all previous updates as well: ```shell -python dbutils.py --game SDEZ --version 2 upgrade +python dbutils.py --game SDEZ upgrade ``` ## Hatsune Miku Project Diva @@ -174,9 +174,7 @@ which version is the latest, f.e. `SBZV_4_upgrade.sql`. In order to upgrade to v perform all previous updates as well: ```shell -python dbutils.py --game SBZV --version 2 upgrade -python dbutils.py --game SBZV --version 3 upgrade -python dbutils.py --game SBZV --version 4 upgrade +python dbutils.py --game SBZV upgrade ``` ## O.N.G.E.K.I. @@ -224,9 +222,7 @@ which version is the latest, f.e. `SDDT_4_upgrade.sql`. In order to upgrade to v perform all previous updates as well: ```shell -python dbutils.py --game SDDT --version 2 upgrade -python dbutils.py --game SDDT --version 3 upgrade -python dbutils.py --game SDDT --version 4 upgrade +python dbutils.py --game SDDT upgrade ``` ## Card Maker @@ -346,6 +342,5 @@ Config file is located in `config/wacca.yaml`. 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. `SDFE_3_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 SDFE --version 2 upgrade -python dbutils.py --game SDFE --version 3 upgrade +python dbutils.py --game SDFE upgrade ``` diff --git a/example_config/core.yaml b/example_config/core.yaml index 561293c..382c51b 100644 --- a/example_config/core.yaml +++ b/example_config/core.yaml @@ -32,6 +32,7 @@ allnet: loglevel: "info" port: 80 allow_online_updates: False + update_cfg_folder: "" billing: port: 8443 diff --git a/example_config/idz.yaml b/example_config/idz.yaml new file mode 100644 index 0000000..1bfff9b --- /dev/null +++ b/example_config/idz.yaml @@ -0,0 +1,11 @@ +server: + enable: True + loglevel: "info" + hostname: "" + news: "" + aes_key: "" + +ports: + userdb: 10000 + match: 10010 + echo: 10020 diff --git a/example_config/pokken.yaml b/example_config/pokken.yaml index 7400060..225e980 100644 --- a/example_config/pokken.yaml +++ b/example_config/pokken.yaml @@ -5,4 +5,5 @@ server: port: 9000 port_stun: 9001 port_turn: 9002 - port_admission: 9003 \ No newline at end of file + port_admission: 9003 + auto_register: True \ No newline at end of file diff --git a/example_config/wacca.yaml b/example_config/wacca.yaml index aea4f16..bd96795 100644 --- a/example_config/wacca.yaml +++ b/example_config/wacca.yaml @@ -29,5 +29,8 @@ gates: - 17 - 18 - 19 + - 20 - 21 - 22 + - 23 + - 24 diff --git a/index.py b/index.py index 13d826d..11fad94 100644 --- a/index.py +++ b/index.py @@ -26,6 +26,22 @@ class HttpDispatcher(resource.Resource): self.title = TitleServlet(cfg, config_dir) self.mucha = MuchaServlet(cfg, config_dir) + self.map_get.connect( + "allnet_downloadorder_ini", + "/dl/ini/{file}", + controller="allnet", + action="handle_dlorder_ini", + conditions=dict(method=["GET"]), + ) + + self.map_post.connect( + "allnet_downloadorder_report", + "/dl/report", + controller="allnet", + action="handle_dlorder_report", + conditions=dict(method=["POST"]), + ) + self.map_post.connect( "allnet_ping", "/naomitest.html", @@ -95,6 +111,7 @@ class HttpDispatcher(resource.Resource): ) def render_GET(self, request: Request) -> bytes: + self.logger.debug(request.uri) test = self.map_get.match(request.uri.decode()) client_ip = Utils.get_ip_addr(request) diff --git a/read.py b/read.py index a1bd0ab..14c5cc2 100644 --- a/read.py +++ b/read.py @@ -135,8 +135,7 @@ if __name__ == "__main__": for dir, mod in titles.items(): if args.series in mod.game_codes: - handler = mod.reader(config, args.version, - bin_arg, opt_arg, args.extra) + handler = mod.reader(config, args.version, bin_arg, opt_arg, args.extra) handler.read() logger.info("Done") diff --git a/readme.md b/readme.md index 4afc225..ec25191 100644 --- a/readme.md +++ b/readme.md @@ -2,15 +2,15 @@ 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 current one in active use in arcades (n-0) or current game versions older then a year (y-1) are supported. +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 + All versions + omnimix -+ Maimai - + All versions up to Universe Plus ++ maimai DX + + All versions up to Festival + Hatsune Miku Arcade + All versions @@ -26,6 +26,8 @@ Games listed below have been tested and confirmed working. Only game versions ol + Lily R + Reverse ++ Pokken + + Final Online ## Requirements - python 3 (tested working with 3.9 and 3.10, other versions YMMV) diff --git a/titles/chuni/index.py b/titles/chuni/index.py index 53db19f..a7545ba 100644 --- a/titles/chuni/index.py +++ b/titles/chuni/index.py @@ -82,21 +82,33 @@ class ChuniServlet: level=self.game_cfg.server.loglevel, logger=self.logger, fmt=log_fmt_str ) self.logger.inited = True - + for version, keys in self.game_cfg.crypto.keys.items(): if len(keys) < 3: continue - + self.hash_table[version] = {} - - method_list = [method for method in dir(self.versions[version]) if not method.startswith('__')] + + method_list = [ + method + for method in dir(self.versions[version]) + if not method.startswith("__") + ] for method in method_list: method_fixed = inflection.camelize(method)[6:-7] - hash = PBKDF2(method_fixed, bytes.fromhex(keys[2]), 128, count=44, hmac_hash_module=SHA1) - + hash = PBKDF2( + method_fixed, + bytes.fromhex(keys[2]), + 128, + count=44, + hmac_hash_module=SHA1, + ) + self.hash_table[version][hash.hex()] = method_fixed - - self.logger.debug(f"Hashed v{version} method {method_fixed} with {bytes.fromhex(keys[2])} to get {hash.hex()}") + + self.logger.debug( + f"Hashed v{version} method {method_fixed} with {bytes.fromhex(keys[2])} to get {hash.hex()}" + ) @classmethod def get_allnet_info( @@ -164,18 +176,22 @@ class ChuniServlet: # technically not 0 if internal_ver < ChuniConstants.VER_CHUNITHM_NEW: endpoint = request.getHeader("User-Agent").split("#")[0] - + else: if internal_ver not in self.hash_table: - self.logger.error(f"v{version} does not support encryption or no keys entered") + self.logger.error( + f"v{version} does not support encryption or no keys entered" + ) return zlib.compress(b'{"stat": "0"}') - + elif endpoint.lower() not in self.hash_table[internal_ver]: - self.logger.error(f"No hash found for v{version} endpoint {endpoint}") + self.logger.error( + f"No hash found for v{version} endpoint {endpoint}" + ) return zlib.compress(b'{"stat": "0"}') endpoint = self.hash_table[internal_ver][endpoint.lower()] - + try: crypt = AES.new( bytes.fromhex(self.game_cfg.crypto.keys[internal_ver][0]), @@ -193,7 +209,11 @@ class ChuniServlet: encrtped = True - if not encrtped and self.game_cfg.crypto.encrypted_only and internal_ver >= ChuniConstants.VER_CHUNITHM_CRYSTAL_PLUS: + if ( + not encrtped + and self.game_cfg.crypto.encrypted_only + and internal_ver >= ChuniConstants.VER_CHUNITHM_CRYSTAL_PLUS + ): self.logger.error( f"Unencrypted v{version} {endpoint} request, but config is set to encrypted only: {req_raw}" ) @@ -210,9 +230,7 @@ class ChuniServlet: req_data = json.loads(unzip) - self.logger.info( - f"v{version} {endpoint} request from {client_ip}" - ) + self.logger.info(f"v{version} {endpoint} request from {client_ip}") self.logger.debug(req_data) func_to_find = "handle_" + inflection.underscore(endpoint) + "_request" diff --git a/titles/chuni/newplus.py b/titles/chuni/newplus.py index 422d57a..4faf47a 100644 --- a/titles/chuni/newplus.py +++ b/titles/chuni/newplus.py @@ -13,8 +13,12 @@ class ChuniNewPlus(ChuniNew): 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"]["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/205/ChuniServlet/" diff --git a/titles/chuni/schema/static.py b/titles/chuni/schema/static.py index bef58c0..4537518 100644 --- a/titles/chuni/schema/static.py +++ b/titles/chuni/schema/static.py @@ -200,7 +200,9 @@ class ChuniStaticData(BaseData): return result.lastrowid def get_login_bonus( - self, version: int, preset_id: int, + self, + version: int, + preset_id: int, ) -> Optional[List[Row]]: sql = login_bonus.select( and_( diff --git a/titles/cm/index.py b/titles/cm/index.py index d544e59..74d3a0d 100644 --- a/titles/cm/index.py +++ b/titles/cm/index.py @@ -12,6 +12,7 @@ from twisted.web.http import Request from logging.handlers import TimedRotatingFileHandler from core.config import CoreConfig +from core.utils import Utils from titles.cm.config import CardMakerConfig from titles.cm.const import CardMakerConstants from titles.cm.base import CardMakerBase @@ -82,6 +83,7 @@ class CardMakerServlet: url_split = url_path.split("/") internal_ver = 0 endpoint = url_split[len(url_split) - 1] + client_ip = Utils.get_ip_addr(request) print(f"version: {version}") @@ -107,7 +109,8 @@ class CardMakerServlet: req_data = json.loads(unzip) - self.logger.info(f"v{version} {endpoint} request - {req_data}") + self.logger.info(f"v{version} {endpoint} request from {client_ip}") + self.logger.debug(req_data) func_to_find = "handle_" + inflection.underscore(endpoint) + "_request" diff --git a/titles/cm/read.py b/titles/cm/read.py index f27b40b..109483c 100644 --- a/titles/cm/read.py +++ b/titles/cm/read.py @@ -80,7 +80,7 @@ class CardMakerReader(BaseReader): for dir in data_dirs: self.read_chuni_card(f"{dir}/CHU/card") self.read_chuni_gacha(f"{dir}/CHU/gacha") - + self.read_mai2_card(f"{dir}/MAI/card") self.read_ongeki_gacha(f"{dir}/MU3/gacha") def read_chuni_card(self, base_dir: str) -> None: @@ -90,7 +90,7 @@ class CardMakerReader(BaseReader): "v2_00": ChuniConstants.VER_CHUNITHM_NEW, "v2_05": ChuniConstants.VER_CHUNITHM_NEW_PLUS, # Chunithm SUN, ignore for now - "v2_10": ChuniConstants.VER_CHUNITHM_NEW_PLUS + 1 + "v2_10": ChuniConstants.VER_CHUNITHM_NEW_PLUS + 1, } for root, dirs, files in os.walk(base_dir): @@ -206,6 +206,7 @@ class CardMakerReader(BaseReader): "1.15": Mai2Constants.VER_MAIMAI_DX_SPLASH_PLUS, "1.20": Mai2Constants.VER_MAIMAI_DX_UNIVERSE, "1.25": Mai2Constants.VER_MAIMAI_DX_UNIVERSE_PLUS, + "1.30": Mai2Constants.VER_MAIMAI_DX_FESTIVAL, } for root, dirs, files in os.walk(base_dir): diff --git a/titles/cxb/index.py b/titles/cxb/index.py index 36c762e..0c38d55 100644 --- a/titles/cxb/index.py +++ b/titles/cxb/index.py @@ -101,9 +101,7 @@ class CxbServlet(resource.Resource): f"Ready on ports {self.game_cfg.server.port} & {self.game_cfg.server.port_secure}" ) else: - self.logger.info( - f"Ready on port {self.game_cfg.server.port}" - ) + self.logger.info(f"Ready on port {self.game_cfg.server.port}") def render_POST(self, request: Request): version = 0 diff --git a/titles/diva/__init__.py b/titles/diva/__init__.py index 9d93468..46ea090 100644 --- a/titles/diva/__init__.py +++ b/titles/diva/__init__.py @@ -7,4 +7,4 @@ index = DivaServlet database = DivaData reader = DivaReader game_codes = [DivaConstants.GAME_CODE] -current_schema_version = 1 +current_schema_version = 5 diff --git a/titles/diva/base.py b/titles/diva/base.py index 1857c81..84e0516 100644 --- a/titles/diva/base.py +++ b/titles/diva/base.py @@ -276,16 +276,17 @@ class DivaBase: def handle_festa_info_request(self, data: bytes) -> str: encoded = "&" params = { - "fi_id": "1,-1", - "fi_name": f"{self.core_cfg.server.name} Opening,xxx", - "fi_kind": "0,0", + "fi_id": "1,2", + "fi_name": f"{self.core_cfg.server.name} Opening,Project DIVA Festa", + # 0=PINK, 1=GREEN + "fi_kind": "1,0", "fi_difficulty": "-1,-1", "fi_pv_id_lst": "ALL,ALL", "fi_attr": "7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF,7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF", - "fi_add_vp": "20,0", - "fi_mul_vp": "1,1", - "fi_st": "2022-06-17 17:00:00.0,2014-07-08 18:10:11.0", - "fi_et": "2029-01-01 10:00:00.0,2014-07-08 18:10:11.0", + "fi_add_vp": "20,5", + "fi_mul_vp": "1,2", + "fi_st": "2019-01-01 00:00:00.0,2019-01-01 00:00:00.0", + "fi_et": "2029-01-01 00:00:00.0,2029-01-01 00:00:00.0", "fi_lut": "{self.time_lut}", } @@ -396,8 +397,28 @@ class DivaBase: profile_shop = self.data.item.get_shop(req.aime_id, self.version) if profile is None: - resp.ps_result = -3 - return resp.make() + return f"&ps_result=-3" + else: + response = "&ps_result=1" + response += "&accept_idx=100" + response += "&nblss_ltt_stts=-1" + response += "&nblss_ltt_tckt=-1" + response += "&nblss_ltt_is_opn=-1" + response += f"&pd_id={data['aime_id']}" + response += f"&player_name={profile['player_name']}" + response += f"&sort_kind={profile['player_name']}" + response += f"&lv_efct_id={profile['lv_efct_id']}" + response += f"&lv_plt_id={profile['lv_plt_id']}" + response += f"&lv_str={profile['lv_str']}" + response += f"&lv_num={profile['lv_num']}" + response += f"&lv_pnt={profile['lv_pnt']}" + response += f"&vcld_pts={profile['vcld_pts']}" + response += f"&skn_eqp={profile['use_pv_skn_eqp']}" + response += f"&btn_se_eqp={profile['btn_se_eqp']}" + response += f"&sld_se_eqp={profile['sld_se_eqp']}" + response += f"&chn_sld_se_eqp={profile['chn_sld_se_eqp']}" + response += f"&sldr_tch_se_eqp={profile['sldr_tch_se_eqp']}" + response += f"&passwd_stat={profile['passwd_stat']}" profile_dict = profile._asdict() profile_dict.pop("id") @@ -467,11 +488,122 @@ class DivaBase: resp.dsp_clr_sts = {profile['dsp_clr_sts']} resp.rgo_sts = {profile['rgo_sts']} + response += "&accept_idx=100" + response += f"&hp_vol={profile['hp_vol']}" + response += f"&btn_se_vol={profile['btn_se_vol']}" + response += f"&btn_se_vol2={profile['btn_se_vol2']}" + response += f"&sldr_se_vol2={profile['sldr_se_vol2']}" + response += f"&sort_kind={profile['sort_kind']}" + response += f"&player_name={profile['player_name']}" + response += f"&lv_num={profile['lv_num']}" + response += f"&lv_pnt={profile['lv_pnt']}" + response += f"&lv_efct_id={profile['lv_efct_id']}" + response += f"&lv_plt_id={profile['lv_plt_id']}" + response += f"&mdl_have={mdl_have}" + response += f"&cstmz_itm_have={cstmz_itm_have}" + response += f"&use_pv_mdl_eqp={int(profile['use_pv_mdl_eqp'])}" + response += f"&use_mdl_pri={int(profile['use_mdl_pri'])}" + response += f"&use_pv_skn_eqp={int(profile['use_pv_skn_eqp'])}" + response += f"&use_pv_btn_se_eqp={int(profile['use_pv_btn_se_eqp'])}" + response += f"&use_pv_sld_se_eqp={int(profile['use_pv_sld_se_eqp'])}" + response += f"&use_pv_chn_sld_se_eqp={int(profile['use_pv_chn_sld_se_eqp'])}" + response += f"&use_pv_sldr_tch_se_eqp={int(profile['use_pv_sldr_tch_se_eqp'])}" + response += f"&vcld_pts={profile['lv_efct_id']}" + response += f"&nxt_pv_id={profile['nxt_pv_id']}" + response += f"&nxt_dffclty={profile['nxt_dffclty']}" + response += f"&nxt_edtn={profile['nxt_edtn']}" + response += f"&dsp_clr_brdr={profile['dsp_clr_brdr']}" + response += f"&dsp_intrm_rnk={profile['dsp_intrm_rnk']}" + response += f"&dsp_clr_sts={profile['dsp_clr_sts']}" + response += f"&rgo_sts={profile['rgo_sts']}" + + # Contest progress + response += f"&cv_cid=-1,-1,-1,-1" + response += f"&cv_sc=-1,-1,-1,-1" + response += f"&cv_bv=-1,-1,-1,-1" + response += f"&cv_bv=-1,-1,-1,-1" + response += f"&cv_bf=-1,-1,-1,-1" + + # Contest now playing id, return -1 if no current playing contest + response += f"&cnp_cid={profile['cnp_cid']}" + response += f"&cnp_val={profile['cnp_val']}" + # border can be 0=bronzem 1=silver, 2=gold + response += f"&cnp_rr={profile['cnp_rr']}" + # only show contest specifier if it is not empty + response += f"&cnp_sp={profile['cnp_sp']}" if profile["cnp_sp"] != "" else "" + # To be fully fixed if "my_qst_id" in profile: resp.my_qst_id = {profile['my_qst_id']} resp.my_qst_sts = {profile['my_qst_sts']} + response += f"&my_qst_prgrs=0,0,0,0,0,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1" + response += f"&my_qst_et=2022-06-19%2010%3A28%3A52.0,2022-06-19%2010%3A28%3A52.0,2022-06-19%2010%3A28%3A52.0,2100-01-01%2008%3A59%3A59.0,2100-01-01%2008%3A59%3A59.0,xxx,xxx,xxx,xxx,xxx,xxx,xxx,xxx,xxx,xxx,xxx,xxx,xxx,xxx,xxx,xxx,xxx,xxx,xxx,xxx" + + # define a helper class to store all counts for clear, great, + # excellent and perfect + class ClearSet: + def __init__(self): + self.clear = 0 + self.great = 0 + self.excellent = 0 + self.perfect = 0 + + # create a dict to store the ClearSets per difficulty + clear_set_dict = { + 0: ClearSet(), # easy + 1: ClearSet(), # normal + 2: ClearSet(), # hard + 3: ClearSet(), # extreme + 4: ClearSet(), # exExtreme + } + + # get clear status from user scores + pv_records = self.data.score.get_best_scores(data["pd_id"]) + clear_status = "0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0" + + if pv_records is not None: + for score in pv_records: + if score["edition"] == 0: + # cheap and standard both count to "clear" + if score["clr_kind"] in {1, 2}: + clear_set_dict[score["difficulty"]].clear += 1 + elif score["clr_kind"] == 3: + clear_set_dict[score["difficulty"]].great += 1 + elif score["clr_kind"] == 4: + clear_set_dict[score["difficulty"]].excellent += 1 + elif score["clr_kind"] == 5: + clear_set_dict[score["difficulty"]].perfect += 1 + else: + # 4=ExExtreme + if score["clr_kind"] in {1, 2}: + clear_set_dict[4].clear += 1 + elif score["clr_kind"] == 3: + clear_set_dict[4].great += 1 + elif score["clr_kind"] == 4: + clear_set_dict[4].excellent += 1 + elif score["clr_kind"] == 5: + clear_set_dict[4].perfect += 1 + + # now add all values to a list + clear_list = [] + for clear_set in clear_set_dict.values(): + clear_list.append(clear_set.clear) + clear_list.append(clear_set.great) + clear_list.append(clear_set.excellent) + clear_list.append(clear_set.perfect) + + clear_status = ",".join(map(str, clear_list)) + + response += f"&clr_sts={clear_status}" + + # Store stuff to add to rework + response += f"&mdl_eqp_tm={self.time_lut}" + + mdl_eqp_ary = "-999,-999,-999" + c_itm_eqp_ary = "-999,-999,-999,-999,-999,-999,-999,-999,-999,-999,-999,-999" + ms_itm_flg_ary = "1,1,1,1,1,1,1,1,1,1,1,1" + # get the common_modules, customize_items and customize_item_flags # from the profile shop if profile_shop: diff --git a/titles/diva/schema/profile.py b/titles/diva/schema/profile.py index 1a498e2..7107068 100644 --- a/titles/diva/schema/profile.py +++ b/titles/diva/schema/profile.py @@ -34,9 +34,17 @@ profile = Table( Column("use_pv_sld_se_eqp", Boolean, nullable=False, server_default="0"), Column("use_pv_chn_sld_se_eqp", Boolean, nullable=False, server_default="0"), Column("use_pv_sldr_tch_se_eqp", Boolean, nullable=False, server_default="0"), + Column("btn_se_eqp", Integer, nullable=False, server_default="-1"), + Column("sld_se_eqp", Integer, nullable=False, server_default="-1"), + Column("chn_sld_se_eqp", Integer, nullable=False, server_default="-1"), + Column("sldr_tch_se_eqp", Integer, nullable=False, server_default="-1"), Column("nxt_pv_id", Integer, nullable=False, server_default="708"), Column("nxt_dffclty", Integer, nullable=False, server_default="2"), Column("nxt_edtn", Integer, nullable=False, server_default="0"), + Column("cnp_cid", Integer, nullable=False, server_default="-1"), + Column("cnp_val", Integer, nullable=False, server_default="-1"), + Column("cnp_rr", Integer, nullable=False, server_default="-1"), + Column("cnp_sp", String(255), nullable=False, server_default=""), Column("dsp_clr_brdr", Integer, nullable=False, server_default="7"), Column("dsp_intrm_rnk", Integer, nullable=False, server_default="1"), Column("dsp_clr_sts", Integer, nullable=False, server_default="1"), diff --git a/titles/diva/schema/score.py b/titles/diva/schema/score.py index 2d86925..2171659 100644 --- a/titles/diva/schema/score.py +++ b/titles/diva/schema/score.py @@ -3,6 +3,7 @@ from sqlalchemy.types import Integer, String, TIMESTAMP, JSON, Boolean from sqlalchemy.schema import ForeignKey from sqlalchemy.sql import func, select from sqlalchemy.dialects.mysql import insert +from sqlalchemy.engine import Row from typing import Optional, List, Dict, Any from core.data.schema import BaseData, metadata @@ -167,7 +168,7 @@ class DivaScoreData(BaseData): def get_best_user_score( self, user_id: int, pv_id: int, difficulty: int, edition: int - ) -> Optional[Dict]: + ) -> Optional[Row]: sql = score.select( and_( score.c.user == user_id, @@ -184,7 +185,7 @@ class DivaScoreData(BaseData): def get_top3_scores( self, pv_id: int, difficulty: int, edition: int - ) -> Optional[List[Dict]]: + ) -> Optional[List[Row]]: sql = ( score.select( and_( @@ -204,7 +205,7 @@ class DivaScoreData(BaseData): def get_global_ranking( self, user_id: int, pv_id: int, difficulty: int, edition: int - ) -> Optional[List]: + ) -> Optional[List[Row]]: # get the subquery max score of a user with pv_id, difficulty and # edition sql_sub = ( @@ -231,7 +232,7 @@ class DivaScoreData(BaseData): return None return result.fetchone() - def get_best_scores(self, user_id: int) -> Optional[List]: + def get_best_scores(self, user_id: int) -> Optional[List[Row]]: sql = score.select(score.c.user == user_id) result = self.execute(sql) diff --git a/titles/idz/__init__.py b/titles/idz/__init__.py new file mode 100644 index 0000000..958d08a --- /dev/null +++ b/titles/idz/__init__.py @@ -0,0 +1,8 @@ +from titles.idz.index import IDZServlet +from titles.idz.const import IDZConstants +from titles.idz.database import IDZData + +index = IDZServlet +database = IDZData +game_codes = [IDZConstants.GAME_CODE] +current_schema_version = 1 diff --git a/titles/idz/config.py b/titles/idz/config.py new file mode 100644 index 0000000..f7af4fd --- /dev/null +++ b/titles/idz/config.py @@ -0,0 +1,73 @@ +from typing import List, Dict + +from core.config import CoreConfig + + +class IDZServerConfig: + def __init__(self, parent_config: "IDZConfig") -> None: + self.__config = parent_config + + @property + def enable(self) -> bool: + return CoreConfig.get_config_field( + self.__config, "idz", "server", "enable", default=True + ) + + @property + def loglevel(self) -> int: + return CoreConfig.str_to_loglevel( + CoreConfig.get_config_field( + self.__config, "idz", "server", "loglevel", default="info" + ) + ) + + @property + def hostname(self) -> str: + return CoreConfig.get_config_field( + self.__config, "idz", "server", "hostname", default="" + ) + + @property + def news(self) -> str: + return CoreConfig.get_config_field( + self.__config, "idz", "server", "news", default="" + ) + + @property + def aes_key(self) -> str: + return CoreConfig.get_config_field( + self.__config, "idz", "server", "aes_key", default="" + ) + + +class IDZPortsConfig: + def __init__(self, parent_config: "IDZConfig") -> None: + self.__config = parent_config + + @property + def userdb(self) -> int: + return CoreConfig.get_config_field( + self.__config, "idz", "ports", "userdb", default=10000 + ) + + @property + def match(self) -> int: + return CoreConfig.get_config_field( + self.__config, "idz", "ports", "match", default=10010 + ) + + @property + def echo(self) -> int: + return CoreConfig.get_config_field( + self.__config, "idz", "ports", "echo", default=10020 + ) + + +class IDZConfig(dict): + def __init__(self) -> None: + self.server = IDZServerConfig(self) + self.ports = IDZPortsConfig(self) + + @property + def rsa_keys(self) -> List[Dict]: + return CoreConfig.get_config_field(self, "idz", "rsa_keys", default=[]) diff --git a/titles/idz/const.py b/titles/idz/const.py new file mode 100644 index 0000000..93cbb4c --- /dev/null +++ b/titles/idz/const.py @@ -0,0 +1,53 @@ +from enum import Enum + + +class IDZConstants: + GAME_CODE = "SDDF" + + CONFIG_NAME = "idz.yaml" + + VER_IDZ_110 = 0 + VER_IDZ_130 = 1 + VER_IDZ_210 = 2 + VER_IDZ_230 = 3 + NUM_VERS = 4 + + VERSION_NAMES = ( + "Initial D Arcade Stage Zero v1.10", + "Initial D Arcade Stage Zero v1.30", + "Initial D Arcade Stage Zero v2.10", + "Initial D Arcade Stage Zero v2.30", + ) + + class PROFILE_STATUS(Enum): + LOCKED = 0 + UNLOCKED = 1 + OLD = 2 + + HASH_LUT = [ + # No clue + 0x9C82E674, + 0x5A4738D9, + 0x8B8D7AE0, + 0x29EC9D81, + # These three are from AES TE0 + 0x1209091B, + 0x1D83839E, + 0x582C2C74, + 0x341A1A2E, + 0x361B1B2D, + 0xDC6E6EB2, + 0xB45A5AEE, + 0x5BA0A0FB, + 0xA45252F6, + 0x763B3B4D, + 0xB7D6D661, + 0x7DB3B3CE, + ] + HASH_NUM = 0 + HASH_MUL = [5, 7, 11, 12][HASH_NUM] + HASH_XOR = [0xB3, 0x8C, 0x14, 0x50][HASH_NUM] + + @classmethod + def game_ver_to_string(cls, ver: int): + return cls.VERSION_NAMES[ver] diff --git a/titles/idz/database.py b/titles/idz/database.py new file mode 100644 index 0000000..525b1c1 --- /dev/null +++ b/titles/idz/database.py @@ -0,0 +1,7 @@ +from core.data import Data +from core.config import CoreConfig + + +class IDZData(Data): + def __init__(self, cfg: CoreConfig) -> None: + super().__init__(cfg) diff --git a/titles/idz/echo.py b/titles/idz/echo.py new file mode 100644 index 0000000..979fd19 --- /dev/null +++ b/titles/idz/echo.py @@ -0,0 +1,19 @@ +from twisted.internet.protocol import DatagramProtocol +import logging + +from core.config import CoreConfig +from .config import IDZConfig + + +class IDZEcho(DatagramProtocol): + def __init__(self, cfg: CoreConfig, game_cfg: IDZConfig) -> None: + super().__init__() + self.core_config = cfg + self.game_config = game_cfg + self.logger = logging.getLogger("idz") + + def datagramReceived(self, data, addr): + self.logger.debug( + f"Echo from from {addr[0]}:{addr[1]} -> {self.transport.getHost().port} - {data.hex()}" + ) + self.transport.write(data, addr) diff --git a/titles/idz/handlers/__init__.py b/titles/idz/handlers/__init__.py new file mode 100644 index 0000000..17213ba --- /dev/null +++ b/titles/idz/handlers/__init__.py @@ -0,0 +1,47 @@ +from .base import IDZHandlerBase + +from .load_server_info import IDZHandlerLoadServerInfo + +from .load_ghost import IDZHandlerLoadGhost + +from .load_config import IDZHandlerLoadConfigA, IDZHandlerLoadConfigB + +from .load_top_ten import IDZHandlerLoadTopTen + +from .update_story_clear_num import IDZHandlerUpdateStoryClearNum + +from .save_expedition import IDZHandlerSaveExpedition + +from .load_2on2 import IDZHandlerLoad2on2A, IDZHandlerLoad2on2B + +from .load_team_ranking import IDZHandlerLoadTeamRankingA, IDZHandlerLoadTeamRankingB + +from .discover_profile import IDZHandlerDiscoverProfile + +from .lock_profile import IDZHandlerLockProfile + +from .check_team_names import IDZHandlerCheckTeamName + +from .unknown import IDZHandlerUnknown + +from .create_profile import IDZHandlerCreateProfile + +from .create_auto_team import IDZHandlerCreateAutoTeam + +from .load_profile import IDZHandlerLoadProfile + +from .save_profile import IDZHandlerSaveProfile + +from .update_provisional_store_rank import IDZHandlerUpdateProvisionalStoreRank + +from .load_reward_table import IDZHandlerLoadRewardTable + +from .save_topic import IDZHandlerSaveTopic + +from .save_time_attack import IDZHandlerSaveTimeAttack + +from .unlock_profile import IDZHandlerUnlockProfile + +from .update_team_points import IDZHandleUpdateTeamPoints + +from .update_ui_report import IDZHandleUpdateUIReport diff --git a/titles/idz/handlers/base.py b/titles/idz/handlers/base.py new file mode 100644 index 0000000..6b1e5d5 --- /dev/null +++ b/titles/idz/handlers/base.py @@ -0,0 +1,26 @@ +import logging +import struct +from core.data import Data +from core.config import CoreConfig +from ..config import IDZConfig +from ..const import IDZConstants + + +class IDZHandlerBase: + name = "generic" + cmd_codes = [0x0000] * IDZConstants.NUM_VERS + rsp_codes = [0x0001] * IDZConstants.NUM_VERS + + def __init__(self, core_cfg: CoreConfig, game_cfg: IDZConfig, version: int) -> None: + self.core_config = core_cfg + self.game_cfg = game_cfg + self.data = Data(core_cfg) + self.logger = logging.getLogger("idz") + self.game = IDZConstants.GAME_CODE + self.version = version + self.size = 0x30 + + def handle(self, data: bytes) -> bytearray: + ret = bytearray([0] * self.size) + struct.pack_into(" None: + super().__init__(core_cfg, game_cfg, version) + self.size = 0x0010 + + def handle(self, data: bytes) -> bytearray: + ret = super().handle(data) + struct.pack_into(" None: + super().__init__(core_cfg, game_cfg, version) + self.size = 0x0CA0 + + def handle(self, data: bytes) -> bytearray: + ret = super().handle(data) + aime_id = struct.unpack_from(" None: + super().__init__(core_cfg, game_cfg, version) + self.size = 0x0020 + + def handle(self, data: bytes) -> bytearray: + ret = super().handle(data) + + aime_id = struct.unpack_from(" 2: + struct.pack_into(" None: + super().__init__(core_cfg, game_cfg, version) + self.size = 0x0010 + + def handle(self, data: bytes) -> bytearray: + ret = super().handle(data) + user_id = struct.unpack_from(" None: + super().__init__(core_cfg, game_cfg, version) + self.size = 0x04C0 + + if version >= IDZConstants.VER_IDZ_210: + self.size = 0x1290 + + def handle(self, data: bytes) -> bytearray: + return super().handle(data) + + +class IDZHandlerLoad2on2B(IDZHandlerBase): + cmd_codes = [0x0132] * 4 + rsp_codes = [0x0133] * 4 + name = "load_2on2B" + + def __init__(self, core_cfg: CoreConfig, game_cfg: IDZConfig, version: int) -> None: + super().__init__(core_cfg, game_cfg, version) + self.size = 0x04C0 + + if version >= IDZConstants.VER_IDZ_210: + self.size = 0x0540 + + def handle(self, data: bytes) -> bytearray: + return super().handle(data) diff --git a/titles/idz/handlers/load_config.py b/titles/idz/handlers/load_config.py new file mode 100644 index 0000000..b3ceb0d --- /dev/null +++ b/titles/idz/handlers/load_config.py @@ -0,0 +1,43 @@ +import struct + +from .base import IDZHandlerBase +from core.config import CoreConfig +from ..config import IDZConfig +from ..const import IDZConstants + + +class IDZHandlerLoadConfigA(IDZHandlerBase): + cmd_codes = [0x0004] * IDZConstants.NUM_VERS + rsp_codes = [0x0005] * IDZConstants.NUM_VERS + name = "load_config_a" + + def __init__(self, core_cfg: CoreConfig, game_cfg: IDZConfig, version: int) -> None: + super().__init__(core_cfg, game_cfg, version) + self.size = 0x01A0 + + if self.version > 1: + self.size = 0x05E0 + + def handle(self, data: bytes) -> bytearray: + ret = super().handle(data) + struct.pack_into(" None: + super().__init__(core_cfg, game_cfg, version) + self.size = 0x0230 + + if self.version > 1: + self.size = 0x0240 + + def handle(self, data: bytes) -> bytearray: + ret = super().handle(data) + struct.pack_into(" None: + super().__init__(core_cfg, game_cfg, version) + self.size = 0x0070 + + def handle(self, data: bytes) -> bytearray: + ret = super().handle(data) + struct.pack_into(" None: + super().__init__(core_cfg, game_cfg, version) + + if self.version == IDZConstants.VER_IDZ_110: + self.size = 0x0D30 + elif self.version == IDZConstants.VER_IDZ_130: + self.size = 0x0EA0 + elif self.version == IDZConstants.VER_IDZ_210: + self.size = 0x1360 + elif self.version == IDZConstants.VER_IDZ_230: + self.size = 0x1640 + + def handle(self, data: bytes) -> bytearray: + ret = super().handle(data) + aime_id = struct.unpack_from(" None: + super().__init__(core_cfg, game_cfg, version) + self.size = 0x01C0 + + def handle(self, data: bytes) -> bytearray: + return super().handle(data) diff --git a/titles/idz/handlers/load_server_info.py b/titles/idz/handlers/load_server_info.py new file mode 100644 index 0000000..ef6e81c --- /dev/null +++ b/titles/idz/handlers/load_server_info.py @@ -0,0 +1,96 @@ +import struct + +from .base import IDZHandlerBase +from core.config import CoreConfig +from ..config import IDZConfig +from ..const import IDZConstants + + +class IDZHandlerLoadServerInfo(IDZHandlerBase): + cmd_codes = [0x0006] * IDZConstants.NUM_VERS + rsp_codes = [0x0007] * IDZConstants.NUM_VERS + name = "load_server_info1" + + def __init__(self, core_cfg: CoreConfig, game_cfg: IDZConfig, version: int) -> None: + super().__init__(core_cfg, game_cfg, version) + self.size = 0x04B0 + + def handle(self, data: bytes) -> bytearray: + ret = super().handle(data) + offset = 0 + if self.version >= IDZConstants.VER_IDZ_210: + offset = 2 + + news_str = f"http://{self.core_config.title.hostname}:{self.core_config.title.port}/SDDF/230/news/news80**.txt" + err_str = f"http://{self.core_config.title.hostname}:{self.core_config.title.port}/SDDF/230/error" + + len_hostname = len(self.core_config.title.hostname) + len_news = len(news_str) + len_error = len(err_str) + + struct.pack_into(" None: + super().__init__(core_cfg, game_cfg, version) + self.size = 0x0BA0 + + def handle(self, data: bytes) -> bytearray: + return super().handle(data) + + +class IDZHandlerLoadTeamRankingB(IDZHandlerBase): + cmd_codes = [0x00BB, 0x00BB, 0x00A9, 0x00A9] + rsp_codes = [0x00A8] * 4 + name = "load_team_ranking_b" + + def __init__(self, core_cfg: CoreConfig, game_cfg: IDZConfig, version: int) -> None: + super().__init__(core_cfg, game_cfg, version) + self.size = 0x0BA0 + + def handle(self, data: bytes) -> bytearray: + return super().handle(data) diff --git a/titles/idz/handlers/load_top_ten.py b/titles/idz/handlers/load_top_ten.py new file mode 100644 index 0000000..09a9f5f --- /dev/null +++ b/titles/idz/handlers/load_top_ten.py @@ -0,0 +1,35 @@ +import struct +from typing import Tuple, List, Dict + +from .base import IDZHandlerBase +from core.config import CoreConfig +from ..config import IDZConfig + + +class IDZHandlerLoadTopTen(IDZHandlerBase): + cmd_codes = [0x012C] * 4 + rsp_codes = [0x00CE] * 4 + name = "load_top_ten" + + def __init__(self, core_cfg: CoreConfig, game_cfg: IDZConfig, version: int) -> None: + super().__init__(core_cfg, game_cfg, version) + self.size = 0x1720 + + def handle(self, data: bytes) -> bytearray: + ret = super().handle(data) + tracks_dates: List[Tuple[int, int]] = [] + for i in range(32): + tracks_dates.append( + ( + struct.unpack_from(" None: + super().__init__(core_cfg, game_cfg, version) + self.size = 0x0020 + + def handle(self, data: bytes) -> bytearray: + ret = super().handle(data) + profile_data = { + "status": IDZConstants.PROFILE_STATUS.UNLOCKED.value, + "expire_time": int( + (datetime.now() + timedelta(hours=1)).timestamp() / 1000 + ), + } + user_id = struct.unpack_from(" 0: + old_profile = None + if old_profile is not None: + profile_data["status"] = IDZConstants.PROFILE_STATUS.OLD.value + + return self.handle_common(profile_data, ret) + + def handle_common(cls, data: Dict, ret: bytearray) -> bytearray: + struct.pack_into(" None: + super().__init__(core_cfg, game_cfg, version) + + def handle(self, data: bytes) -> bytearray: + return super().handle(data) diff --git a/titles/idz/handlers/save_profile.py b/titles/idz/handlers/save_profile.py new file mode 100644 index 0000000..3f5311d --- /dev/null +++ b/titles/idz/handlers/save_profile.py @@ -0,0 +1,16 @@ +import struct + +from .base import IDZHandlerBase +from core.config import CoreConfig +from ..config import IDZConfig + + +class IDZHandlerSaveProfile(IDZHandlerBase): + cmd_codes = [0x0068, 0x0138, 0x0138, 0x0143] + name = "save_profile" + + def __init__(self, core_cfg: CoreConfig, game_cfg: IDZConfig, version: int) -> None: + super().__init__(core_cfg, game_cfg, version) + + def handle(self, data: bytes) -> bytearray: + return super().handle(data) diff --git a/titles/idz/handlers/save_time_attack.py b/titles/idz/handlers/save_time_attack.py new file mode 100644 index 0000000..bea83af --- /dev/null +++ b/titles/idz/handlers/save_time_attack.py @@ -0,0 +1,23 @@ +import struct + +from .base import IDZHandlerBase +from core.config import CoreConfig +from ..config import IDZConfig +from ..const import IDZConstants + + +class IDZHandlerSaveTimeAttack(IDZHandlerBase): + cmd_codes = [0x00CD, 0x0136, 0x0136, 0x0136] + rsp_codes = [0x00CE, 0x00CE, 0x00CD, 0x00CD] + name = "save_time_attack" + + def __init__(self, core_cfg: CoreConfig, game_cfg: IDZConfig, version: int) -> None: + super().__init__(core_cfg, game_cfg, version) + self.size = 0x00B0 + + if self.version > IDZConstants.VER_IDZ_130: + self.size = 0x00F0 + + def handle(self, data: bytes) -> bytearray: + ret = super().handle(data) + return ret diff --git a/titles/idz/handlers/save_topic.py b/titles/idz/handlers/save_topic.py new file mode 100644 index 0000000..090ce52 --- /dev/null +++ b/titles/idz/handlers/save_topic.py @@ -0,0 +1,18 @@ +import struct + +from .base import IDZHandlerBase +from core.config import CoreConfig +from ..config import IDZConfig + + +class IDZHandlerSaveTopic(IDZHandlerBase): + cmd_codes = [0x009A, 0x009A, 0x0091, 0x0091] + rsp_codes = [0x009B, 0x009B, 0x0092, 0x0092] + name = "save_topic" + + def __init__(self, core_cfg: CoreConfig, game_cfg: IDZConfig, version: int) -> None: + super().__init__(core_cfg, game_cfg, version) + self.size = 0x05D0 + + def handle(self, data: bytes) -> bytearray: + return super().handle(data) diff --git a/titles/idz/handlers/unknown.py b/titles/idz/handlers/unknown.py new file mode 100644 index 0000000..8998d81 --- /dev/null +++ b/titles/idz/handlers/unknown.py @@ -0,0 +1,13 @@ +import struct + +from .base import IDZHandlerBase +from core.config import CoreConfig +from ..config import IDZConfig + + +class IDZHandlerUnknown(IDZHandlerBase): + cmd_codes = [0x00AD, 0x00AD, 0x00A2, 0x00A2] + name = "unknown" + + def __init__(self, core_cfg: CoreConfig, game_cfg: IDZConfig, version: int) -> None: + super().__init__(core_cfg, game_cfg, version) diff --git a/titles/idz/handlers/unlock_profile.py b/titles/idz/handlers/unlock_profile.py new file mode 100644 index 0000000..1be50f5 --- /dev/null +++ b/titles/idz/handlers/unlock_profile.py @@ -0,0 +1,21 @@ +import struct + +from .base import IDZHandlerBase +from core.config import CoreConfig +from ..config import IDZConfig +from ..const import IDZConstants + + +class IDZHandlerUnlockProfile(IDZHandlerBase): + cmd_codes = [0x006F, 0x006F, 0x006B, 0x006B] + rsp_codes = [0x0070, 0x0070, 0x006C, 0x006C] + name = "unlock_profile" + + def __init__(self, core_cfg: CoreConfig, game_cfg: IDZConfig, version: int) -> None: + super().__init__(core_cfg, game_cfg, version) + self.size = 0x0010 + + def handle(self, data: bytes) -> bytearray: + ret = super().handle(data) + struct.pack_into(" None: + super().__init__(core_cfg, game_cfg, version) + self.size = 0x02B0 + + def handle(self, data: bytes) -> bytearray: + return super().handle(data) + + def handle_common(cls, aime_id: int, ret: bytearray) -> bytearray: + pass diff --git a/titles/idz/handlers/update_story_clear_num.py b/titles/idz/handlers/update_story_clear_num.py new file mode 100644 index 0000000..bcf44a5 --- /dev/null +++ b/titles/idz/handlers/update_story_clear_num.py @@ -0,0 +1,27 @@ +import struct + +from .base import IDZHandlerBase +from core.config import CoreConfig +from ..config import IDZConfig +from ..const import IDZConstants + + +class IDZHandlerUpdateStoryClearNum(IDZHandlerBase): + cmd_codes = [0x007F, 0x097F, 0x013D, 0x0144] + rsp_codes = [0x0080, 0x013E, 0x013E, 0x0145] + name = "update_story_clear_num" + + def __init__(self, core_cfg: CoreConfig, game_cfg: IDZConfig, version: int) -> None: + super().__init__(core_cfg, game_cfg, version) + + if self.version == IDZConstants.VER_IDZ_110: + self.size = 0x0220 + elif self.version == IDZConstants.VER_IDZ_130: + self.size = 0x04F0 + elif self.version == IDZConstants.VER_IDZ_210: + self.size = 0x0510 + elif self.version == IDZConstants.VER_IDZ_230: + self.size = 0x0800 + + def handle(self, data: bytes) -> bytearray: + return super().handle(data) diff --git a/titles/idz/handlers/update_team_points.py b/titles/idz/handlers/update_team_points.py new file mode 100644 index 0000000..a23d843 --- /dev/null +++ b/titles/idz/handlers/update_team_points.py @@ -0,0 +1,18 @@ +import struct + +from .base import IDZHandlerBase +from core.config import CoreConfig +from ..config import IDZConfig +from ..const import IDZConstants + + +class IDZHandleUpdateTeamPoints(IDZHandlerBase): + cmd_codes = [0x0081, 0x0081, 0x007B, 0x007B] + name = "unlock_profile" + + def __init__(self, core_cfg: CoreConfig, game_cfg: IDZConfig, version: int) -> None: + super().__init__(core_cfg, game_cfg, version) + + def handle(self, data: bytes) -> bytearray: + ret = super().handle(data) + return ret diff --git a/titles/idz/handlers/update_ui_report.py b/titles/idz/handlers/update_ui_report.py new file mode 100644 index 0000000..7e99b40 --- /dev/null +++ b/titles/idz/handlers/update_ui_report.py @@ -0,0 +1,18 @@ +import struct + +from .base import IDZHandlerBase +from core.config import CoreConfig +from ..config import IDZConfig +from ..const import IDZConstants + + +class IDZHandleUpdateUIReport(IDZHandlerBase): + cmd_codes = [0x0084, 0x0084, 0x007E, 0x007E] + name = "update_ui_report" + + def __init__(self, core_cfg: CoreConfig, game_cfg: IDZConfig, version: int) -> None: + super().__init__(core_cfg, game_cfg, version) + + def handle(self, data: bytes) -> bytearray: + ret = super().handle(data) + return ret diff --git a/titles/idz/handlers/update_user_log.py b/titles/idz/handlers/update_user_log.py new file mode 100644 index 0000000..c862f52 --- /dev/null +++ b/titles/idz/handlers/update_user_log.py @@ -0,0 +1,18 @@ +import struct + +from .base import IDZHandlerBase +from core.config import CoreConfig +from ..config import IDZConfig +from ..const import IDZConstants + + +class IDZHandleUpdateUserLog(IDZHandlerBase): + cmd_codes = [0x00BD, 0x00BD, 0x00AB, 0x00B3] + name = "update_user_log" + + def __init__(self, core_cfg: CoreConfig, game_cfg: IDZConfig, version: int) -> None: + super().__init__(core_cfg, game_cfg, version) + + def handle(self, data: bytes) -> bytearray: + ret = super().handle(data) + return ret diff --git a/titles/idz/index.py b/titles/idz/index.py new file mode 100644 index 0000000..0f26a30 --- /dev/null +++ b/titles/idz/index.py @@ -0,0 +1,174 @@ +from twisted.web.http import Request +import yaml +import logging +import coloredlogs +from logging.handlers import TimedRotatingFileHandler +from os import path +from typing import Tuple, List +from twisted.internet import reactor, endpoints +from twisted.web import server, resource +import importlib + +from core.config import CoreConfig +from .config import IDZConfig +from .const import IDZConstants +from .userdb import IDZUserDBFactory, IDZUserDBWeb, IDZKey +from .echo import IDZEcho +from .handlers import IDZHandlerLoadConfigB + + +class IDZServlet: + def __init__(self, core_cfg: CoreConfig, cfg_dir: str) -> None: + self.core_cfg = core_cfg + self.game_cfg = IDZConfig() + if path.exists(f"{cfg_dir}/{IDZConstants.CONFIG_NAME}"): + self.game_cfg.update( + yaml.safe_load(open(f"{cfg_dir}/{IDZConstants.CONFIG_NAME}")) + ) + + self.logger = logging.getLogger("idz") + if not hasattr(self.logger, "inited"): + log_fmt_str = "[%(asctime)s] IDZ | %(levelname)s | %(message)s" + log_fmt = logging.Formatter(log_fmt_str) + fileHandler = TimedRotatingFileHandler( + "{0}/{1}.log".format(self.core_cfg.server.log_dir, "idz"), + encoding="utf8", + when="d", + backupCount=10, + ) + + self.rsa_keys: List[IDZKey] = [] + + 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 + + @classmethod + def rsaHashKeyN(cls, data): + hash_ = 0 + for i in data: + hash_ = ( + hash_ * IDZConstants.HASH_MUL + (i ^ IDZConstants.HASH_XOR) + ^ IDZConstants.HASH_LUT[i & 0xF] + ) + hash_ &= 0xFFFFFFFF + return hash_ + + @classmethod + def get_allnet_info( + cls, game_code: str, core_cfg: CoreConfig, cfg_dir: str + ) -> Tuple[bool, str, str]: + game_cfg = IDZConfig() + if path.exists(f"{cfg_dir}/{IDZConstants.CONFIG_NAME}"): + game_cfg.update( + yaml.safe_load(open(f"{cfg_dir}/{IDZConstants.CONFIG_NAME}")) + ) + + if not game_cfg.server.enable: + return (False, "", "") + + if len(game_cfg.rsa_keys) <= 0 or not game_cfg.server.aes_key: + logging.getLogger("idz").error("IDZ: No RSA/AES keys! IDZ cannot start") + return (False, "", "") + + hostname = ( + core_cfg.title.hostname + if not game_cfg.server.hostname + else game_cfg.server.hostname + ) + return ( + True, + f"", + f"{hostname}:{game_cfg.ports.userdb}", + ) + + def setup(self): + for key in self.game_cfg.rsa_keys: + if "N" not in key or "d" not in key or "e" not in key: + self.logger.error(f"Invalid IDZ key {key}") + continue + + hashN = self.rsaHashKeyN(str(key["N"]).encode()) + self.rsa_keys.append(IDZKey(key["N"], key["d"], key["e"], hashN)) + + if len(self.rsa_keys) <= 0: + self.logger.error("No valid RSA keys provided! IDZ cannot start!") + return + + handler_map = [{} for _ in range(IDZConstants.NUM_VERS)] + handler_mod = mod = importlib.import_module(f"titles.idz.handlers") + + for cls_name in dir(handler_mod): + if cls_name.startswith("__"): + continue + + try: + mod = getattr(handler_mod, cls_name) + mod_cmds: List = getattr(mod, "cmd_codes") + while len(mod_cmds) < IDZConstants.NUM_VERS: + mod_cmds.append(None) + + for i in range(len(mod_cmds)): + if mod_cmds[i] is None: + mod_cmds[i] = mod_cmds[i - 1] + + handler_map[i][mod_cmds[i]] = mod + + except AttributeError as e: + continue + + endpoints.serverFromString( + reactor, + f"tcp:{self.game_cfg.ports.userdb}:interface={self.core_cfg.server.listen_address}", + ).listen( + IDZUserDBFactory(self.core_cfg, self.game_cfg, self.rsa_keys, handler_map) + ) + + reactor.listenUDP( + self.game_cfg.ports.echo, IDZEcho(self.core_cfg, self.game_cfg) + ) + reactor.listenUDP( + self.game_cfg.ports.echo + 1, IDZEcho(self.core_cfg, self.game_cfg) + ) + reactor.listenUDP( + self.game_cfg.ports.match, IDZEcho(self.core_cfg, self.game_cfg) + ) + reactor.listenUDP( + self.game_cfg.ports.userdb + 1, IDZEcho(self.core_cfg, self.game_cfg) + ) + + self.logger.info(f"UserDB Listening on port {self.game_cfg.ports.userdb}") + + def render_POST(self, request: Request, version: int, url_path: str) -> bytes: + req_raw = request.content.getvalue() + self.logger.info(f"IDZ POST request: {url_path} - {req_raw}") + return b"" + + def render_GET(self, request: Request, version: int, url_path: str) -> bytes: + self.logger.info(f"IDZ GET request: {url_path}") + request.responseHeaders.setRawHeaders( + "Content-Type", [b"text/plain; charset=utf-8"] + ) + request.responseHeaders.setRawHeaders( + "Last-Modified", [b"Sun, 23 Apr 2023 05:33:20 GMT"] + ) + + news = ( + self.game_cfg.server.news + if self.game_cfg.server.news + else f"Welcome to Initial D Arcade Stage Zero on {self.core_cfg.server.name}!" + ) + news += "\r\n" + news = "1979/01/01 00:00:00 2099/12/31 23:59:59 " + news + + return news.encode() diff --git a/titles/idz/match.py b/titles/idz/match.py new file mode 100644 index 0000000..e69de29 diff --git a/titles/idz/userdb.py b/titles/idz/userdb.py new file mode 100644 index 0000000..2f70ba4 --- /dev/null +++ b/titles/idz/userdb.py @@ -0,0 +1,195 @@ +from twisted.internet.protocol import Factory, Protocol +import logging, coloredlogs +from Crypto.Cipher import AES +import struct +from typing import Dict, Optional, List, Type +from twisted.web import server, resource +from twisted.internet import reactor, endpoints +from twisted.web.http import Request +from routes import Mapper +import random +from os import walk +import importlib + +from core.config import CoreConfig +from .database import IDZData +from .config import IDZConfig +from .const import IDZConstants +from .handlers import IDZHandlerBase + +HANDLER_MAP: List[Dict] + + +class IDZKey: + def __init__(self, n, d, e, hashN: int) -> None: + self.N = n + self.d = d + self.e = e + self.hashN = hashN + + +class IDZUserDBProtocol(Protocol): + def __init__( + self, + core_cfg: CoreConfig, + game_cfg: IDZConfig, + keys: List[IDZKey], + handlers: List[Dict], + ) -> None: + self.logger = logging.getLogger("idz") + self.core_config = core_cfg + self.game_config = game_cfg + self.rsa_keys = keys + self.handlers = handlers + self.static_key = bytes.fromhex(self.game_config.server.aes_key) + self.version = None + self.version_internal = None + self.skip_next = False + + def append_padding(self, data: bytes): + """Appends 0s to the end of the data until it's at the correct size""" + length = struct.unpack_from(" None: + self.logger.debug(f"{self.transport.getPeer().host} Connected") + base = 0 + + for i in range(len(self.static_key) - 1): + shift = 8 * i + byte = self.static_key[i] + + base |= byte << shift + + rsa_key = random.choice(self.rsa_keys) + key_enc: int = pow(base, rsa_key.e, rsa_key.N) + result = ( + key_enc.to_bytes(0x40, "little") + + struct.pack(" None: + self.logger.debug( + f"{self.transport.getPeer().host} Disconnected - {reason.value}" + ) + + 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) + self.logger.debug(f"Decrypt data {data_dec.hex()}") + + magic = struct.unpack_from(" None: + self.core_config = cfg + self.game_config = game_cfg + self.keys = keys + self.handlers = handlers + + def buildProtocol(self, addr): + return IDZUserDBProtocol( + self.core_config, self.game_config, self.keys, self.handlers + ) + + +class IDZUserDBWeb(resource.Resource): + def __init__(self, core_cfg: CoreConfig, game_cfg: IDZConfig): + super().__init__() + self.isLeaf = True + self.core_config = core_cfg + self.game_config = game_cfg + self.logger = logging.getLogger("idz") + + def render_POST(self, request: Request) -> bytes: + self.logger.info( + f"IDZUserDBWeb POST from {request.getClientAddress().host} to {request.uri} with data {request.content.getvalue()}" + ) + return b"" + + def render_GET(self, request: Request) -> bytes: + self.logger.info( + f"IDZUserDBWeb GET from {request.getClientAddress().host} to {request.uri}" + ) + return b"" diff --git a/titles/mai2/__init__.py b/titles/mai2/__init__.py index 27fba3a..810eac9 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 = 3 +current_schema_version = 4 diff --git a/titles/mai2/base.py b/titles/mai2/base.py index 741ccb6..171378c 100644 --- a/titles/mai2/base.py +++ b/titles/mai2/base.py @@ -1,5 +1,5 @@ from datetime import datetime, date, timedelta -from typing import Dict +from typing import Any, Dict import logging from core.config import CoreConfig @@ -52,6 +52,7 @@ class Mai2Base: events = self.data.static.get_enabled_events(self.version) events_lst = [] if events is None: + self.logger.warn("No enabled events, did you run the reader?") return {"type": data["type"], "length": 0, "gameEventList": []} for event in events: @@ -59,7 +60,11 @@ class Mai2Base: { "type": event["type"], "id": event["eventId"], - "startDate": "2017-12-05 07:00:00.0", + # actually use the startDate from the import so it + # properly shows all the events when new ones are imported + "startDate": datetime.strftime( + event["startDate"], f"{Mai2Constants.DATE_TIME_FORMAT}.0" + ), "endDate": "2099-12-31 00:00:00.0", } ) @@ -79,12 +84,12 @@ class Mai2Base: return {"length": 0, "gameChargeList": []} charge_list = [] - for x in range(len(game_charge_list)): + for i, charge in enumerate(game_charge_list): charge_list.append( { - "orderId": x, - "chargeId": game_charge_list[x]["ticketId"], - "price": game_charge_list[x]["price"], + "orderId": i, + "chargeId": charge["ticketId"], + "price": charge["price"], "startDate": "2017-12-05 07:00:00.0", "endDate": "2099-12-31 00:00:00.0", } @@ -93,16 +98,16 @@ class Mai2Base: return {"length": len(charge_list), "gameChargeList": charge_list} def handle_upsert_client_setting_api_request(self, data: Dict) -> Dict: - pass + return {"returnCode": 1, "apiName": "UpsertClientSettingApi"} def handle_upsert_client_upload_api_request(self, data: Dict) -> Dict: - pass + return {"returnCode": 1, "apiName": "UpsertClientUploadApi"} def handle_upsert_client_bookkeeping_api_request(self, data: Dict) -> Dict: - pass + return {"returnCode": 1, "apiName": "UpsertClientBookkeepingApi"} def handle_upsert_client_testmode_api_request(self, data: Dict) -> Dict: - pass + return {"returnCode": 1, "apiName": "UpsertClientTestmodeApi"} def handle_get_user_preview_api_request(self, data: Dict) -> Dict: p = self.data.profile.get_profile_detail(data["userId"], self.version) @@ -167,6 +172,24 @@ class Mai2Base: self.data.score.put_playlog(user_id, playlog) + return {"returnCode": 1, "apiName": "UploadUserPlaylogApi"} + + def handle_upsert_user_chargelog_api_request(self, data: Dict) -> Dict: + user_id = data["userId"] + charge = data["userCharge"] + + # remove the ".0" from the date string, festival only? + charge["purchaseDate"] = charge["purchaseDate"].replace(".0", "") + self.data.item.put_charge( + user_id, + charge["chargeId"], + charge["stock"], + datetime.strptime(charge["purchaseDate"], Mai2Constants.DATE_TIME_FORMAT), + datetime.strptime(charge["validDate"], Mai2Constants.DATE_TIME_FORMAT), + ) + + return {"returnCode": 1, "apiName": "UpsertUserChargelogApi"} + def handle_upsert_user_all_api_request(self, data: Dict) -> Dict: user_id = data["userId"] upsert = data["upsertUserAll"] @@ -204,15 +227,21 @@ class Mai2Base: if "userChargeList" in upsert and len(upsert["userChargeList"]) > 0: for charge in upsert["userChargeList"]: + # remove the ".0" from the date string, festival only? + charge["purchaseDate"] = charge["purchaseDate"].replace(".0", "") self.data.item.put_charge( user_id, charge["chargeId"], charge["stock"], - datetime.strptime(charge["purchaseDate"], "%Y-%m-%d %H:%M:%S"), - datetime.strptime(charge["validDate"], "%Y-%m-%d %H:%M:%S") + datetime.strptime( + charge["purchaseDate"], Mai2Constants.DATE_TIME_FORMAT + ), + datetime.strptime( + charge["validDate"], Mai2Constants.DATE_TIME_FORMAT + ), ) - if upsert["isNewCharacterList"] and int(upsert["isNewCharacterList"]) > 0: + if "userCharacterList" in upsert and len(upsert["userCharacterList"]) > 0: for char in upsert["userCharacterList"]: self.data.item.put_character( user_id, @@ -222,7 +251,7 @@ class Mai2Base: char["useCount"], ) - if upsert["isNewItemList"] and int(upsert["isNewItemList"]) > 0: + if "userItemList" in upsert and len(upsert["userItemList"]) > 0: for item in upsert["userItemList"]: self.data.item.put_item( user_id, @@ -232,7 +261,7 @@ class Mai2Base: item["isValid"], ) - if upsert["isNewLoginBonusList"] and int(upsert["isNewLoginBonusList"]) > 0: + if "userLoginBonusList" in upsert and len(upsert["userLoginBonusList"]) > 0: for login_bonus in upsert["userLoginBonusList"]: self.data.item.put_login_bonus( user_id, @@ -242,7 +271,7 @@ class Mai2Base: login_bonus["isComplete"], ) - if upsert["isNewMapList"] and int(upsert["isNewMapList"]) > 0: + if "userMapList" in upsert and len(upsert["userMapList"]) > 0: for map in upsert["userMapList"]: self.data.item.put_map( user_id, @@ -253,24 +282,34 @@ class Mai2Base: map["isComplete"], ) - if upsert["isNewMusicDetailList"] and int(upsert["isNewMusicDetailList"]) > 0: + if "userMusicDetailList" in upsert and len(upsert["userMusicDetailList"]) > 0: for music in upsert["userMusicDetailList"]: self.data.score.put_best_score(user_id, music) - if upsert["isNewCourseList"] and int(upsert["isNewCourseList"]) > 0: + if "userCourseList" in upsert and len(upsert["userCourseList"]) > 0: for course in upsert["userCourseList"]: self.data.score.put_course(user_id, course) - if upsert["isNewFavoriteList"] and int(upsert["isNewFavoriteList"]) > 0: + if "userFavoriteList" in upsert and len(upsert["userFavoriteList"]) > 0: for fav in upsert["userFavoriteList"]: self.data.item.put_favorite(user_id, fav["kind"], fav["itemIdList"]) - # if "isNewFriendSeasonRankingList" in upsert and int(upsert["isNewFriendSeasonRankingList"]) > 0: - # for fsr in upsert["userFriendSeasonRankingList"]: - # pass + if ( + "userFriendSeasonRankingList" in upsert + and len(upsert["userFriendSeasonRankingList"]) > 0 + ): + for fsr in upsert["userFriendSeasonRankingList"]: + fsr["recordDate"] = ( + datetime.strptime( + fsr["recordDate"], f"{Mai2Constants.DATE_TIME_FORMAT}.0" + ), + ) + self.data.item.put_friend_season_ranking(user_id, fsr) + + return {"returnCode": 1, "apiName": "UpsertUserAllApi"} def handle_user_logout_api_request(self, data: Dict) -> Dict: - pass + return {"returnCode": 1} def handle_get_user_data_api_request(self, data: Dict) -> Dict: profile = self.data.profile.get_profile_detail(data["userId"], self.version) @@ -311,11 +350,7 @@ class Mai2Base: def handle_get_user_card_api_request(self, data: Dict) -> Dict: user_cards = self.data.item.get_cards(data["userId"]) if user_cards is None: - return { - "userId": data["userId"], - "nextIndex": 0, - "userCardList": [] - } + return {"userId": data["userId"], "nextIndex": 0, "userCardList": []} max_ct = data["maxCount"] next_idx = data["nextIndex"] @@ -333,25 +368,23 @@ class Mai2Base: tmp.pop("id") tmp.pop("user") tmp["startDate"] = datetime.strftime( - tmp["startDate"], "%Y-%m-%d %H:%M:%S") + tmp["startDate"], Mai2Constants.DATE_TIME_FORMAT + ) tmp["endDate"] = datetime.strftime( - tmp["endDate"], "%Y-%m-%d %H:%M:%S") + tmp["endDate"], Mai2Constants.DATE_TIME_FORMAT + ) card_list.append(tmp) return { "userId": data["userId"], "nextIndex": next_idx, - "userCardList": card_list[start_idx:end_idx] + "userCardList": card_list[start_idx:end_idx], } def handle_get_user_charge_api_request(self, data: Dict) -> Dict: user_charges = self.data.item.get_charges(data["userId"]) if user_charges is None: - return { - "userId": data["userId"], - "length": 0, - "userChargeList": [] - } + return {"userId": data["userId"], "length": 0, "userChargeList": []} user_charge_list = [] for charge in user_charges: @@ -359,45 +392,46 @@ class Mai2Base: tmp.pop("id") tmp.pop("user") tmp["purchaseDate"] = datetime.strftime( - tmp["purchaseDate"], "%Y-%m-%d %H:%M:%S") + tmp["purchaseDate"], Mai2Constants.DATE_TIME_FORMAT + ) tmp["validDate"] = datetime.strftime( - tmp["validDate"], "%Y-%m-%d %H:%M:%S") + tmp["validDate"], Mai2Constants.DATE_TIME_FORMAT + ) user_charge_list.append(tmp) return { "userId": data["userId"], "length": len(user_charge_list), - "userChargeList": user_charge_list + "userChargeList": user_charge_list, } def handle_get_user_item_api_request(self, data: Dict) -> Dict: kind = int(data["nextIndex"] / 10000000000) next_idx = int(data["nextIndex"] % 10000000000) - user_items = self.data.item.get_items(data["userId"], kind) - user_item_list = [] - next_idx = 0 + user_item_list = self.data.item.get_items(data["userId"], kind) - for x in range(next_idx, data["maxCount"]): - try: - user_item_list.append({ - "itemKind": user_items[x]["itemKind"], - "itemId": user_items[x]["itemId"], - "stock": user_items[x]["stock"], - "isValid": user_items[x]["isValid"] - }) - except IndexError: + items: list[Dict[str, Any]] = [] + for i in range(next_idx, len(user_item_list)): + tmp = user_item_list[i]._asdict() + tmp.pop("user") + tmp.pop("id") + items.append(tmp) + if len(items) >= int(data["maxCount"]): break - if len(user_item_list) == data["maxCount"]: - next_idx = data["nextIndex"] + data["maxCount"] + 1 - break + xout = kind * 10000000000 + next_idx + len(items) + + if len(items) < int(data["maxCount"]): + next_idx = 0 + else: + next_idx = xout return { "userId": data["userId"], "nextIndex": next_idx, "itemKind": kind, - "userItemList": user_item_list + "userItemList": items, } def handle_get_user_character_api_request(self, data: Dict) -> Dict: @@ -479,21 +513,12 @@ class Mai2Base: tmp.pop("user") mlst.append(tmp) - return { - "userActivity": { - "playList": plst, - "musicList": mlst - } - } + return {"userActivity": {"playList": plst, "musicList": mlst}} def handle_get_user_course_api_request(self, data: Dict) -> Dict: user_courses = self.data.score.get_courses(data["userId"]) if user_courses is None: - return { - "userId": data["userId"], - "nextIndex": 0, - "userCourseList": [] - } + return {"userId": data["userId"], "nextIndex": 0, "userCourseList": []} course_list = [] for course in user_courses: @@ -502,11 +527,7 @@ class Mai2Base: tmp.pop("id") course_list.append(tmp) - return { - "userId": data["userId"], - "nextIndex": 0, - "userCourseList": course_list - } + return {"userId": data["userId"], "nextIndex": 0, "userCourseList": course_list} def handle_get_user_portrait_api_request(self, data: Dict) -> Dict: # No support for custom pfps @@ -514,96 +535,103 @@ class Mai2Base: def handle_get_user_friend_season_ranking_api_request(self, data: Dict) -> Dict: friend_season_ranking = self.data.item.get_friend_season_ranking(data["userId"]) - friend_season_ranking_list = [] - next_index = 0 + if friend_season_ranking is None: + return { + "userId": data["userId"], + "nextIndex": 0, + "userFriendSeasonRankingList": [], + } - for x in range(data["nextIndex"], data["maxCount"] + data["nextIndex"]): - try: - friend_season_ranking_list.append( - { - "mapId": friend_season_ranking_list[x]["map_id"], - "distance": friend_season_ranking_list[x]["distance"], - "isLock": friend_season_ranking_list[x]["is_lock"], - "isClear": friend_season_ranking_list[x]["is_clear"], - "isComplete": friend_season_ranking_list[x]["is_complete"], - } - ) - except: + friend_season_ranking_list = [] + next_idx = int(data["nextIndex"]) + max_ct = int(data["maxCount"]) + + for x in range(next_idx, len(friend_season_ranking)): + tmp = friend_season_ranking[x]._asdict() + tmp.pop("user") + tmp.pop("id") + tmp["recordDate"] = datetime.strftime( + tmp["recordDate"], f"{Mai2Constants.DATE_TIME_FORMAT}.0" + ) + friend_season_ranking_list.append(tmp) + + if len(friend_season_ranking_list) >= max_ct: break - # We're capped and still have some left to go - if ( - len(friend_season_ranking_list) == data["maxCount"] - and len(friend_season_ranking) > data["maxCount"] + data["nextIndex"] - ): - next_index = data["maxCount"] + data["nextIndex"] + if len(friend_season_ranking) >= next_idx + max_ct: + next_idx += max_ct + else: + next_idx = 0 return { "userId": data["userId"], - "nextIndex": next_index, + "nextIndex": next_idx, "userFriendSeasonRankingList": friend_season_ranking_list, } def handle_get_user_map_api_request(self, data: Dict) -> Dict: maps = self.data.item.get_maps(data["userId"]) - map_list = [] - next_index = 0 + if maps is None: + return { + "userId": data["userId"], + "nextIndex": 0, + "userMapList": [], + } - for x in range(data["nextIndex"], data["maxCount"] + data["nextIndex"]): - try: - map_list.append( - { - "mapId": maps[x]["map_id"], - "distance": maps[x]["distance"], - "isLock": maps[x]["is_lock"], - "isClear": maps[x]["is_clear"], - "isComplete": maps[x]["is_complete"], - } - ) - except: + map_list = [] + next_idx = int(data["nextIndex"]) + max_ct = int(data["maxCount"]) + + for x in range(next_idx, len(maps)): + tmp = maps[x]._asdict() + tmp.pop("user") + tmp.pop("id") + map_list.append(tmp) + + if len(map_list) >= max_ct: break - # We're capped and still have some left to go - if ( - len(map_list) == data["maxCount"] - and len(maps) > data["maxCount"] + data["nextIndex"] - ): - next_index = data["maxCount"] + data["nextIndex"] + if len(maps) >= next_idx + max_ct: + next_idx += max_ct + else: + next_idx = 0 return { "userId": data["userId"], - "nextIndex": next_index, + "nextIndex": next_idx, "userMapList": map_list, } def handle_get_user_login_bonus_api_request(self, data: Dict) -> Dict: login_bonuses = self.data.item.get_login_bonuses(data["userId"]) - login_bonus_list = [] - next_index = 0 + if login_bonuses is None: + return { + "userId": data["userId"], + "nextIndex": 0, + "userLoginBonusList": [], + } - for x in range(data["nextIndex"], data["maxCount"] + data["nextIndex"]): - try: - login_bonus_list.append( - { - "bonusId": login_bonuses[x]["bonus_id"], - "point": login_bonuses[x]["point"], - "isCurrent": login_bonuses[x]["is_current"], - "isComplete": login_bonuses[x]["is_complete"], - } - ) - except: + login_bonus_list = [] + next_idx = int(data["nextIndex"]) + max_ct = int(data["maxCount"]) + + for x in range(next_idx, len(login_bonuses)): + tmp = login_bonuses[x]._asdict() + tmp.pop("user") + tmp.pop("id") + login_bonus_list.append(tmp) + + if len(login_bonus_list) >= max_ct: break - # We're capped and still have some left to go - if ( - len(login_bonus_list) == data["maxCount"] - and len(login_bonuses) > data["maxCount"] + data["nextIndex"] - ): - next_index = data["maxCount"] + data["nextIndex"] + if len(login_bonuses) >= next_idx + max_ct: + next_idx += max_ct + else: + next_idx = 0 return { "userId": data["userId"], - "nextIndex": next_index, + "nextIndex": next_idx, "userLoginBonusList": login_bonus_list, } @@ -629,5 +657,5 @@ class Mai2Base: return { "userId": data["userId"], "nextIndex": next_index, - "userMusicList": [{"userMusicDetailList": music_detail_list}] + "userMusicList": [{"userMusicDetailList": music_detail_list}], } diff --git a/titles/mai2/const.py b/titles/mai2/const.py index dd1dca0..dcc7e29 100644 --- a/titles/mai2/const.py +++ b/titles/mai2/const.py @@ -30,14 +30,16 @@ class Mai2Constants: VER_MAIMAI_DX_SPLASH_PLUS = 3 VER_MAIMAI_DX_UNIVERSE = 4 VER_MAIMAI_DX_UNIVERSE_PLUS = 5 + VER_MAIMAI_DX_FESTIVAL = 6 VERSION_STRING = ( - "maimai Delux", - "maimai Delux PLUS", - "maimai Delux Splash", - "maimai Delux Splash PLUS", - "maimai Delux Universe", - "maimai Delux Universe PLUS", + "maimai DX", + "maimai DX PLUS", + "maimai DX Splash", + "maimai DX Splash PLUS", + "maimai DX Universe", + "maimai DX Universe PLUS", + "maimai DX Festival", ) @classmethod diff --git a/titles/mai2/festival.py b/titles/mai2/festival.py new file mode 100644 index 0000000..4e51619 --- /dev/null +++ b/titles/mai2/festival.py @@ -0,0 +1,31 @@ +from typing import Dict + +from core.config import CoreConfig +from titles.mai2.universeplus import Mai2UniversePlus +from titles.mai2.const import Mai2Constants +from titles.mai2.config import Mai2Config + + +class Mai2Festival(Mai2UniversePlus): + def __init__(self, cfg: CoreConfig, game_cfg: Mai2Config) -> None: + super().__init__(cfg, game_cfg) + self.version = Mai2Constants.VER_MAIMAI_DX_FESTIVAL + + 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.36 + user_data["lastDataVersion"] = "1.30.00" + return user_data + + def handle_user_login_api_request(self, data: Dict) -> Dict: + user_login = super().handle_user_login_api_request(data) + # useless? + user_login["Bearer"] = "ARTEMiSTOKEN" + return user_login + + def handle_get_user_recommend_rate_music_api_request(self, data: Dict) -> Dict: + return {"userId": data["userId"], "userRecommendRateMusicIdList": []} + + def handle_get_user_recommend_select_music_api_request(self, data: Dict) -> Dict: + return {"userId": data["userId"], "userRecommendSelectionMusicIdList": []} diff --git a/titles/mai2/index.py b/titles/mai2/index.py index 3cd1629..1b92842 100644 --- a/titles/mai2/index.py +++ b/titles/mai2/index.py @@ -10,6 +10,7 @@ from os import path from typing import Tuple from core.config import CoreConfig +from core.utils import Utils from titles.mai2.config import Mai2Config from titles.mai2.const import Mai2Constants from titles.mai2.base import Mai2Base @@ -18,6 +19,7 @@ from titles.mai2.splash import Mai2Splash from titles.mai2.splashplus import Mai2SplashPlus from titles.mai2.universe import Mai2Universe from titles.mai2.universeplus import Mai2UniversePlus +from titles.mai2.festival import Mai2Festival class Mai2Servlet: @@ -30,12 +32,13 @@ class Mai2Servlet: ) self.versions = [ - Mai2Base(core_cfg, self.game_cfg), - Mai2Plus(core_cfg, self.game_cfg), - Mai2Splash(core_cfg, self.game_cfg), - Mai2SplashPlus(core_cfg, self.game_cfg), - Mai2Universe(core_cfg, self.game_cfg), - Mai2UniversePlus(core_cfg, self.game_cfg), + Mai2Base, + Mai2Plus, + Mai2Splash, + Mai2SplashPlus, + Mai2Universe, + Mai2UniversePlus, + Mai2Festival, ] self.logger = logging.getLogger("mai2") @@ -97,6 +100,7 @@ class Mai2Servlet: url_split = url_path.split("/") internal_ver = 0 endpoint = url_split[len(url_split) - 1] + client_ip = Utils.get_ip_addr(request) if version < 105: # 1.0 internal_ver = Mai2Constants.VER_MAIMAI_DX @@ -108,8 +112,10 @@ class Mai2Servlet: internal_ver = Mai2Constants.VER_MAIMAI_DX_SPLASH_PLUS elif version >= 120 and version < 125: # Universe internal_ver = Mai2Constants.VER_MAIMAI_DX_UNIVERSE - elif version >= 125: # Universe Plus + elif version >= 125 and version < 130: # Universe Plus internal_ver = Mai2Constants.VER_MAIMAI_DX_UNIVERSE_PLUS + elif version >= 130: # Festival + internal_ver = Mai2Constants.VER_MAIMAI_DX_FESTIVAL 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 @@ -128,25 +134,28 @@ class Mai2Servlet: req_data = json.loads(unzip) - self.logger.info(f"v{version} {endpoint} request - {req_data}") + self.logger.info(f"v{version} {endpoint} request from {client_ip}") + self.logger.debug(req_data) func_to_find = "handle_" + inflection.underscore(endpoint) + "_request" + handler_cls = self.versions[internal_ver](self.core_cfg, self.game_cfg) - if not hasattr(self.versions[internal_ver], func_to_find): + if not hasattr(handler_cls, func_to_find): self.logger.warning(f"Unhandled v{version} request {endpoint}") - return zlib.compress(b'{"returnCode": 1}') + resp = {"returnCode": 1} - try: - handler = getattr(self.versions[internal_ver], func_to_find) - resp = handler(req_data) + else: + try: + handler = getattr(handler_cls, func_to_find) + resp = handler(req_data) - except Exception as e: - self.logger.error(f"Error handling v{version} method {endpoint} - {e}") - return zlib.compress(b'{"stat": "0"}') + except Exception as e: + self.logger.error(f"Error handling v{version} method {endpoint} - {e}") + return zlib.compress(b'{"stat": "0"}') if resp == None: resp = {"returnCode": 1} - self.logger.info(f"Response {resp}") + self.logger.debug(f"Response {resp}") return zlib.compress(json.dumps(resp, ensure_ascii=False).encode("utf-8")) diff --git a/titles/mai2/read.py b/titles/mai2/read.py index 2c0567c..5809464 100644 --- a/titles/mai2/read.py +++ b/titles/mai2/read.py @@ -29,7 +29,7 @@ class Mai2Reader(BaseReader): f"Start importer for {Mai2Constants.game_ver_to_string(version)}" ) except IndexError: - self.logger.error(f"Invalid maidx version {version}") + self.logger.error(f"Invalid maimai DX version {version}") exit(1) def read(self) -> None: @@ -43,6 +43,7 @@ class Mai2Reader(BaseReader): for dir in data_dirs: self.logger.info(f"Read from {dir}") self.get_events(f"{dir}/event") + self.disable_events(f"{dir}/information", f"{dir}/scoreRanking") self.read_music(f"{dir}/music") self.read_tickets(f"{dir}/ticket") @@ -64,6 +65,64 @@ class Mai2Reader(BaseReader): ) self.logger.info(f"Added event {id}...") + def disable_events( + self, base_information_dir: str, base_score_ranking_dir: str + ) -> None: + self.logger.info(f"Reading disabled events from {base_information_dir}...") + + for root, dirs, files in os.walk(base_information_dir): + for dir in dirs: + if os.path.exists(f"{root}/{dir}/Information.xml"): + with open(f"{root}/{dir}/Information.xml", encoding="utf-8") as f: + troot = ET.fromstring(f.read()) + + event_id = int(troot.find("name").find("id").text) + + self.data.static.toggle_game_event( + self.version, event_id, toggle=False + ) + self.logger.info(f"Disabled event {event_id}...") + + for root, dirs, files in os.walk(base_score_ranking_dir): + for dir in dirs: + if os.path.exists(f"{root}/{dir}/ScoreRanking.xml"): + with open(f"{root}/{dir}/ScoreRanking.xml", encoding="utf-8") as f: + troot = ET.fromstring(f.read()) + + event_id = int(troot.find("eventName").find("id").text) + + self.data.static.toggle_game_event( + self.version, event_id, toggle=False + ) + self.logger.info(f"Disabled event {event_id}...") + + # manually disable events wich are known to be problematic + for event_id in [ + 1, + 10, + 220311, + 220312, + 220313, + 220314, + 220315, + 220316, + 220317, + 220318, + 20121821, + 21121651, + 22091511, + 22091512, + 22091513, + 22091514, + 22091515, + 22091516, + 22091517, + 22091518, + 22091519, + ]: + self.data.static.toggle_game_event(self.version, event_id, toggle=False) + self.logger.info(f"Disabled event {event_id}...") + def read_music(self, base_dir: str) -> None: self.logger.info(f"Reading music from {base_dir}...") diff --git a/titles/mai2/schema/item.py b/titles/mai2/schema/item.py index d64d954..6280bbb 100644 --- a/titles/mai2/schema/item.py +++ b/titles/mai2/schema/item.py @@ -71,12 +71,12 @@ map = Table( ForeignKey("aime_user.id", ondelete="cascade", onupdate="cascade"), nullable=False, ), - Column("map_id", Integer, nullable=False), + Column("mapId", Integer, nullable=False), Column("distance", Integer, nullable=False), - Column("is_lock", Boolean, nullable=False, server_default="0"), - Column("is_clear", Boolean, nullable=False, server_default="0"), - Column("is_complete", Boolean, nullable=False, server_default="0"), - UniqueConstraint("user", "map_id", name="mai2_item_map_uk"), + Column("isLock", Boolean, nullable=False, server_default="0"), + Column("isClear", Boolean, nullable=False, server_default="0"), + Column("isComplete", Boolean, nullable=False, server_default="0"), + UniqueConstraint("user", "mapId", name="mai2_item_map_uk"), mysql_charset="utf8mb4", ) @@ -89,11 +89,11 @@ login_bonus = Table( ForeignKey("aime_user.id", ondelete="cascade", onupdate="cascade"), nullable=False, ), - Column("bonus_id", Integer, nullable=False), + Column("bonusId", Integer, nullable=False), Column("point", Integer, nullable=False), - Column("is_current", Boolean, nullable=False, server_default="0"), - Column("is_complete", Boolean, nullable=False, server_default="0"), - UniqueConstraint("user", "bonus_id", name="mai2_item_login_bonus_uk"), + Column("isCurrent", Boolean, nullable=False, server_default="0"), + Column("isComplete", Boolean, nullable=False, server_default="0"), + UniqueConstraint("user", "bonusId", name="mai2_item_login_bonus_uk"), mysql_charset="utf8mb4", ) @@ -106,13 +106,15 @@ friend_season_ranking = Table( ForeignKey("aime_user.id", ondelete="cascade", onupdate="cascade"), nullable=False, ), - Column("season_id", Integer, nullable=False), + Column("seasonId", Integer, nullable=False), Column("point", Integer, nullable=False), Column("rank", Integer, nullable=False), - Column("reward_get", Boolean, nullable=False), - Column("user_name", String(8), nullable=False), - Column("record_date", String(255), nullable=False), - UniqueConstraint("user", "season_id", "user_name", name="mai2_item_login_bonus_uk"), + Column("rewardGet", Boolean, nullable=False), + Column("userName", String(8), nullable=False), + Column("recordDate", TIMESTAMP, nullable=False), + UniqueConstraint( + "user", "seasonId", "userName", name="mai2_item_friend_season_ranking_uk" + ), mysql_charset="utf8mb4", ) @@ -244,16 +246,16 @@ class Mai2ItemData(BaseData): ) -> None: sql = insert(login_bonus).values( user=user_id, - bonus_id=bonus_id, + bonusId=bonus_id, point=point, - is_current=is_current, - is_complete=is_complete, + isCurrent=is_current, + isComplete=is_complete, ) conflict = sql.on_duplicate_key_update( point=point, - is_current=is_current, - is_complete=is_complete, + isCurrent=is_current, + isComplete=is_complete, ) result = self.execute(conflict) @@ -293,18 +295,18 @@ class Mai2ItemData(BaseData): ) -> None: sql = insert(map).values( user=user_id, - map_id=map_id, + mapId=map_id, distance=distance, - is_lock=is_lock, - is_clear=is_clear, - is_complete=is_complete, + isLock=is_lock, + isClear=is_clear, + isComplete=is_complete, ) conflict = sql.on_duplicate_key_update( distance=distance, - is_lock=is_lock, - is_clear=is_clear, - is_complete=is_complete, + isLock=is_lock, + isClear=is_clear, + isComplete=is_complete, ) result = self.execute(conflict) @@ -324,7 +326,7 @@ class Mai2ItemData(BaseData): return result.fetchall() def get_map(self, user_id: int, map_id: int) -> Optional[Row]: - sql = map.select(and_(map.c.user == user_id, map.c.map_id == map_id)) + sql = map.select(and_(map.c.user == user_id, map.c.mapId == map_id)) result = self.execute(sql) if result is None: @@ -341,16 +343,16 @@ class Mai2ItemData(BaseData): ) -> None: sql = insert(character).values( user=user_id, - character_id=character_id, + characterId=character_id, level=level, awakening=awakening, - use_count=use_count, + useCount=use_count, ) conflict = sql.on_duplicate_key_update( level=level, awakening=awakening, - use_count=use_count, + useCount=use_count, ) result = self.execute(conflict) @@ -385,7 +387,25 @@ class Mai2ItemData(BaseData): result = self.execute(sql) if result is None: return None - return result.fetchone() + return result.fetchall() + + def put_friend_season_ranking( + self, aime_id: int, friend_season_ranking_data: Dict + ) -> Optional[int]: + sql = insert(friend_season_ranking).values( + user=aime_id, **friend_season_ranking_data + ) + + conflict = sql.on_duplicate_key_update(**friend_season_ranking_data) + result = self.execute(conflict) + + if result is None: + self.logger.warn( + f"put_friend_season_ranking: failed to insert", + f"friend_season_ranking! aime_id: {aime_id}", + ) + return None + return result.lastrowid def put_favorite( self, user_id: int, kind: int, item_id_list: List[int] @@ -438,7 +458,7 @@ class Mai2ItemData(BaseData): result = self.execute(conflict) if result is None: self.logger.warn( - f"put_card: failed to insert card! user_id: {user_id}, kind: {kind}" + f"put_card: failed to insert card! user_id: {user_id}, kind: {card_kind}" ) return None return result.lastrowid diff --git a/titles/mai2/schema/profile.py b/titles/mai2/schema/profile.py index 1ce8046..3cb42d1 100644 --- a/titles/mai2/schema/profile.py +++ b/titles/mai2/schema/profile.py @@ -158,6 +158,7 @@ extend = Table( Column("sortMusicSetting", Integer), Column("selectedCardList", JSON), Column("encountMapNpcList", JSON), + Column("playStatusSetting", Integer, server_default="0"), UniqueConstraint("user", "version", name="mai2_profile_extend_uk"), mysql_charset="utf8mb4", ) @@ -178,6 +179,7 @@ option = Table( Column("slideSpeed", Integer), Column("touchSpeed", Integer), Column("tapDesign", Integer), + Column("tapSe", Integer, server_default="0"), Column("holdDesign", Integer), Column("slideDesign", Integer), Column("starType", Integer), @@ -297,8 +299,10 @@ class Mai2ProfileData(BaseData): return result.lastrowid def get_profile_detail(self, user_id: int, version: int) -> Optional[Row]: - sql = select(detail).where( - and_(detail.c.user == user_id, detail.c.version == version) + sql = ( + select(detail) + .where(and_(detail.c.user == user_id, detail.c.version <= version)) + .order_by(detail.c.version.desc()) ) result = self.execute(sql) @@ -322,8 +326,10 @@ class Mai2ProfileData(BaseData): return result.lastrowid def get_profile_ghost(self, user_id: int, version: int) -> Optional[Row]: - sql = select(ghost).where( - and_(ghost.c.user == user_id, ghost.c.version_int == version) + sql = ( + select(ghost) + .where(and_(ghost.c.user == user_id, ghost.c.version_int <= version)) + .order_by(ghost.c.version.desc()) ) result = self.execute(sql) @@ -347,8 +353,10 @@ class Mai2ProfileData(BaseData): return result.lastrowid def get_profile_extend(self, user_id: int, version: int) -> Optional[Row]: - sql = select(extend).where( - and_(extend.c.user == user_id, extend.c.version == version) + sql = ( + select(extend) + .where(and_(extend.c.user == user_id, extend.c.version <= version)) + .order_by(extend.c.version.desc()) ) result = self.execute(sql) @@ -372,8 +380,10 @@ class Mai2ProfileData(BaseData): return result.lastrowid def get_profile_option(self, user_id: int, version: int) -> Optional[Row]: - sql = select(option).where( - and_(option.c.user == user_id, option.c.version == version) + sql = ( + select(option) + .where(and_(option.c.user == user_id, option.c.version <= version)) + .order_by(option.c.version.desc()) ) result = self.execute(sql) @@ -397,8 +407,10 @@ class Mai2ProfileData(BaseData): return result.lastrowid def get_profile_rating(self, user_id: int, version: int) -> Optional[Row]: - sql = select(rating).where( - and_(rating.c.user == user_id, rating.c.version == version) + sql = ( + select(rating) + .where(and_(rating.c.user == user_id, rating.c.version <= version)) + .order_by(rating.c.version.desc()) ) result = self.execute(sql) diff --git a/titles/mai2/schema/score.py b/titles/mai2/schema/score.py index 4d3291d..0f7f239 100644 --- a/titles/mai2/schema/score.py +++ b/titles/mai2/schema/score.py @@ -25,6 +25,7 @@ best_score = Table( Column("syncStatus", Integer), Column("deluxscoreMax", Integer), Column("scoreRank", Integer), + Column("extNum1", Integer, server_default="0"), UniqueConstraint("user", "musicId", "level", name="mai2_score_best_uk"), mysql_charset="utf8mb4", ) @@ -143,6 +144,7 @@ playlog = Table( Column("isNewFree", Boolean), Column("extNum1", Integer), Column("extNum2", Integer), + Column("extNum4", Integer, server_default="0"), Column("trialPlayAchievement", Integer), mysql_charset="utf8mb4", ) diff --git a/titles/mai2/schema/static.py b/titles/mai2/schema/static.py index e40e37f..76b163c 100644 --- a/titles/mai2/schema/static.py +++ b/titles/mai2/schema/static.py @@ -16,6 +16,7 @@ event = 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", "type", name="mai2_static_event_uk"), mysql_charset="utf8mb4", @@ -108,17 +109,17 @@ class Mai2StaticData(BaseData): return None return result.fetchall() - def toggle_game_events( + def toggle_game_event( self, version: int, event_id: int, toggle: bool ) -> Optional[List]: sql = event.update( - and_(event.c.version == version, event.c.event_id == event_id) + and_(event.c.version == version, event.c.eventId == event_id) ).values(enabled=int(toggle)) result = self.execute(sql) if result is None: self.logger.warning( - f"toggle_game_events: Failed to update event! event_id {event_id} toggle {toggle}" + f"toggle_game_event: Failed to update event! event_id {event_id} toggle {toggle}" ) return result.last_updated_params() diff --git a/titles/mai2/universeplus.py b/titles/mai2/universeplus.py index 54fe896..e45c719 100644 --- a/titles/mai2/universeplus.py +++ b/titles/mai2/universeplus.py @@ -1,7 +1,4 @@ -from typing import Any, List, Dict -from datetime import datetime, timedelta -import pytz -import json +from typing import Dict from core.config import CoreConfig from titles.mai2.universe import Mai2Universe diff --git a/titles/ongeki/__init__.py b/titles/ongeki/__init__.py index ddde049..1ba901b 100644 --- a/titles/ongeki/__init__.py +++ b/titles/ongeki/__init__.py @@ -7,4 +7,4 @@ index = OngekiServlet database = OngekiData reader = OngekiReader game_codes = [OngekiConstants.GAME_CODE] -current_schema_version = 2 +current_schema_version = 4 diff --git a/titles/ongeki/base.py b/titles/ongeki/base.py index 10bb1a8..4f7619c 100644 --- a/titles/ongeki/base.py +++ b/titles/ongeki/base.py @@ -452,8 +452,7 @@ class OngekiBase: tmp.pop("id") items.append(tmp) - xout = kind * 10000000000 + \ - (data["nextIndex"] % 10000000000) + len(items) + xout = kind * 10000000000 + (data["nextIndex"] % 10000000000) + len(items) if len(items) < data["maxCount"] or data["maxCount"] == 0: nextIndex = 0 @@ -852,8 +851,7 @@ class OngekiBase: ) if "userOption" in upsert and len(upsert["userOption"]) > 0: - self.data.profile.put_profile_options( - user_id, upsert["userOption"][0]) + self.data.profile.put_profile_options(user_id, upsert["userOption"][0]) if "userPlaylogList" in upsert: for playlog in upsert["userPlaylogList"]: diff --git a/titles/ongeki/bright.py b/titles/ongeki/bright.py index 23eeb6c..06155a1 100644 --- a/titles/ongeki/bright.py +++ b/titles/ongeki/bright.py @@ -97,7 +97,7 @@ class OngekiBright(OngekiBase): "userId": data["userId"], "length": 0, "nextIndex": 0, - "userCharacterList": [] + "userCharacterList": [], } max_ct = data["maxCount"] @@ -548,7 +548,7 @@ class OngekiBright(OngekiBase): "returnCode": 1, "orderId": 0, "serialId": "11111111111111111111", - "apiName": "CMUpsertUserPrintPlaylogApi" + "apiName": "CMUpsertUserPrintPlaylogApi", } def handle_cm_upsert_user_printlog_api_request(self, data: Dict) -> Dict: @@ -556,7 +556,7 @@ class OngekiBright(OngekiBase): "returnCode": 1, "orderId": 0, "serialId": "11111111111111111111", - "apiName": "CMUpsertUserPrintlogApi" + "apiName": "CMUpsertUserPrintlogApi", } def handle_cm_upsert_user_print_api_request(self, data: Dict) -> Dict: diff --git a/titles/ongeki/schema/item.py b/titles/ongeki/schema/item.py index d826fba..27d90f8 100644 --- a/titles/ongeki/schema/item.py +++ b/titles/ongeki/schema/item.py @@ -705,9 +705,7 @@ class OngekiItemData(BaseData): user=aime_id, serialId=serial_id, **user_print_data ) - conflict = sql.on_duplicate_key_update( - user=aime_id, **user_print_data - ) + conflict = sql.on_duplicate_key_update(user=aime_id, **user_print_data) result = self.execute(conflict) if result is None: diff --git a/titles/pokken/__init__.py b/titles/pokken/__init__.py index ed2ee23..94237c4 100644 --- a/titles/pokken/__init__.py +++ b/titles/pokken/__init__.py @@ -1,8 +1,10 @@ -from titles.pokken.index import PokkenServlet -from titles.pokken.const import PokkenConstants -from titles.pokken.database import PokkenData +from .index import PokkenServlet +from .const import PokkenConstants +from .database import PokkenData +from .frontend import PokkenFrontend index = PokkenServlet database = PokkenData game_codes = [PokkenConstants.GAME_CODE] current_schema_version = 1 +frontend = PokkenFrontend diff --git a/titles/pokken/base.py b/titles/pokken/base.py index 1a9d5da..40e6444 100644 --- a/titles/pokken/base.py +++ b/titles/pokken/base.py @@ -5,8 +5,9 @@ import random from core.data import Data from core import CoreConfig -from titles.pokken.config import PokkenConfig -from titles.pokken.proto import jackal_pb2 +from .config import PokkenConfig +from .proto import jackal_pb2 +from .database import PokkenData class PokkenBase: @@ -15,7 +16,8 @@ class PokkenBase: self.game_cfg = game_cfg self.version = 0 self.logger = logging.getLogger("pokken") - self.data = Data(core_cfg) + self.data = PokkenData(core_cfg) + self.SUPPORT_SET_NONE = 4294967295 def handle_noop(self, request: Any) -> bytes: res = jackal_pb2.Response() @@ -72,33 +74,27 @@ class PokkenBase: return res.SerializeToString() - def handle_save_client_log( - self, request: jackal_pb2.Request - ) -> bytes: + def handle_save_client_log(self, request: jackal_pb2.Request) -> bytes: res = jackal_pb2.Response() res.result = 1 res.type = jackal_pb2.MessageType.SAVE_CLIENT_LOG return res.SerializeToString() - def handle_check_diagnosis( - self, request: jackal_pb2.Request - ) -> bytes: + def handle_check_diagnosis(self, request: jackal_pb2.Request) -> bytes: res = jackal_pb2.Response() res.result = 1 res.type = jackal_pb2.MessageType.CHECK_DIAGNOSIS return res.SerializeToString() - def handle_load_client_settings( - self, request: jackal_pb2.Request - ) -> bytes: + def handle_load_client_settings(self, request: jackal_pb2.Request) -> bytes: res = jackal_pb2.Response() res.result = 1 res.type = jackal_pb2.MessageType.LOAD_CLIENT_SETTINGS settings = jackal_pb2.LoadClientSettingsResponseData() - settings.money_magnification = 0 + settings.money_magnification = 1 settings.continue_bonus_exp = 100 settings.continue_fight_money = 100 settings.event_bonus_exp = 100 @@ -127,109 +123,195 @@ class PokkenBase: ranking.modify_date = int(datetime.now().timestamp() / 1000) res.load_ranking.CopyFrom(ranking) return res.SerializeToString() - + def handle_load_user(self, request: jackal_pb2.Request) -> bytes: res = jackal_pb2.Response() res.result = 1 res.type = jackal_pb2.MessageType.LOAD_USER access_code = request.load_user.access_code + load_usr = jackal_pb2.LoadUserResponseData() user_id = self.data.card.get_user_id_from_card(access_code) - if user_id is None: # TODO: Toggle auto-register + if user_id is None and self.game_cfg.server.auto_register: user_id = self.data.user.create_user() card_id = self.data.card.create_card(user_id, access_code) - - self.logger.info(f"Register new card {access_code} (UserId {user_id}, CardId {card_id})") - - # TODO: Check for user data. For now just treat ever card-in as a new user - load_usr = jackal_pb2.LoadUserResponseData() + self.logger.info( + f"Register new card {access_code} (UserId {user_id}, CardId {card_id})" + ) + + elif user_id is None: + self.logger.info(f"Registration of card {access_code} blocked!") + res.load_user.CopyFrom(load_usr) + return res.SerializeToString() + + """ + TODO: Add repeated values + tutorial_progress_flag + rankmatch_progress + support_pokemon_list + support_set_1 + support_set_2 + support_set_3 + aid_skill_list + achievement_flag + pokemon_data + event_achievement_flag + event_achievement_param + """ + profile = self.data.profile.get_profile(user_id) load_usr.commidserv_result = 1 load_usr.load_hash = 1 load_usr.cardlock_status = False load_usr.banapass_id = user_id load_usr.access_code = access_code - load_usr.new_card_flag = True load_usr.precedent_release_flag = 0xFFFFFFFF - load_usr.navi_newbie_flag = True - load_usr.navi_enable_flag = True - load_usr.pad_vibrate_flag = True - load_usr.home_region_code = 0 - load_usr.home_loc_name = "" - load_usr.pref_code = 0 - load_usr.trainer_name = "Newb" + str(random.randint(1111,999999)) - load_usr.trainer_rank_point = 0 - load_usr.wallet = 0 - load_usr.fight_money = 0 - load_usr.score_point = 0 - load_usr.grade_max_num = 0 - load_usr.total_play_days = 0 - load_usr.play_date_time = 0 - load_usr.lucky_box_fail_num = 0 - load_usr.event_reward_get_flag = 0 - load_usr.rank_pvp_all = 0 - load_usr.rank_pvp_loc = 0 - load_usr.rank_cpu_all = 0 - load_usr.rank_cpu_loc = 0 - load_usr.rank_event = 0 - load_usr.awake_num = 0 - load_usr.use_support_num = 0 - load_usr.rankmatch_flag = 0 - load_usr.title_text_id = 0 - load_usr.title_plate_id = 0 - load_usr.title_decoration_id = 0 - load_usr.navi_trainer = 0 - load_usr.navi_version_id = 0 - load_usr.aid_skill = 0 - load_usr.comment_text_id = 0 - load_usr.comment_word_id = 0 - load_usr.latest_use_pokemon = 0 - load_usr.ex_ko_num = 0 - load_usr.wko_num = 0 - load_usr.timeup_win_num = 0 - load_usr.cool_ko_num = 0 - load_usr.perfect_ko_num = 0 - load_usr.record_flag = 0 - load_usr.site_register_status = 0 - load_usr.continue_num = 0 - load_usr.event_state = 0 - load_usr.event_id = 0 - load_usr.sp_bonus_category_id_1 = 0 - load_usr.sp_bonus_key_value_1 = 0 - load_usr.sp_bonus_category_id_2 = 0 - load_usr.sp_bonus_key_value_2 = 0 + + if profile is None: + profile_id = self.data.profile.create_profile(user_id) + profile_dict = {"id": profile_id, "user": user_id} + pokemon_data = [] + tutorial_progress = [] + rankmatch_progress = [] + achievement_flag = [] + event_achievement_flag = [] + event_achievement_param = [] + load_usr.new_card_flag = True + + else: + profile_dict = {k: v for k, v in profile._asdict().items() if v is not None} + self.logger.info( + f"Card-in user {user_id} (Trainer name {profile_dict.get('trainer_name', '')})" + ) + pokemon_data = self.data.profile.get_all_pokemon_data(user_id) + tutorial_progress = [] + rankmatch_progress = [] + achievement_flag = [] + event_achievement_flag = [] + event_achievement_param = [] + load_usr.new_card_flag = False + + load_usr.navi_newbie_flag = profile_dict.get("navi_newbie_flag", True) + load_usr.navi_enable_flag = profile_dict.get("navi_enable_flag", True) + load_usr.pad_vibrate_flag = profile_dict.get("pad_vibrate_flag", True) + load_usr.home_region_code = profile_dict.get("home_region_code", 0) + load_usr.home_loc_name = profile_dict.get("home_loc_name", "") + load_usr.pref_code = profile_dict.get("pref_code", 0) + load_usr.trainer_name = profile_dict.get( + "trainer_name", "Newb" + str(random.randint(1111, 999999)) + ) + load_usr.trainer_rank_point = profile_dict.get("trainer_rank_point", 0) + load_usr.wallet = profile_dict.get("wallet", 0) + load_usr.fight_money = profile_dict.get("fight_money", 0) + load_usr.score_point = profile_dict.get("score_point", 0) + load_usr.grade_max_num = profile_dict.get("grade_max_num", 0) + load_usr.extra_counter = profile_dict.get("extra_counter", 0) + load_usr.total_play_days = profile_dict.get("total_play_days", 0) + load_usr.play_date_time = profile_dict.get("play_date_time", 0) + load_usr.lucky_box_fail_num = profile_dict.get("lucky_box_fail_num", 0) + load_usr.event_reward_get_flag = profile_dict.get("event_reward_get_flag", 0) + load_usr.rank_pvp_all = profile_dict.get("rank_pvp_all", 0) + load_usr.rank_pvp_loc = profile_dict.get("rank_pvp_loc", 0) + load_usr.rank_cpu_all = profile_dict.get("rank_cpu_all", 0) + load_usr.rank_cpu_loc = profile_dict.get("rank_cpu_loc", 0) + load_usr.rank_event = profile_dict.get("rank_event", 0) + load_usr.awake_num = profile_dict.get("awake_num", 0) + load_usr.use_support_num = profile_dict.get("use_support_num", 0) + load_usr.rankmatch_flag = profile_dict.get("rankmatch_flag", 0) + load_usr.rankmatch_max = profile_dict.get("rankmatch_max", 0) + load_usr.rankmatch_success = profile_dict.get("rankmatch_success", 0) + load_usr.beat_num = profile_dict.get("beat_num", 0) + load_usr.title_text_id = profile_dict.get("title_text_id", 0) + load_usr.title_plate_id = profile_dict.get("title_plate_id", 0) + load_usr.title_decoration_id = profile_dict.get("title_decoration_id", 0) + load_usr.navi_trainer = profile_dict.get("navi_trainer", 0) + load_usr.navi_version_id = profile_dict.get("navi_version_id", 0) + load_usr.aid_skill = profile_dict.get("aid_skill", 0) + load_usr.comment_text_id = profile_dict.get("comment_text_id", 0) + load_usr.comment_word_id = profile_dict.get("comment_word_id", 0) + load_usr.latest_use_pokemon = profile_dict.get("latest_use_pokemon", 0) + load_usr.ex_ko_num = profile_dict.get("ex_ko_num", 0) + load_usr.wko_num = profile_dict.get("wko_num", 0) + load_usr.timeup_win_num = profile_dict.get("timeup_win_num", 0) + load_usr.cool_ko_num = profile_dict.get("cool_ko_num", 0) + load_usr.perfect_ko_num = profile_dict.get("perfect_ko_num", 0) + load_usr.record_flag = profile_dict.get("record_flag", 0) + load_usr.site_register_status = profile_dict.get("site_register_status", 0) + load_usr.continue_num = profile_dict.get("continue_num", 0) + + load_usr.avatar_body = profile_dict.get("avatar_body", 0) + load_usr.avatar_gender = profile_dict.get("avatar_gender", 0) + load_usr.avatar_background = profile_dict.get("avatar_background", 0) + load_usr.avatar_head = profile_dict.get("avatar_head", 0) + load_usr.avatar_battleglass = profile_dict.get("avatar_battleglass", 0) + load_usr.avatar_face0 = profile_dict.get("avatar_face0", 0) + load_usr.avatar_face1 = profile_dict.get("avatar_face1", 0) + load_usr.avatar_face2 = profile_dict.get("avatar_face2", 0) + load_usr.avatar_bodyall = profile_dict.get("avatar_bodyall", 0) + load_usr.avatar_wear = profile_dict.get("avatar_wear", 0) + load_usr.avatar_accessory = profile_dict.get("avatar_accessory", 0) + load_usr.avatar_stamp = profile_dict.get("avatar_stamp", 0) + + load_usr.event_state = profile_dict.get("event_state", 0) + load_usr.event_id = profile_dict.get("event_id", 0) + load_usr.sp_bonus_category_id_1 = profile_dict.get("sp_bonus_category_id_1", 0) + load_usr.sp_bonus_key_value_1 = profile_dict.get("sp_bonus_key_value_1", 0) + load_usr.sp_bonus_category_id_2 = profile_dict.get("sp_bonus_category_id_2", 0) + load_usr.sp_bonus_key_value_2 = profile_dict.get("sp_bonus_key_value_2", 0) + load_usr.last_play_event_id = profile_dict.get("last_play_event_id", 0) res.load_user.CopyFrom(load_usr) return res.SerializeToString() - + def handle_set_bnpassid_lock(self, data: jackal_pb2.Request) -> bytes: res = jackal_pb2.Response() res.result = 1 res.type = jackal_pb2.MessageType.SET_BNPASSID_LOCK return res.SerializeToString() + def handle_save_user(self, request: jackal_pb2.Request) -> bytes: + res = jackal_pb2.Response() + res.result = 1 + res.type = jackal_pb2.MessageType.SAVE_USER + + return res.SerializeToString() + def handle_save_ingame_log(self, data: jackal_pb2.Request) -> bytes: res = jackal_pb2.Response() res.result = 1 - res.type = jackal_pb2.MessageType.SET_BNPASSID_LOCK + res.type = jackal_pb2.MessageType.SAVE_INGAME_LOG return res.SerializeToString() - def handle_matching_noop(self, data: Dict = {}, client_ip: str = "127.0.0.1") -> Dict: - return {} - - def handle_matching_start_matching(self, data: Dict = {}, client_ip: str = "127.0.0.1") -> Dict: + def handle_save_charge(self, data: jackal_pb2.Request) -> bytes: + res = jackal_pb2.Response() + res.result = 1 + res.type = jackal_pb2.MessageType.SAVE_CHARGE + return res.SerializeToString() + + def handle_matching_noop( + self, data: Dict = {}, client_ip: str = "127.0.0.1" + ) -> Dict: return {} - def handle_matching_is_matching(self, data: Dict = {}, client_ip: str = "127.0.0.1") -> Dict: + def handle_matching_start_matching( + self, data: Dict = {}, client_ip: str = "127.0.0.1" + ) -> Dict: + return {} + + def handle_matching_is_matching( + 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":[] """ return {} - - def handle_matching_stop_matching(self, data: Dict = {}, client_ip: str = "127.0.0.1") -> Dict: - return {} \ No newline at end of file + + def handle_matching_stop_matching( + self, data: Dict = {}, client_ip: str = "127.0.0.1" + ) -> Dict: + return {} diff --git a/titles/pokken/config.py b/titles/pokken/config.py index b53fc86..84da8d2 100644 --- a/titles/pokken/config.py +++ b/titles/pokken/config.py @@ -49,6 +49,17 @@ class PokkenServerConfig: self.__config, "pokken", "server", "port_admission", default=9003 ) + @property + def auto_register(self) -> bool: + """ + Automatically register users in `aime_user` on first carding in with pokken + if they don't exist already. Set to false to display an error instead. + """ + return CoreConfig.get_config_field( + self.__config, "pokken", "server", "auto_register", default=True + ) + + class PokkenConfig(dict): def __init__(self) -> None: self.server = PokkenServerConfig(self) diff --git a/titles/pokken/const.py b/titles/pokken/const.py index 802a7b9..2eb5357 100644 --- a/titles/pokken/const.py +++ b/titles/pokken/const.py @@ -1,3 +1,6 @@ +from enum import Enum + + class PokkenConstants: GAME_CODE = "SDAK" @@ -7,6 +10,16 @@ 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 + + class BATTLE_RESULT(Enum): + BATTLE_RESULT_WIN = 1 + BATTLE_RESULT_LOSS = 2 + @classmethod def game_ver_to_string(cls, ver: int): return cls.VERSION_NAMES[ver] diff --git a/titles/pokken/database.py b/titles/pokken/database.py index f77f172..272cfd8 100644 --- a/titles/pokken/database.py +++ b/titles/pokken/database.py @@ -1,7 +1,14 @@ from core.data import Data from core.config import CoreConfig +from .schema import * + class PokkenData(Data): def __init__(self, cfg: CoreConfig) -> None: super().__init__(cfg) + + self.profile = PokkenProfileData(cfg, self.session) + self.match = PokkenMatchData(cfg, self.session) + self.item = PokkenItemData(cfg, self.session) + self.static = PokkenStaticData(cfg, self.session) diff --git a/titles/pokken/frontend.py b/titles/pokken/frontend.py new file mode 100644 index 0000000..e4e8947 --- /dev/null +++ b/titles/pokken/frontend.py @@ -0,0 +1,33 @@ +import yaml +import jinja2 +from twisted.web.http import Request +from os import path + +from core.frontend import FE_Base +from core.config import CoreConfig +from .database import PokkenData +from .config import PokkenConfig +from .const import PokkenConstants + + +class PokkenFrontend(FE_Base): + def __init__( + self, cfg: CoreConfig, environment: jinja2.Environment, cfg_dir: str + ) -> None: + super().__init__(cfg, environment) + self.data = PokkenData(cfg) + self.game_cfg = PokkenConfig() + if path.exists(f"{cfg_dir}/{PokkenConstants.CONFIG_NAME}"): + self.game_cfg.update( + yaml.safe_load(open(f"{cfg_dir}/{PokkenConstants.CONFIG_NAME}")) + ) + self.nav_name = "Pokken" + + def render_GET(self, request: Request) -> bytes: + template = self.environment.get_template( + "titles/pokken/frontend/pokken_index.jinja" + ) + return template.render( + title=f"{self.core_config.server.name} | {self.nav_name}", + game_list=self.environment.globals["game_list"], + ).encode("utf-16") diff --git a/titles/pokken/frontend/pokken_index.jinja b/titles/pokken/frontend/pokken_index.jinja new file mode 100644 index 0000000..446893a --- /dev/null +++ b/titles/pokken/frontend/pokken_index.jinja @@ -0,0 +1,4 @@ +{% extends "core/frontend/index.jinja" %} +{% block content %} +

Pokken

+{% endblock content %} \ No newline at end of file diff --git a/titles/pokken/index.py b/titles/pokken/index.py index a7a328f..bccdcaf 100644 --- a/titles/pokken/index.py +++ b/titles/pokken/index.py @@ -87,7 +87,7 @@ class PokkenServlet(resource.Resource): if not game_cfg.server.enable: return (False, "") - return (True, "PKF2") + return (True, "PKF1") def setup(self) -> None: # TODO: Setup stun, turn (UDP) and admission (WSS) servers @@ -115,17 +115,18 @@ class PokkenServlet(resource.Resource): pokken_request.type ].name.lower() + self.logger.debug(pokken_request) + handler = getattr(self.base, f"handle_{endpoint}", None) if handler is None: self.logger.warn(f"No handler found for message type {endpoint}") return self.base.handle_noop(pokken_request) - + self.logger.info(f"{endpoint} request from {Utils.get_ip_addr(request)}") - self.logger.debug(pokken_request) - + ret = handler(pokken_request) return ret - + def handle_matching(self, request: Request) -> bytes: content = request.content.getvalue() client_ip = Utils.get_ip_addr(request) @@ -134,26 +135,37 @@ class PokkenServlet(resource.Resource): self.logger.info("Empty matching request") return json.dumps(self.base.handle_matching_noop()).encode() - json_content = ast.literal_eval(content.decode().replace('null', 'None').replace('true', 'True').replace('false', 'False')) + json_content = ast.literal_eval( + content.decode() + .replace("null", "None") + .replace("true", "True") + .replace("false", "False") + ) self.logger.info(f"Matching {json_content['call']} request") self.logger.debug(json_content) - handler = getattr(self.base, f"handle_matching_{inflection.underscore(json_content['call'])}", None) + handler = getattr( + self.base, + f"handle_matching_{inflection.underscore(json_content['call'])}", + None, + ) if handler is None: - self.logger.warn(f"No handler found for message type {json_content['call']}") + self.logger.warn( + f"No handler found for message type {json_content['call']}" + ) return json.dumps(self.base.handle_matching_noop()).encode() - + ret = handler(json_content, client_ip) - + if ret is None: - ret = {} + ret = {} if "result" not in ret: ret["result"] = "true" if "data" not in ret: ret["data"] = {} if "timestamp" not in ret: ret["timestamp"] = int(datetime.now().timestamp() * 1000) - + self.logger.debug(f"Response {ret}") return json.dumps(ret).encode() diff --git a/titles/pokken/schema/__init__.py b/titles/pokken/schema/__init__.py new file mode 100644 index 0000000..81b8132 --- /dev/null +++ b/titles/pokken/schema/__init__.py @@ -0,0 +1,4 @@ +from .profile import PokkenProfileData +from .match import PokkenMatchData +from .item import PokkenItemData +from .static import PokkenStaticData diff --git a/titles/pokken/schema/item.py b/titles/pokken/schema/item.py new file mode 100644 index 0000000..4919ea0 --- /dev/null +++ b/titles/pokken/schema/item.py @@ -0,0 +1,34 @@ +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 + +item = Table( + "pokken_item", + metadata, + Column("id", Integer, primary_key=True, nullable=False), + Column( + "user", + ForeignKey("aime_user.id", ondelete="cascade", onupdate="cascade"), + nullable=False, + unique=True, + ), + Column("category", Integer), + Column("content", Integer), + Column("type", Integer), + UniqueConstraint("user", "category", "content", "type", name="pokken_item_uk"), + mysql_charset="utf8mb4", +) + + +class PokkenItemData(BaseData): + """ + Items obtained as rewards + """ + + pass diff --git a/titles/pokken/schema/match.py b/titles/pokken/schema/match.py new file mode 100644 index 0000000..c84ec63 --- /dev/null +++ b/titles/pokken/schema/match.py @@ -0,0 +1,52 @@ +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 + +# Pokken sends depressingly little match data... +match_data = Table( + "pokken_match_data", + metadata, + Column("id", Integer, primary_key=True, nullable=False), + Column( + "user", + ForeignKey("aime_user.id", ondelete="cascade", onupdate="cascade"), + nullable=False, + ), + Column("num_games", Integer), + Column("play_modes", JSON), + Column("results", JSON), + Column("ex_ko_num", Integer), + Column("wko_num", Integer), + Column("timeup_win_num", Integer), + Column("cool_ko_num", Integer), + Column("perfect_ko_num", Integer), + Column("use_navi", Integer), + Column("use_navi_cloth", Integer), + Column("use_aid_skill", Integer), + Column("play_date", TIMESTAMP), + mysql_charset="utf8mb4", +) + + +class PokkenMatchData(BaseData): + """ + Match logs + """ + + def save_match(self, user_id: int, match_data: Dict) -> Optional[int]: + pass + + def get_match(self, match_id: int) -> Optional[Row]: + pass + + def get_matches_by_user(self, user_id: int) -> Optional[List[Row]]: + pass + + def get_matches(self, limit: int = 20) -> Optional[List[Row]]: + pass diff --git a/titles/pokken/schema/profile.py b/titles/pokken/schema/profile.py new file mode 100644 index 0000000..8e536f1 --- /dev/null +++ b/titles/pokken/schema/profile.py @@ -0,0 +1,217 @@ +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 PokkenConstants + +# Some more of the repeated fields could probably be their own tables, for now I just did the ones that made sense to me +# Having the profile table be this massive kinda blows for updates but w/e, **kwargs to the rescue +profile = Table( + "pokken_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("trainer_name", String(16)), # optional + Column("home_region_code", Integer), + Column("home_loc_name", String(255)), + Column("pref_code", Integer), + Column("navi_newbie_flag", Boolean), + Column("navi_enable_flag", Boolean), + Column("pad_vibrate_flag", Boolean), + Column("trainer_rank_point", Integer), + Column("wallet", Integer), + Column("fight_money", Integer), + Column("score_point", Integer), + Column("grade_max_num", Integer), + Column("extra_counter", Integer), # Optional + Column("tutorial_progress_flag", JSON), # Repeated, Integer + Column("total_play_days", Integer), + Column("play_date_time", Integer), + Column("achievement_flag", JSON), # Repeated, Integer + Column("lucky_box_fail_num", Integer), + Column("event_reward_get_flag", Integer), + Column("rank_pvp_all", Integer), + Column("rank_pvp_loc", Integer), + Column("rank_cpu_all", Integer), + Column("rank_cpu_loc", Integer), + Column("rank_event", Integer), + Column("awake_num", Integer), + Column("use_support_num", Integer), + Column("rankmatch_flag", Integer), + Column("rankmatch_max", Integer), # Optional + Column("rankmatch_progress", JSON), # Repeated, Integer + Column("rankmatch_success", Integer), # Optional + Column("beat_num", Integer), # Optional + Column("title_text_id", Integer), + Column("title_plate_id", Integer), + Column("title_decoration_id", Integer), + Column("support_pokemon_list", JSON), # Repeated, Integer + Column("support_set_1_1", Integer), # Repeated, Integer + Column("support_set_1_2", Integer), + Column("support_set_2_1", Integer), # Repeated, Integer + Column("support_set_2_2", Integer), + Column("support_set_3_1", Integer), # Repeated, Integer + Column("support_set_3_2", Integer), + Column("navi_trainer", Integer), + Column("navi_version_id", Integer), + Column("aid_skill_list", JSON), # Repeated, Integer + Column("aid_skill", Integer), + Column("comment_text_id", Integer), + Column("comment_word_id", Integer), + Column("latest_use_pokemon", Integer), + Column("ex_ko_num", Integer), + Column("wko_num", Integer), + Column("timeup_win_num", Integer), + Column("cool_ko_num", Integer), + Column("perfect_ko_num", Integer), + Column("record_flag", Integer), + Column("continue_num", Integer), + Column("avatar_body", Integer), # Optional + Column("avatar_gender", Integer), # Optional + Column("avatar_background", Integer), # Optional + Column("avatar_head", Integer), # Optional + Column("avatar_battleglass", Integer), # Optional + Column("avatar_face0", Integer), # Optional + Column("avatar_face1", Integer), # Optional + Column("avatar_face2", Integer), # Optional + Column("avatar_bodyall", Integer), # Optional + Column("avatar_wear", Integer), # Optional + Column("avatar_accessory", Integer), # Optional + Column("avatar_stamp", Integer), # Optional + Column("event_state", Integer), + Column("event_id", Integer), + Column("sp_bonus_category_id_1", Integer), + Column("sp_bonus_key_value_1", Integer), + Column("sp_bonus_category_id_2", Integer), + Column("sp_bonus_key_value_2", Integer), + Column("last_play_event_id", Integer), # Optional + Column("event_achievement_flag", JSON), # Repeated, Integer + Column("event_achievement_param", JSON), # Repeated, Integer + Column("battle_num_vs_wan", Integer), # 4? + Column("win_vs_wan", Integer), + Column("battle_num_vs_lan", Integer), # 3? + Column("win_vs_lan", Integer), + Column("battle_num_vs_cpu", Integer), # 2 + Column("win_cpu", Integer), + Column("battle_num_tutorial", Integer), # 1? + mysql_charset="utf8mb4", +) + +pokemon_data = Table( + "pokken_pokemon_data", + metadata, + Column("id", Integer, primary_key=True, nullable=False), + Column( + "user", + ForeignKey("aime_user.id", ondelete="cascade", onupdate="cascade"), + nullable=False, + ), + Column("char_id", Integer, nullable=False), + Column("illustration_book_no", Integer), + Column("pokemon_exp", Integer), + Column("battle_num_vs_wan", Integer), # 4? + Column("win_vs_wan", Integer), + Column("battle_num_vs_lan", Integer), # 3? + Column("win_vs_lan", Integer), + Column("battle_num_vs_cpu", Integer), # 2 + Column("win_cpu", Integer), + Column("battle_all_num_tutorial", Integer), + Column("battle_num_tutorial", Integer), # 1? + Column("bp_point_atk", Integer), + Column("bp_point_res", Integer), + Column("bp_point_def", Integer), + Column("bp_point_sp", Integer), + UniqueConstraint("user", "char_id", name="pokken_pokemon_data_uk"), + mysql_charset="utf8mb4", +) + + +class PokkenProfileData(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 pokken profile for user {user_id}!") + 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) + ) + result = self.execute(sql) + if result is None: + self.logger.error( + 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 add_profile_points( + self, user_id: int, rank_pts: int, money: int, score_pts: int + ) -> None: + pass + + 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() + + def put_pokemon_data( + self, + user_id: int, + pokemon_id: int, + illust_no: int, + get_exp: int, + atk: int, + res: int, + defe: int, + sp: int, + ) -> Optional[int]: + pass + + def get_pokemon_data(self, user_id: int, pokemon_id: int) -> Optional[Row]: + pass + + 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 + ) -> None: + """ + Records the match stats (type and win/loss) for the pokemon and profile + """ + pass + + def put_stats( + self, + user_id: int, + exkos: int, + wkos: int, + timeout_wins: int, + cool_kos: int, + perfects: int, + continues: int, + ) -> None: + """ + Records profile stats + """ + pass diff --git a/titles/pokken/schema/static.py b/titles/pokken/schema/static.py new file mode 100644 index 0000000..121ebc4 --- /dev/null +++ b/titles/pokken/schema/static.py @@ -0,0 +1,13 @@ +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 + + +class PokkenStaticData(BaseData): + pass diff --git a/titles/wacca/__init__.py b/titles/wacca/__init__.py index b6e06f8..a3bf96b 100644 --- a/titles/wacca/__init__.py +++ b/titles/wacca/__init__.py @@ -9,4 +9,4 @@ database = WaccaData reader = WaccaReader frontend = WaccaFrontend game_codes = [WaccaConstants.GAME_CODE] -current_schema_version = 3 +current_schema_version = 5 diff --git a/titles/wacca/base.py b/titles/wacca/base.py index 020c167..ada40c6 100644 --- a/titles/wacca/base.py +++ b/titles/wacca/base.py @@ -183,8 +183,6 @@ class WaccaBase: def handle_user_status_login_request(self, data: Dict) -> Dict: req = UserStatusLoginRequest(data) resp = UserStatusLoginResponseV1() - is_new_day = False - is_consec_day = False is_consec_day = True if req.userId == 0: @@ -202,29 +200,29 @@ class WaccaBase: self.logger.info(f"User {req.userId} login on {req.chipId}") last_login_time = int(profile["last_login_date"].timestamp()) resp.lastLoginDate = last_login_time - - # If somebodies login timestamp < midnight of current day, then they are logging in for the first time today - if last_login_time < int( + midnight_today_ts = int( datetime.now() .replace(hour=0, minute=0, second=0, microsecond=0) .timestamp() - ): - is_new_day = True - is_consec_day = True + ) - # If somebodies login timestamp > midnight of current day + 1 day, then they broke their daily login streak - elif last_login_time > int( - ( - datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) - + timedelta(days=1) - ).timestamp() - ): + # If somebodies login timestamp < midnight of current day, then they are logging in for the first time today + if last_login_time < midnight_today_ts: + resp.firstLoginDaily = True + + # If the difference between midnight today and their last login is greater then 1 day (86400 seconds) they've broken their streak + if midnight_today_ts - last_login_time > 86400: is_consec_day = False - # else, they are simply logging in again on the same day, and we don't need to do anything for that - self.data.profile.session_login(req.userId, is_new_day, is_consec_day) + self.data.profile.session_login( + req.userId, resp.firstLoginDaily, is_consec_day + ) - resp.firstLoginDaily = int(is_new_day) + if resp.firstLoginDaily: + # TODO: Daily bonus + pass + + # TODO: VIP dialy/monthly rewards return resp.make() @@ -275,9 +273,6 @@ class WaccaBase: self.data.item.put_item( req.aimeId, WaccaConstants.ITEM_TYPES["touch_effect"], 312001 ) # Added reverse - self.data.item.put_item( - req.aimeId, WaccaConstants.ITEM_TYPES["touch_effect"], 312002 - ) # Added reverse return UserStatusCreateResponseV2(profileId, req.username).make() @@ -635,16 +630,25 @@ class WaccaBase: new_tickets.append([ticket["id"], ticket["ticket_id"], 9999999999]) for item in req.itemsUsed: - if item.itemType == WaccaConstants.ITEM_TYPES["wp"] and not self.game_config.mods.infinite_wp: + if ( + item.itemType == WaccaConstants.ITEM_TYPES["wp"] + and not self.game_config.mods.infinite_wp + ): if current_wp >= item.quantity: current_wp -= item.quantity self.data.profile.spend_wp(req.profileId, item.quantity) else: return BaseResponse().make() - elif item.itemType == WaccaConstants.ITEM_TYPES["ticket"] and not self.game_config.mods.infinite_tickets: + elif ( + item.itemType == WaccaConstants.ITEM_TYPES["ticket"] + and not self.game_config.mods.infinite_tickets + ): for x in range(len(new_tickets)): if new_tickets[x][1] == item.itemId: + self.logger.debug( + f"Remove ticket ID {new_tickets[x][0]} type {new_tickets[x][1]} from {user_id}" + ) self.data.item.spend_ticket(new_tickets[x][0]) new_tickets.pop(x) break @@ -668,13 +672,8 @@ class WaccaBase: ) if self.game_config.mods.infinite_tickets: - new_tickets = [ - [0, 106002, 0], - [1, 106002, 0], - [2, 106002, 0], - [3, 106002, 0], - [4, 106002, 0], - ] + for x in range(5): + new_tickets.append(TicketItem(x, 106002, 0)) if self.game_config.mods.infinite_wp: current_wp = 999999 @@ -836,7 +835,7 @@ class WaccaBase: resp.songDetail.grades = SongDetailGradeCountsV2(counts=grades) else: resp.songDetail.grades = SongDetailGradeCountsV1(counts=grades) - resp.songDetail.lock_state = 1 + resp.songDetail.lockState = 1 return resp.make() # TODO: Coop and vs data @@ -880,7 +879,10 @@ class WaccaBase: user_id = profile["user"] resp.currentWp = profile["wp"] - if req.purchaseType == PurchaseType.PurchaseTypeWP and not self.game_config.mods.infinite_wp: + if ( + req.purchaseType == PurchaseType.PurchaseTypeWP + and not self.game_config.mods.infinite_wp + ): resp.currentWp -= req.cost self.data.profile.spend_wp(req.profileId, req.cost) @@ -982,7 +984,7 @@ class WaccaBase: user_id = self.data.profile.profile_to_aime_user(req.profileId) for opt in req.optsUpdated: - self.data.profile.update_option(user_id, opt.opt_id, opt.opt_val) + self.data.profile.update_option(user_id, opt.optId, opt.optVal) for update in req.datesUpdated: pass @@ -1070,11 +1072,18 @@ class WaccaBase: ): if item.quantity > WaccaConstants.Difficulty.HARD.value: old_score = self.data.score.get_best_score( - user_id, item.itemId, item.quantity - ) + 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 + user_id, + item.itemId, + item.quantity, + 0, + [0] * 5, + [0] * 13, + 0, + 0, ) if item.quantity == 0: diff --git a/titles/wacca/handlers/helpers.py b/titles/wacca/handlers/helpers.py index f86be2f..025c161 100644 --- a/titles/wacca/handlers/helpers.py +++ b/titles/wacca/handlers/helpers.py @@ -158,11 +158,11 @@ class Notice: class UserOption: def __init__(self, opt_id: int = 0, opt_val: Any = 0) -> None: - self.opt_id = opt_id - self.opt_val = opt_val + self.optId = opt_id + self.optVal = opt_val def make(self) -> List: - return [self.opt_id, self.opt_val] + return [self.optId, self.optVal] class UserStatusV1: @@ -348,13 +348,35 @@ class NavigatorItem(IconItem): class SkillItem: - skill_type: int + skillType: int level: int flag: int badge: int def make(self) -> List: - return [self.skill_type, self.level, self.flag, self.badge] + return [self.skillType, self.level, self.flag, self.badge] + + +class UserEventInfo: + def __init__(self) -> None: + self.eventId = 0 + self.conditionInfo: List[UserEventConditionInfo] = [] + + def make(self) -> List: + conditions = [] + for x in self.conditionInfo: + conditions.append(x.make()) + + return [self.eventId, conditions] + + +class UserEventConditionInfo: + def __init__(self) -> None: + self.achievementCondition = 0 + self.progress = 0 + + def make(self) -> List: + return [self.achievementCondition, self.progress] class UserItemInfoV1: @@ -447,19 +469,19 @@ class UserItemInfoV3(UserItemInfoV2): class SongDetailClearCounts: def __init__( self, - play_ct: int = 0, - clear_ct: int = 0, - ml_ct: int = 0, - fc_ct: int = 0, - am_ct: int = 0, + playCt: int = 0, + clearCt: int = 0, + mlCt: int = 0, + fcCt: int = 0, + amCt: int = 0, counts: Optional[List[int]] = None, ) -> None: if counts is None: - self.playCt = play_ct - self.clearCt = clear_ct - self.misslessCt = ml_ct - self.fullComboCt = fc_ct - self.allMarvelousCt = am_ct + self.playCt = playCt + self.clearCt = clearCt + self.misslessCt = mlCt + self.fullComboCt = fcCt + self.allMarvelousCt = amCt else: self.playCt = counts[0] @@ -773,8 +795,12 @@ class GateDetailV2(GateDetailV1): class GachaInfo: + def __init__(self, gacha_id: int = 0, gacha_roll_ct: int = 0) -> None: + self.gachaId = gacha_id + self.rollCt = gacha_roll_ct + def make(self) -> List: - return [] + return [self.gachaId, self.rollCt] class LastSongDetail: @@ -808,9 +834,32 @@ class LastSongDetail: ] -class FriendDetail: +class FriendScoreDetail: + def __init__( + self, song_id: int = 0, difficulty: int = 1, best_score: int = 0 + ) -> None: + self.songId = song_id + self.difficulty = difficulty + self.bestScore = best_score + def make(self) -> List: - return [] + return [self.songId, self.difficulty, self.bestScore] + + +class FriendDetail: + def __init__(self, user_id: int = 0, username: str = "") -> None: + self.friendId = user_id + self.friendUsername = username + self.friendUserType = 1 + self.friendScoreDetail: List[FriendScoreDetail] = [] + + def make(self) -> List: + scores = [] + + for x in self.friendScoreDetail: + scores.append(x.make()) + + return [self.friendId, self.friendUsername, self.friendUserType, scores] class LoginBonusInfo: @@ -894,6 +943,7 @@ class PlayType(Enum): PlayTypeVs = 2 PlayTypeCoop = 3 PlayTypeStageup = 4 + PlayTypeTimeFree = 5 class StageInfo: @@ -942,7 +992,7 @@ class MusicUpdateDetailV1: self.score = 0 self.lowestMissCount = 0 self.maxSkillPts = 0 - self.lock_state = 0 + self.lockState = 0 def make(self) -> List: return [ @@ -954,7 +1004,7 @@ class MusicUpdateDetailV1: self.score, self.lowestMissCount, self.maxSkillPts, - self.lock_state, + self.lockState, ] diff --git a/titles/wacca/handlers/housing.py b/titles/wacca/handlers/housing.py index 8ffa910..f2f079e 100644 --- a/titles/wacca/handlers/housing.py +++ b/titles/wacca/handlers/housing.py @@ -10,10 +10,10 @@ class HousingGetResponse(BaseResponse): def __init__(self, housingId: int) -> None: super().__init__() self.housingId: int = housingId - self.regionId: int = 0 + self.isNewCab: bool = False def make(self) -> Dict: - self.params = [self.housingId, self.regionId] + self.params = [self.housingId, int(self.isNewCab)] return super().make() @@ -32,8 +32,6 @@ class HousingStartRequestV1(BaseRequest): class HousingStartRequestV2(HousingStartRequestV1): def __init__(self, data: Dict) -> None: super(HousingStartRequestV1, self).__init__(data) - self.unknown0: str = self.params[0] - self.errorLog: str = self.params[1] self.creditLog: str = self.params[2] self.info: List[HousingInfo] = [] diff --git a/titles/wacca/handlers/user_info.py b/titles/wacca/handlers/user_info.py index bf6b74b..b70ac35 100644 --- a/titles/wacca/handlers/user_info.py +++ b/titles/wacca/handlers/user_info.py @@ -11,7 +11,7 @@ class UserInfoUpdateRequest(BaseRequest): self.profileId = int(self.params[0]) self.optsUpdated: List[UserOption] = [] self.unknown2: List = self.params[2] - self.datesUpdated: List[DateUpdate] = [] + self.datesUpdated: List[DateUpdate] = [] self.favoritesRemoved: List[int] = self.params[4] self.favoritesAdded: List[int] = self.params[5] diff --git a/titles/wacca/handlers/user_status.py b/titles/wacca/handlers/user_status.py index 0e3819d..6eef16a 100644 --- a/titles/wacca/handlers/user_status.py +++ b/titles/wacca/handlers/user_status.py @@ -39,12 +39,16 @@ class UserStatusGetV2Response(UserStatusGetV1Response): def __init__(self) -> None: super().__init__() self.userStatus: UserStatusV2 = UserStatusV2() - self.unknownArr: List = [] + self.options: List[UserOption] = [] def make(self) -> Dict: super().make() + opts = [] - self.params.append(self.unknownArr) + for x in self.options: + opts.append(x.make()) + + self.params.append(opts) return super(UserStatusGetV1Response, self).make() @@ -137,7 +141,7 @@ class UserStatusGetDetailResponseV2(UserStatusGetDetailResponseV1): self.userItems: UserItemInfoV2 = UserItemInfoV2() self.favorites: List[int] = [] self.stoppedSongIds: List[int] = [] - self.eventInfo: List[int] = [] + self.eventInfo: List[UserEventInfo] = [] self.gateInfo: List[GateDetailV1] = [] self.lastSongInfo: LastSongDetail = LastSongDetail() self.gateTutorialFlags: List[GateTutorialFlag] = [] @@ -149,6 +153,8 @@ class UserStatusGetDetailResponseV2(UserStatusGetDetailResponseV1): gates = [] friends = [] tut_flg = [] + evts = [] + gacha = [] for x in self.gateInfo: gates.append(x.make()) @@ -163,13 +169,19 @@ class UserStatusGetDetailResponseV2(UserStatusGetDetailResponseV1): flag_id = len(tut_flg) + 1 tut_flg.append([flag_id, 0]) + for x in self.eventInfo: + evts.append(x.make()) + + for x in self.gatchaInfo: + gacha.append(x.make()) + self.params.append(self.favorites) self.params.append(self.stoppedSongIds) - self.params.append(self.eventInfo) + self.params.append(evts) self.params.append(gates) self.params.append(self.lastSongInfo.make()) self.params.append(tut_flg) - self.params.append(self.gatchaInfo) + self.params.append(gacha) self.params.append(friends) return super(UserStatusGetDetailResponseV1, self).make() @@ -255,7 +267,9 @@ class UserStatusLoginResponseV3(UserStatusLoginResponseV2): self, is_first_login_daily: bool = False, last_login_date: int = 0 ) -> None: super().__init__(is_first_login_daily, last_login_date) - self.unk: List = [] + self.unk: List = ( + [] + ) # Ticket info, item info, message, title, voice name (not sure how they fit...) def make(self) -> Dict: super().make() diff --git a/titles/wacca/lily.py b/titles/wacca/lily.py index c3b6eb4..6ac60de 100644 --- a/titles/wacca/lily.py +++ b/titles/wacca/lily.py @@ -74,6 +74,8 @@ class WaccaLily(WaccaS): resp.profileStatus = ProfileStatus.ProfileRegister return resp.make() + opts = self.data.profile.get_options(req.aimeId) + self.logger.info(f"User preview for {req.aimeId} from {req.chipId}") if profile["last_game_ver"] is None: resp.lastGameVersion = ShortVersion(str(req.appVersion)) @@ -138,13 +140,14 @@ class WaccaLily(WaccaS): if self.game_config.mods.infinite_wp: resp.userStatus.wp = 999999 + for opt in opts: + resp.options.append(UserOption(opt["opt_id"], opt["value"])) + return resp.make() def handle_user_status_login_request(self, data: Dict) -> Dict: req = UserStatusLoginRequest(data) resp = UserStatusLoginResponseV2() - is_new_day = False - is_consec_day = False is_consec_day = True if req.userId == 0: @@ -162,34 +165,28 @@ class WaccaLily(WaccaS): self.logger.info(f"User {req.userId} login on {req.chipId}") last_login_time = int(profile["last_login_date"].timestamp()) resp.lastLoginDate = last_login_time - - # If somebodies login timestamp < midnight of current day, then they are logging in for the first time today - if last_login_time < int( + midnight_today_ts = int( datetime.now() .replace(hour=0, minute=0, second=0, microsecond=0) .timestamp() - ): - is_new_day = True - is_consec_day = True + ) - # If somebodies login timestamp > midnight of current day + 1 day, then they broke their daily login streak - elif last_login_time > int( - ( - datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) - + timedelta(days=1) - ).timestamp() - ): + # If somebodies login timestamp < midnight of current day, then they are logging in for the first time today + if last_login_time < midnight_today_ts: + resp.firstLoginDaily = True + + # If the difference between midnight today and their last login is greater then 1 day (86400 seconds) they've broken their streak + if midnight_today_ts - last_login_time > 86400: is_consec_day = False - # else, they are simply logging in again on the same day, and we don't need to do anything for that - self.data.profile.session_login(req.userId, is_new_day, is_consec_day) + self.data.profile.session_login( + req.userId, resp.firstLoginDaily, is_consec_day + ) resp.vipInfo.pageYear = datetime.now().year resp.vipInfo.pageMonth = datetime.now().month resp.vipInfo.pageDay = datetime.now().day resp.vipInfo.numItem = 1 - resp.firstLoginDaily = int(is_new_day) - return resp.make() def handle_user_status_getDetail_request(self, data: Dict) -> Dict: diff --git a/titles/wacca/reverse.py b/titles/wacca/reverse.py index f32b0c4..1711013 100644 --- a/titles/wacca/reverse.py +++ b/titles/wacca/reverse.py @@ -136,6 +136,12 @@ class WaccaReverse(WaccaLilyR): resp.seasonalPlayModeCounts.append( PlayModeCounts(self.season, 4, profile["playcount_stageup"]) ) + resp.seasonalPlayModeCounts.append( + PlayModeCounts(self.season, 5, profile["playcount_time_free"]) + ) + + # For some fucking reason if this isn't here time play is disabled + resp.seasonalPlayModeCounts.append(PlayModeCounts(0, 1, 1)) for opt in profile_options: resp.options.append(UserOption(opt["opt_id"], opt["value"])) diff --git a/titles/wacca/schema/profile.py b/titles/wacca/schema/profile.py index 27111be..48eb800 100644 --- a/titles/wacca/schema/profile.py +++ b/titles/wacca/schema/profile.py @@ -7,6 +7,7 @@ from sqlalchemy.engine import Row from sqlalchemy.dialects.mysql import insert from core.data.schema import BaseData, metadata +from ..handlers.helpers import PlayType profile = Table( "wacca_profile", @@ -40,6 +41,7 @@ profile = Table( Column("playcount_multi_vs", Integer, server_default="0"), Column("playcount_multi_coop", Integer, server_default="0"), Column("playcount_stageup", Integer, server_default="0"), + Column("playcount_time_free", Integer, server_default="0"), Column("friend_view_1", Integer), Column("friend_view_2", Integer), Column("friend_view_3", Integer), @@ -160,17 +162,20 @@ class WaccaProfileData(BaseData): ) -> None: sql = profile.update(profile.c.id == profile_id).values( playcount_single=profile.c.playcount_single + 1 - if play_type == 1 + if play_type == PlayType.PlayTypeSingle.value else profile.c.playcount_single, playcount_multi_vs=profile.c.playcount_multi_vs + 1 - if play_type == 2 + if play_type == PlayType.PlayTypeVs.value else profile.c.playcount_multi_vs, playcount_multi_coop=profile.c.playcount_multi_coop + 1 - if play_type == 3 + if play_type == PlayType.PlayTypeCoop.value else profile.c.playcount_multi_coop, playcount_stageup=profile.c.playcount_stageup + 1 - if play_type == 4 + if play_type == PlayType.PlayTypeStageup.value else profile.c.playcount_stageup, + playcount_time_free=profile.c.playcount_time_free + 1 + if play_type == PlayType.PlayTypeTimeFree.value + else profile.c.playcount_time_free, last_game_ver=game_version, )