Merge remote-tracking branch 'origin/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,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,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,7 @@ Games listed below have been tested and confirmed working.
|
||||
| 14 | CHUNITHM SUN PLUS |
|
||||
| 15 | CHUNITHM LUMINOUS |
|
||||
| 16 | CHUNITHM LUMINOUS PLUS |
|
||||
| 17 | CHUNITHM VERSE |
|
||||
|
||||
|
||||
### Importer
|
||||
@@ -110,6 +111,7 @@ crypto:
|
||||
keys:
|
||||
13: ["0000000000000000000000000000000000000000000000000000000000000000", "00000000000000000000000000000000", "0000000000000000"]
|
||||
"13_int": ["0000000000000000000000000000000000000000000000000000000000000000", "00000000000000000000000000000000", "0000000000000000", 42]
|
||||
"13_chn": ["0000000000000000000000000000000000000000000000000000000000000000", "00000000000000000000000000000000", "0000000000000000", 8]
|
||||
```
|
||||
|
||||
### Database upgrade
|
||||
@@ -205,32 +207,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 +261,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.
|
||||
@@ -43,6 +44,9 @@ version:
|
||||
16:
|
||||
rom: 2.25.00
|
||||
data: 2.25.00
|
||||
17:
|
||||
rom: 2.30.00
|
||||
data: 2.30.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,7 @@ Games listed below have been tested and confirmed working. Only game versions ol
|
||||
+ SUN PLUS
|
||||
+ LUMINOUS
|
||||
+ LUMINOUS PLUS
|
||||
+ VERSE
|
||||
|
||||
+ crossbeats REV.
|
||||
+ Crossbeats REV.
|
||||
@@ -43,7 +51,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 +70,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,10 +1090,20 @@ 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
|
||||
)
|
||||
|
||||
|
||||
return {"returnCode": "1"}
|
||||
|
||||
|
||||
@@ -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,7 @@ class ChuniConstants:
|
||||
VER_CHUNITHM_SUN_PLUS = 14
|
||||
VER_CHUNITHM_LUMINOUS = 15
|
||||
VER_CHUNITHM_LUMINOUS_PLUS = 16
|
||||
VER_CHUNITHM_VERSE = 17
|
||||
|
||||
VERSION_NAMES = [
|
||||
"CHUNITHM",
|
||||
@@ -46,6 +48,7 @@ class ChuniConstants:
|
||||
"CHUNITHM SUN PLUS",
|
||||
"CHUNITHM LUMINOUS",
|
||||
"CHUNITHM LUMINOUS PLUS",
|
||||
"CHUNITHM VERSE"
|
||||
]
|
||||
|
||||
SCORE_RANK_INTERVALS_OLD = [
|
||||
|
||||
@@ -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']),
|
||||
@@ -252,12 +256,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 +317,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 +374,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,7 +399,7 @@ 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))
|
||||
@@ -418,7 +422,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 +468,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 +486,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))
|
||||
@@ -655,14 +659,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 |
@@ -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,8 @@ from .sun import ChuniSun
|
||||
from .sunplus import ChuniSunPlus
|
||||
from .luminous import ChuniLuminous
|
||||
from .luminousplus import ChuniLuminousPlus
|
||||
from .verse import ChuniVerse
|
||||
|
||||
|
||||
class ChuniServlet(BaseServlet):
|
||||
def __init__(self, core_cfg: CoreConfig, cfg_dir: str) -> None:
|
||||
@@ -66,6 +70,7 @@ class ChuniServlet(BaseServlet):
|
||||
ChuniSunPlus,
|
||||
ChuniLuminous,
|
||||
ChuniLuminousPlus,
|
||||
ChuniVerse
|
||||
]
|
||||
|
||||
self.logger = logging.getLogger("chuni")
|
||||
@@ -96,20 +101,24 @@ 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,
|
||||
}
|
||||
|
||||
for version, keys in self.game_cfg.crypto.keys.items():
|
||||
@@ -120,7 +129,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 +159,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 +170,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 +193,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 +245,71 @@ 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: # VERSE
|
||||
internal_ver = ChuniConstants.VER_CHUNITHM_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: # LUMINOUS PLUS
|
||||
internal_ver = ChuniConstants.VER_CHUNITHM_LUMINOUS_PLUS
|
||||
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: # LUMINOUS
|
||||
internal_ver = ChuniConstants.VER_CHUNITHM_LUMINOUS
|
||||
|
||||
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 +319,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 +391,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 +434,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)
|
||||
|
||||
|
||||
@@ -16,8 +16,8 @@ class ChuniLuminous(ChuniSunPlus):
|
||||
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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -28,16 +28,18 @@ 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"
|
||||
|
||||
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 +173,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 +246,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 +349,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 +397,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 +452,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 +460,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 +485,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 +502,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,7 @@ 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")
|
||||
|
||||
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 +505,73 @@ 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}")
|
||||
|
||||
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,35 @@ 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",
|
||||
)
|
||||
|
||||
|
||||
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 +327,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 +337,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 +361,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,
|
||||
@@ -452,23 +484,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 +651,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 +757,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 +767,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 +783,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 +806,46 @@ 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()
|
||||
|
||||
@@ -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),
|
||||
Column("trophyIdSub2", Integer),
|
||||
Column("userName", String(25)),
|
||||
Column("isWebJoin", Boolean),
|
||||
Column("playCount", Integer),
|
||||
@@ -139,6 +142,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 +191,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 +255,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 +289,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 +304,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 +320,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 +343,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 +364,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 +384,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 +412,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 +474,12 @@ 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_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 +914,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]
|
||||
@@ -289,6 +289,27 @@ 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_pk"
|
||||
),
|
||||
mysql_charset="utf8mb4",
|
||||
)
|
||||
|
||||
|
||||
class ChuniStaticData(BaseData):
|
||||
async def put_login_bonus(
|
||||
self,
|
||||
@@ -556,7 +577,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 +607,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 +672,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 +733,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 +787,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 +844,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 +901,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 +970,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 +1181,54 @@ 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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,246 @@
|
||||
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.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_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,
|
||||
{
|
||||
"type": MapAreaConditionType.TROPHY_OBTAINED.value, # always unlocked
|
||||
"conditionId": 0,
|
||||
},
|
||||
)
|
||||
|
||||
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, # no idea
|
||||
# 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
|
||||
# same as recMusicList in get_user_rec_music_api_request
|
||||
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)
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
from collections.abc import Iterable
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from sqlalchemy import Column, Table, UniqueConstraint, and_, or_
|
||||
from sqlalchemy import Column, Table, UniqueConstraint, and_, or_, not_
|
||||
from sqlalchemy.dialects.mysql import insert
|
||||
from sqlalchemy.engine import Row
|
||||
from sqlalchemy.schema import ForeignKey
|
||||
@@ -550,25 +551,36 @@ class Mai2ItemData(BaseData):
|
||||
if result:
|
||||
return result.fetchall()
|
||||
|
||||
async def add_fav_music(self, user_id: int, music_id: int, order_id: Optional[int] = None) -> Optional[int]:
|
||||
sql = insert(fav_music).values(
|
||||
user = user_id,
|
||||
musicId = music_id,
|
||||
orderId = order_id
|
||||
)
|
||||
async def put_fav_music(self, user_id: int, fav_list: Iterable[tuple[int, Optional[int]]]) -> Optional[int]:
|
||||
row_count = 0
|
||||
processed_music_ids = []
|
||||
|
||||
for music_id, order_id in fav_list:
|
||||
sql = insert(fav_music).values(
|
||||
user = user_id,
|
||||
musicId = music_id,
|
||||
orderId = order_id
|
||||
)
|
||||
|
||||
conflict = sql.on_duplicate_key_update(orderId = order_id)
|
||||
result = await self.execute(conflict)
|
||||
|
||||
processed_music_ids.append(music_id)
|
||||
|
||||
if not result:
|
||||
self.logger.error(f"Failed to add music {music_id} as favorite for user {user_id}!")
|
||||
continue
|
||||
|
||||
row_count += result.rowcount
|
||||
|
||||
clear_stale_entries_stmt = fav_music.delete(and_(fav_music.c.user == user_id, not_(fav_music.c.musicId.in_(processed_music_ids))))
|
||||
result = await self.execute(clear_stale_entries_stmt)
|
||||
|
||||
if result is None:
|
||||
self.logger.error(f"Failed to clear stale favorite music entries for user {user_id}!")
|
||||
return None
|
||||
|
||||
conflict = sql.on_duplicate_key_update(orderId = order_id)
|
||||
|
||||
result = await self.execute(conflict)
|
||||
if result:
|
||||
return result.lastrowid
|
||||
|
||||
self.logger.error(f"Failed to add music {music_id} as favorite for user {user_id}!")
|
||||
|
||||
async def remove_fav_music(self, user_id: int, music_id: int) -> None:
|
||||
result = await self.execute(fav_music.delete(and_(fav_music.c.user == user_id, fav_music.c.musicId == music_id)))
|
||||
if not result:
|
||||
self.logger.error(f"Failed to remove music {music_id} as favorite for user {user_id}!")
|
||||
return row_count + result.rowcount
|
||||
|
||||
async def put_card(
|
||||
self,
|
||||
|
||||
@@ -148,7 +148,8 @@ playlog = Table(
|
||||
Column("extNum2", Integer),
|
||||
Column("extNum4", Integer),
|
||||
Column("extBool1", Boolean), # new with buddies
|
||||
Column("extBool2", Boolean), # new with prism
|
||||
Column("extBool2", Boolean), # new with prism IsRandomSelect
|
||||
Column("extBool3", Boolean), # new with prism+ IsTrackSkip
|
||||
Column("trialPlayAchievement", Integer),
|
||||
mysql_charset="utf8mb4",
|
||||
)
|
||||
|
||||
@@ -254,7 +254,7 @@ class Mai2StaticData(BaseData):
|
||||
|
||||
async def put_card(self, version: int, card_id: int, card_name: str, opt_id: int = None, **card_data) -> int:
|
||||
sql = insert(cards).values(
|
||||
version=version, cardId=card_id, cardName=card_name, opt=coalesce(cards.c.opt, opt_id) **card_data
|
||||
version=version, cardId=card_id, cardName=card_name, opt=coalesce(cards.c.opt, opt_id), **card_data
|
||||
)
|
||||
|
||||
conflict = sql.on_duplicate_key_update(opt=coalesce(cards.c.opt, opt_id), **card_data)
|
||||
|
||||
@@ -17,6 +17,10 @@
|
||||
<button type="button" class="btn btn-primary btn-sm" data-bs-toggle="modal" data-bs-target="#name_change">Edit</button>
|
||||
</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>ID:</th>
|
||||
<th>{{ profile.user }}</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>version:</td>
|
||||
<td>
|
||||
@@ -86,6 +90,40 @@
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="col-lg-8 m-auto mt-3">
|
||||
<div class="card bg-card rounded">
|
||||
<table class="table-large table-rowdistinct">
|
||||
<caption align="top">
|
||||
RIVALS
|
||||
<button type="button" class="btn btn-primary btn-sm" style="position: absolute; right: 15px;" data-bs-toggle="modal" data-bs-target="#rival_add">Add</button>
|
||||
</caption>
|
||||
<tr>
|
||||
<th scope="col">Id</th>
|
||||
<th scope="col">Name</th>
|
||||
<th scope="col">Rating</th>
|
||||
<th scope="col">Show</th>
|
||||
<th scope="col"></th>
|
||||
</tr>
|
||||
{% for rival in rival_list %}
|
||||
<tr>
|
||||
<td>{{ rival.2 }}</td>
|
||||
<td>{{ rival.0 }}</td>
|
||||
<td>{{ rival.1 }}</td>
|
||||
<td>
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" role="switch"
|
||||
id="rivalSwitch_{{ rival.2 }}"
|
||||
{% if rival.3 %}checked{% endif %}
|
||||
onchange="submitShow('{{ rival.2 }}', this.checked)">
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<button type="button" class="btn btn-primary btn-sm" data-bs-toggle="modal" data-bs-target="#rival_delete_{{ rival.2 }}">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if error is defined %}
|
||||
@@ -120,6 +158,75 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal fade" id="rival_add" tabindex="-1" aria-labelledby="card_add_label" data-bs-theme="dark" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Add rival</h5>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="rival" action="/game/mai2/rival.add" method="post" style="outline: 0;">
|
||||
<label class="form-label" for="rivalUserId">Rival ID</label>
|
||||
<input class="form-control" aria-describedby="rivalIdHelphelp" form="rival" id="rivalUserId" name="rivalUserId" maxlength="8" type="number" required>
|
||||
<div id="rivalIdHelphelp" class="form-text">Please use the ID show next to your name in the profile page.</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<input type=submit class="btn btn-primary" type="button" form="rival" value="Add">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% for rival in rival_list %}
|
||||
<div class="modal fade" id="rival_delete_{{ rival.2 }}" tabindex="-1" aria-labelledby="rival_delete_label" data-bs-theme="dark" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Delete rival</h5>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="rival_delete_{{ rival.2 }}_form" action="/game/mai2/rival.delete" method="post" style="outline: 0;">
|
||||
<p>Are you sure you want to delete rival {{ rival.0 }}?</p>
|
||||
<input type="hidden" name="rivalUserId" value="{{ rival.2 }}">
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<input type="submit" class="btn btn-danger" form="rival_delete_{{ rival.2 }}_form" value="Delete">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% for rival in rival_list %}
|
||||
<div class="modal fade" id="rival_show_{{ rival.2 }}" tabindex="-1" aria-labelledby="rival_show_label" data-bs-theme="dark" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">{% if rival.3 %}Hide{% else %}Show{% endif %} rival</h5>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="rival_show_{{ rival.2 }}_form" action="/game/mai2/rival.show" method="post" style="outline: 0;">
|
||||
<p>Are you sure you want to {% if rival.3 %}hide{% else %}show{% endif %} rival {{ rival.0 }}?</p>
|
||||
<input type="hidden" name="rivalUserId" value="{{ rival.2 }}">
|
||||
<input type="hidden" name="showRival" value="{{ "false" if rival.3 else "true" }}">
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<input type="submit" class="btn btn-primary" form="rival_show_{{ rival.2 }}_form" value="Confirm">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% for rival in rival_list %}
|
||||
<form action="/game/mai2/rival.show" method="post" id="rival_show_{{ rival.2 }}_form" style="display: none;">
|
||||
<input type="hidden" name="rivalUserId" value="{{ rival.2 }}">
|
||||
<input type="hidden" name="showRival" id="showRival_{{ rival.2 }}" value="false">
|
||||
</form>
|
||||
{% endfor %}
|
||||
<script>
|
||||
function changeVersion(sel) {
|
||||
$.post("/game/mai2/version.change", { version: sel.value })
|
||||
@@ -130,5 +237,17 @@
|
||||
alert("Failed to update version.");
|
||||
});
|
||||
}
|
||||
function submitShow(rivalId, checked) {
|
||||
if (checked) {
|
||||
let shownCount = document.querySelectorAll('.form-check-input:checked').length;
|
||||
if (shownCount > 3) {
|
||||
document.getElementById('rivalSwitch_' + rivalId).checked = false;
|
||||
alert('Show rival limit reached (max 3). Please unselect another rival first.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
document.getElementById('showRival_' + rivalId).value = checked;
|
||||
document.getElementById('rival_show_' + rivalId + '_form').submit();
|
||||
}
|
||||
</script>
|
||||
{% endblock content %}
|
||||
@@ -604,13 +604,6 @@ class OngekiStaticData(BaseData):
|
||||
return None
|
||||
return result.fetchall()
|
||||
|
||||
async def get_opts(self) -> Optional[List[Row]]:
|
||||
result = await self.execute(opts.select())
|
||||
|
||||
if result is None:
|
||||
return None
|
||||
return result.fetchall()
|
||||
|
||||
async def set_opt_enabled(self, opt_id: int, enabled: bool) -> bool:
|
||||
result = await self.execute(opts.update(opts.c.id == opt_id).values(isEnable=enabled))
|
||||
|
||||
|
||||
@@ -152,7 +152,7 @@ class SaoServlet(BaseServlet):
|
||||
|
||||
else:
|
||||
self.logger.error(f"Unknown response type {type(resp)}")
|
||||
return SaoNoopResponse(req_header.cmd + 1).make()
|
||||
return Response(SaoNoopResponse(req_header.cmd + 1).make())
|
||||
|
||||
self.logger.debug(f"Response: {resp.hex()}")
|
||||
|
||||
|
||||