Merge branch 'develop' into idac
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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 ###
|
||||
@@ -10,6 +10,7 @@ import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
|
||||
revision = '5cf98cfe52ad'
|
||||
down_revision = '263884e774cc'
|
||||
branch_labels = None
|
||||
|
||||
@@ -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 ###
|
||||
@@ -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 ###
|
||||
@@ -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 ###
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -3,13 +3,9 @@
|
||||
<script type="text/javascript">
|
||||
function swap_ota() {
|
||||
let is_cab = document.getElementById("is_cab").checked;
|
||||
let cbx_ota = document.getElementById("is_ota");
|
||||
let txt_ota = document.getElementById("ota_channel");
|
||||
|
||||
cbx_ota.disabled = !is_cab;
|
||||
|
||||
if (cbx_ota.disabled) {
|
||||
cbx_ota.checked = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<h1>Machine: {{machine.serial}}</h1>
|
||||
@@ -64,8 +60,8 @@ Info
|
||||
<label for="is_cab" class="form-label">Real Cabinet</label>
|
||||
</div>
|
||||
<div class="col mb-3">
|
||||
<input type="checkbox" class="form-control-check" id="is_ota" name="is_ota" {{ 'checked' if machine.ota_enable else ''}}>
|
||||
<label for="is_ota" class="form-label">Allow OTA updates</label>
|
||||
<input type="text" class="form-control-check" id="ota_channel" name="ota_channel" value={{ machine.ota_channel }} {{ 'disabled' if not machine.is_cab else '' }}>
|
||||
<label for="ota_channel" class="form-label">OTA Update Channel</label>
|
||||
</div>
|
||||
<div class="col mb-3">
|
||||
</div>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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: <INSERT AIMEDB KEY HERE>
|
||||
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
|
||||
@@ -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 `""`
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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: ""
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
Before Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
@@ -2,4 +2,4 @@
|
||||
*
|
||||
# Except this file and default unknown
|
||||
!.gitignore
|
||||
!unknown.png
|
||||
!unknown.webp
|
||||
|
Before Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 570 B |
|
Before Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
@@ -0,0 +1,4 @@
|
||||
# Ignore everything in this directory
|
||||
*
|
||||
# Except this file
|
||||
!.gitignore
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 <VERSION>" 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}
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"]
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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]
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -14,13 +14,13 @@
|
||||
<table class="table-large table-rowdistinct">
|
||||
<caption align="top">AVATAR</caption>
|
||||
<tr><td style="height:340px; width:50%" rowspan=8>
|
||||
<img class="avatar-preview avatar-preview-platform" src="img/avatar-platform.png">
|
||||
<img class="avatar-preview avatar-preview-platform" src="img/avatar-platform.webp">
|
||||
<img id="preview1_back" class="avatar-preview avatar-preview-back" src="">
|
||||
<img id="preview1_skin" class="avatar-preview avatar-preview-skin-rightfoot" src="">
|
||||
<img id="preview2_skin" class="avatar-preview avatar-preview-skin-leftfoot" src="">
|
||||
<img id="preview3_skin" class="avatar-preview avatar-preview-skin-body" src="">
|
||||
<img id="preview1_wear" class="avatar-preview avatar-preview-wear" src="">
|
||||
<img class="avatar-preview avatar-preview-common" src="img/avatar-common.png">
|
||||
<img class="avatar-preview avatar-preview-common" src="img/avatar-common.webp">
|
||||
<img id="preview1_head" class="avatar-preview avatar-preview-head" src="">
|
||||
<img id="preview1_face" class="avatar-preview avatar-preview-face" src="">
|
||||
<img id="preview1_item" class="avatar-preview avatar-preview-item-righthand" src="">
|
||||
@@ -36,7 +36,7 @@
|
||||
<tr><td>Front:</td><td><div id="name_front"></div></td></tr>
|
||||
<tr><td>Back:</td><td><div id="name_back"></div></td></tr>
|
||||
|
||||
<tr><td colspan=3 style="padding:8px 0px; text-align: center;">
|
||||
<tr><td colspan=3 style="padding:8px 0px; text-align: center; position: relative;">
|
||||
<button id="save-btn" class="btn btn-primary" style="width:140px;" onClick="saveAvatar()">SAVE</button>
|
||||
<button id="reset-btn" class="btn btn-danger" style="width:140px;" onClick="resetAvatar()">RESET</button>
|
||||
</td></tr>
|
||||
|
||||
@@ -79,6 +79,12 @@
|
||||
<td><div id="system-voice-name">{{ system_voices[profile.voiceId]["name"] if system_voices|length > 0 else "Server DB needs upgraded or is not populated with necessary data" }}</div></td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if cur_version >= 18 %} <!-- STAGE introduced in X-VERSE -->
|
||||
<tr>
|
||||
<td>Stage:</td>
|
||||
<td><div id="stage-name">{{ stages[profile.stageId]["name"] if stages|length > 0 else "Server DB needs upgraded or is not populated with necessary data" }}</div></td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@@ -111,6 +117,21 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if cur_version >= 18 %} <!-- STAGE introduced in X-VERSE -->
|
||||
<!-- STAGE SELECTION -->
|
||||
<div class="col-lg-8 m-auto mt-3 scrolling-lists">
|
||||
<div class="card bg-card rounded">
|
||||
<button class="collapsible">Stage: {{ stages|length }}/{{ total_stages }}</button>
|
||||
<div id="scrollable-stage" class="collapsible-content">
|
||||
{% for item in stages.values() %}
|
||||
<img id="stage-{{ item["id"] }}" style="padding: 8px 8px;" onclick="saveItem('stage', '{{ item["id"] }}', '{{ item["name"] }}')" src="img/stage/{{ item["imagePath"] }}" alt="{{ item["name"] }}">
|
||||
<span id="stage-br-{{ loop.index }}"></span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="col-lg-8 m-auto mt-3">
|
||||
<div class="card bg-card rounded">
|
||||
<table class="table-large table-rowdistinct">
|
||||
@@ -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) {
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<img id="preview_nameplate" class="userbox userbox-nameplate" src="">
|
||||
|
||||
<!-- TEAM -->
|
||||
<img class="userbox userbox-teamframe" src="img/rank/team3.png">
|
||||
<img class="userbox userbox-teamframe" src="img/rank/team3.webp">
|
||||
<div class="userbox userbox-teamname">{{team_name}}</div>
|
||||
|
||||
<!-- TROPHY/TITLE -->
|
||||
@@ -26,7 +26,7 @@
|
||||
<div id="preview_trophy_name" class="userbox userbox-trophy userbox-trophy-name"></div>
|
||||
|
||||
<!-- NAME/RATING -->
|
||||
<img class="userbox userbox-ratingframe" src="img/rank/rating0.png">
|
||||
<img class="userbox userbox-ratingframe" src="img/rank/rating0.webp">
|
||||
<div class="userbox userbox-name">
|
||||
<span class="userbox-name-level-label">Lv.</span>
|
||||
{{ profile.level }} {{ profile.userName }}
|
||||
@@ -37,20 +37,39 @@
|
||||
</div>
|
||||
|
||||
<!-- CHARACTER -->
|
||||
<img class="userbox userbox-charaframe" src="img/character-bg.png">
|
||||
<img class="userbox userbox-charaframe" src="img/character-bg.webp">
|
||||
<img id="preview_character" class="userbox userbox-chara" src="">
|
||||
</td></tr>
|
||||
|
||||
<tr><td>Nameplate:</td><td style="width: 80%;"><div id="name_nameplate"></div></td></tr>
|
||||
|
||||
<tr><td>Trophy:</td><td><div id="name_trophy">
|
||||
<select name="trophy" id="trophy" onchange="changeTrophy()" style="width:100%;">
|
||||
<select name="trophy" id="trophy" onclick="changeTrophy()" style="width:100%;">
|
||||
{% for item in trophies.values() %}
|
||||
<option value="{{ item["id"] }}" class="trophy-rank{{ item["rarity"] }}">{{ item["name"] }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div></td></tr>
|
||||
|
||||
{% if cur_version >= 17 %} <!-- SubTrophies introduced in VERSE -->
|
||||
<tr><td>Trophy Sub 1:</td><td><div id="name_trophy">
|
||||
<select name="trophy-sub-1" id="trophy-sub-1" onclick="changeTrophySub1()" style="width:100%;">
|
||||
<option value="-1"></option>
|
||||
{% for item in trophies.values() %}
|
||||
<option value="{{ item["id"] }}" class="trophy-rank{{ item["rarity"] }}">{{ item["name"] }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div></td></tr>
|
||||
|
||||
<tr><td>Trophy Sub 2:</td><td><div id="name_trophy">
|
||||
<select name="trophy-sub-2" id="trophy-sub-2" onclick="changeTrophySub2()" style="width:100%;">
|
||||
<option value="-1"></option>
|
||||
{% for item in trophies.values() %}
|
||||
<option value="{{ item["id"] }}" class="trophy-rank{{ item["rarity"] }}">{{ item["name"] }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div></td></tr>
|
||||
{% endif %}
|
||||
|
||||
<tr><td>Character:</td><td><div id="name_character"></div></td></tr>
|
||||
|
||||
<tr><td colspan=2 style="padding:8px 0px; text-align: center;">
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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):
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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("<I", 0x01020304)
|
||||
+ rsa_key.hashN.to_bytes(4, "little")
|
||||
)
|
||||
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("<I", 0x01020304)
|
||||
+ rsa_key.hashN.to_bytes(4, "little")
|
||||
)
|
||||
|
||||
self.logger.debug(f"Send handshake {result.hex()}")
|
||||
self.logger.debug(f"Send handshake {result.hex()}")
|
||||
|
||||
writer.write(result)
|
||||
await writer.drain()
|
||||
writer.write(result)
|
||||
await writer.drain()
|
||||
sent_handshake = True
|
||||
|
||||
data: bytes = await reader.read(4096)
|
||||
if len(data) == 0:
|
||||
@@ -88,7 +91,7 @@ class IDZUserDB:
|
||||
self.logger.debug("Connection reset, disconnecting")
|
||||
return
|
||||
|
||||
def dataReceived(self, data: bytes, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> 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)
|
||||
|
||||
@@ -18,4 +18,5 @@ game_codes = [
|
||||
Mai2Constants.GAME_CODE_GREEN,
|
||||
Mai2Constants.GAME_CODE,
|
||||
Mai2Constants.GAME_CODE_DX_INT,
|
||||
Mai2Constants.GAME_CODE_DX_CHN,
|
||||
]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
self.crypto = Mai2CryptoConfig(self)
|
||||
self.charts = Mai2OnlineChartsConfig(self)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
|
||||