diff --git a/changelog.md b/changelog.md index d4ced59..dbbc095 100644 --- a/changelog.md +++ b/changelog.md @@ -1,6 +1,9 @@ # Changelog Documenting updates to ARTEMiS, to be updated every time the master branch is pushed to. +## 20250803 ++ CHUNITHM VERSE support added + ## 20250327 + O.N.G.E.K.I. bright MEMORY Act.3 support added + CardMaker support updated diff --git a/core/allnet.py b/core/allnet.py index 0912f56..3cd7b6d 100644 --- a/core/allnet.py +++ b/core/allnet.py @@ -7,6 +7,7 @@ import logging import coloredlogs import urllib.parse import math +import random from typing import Dict, List, Any, Optional, Union, Final from logging.handlers import TimedRotatingFileHandler from starlette.requests import Request @@ -17,7 +18,10 @@ from datetime import datetime from enum import Enum from Crypto.PublicKey import RSA from Crypto.Hash import SHA +from Crypto.Cipher import AES +from Crypto.Util.Padding import pad from Crypto.Signature import PKCS1_v1_5 +import os from os import path, environ, mkdir, access, W_OK from .config import CoreConfig @@ -132,12 +136,29 @@ class AllnetServlet: async def handle_poweron(self, request: Request): request_ip = Utils.get_ip_addr(request) pragma_header = request.headers.get('Pragma', "") + useragent_header = request.headers.get('User-Agent', "") is_dfi = pragma_header == "DFI" + is_lite = useragent_header[5:] == "Windows/Lite" + lite_id = useragent_header[:4] data = await request.body() + + if not self.config.allnet.allnet_lite_keys and is_lite: + self.logger.error("!!!LITE KEYS NOT SET!!!") + raise AllnetRequestException() + elif is_lite: + for gameids, key in self.config.allnet.allnet_lite_keys.items(): + if gameids == lite_id: + litekey = key + + if is_lite and "litekey" not in locals(): + self.logger.error("!!!UNIQUE LITE KEY NOT FOUND!!!") + raise AllnetRequestException() try: if is_dfi: req_urlencode = self.from_dfi(data) + elif is_lite: + req_urlencode = self.dec_lite(litekey, data[:16], data) else: req_urlencode = data @@ -145,20 +166,30 @@ class AllnetServlet: if req_dict is None: raise AllnetRequestException() - req = AllnetPowerOnRequest(req_dict[0]) + if is_lite: + req = AllnetPowerOnRequestLite(req_dict[0]) + else: + req = AllnetPowerOnRequest(req_dict[0]) # Validate the request. Currently we only validate the fields we plan on using - if not req.game_id or not req.ver or not req.serial or not req.ip or not req.firm_ver or not req.boot_ver: + if not req.game_id or not req.ver or not req.serial or not req.token and is_lite: raise AllnetRequestException( f"Bad auth request params from {request_ip} - {vars(req)}" ) + elif not is_lite: + if not req.game_id or not req.ver or not req.serial or not req.ip or not req.firm_ver or not req.boot_ver: + raise AllnetRequestException( + f"Bad auth request params from {request_ip} - {vars(req)}" + ) except AllnetRequestException as e: if e.message != "": self.logger.error(e) return PlainTextResponse() - if req.format_ver == 3: + if is_lite: + resp = AllnetPowerOnResponseLite(req.token) + elif req.format_ver == 3: resp = AllnetPowerOnResponse3(req.token) elif req.format_ver == 2: resp = AllnetPowerOnResponse2() @@ -175,11 +206,14 @@ class AllnetServlet: ) self.logger.warning(msg) - resp.stat = ALLNET_STAT.bad_machine.value + if is_lite: + resp.result = ALLNET_STAT.bad_machine.value + else: + resp.stat = ALLNET_STAT.bad_machine.value resp_dict = {k: v for k, v in vars(resp).items() if v is not None} return PlainTextResponse(urllib.parse.unquote(urllib.parse.urlencode(resp_dict)) + "\n") - if machine is not None: + if machine is not None and not is_lite: arcade = await self.data.arcade.get_arcade(machine["arcade"]) if self.config.server.check_arcade_ip: if arcade["ip"] and arcade["ip"] is not None and arcade["ip"] != req.ip: @@ -257,7 +291,10 @@ class AllnetServlet: ) self.logger.warning(msg) - resp.stat = ALLNET_STAT.bad_game.value + if is_lite: + resp.result = ALLNET_STAT.bad_game.value + else: + resp.stat = ALLNET_STAT.bad_game.value resp_dict = {k: v for k, v in vars(resp).items() if v is not None} return PlainTextResponse(urllib.parse.unquote(urllib.parse.urlencode(resp_dict)) + "\n") @@ -265,8 +302,12 @@ class AllnetServlet: 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.server.hostname}:{self.config.server.port}/{req.game_id}/{req.ver.replace('.', '')}/" - resp.host = f"{self.config.server.hostname}:{self.config.server.port}" + if is_lite: + resp.uri1 = f"http://{self.config.server.hostname}:{self.config.server.port}/{req.game_id}/{req.ver.replace('.', '')}/" + resp.uri2 = f"{self.config.server.hostname}:{self.config.server.port}" + else: + resp.uri = f"http://{self.config.server.hostname}:{self.config.server.port}/{req.game_id}/{req.ver.replace('.', '')}/" + resp.host = f"{self.config.server.hostname}:{self.config.server.port}" resp_dict = {k: v for k, v in vars(resp).items() if v is not None} resp_str = urllib.parse.unquote(urllib.parse.urlencode(resp_dict)) @@ -277,10 +318,16 @@ class AllnetServlet: int_ver = req.ver.replace(".", "") try: - resp.uri, resp.host = TitleServlet.title_registry[req.game_id].get_allnet_info(req.game_id, int(int_ver), req.serial) + if is_lite: + resp.uri1, resp.uri2 = TitleServlet.title_registry[req.game_id].get_allnet_info(req.game_id, int(int_ver), req.serial) + else: + resp.uri, resp.host = TitleServlet.title_registry[req.game_id].get_allnet_info(req.game_id, int(int_ver), req.serial) except Exception as e: self.logger.error(f"Error running get_allnet_info for {req.game_id} - {e}") - resp.stat = ALLNET_STAT.bad_game.value + if is_lite: + resp.result = ALLNET_STAT.bad_game.value + else: + resp.stat = ALLNET_STAT.bad_game.value resp_dict = {k: v for k, v in vars(resp).items() if v is not None} return PlainTextResponse(urllib.parse.unquote(urllib.parse.urlencode(resp_dict)) + "\n") @@ -308,18 +355,38 @@ class AllnetServlet: "Pragma": "DFI", }, ) + elif is_lite: + iv = bytes([random.randint(2, 255) for _ in range(16)]) + return PlainTextResponse(content=self.enc_lite(litekey, iv, resp_str)) - return PlainTextResponse(resp_str) + return PlainTextResponse(resp_str.encode(req.encode)) async def handle_dlorder(self, request: Request): request_ip = Utils.get_ip_addr(request) pragma_header = request.headers.get('Pragma', "") + useragent_header = request.headers.get('User-Agent', "") is_dfi = pragma_header == "DFI" + is_lite = useragent_header[5:] == "Windows/Lite" + lite_id = useragent_header[:4] data = await request.body() + if not self.config.allnet.allnet_lite_keys and is_lite: + self.logger.error("!!!LITE KEYS NOT SET!!!") + raise AllnetRequestException() + elif is_lite: + for gameids, key in self.config.allnet.allnet_lite_keys.items(): + if gameids == lite_id: + litekey = key + + if is_lite and "litekey" not in locals(): + self.logger.error("!!!UNIQUE LITE KEY NOT FOUND!!!") + raise AllnetRequestException() + try: if is_dfi: req_urlencode = self.from_dfi(data) + elif is_lite: + req_urlencode = self.dec_lite(litekey, data[:16], data) else: req_urlencode = data.decode() @@ -327,7 +394,10 @@ class AllnetServlet: if req_dict is None: raise AllnetRequestException() - req = AllnetDownloadOrderRequest(req_dict[0]) + if is_lite: + req = AllnetDownloadOrderRequestLite(req_dict[0]) + else: + req = AllnetDownloadOrderRequest(req_dict[0]) # Validate the request. Currently we only validate the fields we plan on using if not req.game_id or not req.ver or not req.serial: @@ -343,7 +413,11 @@ class AllnetServlet: self.logger.info( f"DownloadOrder from {request_ip} -> {req.game_id} v{req.ver} serial {req.serial}" ) - resp = AllnetDownloadOrderResponse(serial=req.serial) + + if is_lite: + resp = AllnetDownloadOrderResponseLite() + else: + resp = AllnetDownloadOrderResponse(serial=req.serial) if ( not self.config.allnet.allow_online_updates @@ -354,27 +428,31 @@ class AllnetServlet: return PlainTextResponse( self.to_dfi(resp) + b"\r\n", headers={ "Pragma": "DFI" } ) + elif is_lite: + iv = bytes([random.randint(2, 255) for _ in range(16)]) + return PlainTextResponse(content=self.enc_lite(litekey, iv, resp)) return PlainTextResponse(resp) else: machine = await self.data.arcade.get_machine(req.serial) - if not machine or not machine['ota_enable'] or not machine['is_cab']: + if not machine or not machine['ota_channel'] or not machine['is_cab']: resp = urllib.parse.unquote(urllib.parse.urlencode(vars(resp))) + "\n" if is_dfi: return PlainTextResponse( self.to_dfi(resp) + b"\r\n", headers={ "Pragma": "DFI" } ) + elif is_lite: + iv = bytes([random.randint(2, 255) for _ in range(16)]) + return PlainTextResponse(content=self.enc_lite(litekey, iv, resp)) return PlainTextResponse(resp) + + update = await self.data.arcade.get_ota_update(req.game_id, req.ver, machine['ota_channel']) + if update: + if update['app_ini'] and path.exists(f"{self.config.allnet.update_cfg_folder}/{update['app_ini']}"): + resp.uri = f"http://{self.config.server.hostname}:{self.config.server.port}/dl/ini/{update['app_ini']}" - if path.exists( - f"{self.config.allnet.update_cfg_folder}/{req.game_id}-{req.ver.replace('.', '')}-app.ini" - ): - resp.uri = f"http://{self.config.server.hostname}:{self.config.server.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.replace('.', '')}-opt.ini" - ): - resp.uri += f"|http://{self.config.server.hostname}:{self.config.server.port}/dl/ini/{req.game_id}-{req.ver.replace('.', '')}-opt.ini" + if update['opt_ini'] and path.exists(f"{self.config.allnet.update_cfg_folder}/{update['opt_ini']}"): + resp.uri += f"|http://{self.config.server.hostname}:{self.config.server.port}/dl/ini/{update['opt_ini']}" if resp.uri: self.logger.info(f"Sending download uri {resp.uri}") @@ -393,6 +471,9 @@ class AllnetServlet: "Pragma": "DFI", }, ) + elif is_lite: + iv = bytes([random.randint(2, 255) for _ in range(16)]) + return PlainTextResponse(content=self.enc_lite(litekey, iv, res_str)) return PlainTextResponse(res_str) @@ -413,7 +494,7 @@ class AllnetServlet: f"{self.config.allnet.update_cfg_folder}/{req_file}", "r", encoding="utf-8" ).read()) - self.logger.info(f"DL INI File {req_file} not found") + self.logger.warning(f"DL INI File {req_file} not found") return PlainTextResponse() async def handle_dlorder_report(self, request: Request) -> bytes: @@ -517,6 +598,17 @@ class AllnetServlet: zipped = zlib.compress(unzipped) return base64.b64encode(zipped) + def dec_lite(self, key, iv, data): + cipher = AES.new(bytes(key), AES.MODE_CBC, iv) + decrypted = cipher.decrypt(data) + return decrypted[16:].decode("utf-8") + + def enc_lite(self, key, iv, data): + unencrypted = pad(bytes([0] * 16) + data.encode('utf-8'), 16) + cipher = AES.new(bytes(key), AES.MODE_CBC, iv) + encrypted = cipher.encrypt(unencrypted) + return encrypted + class BillingServlet: def __init__(self, core_cfg: CoreConfig, cfg_folder: str) -> None: self.config = core_cfg @@ -711,8 +803,9 @@ class BillingServlet: ) if req.traceleft > 0: - self.logger.warning(f"{req.traceleft} unsent tracelogs") - + self.logger.info(f"Requesting 20 more of {req.traceleft} unsent tracelogs") + return PlainTextResponse("result=6&waittime=0&linelimit=20\r\n") + playlimit = req.playlimit while req.playcnt > playlimit: playlimit += 1024 @@ -731,9 +824,6 @@ class BillingServlet: resp_str = urllib.parse.unquote(urllib.parse.urlencode(vars(resp))) + "\r\n" self.logger.debug(f"response {vars(resp)}") - if req.traceleft > 0: # TODO: should probably move this up so we don't do a ton of work that doesn't get used - self.logger.info(f"Requesting 20 more of {req.traceleft} unsent tracelogs") - return PlainTextResponse("result=6&waittime=0&linelimit=20\r\n") return PlainTextResponse(resp_str) @@ -773,6 +863,15 @@ class AllnetPowerOnResponse: self.minute = datetime.now().minute self.second = datetime.now().second +class AllnetPowerOnRequestLite: + def __init__(self, req: Dict) -> None: + if req is None: + raise AllnetRequestException("Request processing failed") + self.game_id: str = req.get("title_id", None) + self.ver: str = req.get("title_ver", None) + self.serial: str = req.get("client_id", None) + self.token: str = req.get("token", None) + class AllnetPowerOnResponse3(AllnetPowerOnResponse): def __init__(self, token) -> None: super().__init__() @@ -804,6 +903,30 @@ class AllnetPowerOnResponse2(AllnetPowerOnResponse): self.timezone = "+09:00" self.res_class = "PowerOnResponseV2" +class AllnetPowerOnResponseLite: + def __init__(self, token) -> None: + # Custom Allnet Lite response + self.result = 1 + self.place_id = "0123" + self.uri1 = "" + self.uri2 = "" + self.name = "ARTEMiS" + self.nickname = "ARTEMiS" + self.setting = "1" + self.region0 = "1" + self.region_name0 = "W" + self.region_name1 = "" + self.region_name2 = "" + self.region_name3 = "" + self.country = "CHN" + self.location_type = "1" + self.utc_time = datetime.now(tz=pytz.timezone("UTC")).strftime( + "%Y-%m-%dT%H:%M:%SZ" + ) + self.client_timezone = "+0800" + self.res_ver = "3" + self.token = token + class AllnetDownloadOrderRequest: def __init__(self, req: Dict) -> None: self.game_id = req.get("game_id", "") @@ -811,12 +934,23 @@ class AllnetDownloadOrderRequest: self.serial = req.get("serial", "") self.encode = req.get("encode", "") +class AllnetDownloadOrderRequestLite: + def __init__(self, req: Dict) -> None: + self.game_id = req.get("title_id", "") + self.ver = req.get("title_ver", "") + self.serial = req.get("client_id", "") + class AllnetDownloadOrderResponse: def __init__(self, stat: int = 1, serial: str = "", uri: str = "null") -> None: self.stat = stat self.serial = serial self.uri = uri +class AllnetDownloadOrderResponseLite: + def __init__(self, result: int = 1, uri: str = "null") -> None: + self.result = result + self.uri = uri + class TraceDataType(Enum): CHARGE = 0 EVENT = 1 @@ -1068,7 +1202,9 @@ app_billing = Starlette( allnet = AllnetServlet(cfg, cfg_dir) route_lst = [ Route("/sys/servlet/PowerOn", allnet.handle_poweron, methods=["GET", "POST"]), + Route("/net/initialize", allnet.handle_poweron, methods=["GET", "POST"]), Route("/sys/servlet/DownloadOrder", allnet.handle_dlorder, methods=["GET", "POST"]), + Route("/net/delivery/instruction", allnet.handle_dlorder, methods=["GET", "POST"]), Route("/sys/servlet/LoaderStateRecorder", allnet.handle_loaderstaterecorder, methods=["GET", "POST"]), Route("/sys/servlet/Alive", allnet.handle_alive, methods=["GET", "POST"]), Route("/naomitest.html", allnet.handle_naomitest), diff --git a/core/app.py b/core/app.py index fa1c8f2..4737030 100644 --- a/core/app.py +++ b/core/app.py @@ -11,6 +11,7 @@ from typing import List from core import CoreConfig, TitleServlet, MuchaServlet from core.allnet import AllnetServlet, BillingServlet +from core.chimedb import ChimeServlet from core.frontend import FrontendServlet async def dummy_rt(request: Request): @@ -75,7 +76,9 @@ if not cfg.allnet.standalone: allnet = AllnetServlet(cfg, cfg_dir) route_lst += [ Route("/sys/servlet/PowerOn", allnet.handle_poweron, methods=["GET", "POST"]), + Route("/net/initialize", allnet.handle_poweron, methods=["GET", "POST"]), Route("/sys/servlet/DownloadOrder", allnet.handle_dlorder, methods=["GET", "POST"]), + Route("/net/delivery/instruction", allnet.handle_dlorder, methods=["GET", "POST"]), Route("/sys/servlet/LoaderStateRecorder", allnet.handle_loaderstaterecorder, methods=["GET", "POST"]), Route("/sys/servlet/Alive", allnet.handle_alive, methods=["GET", "POST"]), Route("/naomitest.html", allnet.handle_naomitest), @@ -87,6 +90,14 @@ if not cfg.allnet.standalone: Route("/dl/ini/{file:str}", allnet.handle_dlorder_ini), ] +if cfg.chimedb.enable: + chimedb = ChimeServlet(cfg, cfg_dir) + route_lst += [ + Route("/wc_aime/api/alive_check", chimedb.handle_qr_alive, methods=["POST"]), + Route("/qrcode/api/alive_check", chimedb.handle_qr_alive, methods=["POST"]), + Route("/wc_aime/api/get_data", chimedb.handle_qr_lookup, methods=["POST"]) + ] + for code, game in title.title_registry.items(): route_lst += game.get_routes() diff --git a/core/chimedb.py b/core/chimedb.py new file mode 100644 index 0000000..6e87f69 --- /dev/null +++ b/core/chimedb.py @@ -0,0 +1,139 @@ +import hashlib +import json +import logging +from enum import Enum +from logging.handlers import TimedRotatingFileHandler + +import coloredlogs +from starlette.responses import PlainTextResponse +from starlette.requests import Request + +from core.config import CoreConfig +from core.data import Data + +class ChimeDBStatus(Enum): + NONE = 0 + READER_SETUP_FAIL = 1 + READER_ACCESS_FAIL = 2 + READER_INCOMPATIBLE = 3 + DB_RESOLVE_FAIL = 4 + DB_ACCESS_TIMEOUT = 5 + DB_ACCESS_FAIL = 6 + AIME_ID_INVALID = 7 + NO_BOARD_INFO = 8 + LOCK_BAN_SYSTEM_USER = 9 + LOCK_BAN_SYSTEM = 10 + LOCK_BAN_USER = 11 + LOCK_BAN = 12 + LOCK_SYSTEM_USER = 13 + LOCK_SYSTEM = 14 + LOCK_USER = 15 + +class ChimeServlet: + def __init__(self, core_cfg: CoreConfig, cfg_folder: str) -> None: + self.config = core_cfg + self.config_folder = cfg_folder + + self.data = Data(core_cfg) + + self.logger = logging.getLogger("chimedb") + if not hasattr(self.logger, "initted"): + log_fmt_str = "[%(asctime)s] Chimedb | %(levelname)s | %(message)s" + log_fmt = logging.Formatter(log_fmt_str) + + fileHandler = TimedRotatingFileHandler( + "{0}/{1}.log".format(self.config.server.log_dir, "chimedb"), + when="d", + backupCount=10, + ) + fileHandler.setFormatter(log_fmt) + + consoleHandler = logging.StreamHandler() + consoleHandler.setFormatter(log_fmt) + + self.logger.addHandler(fileHandler) + self.logger.addHandler(consoleHandler) + + self.logger.setLevel(self.config.aimedb.loglevel) + coloredlogs.install( + level=core_cfg.aimedb.loglevel, logger=self.logger, fmt=log_fmt_str + ) + self.logger.initted = True + + if not core_cfg.chimedb.key: + self.logger.error("!!!KEY NOT SET!!!") + exit(1) + + self.logger.info("Serving") + + async def handle_qr_alive(self, request: Request): + return PlainTextResponse("alive") + + async def handle_qr_lookup(self, request: Request) -> bytes: + req = json.loads(await request.body()) + access_code = req["qrCode"][-20:] + timestamp = req["timestamp"] + + try: + userId = await self._lookup(access_code) + data = json.dumps({ + "userID": userId, + "errorID": 0, + "timestamp": timestamp, + "key": self._hash_key(userId, timestamp) + }) + except Exception as e: + + self.logger.error(e.with_traceback(None)) + + data = json.dumps({ + "userID": -1, + "errorID": ChimeDBStatus.DB_ACCESS_FAIL, + "timestamp": timestamp, + "key": self._hash_key(-1, timestamp) + }) + + return PlainTextResponse(data) + + def _hash_key(self, chip_id, timestamp): + input_string = f"{chip_id}{timestamp}{self.config.chimedb.key}" + hash_object = hashlib.sha256(input_string.encode('utf-8')) + hex_dig = hash_object.hexdigest() + + formatted_hex = format(int(hex_dig, 16), '064x').upper() + + return formatted_hex + + async def _lookup(self, access_code): + user_id = await self.data.card.get_user_id_from_card(access_code) + + self.logger.info(f"access_code {access_code} -> user_id {user_id}") + + if not user_id or user_id <= 0: + user_id = await self._register(access_code) + + return user_id + + async def _register(self, access_code): + user_id = -1 + + if self.config.server.allow_user_registration: + user_id = await self.data.user.create_user() + + if user_id is None: + self.logger.error("Failed to register user!") + user_id = -1 + else: + card_id = await self.data.card.create_card(user_id, access_code) + + if card_id is None: + self.logger.error("Failed to register card!") + user_id = -1 + + self.logger.info( + f"Register access code {access_code} -> user_id {user_id}" + ) + else: + self.logger.info(f"Registration blocked!: access code {access_code}") + + return user_id diff --git a/core/config.py b/core/config.py index eb02c4e..3a66e4b 100644 --- a/core/config.py +++ b/core/config.py @@ -1,7 +1,7 @@ import logging import os import ssl -from typing import Any, Union +from typing import Any, Union, Dict from typing_extensions import Optional @@ -45,7 +45,7 @@ class ServerConfig: @property def ssl_cert(self) -> str: return CoreConfig.get_config_field( - self.__config, "core", "title", "ssl_cert", default="cert/title.pem" + self.__config, "core", "server", "ssl_cert", default="cert/title.pem" ) @property @@ -378,6 +378,11 @@ class AllnetConfig: return CoreConfig.get_config_field( self.__config, "core", "allnet", "save_billing", default=False ) + @property + def allnet_lite_keys(self) -> Dict: + return CoreConfig.get_config_field( + self.__config, "core", "allnet", "allnet_lite_keys", default={} + ) class BillingConfig: def __init__(self, parent_config: "CoreConfig") -> None: @@ -469,6 +474,28 @@ class AimedbConfig: self.__config, "core", "aimedb", "id_lifetime_seconds", default=86400 ) +class ChimedbConfig: + def __init__(self, parent_config: "CoreConfig") -> None: + self.__config = parent_config + + @property + def enable(self) -> bool: + return CoreConfig.get_config_field( + self.__config, "core", "chimedb", "enable", default=True + ) + @property + def loglevel(self) -> int: + return CoreConfig.str_to_loglevel( + CoreConfig.get_config_field( + self.__config, "core", "chimedb", "loglevel", default="info" + ) + ) + @property + def key(self) -> str: + return CoreConfig.get_config_field( + self.__config, "core", "chimedb", "key", default="" + ) + class MuchaConfig: def __init__(self, parent_config: "CoreConfig") -> None: self.__config = parent_config @@ -490,6 +517,7 @@ class CoreConfig(dict): self.allnet = AllnetConfig(self) self.billing = BillingConfig(self) self.aimedb = AimedbConfig(self) + self.chimedb = ChimedbConfig(self) self.mucha = MuchaConfig(self) @classmethod diff --git a/core/data/alembic/versions/318d52559e83_chuni_subtrophy_db_fix.py b/core/data/alembic/versions/318d52559e83_chuni_subtrophy_db_fix.py new file mode 100644 index 0000000..e42bc25 --- /dev/null +++ b/core/data/alembic/versions/318d52559e83_chuni_subtrophy_db_fix.py @@ -0,0 +1,31 @@ +"""chuni_subtrophy_db_fix + +Revision ID: 318d52559e83 +Revises: 8b57e9646449 +Create Date: 2026-01-08 19:13:29.803912 + +""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision = '318d52559e83' +down_revision = '8b57e9646449' +branch_labels = None +depends_on = None + + +def upgrade(): + op.alter_column('chuni_profile_data', 'trophyIdSub1', existing_type=mysql.INTEGER(), server_default='-1') + op.alter_column('chuni_profile_data', 'trophyIdSub2', existing_type=mysql.INTEGER(), server_default='-1') + + # fix any current profiles where the bad defaults were used + op.execute("UPDATE chuni_profile_data SET trophyIdSub1=-1 WHERE trophyIdSub1 IS NULL") + op.execute("UPDATE chuni_profile_data SET trophyIdSub2=-1 WHERE trophyIdSub2 IS NULL") + + +def downgrade(): + # dont bother "unfixing" the table + pass diff --git a/core/data/alembic/versions/49c295e89cd4_chunithm_verse.py b/core/data/alembic/versions/49c295e89cd4_chunithm_verse.py new file mode 100644 index 0000000..91c614e --- /dev/null +++ b/core/data/alembic/versions/49c295e89cd4_chunithm_verse.py @@ -0,0 +1,85 @@ +"""CHUNITHM VERSE support + +Revision ID: 49c295e89cd4 +Revises: 7070a6fa8cdc +Create Date: 2025-03-09 14:10:03.067328 + +""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql +from sqlalchemy.sql import func + +# revision identifiers, used by Alembic. +revision = "49c295e89cd4" +down_revision = "7070a6fa8cdc" +branch_labels = None +depends_on = None + + +def upgrade(): + ### commands auto generated by Alembic - please adjust! ### + op.add_column("chuni_profile_data", sa.Column("trophyIdSub1", sa.Integer())) + op.add_column("chuni_profile_data", sa.Column("trophyIdSub2", sa.Integer())) + op.add_column("chuni_score_playlog", sa.Column("monthPoint", sa.Integer())) + op.add_column("chuni_score_playlog", sa.Column("eventPoint", sa.Integer())) + + op.create_table( + "chuni_static_unlock_challenge", + sa.Column("id", sa.Integer(), primary_key=True, nullable=False), + sa.Column("version", sa.Integer(), nullable=False), + sa.Column("unlockChallengeId", sa.Integer(), nullable=False), + sa.Column("name", sa.String(length=255)), + sa.Column("isEnabled", sa.Boolean(), server_default="1"), + sa.Column("startDate", sa.TIMESTAMP(), server_default=func.now()), + sa.Column("courseId1", sa.Integer()), + sa.Column("courseId2", sa.Integer()), + sa.Column("courseId3", sa.Integer()), + sa.Column("courseId4", sa.Integer()), + sa.Column("courseId5", sa.Integer()), + sa.UniqueConstraint( + "version", "unlockChallengeId", name="chuni_static_unlock_challenge_uk" + ), + mysql_charset="utf8mb4", + ) + + op.create_table( + "chuni_item_unlock_challenge", + sa.Column("id", sa.Integer(), primary_key=True, nullable=False), + sa.Column("version", sa.Integer(), nullable=False), + sa.Column( + "user", + sa.Integer(), + sa.ForeignKey("aime_user.id", ondelete="cascade", onupdate="cascade"), + nullable=False, + ), + sa.Column("unlockChallengeId", sa.Integer(), nullable=False), + sa.Column("status", sa.Integer()), + sa.Column("clearCourseId", sa.Integer()), + sa.Column("conditionType", sa.Integer()), + sa.Column("score", sa.Integer()), + sa.Column("life", sa.Integer()), + sa.Column("clearDate", sa.TIMESTAMP(), server_default=func.now()), + sa.UniqueConstraint( + "version", + "user", + "unlockChallengeId", + name="chuni_item_unlock_challenge_uk", + ), + mysql_charset="utf8mb4", + ) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("chuni_score_playlog", "eventPoint") + op.drop_column("chuni_score_playlog", "monthPoint") + op.drop_column("chuni_profile_data", "trophyIdSub2") + op.drop_column("chuni_profile_data", "trophyIdSub1") + + op.drop_table("chuni_static_unlock_challenge") + op.drop_table("chuni_item_unlock_challenge") + # ### end Alembic commands ### diff --git a/core/data/alembic/versions/5cf98cfe52ad_mai2_prism_support.py b/core/data/alembic/versions/5cf98cfe52ad_mai2_prism_support.py index 77ca08a..d3296cd 100644 --- a/core/data/alembic/versions/5cf98cfe52ad_mai2_prism_support.py +++ b/core/data/alembic/versions/5cf98cfe52ad_mai2_prism_support.py @@ -10,6 +10,7 @@ import sqlalchemy as sa # revision identifiers, used by Alembic. + revision = '5cf98cfe52ad' down_revision = '263884e774cc' branch_labels = None diff --git a/core/data/alembic/versions/7070a6fa8cdc_update_channels.py b/core/data/alembic/versions/7070a6fa8cdc_update_channels.py new file mode 100644 index 0000000..63690f5 --- /dev/null +++ b/core/data/alembic/versions/7070a6fa8cdc_update_channels.py @@ -0,0 +1,42 @@ +"""update_channels + +Revision ID: 7070a6fa8cdc +Revises: f6007bbf057d +Create Date: 2025-09-27 16:09:55.853051 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision = '7070a6fa8cdc' +down_revision = 'f6007bbf057d' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('machine_update', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('game', sa.CHAR(length=4), nullable=False), + sa.Column('version', sa.VARCHAR(length=15), nullable=False), + sa.Column('channel', sa.VARCHAR(length=260), nullable=False), + sa.Column('app_ini', sa.VARCHAR(length=260), nullable=True), + sa.Column('opt_ini', sa.VARCHAR(length=260), nullable=True), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('game', 'version', 'channel', name='machine_update_uk'), + mysql_charset='utf8mb4' + ) + op.add_column('machine', sa.Column('ota_channel', sa.VARCHAR(length=260), nullable=True)) + op.drop_column('machine', 'ota_enable') + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('machine', sa.Column('ota_enable', mysql.TINYINT(display_width=1), autoincrement=False, nullable=True)) + op.drop_column('machine', 'ota_channel') + op.drop_table('machine_update') + # ### end Alembic commands ### diff --git a/core/data/alembic/versions/8b57e9646449_chunithm_xverse.py b/core/data/alembic/versions/8b57e9646449_chunithm_xverse.py new file mode 100644 index 0000000..9ff77da --- /dev/null +++ b/core/data/alembic/versions/8b57e9646449_chunithm_xverse.py @@ -0,0 +1,98 @@ +"""CHUNITHM X-VERSE + +Revision ID: 8b57e9646449 +Revises: bdf710616ba4 +Create Date: 2025-12-12 16:09:07.530809 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "8b57e9646449" +down_revision = "bdf710616ba4" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column( + "chuni_profile_data", + sa.Column("stageId", sa.Integer(), nullable=False, server_default="99999"), + ) + op.create_table( + "chuni_static_linked_verse", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("version", sa.Integer(), nullable=False), + sa.Column("linkedVerseId", sa.Integer(), nullable=False), + sa.Column("name", sa.String(length=255), nullable=True), + sa.Column("isEnabled", sa.Boolean(), server_default="1", nullable=False), + sa.Column( + "startDate", sa.TIMESTAMP(), server_default=sa.text("now()"), nullable=True + ), + sa.Column("courseId1", sa.Integer(), nullable=True), + sa.Column("courseId2", sa.Integer(), nullable=True), + sa.Column("courseId3", sa.Integer(), nullable=True), + sa.Column("courseId4", sa.Integer(), nullable=True), + sa.Column("courseId5", sa.Integer(), nullable=True), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "version", "linkedVerseId", name="chuni_static_linked_verse_pk" + ), + mysql_charset="utf8mb4", + ) + op.create_table( + "chuni_item_linked_verse", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("user", sa.Integer(), nullable=False), + sa.Column("linkedVerseId", sa.Integer(), nullable=False), + sa.Column("progress", sa.String(length=255), nullable=True), + sa.Column("statusOpen", sa.Integer(), nullable=True), + sa.Column("statusUnlock", sa.Integer(), nullable=True), + sa.Column("isFirstClear", sa.Integer(), nullable=True), + sa.Column("numClear", sa.Integer(), nullable=True), + sa.Column("clearCourseId", sa.Integer(), nullable=True), + sa.Column("clearCourseLevel", sa.Integer(), nullable=True), + sa.Column("clearScore", sa.Integer(), nullable=True), + sa.Column("clearDate", sa.String(length=25), nullable=True), + sa.Column("clearUserId1", sa.Integer(), nullable=True), + sa.Column("clearUserId2", sa.Integer(), nullable=True), + sa.Column("clearUserId3", sa.Integer(), nullable=True), + sa.Column("clearUserName0", sa.String(length=20), nullable=True), + sa.Column("clearUserName1", sa.String(length=20), nullable=True), + sa.Column("clearUserName2", sa.String(length=20), nullable=True), + sa.Column("clearUserName3", sa.String(length=20), nullable=True), + sa.ForeignKeyConstraint( + ["user"], ["aime_user.id"], onupdate="cascade", ondelete="cascade" + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("user", "linkedVerseId", name="chuni_item_linked_verse_uk"), + mysql_charset="utf8mb4", + ) + op.create_table( + "chuni_static_stage", + sa.Column("id", sa.Integer(), primary_key=True, nullable=False), + sa.Column("version", sa.Integer(), nullable=False), + sa.Column("stageId", sa.Integer(), nullable=False), + sa.Column("name", sa.String(length=255)), + sa.Column("imagePath", sa.String(length=255)), + sa.Column("isEnabled", sa.Boolean(), server_default="1"), + sa.Column("defaultHave", sa.Boolean(), server_default="0"), + sa.Column("opt", sa.BIGINT(), sa.ForeignKey("chuni_static_opt.id", ondelete="SET NULL", onupdate="cascade")), + sa.UniqueConstraint( + "version", "stageId", name="chuni_static_stage_uk" + ), + mysql_charset="utf8mb4", + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("chuni_profile_data", "stageId") + op.drop_table("chuni_item_linked_verse") + op.drop_table("chuni_static_linked_verse") + op.drop_table("chuni_static_stage") + # ### end Alembic commands ### diff --git a/core/data/alembic/versions/bdf710616ba4_mai2_add_prism_plus_support.py b/core/data/alembic/versions/bdf710616ba4_mai2_add_prism_plus_support.py new file mode 100644 index 0000000..30e736a --- /dev/null +++ b/core/data/alembic/versions/bdf710616ba4_mai2_add_prism_plus_support.py @@ -0,0 +1,29 @@ +"""Mai2 add PRiSM+ playlog support + +Revision ID: bdf710616ba4 +Revises: 16f34bf7b968 +Create Date: 2025-04-02 12:42:08.981516 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'bdf710616ba4' + +down_revision = '49c295e89cd4' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('mai2_playlog', sa.Column('extBool3', sa.Boolean(), nullable=True,server_default=sa.text("NULL"))) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('mai2_playlog', 'extBool3') + # ### end Alembic commands ### diff --git a/core/data/schema/arcade.py b/core/data/schema/arcade.py index d1790b8..d587f71 100644 --- a/core/data/schema/arcade.py +++ b/core/data/schema/arcade.py @@ -7,7 +7,7 @@ from sqlalchemy.dialects.mysql import insert from sqlalchemy.engine import Row from sqlalchemy.sql import func, select from sqlalchemy.sql.schema import ForeignKey, PrimaryKeyConstraint -from sqlalchemy.types import JSON, Boolean, Integer, String, BIGINT, INTEGER, CHAR, FLOAT +from sqlalchemy.types import JSON, Boolean, Integer, String, BIGINT, INTEGER, CHAR, FLOAT, VARCHAR from core.data.schema.base import BaseData, metadata @@ -41,13 +41,26 @@ machine: Table = Table( Column("game", String(4)), Column("country", String(3)), # overwrites if not null Column("timezone", String(255)), - Column("ota_enable", Boolean), Column("memo", String(255)), Column("is_cab", Boolean), + Column("ota_channel", VARCHAR(260)), Column("data", JSON), mysql_charset="utf8mb4", ) +update: Table = Table( + "machine_update", + metadata, + Column("id", Integer, primary_key=True, nullable=False), + Column("game", CHAR(4), nullable=False), + Column("version", VARCHAR(15), nullable=False), + Column("channel", VARCHAR(260), nullable=False), + Column("app_ini", VARCHAR(260)), + Column("opt_ini", VARCHAR(260)), + UniqueConstraint("game", "version", "channel", name="machine_update_uk"), + mysql_charset="utf8mb4", +) + arcade_owner: Table = Table( "arcade_owner", metadata, @@ -250,12 +263,12 @@ class ArcadeData(BaseData): return False return True - async def set_machine_can_ota(self, machine_id: int, can_ota: bool = False) -> bool: - sql = machine.update(machine.c.id == machine_id).values(ota_enable = can_ota) + async def set_machine_ota_channel(self, machine_id: int, channel_name: Optional[str] = None) -> bool: + sql = machine.update(machine.c.id == machine_id).values(ota_channel = channel_name) result = await self.execute(sql) if result is None: - self.logger.error(f"Failed to update machine {machine_id} ota_enable to {can_ota}") + self.logger.error(f"Failed to update machine {machine_id} ota channel to {channel_name}") return False return True @@ -433,7 +446,7 @@ class ArcadeData(BaseData): self.logger.error(f"Failed to add billing charge for machine {machine_id}!") return None return result.lastrowid - + async def billing_get_last_charge(self, machine_id: int, game_id: str) -> Optional[Row]: result = await self.execute(billing_charge.select( and_(billing_charge.c.machine == machine_id, billing_charge.c.game_id == game_id) @@ -511,7 +524,7 @@ class ArcadeData(BaseData): if result is None: self.logger.error(f"Failed to add playcount for machine {machine_id} running {game_id}") - + async def billing_get_playcount_3mo(self, machine_id: int, game_id: str) -> Optional[List[Row]]: result = await self.execute(billing_playct.select(and_( billing_playct.c.machine == machine_id, @@ -530,6 +543,29 @@ class ArcadeData(BaseData): if result is not None: return result.fetchone() + async def create_ota_update(self, game_id: str, ver: str, channel: str, app: Optional[str], opt: Optional[str] = None) -> Optional[int]: + result = await self.execute(insert(update).values( + game = game_id, + version = ver, + channel = channel, + app_ini = app, + opt_ini = opt + )) + + if result is None: + self.logger.error(f"Failed to create {game_id} v{ver} update on channel {channel}") + return result.lastrowid + + async def get_ota_update(self, game_id: str, ver: str, channel: str) -> Optional[Row]: + result = await self.execute(update.select(and_( + and_(update.c.game == game_id, update.c.version == ver), + update.c.channel == channel + ))) + + if result is None: + return None + return result.fetchone() + def format_serial( self, platform_code: str, platform_rev: int, serial_letter: str, serial_num: int, append: int, dash: bool = False ) -> str: diff --git a/core/data/schema/user.py b/core/data/schema/user.py index 8686f08..db6b71e 100644 --- a/core/data/schema/user.py +++ b/core/data/schema/user.py @@ -124,3 +124,15 @@ class UserData(BaseData): async def get_user_by_username(self, username: str) -> Optional[Row]: result = await self.execute(aime_user.select(aime_user.c.username == username)) if result: return result.fetchone() + + async def change_permission(self, user_id: int, new_perms: int) -> Optional[bool]: + sql = aime_user.update(aime_user.c.id == user_id).values(permissions = new_perms) + + result = await self.execute(sql) + return result is not None + + async def change_email(self, user_id: int, new_email: int) -> Optional[bool]: + sql = aime_user.update(aime_user.c.id == user_id).values(email = new_email) + + result = await self.execute(sql) + return result is not None diff --git a/core/frontend.py b/core/frontend.py index 47399d2..75528d9 100644 --- a/core/frontend.py +++ b/core/frontend.py @@ -1146,7 +1146,7 @@ class FE_Machine(FE_Base): new_country = frm.get('country', None) new_tz = frm.get('tz', None) new_is_cab = frm.get('is_cab', False) == 'on' - new_is_ota = frm.get('is_ota', False) == 'on' + new_ota_channel = frm.get('ota_channel', None) new_memo = frm.get('memo', None) try: @@ -1158,7 +1158,7 @@ class FE_Machine(FE_Base): did_country = await self.data.arcade.set_machine_country(cab['id'], new_country if new_country else None) did_timezone = await self.data.arcade.set_machine_timezone(cab['id'], new_tz if new_tz else None) did_real_cab = await self.data.arcade.set_machine_real_cabinet(cab['id'], new_is_cab) - did_ota = await self.data.arcade.set_machine_can_ota(cab['id'], new_is_ota) + did_ota = await self.data.arcade.set_machine_ota_channel(cab['id'], new_ota_channel if new_is_cab else None) did_memo = await self.data.arcade.set_machine_memo(cab['id'], new_memo if new_memo else None) if not did_game or not did_country or not did_timezone or not did_real_cab or not did_ota or not did_memo: diff --git a/core/templates/machine/index.jinja b/core/templates/machine/index.jinja index e9b7adb..3914c48 100644 --- a/core/templates/machine/index.jinja +++ b/core/templates/machine/index.jinja @@ -3,13 +3,9 @@
|
- | ||
| Front: | ||
| Back: | ||
| + | ||
{{ system_voices[profile.voiceId]["name"] if system_voices|length > 0 else "Server DB needs upgraded or is not populated with necessary data" }} |
{% endif %}
+ {% if cur_version >= 18 %}
+ ||
| Stage: | +{{ stages[profile.stageId]["name"] if stages|length > 0 else "Server DB needs upgraded or is not populated with necessary data" }} |
+ |
| Nameplate: | |
| Trophy: |
- |
| Trophy Sub 1: |
+ |
| Trophy Sub 2: |
+ |
| Character: | |
| @@ -124,7 +143,10 @@ userbox_components = { }; types = Object.keys(userbox_components); orig_trophy = curr_trophy = "{{ profile.trophyId }}"; +orig_trophy_sub_1 = curr_trophy_sub_1 = "{{ profile.trophyIdSub1 }}"; +orig_trophy_sub_2 = curr_trophy_sub_2 = "{{ profile.trophyIdSub2 }}"; curr_trophy_img = ""; +curr_trophy_name = ""; function enableButtons(enabled) { document.getElementById("reset-btn").disabled = !enabled; @@ -159,16 +181,17 @@ function changeItem(type, id, name, img) { function getRankImage(selected_rank) { for (const x of Array(12).keys()) { if (selected_rank.classList.contains("trophy-rank" + x.toString())) { - return "rank" + x.toString() + ".png"; + return "rank" + x.toString() + ".webp"; } } - return "rank0.png"; // shouldnt ever happen + return "rank0.webp"; // shouldnt ever happen } function changeTrophy() { var trophy_element = document.getElementById("trophy"); curr_trophy = trophy_element.value; + curr_trophy_name = trophy_element[trophy_element.selectedIndex].innerText curr_trophy_img = getRankImage(trophy_element[trophy_element.selectedIndex]); updatePreview(); if (curr_trophy != orig_trophy) { @@ -176,12 +199,38 @@ function changeTrophy() { } } +function changeTrophySub1() { + var trophy_element = document.getElementById("trophy-sub-1"); + + curr_trophy_sub_1 = trophy_element.value; + curr_trophy_img = getRankImage(trophy_element[trophy_element.selectedIndex]); + curr_trophy_name = trophy_element[trophy_element.selectedIndex].innerText + updatePreview(); + if (curr_trophy_sub_1 != orig_trophy_sub_1) { + enableButtons(true); + } +} + +function changeTrophySub2() { + var trophy_element = document.getElementById("trophy-sub-2"); + + curr_trophy_sub_2 = trophy_element.value; + curr_trophy_img = getRankImage(trophy_element[trophy_element.selectedIndex]); + curr_trophy_name = trophy_element[trophy_element.selectedIndex].innerText + updatePreview(); + if (curr_trophy_sub_2 != orig_trophy_sub_2) { + enableButtons(true); + } +} + function resetUserbox() { for (const type of types) { changeItem(type, userbox_components[type][orig_id], userbox_components[type][orig_name], userbox_components[type][orig_img]); } // reset trophy document.getElementById("trophy").value = orig_trophy; + document.getElementById("trophy-sub-1").value = orig_trophy_sub_1; + document.getElementById("trophy-sub-2").value = orig_trophy_sub_2; changeTrophy(); // disable the save/reset buttons until something changes enableButtons(false); @@ -193,12 +242,14 @@ function updatePreview() { document.getElementById("name_" + type).innerHTML = userbox_components[type][curr_name]; } document.getElementById("preview_trophy_rank").src = "img/rank/" + curr_trophy_img; - document.getElementById("preview_trophy_name").innerHTML = document.getElementById("trophy")[document.getElementById("trophy").selectedIndex].innerText; + document.getElementById("preview_trophy_name").innerHTML = curr_trophy_name; } function saveUserbox() { $.post("/game/chuni/update.userbox", { nameplate: userbox_components["nameplate"][curr_id], - trophy: curr_trophy, + trophy: curr_trophy, + trophySub1: curr_trophy_sub_1, + trophySub2: curr_trophy_sub_2, character: userbox_components["character"][curr_id] }) .done(function (data) { // set the current as the original and disable buttons @@ -207,7 +258,9 @@ function saveUserbox() { userbox_components[type][orig_name] = userbox_components[type][orig_name]; userbox_components[type][orig_img] = userbox_components[type][curr_img]; } - orig_trophy = curr_trophy + orig_trophy = curr_trophy; + orig_trophy_sub_1 = curr_trophy_sub_1; + orig_trophy_sub_2 = curr_trophy_sub_2; enableButtons(false); }) .fail(function () { diff --git a/titles/chuni/verse.py b/titles/chuni/verse.py new file mode 100644 index 0000000..55a4058 --- /dev/null +++ b/titles/chuni/verse.py @@ -0,0 +1,281 @@ +from datetime import datetime, timedelta +from typing import Dict, List, Set + +from core.config import CoreConfig +from titles.chuni.config import ChuniConfig +from titles.chuni.const import ( + ChuniConstants, + MapAreaConditionLogicalOperator, + MapAreaConditionType, +) +from titles.chuni.luminous import MysticAreaConditions +from titles.chuni.luminousplus import ChuniLuminousPlus + + +class ChuniVerse(ChuniLuminousPlus): + def __init__(self, core_cfg: CoreConfig, game_cfg: ChuniConfig) -> None: + super().__init__(core_cfg, game_cfg) + self.version = ChuniConstants.VER_CHUNITHM_VERSE + + async def handle_c_m_get_user_preview_api_request(self, data: Dict) -> Dict: + user_data = await super().handle_c_m_get_user_preview_api_request(data) + + # Does CARD MAKER 1.35 work this far up? + user_data["lastDataVersion"] = "2.30.00" + return user_data + + async def handle_get_game_map_area_condition_api_request(self, data: Dict) -> Dict: + # There is no game data for this, everything is server side. + # However, we can selectively show/hide events as data is imported into the server. + events = await self.data.static.get_enabled_events(self.version) + event_by_id = {evt["eventId"]: evt for evt in events} + conditions = [] + + mystic_conditions = MysticAreaConditions( + event_by_id, + 3230401, + self.date_time_format, + ) + + # Mystic Rainbow of VERSE - VERSE ep. I + mystic_conditions.add_condition(16006, 3020798, 3230402) + + # Mystic Rainbow of VERSE - VERSE ep. II + mystic_conditions.add_condition(16204, 3020799, 3230403) + + # Mystic Rainbow of VERSE - VERSE ep. III + mystic_conditions.add_condition(16455, 3020800, 3230404) + + # Mystic Rainbow of VERSE - VERSE ep. IV + mystic_conditions.add_condition(16607, 3020802, 3230405) + + conditions += mystic_conditions.conditions + + return { + "length": len(conditions), + "gameMapAreaConditionList": conditions, + } + + async def handle_get_game_course_level_api_request(self, data: Dict) -> Dict: + unlock_challenges = await self.data.static.get_unlock_challenges(self.version) + game_course_level_list = [] + + for unlock_challenge in unlock_challenges: + course_ids = [ + unlock_challenge[f"courseId{i}"] + for i in range(1, 6) + if unlock_challenge[f"courseId{i}"] is not None + ] + + start_date = unlock_challenge["startDate"].replace( + hour=0, minute=0, second=0 + ) + + for i, course_id in enumerate(course_ids): + start = start_date + timedelta(days=7 * i) + end = start_date + timedelta(days=7 * (i + 1)) - timedelta(seconds=1) + + if i == len(course_ids) - 1: + # If this is the last course, set end date to a far future date + end = datetime(2099, 1, 1) + + game_course_level_list.append( + { + "courseId": course_id, + "startDate": start.strftime(self.date_time_format), + "endDate": end.strftime(self.date_time_format), + } + ) + + return { + "length": len(game_course_level_list), + "gameCourseLevelList": game_course_level_list, + } + + async def handle_get_game_u_c_condition_api_request(self, data: Dict) -> Dict: + unlock_challenges = await self.data.static.get_unlock_challenges(self.version) + game_unlock_challenge_condition_list = [] + + conditions = { + # unlock Theatore Creatore (ULTIMA) after clearing map VERSE ep. I + 10001: { + "type": MapAreaConditionType.MAP_CLEARED.value, + "conditionId": 3020798, + }, + # unlock Crossmythos Rhapsodia after clearing map VERSE ep. IV + 10006: { + "type": MapAreaConditionType.MAP_CLEARED.value, + "conditionId": 3020802, + }, + } + + for unlock_challenge in unlock_challenges: + unlock_challenge_id = unlock_challenge["unlockChallengeId"] + + unlock_condition = conditions.get( + unlock_challenge_id, + # default is to unlock for players above 5.00 rating + { + "type": MapAreaConditionType.MINIMUM_RATING.value, + "conditionId": 500, + }, + ) + + game_unlock_challenge_condition_list.append( + { + "unlockChallengeId": unlock_challenge_id, + "length": 1, + "conditionList": [ + { + "type": unlock_condition["type"], + "conditionId": unlock_condition["conditionId"], + "logicalOpe": MapAreaConditionLogicalOperator.AND.value, + "startDate": unlock_challenge["startDate"].strftime( + self.date_time_format + ), + "endDate": datetime(2099, 1, 1).strftime( + self.date_time_format + ), + } + ], + } + ) + + return { + "length": len(game_unlock_challenge_condition_list), + "gameUnlockChallengeConditionList": game_unlock_challenge_condition_list, + } + + async def handle_get_user_u_c_api_request(self, data: Dict) -> Dict: + user_id = data["userId"] + + user_unlock_challenges = await self.data.item.get_unlock_challenges( + user_id, self.version + ) + + user_unlock_challenge_list = [ + { + "unlockChallengeId": user_uc["unlockChallengeId"], + "status": user_uc["status"], + "clearCourseId": user_uc["clearCourseId"], + "conditionType": user_uc["conditionType"], + "score": user_uc["score"], + "life": user_uc["life"], + "clearDate": user_uc["clearDate"].strftime(self.date_time_format), + } + for user_uc in user_unlock_challenges + ] + + return { + "userId": user_id, + "userUnlockChallengeList": user_unlock_challenge_list, + } + + async def handle_get_user_rec_music_api_request(self, data: Dict) -> Dict: + rec_limit = 25 # limit for recommendations + user_id = data["userId"] + user_rec_music_set = set() + + recent_rating = await self.data.profile.get_profile_recent_rating(user_id) + if not recent_rating: + # If no recent ratings, return an empty list + return { + "length": 0, + "userRecMusicList": [], + } + + recent_ratings = recent_rating["recentRating"] + # cache music info + music_info_list = [] + + for recent_rating in recent_ratings: + music_id = recent_rating["musicId"] + music_info = await self.data.static.get_song(music_id) + if music_info: + music_info_list.append(music_info) + + # use a set to avoid duplicates + user_rec_music_set = set() + + # try adding recommendations in order of: title → artist → genre + for field in ("title", "artist", "genre"): + await self._add_recommendations( + field, user_rec_music_set, music_info_list, rec_limit + ) + if len(user_rec_music_set) >= rec_limit: + break + + user_rec_music_list = [ + { + "musicId": 1, # a song the player recently played + # recMusicList is a semi colon-separated list of music IDs and their order comma separated + # for some reason, not all music ids are shown in game?! + "recMusicList": ";".join( + f"{music_id},{index + 1}" + for index, music_id in enumerate(user_rec_music_set) + ), + }, + ] + + return { + "length": len(user_rec_music_list), + "userRecMusicList": user_rec_music_list, + } + + async def handle_get_user_rec_rating_api_request(self, data: Dict) -> Dict: + class GetUserRecRatingApi: + class UserRecRating: + ratingMin: int + ratingMax: int + # semicolon-delimited list of (musicId, level, sortingKey, score), in the + # same format as GetUserRecMusicApi + recMusicList: str + + length: int + userRecRatingList: list[UserRecRating] + + user_id = data["userId"] + + user_rec_rating_list = [] + + return { + "length": len(user_rec_rating_list), + "userRecRatingList": user_rec_rating_list, + } + + async def _add_recommendations( + self, + field: str, + user_rec_music_set: Set[int], + music_info_list: List[Dict], + limit: int = 25, + ) -> None: + """ + Adds music recommendations based on a specific metadata field (title/artist/genre), + excluding music IDs already in the user's recent ratings and recommendations. + """ + # Collect all existing songId to exclude from recommendations + existing_music_ids = {info["songId"] for info in music_info_list} + + for music_info in music_info_list: + if len(user_rec_music_set) >= limit: + break + + metadata_value = music_info[field] + if not metadata_value: + continue + + recs = await self.data.static.get_music_by_metadata( + **{field: metadata_value} + ) + for rec in recs or []: + song_id = rec["songId"] + # skip if the song is already in the user's recent ratings + # or if the song is already in the user's recommendations + if ( + len(user_rec_music_set) >= limit + or song_id in existing_music_ids + or song_id in user_rec_music_set + ): + continue + user_rec_music_set.add(song_id) diff --git a/titles/chuni/xverse.py b/titles/chuni/xverse.py new file mode 100644 index 0000000..56f51d1 --- /dev/null +++ b/titles/chuni/xverse.py @@ -0,0 +1,317 @@ +import asyncio +from datetime import datetime, timedelta, timezone +from typing import Dict + +from core.config import CoreConfig + +from .config import ChuniConfig +from .const import ( + ChuniConstants, + LinkedVerseUnlockConditionType, + MapAreaConditionLogicalOperator, + MapAreaConditionType, +) +from .luminous import MysticAreaConditions +from .verse import ChuniVerse + + +class ChuniXVerse(ChuniVerse): + def __init__(self, core_cfg: CoreConfig, game_cfg: ChuniConfig) -> None: + super().__init__(core_cfg, game_cfg) + self.version = ChuniConstants.VER_CHUNITHM_X_VERSE + + async def handle_c_m_get_user_preview_api_request(self, data: Dict) -> Dict: + user_data = await super().handle_c_m_get_user_preview_api_request(data) + + # Does CARD MAKER 1.35 work this far up? + user_data["lastDataVersion"] = "2.40.00" + return user_data + + async def handle_get_game_map_area_condition_api_request(self, data: Dict) -> Dict: + events = await self.data.static.get_enabled_events(self.version) + + if events is None: + return {"length": 0, "gameMapAreaConditionList": []} + + events_by_id = {event["eventId"]: event for event in events} + mystic_conditions = MysticAreaConditions( + events_by_id, 3239201, self.date_time_format + ) + + # Mystic Rainbow of X-VERSE Area 2 unlocks when VERSE ep. ORIGIN is finished. + mystic_conditions.add_condition(17021, 3020803, 3239202) + + # Mystic Rainbow of X-VERSE Area 3 unlocks when VERSE ep. AIR is finished. + mystic_conditions.add_condition(17104, 3020804, 3239203) + + # Mystic Rainbow of X-VERSE Area 4 unlocks when VERSE ep. STAR is finished. + mystic_conditions.add_condition(17208, 3020805, 3239204) + + # Mystic Rainbow of X-VERSE Area 5 unlocks when VERSE ep. AMAZON is finished. + mystic_conditions.add_condition(17304, 3020806, 3239205) + + # Mystic Rainbow of X-VERSE Area 6 unlocks when VERSE ep. CRYSTAL is finished. + mystic_conditions.add_condition(17407, 3020807, 3239206) + + # Mystic Rainbow of X-VERSE Area 7 unlocks when VERSE ep. PARADISE is finished. + mystic_conditions.add_condition(17483, 3020808, 3239207) + + return { + "length": len(mystic_conditions.conditions), + "gameMapAreaConditionList": mystic_conditions.conditions, + } + + async def handle_get_game_course_level_api_request(self, data: Dict) -> Dict: + uc_likes = [] # includes both UCs and LVs, though the former doesn't show up at all in X-VERSE + unlock_challenges, linked_verses = await asyncio.gather( + self.data.static.get_unlock_challenges(self.version), + self.data.static.get_linked_verses(self.version), + ) + + if unlock_challenges: + uc_likes.extend(unlock_challenges) + + if linked_verses: + uc_likes.extend(linked_verses) + + if not uc_likes: + return {"length": 0, "gameCourseLevelList": []} + + course_level_list = [] + current_time = datetime.now(timezone.utc).replace(tzinfo=None) + + for uc_like in uc_likes: + course_ids = [ + uc_like[f"courseId{i}"] + for i in range(1, 6) + if uc_like[f"courseId{i}"] is not None + ] + event_start_date = uc_like["startDate"].replace(hour=0, minute=0, second=0) + + for i, course_id in enumerate(course_ids): + start_date = event_start_date + timedelta(days=7 * i) + + if i == len(course_ids) - 1: + end_date = datetime(2099, 12, 31, 23, 59, 59) + else: + end_date = ( + event_start_date + + timedelta(days=7 * (i + 1)) + - timedelta(seconds=1) + ) + + if start_date <= current_time <= end_date: + course_level_list.append( + { + "courseId": course_id, + "startDate": start_date.strftime(self.date_time_format), + "endDate": end_date.strftime(self.date_time_format), + } + ) + + return { + "length": len(course_level_list), + "gameCourseLevelList": course_level_list, + } + + async def handle_get_game_l_v_condition_open_api_request(self, data: Dict) -> Dict: + linked_verses = await self.data.static.get_linked_verses(self.version) + + if not linked_verses: + return {"length": 0, "gameLinkedVerseConditionOpenList": []} + + linked_verse_by_id = {r["linkedVerseId"]: r for r in linked_verses} + conditions = [] + + for lv_id, map_id in [ + (10001, 3020803), # ORIGIN + (10002, 3020804), # AIR + (10003, 3020805), # STAR + (10004, 3020806), # AMAZON + (10005, 3020807), # CRYSTAL + (10006, 3020808), # PARADISE + ]: + if (lv := linked_verse_by_id.get(lv_id)) is None: + continue + + conditions.append( + { + "linkedVerseId": lv["linkedVerseId"], + "length": 1, + "conditionList": [ + { + "type": MapAreaConditionType.MAP_CLEARED.value, + "conditionId": map_id, + "logicalOpe": MapAreaConditionLogicalOperator.AND.value, + "startDate": lv["startDate"].strftime( + self.date_time_format + ), + "endDate": "2099-12-31 23:59:59", + } + ], + } + ) + + return { + "length": len(conditions), + "gameLinkedVerseConditionOpenList": conditions, + } + + async def handle_get_game_l_v_condition_unlock_api_request( + self, data: Dict + ) -> Dict: + linked_verses = await self.data.static.get_linked_verses(self.version) + + if not linked_verses: + return { + "length": 0, + "gameLinkedVerseConditionUnlockList": [], + } + + linked_verse_by_id = {r["linkedVerseId"]: r for r in linked_verses} + conditions = [] + + # For reference on official Linked VERSE conditions: + # https://docs.google.com/spreadsheets/d/1j7kmCR0-R5W3uivwkw-6A_eUCXttnJLnkTO0Qf7dya0/edit?usp=sharing + + # Linked GATE ORIGIN - Play 30 ORIGIN Fables songs + if gate_origin := linked_verse_by_id.get(10001): + conditions.append( + { + "linkedVerseId": gate_origin["linkedVerseId"], + "length": 1, + "conditionList": [ + { + "type": LinkedVerseUnlockConditionType.PLAY_SONGS.value, + "conditionList": "59;79;148;71;75;140;163;80;51;64;65;74;95;67;53;100;108;107;105;82;76;141;63;147;69;151;70;101;152;180", + "logicalOpe": MapAreaConditionLogicalOperator.AND.value, + "startDate": gate_origin["startDate"].strftime( + self.date_time_format + ), + "endDate": "2099-12-31 00:00:00", + } + ], + } + ) + + # Linked GATE AIR - Obtain class banner + if gate_air := linked_verse_by_id.get(10002): + conditions.append( + { + "linkedVerseId": gate_air["linkedVerseId"], + "length": 1, + "conditionList": [ + { + "type": LinkedVerseUnlockConditionType.COURSE_CLEAR_AND_CLASS_EMBLEM.value, + "conditionList": "1_2_3_4_5_6", + "logicalOpe": MapAreaConditionLogicalOperator.AND.value, + "startDate": gate_air["startDate"].strftime( + self.date_time_format + ), + "endDate": "2099-12-31 00:00:00", + } + ], + } + ) + + # Linked GATE STAR - Obtain a trophy by leveling a character to level 15 + if gate_star := linked_verse_by_id.get(10003): + conditions.append( + { + "linkedVerseId": gate_star["linkedVerseId"], + "length": 1, + "conditionList": [ + { + "type": LinkedVerseUnlockConditionType.TROPHY_OBTAINED.value, + "conditionList": "9718", + "logicalOpe": MapAreaConditionLogicalOperator.AND.value, + "startDate": gate_star["startDate"].strftime( + self.date_time_format + ), + "endDate": "2099-12-31 00:00:00", + } + ], + } + ) + + # Linked GATE AMAZON - Play Killing Rhythm and Climax from the favorites folder + if gate_amazon := linked_verse_by_id.get(10004): + conditions.append( + { + "linkedVerseId": gate_amazon["linkedVerseId"], + "length": 1, + "conditionList": [ + { + "type": LinkedVerseUnlockConditionType.PLAY_SONGS_IN_FAVORITE.value, + "conditionList": "712;777", + "logicalOpe": MapAreaConditionLogicalOperator.AND.value, + "startDate": gate_amazon["startDate"].strftime( + self.date_time_format + ), + "endDate": "2099-12-31 00:00:00", + } + ], + } + ) + + # Linked GATE CRYSTAL - Clear team course while equipping a character of minimum rank 26 + if gate_crystal := linked_verse_by_id.get(10005): + conditions.append( + { + "linkedVerseId": gate_crystal["linkedVerseId"], + "length": 1, + "conditionList": [ + { + "type": LinkedVerseUnlockConditionType.CLEAR_TEAM_COURSE_WITH_CHARACTER_OF_MINIMUM_RANK.value, + "conditionList": "26", + "logicalOpe": MapAreaConditionLogicalOperator.AND.value, + "startDate": gate_crystal["startDate"].strftime( + self.date_time_format + ), + "endDate": "2099-12-31 00:00:00", + } + ], + } + ) + + # Linked GATE PARADISE - Play one solo song by each of the artists in Inori + if gate_paradise := linked_verse_by_id.get(10006): + conditions.append( + { + "linkedVerseId": gate_paradise["linkedVerseId"], + "length": 1, + "conditionList": [ + { + "type": LinkedVerseUnlockConditionType.PLAY_SONGS.value, + "conditionList": "180_384_2355;407_2353;788_629_600;2704;2050_2354", + "logicalOpe": MapAreaConditionLogicalOperator.AND.value, + "startDate": gate_paradise["startDate"].strftime( + self.date_time_format + ), + "endDate": "2099-12-31 00:00:00", + } + ], + } + ) + + return { + "length": len(conditions), + "gameLinkedVerseConditionUnlockList": conditions, + } + + async def handle_get_user_l_v_api_request(self, data: Dict) -> Dict: + user_id = int(data["userId"]) + rows = await self.data.item.get_linked_verse(user_id) or [] + linked_verses = [] + + for row in rows: + data = row._asdict() + data.pop("id") + data.pop("user") + + linked_verses.append(data) + + return { + "userId": user_id, + "userLinkedVerseList": linked_verses, + } diff --git a/titles/cm/read.py b/titles/cm/read.py index 8a1bb84..5eb8632 100644 --- a/titles/cm/read.py +++ b/titles/cm/read.py @@ -208,7 +208,8 @@ class CardMakerReader(BaseReader): "1.35": Mai2Constants.VER_MAIMAI_DX_FESTIVAL_PLUS, "1.40": Mai2Constants.VER_MAIMAI_DX_BUDDIES, "1.45": Mai2Constants.VER_MAIMAI_DX_BUDDIES_PLUS, - "1.50": Mai2Constants.VER_MAIMAI_DX_PRISM + "1.50": Mai2Constants.VER_MAIMAI_DX_PRISM, + "1.55": Mai2Constants.VER_MAIMAI_DX_PRISM_PLUS } for root, dirs, files in os.walk(base_dir): diff --git a/titles/diva/index.py b/titles/diva/index.py index 01e5eeb..bbba473 100644 --- a/titles/diva/index.py +++ b/titles/diva/index.py @@ -100,7 +100,7 @@ class DivaServlet(BaseServlet): try: handler = getattr(self.base, f"handle_{bin_req_data['cmd']}_request") - resp = handler(bin_req_data) + resp = await handler(bin_req_data) except AttributeError as e: self.logger.warning(f"Unhandled {bin_req_data['cmd']} request {e}") diff --git a/titles/idac/index.py b/titles/idac/index.py index 00d90b1..a55f891 100644 --- a/titles/idac/index.py +++ b/titles/idac/index.py @@ -166,8 +166,8 @@ class IDACServlet(BaseServlet): resp = { "status_code": "0", # Only IPv4 is supported - "host": self.game_config.server.matching_host, - "port": self.game_config.server.matching_p2p, + "host": self.game_cfg.server.matching_host, + "port": self.game_cfg.server.matching_p2p, "room_name": "INDTA", "state": 1, } diff --git a/titles/idz/index.py b/titles/idz/index.py index 0ff5b8d..a9d6775 100644 --- a/titles/idz/index.py +++ b/titles/idz/index.py @@ -22,6 +22,7 @@ class IDZServlet(BaseServlet): def __init__(self, core_cfg: CoreConfig, cfg_dir: str) -> None: super().__init__(core_cfg, cfg_dir) self.game_cfg = IDZConfig() + self.rsa_keys: List[IDZKey] = [] if path.exists(f"{cfg_dir}/{IDZConstants.CONFIG_NAME}"): self.game_cfg.update( yaml.safe_load(open(f"{cfg_dir}/{IDZConstants.CONFIG_NAME}")) @@ -38,8 +39,6 @@ class IDZServlet(BaseServlet): backupCount=10, ) - self.rsa_keys: List[IDZKey] = [] - fileHandler.setFormatter(log_fmt) consoleHandler = logging.StreamHandler() @@ -79,7 +78,32 @@ class IDZServlet(BaseServlet): 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") + logger = logging.getLogger("idz") + if not hasattr(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(core_cfg.server.log_dir, "idz"), + encoding="utf8", + when="d", + backupCount=10, + ) + + fileHandler.setFormatter(log_fmt) + + consoleHandler = logging.StreamHandler() + consoleHandler.setFormatter(log_fmt) + + logger.addHandler(fileHandler) + logger.addHandler(consoleHandler) + + logger.setLevel(game_cfg.server.loglevel) + coloredlogs.install( + level=game_cfg.server.loglevel, logger=logger, fmt=log_fmt_str + ) + logger.inited = True + + logger.error("No RSA/AES keys! IDZ cannot start") return False return True diff --git a/titles/idz/userdb.py b/titles/idz/userdb.py index 089778a..83aeabd 100644 --- a/titles/idz/userdb.py +++ b/titles/idz/userdb.py @@ -53,28 +53,31 @@ class IDZUserDB: async def connection_cb(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter): self.logger.debug(f"Connection made from {writer.get_extra_info('peername')[0]}") + sent_handshake = False while True: try: - base = 0 + if not sent_handshake: + base = 0 - for i in range(len(self.static_key) - 1): - shift = 8 * i - byte = self.static_key[i] + for i in range(len(self.static_key) - 1): + shift = 8 * i + byte = self.static_key[i] - base |= byte << shift + 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: + async def dataReceived(self, data: bytes, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: self.logger.debug(f"Receive data {data.hex()}") client_ip = writer.get_extra_info('peername')[0] crypt = AES.new(self.static_key, AES.MODE_ECB) diff --git a/titles/mai2/__init__.py b/titles/mai2/__init__.py index 234e864..4c1739d 100644 --- a/titles/mai2/__init__.py +++ b/titles/mai2/__init__.py @@ -18,4 +18,5 @@ game_codes = [ Mai2Constants.GAME_CODE_GREEN, Mai2Constants.GAME_CODE, Mai2Constants.GAME_CODE_DX_INT, + Mai2Constants.GAME_CODE_DX_CHN, ] diff --git a/titles/mai2/base.py b/titles/mai2/base.py index 5d1c767..1983ef7 100644 --- a/titles/mai2/base.py +++ b/titles/mai2/base.py @@ -139,6 +139,9 @@ class Mai2Base: async def handle_get_game_ng_music_id_api_request(self, data: Dict) -> Dict: return {"length": 0, "musicIdList": []} + async def handle_get_game_ng_word_list_api_request(self, data: Dict) -> Dict: + return {"ngWordExactMatchLength": 0, "ngWordExactMatchList": [], "ngWordPartialMatchLength": 0, "ngWordPartialMatchList": []} + async def handle_get_game_charge_api_request(self, data: Dict) -> Dict: game_charge_list = await self.data.static.get_enabled_tickets(self.version, 1) if game_charge_list is None: diff --git a/titles/mai2/buddiesplus.py b/titles/mai2/buddiesplus.py index e87fae6..6ed0025 100644 --- a/titles/mai2/buddiesplus.py +++ b/titles/mai2/buddiesplus.py @@ -58,3 +58,62 @@ class Mai2BuddiesPlus(Mai2Buddies): "friendBonusFlag": False } } + + async def handle_get_user_friend_check_api_request(self, data: Dict) -> Dict: + user1rivalList = await self.data.profile.get_rivals(data["userId1"]) + user2rivalList = await self.data.profile.get_rivals(data["userId2"]) + + is_user2_in_user1_rivals = any(rival["rival"] == data["userId2"] for rival in user1rivalList) + is_user1_in_user2_rivals = any(rival["rival"] == data["userId1"] for rival in user2rivalList) + + if is_user2_in_user1_rivals and is_user1_in_user2_rivals: + return {"returnCode": 0} + else: + return {"returnCode": 1} + + async def handle_user_friend_regist_api_request(self, data: Dict) -> Dict: + user1rivalList = await self.data.profile.get_rivals(data["userId1"]) or [] + user2rivalList = await self.data.profile.get_rivals(data["userId2"]) or [] + + is_user2_in_user1_rivals = any(row.rival == data["userId2"] for row in user1rivalList) + is_user1_in_user2_rivals = any(row.rival == data["userId1"] for row in user2rivalList) + user1_show_count = sum(1 for row in user1rivalList if row.show is True) + user2_show_count = sum(1 for row in user2rivalList if row.show is True) + + # initialize returnCode + returnCode1 = 2 + returnCode2 = 2 + + # Case1 no rival + if not is_user2_in_user1_rivals and not is_user1_in_user2_rivals: + if user1_show_count >= 3 and user2_show_count >= 3: + returnCode1, returnCode2 = 1, 1 + elif user1_show_count >= 3: + returnCode1, returnCode2 = 1, 2 + elif user2_show_count >= 3: + returnCode1, returnCode2 = 2, 1 + + # Case2 has single rival + elif is_user2_in_user1_rivals != is_user1_in_user2_rivals: + if user1_show_count >= 3 and user2_show_count >= 3: + returnCode1, returnCode2 = 1, 1 + elif user1_show_count >= 3: + returnCode1, returnCode2 = 1, 2 + elif user2_show_count >= 3: + returnCode1, returnCode2 = 2, 1 + + # execute add_rival and show_rival + if not is_user2_in_user1_rivals: + await self.data.profile.add_rival(data["userId1"], data["userId2"]) + if returnCode1 == 2 and user1_show_count < 3: + await self.data.profile.set_rival_shown(data["userId1"], data["userId2"], True) + + if not is_user1_in_user2_rivals: + await self.data.profile.add_rival(data["userId2"], data["userId1"]) + if returnCode2 == 2 and user2_show_count < 3: + await self.data.profile.set_rival_shown(data["userId2"], data["userId1"], True) + + return { + "returnCode1": returnCode1, + "returnCode2": returnCode2 + } \ No newline at end of file diff --git a/titles/mai2/config.py b/titles/mai2/config.py index efd3ba5..2e24bc9 100644 --- a/titles/mai2/config.py +++ b/titles/mai2/config.py @@ -20,6 +20,12 @@ class Mai2ServerConfig: self.__config, "mai2", "server", "loglevel", default="info" ) ) + + @property + def use_https(self) -> bool: + return CoreConfig.get_config_field( + self.__config, "mai2", "server", "use_https", default=False + ) class Mai2DeliverConfig: def __init__(self, parent: "Mai2Config") -> None: @@ -71,6 +77,22 @@ class Mai2UploadsConfig: self.__config, "mai2", "uploads", "movies_dir", default="" ) +class Mai2OnlineChartsConfig: + def __init__(self, parent: "Mai2Config") -> None: + self.__config = parent + + @property + def enable(self) -> bool: + return CoreConfig.get_config_field( + self.__config, "mai2", "chart_deliver", "enable", default=False + ) + + @property + def chart_folder(self) -> int: + return CoreConfig.get_config_field( + self.__config, "mai2", "chart_deliver", "chart_folder", default="" + ) + class Mai2CryptoConfig: def __init__(self, parent_config: "Mai2Config") -> None: @@ -100,4 +122,5 @@ class Mai2Config(dict): self.server = Mai2ServerConfig(self) self.deliver = Mai2DeliverConfig(self) self.uploads = Mai2UploadsConfig(self) - self.crypto = Mai2CryptoConfig(self) \ No newline at end of file + self.crypto = Mai2CryptoConfig(self) + self.charts = Mai2OnlineChartsConfig(self) \ No newline at end of file diff --git a/titles/mai2/const.py b/titles/mai2/const.py index 99642b2..117ba6f 100644 --- a/titles/mai2/const.py +++ b/titles/mai2/const.py @@ -32,6 +32,7 @@ class Mai2Constants: GAME_CODE_FINALE = "SDEY" GAME_CODE_DX = "SDEZ" GAME_CODE_DX_INT = "SDGA" + GAME_CODE_DX_CHN = "SDGB" CONFIG_NAME = "mai2.yaml" @@ -60,6 +61,7 @@ class Mai2Constants: VER_MAIMAI_DX_BUDDIES = 21 VER_MAIMAI_DX_BUDDIES_PLUS = 22 VER_MAIMAI_DX_PRISM = 23 + VER_MAIMAI_DX_PRISM_PLUS = 24 VERSION_STRING = ( "maimai", @@ -85,7 +87,8 @@ class Mai2Constants: "maimai DX FESTiVAL PLUS", "maimai DX BUDDiES", "maimai DX BUDDiES PLUS", - "maimai DX PRiSM" + "maimai DX PRiSM", + "maimai DX PRiSM PLUS" ) KALEIDXSCOPE_KEY_CONDITION={ 1: [11009, 11008, 11100, 11097, 11098, 11099, 11163, 11162, 11161, 11228, 11229, 11231, 11463, 11464, 11465, 11538, 11539, 11541, 11620, 11622, 11623, 11737, 11738, 11164, 11230, 11466, 11540, 11621, 11739], @@ -93,9 +96,21 @@ class Mai2Constants: 2: [11102, 11234, 11300, 11529, 11542, 11612], #白の扉: set Frame as "Latent Kingdom" (459504), play 3 or 4 songs by the composer 大国奏音 in 1 pc 3: [], - #紫の扉: need to enter redeem code 51090942171709440000 + #紫の扉: JP: need to enter redeem code 51090942171709440000 4: [11023, 11106, 11221, 11222, 11300, 11374, 11458, 11523, 11619, 11663, 11746], - #青の扉: Played 11 songs + #黑の扉: Played 11 songs + 5: [11003, 11095, 11152, 11224, 11296, 11375, 11452, 11529, 11608, 11669, 11736, 11806], + #黄の扉: Use random selection to play one of the songs + 6: [212, 213, 337, 270, 271, 11504, 339, 453, 11336, 11852], + #赤の扉: Played 10 songs + 7: [], + #PRISM TOWER: Get the key after clearing six doors. + 8: [], + #KALEIDXSCOPE_FIRST_STAGE: Clear Prism Tower + 9: [], + #希望の扉: CLEAR KALEIDXSCOPE_FIRST_STAGE + 10: [] + #KALEIDXSCOPE_SECOND_STAGE: JP: scan the DXPASS of 希望の鍵, will automatically unlock after clearing 希望の扉 in artemis } MAI_VERSION_LUT = { "100": VER_MAIMAI, @@ -124,7 +139,8 @@ class Mai2Constants: "135": VER_MAIMAI_DX_FESTIVAL_PLUS, "140": VER_MAIMAI_DX_BUDDIES, "145": VER_MAIMAI_DX_BUDDIES_PLUS, - "150": VER_MAIMAI_DX_PRISM + "150": VER_MAIMAI_DX_PRISM, + "155": VER_MAIMAI_DX_PRISM_PLUS } @classmethod diff --git a/titles/mai2/dx.py b/titles/mai2/dx.py index 9b8b547..498aabb 100644 --- a/titles/mai2/dx.py +++ b/titles/mai2/dx.py @@ -258,9 +258,9 @@ class Mai2DX(Mai2Base): if kind_id is not None: await self.data.item.put_favorite(user_id, kind_id, fav["itemIdList"]) - if "userFavoritemusicList" in upsert and len(upsert["userFavoritemusicList"]) > 0: - for fav in upsert["userFavoritemusicList"]: - await self.data.item.add_fav_music(user_id, fav["id"], fav["orderId"]) + # added in BUDDiES+ + if "isNewFavoritemusicList" in upsert and upsert["isNewFavoritemusicList"] != "" and "userFavoritemusicList" in upsert: + await self.data.item.put_fav_music(user_id, ((fav["id"], fav["orderId"]) for fav in upsert["userFavoritemusicList"])) if ( "userFriendSeasonRankingList" in upsert diff --git a/titles/mai2/frontend.py b/titles/mai2/frontend.py index c760e13..aa030b8 100644 --- a/titles/mai2/frontend.py +++ b/titles/mai2/frontend.py @@ -46,6 +46,9 @@ class Mai2Frontend(FE_Base): Route("/update.name", self.update_name, methods=['POST']), Route("/version.change", self.version_change, methods=['POST']), Route("/photo/{photo_id}", self.get_photo, methods=['GET']), + Route("/rival.add", self.rival_POST, methods=['POST']), + Route("/rival.delete", self.rival_POST, methods=['POST']), + Route("/rival.show", self.rival_POST, methods=['POST']), ] async def render_GET(self, request: Request) -> bytes: @@ -61,11 +64,22 @@ class Mai2Frontend(FE_Base): if usr_sesh.user_id > 0: versions = await self.data.profile.get_all_profile_versions(usr_sesh.user_id) profile = [] + new_rival_list = [] if versions: # maimai_version is -1 means it is not initialized yet, select a default version from existing. if incoming_ver < 0: usr_sesh.maimai_version = versions[0]['version'] profile = await self.data.profile.get_profile_detail(usr_sesh.user_id, usr_sesh.maimai_version) + rival_list = await self.data.profile.get_rivals(usr_sesh.user_id) + + for rival in rival_list: + rivalid = rival["rival"] + rivalShow = rival["show"] + rivalprofile = await self.data.profile.get_profile_detail(rivalid, usr_sesh.maimai_version) + rivalName = rivalprofile["userName"] if rivalprofile else "UnknownName" + rivalRating = rivalprofile["playerRating"] if rivalprofile else 0 + new_rival = (rivalName, rivalRating, rivalid, rivalShow) + new_rival_list.append(new_rival) versions = [x['version'] for x in versions] resp = Response(template.render( @@ -76,7 +90,8 @@ class Mai2Frontend(FE_Base): profile=profile, version_list=Mai2Constants.VERSION_STRING, versions=versions, - cur_version=usr_sesh.maimai_version + cur_version=usr_sesh.maimai_version, + rival_list=new_rival_list ), media_type="text/html; charset=utf-8") if incoming_ver < 0: @@ -420,3 +435,28 @@ class Mai2Frontend(FE_Base): return FileResponse(f"{out_folder}.jpeg") return Response(status_code=404) + async def rival_POST(self, request: Request): + uri = request.url.path + frm = await request.form() + usr_sesh = self.validate_session(request) + if not usr_sesh: + usr_sesh = UserSession() + + if usr_sesh.user_id > 0: + if uri == "/game/mai2/rival.add": + rival_id = frm.get("rivalUserId") + await self.data.profile.add_rival(usr_sesh.user_id, rival_id) + # self.logger.info(f"{usr_sesh.user_id} added a rival") + return RedirectResponse("/game/mai2/", 303) + + elif uri == "/game/mai2/rival.delete": + rival_id = frm.get("rivalUserId") + await self.data.profile.remove_rival(usr_sesh.user_id, rival_id) + # self.logger.info(f"{response}") + return RedirectResponse("/game/mai2/", 303) + + elif uri == "/game/mai2/rival.show": + rival_id = frm.get("rivalUserId") + show = frm.get("showRival", "false") == "true" + await self.data.profile.set_rival_shown(usr_sesh.user_id, rival_id, show) + return RedirectResponse("/game/mai2/", 303) \ No newline at end of file diff --git a/titles/mai2/index.py b/titles/mai2/index.py index d8e2a4f..d753379 100644 --- a/titles/mai2/index.py +++ b/titles/mai2/index.py @@ -32,13 +32,14 @@ from .festivalplus import Mai2FestivalPlus from .buddies import Mai2Buddies from .buddiesplus import Mai2BuddiesPlus from .prism import Mai2Prism +from .prismplus import Mai2PrismPlus class Mai2Servlet(BaseServlet): def __init__(self, core_cfg: CoreConfig, cfg_dir: str) -> None: super().__init__(core_cfg, cfg_dir) self.game_cfg = Mai2Config() - self.hash_table: Dict[int, Dict[str, str]] = {} + self.hash_table: Dict[str, Dict[str, str]] = {} if path.exists(f"{cfg_dir}/{Mai2Constants.CONFIG_NAME}"): self.game_cfg.update( yaml.safe_load(open(f"{cfg_dir}/{Mai2Constants.CONFIG_NAME}")) @@ -68,7 +69,8 @@ class Mai2Servlet(BaseServlet): Mai2FestivalPlus, Mai2Buddies, Mai2BuddiesPlus, - Mai2Prism + Mai2Prism, + Mai2PrismPlus ] self.logger = logging.getLogger("mai2") @@ -97,16 +99,21 @@ class Mai2Servlet(BaseServlet): self.logger.initted = True for version, keys in self.game_cfg.crypto.keys.items(): - if version < Mai2Constants.VER_MAIMAI_DX: + if int(str(version).split('_')[0]) < Mai2Constants.VER_MAIMAI_DX: continue if len(keys) < 3: continue + if isinstance(version, int): + version_idx = version + else: + version_idx = int(version.split("_")[0]) + self.hash_table[version] = {} method_list = [ method - for method in dir(self.versions[version]) + for method in dir(self.versions[version_idx]) if not method.startswith("__") ] @@ -115,6 +122,21 @@ class Mai2Servlet(BaseServlet): # remove the first 6 chars and the final 7 chars to get the canonical # endpoint name. method_fixed = inflection.camelize(method)[6:-7] + + # This only applies for maimai DX International and later for some reason. + if ( + isinstance(version, str) + and version.endswith("_int") + and version_idx >= Mai2Constants.VER_MAIMAI_DX_UNIVERSE + ): + method_fixed += "MaimaiExp" + elif ( + isinstance(version, str) + and version.endswith("_chn") + and version_idx >= Mai2Constants.VER_MAIMAI_DX_UNIVERSE # 1.00, 1.11 and 1.20 all use DX, but they add MaimaiChn in 1.20, we set 1.20 to use UNIVERSE code + ): + method_fixed += "MaimaiChn" + hash = MD5.new((method_fixed + keys[2]).encode()) # truncate unused bytes like the game does @@ -157,14 +179,29 @@ class Mai2Servlet(BaseServlet): ] def get_allnet_info(self, game_code: str, game_ver: int, keychip: str) -> Tuple[str, str]: - if not self.core_cfg.server.is_using_proxy and Utils.get_title_port(self.core_cfg) != 80: - return ( - f"http://{self.core_cfg.server.hostname}:{Utils.get_title_port(self.core_cfg)}/{game_code}/{game_ver}/", - f"{self.core_cfg.server.hostname}", - ) + title_port_int = Utils.get_title_port(self.core_cfg) + title_port_ssl_int = Utils.get_title_port_ssl(self.core_cfg) + + if self.game_cfg.server.use_https: + if (game_code == "SDEZ" and game_ver >= 114) or (game_code == "SDGA" and game_ver >= 110): # SDEZ and SDGA use tls from Splash version + proto = "" # game will auto add https:// in uri with original code + elif game_code == "SDGB" and game_ver >= 130: # SDGB use tls from 1.30 + # game will check if uri start with "http:", if yes, set IsHttpConnection = true + # so we can return https://example.com or http://example.com, all will work + proto = "https://" + else: + # "maimai", SDEZ 1.00 ~ 1.13, SDGA 1.00 ~ 1.06 and SDGB 1.01, 1.20 use http:// + proto = "http://" + else: + proto = "http://" + + if proto == "" or proto == "https://": + t_port = f":{title_port_ssl_int}" if title_port_ssl_int != 443 else "" + else: + t_port = f":{title_port_int}" if title_port_int != 80 else "" return ( - f"http://{self.core_cfg.server.hostname}/{game_code}/{game_ver}/", + f"{proto}{self.core_cfg.server.hostname}{t_port}/{game_code}/{game_ver}/", f"{self.core_cfg.server.hostname}", ) @@ -308,10 +345,12 @@ class Mai2Servlet(BaseServlet): internal_ver = Mai2Constants.VER_MAIMAI_DX_FESTIVAL_PLUS elif version >= 140 and version < 145: # BUDDiES internal_ver = Mai2Constants.VER_MAIMAI_DX_BUDDIES - elif version >= 145 and version <150: # BUDDiES PLUS + elif version >= 145 and version < 150: # BUDDiES PLUS internal_ver = Mai2Constants.VER_MAIMAI_DX_BUDDIES_PLUS - elif version >=150: + elif version >= 150 and version < 155: internal_ver = Mai2Constants.VER_MAIMAI_DX_PRISM + elif version >= 155: + internal_ver = Mai2Constants.VER_MAIMAI_DX_PRISM_PLUS elif game_code == "SDGA": # Int if version < 105: # 1.0 @@ -332,35 +371,63 @@ class Mai2Servlet(BaseServlet): internal_ver = Mai2Constants.VER_MAIMAI_DX_FESTIVAL_PLUS elif version >= 140 and version < 145: # BUDDiES internal_ver = Mai2Constants.VER_MAIMAI_DX_BUDDIES - elif version >= 145 and version <150: # BUDDiES PLUS + elif version >= 145 and version < 150: # BUDDiES PLUS internal_ver = Mai2Constants.VER_MAIMAI_DX_BUDDIES_PLUS - elif version >=150: + elif version >= 150 and version < 155: + internal_ver = Mai2Constants.VER_MAIMAI_DX_PRISM + elif version >= 155: + internal_ver = Mai2Constants.VER_MAIMAI_DX_PRISM_PLUS + + elif game_code == "SDGB": # Chn + if version < 110: # Muji + internal_ver = Mai2Constants.VER_MAIMAI_DX + elif version >= 110 and version < 120: # Muji + internal_ver = Mai2Constants.VER_MAIMAI_DX_SPLASH # still DX, but need Splash to set encryption key + elif version >= 120 and version < 130: # Muji (LMAO) + internal_ver = Mai2Constants.VER_MAIMAI_DX_UNIVERSE # still DX, but need UNIVERSE to set encryption key + elif version >= 130 and version < 140: # FESTiVAL + internal_ver = Mai2Constants.VER_MAIMAI_DX_FESTIVAL + elif version >= 140 and version < 150: # BUDDiES + internal_ver = Mai2Constants.VER_MAIMAI_DX_BUDDIES + elif version >= 150: # PRiSM internal_ver = Mai2Constants.VER_MAIMAI_DX_PRISM + if all(c in string.hexdigits for c in endpoint) and len(endpoint) == 32: + + if game_code == "SDGA": + crypto_cfg_key = f"{internal_ver}_int" + hash_table_key = f"{internal_ver}_int" + elif game_code == "SDGB": + crypto_cfg_key = f"{internal_ver}_chn" + hash_table_key = f"{internal_ver}_chn" + else: + crypto_cfg_key = internal_ver + hash_table_key = internal_ver + # If we get a 32 character long hex string, it's a hash and we're # dealing with an encrypted request. False positives shouldn't happen # as long as requests are suffixed with `Api`. - if internal_ver not in self.hash_table: + if hash_table_key not in self.hash_table: self.logger.error( "v%s does not support encryption or no keys entered", version, ) return Response(zlib.compress(b'{"stat": "0"}')) - elif endpoint.lower() not in self.hash_table[internal_ver]: + elif endpoint.lower() not in self.hash_table[hash_table_key]: self.logger.error( "No hash found for v%s endpoint %s", version, endpoint ) return Response(zlib.compress(b'{"stat": "0"}')) - endpoint = self.hash_table[internal_ver][endpoint.lower()] + endpoint = self.hash_table[hash_table_key][endpoint.lower()] try: crypt = AES.new( - bytes.fromhex(self.game_cfg.crypto.keys[internal_ver][0]), + bytes.fromhex(self.game_cfg.crypto.keys[crypto_cfg_key][0]), AES.MODE_CBC, - bytes.fromhex(self.game_cfg.crypto.keys[internal_ver][1]), + bytes.fromhex(self.game_cfg.crypto.keys[crypto_cfg_key][1]), ) req_raw = crypt.decrypt(req_raw) @@ -378,7 +445,10 @@ class Mai2Servlet(BaseServlet): if ( not encrypted and self.game_cfg.crypto.encrypted_only - and version >= 110 + and ( + # SDEZ start from 1.10, SDGA and SDGB keep use encryption from 1.00 + internal_ver >= Mai2Constants.VER_MAIMAI_DX_PLUS or (game_code == "SDGA" or game_code == "SDGB") + ) ): self.logger.error( "Unencrypted v%s %s request, but config is set to encrypted only: %r", @@ -402,7 +472,9 @@ class Mai2Servlet(BaseServlet): endpoint = ( endpoint.replace("MaimaiExp", "") - if game_code == Mai2Constants.GAME_CODE_DX_INT + if game_code == Mai2Constants.GAME_CODE_DX_INT and version >= 120 + else endpoint.replace("MaimaiChn", "") + if game_code == Mai2Constants.GAME_CODE_DX_CHN and version >= 120 else endpoint ) func_to_find = "handle_" + inflection.underscore(endpoint) + "_request" @@ -428,15 +500,17 @@ class Mai2Servlet(BaseServlet): zipped = zlib.compress(json.dumps(resp, ensure_ascii=False).encode("utf-8")) - if not encrypted or version < 110: + if not encrypted or ( + internal_ver < Mai2Constants.VER_MAIMAI_DX_PLUS and game_code == "SDEZ" + ): return Response(zipped) padded = pad(zipped, 16) crypt = AES.new( - bytes.fromhex(self.game_cfg.crypto.keys[internal_ver][0]), + bytes.fromhex(self.game_cfg.crypto.keys[crypto_cfg_key][0]), AES.MODE_CBC, - bytes.fromhex(self.game_cfg.crypto.keys[internal_ver][1]), + bytes.fromhex(self.game_cfg.crypto.keys[crypto_cfg_key][1]), ) return Response(crypt.encrypt(padded)) diff --git a/titles/mai2/prism.py b/titles/mai2/prism.py index 95ebb74..dbb00f6 100644 --- a/titles/mai2/prism.py +++ b/titles/mai2/prism.py @@ -1,3 +1,5 @@ +import base64 +import os from typing import Dict from core.config import CoreConfig @@ -25,16 +27,43 @@ class Mai2Prism(Mai2BuddiesPlus): "userItemList": [] } - #seems to be used for downloading music scores online + #used for downloading music scores online async def handle_get_game_music_score_api_request(self, data: Dict) -> Dict: - return { - "gameMusicScore": { - "musicId": data["musicId"], - "level": data["level"], - "type": data["type"], - "scoreData": "" - } - } + if not self.game_config.charts.enable or not self.game_config.charts.chart_folder: + return {"gameMusicScore": {"musicId": data["musicId"], "level": data["level"], "type": data["type"], "scoreData": ""}} + + padded_music_id = str(data["musicId"]).zfill(6) + padded_level_id = str(data["level"]).zfill(2) + music_folder = f"music{padded_music_id}" + + if data["type"] == 0: + target_filename = f"{padded_music_id}_{padded_level_id}.ma2" + elif data["type"] == 1: + target_filename = f"{padded_music_id}_{padded_level_id}_L.ma2" + elif data["type"] == 2: + target_filename = f"{padded_music_id}_{padded_level_id}_R.ma2" + else: + self.logger.error("Invalid MusicScore type!") + return {"gameMusicScore": {"musicId": data["musicId"], "level": data["level"], "type": data["type"], "scoreData": ""}} + + + chart_path = os.path.join(self.game_config.charts.chart_folder, str(self.version), music_folder, target_filename) + if os.path.isfile(chart_path): + with open(chart_path, 'rb') as file: + file_content = file.read() + base64_content = base64.b64encode(file_content).decode('ascii') + return { + "gameMusicScore": { + "musicId": data["musicId"], + "level": data["level"], + "type": data["type"], + "scoreData": base64_content + } + } + else: + self.logger.warning(f"Version {self.version} Chart {target_filename} not found!") + return {"gameMusicScore": {"musicId": data["musicId"], "level": data["level"], "type": data["type"], "scoreData": ""}} + async def handle_get_game_kaleidx_scope_api_request(self, data: Dict) -> Dict: return { diff --git a/titles/mai2/prismplus.py b/titles/mai2/prismplus.py new file mode 100644 index 0000000..04b8bc4 --- /dev/null +++ b/titles/mai2/prismplus.py @@ -0,0 +1,141 @@ +from typing import Dict + +from core.config import CoreConfig +from titles.mai2.prism import Mai2Prism +from titles.mai2.const import Mai2Constants +from titles.mai2.config import Mai2Config + + + +class Mai2PrismPlus(Mai2Prism): + def __init__(self, cfg: CoreConfig, game_cfg: Mai2Config) -> None: + super().__init__(cfg, game_cfg) + self.version = Mai2Constants.VER_MAIMAI_DX_PRISM_PLUS + + async def handle_cm_get_user_preview_api_request(self, data: Dict) -> Dict: + user_data = await super().handle_cm_get_user_preview_api_request(data) + + # hardcode lastDataVersion for CardMaker + user_data["lastDataVersion"] = "1.55.00" + return user_data + + async def handle_upsert_client_play_time_api_request(self, data: Dict) -> Dict: + return{ + "returnCode": 1, + "apiName": "UpsertClientPlayTimeApi" + } + async def handle_get_game_kaleidx_scope_api_request(self, data: Dict) -> Dict: + return { + "gameKaleidxScopeList": [ + {"gateId": 1, "phaseId": 6}, + {"gateId": 2, "phaseId": 6}, + {"gateId": 3, "phaseId": 6}, + {"gateId": 4, "phaseId": 6}, + {"gateId": 5, "phaseId": 6}, + {"gateId": 6, "phaseId": 6}, + {"gateId": 7, "phaseId": 6}, + {"gateId": 8, "phaseId": 6}, + {"gateId": 9, "phaseId": 6}, + {"gateId": 10, "phaseId": 13} + ] + } + + async def handle_get_user_kaleidx_scope_api_request(self, data: Dict) -> Dict: + # kaleidxscope keyget condition judgement + # player may get key before GateFound + for gate in range(1,11): + if gate == 1 or gate == 4 or gate == 6: + condition_satisfy = 0 + for condition in Mai2Constants.KALEIDXSCOPE_KEY_CONDITION[gate]: + score_list = await self.data.score.get_best_scores(user_id=data["userId"], song_id=condition) + if score_list: + condition_satisfy = condition_satisfy + 1 + if len(Mai2Constants.KALEIDXSCOPE_KEY_CONDITION[gate]) == condition_satisfy: + new_kaleidxscope = {'gateId': gate, "isKeyFound": True} + await self.data.score.put_user_kaleidxscope(data["userId"], new_kaleidxscope) + + elif gate == 2: + user_profile = await self.data.profile.get_profile_detail(user_id=data["userId"], version=self.version) + user_frame = user_profile["frameId"] + if user_frame == 459504: + playlogs = await self.data.score.get_playlogs(user_id=data["userId"], idx=0, limit=0) + + playlog_dict = {} + for playlog in playlogs: + playlog_id = playlog["playlogId"] + if playlog_id not in playlog_dict: + playlog_dict[playlog_id] = [] + playlog_dict[playlog_id].append(playlog["musicId"]) + valid_playlogs = [] + allowed_music = set(Mai2Constants.KALEIDXSCOPE_KEY_CONDITION[2]) + for playlog_id, music_ids in playlog_dict.items(): + + if len(music_ids) != len(set(music_ids)): + continue + all_valid = True + for mid in music_ids: + if mid not in allowed_music: + all_valid = False + break + if all_valid: + valid_playlogs.append(playlog_id) + + if valid_playlogs: + new_kaleidxscope = {'gateId': 2, "isKeyFound": True} + await self.data.score.put_user_kaleidxscope(data["userId"], new_kaleidxscope) + + + elif gate == 5: + + playlogs = await self.data.score.get_playlogs(user_id=data["userId"], idx=0, limit=0) + allowed_music = set(Mai2Constants.KALEIDXSCOPE_KEY_CONDITION[5]) + valid_playlogs = [] + + for playlog in playlogs: + if playlog["extBool2"] == 1 and playlog["musicId"] in allowed_music: + valid_playlogs.append(playlog["playlogId"]) # 直接记录 playlogId + if valid_playlogs: + new_kaleidxscope = {'gateId': 5, "isKeyFound": True} + await self.data.score.put_user_kaleidxscope(data["userId"], new_kaleidxscope) + + elif gate == 7: + + played_kaleidxscope_list = await self.data.score.get_user_kaleidxscope_list(data["userId"]) + check_results = {} + for i in range(1,7): + check_results[i] = False + for played_kaleidxscope in played_kaleidxscope_list: + if played_kaleidxscope[2] == i and played_kaleidxscope[5] == True: + check_results[i] = True + break + all_true = all(check_results.values()) + + if all_true: + new_kaleidxscope = {'gateId': 7, "isKeyFound": True} + await self.data.score.put_user_kaleidxscope(data["userId"], new_kaleidxscope) + + elif gate == 10: + + played_kaleidxscope_list = await self.data.score.get_user_kaleidxscope_list(data["userId"]) + for played_kaleidxscope in played_kaleidxscope_list: + if played_kaleidxscope[2] == 9 and played_kaleidxscope[5] == True: + new_kaleidxscope = {'gateId': 10, "isGateFound": True, "isKeyFound": True} + await self.data.score.put_user_kaleidxscope(data["userId"], new_kaleidxscope) + + + + kaleidxscope = await self.data.score.get_user_kaleidxscope_list(data["userId"]) + + if kaleidxscope is None: + return {"userId": data["userId"], "userKaleidxScopeList":[]} + + kaleidxscope_list = [] + for kaleidxscope_data in kaleidxscope: + tmp = kaleidxscope_data._asdict() + tmp.pop("user") + tmp.pop("id") + kaleidxscope_list.append(tmp) + return { + "userId": data["userId"], + "userKaleidxScopeList": kaleidxscope_list + } \ No newline at end of file diff --git a/titles/mai2/read.py b/titles/mai2/read.py index 1c86518..221d704 100644 --- a/titles/mai2/read.py +++ b/titles/mai2/read.py @@ -53,6 +53,10 @@ class Mai2Reader(BaseReader): self.logger.error(f"tables directory not found in {self.bin_dir}") return + if not os.path.exists(f"{self.opt_dir}/tables"): + self.logger.warning(f"tables directory not found in {self.opt_dir}, not using") + self.opt_dir = None + if self.version >= Mai2Constants.VER_MAIMAI_MILK: if self.extra is None: self.logger.error("Milk - Finale requre an AES key via a hex string send as the --extra flag") @@ -63,45 +67,34 @@ class Mai2Reader(BaseReader): else: key = None - evt_table = self.load_table_raw(f"{self.bin_dir}/tables", "mmEvent.bin", key) - txt_table = self.load_table_raw(f"{self.bin_dir}/tables", "mmtextout_jp.bin", key) - score_table = self.load_table_raw(f"{self.bin_dir}/tables", "mmScore.bin", key) + jp_table = self.parse_textout(f"{self.bin_dir}/tables", "mmtextout_jp.bin", key, f"{self.opt_dir}/tables" if self.opt_dir else None) + en_table = self.parse_textout(f"{self.bin_dir}/tables", "mmtextout_ex.bin", key, f"{self.opt_dir}/tables" if self.opt_dir else None) + evt_table = self.parse_table(f"{self.bin_dir}/tables", "mmEvent.bin", key, f"{self.opt_dir}/tables" if self.opt_dir else None) + score_table = self.parse_table(f"{self.bin_dir}/tables", "mmScore.bin", key, f"{self.opt_dir}/tables" if self.opt_dir else None) + music_table = self.parse_table(f"{self.bin_dir}/tables", "mmMusic.bin", key, f"{self.opt_dir}/tables" if self.opt_dir else None) + genre_table = self.parse_table(f"{self.bin_dir}/tables", "mmGenre.bin", key, f"{self.opt_dir}/tables" if self.opt_dir else None) + + genre_lookup = {} + for entry in genre_table: + genre_lookup[entry['ID']] = jp_table[entry['名前テキスト']] + self.logger.info(f"Insert {len(evt_table)} events") await self.read_old_events(evt_table) - await self.read_old_music(score_table, txt_table) - - if self.opt_dir is not None: - evt_table = self.load_table_raw(f"{self.opt_dir}/tables", "mmEvent.bin", key) - txt_table = self.load_table_raw(f"{self.opt_dir}/tables", "mmtextout_jp.bin", key) - score_table = self.load_table_raw(f"{self.opt_dir}/tables", "mmScore.bin", key) - - await self.read_old_events(evt_table) - await self.read_old_music(score_table, txt_table) - - return + self.logger.info(f"Insert {len(score_table)} charts") + await self.read_old_music(music_table, score_table, jp_table, genre_lookup) - def load_table_raw(self, dir: str, file: str, key: Optional[bytes]) -> Optional[List[Dict[str, str]]]: - if not os.path.exists(f"{dir}/{file}"): - self.logger.warning(f"file {file} does not exist in directory {dir}, skipping") - return + def parse_textout(self, dir: str, file: str, key: Optional[bytes], opt_dir: Optional[str] = None) -> Dict[str, str]: + f_decoded = self.load_table_raw(dir, file, key, opt_dir) + out = {} + for line in f_decoded.splitlines(): + matcher = re.match(r"^[A-Z]+\( L\"(.+)\" ,L\"(.*)\" \)$", line) + if not matcher: continue + out[matcher.group(1)] = matcher.group(2) - self.logger.info(f"Load table {file} from {dir}") - if key is not None: - cipher = AES.new(key, AES.MODE_CBC) - with open(f"{dir}/{file}", "rb") as f: - f_encrypted = f.read() - f_data = cipher.decrypt(f_encrypted)[0x10:] - - else: - with open(f"{dir}/{file}", "rb") as f: - f_data = f.read()[0x10:] - - if f_data is None or not f_data: - self.logger.warning(f"file {dir} could not be read, skipping") - return - - f_data_deflate = zlib.decompress(f_data, wbits = zlib.MAX_WBITS | 16)[0x12:] # lop off the junk at the beginning - f_decoded = codecs.utf_16_le_decode(f_data_deflate)[0] + return out + + def parse_table(self, dir: str, file: str, key: Optional[bytes], opt_dir: Optional[str] = None) -> Optional[List[Dict[str, str]]]: + f_decoded = self.load_table_raw(dir, file, key, opt_dir) f_split = f_decoded.splitlines() has_struct_def = "struct " in f_decoded @@ -176,6 +169,34 @@ class Mai2Reader(BaseReader): self.logger.warning("Failed load table content, skipping") return + def load_table_raw(self, dir: str, file: str, key: Optional[bytes], opt_dir: Optional[str] = None) -> str: + if opt_dir is not None and os.path.exists(f"{opt_dir}/{file}"): + fpath = f"{opt_dir}/{file}" + else: + fpath = f"{dir}/{file}" + + if not os.path.exists(fpath): + self.logger.warning(f"file {file} does not exist in directory {dir}, skipping") + return + + self.logger.info(f"Load table {fpath}") + if key is not None: + cipher = AES.new(key, AES.MODE_CBC) + with open(fpath, "rb") as f: + f_encrypted = f.read() + f_data = cipher.decrypt(f_encrypted)[0x10:] + + else: + with open(fpath, "rb") as f: + f_data = f.read()[0x10:] + + if f_data is None or not f_data: + self.logger.warning(f"file {fpath} could not be read, skipping") + return + + f_data_deflate = zlib.decompress(f_data, wbits = zlib.MAX_WBITS | 16)[0x12:] # lop off the junk at the beginning + return codecs.utf_16_le_decode(f_data_deflate)[0] + async def get_events(self, base_dir: str, opt_id: int = None) -> None: self.logger.info(f"Reading events from {base_dir}...") @@ -325,20 +346,37 @@ class Mai2Reader(BaseReader): for event in events: evt_id = int(event.get('イベントID', '0')) - evt_expire_time = float(event.get('オフ時強制時期', '0.0')) - is_exp = bool(int(event.get('海外許可', '0'))) - is_aou = bool(int(event.get('AOU許可', '0'))) name = event.get('comment', f'evt_{evt_id}') await self.data.static.put_game_event(self.version, 0, evt_id, name) - - if not (is_exp or is_aou): - await self.data.static.toggle_game_event(self.version, evt_id, False) - async def read_old_music(self, scores: Optional[List[Dict[str, str]]], text: Optional[List[Dict[str, str]]]) -> None: - if scores is None or text is None: + async def read_old_music(self, music: Optional[List[Dict[str, str]]], scores: Optional[List[Dict[str, str]]], text: Optional[Dict[str, str]], genre: Dict[str, str]) -> None: + if music is None or scores is None or text is None: return - # TODO + + last_music = music[0] + for score in scores: + mid = score['ID'][:-2] + cid = score['ID'][-2:] + + if last_music['ID'] != mid: + for x in range(len(music)): + if music[x]['ID'] == mid: + last_music = music[x] + break + + await self.data.static.put_game_music( + self.version, + int(mid), + int(cid), + text[last_music['タイトル']], + text[last_music['アーティスト']], + genre[last_music['GenreID']], + last_music['BPM'], + last_music['Ver'], + float(score['LV']), + text[f"RST_SCORECREATOR_{int(score['譜面作者ID']):04d}"] + ) async def read_opt_info(self, directory: str) -> Optional[int]: datacfg_file = os.path.join(directory, "DataConfig.xml") diff --git a/titles/mai2/schema/item.py b/titles/mai2/schema/item.py index 8639ae5..bb35756 100644 --- a/titles/mai2/schema/item.py +++ b/titles/mai2/schema/item.py @@ -1,7 +1,8 @@ +from collections.abc import Iterable from datetime import datetime from typing import Dict, List, Optional -from sqlalchemy import Column, Table, UniqueConstraint, and_, or_ +from sqlalchemy import Column, Table, UniqueConstraint, and_, or_, not_ from sqlalchemy.dialects.mysql import insert from sqlalchemy.engine import Row from sqlalchemy.schema import ForeignKey @@ -550,25 +551,36 @@ class Mai2ItemData(BaseData): if result: return result.fetchall() - async def add_fav_music(self, user_id: int, music_id: int, order_id: Optional[int] = None) -> Optional[int]: - sql = insert(fav_music).values( - user = user_id, - musicId = music_id, - orderId = order_id - ) + async def put_fav_music(self, user_id: int, fav_list: Iterable[tuple[int, Optional[int]]]) -> Optional[int]: + row_count = 0 + processed_music_ids = [] + + for music_id, order_id in fav_list: + sql = insert(fav_music).values( + user = user_id, + musicId = music_id, + orderId = order_id + ) + + conflict = sql.on_duplicate_key_update(orderId = order_id) + result = await self.execute(conflict) + + processed_music_ids.append(music_id) + + if not result: + self.logger.error(f"Failed to add music {music_id} as favorite for user {user_id}!") + continue + + row_count += result.rowcount + + clear_stale_entries_stmt = fav_music.delete(and_(fav_music.c.user == user_id, not_(fav_music.c.musicId.in_(processed_music_ids)))) + result = await self.execute(clear_stale_entries_stmt) + + if result is None: + self.logger.error(f"Failed to clear stale favorite music entries for user {user_id}!") + return None - conflict = sql.on_duplicate_key_update(orderId = order_id) - - result = await self.execute(conflict) - if result: - return result.lastrowid - - self.logger.error(f"Failed to add music {music_id} as favorite for user {user_id}!") - - async def remove_fav_music(self, user_id: int, music_id: int) -> None: - result = await self.execute(fav_music.delete(and_(fav_music.c.user == user_id, fav_music.c.musicId == music_id))) - if not result: - self.logger.error(f"Failed to remove music {music_id} as favorite for user {user_id}!") + return row_count + result.rowcount async def put_card( self, diff --git a/titles/mai2/schema/score.py b/titles/mai2/schema/score.py index d03dba4..804cbaa 100644 --- a/titles/mai2/schema/score.py +++ b/titles/mai2/schema/score.py @@ -148,7 +148,8 @@ playlog = Table( Column("extNum2", Integer), Column("extNum4", Integer), Column("extBool1", Boolean), # new with buddies - Column("extBool2", Boolean), # new with prism + Column("extBool2", Boolean), # new with prism IsRandomSelect + Column("extBool3", Boolean), # new with prism+ IsTrackSkip Column("trialPlayAchievement", Integer), mysql_charset="utf8mb4", ) diff --git a/titles/mai2/schema/static.py b/titles/mai2/schema/static.py index 29e020e..a12e0fd 100644 --- a/titles/mai2/schema/static.py +++ b/titles/mai2/schema/static.py @@ -254,7 +254,7 @@ class Mai2StaticData(BaseData): async def put_card(self, version: int, card_id: int, card_name: str, opt_id: int = None, **card_data) -> int: sql = insert(cards).values( - version=version, cardId=card_id, cardName=card_name, opt=coalesce(cards.c.opt, opt_id) **card_data + version=version, cardId=card_id, cardName=card_name, opt=coalesce(cards.c.opt, opt_id), **card_data ) conflict = sql.on_duplicate_key_update(opt=coalesce(cards.c.opt, opt_id), **card_data) diff --git a/titles/mai2/templates/mai2_index.jinja b/titles/mai2/templates/mai2_index.jinja index 6490fdc..50ebb72 100644 --- a/titles/mai2/templates/mai2_index.jinja +++ b/titles/mai2/templates/mai2_index.jinja @@ -17,6 +17,10 @@ | |
| ID: | +{{ profile.user }} | +
|---|---|
| version: | @@ -86,6 +90,40 @@ |
| Id | +Name | +Rating | +Show | ++ |
|---|---|---|---|---|
| {{ rival.2 }} | +{{ rival.0 }} | +{{ rival.1 }} | +
+
+
+
+ |
+ + + | +