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 @@

Machine: {{machine.serial}}

@@ -64,8 +60,8 @@ Info
- - + +
diff --git a/core/title.py b/core/title.py index 9165628..d8825b9 100644 --- a/core/title.py +++ b/core/title.py @@ -149,41 +149,3 @@ class TitleServlet: self.logger.info( f"Serving {len(self.title_registry)} game codes {'on port ' + str(core_cfg.server.port) if core_cfg.server.port > 0 else ''}" ) - - def render_GET(self, request: Request, endpoints: dict) -> bytes: - code = endpoints["title"] - subaction = endpoints['subaction'] - - if code not in self.title_registry: - self.logger.warning(f"Unknown game code {code}") - request.setResponseCode(404) - return b"" - - index = self.title_registry[code] - handler = getattr(index, f"{subaction}", None) - if handler is None: - self.logger.error(f"{code} does not have handler for GET subaction {subaction}") - request.setResponseCode(500) - return b"" - - return handler(request, code, endpoints) - - def render_POST(self, request: Request, endpoints: dict) -> bytes: - code = endpoints["title"] - subaction = endpoints['subaction'] - - if code not in self.title_registry: - self.logger.warning(f"Unknown game code {code}") - request.setResponseCode(404) - return b"" - - index = self.title_registry[code] - handler = getattr(index, f"{subaction}", None) - if handler is None: - self.logger.error(f"{code} does not have handler for POST subaction {subaction}") - request.setResponseCode(500) - return b"" - - endpoints.pop("title") - endpoints.pop("subaction") - return handler(request, code, endpoints) diff --git a/docker-compose.yml b/docker-compose.yml index 6a35355..beab3a7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,6 +6,7 @@ services: volumes: - ./aime:/app/aime - ./configs/config:/app/config + - ./cert:/app/cert environment: CFG_DEV: 1 @@ -14,7 +15,8 @@ services: CFG_CORE_MEMCACHED_HOSTNAME: ma.memcached CFG_CORE_AIMEDB_KEY: CFG_CHUNI_SERVER_LOGLEVEL: debug - + + ##Note: comment 80 and 8443 when you plan to use with nginx ports: - "80:80" - "8443:8443" @@ -64,3 +66,18 @@ services: ports: - "9090:8080" + ##Note: uncomment to allow use nginx with artemis, don't forget to comment 80 and 8443 ports on artemis + #nginx: + # hostname: ma.nginx + # image: nginx:latest + # ports: + # - "80:80" + # - "443:443" + # - "8443:8443" + # volumes: + ##Note: copy example_config/example_nginx.conf to configs/nginx folder, edit it and rename to nginx.conf + # - ./configs/nginx:/etc/nginx/conf.d + # - ./cert:/etc/nginx/cert + # - ./logs/nginx:/var/log/nginx + # depends_on: + # - app \ No newline at end of file diff --git a/docs/config.md b/docs/config.md index f85e8e7..6cf2482 100644 --- a/docs/config.md +++ b/docs/config.md @@ -41,6 +41,13 @@ - `loglevel`: Logging level for the allnet server. Default `info` - `allow_online_updates`: Allow allnet to distribute online updates via DownloadOrders. This system is currently non-functional, so leave it disabled. Default `False` - `update_cfg_folder`: Folder where delivery INI files will be checked for. Ignored if `allow_online_updates` is `False`. Default `""` +- `allnet_lite_keys:` Allnet Lite (Chinese Allnet) PowerOn/DownloadOrder unique keys. Default ` ` +```yaml + allnet_lite_keys: + "SDJJ": [ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 ] + "SDHJ": [ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 ] + "SDGB": [ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 ] +``` ## Billing - `standalone`: Whether the billing server should launch it's own servlet on it's own port, or be part of the main servlet on the default port. Setting this to `True` requires that you have `ssl_key` and `ssl_cert` set. Default `False` - `loglevel`: Logging level for the billing server. Default `info` @@ -56,3 +63,8 @@ - `key`: Key to encrypt/decrypt aimedb requests and responses. MUST be set or the server will not start. If set incorrectly, your server will not properly handle aimedb requests. Default `""` - `id_secret`: Base64-encoded JWT secret for Sega Auth IDs. Leaving this blank disables this feature. Default `""` - `id_lifetime_seconds`: Number of secons a JWT generated should be valid for. Default `86400` (1 day) +## Chimedb +- `enable`: Whether or not chimedb should run. Default `False` +- `loglevel`: Logging level for the chimedb server. Default `info` +- `key`: Key to hash chimedb requests and responses. MUST be set or the server will not start. If set incorrectly, your server will not properly handle chimedb requests. Default `""` + diff --git a/docs/game_specific_info.md b/docs/game_specific_info.md index aa9389d..32a9ec5 100644 --- a/docs/game_specific_info.md +++ b/docs/game_specific_info.md @@ -68,6 +68,8 @@ Games listed below have been tested and confirmed working. | 14 | CHUNITHM SUN PLUS | | 15 | CHUNITHM LUMINOUS | | 16 | CHUNITHM LUMINOUS PLUS | +| 17 | CHUNITHM VERSE | +| 18 | CHUNITHM X-VERSE | ### Importer @@ -110,6 +112,7 @@ crypto: keys: 13: ["0000000000000000000000000000000000000000000000000000000000000000", "00000000000000000000000000000000", "0000000000000000"] "13_int": ["0000000000000000000000000000000000000000000000000000000000000000", "00000000000000000000000000000000", "0000000000000000", 42] + "13_chn": ["0000000000000000000000000000000000000000000000000000000000000000", "00000000000000000000000000000000", "0000000000000000", 8] ``` ### Database upgrade @@ -205,32 +208,32 @@ Presents are items given to the user when they login, with a little animation (f ### Versions | Game Code | Version ID | Version Name | -|-----------|------------|-------------------------| -| SBXL | 0 | maimai | -| SBXL | 1 | maimai PLUS | -| SBZF | 2 | maimai GreeN | -| SBZF | 3 | maimai GreeN PLUS | -| SDBM | 4 | maimai ORANGE | -| SDBM | 5 | maimai ORANGE PLUS | -| SDCQ | 6 | maimai PiNK | -| SDCQ | 7 | maimai PiNK PLUS | -| SDDK | 8 | maimai MURASAKi | -| SDDK | 9 | maimai MURASAKi PLUS | -| SDDZ | 10 | maimai MiLK | -| SDDZ | 11 | maimai MiLK PLUS | -| SDEY | 12 | maimai FiNALE | -| SDEZ | 13 | maimai DX | -| SDEZ | 14 | maimai DX PLUS | -| SDEZ | 15 | maimai DX Splash | -| SDEZ | 16 | maimai DX Splash PLUS | -| SDEZ | 17 | maimai DX UNiVERSE | -| SDEZ | 18 | maimai DX UNiVERSE PLUS | -| SDEZ | 19 | maimai DX FESTiVAL | -| SDEZ | 20 | maimai DX FESTiVAL PLUS | -| SDEZ | 21 | maimai DX BUDDiES | -| SDEZ | 22 | maimai DX BUDDiES PLUS | -| SDEZ | 23 | maimai DX PRiSM | - +|----------|------------|-------------------------| +| SBXL | 0 | maimai | +| SBXL | 1 | maimai PLUS | +| SBZF | 2 | maimai GreeN | +| SBZF | 3 | maimai GreeN PLUS | +| SDBM | 4 | maimai ORANGE | +| SDBM | 5 | maimai ORANGE PLUS | +| SDCQ | 6 | maimai PiNK | +| SDCQ | 7 | maimai PiNK PLUS | +| SDDK | 8 | maimai MURASAKi | +| SDDK | 9 | maimai MURASAKi PLUS | +| SDDZ | 10 | maimai MiLK | +| SDDZ | 11 | maimai MiLK PLUS | +| SDEY | 12 | maimai FiNALE | +| SDEZ | 13 | maimai DX | +| SDEZ | 14 | maimai DX PLUS | +| SDEZ | 15 | maimai DX Splash | +| SDEZ | 16 | maimai DX Splash PLUS | +| SDEZ | 17 | maimai DX UNiVERSE | +| SDEZ | 18 | maimai DX UNiVERSE PLUS | +| SDEZ | 19 | maimai DX FESTiVAL | +| SDEZ | 20 | maimai DX FESTiVAL PLUS | +| SDEZ | 21 | maimai DX BUDDiES | +| SDEZ | 22 | maimai DX BUDDiES PLUS | +| SDEZ | 23 | maimai DX PRiSM | +| SDEZ | 24 | maimai DX PRiSM PLUS | ### Importer @@ -259,6 +262,43 @@ python dbutils.py upgrade Pre-Dx uses the same database as DX, so only upgrade using the SDEZ game code! +### Config + +Config file is located in `config/mai2.yaml`. + +| Option | Info | +|-----------------------|-------------------------------------------------------------------------------------------------------------------------------------------| +| `crypto` | This option is used to enable the TLS Encryption | + + +If you would like to use network encryption, add the keys to the `keys` section under `crypto`, where the key +is the version ID for Japanese (SDEZ) versions and `"{versionID}_int"` for Export (SDGA) versions, and the value +is an array containing `[key, iv, salt]` in order. + +Just copy your salt in here, no need to convert anything. + + +```yaml +crypto: + encrypted_only: False + keys: + 23: ["0000000000000000000000000000000000000000000000000000000000000000", "00000000000000000000000000000000", "0000000000000000"] + "23_int": ["0000000000000000000000000000000000000000000000000000000000000000", "00000000000000000000000000000000", "0000000000000000"] + "23_chn": ["0000000000000000000000000000000000000000000000000000000000000000", "00000000000000000000000000000000", "0000000000000000"] +``` + +| Option | Info | +|-----------------|------------------------------------------------------| +| `chart_deliver` | This option is used to delivery charts to the client | + +If you would like to use chart delivery, set this option to `True` and configure the directory to read from. Then put charts in your chart folder like this: +``` +chart_folder/23/music001736/001736_00.ma2 +chart_folder/23/music001736/001736_01.ma2 # PRiSM +chart_folder/24/music001901/001901_00.ma2 +chart_folder/24/music001901/001901_01.ma2 # PRiSM PLUS +``` + ## Hatsune Miku Project Diva ### SBZV diff --git a/example_config/chuni.yaml b/example_config/chuni.yaml index a3781d6..a4d7047 100644 --- a/example_config/chuni.yaml +++ b/example_config/chuni.yaml @@ -2,6 +2,7 @@ server: enable: True loglevel: "info" news_msg: "" + use_https: False # for CRYSTAL PLUS and later or SUPERSTAR and later team: name: ARTEMiS # If this is set, all players that are not on a team will use this one by default. @@ -10,7 +11,7 @@ mods: use_login_bonus: True # stock_tickets allows specified ticket IDs to be auto-stocked at login. Format is a comma-delimited string of ticket IDs # note: quanity is not refreshed on "continue" after set - only on subsequent login - stock_tickets: + stock_tickets: stock_count: 99 # Allow use of all available customization items in frontend web ui @@ -18,12 +19,13 @@ mods: # warning: This can result in pushing a lot of data, especially the userbox items. Recommended for local network use only. forced_item_unlocks: map_icons: False - system_voices: False + system_voices: False avatar_accessories: False nameplates: False trophies: False character_icons: False - + stages: False + version: 11: rom: 2.00.00 @@ -43,6 +45,12 @@ version: 16: rom: 2.25.00 data: 2.25.00 + 17: + rom: 2.30.00 + data: 2.30.00 + 18: + rom: 2.40.00 + data: 2.40.00 crypto: encrypted_only: False diff --git a/example_config/core.yaml b/example_config/core.yaml index fa04a67..613883f 100644 --- a/example_config/core.yaml +++ b/example_config/core.yaml @@ -46,6 +46,7 @@ allnet: allow_online_updates: False update_cfg_folder: "" save_billing: True + allnet_lite_keys: [] billing: standalone: True @@ -64,5 +65,10 @@ aimedb: id_secret: "" id_lifetime_seconds: 86400 +chimedb: + enable: False + loglevel: "info" + key: "" + mucha: loglevel: "info" diff --git a/example_config/mai2.yaml b/example_config/mai2.yaml index f0d7754..094d608 100644 --- a/example_config/mai2.yaml +++ b/example_config/mai2.yaml @@ -1,12 +1,17 @@ server: enable: True loglevel: "info" + use_https: False # for DX and later deliver: enable: False udbdl_enable: False content_folder: "" +chart_deliver: #for Prism and later + enable: False + chart_folder: "" + uploads: photos: False photos_dir: "" diff --git a/example_config/nginx_example.conf b/example_config/nginx_example.conf index b01a822..5823ba4 100644 --- a/example_config/nginx_example.conf +++ b/example_config/nginx_example.conf @@ -66,6 +66,52 @@ server { } } +# WAHLAP Billing, they use 443 port +# comment this out if running billing standalone +# still not work for some reason, please set +# billing=127.0.0.1 in segatools.ini for now and looking for fix +server { + listen 443 ssl; + server_name bl.sys-all.cn; + + ssl_certificate /path/to/cert/server.pem; + ssl_certificate_key /path/to/cert/server.key; + ssl_session_timeout 1d; + ssl_session_cache shared:MozSSL:10m; + ssl_session_tickets off; + + ssl_protocols TLSv1 TLSv1.1 TLSv1.2 TLSv1.3; + ssl_ciphers "ALL:@SECLEVEL=0"; + ssl_prefer_server_ciphers off; + + location / { + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_pass_request_headers on; + proxy_pass http://127.0.0.1:8080/; + } +} + +server { + listen 443 ssl; + server_name bl.sys-allnet.cn; + + ssl_certificate /path/to/cert/server.pem; + ssl_certificate_key /path/to/cert/server.key; + ssl_session_timeout 1d; + ssl_session_cache shared:MozSSL:10m; + ssl_session_tickets off; + + ssl_protocols TLSv1 TLSv1.1 TLSv1.2 TLSv1.3; + ssl_ciphers "ALL:@SECLEVEL=0"; + ssl_prefer_server_ciphers off; + + location / { + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_pass_request_headers on; + proxy_pass http://127.0.0.1:8080/; + } +} + # Frontend, set to redirect to HTTPS. Comment out if you don't intend to use the frontend server { listen 80; diff --git a/readme.md b/readme.md index e29784d..ab405ef 100644 --- a/readme.md +++ b/readme.md @@ -8,6 +8,11 @@ Games listed below have been tested and confirmed working. Only game versions ol + 1.30 + 1.35 ++ CHUNITHM CHINA + + NEW + + 2024 (NEW) + + 2024 (LUMINOUS) + + CHUNITHM INTL + SUPERSTAR + SUPERSTAR PLUS @@ -15,6 +20,8 @@ Games listed below have been tested and confirmed working. Only game versions ol + NEW PLUS + SUN + SUN PLUS + + LUMINOUS + + LUMINOUS PLUS + CHUNITHM JP + AIR @@ -31,6 +38,8 @@ Games listed below have been tested and confirmed working. Only game versions ol + SUN PLUS + LUMINOUS + LUMINOUS PLUS + + VERSE + + X-VERSE + crossbeats REV. + Crossbeats REV. @@ -43,7 +52,16 @@ Games listed below have been tested and confirmed working. Only game versions ol + Initial D THE ARCADE + Season 2 -+ maimai DX ++ maimai DX CHINA + + DX (Muji) + + 2021 (Muji) + + 2022 (Muji) + + 2023 (FESTiVAL) + + 2024 (BUDDiES) + ++ maimai DX INTL + + DX + + DX Plus + Splash + Splash Plus + UNiVERSE @@ -53,6 +71,20 @@ Games listed below have been tested and confirmed working. Only game versions ol + BUDDiES + BUDDiES PLUS + PRiSM + ++ maimai DX + + DX + + DX Plus + + Splash + + Splash Plus + + UNiVERSE + + UNiVERSE PLUS + + FESTiVAL + + FESTiVAL PLUS + + BUDDiES + + BUDDiES PLUS + + PRiSM + + PRiSM PLUS + O.N.G.E.K.I. + SUMMER diff --git a/titles/chuni/__init__.py b/titles/chuni/__init__.py index 226594a..faacc0f 100644 --- a/titles/chuni/__init__.py +++ b/titles/chuni/__init__.py @@ -8,4 +8,4 @@ index = ChuniServlet database = ChuniData reader = ChuniReader frontend = ChuniFrontend -game_codes = [ChuniConstants.GAME_CODE, ChuniConstants.GAME_CODE_NEW, ChuniConstants.GAME_CODE_INT] +game_codes = [ChuniConstants.GAME_CODE, ChuniConstants.GAME_CODE_NEW, ChuniConstants.GAME_CODE_INT, ChuniConstants.GAME_CODE_CHN] diff --git a/titles/chuni/base.py b/titles/chuni/base.py index 9333aab..56f442a 100644 --- a/titles/chuni/base.py +++ b/titles/chuni/base.py @@ -53,7 +53,9 @@ class ChuniBase: if not self.game_cfg.mods.use_login_bonus: return {"returnCode": 1} - login_bonus_presets = await self.data.static.get_login_bonus_presets(self.version) + login_bonus_presets = await self.data.static.get_login_bonus_presets( + self.version + ) for preset in login_bonus_presets: # check if a user already has some pogress and if not add the @@ -197,15 +199,21 @@ class ChuniBase: async def handle_get_game_message_api_request(self, data: Dict) -> Dict: return { - "type": data["type"], - "length": 1, - "gameMessageList": [{ - "id": 1, - "type": 1, - "message": f"Welcome to {self.core_cfg.server.name} network!" if not self.game_cfg.server.news_msg else self.game_cfg.server.news_msg, - "startDate": "2017-12-05 07:00:00.0", - "endDate": "2099-12-31 00:00:00.0" - }] + "type": data["type"], + "length": 1, + "gameMessageList": [ + { + "id": 1, + "type": 1, + "message": ( + f"Welcome to {self.core_cfg.server.name} network!" + if not self.game_cfg.server.news_msg + else self.game_cfg.server.news_msg + ), + "startDate": "2017-12-05 07:00:00.0", + "endDate": "2099-12-31 00:00:00.0", + } + ], } async def handle_get_game_ranking_api_request(self, data: Dict) -> Dict: @@ -217,7 +225,10 @@ class ChuniBase: async def handle_get_game_setting_api_request(self, data: Dict) -> Dict: # if reboot start/end time is not defined use the default behavior of being a few hours ago - if self.core_cfg.title.reboot_start_time == "" or self.core_cfg.title.reboot_end_time == "": + if ( + self.core_cfg.title.reboot_start_time == "" + or self.core_cfg.title.reboot_end_time == "" + ): reboot_start = datetime.strftime( datetime.utcnow() + timedelta(hours=6), self.date_time_format ) @@ -226,15 +237,29 @@ class ChuniBase: ) else: # get current datetime in JST - current_jst = datetime.now(pytz.timezone('Asia/Tokyo')).date() + current_jst = datetime.now(pytz.timezone("Asia/Tokyo")).date() # parse config start/end times into datetime - reboot_start_time = datetime.strptime(self.core_cfg.title.reboot_start_time, "%H:%M") - reboot_end_time = datetime.strptime(self.core_cfg.title.reboot_end_time, "%H:%M") + reboot_start_time = datetime.strptime( + self.core_cfg.title.reboot_start_time, "%H:%M" + ) + reboot_end_time = datetime.strptime( + self.core_cfg.title.reboot_end_time, "%H:%M" + ) # offset datetimes with current date/time - reboot_start_time = reboot_start_time.replace(year=current_jst.year, month=current_jst.month, day=current_jst.day, tzinfo=pytz.timezone('Asia/Tokyo')) - reboot_end_time = reboot_end_time.replace(year=current_jst.year, month=current_jst.month, day=current_jst.day, tzinfo=pytz.timezone('Asia/Tokyo')) + reboot_start_time = reboot_start_time.replace( + year=current_jst.year, + month=current_jst.month, + day=current_jst.day, + tzinfo=pytz.timezone("Asia/Tokyo"), + ) + reboot_end_time = reboot_end_time.replace( + year=current_jst.year, + month=current_jst.month, + day=current_jst.day, + tzinfo=pytz.timezone("Asia/Tokyo"), + ) # create strings for use in gameSetting reboot_start = reboot_start_time.strftime(self.date_time_format) @@ -255,6 +280,7 @@ class ChuniBase: "isDumpUpload": "false", "isAou": "false", } + async def handle_get_user_activity_api_request(self, data: Dict) -> Dict: user_activity_list = await self.data.profile.get_profile_activity( data["userId"], data["kind"] @@ -285,7 +311,7 @@ class ChuniBase: rows = await self.data.item.get_characters( user_id, limit=max_ct + 1, offset=next_idx ) - + if rows is None or len(rows) == 0: return { "userId": user_id, @@ -335,7 +361,7 @@ class ChuniBase: return { "userId": data["userId"], "length": 0, - "userRecentPlayerList": [], # playUserId, playUserName, playDate, friendPoint + "userRecentPlayerList": [], # playUserId, playUserName, playDate, friendPoint } async def handle_get_user_course_api_request(self, data: Dict) -> Dict: @@ -421,15 +447,9 @@ class ChuniBase: p = await self.data.profile.get_rival(data["rivalId"]) if p is None: return {} - userRivalData = { - "rivalId": p.user, - "rivalName": p.userName - } - return { - "userId": data["userId"], - "userRivalData": userRivalData - } - + userRivalData = {"rivalId": p.user, "rivalName": p.userName} + return {"userId": data["userId"], "userRivalData": userRivalData} + async def handle_get_user_rival_music_api_request(self, data: Dict) -> Dict: user_id = int(data["userId"]) rival_id = int(data["rivalId"]) @@ -459,18 +479,25 @@ class ChuniBase: # note that itertools.groupby will only work on sorted keys, which is already sorted by # the query in get_scores - for music_id, details_iter in itertools.groupby(music_details, key=lambda x: x["musicId"]): + for music_id, details_iter in itertools.groupby( + music_details, key=lambda x: x["musicId"] + ): details: list[dict[Any, Any]] = [ - {"level": d["level"], "scoreMax": d["scoreMax"]} - for d in details_iter + {"level": d["level"], "scoreMax": d["scoreMax"]} for d in details_iter ] - music_list.append({"musicId": music_id, "length": len(details), "userRivalMusicDetailList": details}) + music_list.append( + { + "musicId": music_id, + "length": len(details), + "userRivalMusicDetailList": details, + } + ) returned_music_details_count += len(details) if len(music_list) >= max_ct: break - + # if we returned fewer PBs than we originally asked for from the database, that means # we queried for the PBs of max_ct + 1 songs. if returned_music_details_count < len(rows): @@ -485,7 +512,7 @@ class ChuniBase: "nextIndex": next_idx, "userRivalMusicList": music_list, } - + async def handle_get_user_favorite_item_api_request(self, data: Dict) -> Dict: user_id = int(data["userId"]) next_idx = int(data["nextIndex"]) @@ -571,7 +598,9 @@ class ChuniBase: async def handle_get_user_login_bonus_api_request(self, data: Dict) -> Dict: user_id = data["userId"] - user_login_bonus = await self.data.item.get_all_login_bonus(user_id, self.version) + user_login_bonus = await self.data.item.get_all_login_bonus( + user_id, self.version + ) # ignore the loginBonus request if its disabled in config if user_login_bonus is None or not self.game_cfg.mods.use_login_bonus: return {"userId": user_id, "length": 0, "userLoginBonusList": []} @@ -621,7 +650,7 @@ class ChuniBase: rows = await self.data.score.get_scores( user_id, limit=max_ct + 1, offset=next_idx ) - + if rows is None or len(rows) == 0: return { "userId": user_id, @@ -636,7 +665,9 @@ class ChuniBase: # note that itertools.groupby will only work on sorted keys, which is already sorted by # the query in get_scores - for _music_id, details_iter in itertools.groupby(music_details, key=lambda x: x["musicId"]): + for _music_id, details_iter in itertools.groupby( + music_details, key=lambda x: x["musicId"] + ): details: list[dict[Any, Any]] = [] for d in details_iter: @@ -650,14 +681,14 @@ class ChuniBase: if len(music_list) >= max_ct: break - + # if we returned fewer PBs than we originally asked for from the database, that means # we queried for the PBs of max_ct + 1 songs. if returned_music_details_count < len(rows): next_idx += max_ct else: next_idx = -1 - + return { "userId": user_id, "length": len(music_list), @@ -687,7 +718,9 @@ class ChuniBase: return bytes([ord(c) for c in src]).decode("utf-8") async def handle_get_user_preview_api_request(self, data: Dict) -> Dict: - profile = await self.data.profile.get_profile_preview(data["userId"], self.version) + profile = await self.data.profile.get_profile_preview( + data["userId"], self.version + ) if profile is None: return None profile_character = await self.data.item.get_character( @@ -729,7 +762,9 @@ class ChuniBase: } async def handle_get_user_recent_rating_api_request(self, data: Dict) -> Dict: - recent_rating_list = await self.data.profile.get_profile_recent_rating(data["userId"]) + recent_rating_list = await self.data.profile.get_profile_recent_rating( + data["userId"] + ) if recent_rating_list is None: return { "userId": data["userId"], @@ -762,7 +797,7 @@ class ChuniBase: profile = await self.data.profile.get_profile_data(data["userId"], self.version) if profile is None: - return {"userId": data["userId"], "teamId": 0} + return {"userId": data["userId"], "teamId": 0} if profile and profile["teamId"]: # Get team by id @@ -787,7 +822,7 @@ class ChuniBase: "teamId": team_id, "teamRank": team_rank, "teamName": team_name, - "assaultTimeRate": 1, # TODO: Figure out assaultTime, which might be team point boost? + "assaultTimeRate": 1, # TODO: Figure out assaultTime, which might be team point boost? "userTeamPoint": { "userId": data["userId"], "teamId": team_id, @@ -796,7 +831,7 @@ class ChuniBase: "aggrDate": data["playDate"], }, } - + async def handle_get_team_course_setting_api_request(self, data: Dict) -> Dict: return { "userId": data["userId"], @@ -805,7 +840,9 @@ class ChuniBase: "teamCourseSettingList": [], } - async def handle_get_team_course_setting_api_request_proto(self, data: Dict) -> Dict: + async def handle_get_team_course_setting_api_request_proto( + self, data: Dict + ) -> Dict: return { "userId": data["userId"], "length": 1, @@ -820,11 +857,11 @@ class ChuniBase: "teamCourseMusicList": [ {"track": 184, "type": 1, "level": 3, "selectLevel": -1}, {"track": 184, "type": 1, "level": 3, "selectLevel": -1}, - {"track": 184, "type": 1, "level": 3, "selectLevel": -1} + {"track": 184, "type": 1, "level": 3, "selectLevel": -1}, ], "teamCourseRankingInfoList": [], "recodeDate": "2099-12-31 11:59:99.0", - "isPlayed": False + "isPlayed": False, } ], } @@ -834,7 +871,7 @@ class ChuniBase: "userId": data["userId"], "length": 0, "nextIndex": -1, - "teamCourseRuleList": [] + "teamCourseRuleList": [], } async def handle_get_team_course_rule_api_request_proto(self, data: Dict) -> Dict: @@ -849,7 +886,7 @@ class ChuniBase: "damageMiss": 1, "damageAttack": 1, "damageJustice": 1, - "damageJusticeC": 1 + "damageJusticeC": 1, } ], } @@ -860,7 +897,7 @@ class ChuniBase: if int(user_id) & 0x1000000000001 == 0x1000000000001: place_id = int(user_id) & 0xFFFC00000000 - + self.logger.info("Guest play from place ID %d, ignoring.", place_id) return {"returnCode": "1"} @@ -882,7 +919,9 @@ class ChuniBase: ) if "userGameOption" in upsert: - await self.data.profile.put_profile_option(user_id, upsert["userGameOption"][0]) + await self.data.profile.put_profile_option( + user_id, upsert["userGameOption"][0] + ) if "userGameOptionEx" in upsert: await self.data.profile.put_profile_option_ex( @@ -929,33 +968,41 @@ class ChuniBase: for playlog in upsert["userPlaylogList"]: # convert the player names to utf-8 if playlog["playedUserName1"] is not None: - playlog["playedUserName1"] = self.read_wtf8(playlog["playedUserName1"]) + playlog["playedUserName1"] = self.read_wtf8( + playlog["playedUserName1"] + ) if playlog["playedUserName2"] is not None: - playlog["playedUserName2"] = self.read_wtf8(playlog["playedUserName2"]) + playlog["playedUserName2"] = self.read_wtf8( + playlog["playedUserName2"] + ) if playlog["playedUserName3"] is not None: - playlog["playedUserName3"] = self.read_wtf8(playlog["playedUserName3"]) + playlog["playedUserName3"] = self.read_wtf8( + playlog["playedUserName3"] + ) await self.data.score.put_playlog(user_id, playlog, self.version) if "userTeamPoint" in upsert: team_points = upsert["userTeamPoint"] try: for tp in team_points: - if tp["teamId"] != '65535': + if tp["teamId"] != "65535": # Fetch the current team data - current_team = await self.data.profile.get_team_by_id(tp["teamId"]) + current_team = await self.data.profile.get_team_by_id( + tp["teamId"] + ) # Calculate the new teamPoint - new_team_point = int(tp["teamPoint"]) + current_team["teamPoint"] + new_team_point = ( + int(tp["teamPoint"]) + current_team["teamPoint"] + ) # Prepare the data to update - team_data = { - "teamPoint": new_team_point - } + team_data = {"teamPoint": new_team_point} # Update the team data await self.data.profile.update_team(tp["teamId"], team_data) except: - pass # Probably a better way to catch if the team is not set yet (new profiles), but let's just pass + pass # Probably a better way to catch if the team is not set yet (new profiles), but let's just pass if "userMapAreaList" in upsert: for map_area in upsert["userMapAreaList"]: await self.data.item.put_map_area(user_id, map_area) @@ -973,22 +1020,28 @@ class ChuniBase: await self.data.item.put_login_bonus( user_id, self.version, login["presetId"], isWatched=True ) - - if "userRecentPlayerList" in upsert: # TODO: Seen in Air, maybe implement sometime + + if ( + "userRecentPlayerList" in upsert + ): # TODO: Seen in Air, maybe implement sometime for rp in upsert["userRecentPlayerList"]: pass - for rating_type in {"userRatingBaseList", "userRatingBaseHotList", "userRatingBaseNextList"}: + for rating_type in { + "userRatingBaseList", + "userRatingBaseHotList", + "userRatingBaseNextList", + }: if rating_type not in upsert: continue - + await self.data.profile.put_profile_rating( user_id, self.version, rating_type, upsert[rating_type], ) - + # added in LUMINOUS if "userCMissionList" in upsert: for cmission in upsert["userCMissionList"]: @@ -1003,7 +1056,9 @@ class ChuniBase: ) for progress in cmission["userCMissionProgressList"]: - await self.data.item.put_cmission_progress(user_id, mission_id, progress) + await self.data.item.put_cmission_progress( + user_id, mission_id, progress + ) if "userNetBattleData" in upsert: net_battle = upsert["userNetBattleData"][0] @@ -1035,11 +1090,25 @@ class ChuniBase: added_ids = music_ids - keep_ids for fav_id in deleted_ids: - await self.data.item.delete_favorite_music(user_id, self.version, fav_id) - + await self.data.item.delete_favorite_music( + user_id, self.version, fav_id + ) + for fav_id in added_ids: await self.data.item.put_favorite_music(user_id, self.version, fav_id) + # added in CHUNITHM VERSE + if "userUnlockChallengeList" in upsert: + for unlock_challenge in upsert["userUnlockChallengeList"]: + await self.data.item.put_unlock_challenge( + user_id, self.version, unlock_challenge + ) + + # added in CHUNITHM X-VERSE + if "userLinkedVerseList" in upsert: + for linked_verse in upsert["userLinkedVerseList"]: + await self.data.item.put_linked_verse(user_id, linked_verse) + return {"returnCode": "1"} async def handle_upsert_user_chargelog_api_request(self, data: Dict) -> Dict: diff --git a/titles/chuni/config.py b/titles/chuni/config.py index f0e15f3..95720bc 100644 --- a/titles/chuni/config.py +++ b/titles/chuni/config.py @@ -25,6 +25,12 @@ class ChuniServerConfig: return CoreConfig.get_config_field( self.__config, "chuni", "server", "news_msg", default="" ) + + @property + def use_https(self) -> bool: + return CoreConfig.get_config_field( + self.__config, "chuni", "server", "use_https", default=False + ) class ChuniTeamConfig: diff --git a/titles/chuni/const.py b/titles/chuni/const.py index fd05003..582d239 100644 --- a/titles/chuni/const.py +++ b/titles/chuni/const.py @@ -6,6 +6,7 @@ class ChuniConstants: GAME_CODE = "SDBT" GAME_CODE_NEW = "SDHD" GAME_CODE_INT = "SDGS" + GAME_CODE_CHN = "SDHJ" CONFIG_NAME = "chuni.yaml" @@ -27,6 +28,8 @@ class ChuniConstants: VER_CHUNITHM_SUN_PLUS = 14 VER_CHUNITHM_LUMINOUS = 15 VER_CHUNITHM_LUMINOUS_PLUS = 16 + VER_CHUNITHM_VERSE = 17 + VER_CHUNITHM_X_VERSE = 18 VERSION_NAMES = [ "CHUNITHM", @@ -46,6 +49,8 @@ class ChuniConstants: "CHUNITHM SUN PLUS", "CHUNITHM LUMINOUS", "CHUNITHM LUMINOUS PLUS", + "CHUNITHM VERSE", + "CHUNITHM X-VERSE", ] SCORE_RANK_INTERVALS_OLD = [ @@ -97,6 +102,8 @@ class ChuniConstants: "215": VER_CHUNITHM_SUN_PLUS, "220": VER_CHUNITHM_LUMINOUS, "225": VER_CHUNITHM_LUMINOUS_PLUS, + "230": VER_CHUNITHM_VERSE, + "240": VER_CHUNITHM_X_VERSE, } @classmethod @@ -109,31 +116,177 @@ class ChuniConstants: return cls.VERSION_LUT.get(str(floor_to_nearest_005(ver)), None) class MapAreaConditionType(IntEnum): - """Condition types for the GetGameMapAreaConditionApi endpoint. Incomplete. + """ + Condition IDs for the `GetGameMapAreaConditionApi` and `GetGameUCConditionApi` requests. - For the MAP_CLEARED/MAP_AREA_CLEARED/TROPHY_OBTAINED conditions, the conditionId - is the map/map area/trophy. + - "Item" or "locked item" refers to the map area, unlock challenge or + Linked VERSE locked using this system. + - "Chart ID" refers to musicID \\* 100 + difficulty, where difficulty is 0 for BASIC + up to 6 for WORLD'S END. For example, Halcyon ULTIMA is 17305. + """ - For the RANK_*/ALL_JUSTICE conditions, the conditionId is songId * 100 + difficultyId. - For example, Halcyon [ULTIMA] would be 173 * 100 + 4 = 17304. + INVALID = 0 + """ + Invalid condition type. Should cause the hidden item to be automatically unlocked, + but seemingly only works with map areas. """ - ALWAYS_UNLOCKED = 0 - MAP_CLEARED = 1 + """Finish the map with ID `conditionId`.""" + MAP_AREA_CLEARED = 2 - + """Finish the map area with ID `conditionId`.""" + TROPHY_OBTAINED = 3 + """Unlock the trophy with ID `conditionId`.""" + + TROPHY_EQUIPPED = 4 + """ + Equip the trophy with ID `conditionId`. The item is locked again when the trophy is + unequipped. + """ + + NAMEPLATE_OBTAINED = 5 + """Unlock the nameplate with ID `conditionId`.""" + + NAMEPLATE_EQUIPPED = 6 + """ + Equip the nameplate with ID `conditionId`. The item is locked again when the nameplate + is unequipped. + """ + + CHARACTER_OBTAINED = 7 + """Unlock the character with ID `conditionId`.""" + + CHARACTER_EQUIPPED = 8 + """ + Equip the character with ID `conditionId`. The item is locked again when the character + is unequipped. + """ + + CHARACTER_TRANSFORM_EQUIPPED = 9 + """ + Equip the character, with the character transform ID `conditionId`. The item is locked again + if the incorrect character is equipped, or the correct character is equipped with the wrong + transform. + """ + + MUSIC_OBTAINED = 10 + """Unlock the music with ID `conditionId`.""" + + AVATAR_ACCESSORY_OBTAINED = 11 + """Unlock the avatar accessory with ID `conditionId`.""" + + AVATAR_ACCESSORY_EQUIPPED = 12 + """ + Equip the avatar accessory with ID `conditionId`. The item is locked again when the avatar + accessory is unequipped. + """ + + MAP_ICON_OBTAINED = 13 + """Unlock the map icon with ID `conditionId`.""" + + MAP_ICON_EQUIPPED = 14 + """ + Equip the map icon with ID `conditionId`. The item is locked again when the map icon is + unequipped. + """ + + SYSTEM_VOICE_OBTAINED = 15 + """Unlock the system voice with ID `conditionId`.""" + + SYSTEM_VOICE_EQUIPPED = 16 + """ + Equip the system voice with ID `conditionId`. The item is locked again when the system voice + is unequipped. + """ + + ALL_JUSTICE_CRITICAL = 17 + """Obtain ALL JUSTICE CRITICAL on the chart given by `conditionId`.""" RANK_SSSP = 18 + """Obtain rank SSS+ on the chart given by `conditionId`.""" + RANK_SSS = 19 + """Obtain rank SSS on the chart given by `conditionId`.""" + RANK_SSP = 20 + """Obtain rank SS+ on the chart given by `conditionId`.""" + RANK_SS = 21 + """Obtain rank SS on the chart given by `conditionId`.""" + RANK_SP = 22 + """Obtain rank S+ on the chart given by `conditionId`.""" + RANK_S = 23 + """Obtain rank S on the chart given by `conditionId`.""" + + RANK_AAA = 24 + """Obtain rank AAA on the chart given by `conditionId`.""" + + RANK_AA = 25 + """Obtain rank AA on the chart given by `conditionId`.""" + + RANK_A = 26 + """Obtain rank A on the chart given by `conditionId`.""" + + MINIMUM_BEST_30_AVERAGE = 27 + """Obtain a best 30 average of at least `conditionId / 100`.""" ALL_JUSTICE = 28 + """Obtain ALL JUSTICE on the chart given by `conditionId`.""" + FULL_COMBO = 29 + """Obtain FULL COMBO on the chart given by `conditionId`.""" + + UNLOCK_CHALLENGE_DISCOVERED = 30 + """Discover/unlock the unlock challenge with ID `conditionId`.""" + + UNLOCK_CHALLENGE_CLEARED = 31 + """Clear the unlock challenge with ID `conditionId`.""" + + MINIMUM_RATING = 32 + """Obtain a rating of at least `conditionId / 100`.""" + + +class LinkedVerseUnlockConditionType(IntEnum): + """ + `conditionList` is a semicolon-delimited list of numbers, where the number's meaning + is defined by the specific `conditionId`. Additionally, each element of the list + can be further separated by underscores. For example `1;2_3;4` means that the player + must achieve 1 AND (2 OR 3) AND 4. + """ + + PLAY_SONGS = 33 + """ + Play songs given by `conditionList`, where `conditionList` is a + list of song IDs. + """ + + COURSE_CLEAR_AND_CLASS_EMBLEM = 34 + """ + Obtain a class emblem (by clearing all courses of a given class) on **any** + of the classes given by `conditionList`, where `conditionList` is an + underscore-separated list of class IDs (1 for CLASS I to 6 for CLASS ∞). + """ + + TROPHY_OBTAINED = 35 + """ + Obtain trophies given by `conditionList`, where `conditionList` is a + list of trophy IDs. + """ + + PLAY_SONGS_IN_FAVORITE = 36 + """ + Play songs given by `conditionList` **from the favorites folder**, where + `conditionList` is a list of song IDs. + """ + + CLEAR_TEAM_COURSE_WITH_CHARACTER_OF_MINIMUM_RANK = 37 + """ + Clear a team course while equipping a character of minimum rank. + """ class MapAreaConditionLogicalOperator(Enum): AND = 1 @@ -176,6 +329,8 @@ class ItemKind(IntEnum): """This only applies to ULTIMA difficulties that are *not* unlocked by reaching S rank on EXPERT difficulty or above. """ + + STAGE = 13 class FavoriteItemKind(IntEnum): diff --git a/titles/chuni/frontend.py b/titles/chuni/frontend.py index 1059d7c..5745594 100644 --- a/titles/chuni/frontend.py +++ b/titles/chuni/frontend.py @@ -13,6 +13,7 @@ from core.config import CoreConfig from .database import ChuniData from .config import ChuniConfig from .const import ChuniConstants, AvatarCategory, ItemKind +from .read import ChuniReader def pairwise(iterable): @@ -91,6 +92,9 @@ class ChuniFrontend(FE_Base): self.data = ChuniData(cfg, self.game_cfg) self.nav_name = "Chunithm" + # Convert any old assets created with a previous version of the importer + ChuniReader.ConvertOldAssets(self.logger) + def get_routes(self) -> List[Route]: return [ Route("/", self.render_GET, methods=['GET']), @@ -104,6 +108,7 @@ class ChuniFrontend(FE_Base): Route("/avatar", self.render_GET_avatar, methods=['GET']), Route("/update.map-icon", self.update_map_icon, methods=['POST']), Route("/update.system-voice", self.update_system_voice, methods=['POST']), + Route("/update.stage", self.update_stage, methods=['POST']), Route("/update.userbox", self.update_userbox, methods=['POST']), Route("/update.avatar", self.update_avatar, methods=['POST']), Route("/update.name", self.update_name, methods=['POST']), @@ -137,6 +142,7 @@ class ChuniFrontend(FE_Base): # version here - it'll just end up being empty sets and the jinja will ignore the variables anyway. map_icons, total_map_icons = await self.get_available_map_icons(version, profile) system_voices, total_system_voices = await self.get_available_system_voices(version, profile) + stages, total_stages = await self.get_available_stages(version, profile) resp = Response(template.render( title=f"{self.core_config.server.name} | {self.nav_name}", @@ -151,7 +157,9 @@ class ChuniFrontend(FE_Base): map_icons=map_icons, system_voices=system_voices, total_map_icons=total_map_icons, - total_system_voices=total_system_voices + total_system_voices=total_system_voices, + stages=stages, + total_stages=total_stages ), media_type="text/html; charset=utf-8") if usr_sesh.chunithm_version >= 0: @@ -252,12 +260,12 @@ class ChuniFrontend(FE_Base): artist=music_chart.artist title=music_chart.title (jacket, ext) = path.splitext(music_chart.jacketPath) - jacket += ".png" + jacket += ".webp" else: difficultyNum=0 artist="unknown" title="musicid: " + str(record.musicId) - jacket = "unknown.png" + jacket = "unknown.webp" # Check if this song is a favorite so we can populate the add/remove button is_favorite = await self.data.item.is_favorite(user_id, version, record.musicId) @@ -313,12 +321,12 @@ class ChuniFrontend(FE_Base): title=song.title genre=song.genre (jacket, ext) = path.splitext(song.jacketPath) - jacket += ".png" + jacket += ".webp" else: artist="unknown" title="musicid: " + str(favorite.favId) genre="unknown" - jacket = "unknown.png" + jacket = "unknown.webp" # add a new collection for the genre if this is our first time seeing it if genre not in favorites_by_genre: @@ -370,7 +378,7 @@ class ChuniFrontend(FE_Base): item = dict() item["id"] = row["mapIconId"] item["name"] = row["name"] - item["iconPath"] = path.splitext(row["iconPath"])[0] + ".png" + item["iconPath"] = path.splitext(row["iconPath"])[0] + ".webp" items[row["mapIconId"]] = item return (items, len(rows)) @@ -395,11 +403,36 @@ class ChuniFrontend(FE_Base): item = dict() item["id"] = row["voiceId"] item["name"] = row["name"] - item["imagePath"] = path.splitext(row["imagePath"])[0] + ".png" + item["imagePath"] = path.splitext(row["imagePath"])[0] + ".webp" items[row["voiceId"]] = item return (items, len(rows)) + async def get_available_stages(self, version: int, profile: Row) -> Tuple[List[Dict], int]: + if profile is None: + return ([], 0) + items = dict() + rows = await self.data.static.get_stages(version) + if rows is None: + return (items, 0) # can only happen with old db + + force_unlocked = self.game_cfg.mods.forced_item_unlocks("stages") + + user_stages = [] + if not force_unlocked: + user_stages = await self.data.item.get_items(profile.user, ItemKind.STAGE.value) + user_stages = [icon["itemId"] for icon in user_stages] + [profile.stageId] + + for row in rows: + if force_unlocked or row["defaultHave"] or row["stageId"] in user_stages: + item = dict() + item["id"] = row["stageId"] + item["name"] = row["name"] + item["imagePath"] = path.splitext(row["imagePath"])[0] + ".webp" + items[row["stageId"]] = item + + return (items, len(rows)) + async def get_available_nameplates(self, version: int, profile: Row) -> Tuple[List[Dict], int]: items = dict() rows = await self.data.static.get_nameplates(version) @@ -418,7 +451,7 @@ class ChuniFrontend(FE_Base): item = dict() item["id"] = row["nameplateId"] item["name"] = row["name"] - item["texturePath"] = path.splitext(row["texturePath"])[0] + ".png" + item["texturePath"] = path.splitext(row["texturePath"])[0] + ".webp" items[row["nameplateId"]] = item return (items, len(rows)) @@ -464,7 +497,7 @@ class ChuniFrontend(FE_Base): item = dict() item["id"] = row["characterId"] item["name"] = row["name"] - item["iconPath"] = path.splitext(row["imagePath3"])[0] + ".png" + item["iconPath"] = path.splitext(row["imagePath3"])[0] + ".webp" items[row["characterId"]] = item return (items, len(rows)) @@ -482,8 +515,8 @@ class ChuniFrontend(FE_Base): item = dict() item["id"] = row["avatarAccessoryId"] item["name"] = row["name"] - item["iconPath"] = path.splitext(row["iconPath"])[0] + ".png" - item["texturePath"] = path.splitext(row["texturePath"])[0] + ".png" + item["iconPath"] = path.splitext(row["iconPath"])[0] + ".webp" + item["texturePath"] = path.splitext(row["texturePath"])[0] + ".webp" items[row["avatarAccessoryId"]] = item return (items, len(rows)) @@ -646,6 +679,22 @@ class ChuniFrontend(FE_Base): return RedirectResponse("/gate/?e=999", 303) return RedirectResponse("/game/chuni/", 303) + + async def update_stage(self, request: Request) -> bytes: + usr_sesh = self.validate_session(request) + if not usr_sesh: + return RedirectResponse("/gate/", 303) + + form_data = await request.form() + new_system_voice: str = form_data.get("id") + + if not new_system_voice: + return RedirectResponse("/gate/?e=4", 303) + + if not await self.data.profile.update_stage(usr_sesh.user_id, usr_sesh.chunithm_version, new_system_voice): + return RedirectResponse("/gate/?e=999", 303) + + return RedirectResponse("/game/chuni/", 303) async def update_userbox(self, request: Request) -> bytes: usr_sesh = self.validate_session(request) @@ -655,14 +704,18 @@ class ChuniFrontend(FE_Base): form_data = await request.form() new_nameplate: str = form_data.get("nameplate") new_trophy: str = form_data.get("trophy") + new_trophy_sub_1: str = form_data.get("trophySub1") + new_trophy_sub_2: str = form_data.get("trophySub2") new_character: str = form_data.get("character") if not new_nameplate or \ not new_trophy or \ + not new_trophy_sub_1 or \ + not new_trophy_sub_2 or \ not new_character: return RedirectResponse("/game/chuni/userbox?e=4", 303) - if not await self.data.profile.update_userbox(usr_sesh.user_id, usr_sesh.chunithm_version, new_nameplate, new_trophy, new_character): + if not await self.data.profile.update_userbox(usr_sesh.user_id, usr_sesh.chunithm_version, new_nameplate, new_trophy, new_trophy_sub_1, new_trophy_sub_2, new_character): return RedirectResponse("/gate/?e=999", 303) return RedirectResponse("/game/chuni/userbox", 303) diff --git a/titles/chuni/img/avatar-common.png b/titles/chuni/img/avatar-common.png deleted file mode 100644 index 030b55e..0000000 Binary files a/titles/chuni/img/avatar-common.png and /dev/null differ diff --git a/titles/chuni/img/avatar-common.webp b/titles/chuni/img/avatar-common.webp new file mode 100644 index 0000000..1e2033f Binary files /dev/null and b/titles/chuni/img/avatar-common.webp differ diff --git a/titles/chuni/img/avatar-platform.png b/titles/chuni/img/avatar-platform.png deleted file mode 100644 index e981414..0000000 Binary files a/titles/chuni/img/avatar-platform.png and /dev/null differ diff --git a/titles/chuni/img/avatar-platform.webp b/titles/chuni/img/avatar-platform.webp new file mode 100644 index 0000000..1271b56 Binary files /dev/null and b/titles/chuni/img/avatar-platform.webp differ diff --git a/titles/chuni/img/character-bg.png b/titles/chuni/img/character-bg.png deleted file mode 100644 index d345ef7..0000000 Binary files a/titles/chuni/img/character-bg.png and /dev/null differ diff --git a/titles/chuni/img/character-bg.webp b/titles/chuni/img/character-bg.webp new file mode 100644 index 0000000..52fc41e Binary files /dev/null and b/titles/chuni/img/character-bg.webp differ diff --git a/titles/chuni/img/jacket/.gitignore b/titles/chuni/img/jacket/.gitignore index 99b4674..a5a9242 100644 --- a/titles/chuni/img/jacket/.gitignore +++ b/titles/chuni/img/jacket/.gitignore @@ -2,4 +2,4 @@ * # Except this file and default unknown !.gitignore -!unknown.png \ No newline at end of file +!unknown.webp \ No newline at end of file diff --git a/titles/chuni/img/jacket/unknown.png b/titles/chuni/img/jacket/unknown.png deleted file mode 100644 index 92a72d6..0000000 Binary files a/titles/chuni/img/jacket/unknown.png and /dev/null differ diff --git a/titles/chuni/img/jacket/unknown.webp b/titles/chuni/img/jacket/unknown.webp new file mode 100644 index 0000000..ebfaba4 Binary files /dev/null and b/titles/chuni/img/jacket/unknown.webp differ diff --git a/titles/chuni/img/rank/rank0.png b/titles/chuni/img/rank/rank0.png deleted file mode 100644 index ba152db..0000000 Binary files a/titles/chuni/img/rank/rank0.png and /dev/null differ diff --git a/titles/chuni/img/rank/rank0.webp b/titles/chuni/img/rank/rank0.webp new file mode 100644 index 0000000..4ae41bd Binary files /dev/null and b/titles/chuni/img/rank/rank0.webp differ diff --git a/titles/chuni/img/rank/rank1.png b/titles/chuni/img/rank/rank1.png deleted file mode 100644 index 552b12c..0000000 Binary files a/titles/chuni/img/rank/rank1.png and /dev/null differ diff --git a/titles/chuni/img/rank/rank1.webp b/titles/chuni/img/rank/rank1.webp new file mode 100644 index 0000000..1bda4dd Binary files /dev/null and b/titles/chuni/img/rank/rank1.webp differ diff --git a/titles/chuni/img/rank/rank10.png b/titles/chuni/img/rank/rank10.png deleted file mode 100644 index 14d976c..0000000 Binary files a/titles/chuni/img/rank/rank10.png and /dev/null differ diff --git a/titles/chuni/img/rank/rank10.webp b/titles/chuni/img/rank/rank10.webp new file mode 100644 index 0000000..e1c36b8 Binary files /dev/null and b/titles/chuni/img/rank/rank10.webp differ diff --git a/titles/chuni/img/rank/rank11.png b/titles/chuni/img/rank/rank11.png deleted file mode 100644 index 4e31d19..0000000 Binary files a/titles/chuni/img/rank/rank11.png and /dev/null differ diff --git a/titles/chuni/img/rank/rank11.webp b/titles/chuni/img/rank/rank11.webp new file mode 100644 index 0000000..124f47e Binary files /dev/null and b/titles/chuni/img/rank/rank11.webp differ diff --git a/titles/chuni/img/rank/rank2.png b/titles/chuni/img/rank/rank2.png deleted file mode 100644 index 0c78938..0000000 Binary files a/titles/chuni/img/rank/rank2.png and /dev/null differ diff --git a/titles/chuni/img/rank/rank2.webp b/titles/chuni/img/rank/rank2.webp new file mode 100644 index 0000000..df6dcca Binary files /dev/null and b/titles/chuni/img/rank/rank2.webp differ diff --git a/titles/chuni/img/rank/rank3.png b/titles/chuni/img/rank/rank3.png deleted file mode 100644 index 5d55b3d..0000000 Binary files a/titles/chuni/img/rank/rank3.png and /dev/null differ diff --git a/titles/chuni/img/rank/rank3.webp b/titles/chuni/img/rank/rank3.webp new file mode 100644 index 0000000..3491a22 Binary files /dev/null and b/titles/chuni/img/rank/rank3.webp differ diff --git a/titles/chuni/img/rank/rank4.png b/titles/chuni/img/rank/rank4.png deleted file mode 100644 index 5d55b3d..0000000 Binary files a/titles/chuni/img/rank/rank4.png and /dev/null differ diff --git a/titles/chuni/img/rank/rank4.webp b/titles/chuni/img/rank/rank4.webp new file mode 100644 index 0000000..3491a22 Binary files /dev/null and b/titles/chuni/img/rank/rank4.webp differ diff --git a/titles/chuni/img/rank/rank5.png b/titles/chuni/img/rank/rank5.png deleted file mode 100644 index af519b6..0000000 Binary files a/titles/chuni/img/rank/rank5.png and /dev/null differ diff --git a/titles/chuni/img/rank/rank5.webp b/titles/chuni/img/rank/rank5.webp new file mode 100644 index 0000000..8a04d1c Binary files /dev/null and b/titles/chuni/img/rank/rank5.webp differ diff --git a/titles/chuni/img/rank/rank6.png b/titles/chuni/img/rank/rank6.png deleted file mode 100644 index af519b6..0000000 Binary files a/titles/chuni/img/rank/rank6.png and /dev/null differ diff --git a/titles/chuni/img/rank/rank6.webp b/titles/chuni/img/rank/rank6.webp new file mode 100644 index 0000000..8a04d1c Binary files /dev/null and b/titles/chuni/img/rank/rank6.webp differ diff --git a/titles/chuni/img/rank/rank7.png b/titles/chuni/img/rank/rank7.png deleted file mode 100644 index db7ae79..0000000 Binary files a/titles/chuni/img/rank/rank7.png and /dev/null differ diff --git a/titles/chuni/img/rank/rank7.webp b/titles/chuni/img/rank/rank7.webp new file mode 100644 index 0000000..723a10d Binary files /dev/null and b/titles/chuni/img/rank/rank7.webp differ diff --git a/titles/chuni/img/rank/rank8.png b/titles/chuni/img/rank/rank8.png deleted file mode 100644 index db7ae79..0000000 Binary files a/titles/chuni/img/rank/rank8.png and /dev/null differ diff --git a/titles/chuni/img/rank/rank8.webp b/titles/chuni/img/rank/rank8.webp new file mode 100644 index 0000000..723a10d Binary files /dev/null and b/titles/chuni/img/rank/rank8.webp differ diff --git a/titles/chuni/img/rank/rank9.png b/titles/chuni/img/rank/rank9.png deleted file mode 100644 index 145096d..0000000 Binary files a/titles/chuni/img/rank/rank9.png and /dev/null differ diff --git a/titles/chuni/img/rank/rank9.webp b/titles/chuni/img/rank/rank9.webp new file mode 100644 index 0000000..35ee986 Binary files /dev/null and b/titles/chuni/img/rank/rank9.webp differ diff --git a/titles/chuni/img/rank/rating0.png b/titles/chuni/img/rank/rating0.png deleted file mode 100644 index 66813b8..0000000 Binary files a/titles/chuni/img/rank/rating0.png and /dev/null differ diff --git a/titles/chuni/img/rank/rating0.webp b/titles/chuni/img/rank/rating0.webp new file mode 100644 index 0000000..d75d5eb Binary files /dev/null and b/titles/chuni/img/rank/rating0.webp differ diff --git a/titles/chuni/img/rank/team3.png b/titles/chuni/img/rank/team3.png deleted file mode 100644 index 9ac4724..0000000 Binary files a/titles/chuni/img/rank/team3.png and /dev/null differ diff --git a/titles/chuni/img/rank/team3.webp b/titles/chuni/img/rank/team3.webp new file mode 100644 index 0000000..4521bbb Binary files /dev/null and b/titles/chuni/img/rank/team3.webp differ diff --git a/titles/chuni/img/stage/.gitignore b/titles/chuni/img/stage/.gitignore new file mode 100644 index 0000000..86d0cb2 --- /dev/null +++ b/titles/chuni/img/stage/.gitignore @@ -0,0 +1,4 @@ +# Ignore everything in this directory +* +# Except this file +!.gitignore \ No newline at end of file diff --git a/titles/chuni/index.py b/titles/chuni/index.py index 080c041..376ea85 100644 --- a/titles/chuni/index.py +++ b/titles/chuni/index.py @@ -1,20 +1,22 @@ -from starlette.requests import Request -from starlette.routing import Route -from starlette.responses import Response +import asyncio +import re import logging import coloredlogs -from logging.handlers import TimedRotatingFileHandler import zlib import yaml import json import inflection import string +from os import path +from typing import Tuple, Dict, List +from logging.handlers import TimedRotatingFileHandler +from starlette.requests import Request +from starlette.routing import Route +from starlette.responses import Response from Crypto.Cipher import AES from Crypto.Util.Padding import pad from Crypto.Protocol.KDF import PBKDF2 from Crypto.Hash import SHA1 -from os import path -from typing import Tuple, Dict, List from core import CoreConfig, Utils from core.title import BaseServlet @@ -37,6 +39,9 @@ from .sun import ChuniSun from .sunplus import ChuniSunPlus from .luminous import ChuniLuminous from .luminousplus import ChuniLuminousPlus +from .verse import ChuniVerse +from .xverse import ChuniXVerse + class ChuniServlet(BaseServlet): def __init__(self, core_cfg: CoreConfig, cfg_dir: str) -> None: @@ -66,6 +71,8 @@ class ChuniServlet(BaseServlet): ChuniSunPlus, ChuniLuminous, ChuniLuminousPlus, + ChuniVerse, + ChuniXVerse, ] self.logger = logging.getLogger("chuni") @@ -96,20 +103,27 @@ class ChuniServlet(BaseServlet): known_iter_counts = { ChuniConstants.VER_CHUNITHM_CRYSTAL_PLUS: 67, - f"{ChuniConstants.VER_CHUNITHM_CRYSTAL_PLUS}_int": 25, # SUPERSTAR + f"{ChuniConstants.VER_CHUNITHM_CRYSTAL_PLUS}_int": 25, # SUPERSTAR ChuniConstants.VER_CHUNITHM_PARADISE: 44, - f"{ChuniConstants.VER_CHUNITHM_PARADISE}_int": 51, # SUPERSTAR PLUS + f"{ChuniConstants.VER_CHUNITHM_PARADISE}_int": 51, # SUPERSTAR PLUS ChuniConstants.VER_CHUNITHM_NEW: 54, f"{ChuniConstants.VER_CHUNITHM_NEW}_int": 49, + f"{ChuniConstants.VER_CHUNITHM_NEW}_chn": 37, ChuniConstants.VER_CHUNITHM_NEW_PLUS: 25, f"{ChuniConstants.VER_CHUNITHM_NEW_PLUS}_int": 31, + f"{ChuniConstants.VER_CHUNITHM_NEW_PLUS}_chn": 35, # NEW ChuniConstants.VER_CHUNITHM_SUN: 70, f"{ChuniConstants.VER_CHUNITHM_SUN}_int": 35, ChuniConstants.VER_CHUNITHM_SUN_PLUS: 36, f"{ChuniConstants.VER_CHUNITHM_SUN_PLUS}_int": 36, ChuniConstants.VER_CHUNITHM_LUMINOUS: 8, f"{ChuniConstants.VER_CHUNITHM_LUMINOUS}_int": 8, + f"{ChuniConstants.VER_CHUNITHM_LUMINOUS}_chn": 8, ChuniConstants.VER_CHUNITHM_LUMINOUS_PLUS: 56, + ChuniConstants.VER_CHUNITHM_VERSE: 42, + f"{ChuniConstants.VER_CHUNITHM_VERSE}_chn": 37, + ChuniConstants.VER_CHUNITHM_X_VERSE: 14, + f"{ChuniConstants.VER_CHUNITHM_X_VERSE}_int": 96, } for version, keys in self.game_cfg.crypto.keys.items(): @@ -120,7 +134,7 @@ class ChuniServlet(BaseServlet): version_idx = version else: version_idx = int(version.split("_")[0]) - + salt = bytes.fromhex(keys[2]) if len(keys) >= 4: @@ -150,7 +164,9 @@ class ChuniServlet(BaseServlet): and version_idx >= ChuniConstants.VER_CHUNITHM_NEW ): method_fixed += "C3Exp" - + elif isinstance(version, str) and version.endswith("_chn"): + method_fixed += "Chn" + hash = PBKDF2( method_fixed, salt, @@ -159,7 +175,8 @@ class ChuniServlet(BaseServlet): hmac_hash_module=SHA1, ) - hashed_name = hash.hex()[:32] # truncate unused bytes like the game does + # truncate unused bytes like the game does + hashed_name = hash.hex()[:32] self.hash_table[version][hashed_name] = method_fixed self.logger.debug( @@ -181,22 +198,48 @@ class ChuniServlet(BaseServlet): return True - 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}/", self.core_cfg.server.hostname) + def get_allnet_info( + self, game_code: str, game_ver: int, keychip: str + ) -> Tuple[str, str]: + title_port_int = Utils.get_title_port(self.core_cfg) + title_port_ssl_int = Utils.get_title_port_ssl(self.core_cfg) - return (f"http://{self.core_cfg.server.hostname}/{game_code}/{game_ver}/", self.core_cfg.server.hostname) + if self.game_cfg.server.use_https and ( + (game_code == "SDBT" and game_ver >= 145) or # JP use TLS from CRYSTAL PLUS + game_code != "SDBT" # SDGS and SDHJ all version can use TLS + ): + proto = "https" + else: + proto = "http" + + if 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"{proto}://{self.core_cfg.server.hostname}{t_port}/{game_code}/{game_ver}/", + f"{self.core_cfg.server.hostname}", + ) def get_routes(self) -> List[Route]: return [ - Route("/{game:str}/{version:int}/ChuniServlet/{endpoint:str}", self.render_POST, methods=['POST']), - Route("/{game:str}/{version:int}/ChuniServlet/MatchingServer/{endpoint:str}", self.render_POST, methods=['POST']), + Route( + "/{game:str}/{version:int}/ChuniServlet/{endpoint:str}", + self.render_POST, + methods=["POST"], + ), + Route( + "/{game:str}/{version:int}/ChuniServlet/MatchingServer/{endpoint:str}", + self.render_POST, + methods=["POST"], + ), ] async def render_POST(self, request: Request) -> bytes: - endpoint: str = request.path_params.get('endpoint') - version: int = request.path_params.get('version') - game_code: str = request.path_params.get('game') + endpoint: str = request.path_params.get("endpoint") + version: int = request.path_params.get("version") + game_code: str = request.path_params.get("game") if endpoint.lower() == "ping": return Response(zlib.compress(b'{"returnCode": "1"}')) @@ -207,58 +250,79 @@ class ChuniServlet(BaseServlet): internal_ver = 0 client_ip = Utils.get_ip_addr(request) - if game_code == "SDHD" or game_code == "SDBT": # JP - if version < 105: # 1.0 - internal_ver = ChuniConstants.VER_CHUNITHM - elif version >= 105 and version < 110: # PLUS - internal_ver = ChuniConstants.VER_CHUNITHM_PLUS - elif version >= 110 and version < 115: # AIR - internal_ver = ChuniConstants.VER_CHUNITHM_AIR - elif version >= 115 and version < 120: # AIR PLUS - internal_ver = ChuniConstants.VER_CHUNITHM_AIR_PLUS - elif version >= 120 and version < 125: # STAR - internal_ver = ChuniConstants.VER_CHUNITHM_STAR - elif version >= 125 and version < 130: # STAR PLUS - internal_ver = ChuniConstants.VER_CHUNITHM_STAR_PLUS - elif version >= 130 and version < 135: # AMAZON - internal_ver = ChuniConstants.VER_CHUNITHM_AMAZON - elif version >= 135 and version < 140: # AMAZON PLUS - internal_ver = ChuniConstants.VER_CHUNITHM_AMAZON_PLUS - elif version >= 140 and version < 145: # CRYSTAL - internal_ver = ChuniConstants.VER_CHUNITHM_CRYSTAL - elif version >= 145 and version < 150: # CRYSTAL PLUS - internal_ver = ChuniConstants.VER_CHUNITHM_CRYSTAL_PLUS - elif version >= 150 and version < 200: # PARADISE - internal_ver = ChuniConstants.VER_CHUNITHM_PARADISE - elif version >= 200 and version < 205: # NEW!! - internal_ver = ChuniConstants.VER_CHUNITHM_NEW - elif version >= 205 and version < 210: # NEW PLUS!! - internal_ver = ChuniConstants.VER_CHUNITHM_NEW_PLUS - elif version >= 210 and version < 215: # SUN - internal_ver = ChuniConstants.VER_CHUNITHM_SUN - elif version >= 215 and version < 220: # SUN PLUS - internal_ver = ChuniConstants.VER_CHUNITHM_SUN_PLUS - elif version >= 220 and version < 225: # LUMINOUS - internal_ver = ChuniConstants.VER_CHUNITHM_LUMINOUS - elif version >= 225: # LUMINOUS PLUS - internal_ver = ChuniConstants.VER_CHUNITHM_LUMINOUS_PLUS - elif game_code == "SDGS": # Int - if version < 105: # SUPERSTAR - internal_ver = ChuniConstants.VER_CHUNITHM_CRYSTAL_PLUS - elif version >= 105 and version < 110: # SUPERSTAR PLUS *Cursed but needed due to different encryption key - internal_ver = ChuniConstants.VER_CHUNITHM_PARADISE - elif version >= 110 and version < 115: # NEW - internal_ver = ChuniConstants.VER_CHUNITHM_NEW - elif version >= 115 and version < 120: # NEW PLUS!! - internal_ver = ChuniConstants.VER_CHUNITHM_NEW_PLUS - elif version >= 120 and version < 125: # SUN - internal_ver = ChuniConstants.VER_CHUNITHM_SUN - elif version >= 125 and version < 130: # SUN PLUS - internal_ver = ChuniConstants.VER_CHUNITHM_SUN_PLUS - elif version >= 130 and version < 135: # LUMINOUS - internal_ver = ChuniConstants.VER_CHUNITHM_LUMINOUS - elif version >= 135: # LUMINOUS PLUS - internal_ver = ChuniConstants.VER_CHUNITHM_LUMINOUS_PLUS + if game_code == "SDHD" or game_code == "SDBT": # JP + if version < 105: # 1.0 + internal_ver = ChuniConstants.VER_CHUNITHM + elif version >= 105 and version < 110: # PLUS + internal_ver = ChuniConstants.VER_CHUNITHM_PLUS + elif version >= 110 and version < 115: # AIR + internal_ver = ChuniConstants.VER_CHUNITHM_AIR + elif version >= 115 and version < 120: # AIR PLUS + internal_ver = ChuniConstants.VER_CHUNITHM_AIR_PLUS + elif version >= 120 and version < 125: # STAR + internal_ver = ChuniConstants.VER_CHUNITHM_STAR + elif version >= 125 and version < 130: # STAR PLUS + internal_ver = ChuniConstants.VER_CHUNITHM_STAR_PLUS + elif version >= 130 and version < 135: # AMAZON + internal_ver = ChuniConstants.VER_CHUNITHM_AMAZON + elif version >= 135 and version < 140: # AMAZON PLUS + internal_ver = ChuniConstants.VER_CHUNITHM_AMAZON_PLUS + elif version >= 140 and version < 145: # CRYSTAL + internal_ver = ChuniConstants.VER_CHUNITHM_CRYSTAL + elif version >= 145 and version < 150: # CRYSTAL PLUS + internal_ver = ChuniConstants.VER_CHUNITHM_CRYSTAL_PLUS + elif version >= 150 and version < 200: # PARADISE + internal_ver = ChuniConstants.VER_CHUNITHM_PARADISE + elif version >= 200 and version < 205: # NEW!! + internal_ver = ChuniConstants.VER_CHUNITHM_NEW + elif version >= 205 and version < 210: # NEW PLUS!! + internal_ver = ChuniConstants.VER_CHUNITHM_NEW_PLUS + elif version >= 210 and version < 215: # SUN + internal_ver = ChuniConstants.VER_CHUNITHM_SUN + elif version >= 215 and version < 220: # SUN PLUS + internal_ver = ChuniConstants.VER_CHUNITHM_SUN_PLUS + elif version >= 220 and version < 225: # LUMINOUS + internal_ver = ChuniConstants.VER_CHUNITHM_LUMINOUS + elif version >= 225 and version < 230: # LUMINOUS PLUS + internal_ver = ChuniConstants.VER_CHUNITHM_LUMINOUS_PLUS + elif version >= 230 and version < 240: # VERSE + internal_ver = ChuniConstants.VER_CHUNITHM_VERSE + elif version >= 240: # X-VERSE + internal_ver = ChuniConstants.VER_CHUNITHM_X_VERSE + elif game_code == "SDGS": # Int + if version < 105: # SUPERSTAR + internal_ver = ChuniConstants.VER_CHUNITHM_CRYSTAL_PLUS + elif ( + version >= 105 and version < 110 + ): # SUPERSTAR PLUS *Cursed but needed due to different encryption key + internal_ver = ChuniConstants.VER_CHUNITHM_PARADISE + elif version >= 110 and version < 115: # NEW + internal_ver = ChuniConstants.VER_CHUNITHM_NEW + elif version >= 115 and version < 120: # NEW PLUS!! + internal_ver = ChuniConstants.VER_CHUNITHM_NEW_PLUS + elif version >= 120 and version < 125: # SUN + internal_ver = ChuniConstants.VER_CHUNITHM_SUN + elif version >= 125 and version < 130: # SUN PLUS + internal_ver = ChuniConstants.VER_CHUNITHM_SUN_PLUS + elif version >= 130 and version < 135: # LUMINOUS + internal_ver = ChuniConstants.VER_CHUNITHM_LUMINOUS + elif version >= 135 and version < 140: # LUMINOUS PLUS + internal_ver = ChuniConstants.VER_CHUNITHM_LUMINOUS_PLUS + elif version >= 140 and version < 150: # VERSE + internal_ver = ChuniConstants.VER_CHUNITHM_VERSE + elif version >= 150: # X-VERSE + internal_ver = ChuniConstants.VER_CHUNITHM_X_VERSE + elif game_code == "SDHJ": # Chn + if version < 110: # NEW + internal_ver = ChuniConstants.VER_CHUNITHM_NEW + elif ( + version >= 110 and version < 120 + ): # NEW *Cursed but needed due to different encryption key + internal_ver = ChuniConstants.VER_CHUNITHM_NEW_PLUS + elif version >= 120 and version < 130: # LUMINOUS + internal_ver = ChuniConstants.VER_CHUNITHM_LUMINOUS + elif version >= 130: # VERSE + internal_ver = ChuniConstants.VER_CHUNITHM_VERSE if all(c in string.hexdigits for c in endpoint) and len(endpoint) == 32: # If we get a 32 character long hex string, it's a hash and we're @@ -268,6 +332,9 @@ class ChuniServlet(BaseServlet): if game_code == "SDGS": crypto_cfg_key = f"{internal_ver}_int" hash_table_key = f"{internal_ver}_int" + elif game_code == "SDHJ": + 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 @@ -337,14 +404,16 @@ class ChuniServlet(BaseServlet): endpoint = endpoint.replace("C3Exp", "") elif game_code == "SDGS" and version < 110: endpoint = endpoint.replace("Exp", "") + elif game_code == "SDHJ": + endpoint = endpoint.replace("Chn", "") else: endpoint = endpoint - func_to_find = "handle_" + inflection.underscore(endpoint) + "_request" + func_to_find = "handle_" + self.strict_underscore(endpoint) + "_request" handler_cls = self.versions[internal_ver](self.core_cfg, self.game_cfg) if not hasattr(handler_cls, func_to_find): - self.logger.warning(f"Unhandled v{version} request {endpoint}") + self.logger.warning(f"Unhandled v{version} request {func_to_find}") resp = {"returnCode": 1} else: @@ -378,3 +447,9 @@ class ChuniServlet(BaseServlet): ) return Response(crypt.encrypt(padded)) + + def strict_underscore(self, name: str) -> str: + # Insert underscores between *all* capital letters + name = re.sub(r"([A-Z])([A-Z])", r"\1_\2", name) + return inflection.underscore(name) + diff --git a/titles/chuni/luminous.py b/titles/chuni/luminous.py index 6fcc9ea..48f31e7 100644 --- a/titles/chuni/luminous.py +++ b/titles/chuni/luminous.py @@ -1,6 +1,8 @@ from datetime import timedelta from typing import Dict +from sqlalchemy.engine import Row + from core.config import CoreConfig from titles.chuni.config import ChuniConfig from titles.chuni.const import ( @@ -11,13 +13,80 @@ from titles.chuni.const import ( from titles.chuni.sunplus import ChuniSunPlus +class MysticAreaConditions: + """The "Mystic Rainbow of " map is a special reward map for obtaining + rainbow statues. There's one gold statue area that's unlocked when at least one + original map is finished, and additional rainbow statue areas are added as new + original maps are added. + """ + + def __init__( + self, events_by_id: dict[int, Row], map_area_1_id: int, date_time_format: str + ): + self.events_by_id = events_by_id + self.date_time_format = date_time_format + + self._map_area_1_conditions = { + "mapAreaId": map_area_1_id, + "length": 0, + "mapAreaConditionList": [], + } + self._map_area_1_added = False + self._conditions = [] + + @property + def conditions(self): + return self._conditions + + def add_condition( + self, map_flag_event_id: int, condition_map_id: int, mystic_map_area_id: int + ): + if (event := self.events_by_id.get(map_flag_event_id)) is None: + return + + start_date = event["startDate"].strftime(self.date_time_format) + + self._map_area_1_conditions["mapAreaConditionList"].append( + { + "type": MapAreaConditionType.MAP_CLEARED.value, + "conditionId": condition_map_id, + "logicalOpe": MapAreaConditionLogicalOperator.OR.value, + "startDate": start_date, + "endDate": "2099-12-31 00:00:00", + } + ) + self._map_area_1_conditions["length"] = len( + self._map_area_1_conditions["mapAreaConditionList"] + ) + + if not self._map_area_1_added: + self._conditions.append(self._map_area_1_conditions) + self._map_area_1_added = True + + self._conditions.append( + { + "mapAreaId": mystic_map_area_id, + "length": 1, + "mapAreaConditionList": [ + { + "type": MapAreaConditionType.MAP_CLEARED.value, + "conditionId": condition_map_id, + "logicalOpe": MapAreaConditionLogicalOperator.AND.value, + "startDate": start_date, + "endDate": "2099-12-31 00:00:00", + } + ], + } + ) + + class ChuniLuminous(ChuniSunPlus): def __init__(self, core_cfg: CoreConfig, game_cfg: ChuniConfig) -> None: super().__init__(core_cfg, game_cfg) self.version = ChuniConstants.VER_CHUNITHM_LUMINOUS - 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) + 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.20.00" @@ -77,18 +146,18 @@ class ChuniLuminous(ChuniSunPlus): 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) + events = await self.data.static.get_enabled_events(self.version) or [] event_by_id = {evt["eventId"]: evt for evt in events} conditions = [] - # The Mystic Rainbow of LUMINOUS map unlocks when any mainline LUMINOUS area - # (ep. I, ep. II, ep. III) are completed. - mystic_area_1_conditions = { - "mapAreaId": 3229301, # Mystic Rainbow of LUMINOUS Area 1 - "length": 0, - "mapAreaConditionList": [], - } - mystic_area_1_added = False + mystic_conditions = MysticAreaConditions( + event_by_id, 3229301, self.date_time_format + ) + mystic_conditions.add_condition(14005, 3020701, 3229302) + mystic_conditions.add_condition(14251, 3020702, 3229303) + mystic_conditions.add_condition(14481, 3020703, 3229304) + + conditions += mystic_conditions.conditions # Secret AREA: MUSIC GAME if 14029 in event_by_id: @@ -229,114 +298,6 @@ class ChuniLuminous(ChuniSunPlus): ] ) - # LUMINOUS ep. I - if 14005 in event_by_id: - start_date = event_by_id[14005]["startDate"].strftime(self.date_time_format) - - if not mystic_area_1_added: - conditions.append(mystic_area_1_conditions) - mystic_area_1_added = True - - mystic_area_1_conditions["length"] += 1 - mystic_area_1_conditions["mapAreaConditionList"].append( - { - "type": MapAreaConditionType.MAP_CLEARED.value, - "conditionId": 3020701, - "logicalOpe": MapAreaConditionLogicalOperator.OR.value, - "startDate": start_date, - "endDate": "2099-12-31 00:00:00.0", - } - ) - - conditions.append( - { - "mapAreaId": 3229302, # Mystic Rainbow of LUMINOUS Area 2, - "length": 1, - # Unlocks when LUMINOUS ep. I is completed. - "mapAreaConditionList": [ - { - "type": MapAreaConditionType.MAP_CLEARED.value, - "conditionId": 3020701, - "logicalOpe": MapAreaConditionLogicalOperator.AND.value, - "startDate": start_date, - "endDate": "2099-12-31 00:00:00.0", - }, - ], - } - ) - - # LUMINOUS ep. II - if 14251 in event_by_id: - start_date = event_by_id[14251]["startDate"].strftime(self.date_time_format) - - if not mystic_area_1_added: - conditions.append(mystic_area_1_conditions) - mystic_area_1_added = True - - mystic_area_1_conditions["length"] += 1 - mystic_area_1_conditions["mapAreaConditionList"].append( - { - "type": MapAreaConditionType.MAP_CLEARED.value, - "conditionId": 3020702, - "logicalOpe": MapAreaConditionLogicalOperator.OR.value, - "startDate": start_date, - "endDate": "2099-12-31 00:00:00.0", - } - ) - - conditions.append( - { - "mapAreaId": 3229303, # Mystic Rainbow of LUMINOUS Area 3, - "length": 1, - # Unlocks when LUMINOUS ep. II is completed. - "mapAreaConditionList": [ - { - "type": MapAreaConditionType.MAP_CLEARED.value, - "conditionId": 3020702, - "logicalOpe": MapAreaConditionLogicalOperator.AND.value, - "startDate": start_date, - "endDate": "2099-12-31 00:00:00.0", - }, - ], - } - ) - - # LUMINOUS ep. III - if 14481 in event_by_id: - start_date = event_by_id[14481]["startDate"].strftime(self.date_time_format) - - if not mystic_area_1_added: - conditions.append(mystic_area_1_conditions) - mystic_area_1_added = True - - mystic_area_1_conditions["length"] += 1 - mystic_area_1_conditions["mapAreaConditionList"].append( - { - "type": MapAreaConditionType.MAP_CLEARED.value, - "conditionId": 3020703, - "logicalOpe": MapAreaConditionLogicalOperator.OR.value, - "startDate": start_date, - "endDate": "2099-12-31 00:00:00.0", - } - ) - - conditions.append( - { - "mapAreaId": 3229304, # Mystic Rainbow of LUMINOUS Area 4, - "length": 1, - # Unlocks when LUMINOUS ep. III is completed. - "mapAreaConditionList": [ - { - "type": MapAreaConditionType.MAP_CLEARED.value, - "conditionId": 3020703, - "logicalOpe": MapAreaConditionLogicalOperator.AND.value, - "startDate": start_date, - "endDate": "2099-12-31 00:00:00.0", - }, - ], - } - ) - # 1UM1N0U5 ep. 111 if 14483 in event_by_id: start_date = event_by_id[14483]["startDate"].replace( @@ -381,14 +342,14 @@ class ChuniLuminous(ChuniSunPlus): MapAreaConditionType.RANK_SSP.value, MapAreaConditionType.RANK_SP.value, MapAreaConditionType.RANK_S.value, - MapAreaConditionType.ALWAYS_UNLOCKED.value, + MapAreaConditionType.INVALID.value, ] ): start = (start_date + timedelta(days=14 * (i + 1))).strftime( self.date_time_format ) - if typ != MapAreaConditionType.ALWAYS_UNLOCKED.value: + if typ != MapAreaConditionType.INVALID.value: end = ( start_date + timedelta(days=14 * (i + 2)) - timedelta(seconds=1) ).strftime(self.date_time_format) @@ -407,7 +368,7 @@ class ChuniLuminous(ChuniSunPlus): ) else: end = "2099-12-31 00:00:00" - + title_conditions.append( { "type": typ, @@ -431,7 +392,7 @@ class ChuniLuminous(ChuniSunPlus): # Ultimate Force # For the first 14 days, the condition is to obtain all 9 "Key of ..." titles # Afterwards, the condition is the 6 "Key of ..." titles that you can obtain - # by playing the 6 areas, as well as obtaining specific ranks on + # by playing the 6 areas, as well as obtaining specific ranks on # [CRYSTAL_ACCESS] / Strange Love / βlαnoir ultimate_force_conditions = [] @@ -473,7 +434,7 @@ class ChuniLuminous(ChuniSunPlus): start = (start_date + timedelta(days=14 * (i + 1))).strftime( self.date_time_format ) - + end = ( start_date + timedelta(days=14 * (i + 2)) - timedelta(seconds=1) ).strftime(self.date_time_format) @@ -487,7 +448,7 @@ class ChuniLuminous(ChuniSunPlus): "startDate": start, "endDate": end, } - for condition_id in {109403, 212103, 244203} + for condition_id in {109403, 212103, 244203} ] ) diff --git a/titles/chuni/luminousplus.py b/titles/chuni/luminousplus.py index 659b39d..045e4e5 100644 --- a/titles/chuni/luminousplus.py +++ b/titles/chuni/luminousplus.py @@ -4,7 +4,7 @@ from typing import Dict from core.config import CoreConfig from titles.chuni.config import ChuniConfig from titles.chuni.const import ChuniConstants, MapAreaConditionLogicalOperator, MapAreaConditionType -from titles.chuni.luminous import ChuniLuminous +from titles.chuni.luminous import ChuniLuminous, MysticAreaConditions class ChuniLuminousPlus(ChuniLuminous): @@ -12,8 +12,8 @@ class ChuniLuminousPlus(ChuniLuminous): super().__init__(core_cfg, game_cfg) self.version = ChuniConstants.VER_CHUNITHM_LUMINOUS_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) + 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.25.00" @@ -66,6 +66,158 @@ class ChuniLuminousPlus(ChuniLuminous): 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, + 3229601, + self.date_time_format, + ) + + # Mystic Rainbow of LUMINOUS PLUS - LUMINOUS ep. IV + mystic_conditions.add_condition(15005, 3020704, 3229602) + + # Mystic Rainbow of LUMINOUS PLUS - LUMINOUS ep. V + mystic_conditions.add_condition(15306, 3020705, 3229603) + + # Mystic Rainbow of LUMINOUS PLUS - LUMINOUS ep. VI + mystic_conditions.add_condition(15451, 3020706, 3229604) + + # Mystic Rainbow of LUMINOUS PLUS - LUMINOUS ep. VII + mystic_conditions.add_condition(15506, 3020707, 3229605) + + conditions += mystic_conditions.conditions + + # 1UM1N0U5 ep. 111 continues. The map is automatically unlocked after finishing + # LUMINOUS ep. III in LUMINOUS PLUS. + if ep_111 := event_by_id.get(15009): + start_date = ep_111["startDate"].strftime(self.date_time_format) + + conditions.append({ + "mapAreaId": 3229207, + "length": 1, + "mapAreaConditionList": [ + { + "type": MapAreaConditionType.MAP_CLEARED.value, + "conditionId": 3020703, + "logicalOpe": MapAreaConditionLogicalOperator.AND.value, + "startDate": start_date, + "endDate": "2099-12-31 00:00:00", + }, + ], + }) + + # ■・■■■■■■・■ + # Finish LUMINOUS ep. IV and obtain the title 「ここは…何処なんだ…?」. + if re_fiction_o := event_by_id.get(15032): + start_date = re_fiction_o["startDate"].strftime(self.date_time_format) + + conditions.append({ + "mapAreaId": 3229501, + "length": 2, + "mapAreaConditionList": [ + { + "type": MapAreaConditionType.MAP_CLEARED.value, + "conditionId": 3020704, + "logicalOpe": MapAreaConditionLogicalOperator.AND.value, + "startDate": start_date, + "endDate": "2099-12-31 00:00:00", + }, + { + "type": MapAreaConditionType.TROPHY_OBTAINED.value, + "conditionId": 7105, + "logicalOpe": MapAreaConditionLogicalOperator.AND.value, + "startDate": start_date, + "endDate": "2099-12-31 00:00:00", + } + ] + }) + + # The Conductor's Path + # ALL JUSTICE CRITICAL 其のエメラルドを見よ MASTER. + if the_conductors_path := event_by_id.get(15033): + start_date = the_conductors_path["startDate"].strftime(self.date_time_format) + + conditions.append({ + "mapAreaId": 3229701, + "length": 1, + "mapAreaConditionList": [ + { + "type": MapAreaConditionType.ALL_JUSTICE_CRITICAL.value, + "conditionId": 260003, + "logicalOpe": MapAreaConditionLogicalOperator.AND.value, + "startDate": start_date, + "endDate": "2099-12-31 00:00:00", + } + ] + }) + + # Cave of RVESE + if episode__x__ := event_by_id.get(15254): + start_date = episode__x__["startDate"].strftime(self.date_time_format) + + conditions.extend([ + # Episode. _ _ X _ _ map area 1 + # Finish the HARDCORE TANO*C collaboration map. + { + "mapAreaId": 2208801, + "length": 1, + "mapAreaConditionList": [ + { + "type": MapAreaConditionType.MAP_CLEARED.value, + "conditionId": 2006533, + "logicalOpe": MapAreaConditionLogicalOperator.AND.value, + "startDate": start_date, + "endDate": "2099-12-31 00:00:00", + }, + ], + }, + # Episode. _ _ X _ _ map area 2 + # Equip the title 「第壱の石版【V】」 to access the map area. + { + "mapAreaId": 2208802, + "length": 1, + "mapAreaConditionList": [ + { + "type": MapAreaConditionType.TROPHY_EQUIPPED.value, + "conditionId": 7107, + "logicalOpe": MapAreaConditionLogicalOperator.AND.value, + "startDate": start_date, + "endDate": "2099-12-31 00:00:00", + }, + ], + }, + # Episode. _ _ X _ _ map area 3 + # Equip the title 「第弐の石版【Λ】」 to access the map area. + { + "mapAreaId": 2208803, + "length": 1, + "mapAreaConditionList": [ + { + "type": MapAreaConditionType.TROPHY_EQUIPPED.value, + "conditionId": 7104, + "logicalOpe": MapAreaConditionLogicalOperator.AND.value, + "startDate": start_date, + "endDate": "2099-12-31 00:00:00", + }, + ], + }, + # Episode. _ _ X _ _ map area 4 + # Complete the 3 other map areas. + { + "mapAreaId": 2208804, + "length": 3, + "mapAreaConditionList": [ + { + "type": MapAreaConditionType.MAP_AREA_CLEARED.value, + "conditionId": area_id, + "logicalOpe": MapAreaConditionLogicalOperator.AND.value, + "startDate": start_date, + "endDate": "2099-12-31 00:00:00", + } + for area_id in range(2208801, 2208804) + ], + }, + ]) # LUMINOUS ep. Ascension if ep_ascension := event_by_id.get(15512): diff --git a/titles/chuni/new.py b/titles/chuni/new.py index a3aa1a3..76221cf 100644 --- a/titles/chuni/new.py +++ b/titles/chuni/new.py @@ -28,16 +28,20 @@ class ChuniNew(ChuniBase): def _interal_ver_to_intver(self) -> str: if self.version == ChuniConstants.VER_CHUNITHM_NEW: return "200" - if self.version == ChuniConstants.VER_CHUNITHM_NEW_PLUS: + elif self.version == ChuniConstants.VER_CHUNITHM_NEW_PLUS: return "205" - if self.version == ChuniConstants.VER_CHUNITHM_SUN: + elif self.version == ChuniConstants.VER_CHUNITHM_SUN: return "210" - if self.version == ChuniConstants.VER_CHUNITHM_SUN_PLUS: + elif self.version == ChuniConstants.VER_CHUNITHM_SUN_PLUS: return "215" - if self.version == ChuniConstants.VER_CHUNITHM_LUMINOUS: + elif self.version == ChuniConstants.VER_CHUNITHM_LUMINOUS: return "220" - if self.version == ChuniConstants.VER_CHUNITHM_LUMINOUS_PLUS: + elif self.version == ChuniConstants.VER_CHUNITHM_LUMINOUS_PLUS: return "225" + elif self.version == ChuniConstants.VER_CHUNITHM_VERSE: + return "230" + elif self.version == ChuniConstants.VER_CHUNITHM_X_VERSE: + return "240" async def handle_get_game_setting_api_request(self, data: Dict) -> Dict: # use UTC time and convert it to JST time by adding +9 @@ -171,7 +175,7 @@ class ChuniNew(ChuniBase): } return data1 - async def handle_cm_get_user_preview_api_request(self, data: Dict) -> Dict: + async def handle_c_m_get_user_preview_api_request(self, data: Dict) -> Dict: p = await self.data.profile.get_profile_data(data["userId"], self.version) if p is None: return {} @@ -244,7 +248,7 @@ class ChuniNew(ChuniBase): "ssrBookCalcList": [], } - async def handle_cm_get_user_data_api_request(self, data: Dict) -> Dict: + async def handle_c_m_get_user_data_api_request(self, data: Dict) -> Dict: p = await self.data.profile.get_profile_data(data["userId"], self.version) if p is None: return {} @@ -347,10 +351,10 @@ class ChuniNew(ChuniBase): "userCardPrintStateList": card_print_state_list, } - async def handle_cm_get_user_character_api_request(self, data: Dict) -> Dict: + async def handle_c_m_get_user_character_api_request(self, data: Dict) -> Dict: return await super().handle_get_user_character_api_request(data) - async def handle_cm_get_user_item_api_request(self, data: Dict) -> Dict: + async def handle_c_m_get_user_item_api_request(self, data: Dict) -> Dict: return await super().handle_get_user_item_api_request(data) async def handle_roll_gacha_api_request(self, data: Dict) -> Dict: @@ -395,7 +399,7 @@ class ChuniNew(ChuniBase): return {"length": len(rolled_cards), "gameGachaCardList": rolled_cards} - async def handle_cm_upsert_user_gacha_api_request(self, data: Dict) -> Dict: + async def handle_c_m_upsert_user_gacha_api_request(self, data: Dict) -> Dict: upsert = data["cmUpsertUserGacha"] user_id = data["userId"] place_id = data["placeId"] @@ -450,7 +454,7 @@ class ChuniNew(ChuniBase): "userCardPrintStateList": card_print_state_list, } - async def handle_cm_upsert_user_printlog_api_request(self, data: Dict) -> Dict: + async def handle_c_m_upsert_user_printlog_api_request(self, data: Dict) -> Dict: return { "returnCode": 1, "orderId": 0, @@ -458,7 +462,7 @@ class ChuniNew(ChuniBase): "apiName": "CMUpsertUserPrintlogApi", } - async def handle_cm_upsert_user_print_api_request(self, data: Dict) -> Dict: + async def handle_c_m_upsert_user_print_api_request(self, data: Dict) -> Dict: user_print_detail = data["userPrintDetail"] user_id = data["userId"] @@ -483,7 +487,7 @@ class ChuniNew(ChuniBase): "apiName": "CMUpsertUserPrintApi", } - async def handle_cm_upsert_user_print_subtract_api_request(self, data: Dict) -> Dict: + async def handle_c_m_upsert_user_print_subtract_api_request(self, data: Dict) -> Dict: upsert = data["userCardPrintState"] user_id = data["userId"] place_id = data["placeId"] @@ -500,7 +504,7 @@ class ChuniNew(ChuniBase): return {"returnCode": "1", "apiName": "CMUpsertUserPrintSubtractApi"} - async def handle_cm_upsert_user_print_cancel_api_request(self, data: Dict) -> Dict: + async def handle_c_m_upsert_user_print_cancel_api_request(self, data: Dict) -> Dict: order_ids = data["orderIdList"] user_id = data["userId"] diff --git a/titles/chuni/newplus.py b/titles/chuni/newplus.py index 84467fb..83862bc 100644 --- a/titles/chuni/newplus.py +++ b/titles/chuni/newplus.py @@ -11,8 +11,8 @@ class ChuniNewPlus(ChuniNew): super().__init__(core_cfg, game_cfg) self.version = ChuniConstants.VER_CHUNITHM_NEW_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) + 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) # hardcode lastDataVersion for CardMaker 1.35 A028 user_data["lastDataVersion"] = "2.05.00" diff --git a/titles/chuni/read.py b/titles/chuni/read.py index bd6ff07..3c9513d 100644 --- a/titles/chuni/read.py +++ b/titles/chuni/read.py @@ -1,9 +1,11 @@ +from logging import Logger from typing import Optional -from os import walk, path +from os import walk, path, remove import xml.etree.ElementTree as ET from read import BaseReader from PIL import Image import configparser +import glob from core.config import CoreConfig from titles.chuni.database import ChuniData @@ -43,6 +45,9 @@ class ChuniReader(BaseReader): if self.version >= ChuniConstants.VER_CHUNITHM_NEW: we_diff = "5" + # Convert any old assets created with a previous version of the importer + ChuniReader.ConvertOldAssets(self.logger) + # character images could be stored anywhere across all the data dirs. Map them first self.logger.info(f"Mapping DDS image files...") dds_images = dict() @@ -62,6 +67,10 @@ class ChuniReader(BaseReader): await self.read_character(f"{dir}/chara", dds_images, this_opt_id) await self.read_map_icon(f"{dir}/mapIcon", this_opt_id) await self.read_system_voice(f"{dir}/systemVoice", this_opt_id) + await self.read_unlock_challenge(f"{dir}/unlockChallenge") + await self.read_linked_verse(f"{dir}/linkedVerse") + if self.version >= ChuniConstants.VER_CHUNITHM_X_VERSE: + await self.read_stage(f"{dir}/stage", this_opt_id) async def read_login_bonus(self, root_dir: str, opt_id: Optional[int] = None) -> None: for root, dirs, files in walk(f"{root_dir}loginBonusPreset"): @@ -499,18 +508,132 @@ class ChuniReader(BaseReader): self.logger.info(f"Opt folder {opt_folder} (Database ID {opt_id}) contains {data_config['Version']['Name']} v{data_config['Version']['VerMajor']}.{data_config['Version']['VerMinor']}.{opt_seq}") return opt_id + async def read_unlock_challenge(self, uc_dir: str) -> None: + for root, dirs, files in walk(uc_dir): + for dir in dirs: + if path.exists(f"{root}/{dir}/UnlockChallenge.xml"): + with open(f"{root}/{dir}/UnlockChallenge.xml", "r", encoding="utf-8") as fp: + strdata = fp.read() + + xml_root = ET.fromstring(strdata) + for name in xml_root.findall("name"): + id = name.find("id").text + name = name.find("str").text + + course_ids = [] + for course in xml_root.find("musicList/list/UnlockChallengeMusicListSubData/unlockChallengeMusicData/courseList/list").findall("UnlockChallengeCourseListSubData"): + course_id = course.find("unlockChallengeCourseData/courseName").find("id").text + course_ids.append(course_id) + + # Build keyword arguments dynamically for up to 5 course IDs + course_kwargs = { + f"course_id{i+1}": course_ids[i] + for i in range(min(5, len(course_ids))) + } + + result = await self.data.static.put_unlock_challenge( + self.version, id, name, + **course_kwargs + ) + if result is not None: + self.logger.info(f"Inserted unlock challenge {id}") + else: + self.logger.warning(f"Failed to unlock challenge {id}") + + async def read_linked_verse(self, lv_dir: str) -> None: + for root, dirs, files in walk(lv_dir): + for dir in dirs: + if path.exists(f"{root}/{dir}/LinkedVerse.xml"): + with open(f"{root}/{dir}/LinkedVerse.xml", "r", encoding="utf-8") as fp: + strdata = fp.read() + + xml_root = ET.fromstring(strdata) + for name in xml_root.findall("name"): + id = name.find("id").text + name = name.find("str").text + + course_ids = [] + for course in xml_root.find("musicList/list/LinkedVerseMusicListSubData/linkedVerseMusicData/courseList/list").findall("LinkedVerseCourseListSubData"): + course_id = course.find("linkedVerseCourseData/courseName").find("id").text + course_ids.append(course_id) + + # Build keyword arguments dynamically for up to 5 course IDs + course_kwargs = { + f"course_id{i+1}": course_ids[i] + for i in range(min(5, len(course_ids))) + } + + result = await self.data.static.put_linked_verse( + self.version, id, name, + **course_kwargs + ) + if result is not None: + self.logger.info(f"Inserted Linked VERSE {id}") + else: + self.logger.warning(f"Failed to Linked VERSE {id}") + + async def read_stage(self, stage_dir: str, opt_id: Optional[int] = None) -> None: + for root, dirs, files in walk(stage_dir): + for dir in dirs: + if path.exists(f"{root}/{dir}/Stage.xml"): + with open(f"{root}/{dir}/Stage.xml", "r", encoding='utf-8') as fp: + strdata = fp.read() + + xml_root = ET.fromstring(strdata) + for name in xml_root.findall("name"): + id = name.find("id").text + name = name.find("str").text + for image in xml_root.findall("image"): + image_path = image.find("path").text + self.copy_image(image_path, f"{root}/{dir}", "titles/chuni/img/stage/") + default_have = xml_root.find("defaultHave").text == 'true' + disable_flag = xml_root.find("disableFlag") # may not exist in older data + is_enabled = True if (disable_flag is None or disable_flag.text == "false") else False + + result = await self.data.static.put_stage( + self.version, id, name, image_path, is_enabled, default_have, opt_id + ) + + if result is not None: + self.logger.info(f"Inserted stage {id}") + else: + self.logger.warning(f"Failed to insert stage {id}") + def copy_image(self, filename: str, src_dir: str, dst_dir: str) -> None: - # Convert the image to png so we can easily display it in the frontend + # Convert the image to webp so we can easily display it in the frontend file_src = path.join(src_dir, filename) (basename, ext) = path.splitext(filename) - file_dst = path.join(dst_dir, basename) + ".png" + file_dst = path.join(dst_dir, basename) + ".webp" if path.exists(file_src) and not path.exists(file_dst): try: im = Image.open(file_src) im.save(file_dst) except Exception: - self.logger.warning(f"Failed to convert {filename} to png") + self.logger.warning(f"Failed to convert {filename} to webp") + + def ConvertOldAssets(logger: Logger): + """ + Converts any previously-imported png files to webp. + In the initial version of the userbox/avatar frontend support, png images were used, scraped via read.py. + The amount of data pushed once a lot of stuff was unlocked was noticeable so the frontend now uses webp format + for these assets. If any png files are present, convert them to webp now. + """ + # Find all pngs under the /img directory + png_files = glob.glob(f'titles/chuni/img/**/*.png', recursive=True) + if len(png_files) > 0: + logger.info(f'Found {len(png_files)} old assets. Converting to webp... (may take a few minutes)') + for img_png in png_files: + img_webp = path.splitext(img_png)[0] + '.webp' + try: + # convert to webp + im = Image.open(img_png) + im.save(img_webp) + # delete the original file + remove(img_png) + except Exception as e: + logger.warning(f'Failed to convert {img_png} to webp') + logger.info(f'Conversion complete') def map_dds_images(self, image_dict: dict, dds_dir: str) -> None: for root, dirs, files in walk(dds_dir): diff --git a/titles/chuni/schema/item.py b/titles/chuni/schema/item.py index 93dcf86..d48f55b 100644 --- a/titles/chuni/schema/item.py +++ b/titles/chuni/schema/item.py @@ -22,6 +22,7 @@ character: Table = Table( Column("id", Integer, primary_key=True, nullable=False), Column( "user", + Integer, ForeignKey("aime_user.id", ondelete="cascade", onupdate="cascade"), nullable=False, ), @@ -46,6 +47,7 @@ item: Table = Table( Column("id", Integer, primary_key=True, nullable=False), Column( "user", + Integer, ForeignKey("aime_user.id", ondelete="cascade", onupdate="cascade"), nullable=False, ), @@ -63,6 +65,7 @@ duel = Table( Column("id", Integer, primary_key=True, nullable=False), Column( "user", + Integer, ForeignKey("aime_user.id", ondelete="cascade", onupdate="cascade"), nullable=False, ), @@ -85,6 +88,7 @@ map = Table( Column("id", Integer, primary_key=True, nullable=False), Column( "user", + Integer, ForeignKey("aime_user.id", ondelete="cascade", onupdate="cascade"), nullable=False, ), @@ -107,6 +111,7 @@ map_area = Table( Column("id", Integer, primary_key=True, nullable=False), Column( "user", + Integer, ForeignKey("aime_user.id", ondelete="cascade", onupdate="cascade"), nullable=False, ), @@ -127,6 +132,7 @@ gacha = Table( Column("id", Integer, primary_key=True, nullable=False), Column( "user", + Integer, ForeignKey("aime_user.id", ondelete="cascade", onupdate="cascade"), nullable=False, ), @@ -147,6 +153,7 @@ print_state: Table = Table( Column("id", Integer, primary_key=True, nullable=False), Column( "user", + Integer, ForeignKey("aime_user.id", ondelete="cascade", onupdate="cascade"), nullable=False, ), @@ -167,6 +174,7 @@ print_detail = Table( Column("id", Integer, primary_key=True, nullable=False), Column( "user", + Integer, ForeignKey("aime_user.id", ondelete="cascade", onupdate="cascade"), nullable=False, ), @@ -197,6 +205,7 @@ login_bonus = Table( Column("id", Integer, primary_key=True, nullable=False), Column( "user", + Integer, ForeignKey("aime_user.id", ondelete="cascade", onupdate="cascade"), nullable=False, ), @@ -216,6 +225,7 @@ favorite: Table = Table( Column("id", Integer, primary_key=True, nullable=False), Column( "user", + Integer, ForeignKey("aime_user.id", ondelete="cascade", onupdate="cascade"), nullable=False, ), @@ -232,6 +242,7 @@ matching = Table( Column("roomId", Integer, nullable=False), Column( "user", + Integer, ForeignKey("aime_user.id", ondelete="cascade", onupdate="cascade"), nullable=False, ), @@ -249,6 +260,7 @@ cmission = Table( Column("id", Integer, primary_key=True, nullable=False), Column( "user", + Integer, ForeignKey("aime_user.id", ondelete="cascade", onupdate="cascade"), nullable=False, ), @@ -262,7 +274,12 @@ cmission_progress = Table( "chuni_item_cmission_progress", metadata, Column("id", Integer, primary_key=True, nullable=False), - Column("user", ForeignKey("aime_user.id", ondelete="cascade", onupdate="cascade"), nullable=False), + Column( + "user", + Integer, + ForeignKey("aime_user.id", ondelete="cascade", onupdate="cascade"), + nullable=False, + ), Column("missionId", Integer, nullable=False), Column("order", Integer), Column("stage", Integer), @@ -273,14 +290,66 @@ cmission_progress = Table( mysql_charset="utf8mb4", ) +unlock_challenge = Table( + "chuni_item_unlock_challenge", + metadata, + Column("id", Integer, primary_key=True, nullable=False), + Column("version", Integer, nullable=False), + Column( + "user", + Integer, + ForeignKey("aime_user.id", ondelete="cascade", onupdate="cascade"), + nullable=False, + ), + Column("unlockChallengeId", Integer, nullable=False), + Column("status", Integer), + Column("clearCourseId", Integer), + Column("conditionType", Integer), + Column("score", Integer), + Column("life", Integer), + Column("clearDate", TIMESTAMP, server_default=func.now()), + UniqueConstraint( + "version", "user", "unlockChallengeId", name="chuni_item_unlock_challenge_uk" + ), + mysql_charset="utf8mb4", +) + +linked_verse: Table = Table( + "chuni_item_linked_verse", + metadata, + Column("id", Integer, primary_key=True, nullable=False), + Column( + "user", + Integer, + ForeignKey("aime_user.id", ondelete="cascade", onupdate="cascade"), + nullable=False, + ), + Column("linkedVerseId", Integer, nullable=False), + Column("progress", String(255)), + Column("statusOpen", Integer), + Column("statusUnlock", Integer), + Column("isFirstClear", Integer), + Column("numClear", Integer), + Column("clearCourseId", Integer), + Column("clearCourseLevel", Integer), + Column("clearScore", Integer), + Column("clearDate", String(25)), + Column("clearUserId1", Integer), + Column("clearUserId2", Integer), + Column("clearUserId3", Integer), + Column("clearUserName0", String(20)), + Column("clearUserName1", String(20)), + Column("clearUserName2", String(20)), + Column("clearUserName3", String(20)), + UniqueConstraint("user", "linkedVerseId", name="chuni_item_linked_verse_uk"), + mysql_charset="utf8mb4", +) + class ChuniItemData(BaseData): async def get_oldest_free_matching(self, version: int) -> Optional[Row]: sql = matching.select( - and_( - matching.c.version == version, - matching.c.isFull == False - ) + and_(matching.c.version == version, matching.c.isFull == False) ).order_by(matching.c.roomId.asc()) result = await self.execute(sql) @@ -289,11 +358,9 @@ class ChuniItemData(BaseData): return result.fetchone() async def get_newest_matching(self, version: int) -> Optional[Row]: - sql = matching.select( - and_( - matching.c.version == version - ) - ).order_by(matching.c.roomId.desc()) + sql = matching.select(and_(matching.c.version == version)).order_by( + matching.c.roomId.desc() + ) result = await self.execute(sql) if result is None: @@ -301,11 +368,7 @@ class ChuniItemData(BaseData): return result.fetchone() async def get_all_matchings(self, version: int) -> Optional[List[Row]]: - sql = matching.select( - and_( - matching.c.version == version - ) - ) + sql = matching.select(and_(matching.c.version == version)) result = await self.execute(sql) if result is None: @@ -329,7 +392,7 @@ class ChuniItemData(BaseData): matching_member_info_list: List, user_id: int = None, rest_sec: int = 60, - is_full: bool = False + is_full: bool = False, ) -> Optional[int]: sql = insert(matching).values( roomId=room_id, @@ -362,7 +425,6 @@ class ChuniItemData(BaseData): async def is_favorite( self, user_id: int, version: int, fav_id: int, fav_kind: int = 1 ) -> bool: - sql = favorite.select( and_( favorite.c.version == version, @@ -452,23 +514,31 @@ class ChuniItemData(BaseData): return None return result.fetchone() - async def put_favorite_music(self, user_id: int, version: int, music_id: int) -> Optional[int]: - sql = insert(favorite).values(user=user_id, version=version, favId=music_id, favKind=1) + async def put_favorite_music( + self, user_id: int, version: int, music_id: int + ) -> Optional[int]: + sql = insert(favorite).values( + user=user_id, version=version, favId=music_id, favKind=1 + ) - conflict = sql.on_duplicate_key_update(user=user_id, version=version, favId=music_id, favKind=1) + conflict = sql.on_duplicate_key_update( + user=user_id, version=version, favId=music_id, favKind=1 + ) result = await self.execute(conflict) if result is None: return None return result.lastrowid - async def delete_favorite_music(self, user_id: int, version: int, music_id: int) -> Optional[int]: + async def delete_favorite_music( + self, user_id: int, version: int, music_id: int + ) -> Optional[int]: sql = delete(favorite).where( and_( - favorite.c.user==user_id, - favorite.c.version==version, - favorite.c.favId==music_id, - favorite.c.favKind==1 + favorite.c.user == user_id, + favorite.c.version == version, + favorite.c.favId == music_id, + favorite.c.favKind == 1, ) ) @@ -611,8 +681,12 @@ class ChuniItemData(BaseData): return None return result.lastrowid - async def get_map_areas(self, user_id: int, map_area_ids: List[int]) -> Optional[List[Row]]: - sql = select(map_area).where(map_area.c.user == user_id, map_area.c.mapAreaId.in_(map_area_ids)) + async def get_map_areas( + self, user_id: int, map_area_ids: List[int] + ) -> Optional[List[Row]]: + sql = select(map_area).where( + map_area.c.user == user_id, map_area.c.mapAreaId.in_(map_area_ids) + ) result = await self.execute(sql) if result is None: @@ -713,7 +787,7 @@ class ChuniItemData(BaseData): ) return None return result.lastrowid - + async def put_cmission_progress( self, user_id: int, mission_id: int, progress_data: Dict ) -> Optional[int]: @@ -723,10 +797,10 @@ class ChuniItemData(BaseData): sql = insert(cmission_progress).values(**progress_data) conflict = sql.on_duplicate_key_update(**progress_data) result = await self.execute(conflict) - + if result is None: return None - + return result.lastrowid async def get_cmission_progress( @@ -739,21 +813,21 @@ class ChuniItemData(BaseData): ) ).order_by(cmission_progress.c.order.asc()) result = await self.execute(sql) - + if result is None: return None - + return result.fetchall() - + async def get_cmission(self, user_id: int, mission_id: int) -> Optional[Row]: sql = cmission.select( and_(cmission.c.user == user_id, cmission.c.missionId == mission_id) ) result = await self.execute(sql) - + if result is None: return None - + return result.fetchone() async def put_cmission(self, user_id: int, mission_data: Dict) -> Optional[int]: @@ -762,17 +836,65 @@ class ChuniItemData(BaseData): sql = insert(cmission).values(**mission_data) conflict = sql.on_duplicate_key_update(**mission_data) result = await self.execute(conflict) - + if result is None: return None - + return result.lastrowid async def get_cmissions(self, user_id: int) -> Optional[List[Row]]: sql = cmission.select(cmission.c.user == user_id) result = await self.execute(sql) - + if result is None: return None - + return result.fetchall() + + async def put_unlock_challenge( + self, user_id: int, version: int, unlock_challenge_data: Dict + ) -> Optional[int]: + unlock_challenge_data["user"] = user_id + unlock_challenge_data["version"] = version + + sql = insert(unlock_challenge).values(**unlock_challenge_data) + conflict = sql.on_duplicate_key_update(**unlock_challenge_data) + + result = await self.execute(conflict) + if result is None: + return None + return result.lastrowid + + async def get_unlock_challenges( + self, user_id: int, version: int + ) -> Optional[List[Row]]: + sql = unlock_challenge.select( + and_( + unlock_challenge.c.user == user_id, + unlock_challenge.c.version == version, + ) + ) + + result = await self.execute(sql) + if result is None: + return None + return result.fetchall() + + async def get_linked_verse(self, aime_id: int) -> Optional[List[Row]]: + result = await self.execute( + linked_verse.select().where(linked_verse.c.user == aime_id) + ) + + if result: + return result.fetchall() + + async def put_linked_verse(self, aime_id: int, linked_verse_data: Dict): + linked_verse_data = self.fix_bools(linked_verse_data) + sql = insert(linked_verse).values(user=aime_id, **linked_verse_data) + conflict = sql.on_duplicate_key_update(**linked_verse_data) + result = await self.execute(conflict) + + if result: + return result.inserted_primary_key["id"] + + self.logger.error("Failed to put Linked Verse data for user %s", aime_id) diff --git a/titles/chuni/schema/profile.py b/titles/chuni/schema/profile.py index c7fb750..1ab86a8 100644 --- a/titles/chuni/schema/profile.py +++ b/titles/chuni/schema/profile.py @@ -15,6 +15,7 @@ profile = Table( Column("id", Integer, primary_key=True, nullable=False), Column( "user", + Integer, ForeignKey("aime_user.id", ondelete="cascade", onupdate="cascade"), nullable=False, ), @@ -25,6 +26,8 @@ profile = Table( Column("frameId", Integer), Column("isMaimai", Boolean), Column("trophyId", Integer), + Column("trophyIdSub1", Integer, server_default="-1"), + Column("trophyIdSub2", Integer, server_default="-1"), Column("userName", String(25)), Column("isWebJoin", Boolean), Column("playCount", Integer), @@ -129,6 +132,9 @@ profile = Table( Column("avatarFront", Integer, server_default="0"), Column("avatarSkin", Integer, server_default="0"), Column("avatarHead", Integer, server_default="0"), + Column( + "stageId", Integer, server_default="99999", nullable=False + ), # 99999 is the pseudo stage ID for unset stage UniqueConstraint("user", "version", name="chuni_profile_profile_uk"), mysql_charset="utf8mb4", ) @@ -139,6 +145,7 @@ profile_ex = Table( Column("id", Integer, primary_key=True, nullable=False), Column( "user", + Integer, ForeignKey("aime_user.id", ondelete="cascade", onupdate="cascade"), nullable=False, ), @@ -187,6 +194,7 @@ option = Table( Column("id", Integer, primary_key=True, nullable=False), Column( "user", + Integer, ForeignKey("aime_user.id", ondelete="cascade", onupdate="cascade"), nullable=False, ), @@ -250,6 +258,7 @@ option_ex = Table( Column("id", Integer, primary_key=True, nullable=False), Column( "user", + Integer, ForeignKey("aime_user.id", ondelete="cascade", onupdate="cascade"), nullable=False, ), @@ -283,6 +292,7 @@ recent_rating = Table( Column("id", Integer, primary_key=True, nullable=False), Column( "user", + Integer, ForeignKey("aime_user.id", ondelete="cascade", onupdate="cascade"), nullable=False, ), @@ -297,6 +307,7 @@ region = Table( Column("id", Integer, primary_key=True, nullable=False), Column( "user", + Integer, ForeignKey("aime_user.id", ondelete="cascade", onupdate="cascade"), nullable=False, ), @@ -312,6 +323,7 @@ activity = Table( Column("id", Integer, primary_key=True, nullable=False), Column( "user", + Integer, ForeignKey("aime_user.id", ondelete="cascade", onupdate="cascade"), nullable=False, ), @@ -334,6 +346,7 @@ charge = Table( Column("id", Integer, primary_key=True, nullable=False), Column( "user", + Integer, ForeignKey("aime_user.id", ondelete="cascade", onupdate="cascade"), nullable=False, ), @@ -354,6 +367,7 @@ emoney = Table( Column("id", Integer, primary_key=True, nullable=False), Column( "user", + Integer, ForeignKey("aime_user.id", ondelete="cascade", onupdate="cascade"), nullable=False, ), @@ -373,6 +387,7 @@ overpower = Table( Column("id", Integer, primary_key=True, nullable=False), Column( "user", + Integer, ForeignKey("aime_user.id", ondelete="cascade", onupdate="cascade"), nullable=False, ), @@ -400,6 +415,7 @@ rating = Table( Column("id", Integer, primary_key=True, nullable=False), Column( "user", + Integer, ForeignKey("aime_user.id", ondelete="cascade", onupdate="cascade"), nullable=False, ), @@ -461,10 +477,23 @@ class ChuniProfileData(BaseData): return False return True - async def update_userbox(self, user_id: int, version: int, new_nameplate: int, new_trophy: int, new_character: int) -> bool: + async def update_stage(self, user_id: int, version: int, new_stage: int) -> bool: + sql = profile.update((profile.c.user == user_id) & (profile.c.version == version)).values( + stageId=new_stage + ) + result = await self.execute(sql) + + if result is None: + self.logger.warning(f"Failed to set user {user_id} stage") + return False + return True + + async def update_userbox(self, user_id: int, version: int, new_nameplate: int, new_trophy: int, new_trophy_sub_1: int, new_trophy_sub_2: int, new_character: int) -> bool: sql = profile.update((profile.c.user == user_id) & (profile.c.version == version)).values( nameplateId=new_nameplate, trophyId=new_trophy, + trophyIdSub1=new_trophy_sub_1, + trophyIdSub2=new_trophy_sub_2, charaIllustId=new_character ) result = await self.execute(sql) @@ -899,4 +928,4 @@ class ChuniProfileData(BaseData): async def get_net_battle(self, aime_id: int) -> Optional[Row]: result = await self.execute(net_battle.select(net_battle.c.user == aime_id)) if result: - return result.fetchone() + return result.fetchone() \ No newline at end of file diff --git a/titles/chuni/schema/score.py b/titles/chuni/schema/score.py index 50a8f7f..b736327 100644 --- a/titles/chuni/schema/score.py +++ b/titles/chuni/schema/score.py @@ -17,6 +17,7 @@ course: Table = Table( Column("id", Integer, primary_key=True, nullable=False), Column( "user", + Integer, ForeignKey("aime_user.id", ondelete="cascade", onupdate="cascade"), nullable=False, ), @@ -48,6 +49,7 @@ best_score: Table = Table( Column("id", Integer, primary_key=True, nullable=False), Column( "user", + Integer, ForeignKey("aime_user.id", ondelete="cascade", onupdate="cascade"), nullable=False, ), @@ -79,6 +81,7 @@ playlog = Table( Column("id", Integer, primary_key=True, nullable=False), Column( "user", + Integer, ForeignKey("aime_user.id", ondelete="cascade", onupdate="cascade"), nullable=False, ), @@ -139,6 +142,8 @@ playlog = Table( Column("regionId", Integer), Column("machineType", Integer), Column("ticketId", Integer), + Column("monthPoint", Integer), + Column("eventPoint", Integer), mysql_charset="utf8mb4" ) @@ -420,4 +425,4 @@ class ChuniScoreData(BaseData): return None rows = result.fetchall() - return [dict(row) for row in rows] + return [dict(row) for row in rows] \ No newline at end of file diff --git a/titles/chuni/schema/static.py b/titles/chuni/schema/static.py index f4f0f9f..6db5374 100644 --- a/titles/chuni/schema/static.py +++ b/titles/chuni/schema/static.py @@ -40,7 +40,7 @@ events = Table( Column("name", String(255)), Column("startDate", TIMESTAMP, server_default=func.now()), Column("enabled", Boolean, server_default="1"), - Column("opt", ForeignKey("chuni_static_opt.id", ondelete="SET NULL", onupdate="cascade")), + Column("opt", BIGINT, ForeignKey("chuni_static_opt.id", ondelete="SET NULL", onupdate="cascade")), UniqueConstraint("version", "eventId", name="chuni_static_events_uk"), mysql_charset="utf8mb4", ) @@ -58,7 +58,7 @@ music = Table( Column("genre", String(255)), Column("jacketPath", String(255)), Column("worldsEndTag", String(7)), - Column("opt", ForeignKey("chuni_static_opt.id", ondelete="SET NULL", onupdate="cascade")), + Column("opt", BIGINT, ForeignKey("chuni_static_opt.id", ondelete="SET NULL", onupdate="cascade")), UniqueConstraint("version", "songId", "chartId", name="chuni_static_music_uk"), mysql_charset="utf8mb4", ) @@ -74,7 +74,7 @@ charge = Table( Column("consumeType", Integer), Column("sellingAppeal", Boolean), Column("enabled", Boolean, server_default="1"), - Column("opt", ForeignKey("chuni_static_opt.id", ondelete="SET NULL", onupdate="cascade")), + Column("opt", BIGINT, ForeignKey("chuni_static_opt.id", ondelete="SET NULL", onupdate="cascade")), UniqueConstraint("version", "chargeId", name="chuni_static_charge_uk"), mysql_charset="utf8mb4", ) @@ -92,7 +92,7 @@ avatar = Table( Column("isEnabled", Boolean, server_default="1"), Column("defaultHave", Boolean, server_default="0"), Column("sortName", String(255)), - Column("opt", ForeignKey("chuni_static_opt.id", ondelete="SET NULL", onupdate="cascade")), + Column("opt", BIGINT, ForeignKey("chuni_static_opt.id", ondelete="SET NULL", onupdate="cascade")), UniqueConstraint("version", "avatarAccessoryId", name="chuni_static_avatar_uk"), mysql_charset="utf8mb4", ) @@ -108,7 +108,7 @@ nameplate = Table( Column("isEnabled", Boolean, server_default="1"), Column("defaultHave", Boolean, server_default="0"), Column("sortName", String(255)), - Column("opt", ForeignKey("chuni_static_opt.id", ondelete="SET NULL", onupdate="cascade")), + Column("opt", BIGINT, ForeignKey("chuni_static_opt.id", ondelete="SET NULL", onupdate="cascade")), UniqueConstraint("version", "nameplateId", name="chuni_static_nameplate_uk"), mysql_charset="utf8mb4", ) @@ -128,7 +128,7 @@ character = Table( Column("imagePath3", String(255)), Column("isEnabled", Boolean, server_default="1"), Column("defaultHave", Boolean, server_default="0"), - Column("opt", ForeignKey("chuni_static_opt.id", ondelete="SET NULL", onupdate="cascade")), + Column("opt", BIGINT, ForeignKey("chuni_static_opt.id", ondelete="SET NULL", onupdate="cascade")), UniqueConstraint("version", "characterId", name="chuni_static_character_uk"), mysql_charset="utf8mb4", ) @@ -143,7 +143,7 @@ trophy = Table( Column("rareType", Integer), Column("isEnabled", Boolean, server_default="1"), Column("defaultHave", Boolean, server_default="0"), - Column("opt", ForeignKey("chuni_static_opt.id", ondelete="SET NULL", onupdate="cascade")), + Column("opt", BIGINT, ForeignKey("chuni_static_opt.id", ondelete="SET NULL", onupdate="cascade")), UniqueConstraint("version", "trophyId", name="chuni_static_trophy_uk"), mysql_charset="utf8mb4", ) @@ -159,7 +159,7 @@ map_icon = Table( Column("iconPath", String(255)), Column("isEnabled", Boolean, server_default="1"), Column("defaultHave", Boolean, server_default="0"), - Column("opt", ForeignKey("chuni_static_opt.id", ondelete="SET NULL", onupdate="cascade")), + Column("opt", BIGINT, ForeignKey("chuni_static_opt.id", ondelete="SET NULL", onupdate="cascade")), UniqueConstraint("version", "mapIconId", name="chuni_static_mapicon_uk"), mysql_charset="utf8mb4", ) @@ -175,7 +175,7 @@ system_voice = Table( Column("imagePath", String(255)), Column("isEnabled", Boolean, server_default="1"), Column("defaultHave", Boolean, server_default="0"), - Column("opt", ForeignKey("chuni_static_opt.id", ondelete="SET NULL", onupdate="cascade")), + Column("opt", BIGINT, ForeignKey("chuni_static_opt.id", ondelete="SET NULL", onupdate="cascade")), UniqueConstraint("version", "voiceId", name="chuni_static_systemvoice_uk"), mysql_charset="utf8mb4", ) @@ -197,7 +197,7 @@ gachas = Table( Column("endDate", TIMESTAMP, server_default="2038-01-01 00:00:00.0"), Column("noticeStartDate", TIMESTAMP, server_default="2018-01-01 00:00:00.0"), Column("noticeEndDate", TIMESTAMP, server_default="2038-01-01 00:00:00.0"), - Column("opt", ForeignKey("cm_static_opts.id", ondelete="SET NULL", onupdate="cascade")), + Column("opt", BIGINT,ForeignKey("cm_static_opts.id", ondelete="SET NULL", onupdate="cascade")), UniqueConstraint("version", "gachaId", "gachaName", name="chuni_static_gachas_uk"), mysql_charset="utf8mb4", ) @@ -218,7 +218,7 @@ cards = Table( Column("combo", Integer, nullable=False), Column("chain", Integer, nullable=False), Column("skillName", String(255), nullable=False), - Column("opt", ForeignKey("cm_static_opts.id", ondelete="SET NULL", onupdate="cascade")), + Column("opt", BIGINT, ForeignKey("cm_static_opts.id", ondelete="SET NULL", onupdate="cascade")), UniqueConstraint("version", "cardId", name="chuni_static_cards_uk"), mysql_charset="utf8mb4", ) @@ -289,6 +289,65 @@ login_bonus = Table( mysql_charset="utf8mb4", ) +unlock_challenge = Table( + "chuni_static_unlock_challenge", + metadata, + Column("id", Integer, primary_key=True, nullable=False), + Column("version", Integer, nullable=False), + Column("unlockChallengeId", Integer, nullable=False), + Column("name", String(255)), + Column("isEnabled", Boolean, server_default="1"), + Column("startDate", TIMESTAMP, server_default=func.now()), + Column("courseId1", Integer), + Column("courseId2", Integer), + Column("courseId3", Integer), + Column("courseId4", Integer), + Column("courseId5", Integer), + UniqueConstraint( + "version", "unlockChallengeId", name="chuni_static_unlock_challenge_uk" + ), + mysql_charset="utf8mb4", +) + + +linked_verse = Table( + "chuni_static_linked_verse", + metadata, + Column("id", Integer, primary_key=True, nullable=False), + Column("version", Integer, nullable=False), + Column("linkedVerseId", Integer, nullable=False), + Column("name", String(255)), + Column("isEnabled", Boolean, server_default="1", nullable=False), + Column("startDate", TIMESTAMP, server_default=func.now()), + Column("courseId1", Integer), + Column("courseId2", Integer), + Column("courseId3", Integer), + Column("courseId4", Integer), + Column("courseId5", Integer), + UniqueConstraint( + "version", "linkedVerseId", name="chuni_static_linked_verse_uk" + ), + mysql_charset="utf8mb4", +) + +stage = Table( + "chuni_static_stage", + metadata, + Column("id", Integer, primary_key=True, nullable=False), + Column("version", Integer, nullable=False), + Column("stageId", Integer, nullable=False), + Column("name", String(255)), + Column("imagePath", String(255)), + Column("isEnabled", Boolean, server_default="1"), + Column("defaultHave", Boolean, server_default="0"), + Column("opt", BIGINT, ForeignKey("chuni_static_opt.id", ondelete="SET NULL", onupdate="cascade")), + UniqueConstraint( + "version", "stageId", name="chuni_static_stage_uk" + ), + mysql_charset="utf8mb4", +) + + class ChuniStaticData(BaseData): async def put_login_bonus( self, @@ -556,7 +615,7 @@ class ChuniStaticData(BaseData): return result.fetchall() async def get_music(self, version: int) -> Optional[List[Row]]: - sql = music.select(music.c.version <= version) + sql = music.select(music.c.version == version) result = await self.execute(sql) if result is None: @@ -586,6 +645,28 @@ class ChuniStaticData(BaseData): if result is None: return None return result.fetchone() + + async def get_music_by_metadata( + self, title: Optional[str] = None, artist: Optional[str] = None, genre: Optional[str] = None + ) -> Optional[List[Row]]: + # all conditions should use like for partial matches + conditions = [] + if title: + conditions.append(music.c.title.like(f"%{title}%")) + if artist: + conditions.append(music.c.artist.like(f"%{artist}%")) + if genre: + conditions.append(music.c.genre.like(f"%{genre}%")) + + if not conditions: + return None + + sql = select(music).where(and_(*conditions)) + + result = await self.execute(sql) + if result is None: + return None + return result.fetchall() async def put_avatar( self, @@ -629,11 +710,25 @@ class ChuniStaticData(BaseData): return None return result.lastrowid - async def get_avatar_items(self, version: int, category: int, enabled_only: bool = True) -> Optional[List[Dict]]: + async def get_avatar_items( + self, version: int, category: int, enabled_only: bool = True + ) -> Optional[List[Dict]]: if enabled_only: - sql = select(avatar).where((avatar.c.version == version) & (avatar.c.category == category) & (avatar.c.isEnabled)).order_by(avatar.c.sortName) + sql = ( + select(avatar) + .where( + (avatar.c.version == version) + & (avatar.c.category == category) + & (avatar.c.isEnabled) + ) + .order_by(avatar.c.sortName) + ) else: - sql = select(avatar).where((avatar.c.version == version) & (avatar.c.category == category)).order_by(avatar.c.sortName) + sql = ( + select(avatar) + .where((avatar.c.version == version) & (avatar.c.category == category)) + .order_by(avatar.c.sortName) + ) result = await self.execute(sql) if result is None: @@ -676,11 +771,21 @@ class ChuniStaticData(BaseData): return None return result.lastrowid - async def get_nameplates(self, version: int, enabled_only: bool = True) -> Optional[List[Dict]]: + async def get_nameplates( + self, version: int, enabled_only: bool = True + ) -> Optional[List[Dict]]: if enabled_only: - sql = select(nameplate).where((nameplate.c.version == version) & (nameplate.c.isEnabled)).order_by(nameplate.c.sortName) + sql = ( + select(nameplate) + .where((nameplate.c.version == version) & (nameplate.c.isEnabled)) + .order_by(nameplate.c.sortName) + ) else: - sql = select(nameplate).where(nameplate.c.version == version).order_by(nameplate.c.sortName) + sql = ( + select(nameplate) + .where(nameplate.c.version == version) + .order_by(nameplate.c.sortName) + ) result = await self.execute(sql) if result is None: @@ -720,11 +825,21 @@ class ChuniStaticData(BaseData): return None return result.lastrowid - async def get_trophies(self, version: int, enabled_only: bool = True) -> Optional[List[Dict]]: + async def get_trophies( + self, version: int, enabled_only: bool = True + ) -> Optional[List[Dict]]: if enabled_only: - sql = select(trophy).where((trophy.c.version == version) & (trophy.c.isEnabled)).order_by(trophy.c.name) + sql = ( + select(trophy) + .where((trophy.c.version == version) & (trophy.c.isEnabled)) + .order_by(trophy.c.name) + ) else: - sql = select(trophy).where(trophy.c.version == version).order_by(trophy.c.name) + sql = ( + select(trophy) + .where(trophy.c.version == version) + .order_by(trophy.c.name) + ) result = await self.execute(sql) if result is None: @@ -767,11 +882,21 @@ class ChuniStaticData(BaseData): return None return result.lastrowid - async def get_map_icons(self, version: int, enabled_only: bool = True) -> Optional[List[Dict]]: + async def get_map_icons( + self, version: int, enabled_only: bool = True + ) -> Optional[List[Dict]]: if enabled_only: - sql = select(map_icon).where((map_icon.c.version == version) & (map_icon.c.isEnabled)).order_by(map_icon.c.sortName) + sql = ( + select(map_icon) + .where((map_icon.c.version == version) & (map_icon.c.isEnabled)) + .order_by(map_icon.c.sortName) + ) else: - sql = select(map_icon).where(map_icon.c.version == version).order_by(map_icon.c.sortName) + sql = ( + select(map_icon) + .where(map_icon.c.version == version) + .order_by(map_icon.c.sortName) + ) result = await self.execute(sql) if result is None: @@ -814,11 +939,21 @@ class ChuniStaticData(BaseData): return None return result.lastrowid - async def get_system_voices(self, version: int, enabled_only: bool = True) -> Optional[List[Dict]]: + async def get_system_voices( + self, version: int, enabled_only: bool = True + ) -> Optional[List[Dict]]: if enabled_only: - sql = select(system_voice).where((system_voice.c.version == version) & (system_voice.c.isEnabled)).order_by(system_voice.c.sortName) + sql = ( + select(system_voice) + .where((system_voice.c.version == version) & (system_voice.c.isEnabled)) + .order_by(system_voice.c.sortName) + ) else: - sql = select(system_voice).where(system_voice.c.version == version).order_by(system_voice.c.sortName) + sql = ( + select(system_voice) + .where(system_voice.c.version == version) + .order_by(system_voice.c.sortName) + ) result = await self.execute(sql) if result is None: @@ -873,11 +1008,21 @@ class ChuniStaticData(BaseData): return None return result.lastrowid - async def get_characters(self, version: int, enabled_only: bool = True) -> Optional[List[Dict]]: + async def get_characters( + self, version: int, enabled_only: bool = True + ) -> Optional[List[Dict]]: if enabled_only: - sql = select(character).where((character.c.version == version) & (character.c.isEnabled)).order_by(character.c.sortName) + sql = ( + select(character) + .where((character.c.version == version) & (character.c.isEnabled)) + .order_by(character.c.sortName) + ) else: - sql = select(character).where(character.c.version == version).order_by(character.c.sortName) + sql = ( + select(character) + .where(character.c.version == version) + .order_by(character.c.sortName) + ) result = await self.execute(sql) if result is None: @@ -1074,3 +1219,159 @@ class ChuniStaticData(BaseData): self.logger.error(f"Failed to set opt enabled status to {enabled} for opt {opt_id}") return False return True + + + async def put_unlock_challenge( + self, + version: int, + unlock_challenge_id: int, + name: str, + course_id1: Optional[int] = None, + course_id2: Optional[int] = None, + course_id3: Optional[int] = None, + course_id4: Optional[int] = None, + course_id5: Optional[int] = None, + ) -> Optional[int]: + + sql = insert(unlock_challenge).values( + version=version, + unlockChallengeId=unlock_challenge_id, + name=name, + courseId1=course_id1, + courseId2=course_id2, + courseId3=course_id3, + courseId4=course_id4, + courseId5=course_id5, + ) + + conflict = sql.on_duplicate_key_update( + name=name, + courseId1=course_id1, + courseId2=course_id2, + courseId3=course_id3, + courseId4=course_id4, + courseId5=course_id5, + ) + + result = await self.execute(conflict) + if result is None: + return None + return result.lastrowid + + async def get_unlock_challenges(self, version: int) -> Optional[List[Dict]]: + sql = unlock_challenge.select( + and_( + unlock_challenge.c.version == version, + unlock_challenge.c.isEnabled == True, + ) + ).order_by(unlock_challenge.c.startDate.asc()) + + result = await self.execute(sql) + if result is None: + return None + return result.fetchall() + + async def put_linked_verse( + self, + version: int, + linked_verse_id: int, + name: str, + course_id1: Optional[int] = None, + course_id2: Optional[int] = None, + course_id3: Optional[int] = None, + course_id4: Optional[int] = None, + course_id5: Optional[int] = None, + ) -> Optional[int]: + + sql = insert(linked_verse).values( + version=version, + linkedVerseId=linked_verse_id, + name=name, + courseId1=course_id1, + courseId2=course_id2, + courseId3=course_id3, + courseId4=course_id4, + courseId5=course_id5, + ) + + conflict = sql.on_duplicate_key_update( + name=name, + courseId1=course_id1, + courseId2=course_id2, + courseId3=course_id3, + courseId4=course_id4, + courseId5=course_id5, + ) + + result = await self.execute(conflict) + + if result is None: + return None + + return result.lastrowid + + async def get_linked_verses(self, version: int) -> Optional[List[Dict]]: + sql = linked_verse.select( + and_( + linked_verse.c.version == version, + linked_verse.c.isEnabled == True, + ) + ).order_by(linked_verse.c.startDate.asc()) + + result = await self.execute(sql) + + if result is None: + return None + + return result.fetchall() + + async def put_stage( + self, + version: int, + stage_id: int, + name: str, + image_path: str, + is_enabled: int, + default_have: int, + opt_id: int = None + ) -> Optional[int]: + + sql = insert(stage).values( + version=version, + stageId=stage_id, + name=name, + imagePath=image_path, + isEnabled=is_enabled, + defaultHave=default_have, + opt=coalesce(stage.c.opt, opt_id) + ) + + conflict = sql.on_duplicate_key_update( + name=name, + imagePath=image_path, + isEnabled=is_enabled, + defaultHave=default_have, + opt=coalesce(stage.c.opt, opt_id) + ) + + result = await self.execute(conflict) + + if result is None: + return None + + return result.lastrowid + + async def get_stages(self, version: int) -> Optional[List[Dict]]: + sql = stage.select( + and_( + stage.c.version == version, + stage.c.isEnabled == True, + ) + ) + + result = await self.execute(sql) + + if result is None: + return None + + return result.fetchall() diff --git a/titles/chuni/sun.py b/titles/chuni/sun.py index 4957c4b..abd7b7d 100644 --- a/titles/chuni/sun.py +++ b/titles/chuni/sun.py @@ -11,8 +11,8 @@ class ChuniSun(ChuniNewPlus): super().__init__(core_cfg, game_cfg) self.version = ChuniConstants.VER_CHUNITHM_SUN - 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) + 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) # hardcode lastDataVersion for CardMaker 1.35 A032 user_data["lastDataVersion"] = "2.10.00" diff --git a/titles/chuni/sunplus.py b/titles/chuni/sunplus.py index 1f3f271..e9394aa 100644 --- a/titles/chuni/sunplus.py +++ b/titles/chuni/sunplus.py @@ -11,8 +11,8 @@ class ChuniSunPlus(ChuniSun): super().__init__(core_cfg, game_cfg) self.version = ChuniConstants.VER_CHUNITHM_SUN_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) + 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) # I don't know if lastDataVersion is going to matter, I don't think CardMaker 1.35 works this far up user_data["lastDataVersion"] = "2.15.00" diff --git a/titles/chuni/templates/chuni_avatar.jinja b/titles/chuni/templates/chuni_avatar.jinja index 30fecf1..4fa4a45 100644 --- a/titles/chuni/templates/chuni_avatar.jinja +++ b/titles/chuni/templates/chuni_avatar.jinja @@ -14,13 +14,13 @@ - diff --git a/titles/chuni/templates/chuni_index.jinja b/titles/chuni/templates/chuni_index.jinja index 417b04f..02a5c62 100644 --- a/titles/chuni/templates/chuni_index.jinja +++ b/titles/chuni/templates/chuni_index.jinja @@ -79,6 +79,12 @@ {% endif %} + {% if cur_version >= 18 %} + + + + + {% endif %}
AVATAR
- + - + @@ -36,7 +36,7 @@
Front:
Back:
+
    
