Merge remote-tracking branch 'origin/develop' into idac

This commit is contained in:
Dniel97
2025-11-26 20:12:25 +01:00
101 changed files with 2981 additions and 480 deletions
+3
View File
@@ -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
+165 -29
View File
@@ -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
View File
@@ -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()
+139
View File
@@ -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
+30 -2
View File
@@ -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 ###
+43 -7
View File
@@ -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:
+12
View File
@@ -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
+2 -2
View File
@@ -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 -7
View File
@@ -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>
-38
View File
@@ -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)
+18 -1
View File
@@ -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
+12
View File
@@ -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 `""`
+65 -26
View File
@@ -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
+4
View File
@@ -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
+6
View File
@@ -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"
+5
View File
@@ -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: ""
+46
View File
@@ -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;
+32 -1
View File
@@ -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
+1 -1
View File
@@ -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]
+133 -68
View File
@@ -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"}
+6
View File
@@ -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:
+3
View File
@@ -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 = [
+19 -11
View File
@@ -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)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

+1 -1
View File
@@ -2,4 +2,4 @@
*
# Except this file and default unknown
!.gitignore
!unknown.png
!unknown.webp
Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 570 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

+136 -74
View File
@@ -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)
+2 -2
View File
@@ -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"
+2 -2
View File
@@ -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"
+16 -14
View File
@@ -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"]
+2 -2
View File
@@ -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"
+65 -4
View File
@@ -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):
+111 -38
View File
@@ -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()
+17 -2
View File
@@ -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()
+6 -1
View File
@@ -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]
+177 -19
View File
@@ -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()
+2 -2
View File
@@ -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"
+2 -2
View File
@@ -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"
+3 -3
View File
@@ -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>&nbsp;&nbsp;&nbsp;&nbsp;
<button id="reset-btn" class="btn btn-danger" style="width:140px;" onClick="resetAvatar()">RESET</button>
</td></tr>
+63 -10
View File
@@ -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 }}&nbsp;&nbsp;&nbsp;{{ 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 () {
+246
View File
@@ -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)
+2 -1
View File
@@ -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):
+1 -1
View File
@@ -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}")
+2 -2
View File
@@ -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,
}
+27 -3
View File
@@ -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
+19 -16
View File
@@ -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)
+1
View File
@@ -18,4 +18,5 @@ game_codes = [
Mai2Constants.GAME_CODE_GREEN,
Mai2Constants.GAME_CODE,
Mai2Constants.GAME_CODE_DX_INT,
Mai2Constants.GAME_CODE_DX_CHN,
]
+3
View File
@@ -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:
+59
View File
@@ -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
}
+24 -1
View File
@@ -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)
+20 -4
View File
@@ -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
+3 -3
View File
@@ -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
+41 -1
View File
@@ -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)
+98 -24
View File
@@ -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))
+38 -9
View File
@@ -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 {
+141
View File
@@ -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
}
+31 -19
View File
@@ -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,
+2 -1
View File
@@ -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",
)
+1 -1
View File
@@ -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)
+119
View File
@@ -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 %}
-7
View File
@@ -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))
+1 -1
View File
@@ -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()}")

Some files were not shown because too many files have changed in this diff Show More