{{ system_voices[profile.voiceId]["name"] if system_voices|length > 0 else "Server DB needs upgraded or is not populated with necessary data" }}
Stage:
{{ stages[profile.stageId]["name"] if stages|length > 0 else "Server DB needs upgraded or is not populated with necessary data" }}
@@ -111,6 +117,21 @@ {% endif %} + {% if cur_version >= 18 %} + +
+
+ +
+ {% for item in stages.values() %} + {{ item[ + + {% endfor %} +
+
+
+ {% endif %} +
@@ -201,6 +222,10 @@ items = { "map-icon": ["{{ map_icons|length }}", "{{ profile.mapIconId }}"], "system-voice":["{{ system_voices|length }}", "{{ profile.voiceId }}"] }; +// STAGE introduced in X-VERSE +if ({{cur_version}} >= 18) { + items.stage = ["{{ stages|length }}", "{{ profile.stageId }}"] +} types = Object.keys(items); function changeItem(type, id, name) { diff --git a/titles/chuni/templates/chuni_userbox.jinja b/titles/chuni/templates/chuni_userbox.jinja index 5114b17..192d96e 100644 --- a/titles/chuni/templates/chuni_userbox.jinja +++ b/titles/chuni/templates/chuni_userbox.jinja @@ -18,7 +18,7 @@ - +
{{team_name}}
@@ -26,7 +26,7 @@
- +
Lv. {{ profile.level }}   {{ profile.userName }} @@ -37,20 +37,39 @@
- + - + {% if cur_version >= 17 %} + + + + {% endif %} + + + + +
Nameplate:
Trophy:
- {% for item in trophies.values() %} {% endfor %}
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 @@
+
+
+ + + + + + + + + + {% for rival in rival_list %} + + + + + + + + {% endfor %} +
+ RIVALS + +
IdNameRatingShow
{{ rival.2 }}{{ rival.0 }}{{ rival.1 }} +
+ +
+
+ +
+
{% if error is defined %} @@ -120,6 +158,75 @@ + +{% for rival in rival_list %} + +{% endfor %} +{% for rival in rival_list %} + +{% endfor %} +{% for rival in rival_list %} + +{% endfor %} {% endblock content %} \ No newline at end of file diff --git a/titles/ongeki/schema/static.py b/titles/ongeki/schema/static.py index bf4af07..f390959 100644 --- a/titles/ongeki/schema/static.py +++ b/titles/ongeki/schema/static.py @@ -604,13 +604,6 @@ class OngekiStaticData(BaseData): return None return result.fetchall() - async def get_opts(self) -> Optional[List[Row]]: - result = await self.execute(opts.select()) - - if result is None: - return None - return result.fetchall() - async def set_opt_enabled(self, opt_id: int, enabled: bool) -> bool: result = await self.execute(opts.update(opts.c.id == opt_id).values(isEnable=enabled)) diff --git a/titles/sao/index.py b/titles/sao/index.py index 43484d8..65d0c0d 100644 --- a/titles/sao/index.py +++ b/titles/sao/index.py @@ -152,7 +152,7 @@ class SaoServlet(BaseServlet): else: self.logger.error(f"Unknown response type {type(resp)}") - return SaoNoopResponse(req_header.cmd + 1).make() + return Response(SaoNoopResponse(req_header.cmd + 1).make()) self.logger.debug(f"Response: {resp.hex()}") diff --git a/tui.py b/tui.py index b140964..6486629 100644 --- a/tui.py +++ b/tui.py @@ -8,9 +8,11 @@ import bcrypt import secrets import string from sqlalchemy.engine import Row +import inflection from core.data import Data from core.config import CoreConfig +from core.const import AllnetCountryCode try: from asciimatics.widgets import Frame, Layout, Text, Button, RadioButtons, CheckBox, Divider, Label @@ -77,10 +79,10 @@ class State: return self.id if self.id else 0 def __init__(self): - self.selected_user: self.SelectedUser = self.SelectedUser() - self.selected_card: self.SelectedCard = self.SelectedCard() - self.selected_arcade: self.SelectedArcade = self.SelectedArcade() - self.selected_machine: self.SelectedMachine = self.SelectedMachine() + self.selected_user = self.SelectedUser() + self.selected_card = self.SelectedCard() + self.selected_arcade = self.SelectedArcade() + self.selected_machine = self.SelectedMachine() self.last_err: str = "" self.search_results: List[Row] = [] self.search_type: str = "" @@ -89,6 +91,7 @@ class State: self.selected_user = self.SelectedUser(id, username) def clear_user(self) -> None: + print(self.selected_user) self.selected_user = self.SelectedUser() def set_card(self, id: int, access_code: Optional[str]) -> None: @@ -138,7 +141,7 @@ class MainView(Frame): layout.add_widget(Button("User Management", self._user_mgmt)) layout.add_widget(Button("Card Management", self._card_mgmt)) layout.add_widget(Button("Arcade Management", self._arcade_mgmt)) - layout.add_widget(Button("Machine Management", self._mech_mgmt)) + layout.add_widget(Button("Machine Management", self._machine_mgmt)) layout.add_widget(Button("Quit", self._quit)) self.fix() @@ -155,17 +158,17 @@ class MainView(Frame): self.save() raise NextScene("Arcade Management") - def _mech_mgmt(self): + def _machine_mgmt(self): self.save() - raise NextScene("Mech Management") + raise NextScene("Machine Management") @staticmethod def _quit(): raise StopApplication("User pressed quit") -class ManageUser(Frame): +class ManageUserView(Frame): def __init__(self, screen: Screen): - super(ManageUser, self).__init__( + super(ManageUserView, self).__init__( screen, screen.height * 2 // 3, screen.width * 2 // 3, @@ -192,7 +195,7 @@ class ManageUser(Frame): usr_cards = [] if state.selected_user.id != 0: cards = loop.run_until_complete(data.card.get_user_cards(state.selected_user.id)) - for card in cards: + for card in cards or []: usr_cards.append(card._asdict()) if len(usr_cards) > 0: @@ -250,9 +253,9 @@ class ManageUser(Frame): self.save() raise NextScene("Main") -class ManageCard(Frame): +class ManageCardView(Frame): def __init__(self, screen: Screen): - super(ManageCard, self).__init__( + super(ManageCardView, self).__init__( screen, screen.height * 2 // 3, screen.width * 2 // 3, @@ -415,6 +418,19 @@ class SearchResultsView(Frame): name = usr['username'][:5] + "..." opts.append((f"{usr['id']:05d} | {name} | {usr['permissions']:08b} | {usr['email']}", state.SelectedUser(usr["id"], str(usr['username'])))) + + elif state.search_type == "arcade": + layout.add_widget(Label(" ID | Name | Country | # Machines ")) + layout.add_widget(Divider()) + + for ac in state.search_results: + name = str(ac['name']) + if len(name) < 8: + name = str(ac['name']) + ' ' * (8 - len(name)) + elif len(name) > 8: + name = ac['name'][:5] + "..." + + opts.append((f"{ac['id']:04X} | {name} | {ac['country']} | {usr['mech_ct']}", state.SelectedArcade(ac["id"], ac['country'], str(ac['name'])))) layout.add_widget(RadioButtons(opts, "", "selopt")) @@ -423,12 +439,25 @@ class SearchResultsView(Frame): def _select_current(self): self.save() a = self.data.get('selopt') - state.set_user(a.id, a.name) - raise NextScene("User Management") + if state.search_type == "user": + state.set_user(a.id, a.name) + raise NextScene("User Management") + + elif state.search_type == "arcade": + state.set_arcade(a.id, a.country, a.name) + raise NextScene("Arcade Management") def _cancel(self): state.clear_last_err() - raise NextScene("User Management") + if state.search_type == "user": + raise NextScene("User Management") + + elif state.search_type == "arcade": + raise NextScene("Arcade Management") + + def _back(self): + self.save() + raise NextScene("Main") class LookupUserView(Frame): def __init__(self, screen): @@ -528,14 +557,550 @@ class LookupUserView(Frame): self.find_widget('status').value = state.last_err raise NextScene("User Management") +class EditUserView(Frame): + def __init__(self, screen): + super(EditUserView, self).__init__( + screen, + screen.height * 2 // 3, + screen.width * 2 // 3, + hover_focus=True, + can_scroll=False, + title="Edit User", + on_load=self._redraw + ) + + layout = Layout([100], fill_frame=True) + self.add_layout(layout) + layout.add_widget(Text("Username:", "username")) + layout.add_widget(Text("Email:", "email")) + layout.add_widget(Text("Password:", "passwd")) + layout.add_widget(RadioButtons([ + ("User", "1"), + ("User Manager", "2"), + ("Arcde Manager", "4"), + ("Sysadmin", "8"), + ("Owner", "255"), + ], "Role:", "role")) + + layout3 = Layout([100]) + self.add_layout(layout3) + layout3.add_widget(Text("", f"status", readonly=True, disabled=True)) + + layout2 = Layout([1, 1, 1, 1]) + self.add_layout(layout2) + layout2.add_widget(Button("Save", self._ok), 0) + layout2.add_widget(Button("Cancel", self._cancel), 3) + + self.fix() + + def _redraw(self): + uinfo = loop.run_until_complete(data.user.get_user(state.selected_user.id)) + self.find_widget('username').value = uinfo['username'] + self.find_widget('email').value = uinfo['email'] + self.find_widget('role').value = str(uinfo['permissions']) + + def _ok(self): + self.save() + if not self.data.get("username"): + state.set_last_err("Username cannot be blank") + self.find_widget('status').value = state.last_err + self.screen.reset() + return + + state.clear_last_err() + self.find_widget('status').value = state.last_err + + pw = self.data.get("passwd") + hash = bcrypt.hashpw(pw.encode(), bcrypt.gensalt()) + + is_good = loop.run_until_complete(self._update_user_async(self.data.get("username"), hash.decode(), self.data.get("email"), self.data.get('role'))) + + self.find_widget('status').value = "User Updated" if is_good else "User update failed" + + raise NextScene("User Management") + + async def _update_user_async(self, username: Optional[str], password: Optional[str], email: Optional[str], role: Optional[str]) -> bool: + if username: namechange_ok = await data.user.change_username(state.selected_user.id, username) + else: namechange_ok = True + + if password: pw_ok = await data.user.change_password(state.selected_user.id, password) + else: pw_ok = True + + if role: role_ok = await data.user.change_permission(state.selected_user.id, role) + else: role_ok = True + + if email: email_ok = await data.user.change_email(state.selected_user.id, email) + else: email_ok = True + + state.set_user(state.selected_user.id, username if username and namechange_ok else state.selected_user.name) + return namechange_ok and pw_ok and role_ok and email_ok + + def _cancel(self): + state.clear_last_err() + self.find_widget('status').value = state.last_err + raise NextScene("User Management") + +class ManageArcadeView(Frame): + def __init__(self, screen: Screen): + super(ManageArcadeView, self).__init__( + screen, + screen.height * 2 // 3, + screen.width * 2 // 3, + hover_focus=True, + can_scroll=False, + title="Arcade Management", + on_load=self._redraw + ) + + layout = Layout([3]) + self.add_layout(layout) + layout.add_widget(Button("Create Arcade", self._create_arcade)) + layout.add_widget(Button("Lookup Arcade", self._lookup)) + + def _redraw(self): + self._layouts = [self._layouts[0]] + + layout = Layout([3]) + self.add_layout(layout) + layout.add_widget(Button("Edit Arcade", self._edit_arcade, disabled=state.selected_arcade.id == 0 or state.selected_arcade.id is None)) + layout.add_widget(Button("Delete Arcade", self._del_arcade, disabled=state.selected_arcade.id == 0 or state.selected_arcade.id is None)) + layout.add_widget((Divider())) + + layout2 = Layout([1, 1, 1]) + self.add_layout(layout2) + a = Text("", f"status", readonly=True, disabled=True) + a.value = f"Selected Arcade: {state.selected_arcade}" + layout2.add_widget(a) + layout2.add_widget(Button("Back", self._back), 2) + + self.fix() + + def _create_arcade(self): + self.save() + raise NextScene("Create Arcade") + + def _lookup(self): + self.save() + raise NextScene("Lookup Arcade") + + def _edit_arcade(self): + self.save() + raise NextScene("Edit Arcade") + + def _del_arcade(self): + self.save() + raise NextScene("Delete Arcade") + + def _back(self): + self.save() + raise NextScene("Main") + +class CreateArcadeView(Frame): + def __init__(self, screen: Screen): + super(CreateArcadeView, self).__init__( + screen, + screen.height * 2 // 3, + screen.width * 2 // 3, + hover_focus=True, + can_scroll=False, + title="Create Arcade" + ) + + layout = Layout([100], fill_frame=True) + self.add_layout(layout) + layout.add_widget(Text("Name:", "name")) + layout.add_widget(Text("Nickname:", "nickname")) + layout.add_widget(Text("Timezone:", "timezone")) + layout.add_widget(Text("VPN IP:", "ip")) + layout.add_widget(CheckBox("", "Add Machine:", "is_add_machine", )) + layout.add_widget(RadioButtons([ + (inflection.titleize(x.name), x.value) for x in AllnetCountryCode + ], "Country:", "country")) + + layout3 = Layout([100]) + self.add_layout(layout3) + layout3.add_widget(Text("", f"status", readonly=True, disabled=True)) + + layout2 = Layout([1, 1, 1, 1]) + self.add_layout(layout2) + layout2.add_widget(Button("OK", self._ok), 0) + layout2.add_widget(Button("Cancel", self._cancel), 3) + + self.fix() + + def _ok(self): + self.save() + state.clear_last_err() + self.find_widget('status').value = state.last_err + + loop.run_until_complete(self._create_arcade_async(self.data.get("name"), self.data.get("nickname"), self.data.get("country"), self.data.get('timezone'), self.data.get('ip'))) + + raise NextScene("Create Machine" if self.data.get("is_add_machine") else "Arcade Management") + + async def _create_arcade_async(self, name: str, nickname: Optional[str], country: str, timezone: Optional[str], ip: Optional[str]): + arcade_id = await data.arcade.create_arcade(name, nickname if nickname != "" else None, country) + + if timezone: + data.arcade.set_arcade_timezone(arcade_id, timezone) + + if ip: + data.arcade.set_arcade_vpn_ip(arcade_id, ip) + + if arcade_id: + state.set_arcade(arcade_id, country, name) + + def _cancel(self): + state.clear_last_err() + self.find_widget('status').value = state.last_err + raise NextScene("Arcade Management") + +class LookupArcadeView(Frame): + def __init__(self, screen): + super(LookupArcadeView, self).__init__( + screen, + screen.height * 2 // 3, + screen.width * 2 // 3, + hover_focus=True, + can_scroll=False, + title="Lookup Arcade" + ) + + layout = Layout([1, 1], fill_frame=True) + self.add_layout(layout) + layout.add_widget(RadioButtons([ + ("Name", "1"), + ("Serial", "2"), + ("Place ID", "3"), + ("Arcade ID", "4"), + ], "Search By:", "search_type")) + layout.add_widget(Text("Search:", "search_str"), 1) + + layout3 = Layout([100]) + self.add_layout(layout3) + layout3.add_widget(Text("", f"status", readonly=True, disabled=True)) + + layout2 = Layout([1, 1, 1, 1]) + self.add_layout(layout2) + layout2.add_widget(Button("Search", self._lookup), 0) + layout2.add_widget(Button("Cancel", self._cancel), 3) + + self.fix() + + def _lookup(self): + self.save() + if not self.data.get("search_str"): + state.set_last_err("Search cannot be blank") + self.find_widget('status').value = state.last_err + self.screen.reset() + return + + state.clear_last_err() + self.find_widget('status').value = state.last_err + + search_type = self.data.get("search_type") + if search_type == "1": + loop.run_until_complete(self._lookup_arcade_by_name(self.data.get("search_str"))) + elif search_type == "2": + loop.run_until_complete(self._lookup_arcade_by_serial(self.data.get("search_str"))) + elif search_type == "3": + real_id = int(self.data.get("search_str"), 16) + loop.run_until_complete(self._lookup_arcade_by_id(real_id)) + elif search_type == "4": + loop.run_until_complete(self._lookup_arcade_by_id(self.data.get("search_str"))) + else: + state.set_last_err("Unknown search type") + self.find_widget('status').value = state.last_err + self.screen.reset() + return + + if len(state.search_results) < 1: + state.set_last_err("Search returned no results") + self.find_widget('status').value = state.last_err + self.screen.reset() + return + + state.search_type = "user" + raise NextScene("Search Results") + + async def _lookup_arcade_by_id(self, ac_id: str): + ac = await data.arcade.get_arcade(ac_id) + + if ac is not None: + res = ac._asdict() + num_cabs = await data.arcade.get_arcade_machines(ac_id) + res['mech_ct'] = len(num_cabs) if num_cabs else 0 + state.search_results = [res] + + async def _lookup_arcade_by_name(self, name: str): + ac = await data.arcade.get_arcade_by_name(name) + + if ac is not None: + res = [] + for ac_res in ac: + t = ac_res._asdict() + num_cabs = await data.arcade.get_arcade_machines(t['id']) + t['mech_ct'] = len(num_cabs) if num_cabs else 0 + res.append(t) + + state.search_results = res + + async def _lookup_arcade_by_serial(self, serial: str): + mech = await data.arcade.get_machine(serial) + + if mech is not None: + ac = await data.arcade.get_arcade(mech['arcade']) + + if ac is not None: + res = ac._asdict() + num_cabs = await data.arcade.get_arcade_machines(mech['arcade']) + res['mech_ct'] = len(num_cabs) if num_cabs else 0 + state.search_results = [res] + + def _cancel(self): + state.clear_last_err() + self.find_widget('status').value = state.last_err + raise NextScene("Arcade Management") + +class ManageMachineView(Frame): + def __init__(self, screen: Screen): + super(ManageMachineView, self).__init__( + screen, + screen.height * 2 // 3, + screen.width * 2 // 3, + hover_focus=True, + can_scroll=False, + title="Machine Management", + on_load=self._redraw + ) + + layout = Layout([3]) + self.add_layout(layout) + layout.add_widget(Button("Create Machine", self._create)) + layout.add_widget(Button("Lookup Machine", self._lookup)) + + def _redraw(self): + self._layouts = [self._layouts[0]] + + layout = Layout([3]) + self.add_layout(layout) + layout.add_widget(Button("Reassign Machine", self._reassign, disabled=state.selected_machine.id == 0 or state.selected_machine.id is None)) + layout.add_widget(Button("Edit Machine", self._edit, disabled=state.selected_machine.id == 0 or state.selected_machine.id is None)) + layout.add_widget(Button("Delete Machine", self._del, disabled=state.selected_machine.id == 0 or state.selected_machine.id is None)) + layout.add_widget((Divider())) + + layout2 = Layout([1, 1, 1]) + self.add_layout(layout2) + a = Text("", f"status", readonly=True, disabled=True) + a.value = f"Selected Machine: {state.selected_arcade}" + layout2.add_widget(a) + layout2.add_widget(Button("Back", self._back), 2) + + self.fix() + + def _create(self): + self.save() + raise NextScene("Create Machine") + + def _lookup(self): + self.save() + raise NextScene("Lookup Machine") + + def _reassign(self): + self.save() + raise NextScene("Reassign Machine") + + def _edit(self): + self.save() + raise NextScene("Edit Machine") + + def _del(self): + self.save() + raise NextScene("Delete Machine") + + def _back(self): + self.save() + raise NextScene("Main") + +class CreateMachineView(Frame): + def __init__(self, screen: Screen): + super(CreateMachineView, self).__init__( + screen, + screen.height - 10, + screen.width * 2 // 3, + hover_focus=True, + can_scroll=False, + title="Create Machine" + ) + + layout = Layout([100], fill_frame=True) + self.add_layout(layout) + layout.add_widget(Text("Arcade:", "arcade_id")) + layout.add_widget(Text("Serial:", "serial")) + layout.add_widget(Text("Game:", "game_id")) + layout.add_widget(CheckBox("", "Real Cabinet:", "is_cab", )) + layout.add_widget(RadioButtons([("Not Set", None)] + [ + (inflection.titleize(x.name), x.value) for x in AllnetCountryCode + ], "Country Override:", "country")) + + layout3 = Layout([100]) + self.add_layout(layout3) + layout3.add_widget(Text("", f"status", readonly=True, disabled=True)) + + layout2 = Layout([1, 1, 1, 1]) + self.add_layout(layout2) + layout2.add_widget(Button("OK", self._ok), 0) + layout2.add_widget(Button("Cancel", self._cancel), 3) + + self.fix() + + def _redraw(self): + self.find_widget("arcade_id").value = state.selected_arcade.id + + def _ok(self): + self.save() + state.clear_last_err() + self.find_widget('status').value = state.last_err + + loop.run_until_complete(self._create_arcade_async(self.data.get("name"), self.data.get("nickname"), self.data.get("country"), self.data.get('timezone'), self.data.get('ip'))) + + raise NextScene("Arcade Management") + + async def _create_arcade_async(self, arcade_id: int, serial: str, game: Optional[str], is_cab: bool, country: Optional[str]): + machine_id = await data.arcade.create_machine(arcade_id, serial, None, game, is_cab) + + if country: + data.arcade.set_machine_country(machine_id, country) + + if machine_id: + state.set_machine(machine_id, serial) + + def _cancel(self): + state.clear_last_err() + self.find_widget('status').value = state.last_err + raise NextScene("Machine Management") + +class LookupMachineView(Frame): + def __init__(self, screen): + super(LookupMachineView, self).__init__( + screen, + screen.height * 2 // 3, + screen.width * 2 // 3, + hover_focus=True, + can_scroll=False, + title="Lookup Machine" + ) + + layout = Layout([1, 1], fill_frame=True) + self.add_layout(layout) + layout.add_widget(RadioButtons([ + ("Name", "1"), + ("Serial", "2"), + ("Arcade ID", "3"), + ("Machine ID", "4"), + ], "Search By:", "search_type")) + layout.add_widget(Text("Search:", "search_str"), 1) + + layout3 = Layout([100]) + self.add_layout(layout3) + layout3.add_widget(Text("", f"status", readonly=True, disabled=True)) + + layout2 = Layout([1, 1, 1, 1]) + self.add_layout(layout2) + layout2.add_widget(Button("Search", self._lookup), 0) + layout2.add_widget(Button("Cancel", self._cancel), 3) + + self.fix() + + def _lookup(self): + self.save() + if not self.data.get("search_str"): + state.set_last_err("Search cannot be blank") + self.find_widget('status').value = state.last_err + self.screen.reset() + return + + state.clear_last_err() + self.find_widget('status').value = state.last_err + + search_type = self.data.get("search_type") + if search_type == "1": + loop.run_until_complete(self._lookup_arcade_by_name(self.data.get("search_str"))) + elif search_type == "2": + loop.run_until_complete(self._lookup_arcade_by_serial(self.data.get("search_str"))) + elif search_type == "3": + real_id = int(self.data.get("search_str"), 16) + loop.run_until_complete(self._lookup_arcade_by_id(real_id)) + elif search_type == "4": + loop.run_until_complete(self._lookup_arcade_by_id(self.data.get("search_str"))) + else: + state.set_last_err("Unknown search type") + self.find_widget('status').value = state.last_err + self.screen.reset() + return + + if len(state.search_results) < 1: + state.set_last_err("Search returned no results") + self.find_widget('status').value = state.last_err + self.screen.reset() + return + + state.search_type = "user" + raise NextScene("Search Results") + + async def _lookup_arcade_by_id(self, ac_id: str): + ac = await data.arcade.get_arcade(ac_id) + + if ac is not None: + res = ac._asdict() + num_cabs = await data.arcade.get_arcade_machines(ac_id) + res['mech_ct'] = len(num_cabs) if num_cabs else 0 + state.search_results = [res] + + async def _lookup_arcade_by_name(self, name: str): + ac = await data.arcade.get_arcade_by_name(name) + + if ac is not None: + res = [] + for ac_res in ac: + t = ac_res._asdict() + num_cabs = await data.arcade.get_arcade_machines(t['id']) + t['mech_ct'] = len(num_cabs) if num_cabs else 0 + res.append(t) + + state.search_results = res + + async def _lookup_arcade_by_serial(self, serial: str): + mech = await data.arcade.get_machine(serial) + + if mech is not None: + ac = await data.arcade.get_arcade(mech['arcade']) + + if ac is not None: + res = ac._asdict() + num_cabs = await data.arcade.get_arcade_machines(mech['arcade']) + res['mech_ct'] = len(num_cabs) if num_cabs else 0 + state.search_results = [res] + + def _cancel(self): + state.clear_last_err() + self.find_widget('status').value = state.last_err + raise NextScene("Machine Management") + def demo(screen:Screen, scene: Scene): scenes = [ Scene([MainView(screen)], -1, name="Main"), - Scene([ManageUser(screen)], -1, name="User Management"), + Scene([ManageUserView(screen)], -1, name="User Management"), Scene([CreateUserView(screen)], -1, name="Create User"), Scene([LookupUserView(screen)], -1, name="Lookup User"), Scene([SearchResultsView(screen)], -1, name="Search Results"), - Scene([ManageCard(screen)], -1, name="Card Management"), + Scene([ManageCardView(screen)], -1, name="Card Management"), + Scene([EditUserView(screen)], -1, name="Edit User"), + Scene([ManageArcadeView(screen)], -1, name="Arcade Management"), + Scene([CreateArcadeView(screen)], -1, name="Create Arcade"), + Scene([LookupArcadeView(screen)], -1, name="Lookup Arcade"), + Scene([ManageMachineView(screen)], -1, name="Machine Management"), + Scene([LookupMachineView(screen)], -1, name="Lookup Machine"), + Scene([CreateMachineView(screen)], -1, name="Create Machine"), ] screen.play(scenes, stop_on_resize=False, start_scene=scene, allow_int=True)