Merge branch 'develop' into idac

This commit is contained in:
Dniel97
2026-04-01 18:06:03 +02:00
107 changed files with 4324 additions and 672 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,31 @@
"""chuni_subtrophy_db_fix
Revision ID: 318d52559e83
Revises: 8b57e9646449
Create Date: 2026-01-08 19:13:29.803912
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = '318d52559e83'
down_revision = '8b57e9646449'
branch_labels = None
depends_on = None
def upgrade():
op.alter_column('chuni_profile_data', 'trophyIdSub1', existing_type=mysql.INTEGER(), server_default='-1')
op.alter_column('chuni_profile_data', 'trophyIdSub2', existing_type=mysql.INTEGER(), server_default='-1')
# fix any current profiles where the bad defaults were used
op.execute("UPDATE chuni_profile_data SET trophyIdSub1=-1 WHERE trophyIdSub1 IS NULL")
op.execute("UPDATE chuni_profile_data SET trophyIdSub2=-1 WHERE trophyIdSub2 IS NULL")
def downgrade():
# dont bother "unfixing" the table
pass
@@ -0,0 +1,85 @@
"""CHUNITHM VERSE support
Revision ID: 49c295e89cd4
Revises: 7070a6fa8cdc
Create Date: 2025-03-09 14:10:03.067328
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
from sqlalchemy.sql import func
# revision identifiers, used by Alembic.
revision = "49c295e89cd4"
down_revision = "7070a6fa8cdc"
branch_labels = None
depends_on = None
def upgrade():
### commands auto generated by Alembic - please adjust! ###
op.add_column("chuni_profile_data", sa.Column("trophyIdSub1", sa.Integer()))
op.add_column("chuni_profile_data", sa.Column("trophyIdSub2", sa.Integer()))
op.add_column("chuni_score_playlog", sa.Column("monthPoint", sa.Integer()))
op.add_column("chuni_score_playlog", sa.Column("eventPoint", sa.Integer()))
op.create_table(
"chuni_static_unlock_challenge",
sa.Column("id", sa.Integer(), primary_key=True, nullable=False),
sa.Column("version", sa.Integer(), nullable=False),
sa.Column("unlockChallengeId", sa.Integer(), nullable=False),
sa.Column("name", sa.String(length=255)),
sa.Column("isEnabled", sa.Boolean(), server_default="1"),
sa.Column("startDate", sa.TIMESTAMP(), server_default=func.now()),
sa.Column("courseId1", sa.Integer()),
sa.Column("courseId2", sa.Integer()),
sa.Column("courseId3", sa.Integer()),
sa.Column("courseId4", sa.Integer()),
sa.Column("courseId5", sa.Integer()),
sa.UniqueConstraint(
"version", "unlockChallengeId", name="chuni_static_unlock_challenge_uk"
),
mysql_charset="utf8mb4",
)
op.create_table(
"chuni_item_unlock_challenge",
sa.Column("id", sa.Integer(), primary_key=True, nullable=False),
sa.Column("version", sa.Integer(), nullable=False),
sa.Column(
"user",
sa.Integer(),
sa.ForeignKey("aime_user.id", ondelete="cascade", onupdate="cascade"),
nullable=False,
),
sa.Column("unlockChallengeId", sa.Integer(), nullable=False),
sa.Column("status", sa.Integer()),
sa.Column("clearCourseId", sa.Integer()),
sa.Column("conditionType", sa.Integer()),
sa.Column("score", sa.Integer()),
sa.Column("life", sa.Integer()),
sa.Column("clearDate", sa.TIMESTAMP(), server_default=func.now()),
sa.UniqueConstraint(
"version",
"user",
"unlockChallengeId",
name="chuni_item_unlock_challenge_uk",
),
mysql_charset="utf8mb4",
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column("chuni_score_playlog", "eventPoint")
op.drop_column("chuni_score_playlog", "monthPoint")
op.drop_column("chuni_profile_data", "trophyIdSub2")
op.drop_column("chuni_profile_data", "trophyIdSub1")
op.drop_table("chuni_static_unlock_challenge")
op.drop_table("chuni_item_unlock_challenge")
# ### end Alembic commands ###
@@ -10,6 +10,7 @@ import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '5cf98cfe52ad'
down_revision = '263884e774cc'
branch_labels = None
@@ -0,0 +1,42 @@
"""update_channels
Revision ID: 7070a6fa8cdc
Revises: f6007bbf057d
Create Date: 2025-09-27 16:09:55.853051
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = '7070a6fa8cdc'
down_revision = 'f6007bbf057d'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('machine_update',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('game', sa.CHAR(length=4), nullable=False),
sa.Column('version', sa.VARCHAR(length=15), nullable=False),
sa.Column('channel', sa.VARCHAR(length=260), nullable=False),
sa.Column('app_ini', sa.VARCHAR(length=260), nullable=True),
sa.Column('opt_ini', sa.VARCHAR(length=260), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('game', 'version', 'channel', name='machine_update_uk'),
mysql_charset='utf8mb4'
)
op.add_column('machine', sa.Column('ota_channel', sa.VARCHAR(length=260), nullable=True))
op.drop_column('machine', 'ota_enable')
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('machine', sa.Column('ota_enable', mysql.TINYINT(display_width=1), autoincrement=False, nullable=True))
op.drop_column('machine', 'ota_channel')
op.drop_table('machine_update')
# ### end Alembic commands ###
@@ -0,0 +1,98 @@
"""CHUNITHM X-VERSE
Revision ID: 8b57e9646449
Revises: bdf710616ba4
Create Date: 2025-12-12 16:09:07.530809
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "8b57e9646449"
down_revision = "bdf710616ba4"
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column(
"chuni_profile_data",
sa.Column("stageId", sa.Integer(), nullable=False, server_default="99999"),
)
op.create_table(
"chuni_static_linked_verse",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("version", sa.Integer(), nullable=False),
sa.Column("linkedVerseId", sa.Integer(), nullable=False),
sa.Column("name", sa.String(length=255), nullable=True),
sa.Column("isEnabled", sa.Boolean(), server_default="1", nullable=False),
sa.Column(
"startDate", sa.TIMESTAMP(), server_default=sa.text("now()"), nullable=True
),
sa.Column("courseId1", sa.Integer(), nullable=True),
sa.Column("courseId2", sa.Integer(), nullable=True),
sa.Column("courseId3", sa.Integer(), nullable=True),
sa.Column("courseId4", sa.Integer(), nullable=True),
sa.Column("courseId5", sa.Integer(), nullable=True),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"version", "linkedVerseId", name="chuni_static_linked_verse_pk"
),
mysql_charset="utf8mb4",
)
op.create_table(
"chuni_item_linked_verse",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("user", sa.Integer(), nullable=False),
sa.Column("linkedVerseId", sa.Integer(), nullable=False),
sa.Column("progress", sa.String(length=255), nullable=True),
sa.Column("statusOpen", sa.Integer(), nullable=True),
sa.Column("statusUnlock", sa.Integer(), nullable=True),
sa.Column("isFirstClear", sa.Integer(), nullable=True),
sa.Column("numClear", sa.Integer(), nullable=True),
sa.Column("clearCourseId", sa.Integer(), nullable=True),
sa.Column("clearCourseLevel", sa.Integer(), nullable=True),
sa.Column("clearScore", sa.Integer(), nullable=True),
sa.Column("clearDate", sa.String(length=25), nullable=True),
sa.Column("clearUserId1", sa.Integer(), nullable=True),
sa.Column("clearUserId2", sa.Integer(), nullable=True),
sa.Column("clearUserId3", sa.Integer(), nullable=True),
sa.Column("clearUserName0", sa.String(length=20), nullable=True),
sa.Column("clearUserName1", sa.String(length=20), nullable=True),
sa.Column("clearUserName2", sa.String(length=20), nullable=True),
sa.Column("clearUserName3", sa.String(length=20), nullable=True),
sa.ForeignKeyConstraint(
["user"], ["aime_user.id"], onupdate="cascade", ondelete="cascade"
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("user", "linkedVerseId", name="chuni_item_linked_verse_uk"),
mysql_charset="utf8mb4",
)
op.create_table(
"chuni_static_stage",
sa.Column("id", sa.Integer(), primary_key=True, nullable=False),
sa.Column("version", sa.Integer(), nullable=False),
sa.Column("stageId", sa.Integer(), nullable=False),
sa.Column("name", sa.String(length=255)),
sa.Column("imagePath", sa.String(length=255)),
sa.Column("isEnabled", sa.Boolean(), server_default="1"),
sa.Column("defaultHave", sa.Boolean(), server_default="0"),
sa.Column("opt", sa.BIGINT(), sa.ForeignKey("chuni_static_opt.id", ondelete="SET NULL", onupdate="cascade")),
sa.UniqueConstraint(
"version", "stageId", name="chuni_static_stage_uk"
),
mysql_charset="utf8mb4",
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column("chuni_profile_data", "stageId")
op.drop_table("chuni_item_linked_verse")
op.drop_table("chuni_static_linked_verse")
op.drop_table("chuni_static_stage")
# ### end Alembic commands ###
@@ -0,0 +1,29 @@
"""Mai2 add PRiSM+ playlog support
Revision ID: bdf710616ba4
Revises: 16f34bf7b968
Create Date: 2025-04-02 12:42:08.981516
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'bdf710616ba4'
down_revision = '49c295e89cd4'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('mai2_playlog', sa.Column('extBool3', sa.Boolean(), nullable=True,server_default=sa.text("NULL")))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('mai2_playlog', 'extBool3')
# ### end Alembic commands ###
+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 `""`
+66 -26
View File
@@ -68,6 +68,8 @@ Games listed below have been tested and confirmed working.
| 14 | CHUNITHM SUN PLUS |
| 15 | CHUNITHM LUMINOUS |
| 16 | CHUNITHM LUMINOUS PLUS |
| 17 | CHUNITHM VERSE |
| 18 | CHUNITHM X-VERSE |
### Importer
@@ -110,6 +112,7 @@ crypto:
keys:
13: ["0000000000000000000000000000000000000000000000000000000000000000", "00000000000000000000000000000000", "0000000000000000"]
"13_int": ["0000000000000000000000000000000000000000000000000000000000000000", "00000000000000000000000000000000", "0000000000000000", 42]
"13_chn": ["0000000000000000000000000000000000000000000000000000000000000000", "00000000000000000000000000000000", "0000000000000000", 8]
```
### Database upgrade
@@ -205,32 +208,32 @@ Presents are items given to the user when they login, with a little animation (f
### Versions
| Game Code | Version ID | Version Name |
|-----------|------------|-------------------------|
| SBXL | 0 | maimai |
| SBXL | 1 | maimai PLUS |
| SBZF | 2 | maimai GreeN |
| SBZF | 3 | maimai GreeN PLUS |
| SDBM | 4 | maimai ORANGE |
| SDBM | 5 | maimai ORANGE PLUS |
| SDCQ | 6 | maimai PiNK |
| SDCQ | 7 | maimai PiNK PLUS |
| SDDK | 8 | maimai MURASAKi |
| SDDK | 9 | maimai MURASAKi PLUS |
| SDDZ | 10 | maimai MiLK |
| SDDZ | 11 | maimai MiLK PLUS |
| SDEY | 12 | maimai FiNALE |
| SDEZ | 13 | maimai DX |
| SDEZ | 14 | maimai DX PLUS |
| SDEZ | 15 | maimai DX Splash |
| SDEZ | 16 | maimai DX Splash PLUS |
| SDEZ | 17 | maimai DX UNiVERSE |
| SDEZ | 18 | maimai DX UNiVERSE PLUS |
| SDEZ | 19 | maimai DX FESTiVAL |
| SDEZ | 20 | maimai DX FESTiVAL PLUS |
| SDEZ | 21 | maimai DX BUDDiES |
| SDEZ | 22 | maimai DX BUDDiES PLUS |
| SDEZ | 23 | maimai DX PRiSM |
|----------|------------|-------------------------|
| SBXL | 0 | maimai |
| SBXL | 1 | maimai PLUS |
| SBZF | 2 | maimai GreeN |
| SBZF | 3 | maimai GreeN PLUS |
| SDBM | 4 | maimai ORANGE |
| SDBM | 5 | maimai ORANGE PLUS |
| SDCQ | 6 | maimai PiNK |
| SDCQ | 7 | maimai PiNK PLUS |
| SDDK | 8 | maimai MURASAKi |
| SDDK | 9 | maimai MURASAKi PLUS |
| SDDZ | 10 | maimai MiLK |
| SDDZ | 11 | maimai MiLK PLUS |
| SDEY | 12 | maimai FiNALE |
| SDEZ | 13 | maimai DX |
| SDEZ | 14 | maimai DX PLUS |
| SDEZ | 15 | maimai DX Splash |
| SDEZ | 16 | maimai DX Splash PLUS |
| SDEZ | 17 | maimai DX UNiVERSE |
| SDEZ | 18 | maimai DX UNiVERSE PLUS |
| SDEZ | 19 | maimai DX FESTiVAL |
| SDEZ | 20 | maimai DX FESTiVAL PLUS |
| SDEZ | 21 | maimai DX BUDDiES |
| SDEZ | 22 | maimai DX BUDDiES PLUS |
| SDEZ | 23 | maimai DX PRiSM |
| SDEZ | 24 | maimai DX PRiSM PLUS |
### Importer
@@ -259,6 +262,43 @@ python dbutils.py upgrade
Pre-Dx uses the same database as DX, so only upgrade using the SDEZ game code!
### Config
Config file is located in `config/mai2.yaml`.
| Option | Info |
|-----------------------|-------------------------------------------------------------------------------------------------------------------------------------------|
| `crypto` | This option is used to enable the TLS Encryption |
If you would like to use network encryption, add the keys to the `keys` section under `crypto`, where the key
is the version ID for Japanese (SDEZ) versions and `"{versionID}_int"` for Export (SDGA) versions, and the value
is an array containing `[key, iv, salt]` in order.
Just copy your salt in here, no need to convert anything.
```yaml
crypto:
encrypted_only: False
keys:
23: ["0000000000000000000000000000000000000000000000000000000000000000", "00000000000000000000000000000000", "0000000000000000"]
"23_int": ["0000000000000000000000000000000000000000000000000000000000000000", "00000000000000000000000000000000", "0000000000000000"]
"23_chn": ["0000000000000000000000000000000000000000000000000000000000000000", "00000000000000000000000000000000", "0000000000000000"]
```
| Option | Info |
|-----------------|------------------------------------------------------|
| `chart_deliver` | This option is used to delivery charts to the client |
If you would like to use chart delivery, set this option to `True` and configure the directory to read from. Then put charts in your chart folder like this:
```
chart_folder/23/music001736/001736_00.ma2
chart_folder/23/music001736/001736_01.ma2 # PRiSM
chart_folder/24/music001901/001901_00.ma2
chart_folder/24/music001901/001901_01.ma2 # PRiSM PLUS
```
## Hatsune Miku Project Diva
### SBZV
+11 -3
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.
@@ -10,7 +11,7 @@ mods:
use_login_bonus: True
# stock_tickets allows specified ticket IDs to be auto-stocked at login. Format is a comma-delimited string of ticket IDs
# note: quanity is not refreshed on "continue" after set - only on subsequent login
stock_tickets:
stock_tickets:
stock_count: 99
# Allow use of all available customization items in frontend web ui
@@ -18,12 +19,13 @@ mods:
# warning: This can result in pushing a lot of data, especially the userbox items. Recommended for local network use only.
forced_item_unlocks:
map_icons: False
system_voices: False
system_voices: False
avatar_accessories: False
nameplates: False
trophies: False
character_icons: False
stages: False
version:
11:
rom: 2.00.00
@@ -43,6 +45,12 @@ version:
16:
rom: 2.25.00
data: 2.25.00
17:
rom: 2.30.00
data: 2.30.00
18:
rom: 2.40.00
data: 2.40.00
crypto:
encrypted_only: False
+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;
+33 -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,8 @@ Games listed below have been tested and confirmed working. Only game versions ol
+ SUN PLUS
+ LUMINOUS
+ LUMINOUS PLUS
+ VERSE
+ X-VERSE
+ crossbeats REV.
+ Crossbeats REV.
@@ -43,7 +52,16 @@ Games listed below have been tested and confirmed working. Only game versions ol
+ Initial D THE ARCADE
+ Season 2
+ maimai DX
+ maimai DX CHINA
+ DX (Muji)
+ 2021 (Muji)
+ 2022 (Muji)
+ 2023 (FESTiVAL)
+ 2024 (BUDDiES)
+ maimai DX INTL
+ DX
+ DX Plus
+ Splash
+ Splash Plus
+ UNiVERSE
@@ -53,6 +71,20 @@ Games listed below have been tested and confirmed working. Only game versions ol
+ BUDDiES
+ BUDDiES PLUS
+ PRiSM
+ maimai DX
+ DX
+ DX Plus
+ Splash
+ Splash Plus
+ UNiVERSE
+ UNiVERSE PLUS
+ FESTiVAL
+ FESTiVAL PLUS
+ BUDDiES
+ BUDDiES PLUS
+ PRiSM
+ PRiSM PLUS
+ O.N.G.E.K.I.
+ SUMMER
+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]
+137 -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,11 +1090,25 @@ class ChuniBase:
added_ids = music_ids - keep_ids
for fav_id in deleted_ids:
await self.data.item.delete_favorite_music(user_id, self.version, fav_id)
await self.data.item.delete_favorite_music(
user_id, self.version, fav_id
)
for fav_id in added_ids:
await self.data.item.put_favorite_music(user_id, self.version, fav_id)
# added in CHUNITHM VERSE
if "userUnlockChallengeList" in upsert:
for unlock_challenge in upsert["userUnlockChallengeList"]:
await self.data.item.put_unlock_challenge(
user_id, self.version, unlock_challenge
)
# added in CHUNITHM X-VERSE
if "userLinkedVerseList" in upsert:
for linked_verse in upsert["userLinkedVerseList"]:
await self.data.item.put_linked_verse(user_id, linked_verse)
return {"returnCode": "1"}
async def handle_upsert_user_chargelog_api_request(self, data: Dict) -> Dict:
+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:
+163 -8
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,8 @@ class ChuniConstants:
VER_CHUNITHM_SUN_PLUS = 14
VER_CHUNITHM_LUMINOUS = 15
VER_CHUNITHM_LUMINOUS_PLUS = 16
VER_CHUNITHM_VERSE = 17
VER_CHUNITHM_X_VERSE = 18
VERSION_NAMES = [
"CHUNITHM",
@@ -46,6 +49,8 @@ class ChuniConstants:
"CHUNITHM SUN PLUS",
"CHUNITHM LUMINOUS",
"CHUNITHM LUMINOUS PLUS",
"CHUNITHM VERSE",
"CHUNITHM X-VERSE",
]
SCORE_RANK_INTERVALS_OLD = [
@@ -97,6 +102,8 @@ class ChuniConstants:
"215": VER_CHUNITHM_SUN_PLUS,
"220": VER_CHUNITHM_LUMINOUS,
"225": VER_CHUNITHM_LUMINOUS_PLUS,
"230": VER_CHUNITHM_VERSE,
"240": VER_CHUNITHM_X_VERSE,
}
@classmethod
@@ -109,31 +116,177 @@ class ChuniConstants:
return cls.VERSION_LUT.get(str(floor_to_nearest_005(ver)), None)
class MapAreaConditionType(IntEnum):
"""Condition types for the GetGameMapAreaConditionApi endpoint. Incomplete.
"""
Condition IDs for the `GetGameMapAreaConditionApi` and `GetGameUCConditionApi` requests.
For the MAP_CLEARED/MAP_AREA_CLEARED/TROPHY_OBTAINED conditions, the conditionId
is the map/map area/trophy.
- "Item" or "locked item" refers to the map area, unlock challenge or
Linked VERSE locked using this system.
- "Chart ID" refers to musicID \\* 100 + difficulty, where difficulty is 0 for BASIC
up to 6 for WORLD'S END. For example, Halcyon ULTIMA is 17305.
"""
For the RANK_*/ALL_JUSTICE conditions, the conditionId is songId * 100 + difficultyId.
For example, Halcyon [ULTIMA] would be 173 * 100 + 4 = 17304.
INVALID = 0
"""
Invalid condition type. Should cause the hidden item to be automatically unlocked,
but seemingly only works with map areas.
"""
ALWAYS_UNLOCKED = 0
MAP_CLEARED = 1
"""Finish the map with ID `conditionId`."""
MAP_AREA_CLEARED = 2
"""Finish the map area with ID `conditionId`."""
TROPHY_OBTAINED = 3
"""Unlock the trophy with ID `conditionId`."""
TROPHY_EQUIPPED = 4
"""
Equip the trophy with ID `conditionId`. The item is locked again when the trophy is
unequipped.
"""
NAMEPLATE_OBTAINED = 5
"""Unlock the nameplate with ID `conditionId`."""
NAMEPLATE_EQUIPPED = 6
"""
Equip the nameplate with ID `conditionId`. The item is locked again when the nameplate
is unequipped.
"""
CHARACTER_OBTAINED = 7
"""Unlock the character with ID `conditionId`."""
CHARACTER_EQUIPPED = 8
"""
Equip the character with ID `conditionId`. The item is locked again when the character
is unequipped.
"""
CHARACTER_TRANSFORM_EQUIPPED = 9
"""
Equip the character, with the character transform ID `conditionId`. The item is locked again
if the incorrect character is equipped, or the correct character is equipped with the wrong
transform.
"""
MUSIC_OBTAINED = 10
"""Unlock the music with ID `conditionId`."""
AVATAR_ACCESSORY_OBTAINED = 11
"""Unlock the avatar accessory with ID `conditionId`."""
AVATAR_ACCESSORY_EQUIPPED = 12
"""
Equip the avatar accessory with ID `conditionId`. The item is locked again when the avatar
accessory is unequipped.
"""
MAP_ICON_OBTAINED = 13
"""Unlock the map icon with ID `conditionId`."""
MAP_ICON_EQUIPPED = 14
"""
Equip the map icon with ID `conditionId`. The item is locked again when the map icon is
unequipped.
"""
SYSTEM_VOICE_OBTAINED = 15
"""Unlock the system voice with ID `conditionId`."""
SYSTEM_VOICE_EQUIPPED = 16
"""
Equip the system voice with ID `conditionId`. The item is locked again when the system voice
is unequipped.
"""
ALL_JUSTICE_CRITICAL = 17
"""Obtain ALL JUSTICE CRITICAL on the chart given by `conditionId`."""
RANK_SSSP = 18
"""Obtain rank SSS+ on the chart given by `conditionId`."""
RANK_SSS = 19
"""Obtain rank SSS on the chart given by `conditionId`."""
RANK_SSP = 20
"""Obtain rank SS+ on the chart given by `conditionId`."""
RANK_SS = 21
"""Obtain rank SS on the chart given by `conditionId`."""
RANK_SP = 22
"""Obtain rank S+ on the chart given by `conditionId`."""
RANK_S = 23
"""Obtain rank S on the chart given by `conditionId`."""
RANK_AAA = 24
"""Obtain rank AAA on the chart given by `conditionId`."""
RANK_AA = 25
"""Obtain rank AA on the chart given by `conditionId`."""
RANK_A = 26
"""Obtain rank A on the chart given by `conditionId`."""
MINIMUM_BEST_30_AVERAGE = 27
"""Obtain a best 30 average of at least `conditionId / 100`."""
ALL_JUSTICE = 28
"""Obtain ALL JUSTICE on the chart given by `conditionId`."""
FULL_COMBO = 29
"""Obtain FULL COMBO on the chart given by `conditionId`."""
UNLOCK_CHALLENGE_DISCOVERED = 30
"""Discover/unlock the unlock challenge with ID `conditionId`."""
UNLOCK_CHALLENGE_CLEARED = 31
"""Clear the unlock challenge with ID `conditionId`."""
MINIMUM_RATING = 32
"""Obtain a rating of at least `conditionId / 100`."""
class LinkedVerseUnlockConditionType(IntEnum):
"""
`conditionList` is a semicolon-delimited list of numbers, where the number's meaning
is defined by the specific `conditionId`. Additionally, each element of the list
can be further separated by underscores. For example `1;2_3;4` means that the player
must achieve 1 AND (2 OR 3) AND 4.
"""
PLAY_SONGS = 33
"""
Play songs given by `conditionList`, where `conditionList` is a
list of song IDs.
"""
COURSE_CLEAR_AND_CLASS_EMBLEM = 34
"""
Obtain a class emblem (by clearing all courses of a given class) on **any**
of the classes given by `conditionList`, where `conditionList` is an
underscore-separated list of class IDs (1 for CLASS I to 6 for CLASS ).
"""
TROPHY_OBTAINED = 35
"""
Obtain trophies given by `conditionList`, where `conditionList` is a
list of trophy IDs.
"""
PLAY_SONGS_IN_FAVORITE = 36
"""
Play songs given by `conditionList` **from the favorites folder**, where
`conditionList` is a list of song IDs.
"""
CLEAR_TEAM_COURSE_WITH_CHARACTER_OF_MINIMUM_RANK = 37
"""
Clear a team course while equipping a character of minimum rank.
"""
class MapAreaConditionLogicalOperator(Enum):
AND = 1
@@ -176,6 +329,8 @@ class ItemKind(IntEnum):
"""This only applies to ULTIMA difficulties that are *not* unlocked by
reaching S rank on EXPERT difficulty or above.
"""
STAGE = 13
class FavoriteItemKind(IntEnum):
+65 -12
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']),
@@ -104,6 +108,7 @@ class ChuniFrontend(FE_Base):
Route("/avatar", self.render_GET_avatar, methods=['GET']),
Route("/update.map-icon", self.update_map_icon, methods=['POST']),
Route("/update.system-voice", self.update_system_voice, methods=['POST']),
Route("/update.stage", self.update_stage, methods=['POST']),
Route("/update.userbox", self.update_userbox, methods=['POST']),
Route("/update.avatar", self.update_avatar, methods=['POST']),
Route("/update.name", self.update_name, methods=['POST']),
@@ -137,6 +142,7 @@ class ChuniFrontend(FE_Base):
# version here - it'll just end up being empty sets and the jinja will ignore the variables anyway.
map_icons, total_map_icons = await self.get_available_map_icons(version, profile)
system_voices, total_system_voices = await self.get_available_system_voices(version, profile)
stages, total_stages = await self.get_available_stages(version, profile)
resp = Response(template.render(
title=f"{self.core_config.server.name} | {self.nav_name}",
@@ -151,7 +157,9 @@ class ChuniFrontend(FE_Base):
map_icons=map_icons,
system_voices=system_voices,
total_map_icons=total_map_icons,
total_system_voices=total_system_voices
total_system_voices=total_system_voices,
stages=stages,
total_stages=total_stages
), media_type="text/html; charset=utf-8")
if usr_sesh.chunithm_version >= 0:
@@ -252,12 +260,12 @@ class ChuniFrontend(FE_Base):
artist=music_chart.artist
title=music_chart.title
(jacket, ext) = path.splitext(music_chart.jacketPath)
jacket += ".png"
jacket += ".webp"
else:
difficultyNum=0
artist="unknown"
title="musicid: " + str(record.musicId)
jacket = "unknown.png"
jacket = "unknown.webp"
# Check if this song is a favorite so we can populate the add/remove button
is_favorite = await self.data.item.is_favorite(user_id, version, record.musicId)
@@ -313,12 +321,12 @@ class ChuniFrontend(FE_Base):
title=song.title
genre=song.genre
(jacket, ext) = path.splitext(song.jacketPath)
jacket += ".png"
jacket += ".webp"
else:
artist="unknown"
title="musicid: " + str(favorite.favId)
genre="unknown"
jacket = "unknown.png"
jacket = "unknown.webp"
# add a new collection for the genre if this is our first time seeing it
if genre not in favorites_by_genre:
@@ -370,7 +378,7 @@ class ChuniFrontend(FE_Base):
item = dict()
item["id"] = row["mapIconId"]
item["name"] = row["name"]
item["iconPath"] = path.splitext(row["iconPath"])[0] + ".png"
item["iconPath"] = path.splitext(row["iconPath"])[0] + ".webp"
items[row["mapIconId"]] = item
return (items, len(rows))
@@ -395,11 +403,36 @@ class ChuniFrontend(FE_Base):
item = dict()
item["id"] = row["voiceId"]
item["name"] = row["name"]
item["imagePath"] = path.splitext(row["imagePath"])[0] + ".png"
item["imagePath"] = path.splitext(row["imagePath"])[0] + ".webp"
items[row["voiceId"]] = item
return (items, len(rows))
async def get_available_stages(self, version: int, profile: Row) -> Tuple[List[Dict], int]:
if profile is None:
return ([], 0)
items = dict()
rows = await self.data.static.get_stages(version)
if rows is None:
return (items, 0) # can only happen with old db
force_unlocked = self.game_cfg.mods.forced_item_unlocks("stages")
user_stages = []
if not force_unlocked:
user_stages = await self.data.item.get_items(profile.user, ItemKind.STAGE.value)
user_stages = [icon["itemId"] for icon in user_stages] + [profile.stageId]
for row in rows:
if force_unlocked or row["defaultHave"] or row["stageId"] in user_stages:
item = dict()
item["id"] = row["stageId"]
item["name"] = row["name"]
item["imagePath"] = path.splitext(row["imagePath"])[0] + ".webp"
items[row["stageId"]] = item
return (items, len(rows))
async def get_available_nameplates(self, version: int, profile: Row) -> Tuple[List[Dict], int]:
items = dict()
rows = await self.data.static.get_nameplates(version)
@@ -418,7 +451,7 @@ class ChuniFrontend(FE_Base):
item = dict()
item["id"] = row["nameplateId"]
item["name"] = row["name"]
item["texturePath"] = path.splitext(row["texturePath"])[0] + ".png"
item["texturePath"] = path.splitext(row["texturePath"])[0] + ".webp"
items[row["nameplateId"]] = item
return (items, len(rows))
@@ -464,7 +497,7 @@ class ChuniFrontend(FE_Base):
item = dict()
item["id"] = row["characterId"]
item["name"] = row["name"]
item["iconPath"] = path.splitext(row["imagePath3"])[0] + ".png"
item["iconPath"] = path.splitext(row["imagePath3"])[0] + ".webp"
items[row["characterId"]] = item
return (items, len(rows))
@@ -482,8 +515,8 @@ class ChuniFrontend(FE_Base):
item = dict()
item["id"] = row["avatarAccessoryId"]
item["name"] = row["name"]
item["iconPath"] = path.splitext(row["iconPath"])[0] + ".png"
item["texturePath"] = path.splitext(row["texturePath"])[0] + ".png"
item["iconPath"] = path.splitext(row["iconPath"])[0] + ".webp"
item["texturePath"] = path.splitext(row["texturePath"])[0] + ".webp"
items[row["avatarAccessoryId"]] = item
return (items, len(rows))
@@ -646,6 +679,22 @@ class ChuniFrontend(FE_Base):
return RedirectResponse("/gate/?e=999", 303)
return RedirectResponse("/game/chuni/", 303)
async def update_stage(self, request: Request) -> bytes:
usr_sesh = self.validate_session(request)
if not usr_sesh:
return RedirectResponse("/gate/", 303)
form_data = await request.form()
new_system_voice: str = form_data.get("id")
if not new_system_voice:
return RedirectResponse("/gate/?e=4", 303)
if not await self.data.profile.update_stage(usr_sesh.user_id, usr_sesh.chunithm_version, new_system_voice):
return RedirectResponse("/gate/?e=999", 303)
return RedirectResponse("/game/chuni/", 303)
async def update_userbox(self, request: Request) -> bytes:
usr_sesh = self.validate_session(request)
@@ -655,14 +704,18 @@ class ChuniFrontend(FE_Base):
form_data = await request.form()
new_nameplate: str = form_data.get("nameplate")
new_trophy: str = form_data.get("trophy")
new_trophy_sub_1: str = form_data.get("trophySub1")
new_trophy_sub_2: str = form_data.get("trophySub2")
new_character: str = form_data.get("character")
if not new_nameplate or \
not new_trophy or \
not new_trophy_sub_1 or \
not new_trophy_sub_2 or \
not new_character:
return RedirectResponse("/game/chuni/userbox?e=4", 303)
if not await self.data.profile.update_userbox(usr_sesh.user_id, usr_sesh.chunithm_version, new_nameplate, new_trophy, new_character):
if not await self.data.profile.update_userbox(usr_sesh.user_id, usr_sesh.chunithm_version, new_nameplate, new_trophy, new_trophy_sub_1, new_trophy_sub_2, new_character):
return RedirectResponse("/gate/?e=999", 303)
return RedirectResponse("/game/chuni/userbox", 303)
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

+4
View File
@@ -0,0 +1,4 @@
# Ignore everything in this directory
*
# Except this file
!.gitignore
+149 -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,9 @@ from .sun import ChuniSun
from .sunplus import ChuniSunPlus
from .luminous import ChuniLuminous
from .luminousplus import ChuniLuminousPlus
from .verse import ChuniVerse
from .xverse import ChuniXVerse
class ChuniServlet(BaseServlet):
def __init__(self, core_cfg: CoreConfig, cfg_dir: str) -> None:
@@ -66,6 +71,8 @@ class ChuniServlet(BaseServlet):
ChuniSunPlus,
ChuniLuminous,
ChuniLuminousPlus,
ChuniVerse,
ChuniXVerse,
]
self.logger = logging.getLogger("chuni")
@@ -96,20 +103,27 @@ class ChuniServlet(BaseServlet):
known_iter_counts = {
ChuniConstants.VER_CHUNITHM_CRYSTAL_PLUS: 67,
f"{ChuniConstants.VER_CHUNITHM_CRYSTAL_PLUS}_int": 25, # SUPERSTAR
f"{ChuniConstants.VER_CHUNITHM_CRYSTAL_PLUS}_int": 25, # SUPERSTAR
ChuniConstants.VER_CHUNITHM_PARADISE: 44,
f"{ChuniConstants.VER_CHUNITHM_PARADISE}_int": 51, # SUPERSTAR PLUS
f"{ChuniConstants.VER_CHUNITHM_PARADISE}_int": 51, # SUPERSTAR PLUS
ChuniConstants.VER_CHUNITHM_NEW: 54,
f"{ChuniConstants.VER_CHUNITHM_NEW}_int": 49,
f"{ChuniConstants.VER_CHUNITHM_NEW}_chn": 37,
ChuniConstants.VER_CHUNITHM_NEW_PLUS: 25,
f"{ChuniConstants.VER_CHUNITHM_NEW_PLUS}_int": 31,
f"{ChuniConstants.VER_CHUNITHM_NEW_PLUS}_chn": 35, # NEW
ChuniConstants.VER_CHUNITHM_SUN: 70,
f"{ChuniConstants.VER_CHUNITHM_SUN}_int": 35,
ChuniConstants.VER_CHUNITHM_SUN_PLUS: 36,
f"{ChuniConstants.VER_CHUNITHM_SUN_PLUS}_int": 36,
ChuniConstants.VER_CHUNITHM_LUMINOUS: 8,
f"{ChuniConstants.VER_CHUNITHM_LUMINOUS}_int": 8,
f"{ChuniConstants.VER_CHUNITHM_LUMINOUS}_chn": 8,
ChuniConstants.VER_CHUNITHM_LUMINOUS_PLUS: 56,
ChuniConstants.VER_CHUNITHM_VERSE: 42,
f"{ChuniConstants.VER_CHUNITHM_VERSE}_chn": 37,
ChuniConstants.VER_CHUNITHM_X_VERSE: 14,
f"{ChuniConstants.VER_CHUNITHM_X_VERSE}_int": 96,
}
for version, keys in self.game_cfg.crypto.keys.items():
@@ -120,7 +134,7 @@ class ChuniServlet(BaseServlet):
version_idx = version
else:
version_idx = int(version.split("_")[0])
salt = bytes.fromhex(keys[2])
if len(keys) >= 4:
@@ -150,7 +164,9 @@ class ChuniServlet(BaseServlet):
and version_idx >= ChuniConstants.VER_CHUNITHM_NEW
):
method_fixed += "C3Exp"
elif isinstance(version, str) and version.endswith("_chn"):
method_fixed += "Chn"
hash = PBKDF2(
method_fixed,
salt,
@@ -159,7 +175,8 @@ class ChuniServlet(BaseServlet):
hmac_hash_module=SHA1,
)
hashed_name = hash.hex()[:32] # truncate unused bytes like the game does
# truncate unused bytes like the game does
hashed_name = hash.hex()[:32]
self.hash_table[version][hashed_name] = method_fixed
self.logger.debug(
@@ -181,22 +198,48 @@ class ChuniServlet(BaseServlet):
return True
def get_allnet_info(self, game_code: str, game_ver: int, keychip: str) -> Tuple[str, str]:
if not self.core_cfg.server.is_using_proxy and Utils.get_title_port(self.core_cfg) != 80:
return (f"http://{self.core_cfg.server.hostname}:{Utils.get_title_port(self.core_cfg)}/{game_code}/{game_ver}/", self.core_cfg.server.hostname)
def get_allnet_info(
self, game_code: str, game_ver: int, keychip: str
) -> Tuple[str, str]:
title_port_int = Utils.get_title_port(self.core_cfg)
title_port_ssl_int = Utils.get_title_port_ssl(self.core_cfg)
return (f"http://{self.core_cfg.server.hostname}/{game_code}/{game_ver}/", self.core_cfg.server.hostname)
if self.game_cfg.server.use_https and (
(game_code == "SDBT" and game_ver >= 145) or # JP use TLS from CRYSTAL PLUS
game_code != "SDBT" # SDGS and SDHJ all version can use TLS
):
proto = "https"
else:
proto = "http"
if proto == "https":
t_port = f":{title_port_ssl_int}" if title_port_ssl_int != 443 else ""
else:
t_port = f":{title_port_int}" if title_port_int != 80 else ""
return (
f"{proto}://{self.core_cfg.server.hostname}{t_port}/{game_code}/{game_ver}/",
f"{self.core_cfg.server.hostname}",
)
def get_routes(self) -> List[Route]:
return [
Route("/{game:str}/{version:int}/ChuniServlet/{endpoint:str}", self.render_POST, methods=['POST']),
Route("/{game:str}/{version:int}/ChuniServlet/MatchingServer/{endpoint:str}", self.render_POST, methods=['POST']),
Route(
"/{game:str}/{version:int}/ChuniServlet/{endpoint:str}",
self.render_POST,
methods=["POST"],
),
Route(
"/{game:str}/{version:int}/ChuniServlet/MatchingServer/{endpoint:str}",
self.render_POST,
methods=["POST"],
),
]
async def render_POST(self, request: Request) -> bytes:
endpoint: str = request.path_params.get('endpoint')
version: int = request.path_params.get('version')
game_code: str = request.path_params.get('game')
endpoint: str = request.path_params.get("endpoint")
version: int = request.path_params.get("version")
game_code: str = request.path_params.get("game")
if endpoint.lower() == "ping":
return Response(zlib.compress(b'{"returnCode": "1"}'))
@@ -207,58 +250,79 @@ class ChuniServlet(BaseServlet):
internal_ver = 0
client_ip = Utils.get_ip_addr(request)
if game_code == "SDHD" or game_code == "SDBT": # JP
if version < 105: # 1.0
internal_ver = ChuniConstants.VER_CHUNITHM
elif version >= 105 and version < 110: # PLUS
internal_ver = ChuniConstants.VER_CHUNITHM_PLUS
elif version >= 110 and version < 115: # AIR
internal_ver = ChuniConstants.VER_CHUNITHM_AIR
elif version >= 115 and version < 120: # AIR PLUS
internal_ver = ChuniConstants.VER_CHUNITHM_AIR_PLUS
elif version >= 120 and version < 125: # STAR
internal_ver = ChuniConstants.VER_CHUNITHM_STAR
elif version >= 125 and version < 130: # STAR PLUS
internal_ver = ChuniConstants.VER_CHUNITHM_STAR_PLUS
elif version >= 130 and version < 135: # AMAZON
internal_ver = ChuniConstants.VER_CHUNITHM_AMAZON
elif version >= 135 and version < 140: # AMAZON PLUS
internal_ver = ChuniConstants.VER_CHUNITHM_AMAZON_PLUS
elif version >= 140 and version < 145: # CRYSTAL
internal_ver = ChuniConstants.VER_CHUNITHM_CRYSTAL
elif version >= 145 and version < 150: # CRYSTAL PLUS
internal_ver = ChuniConstants.VER_CHUNITHM_CRYSTAL_PLUS
elif version >= 150 and version < 200: # PARADISE
internal_ver = ChuniConstants.VER_CHUNITHM_PARADISE
elif version >= 200 and version < 205: # NEW!!
internal_ver = ChuniConstants.VER_CHUNITHM_NEW
elif version >= 205 and version < 210: # NEW PLUS!!
internal_ver = ChuniConstants.VER_CHUNITHM_NEW_PLUS
elif version >= 210 and version < 215: # SUN
internal_ver = ChuniConstants.VER_CHUNITHM_SUN
elif version >= 215 and version < 220: # SUN PLUS
internal_ver = ChuniConstants.VER_CHUNITHM_SUN_PLUS
elif version >= 220 and version < 225: # LUMINOUS
internal_ver = ChuniConstants.VER_CHUNITHM_LUMINOUS
elif version >= 225: # LUMINOUS PLUS
internal_ver = ChuniConstants.VER_CHUNITHM_LUMINOUS_PLUS
elif game_code == "SDGS": # Int
if version < 105: # SUPERSTAR
internal_ver = ChuniConstants.VER_CHUNITHM_CRYSTAL_PLUS
elif version >= 105 and version < 110: # SUPERSTAR PLUS *Cursed but needed due to different encryption key
internal_ver = ChuniConstants.VER_CHUNITHM_PARADISE
elif version >= 110 and version < 115: # NEW
internal_ver = ChuniConstants.VER_CHUNITHM_NEW
elif version >= 115 and version < 120: # NEW PLUS!!
internal_ver = ChuniConstants.VER_CHUNITHM_NEW_PLUS
elif version >= 120 and version < 125: # SUN
internal_ver = ChuniConstants.VER_CHUNITHM_SUN
elif version >= 125 and version < 130: # SUN PLUS
internal_ver = ChuniConstants.VER_CHUNITHM_SUN_PLUS
elif version >= 130 and version < 135: # LUMINOUS
internal_ver = ChuniConstants.VER_CHUNITHM_LUMINOUS
elif version >= 135: # LUMINOUS PLUS
internal_ver = ChuniConstants.VER_CHUNITHM_LUMINOUS_PLUS
if game_code == "SDHD" or game_code == "SDBT": # JP
if version < 105: # 1.0
internal_ver = ChuniConstants.VER_CHUNITHM
elif version >= 105 and version < 110: # PLUS
internal_ver = ChuniConstants.VER_CHUNITHM_PLUS
elif version >= 110 and version < 115: # AIR
internal_ver = ChuniConstants.VER_CHUNITHM_AIR
elif version >= 115 and version < 120: # AIR PLUS
internal_ver = ChuniConstants.VER_CHUNITHM_AIR_PLUS
elif version >= 120 and version < 125: # STAR
internal_ver = ChuniConstants.VER_CHUNITHM_STAR
elif version >= 125 and version < 130: # STAR PLUS
internal_ver = ChuniConstants.VER_CHUNITHM_STAR_PLUS
elif version >= 130 and version < 135: # AMAZON
internal_ver = ChuniConstants.VER_CHUNITHM_AMAZON
elif version >= 135 and version < 140: # AMAZON PLUS
internal_ver = ChuniConstants.VER_CHUNITHM_AMAZON_PLUS
elif version >= 140 and version < 145: # CRYSTAL
internal_ver = ChuniConstants.VER_CHUNITHM_CRYSTAL
elif version >= 145 and version < 150: # CRYSTAL PLUS
internal_ver = ChuniConstants.VER_CHUNITHM_CRYSTAL_PLUS
elif version >= 150 and version < 200: # PARADISE
internal_ver = ChuniConstants.VER_CHUNITHM_PARADISE
elif version >= 200 and version < 205: # NEW!!
internal_ver = ChuniConstants.VER_CHUNITHM_NEW
elif version >= 205 and version < 210: # NEW PLUS!!
internal_ver = ChuniConstants.VER_CHUNITHM_NEW_PLUS
elif version >= 210 and version < 215: # SUN
internal_ver = ChuniConstants.VER_CHUNITHM_SUN
elif version >= 215 and version < 220: # SUN PLUS
internal_ver = ChuniConstants.VER_CHUNITHM_SUN_PLUS
elif version >= 220 and version < 225: # LUMINOUS
internal_ver = ChuniConstants.VER_CHUNITHM_LUMINOUS
elif version >= 225 and version < 230: # LUMINOUS PLUS
internal_ver = ChuniConstants.VER_CHUNITHM_LUMINOUS_PLUS
elif version >= 230 and version < 240: # VERSE
internal_ver = ChuniConstants.VER_CHUNITHM_VERSE
elif version >= 240: # X-VERSE
internal_ver = ChuniConstants.VER_CHUNITHM_X_VERSE
elif game_code == "SDGS": # Int
if version < 105: # SUPERSTAR
internal_ver = ChuniConstants.VER_CHUNITHM_CRYSTAL_PLUS
elif (
version >= 105 and version < 110
): # SUPERSTAR PLUS *Cursed but needed due to different encryption key
internal_ver = ChuniConstants.VER_CHUNITHM_PARADISE
elif version >= 110 and version < 115: # NEW
internal_ver = ChuniConstants.VER_CHUNITHM_NEW
elif version >= 115 and version < 120: # NEW PLUS!!
internal_ver = ChuniConstants.VER_CHUNITHM_NEW_PLUS
elif version >= 120 and version < 125: # SUN
internal_ver = ChuniConstants.VER_CHUNITHM_SUN
elif version >= 125 and version < 130: # SUN PLUS
internal_ver = ChuniConstants.VER_CHUNITHM_SUN_PLUS
elif version >= 130 and version < 135: # LUMINOUS
internal_ver = ChuniConstants.VER_CHUNITHM_LUMINOUS
elif version >= 135 and version < 140: # LUMINOUS PLUS
internal_ver = ChuniConstants.VER_CHUNITHM_LUMINOUS_PLUS
elif version >= 140 and version < 150: # VERSE
internal_ver = ChuniConstants.VER_CHUNITHM_VERSE
elif version >= 150: # X-VERSE
internal_ver = ChuniConstants.VER_CHUNITHM_X_VERSE
elif game_code == "SDHJ": # Chn
if version < 110: # NEW
internal_ver = ChuniConstants.VER_CHUNITHM_NEW
elif (
version >= 110 and version < 120
): # NEW *Cursed but needed due to different encryption key
internal_ver = ChuniConstants.VER_CHUNITHM_NEW_PLUS
elif version >= 120 and version < 130: # LUMINOUS
internal_ver = ChuniConstants.VER_CHUNITHM_LUMINOUS
elif version >= 130: # VERSE
internal_ver = ChuniConstants.VER_CHUNITHM_VERSE
if all(c in string.hexdigits for c in endpoint) and len(endpoint) == 32:
# If we get a 32 character long hex string, it's a hash and we're
@@ -268,6 +332,9 @@ class ChuniServlet(BaseServlet):
if game_code == "SDGS":
crypto_cfg_key = f"{internal_ver}_int"
hash_table_key = f"{internal_ver}_int"
elif game_code == "SDHJ":
crypto_cfg_key = f"{internal_ver}_chn"
hash_table_key = f"{internal_ver}_chn"
else:
crypto_cfg_key = internal_ver
hash_table_key = internal_ver
@@ -337,14 +404,16 @@ class ChuniServlet(BaseServlet):
endpoint = endpoint.replace("C3Exp", "")
elif game_code == "SDGS" and version < 110:
endpoint = endpoint.replace("Exp", "")
elif game_code == "SDHJ":
endpoint = endpoint.replace("Chn", "")
else:
endpoint = endpoint
func_to_find = "handle_" + inflection.underscore(endpoint) + "_request"
func_to_find = "handle_" + self.strict_underscore(endpoint) + "_request"
handler_cls = self.versions[internal_ver](self.core_cfg, self.game_cfg)
if not hasattr(handler_cls, func_to_find):
self.logger.warning(f"Unhandled v{version} request {endpoint}")
self.logger.warning(f"Unhandled v{version} request {func_to_find}")
resp = {"returnCode": 1}
else:
@@ -378,3 +447,9 @@ class ChuniServlet(BaseServlet):
)
return Response(crypt.encrypt(padded))
def strict_underscore(self, name: str) -> str:
# Insert underscores between *all* capital letters
name = re.sub(r"([A-Z])([A-Z])", r"\1_\2", name)
return inflection.underscore(name)
+86 -125
View File
@@ -1,6 +1,8 @@
from datetime import timedelta
from typing import Dict
from sqlalchemy.engine import Row
from core.config import CoreConfig
from titles.chuni.config import ChuniConfig
from titles.chuni.const import (
@@ -11,13 +13,80 @@ from titles.chuni.const import (
from titles.chuni.sunplus import ChuniSunPlus
class MysticAreaConditions:
"""The "Mystic Rainbow of <VERSION>" map is a special reward map for obtaining
rainbow statues. There's one gold statue area that's unlocked when at least one
original map is finished, and additional rainbow statue areas are added as new
original maps are added.
"""
def __init__(
self, events_by_id: dict[int, Row], map_area_1_id: int, date_time_format: str
):
self.events_by_id = events_by_id
self.date_time_format = date_time_format
self._map_area_1_conditions = {
"mapAreaId": map_area_1_id,
"length": 0,
"mapAreaConditionList": [],
}
self._map_area_1_added = False
self._conditions = []
@property
def conditions(self):
return self._conditions
def add_condition(
self, map_flag_event_id: int, condition_map_id: int, mystic_map_area_id: int
):
if (event := self.events_by_id.get(map_flag_event_id)) is None:
return
start_date = event["startDate"].strftime(self.date_time_format)
self._map_area_1_conditions["mapAreaConditionList"].append(
{
"type": MapAreaConditionType.MAP_CLEARED.value,
"conditionId": condition_map_id,
"logicalOpe": MapAreaConditionLogicalOperator.OR.value,
"startDate": start_date,
"endDate": "2099-12-31 00:00:00",
}
)
self._map_area_1_conditions["length"] = len(
self._map_area_1_conditions["mapAreaConditionList"]
)
if not self._map_area_1_added:
self._conditions.append(self._map_area_1_conditions)
self._map_area_1_added = True
self._conditions.append(
{
"mapAreaId": mystic_map_area_id,
"length": 1,
"mapAreaConditionList": [
{
"type": MapAreaConditionType.MAP_CLEARED.value,
"conditionId": condition_map_id,
"logicalOpe": MapAreaConditionLogicalOperator.AND.value,
"startDate": start_date,
"endDate": "2099-12-31 00:00:00",
}
],
}
)
class ChuniLuminous(ChuniSunPlus):
def __init__(self, core_cfg: CoreConfig, game_cfg: ChuniConfig) -> None:
super().__init__(core_cfg, game_cfg)
self.version = ChuniConstants.VER_CHUNITHM_LUMINOUS
async def handle_cm_get_user_preview_api_request(self, data: Dict) -> Dict:
user_data = await super().handle_cm_get_user_preview_api_request(data)
async def handle_c_m_get_user_preview_api_request(self, data: Dict) -> Dict:
user_data = await super().handle_c_m_get_user_preview_api_request(data)
# Does CARD MAKER 1.35 work this far up?
user_data["lastDataVersion"] = "2.20.00"
@@ -77,18 +146,18 @@ class ChuniLuminous(ChuniSunPlus):
async def handle_get_game_map_area_condition_api_request(self, data: Dict) -> Dict:
# There is no game data for this, everything is server side.
# However, we can selectively show/hide events as data is imported into the server.
events = await self.data.static.get_enabled_events(self.version)
events = await self.data.static.get_enabled_events(self.version) or []
event_by_id = {evt["eventId"]: evt for evt in events}
conditions = []
# The Mystic Rainbow of LUMINOUS map unlocks when any mainline LUMINOUS area
# (ep. I, ep. II, ep. III) are completed.
mystic_area_1_conditions = {
"mapAreaId": 3229301, # Mystic Rainbow of LUMINOUS Area 1
"length": 0,
"mapAreaConditionList": [],
}
mystic_area_1_added = False
mystic_conditions = MysticAreaConditions(
event_by_id, 3229301, self.date_time_format
)
mystic_conditions.add_condition(14005, 3020701, 3229302)
mystic_conditions.add_condition(14251, 3020702, 3229303)
mystic_conditions.add_condition(14481, 3020703, 3229304)
conditions += mystic_conditions.conditions
# Secret AREA: MUSIC GAME
if 14029 in event_by_id:
@@ -229,114 +298,6 @@ class ChuniLuminous(ChuniSunPlus):
]
)
# LUMINOUS ep. I
if 14005 in event_by_id:
start_date = event_by_id[14005]["startDate"].strftime(self.date_time_format)
if not mystic_area_1_added:
conditions.append(mystic_area_1_conditions)
mystic_area_1_added = True
mystic_area_1_conditions["length"] += 1
mystic_area_1_conditions["mapAreaConditionList"].append(
{
"type": MapAreaConditionType.MAP_CLEARED.value,
"conditionId": 3020701,
"logicalOpe": MapAreaConditionLogicalOperator.OR.value,
"startDate": start_date,
"endDate": "2099-12-31 00:00:00.0",
}
)
conditions.append(
{
"mapAreaId": 3229302, # Mystic Rainbow of LUMINOUS Area 2,
"length": 1,
# Unlocks when LUMINOUS ep. I is completed.
"mapAreaConditionList": [
{
"type": MapAreaConditionType.MAP_CLEARED.value,
"conditionId": 3020701,
"logicalOpe": MapAreaConditionLogicalOperator.AND.value,
"startDate": start_date,
"endDate": "2099-12-31 00:00:00.0",
},
],
}
)
# LUMINOUS ep. II
if 14251 in event_by_id:
start_date = event_by_id[14251]["startDate"].strftime(self.date_time_format)
if not mystic_area_1_added:
conditions.append(mystic_area_1_conditions)
mystic_area_1_added = True
mystic_area_1_conditions["length"] += 1
mystic_area_1_conditions["mapAreaConditionList"].append(
{
"type": MapAreaConditionType.MAP_CLEARED.value,
"conditionId": 3020702,
"logicalOpe": MapAreaConditionLogicalOperator.OR.value,
"startDate": start_date,
"endDate": "2099-12-31 00:00:00.0",
}
)
conditions.append(
{
"mapAreaId": 3229303, # Mystic Rainbow of LUMINOUS Area 3,
"length": 1,
# Unlocks when LUMINOUS ep. II is completed.
"mapAreaConditionList": [
{
"type": MapAreaConditionType.MAP_CLEARED.value,
"conditionId": 3020702,
"logicalOpe": MapAreaConditionLogicalOperator.AND.value,
"startDate": start_date,
"endDate": "2099-12-31 00:00:00.0",
},
],
}
)
# LUMINOUS ep. III
if 14481 in event_by_id:
start_date = event_by_id[14481]["startDate"].strftime(self.date_time_format)
if not mystic_area_1_added:
conditions.append(mystic_area_1_conditions)
mystic_area_1_added = True
mystic_area_1_conditions["length"] += 1
mystic_area_1_conditions["mapAreaConditionList"].append(
{
"type": MapAreaConditionType.MAP_CLEARED.value,
"conditionId": 3020703,
"logicalOpe": MapAreaConditionLogicalOperator.OR.value,
"startDate": start_date,
"endDate": "2099-12-31 00:00:00.0",
}
)
conditions.append(
{
"mapAreaId": 3229304, # Mystic Rainbow of LUMINOUS Area 4,
"length": 1,
# Unlocks when LUMINOUS ep. III is completed.
"mapAreaConditionList": [
{
"type": MapAreaConditionType.MAP_CLEARED.value,
"conditionId": 3020703,
"logicalOpe": MapAreaConditionLogicalOperator.AND.value,
"startDate": start_date,
"endDate": "2099-12-31 00:00:00.0",
},
],
}
)
# 1UM1N0U5 ep. 111
if 14483 in event_by_id:
start_date = event_by_id[14483]["startDate"].replace(
@@ -381,14 +342,14 @@ class ChuniLuminous(ChuniSunPlus):
MapAreaConditionType.RANK_SSP.value,
MapAreaConditionType.RANK_SP.value,
MapAreaConditionType.RANK_S.value,
MapAreaConditionType.ALWAYS_UNLOCKED.value,
MapAreaConditionType.INVALID.value,
]
):
start = (start_date + timedelta(days=14 * (i + 1))).strftime(
self.date_time_format
)
if typ != MapAreaConditionType.ALWAYS_UNLOCKED.value:
if typ != MapAreaConditionType.INVALID.value:
end = (
start_date + timedelta(days=14 * (i + 2)) - timedelta(seconds=1)
).strftime(self.date_time_format)
@@ -407,7 +368,7 @@ class ChuniLuminous(ChuniSunPlus):
)
else:
end = "2099-12-31 00:00:00"
title_conditions.append(
{
"type": typ,
@@ -431,7 +392,7 @@ class ChuniLuminous(ChuniSunPlus):
# Ultimate Force
# For the first 14 days, the condition is to obtain all 9 "Key of ..." titles
# Afterwards, the condition is the 6 "Key of ..." titles that you can obtain
# by playing the 6 areas, as well as obtaining specific ranks on
# by playing the 6 areas, as well as obtaining specific ranks on
# [CRYSTAL_ACCESS] / Strange Love / βlαnoir
ultimate_force_conditions = []
@@ -473,7 +434,7 @@ class ChuniLuminous(ChuniSunPlus):
start = (start_date + timedelta(days=14 * (i + 1))).strftime(
self.date_time_format
)
end = (
start_date + timedelta(days=14 * (i + 2)) - timedelta(seconds=1)
).strftime(self.date_time_format)
@@ -487,7 +448,7 @@ class ChuniLuminous(ChuniSunPlus):
"startDate": start,
"endDate": end,
}
for condition_id in {109403, 212103, 244203}
for condition_id in {109403, 212103, 244203}
]
)
+155 -3
View File
@@ -4,7 +4,7 @@ from typing import Dict
from core.config import CoreConfig
from titles.chuni.config import ChuniConfig
from titles.chuni.const import ChuniConstants, MapAreaConditionLogicalOperator, MapAreaConditionType
from titles.chuni.luminous import ChuniLuminous
from titles.chuni.luminous import ChuniLuminous, MysticAreaConditions
class ChuniLuminousPlus(ChuniLuminous):
@@ -12,8 +12,8 @@ class ChuniLuminousPlus(ChuniLuminous):
super().__init__(core_cfg, game_cfg)
self.version = ChuniConstants.VER_CHUNITHM_LUMINOUS_PLUS
async def handle_cm_get_user_preview_api_request(self, data: Dict) -> Dict:
user_data = await super().handle_cm_get_user_preview_api_request(data)
async def handle_c_m_get_user_preview_api_request(self, data: Dict) -> Dict:
user_data = await super().handle_c_m_get_user_preview_api_request(data)
# Does CARD MAKER 1.35 work this far up?
user_data["lastDataVersion"] = "2.25.00"
@@ -66,6 +66,158 @@ class ChuniLuminousPlus(ChuniLuminous):
events = await self.data.static.get_enabled_events(self.version)
event_by_id = {evt["eventId"]: evt for evt in events}
conditions = []
mystic_conditions = MysticAreaConditions(
event_by_id,
3229601,
self.date_time_format,
)
# Mystic Rainbow of LUMINOUS PLUS - LUMINOUS ep. IV
mystic_conditions.add_condition(15005, 3020704, 3229602)
# Mystic Rainbow of LUMINOUS PLUS - LUMINOUS ep. V
mystic_conditions.add_condition(15306, 3020705, 3229603)
# Mystic Rainbow of LUMINOUS PLUS - LUMINOUS ep. VI
mystic_conditions.add_condition(15451, 3020706, 3229604)
# Mystic Rainbow of LUMINOUS PLUS - LUMINOUS ep. VII
mystic_conditions.add_condition(15506, 3020707, 3229605)
conditions += mystic_conditions.conditions
# 1UM1N0U5 ep. 111 continues. The map is automatically unlocked after finishing
# LUMINOUS ep. III in LUMINOUS PLUS.
if ep_111 := event_by_id.get(15009):
start_date = ep_111["startDate"].strftime(self.date_time_format)
conditions.append({
"mapAreaId": 3229207,
"length": 1,
"mapAreaConditionList": [
{
"type": MapAreaConditionType.MAP_CLEARED.value,
"conditionId": 3020703,
"logicalOpe": MapAreaConditionLogicalOperator.AND.value,
"startDate": start_date,
"endDate": "2099-12-31 00:00:00",
},
],
})
# ■・■■■■■■・■
# Finish LUMINOUS ep. IV and obtain the title 「ここは…何処なんだ…?」.
if re_fiction_o := event_by_id.get(15032):
start_date = re_fiction_o["startDate"].strftime(self.date_time_format)
conditions.append({
"mapAreaId": 3229501,
"length": 2,
"mapAreaConditionList": [
{
"type": MapAreaConditionType.MAP_CLEARED.value,
"conditionId": 3020704,
"logicalOpe": MapAreaConditionLogicalOperator.AND.value,
"startDate": start_date,
"endDate": "2099-12-31 00:00:00",
},
{
"type": MapAreaConditionType.TROPHY_OBTAINED.value,
"conditionId": 7105,
"logicalOpe": MapAreaConditionLogicalOperator.AND.value,
"startDate": start_date,
"endDate": "2099-12-31 00:00:00",
}
]
})
# The Conductor's Path
# ALL JUSTICE CRITICAL 其のエメラルドを見よ MASTER.
if the_conductors_path := event_by_id.get(15033):
start_date = the_conductors_path["startDate"].strftime(self.date_time_format)
conditions.append({
"mapAreaId": 3229701,
"length": 1,
"mapAreaConditionList": [
{
"type": MapAreaConditionType.ALL_JUSTICE_CRITICAL.value,
"conditionId": 260003,
"logicalOpe": MapAreaConditionLogicalOperator.AND.value,
"startDate": start_date,
"endDate": "2099-12-31 00:00:00",
}
]
})
# Cave of RVESE
if episode__x__ := event_by_id.get(15254):
start_date = episode__x__["startDate"].strftime(self.date_time_format)
conditions.extend([
# Episode. _ _ X _ _ map area 1
# Finish the HARDCORE TANO*C collaboration map.
{
"mapAreaId": 2208801,
"length": 1,
"mapAreaConditionList": [
{
"type": MapAreaConditionType.MAP_CLEARED.value,
"conditionId": 2006533,
"logicalOpe": MapAreaConditionLogicalOperator.AND.value,
"startDate": start_date,
"endDate": "2099-12-31 00:00:00",
},
],
},
# Episode. _ _ X _ _ map area 2
# Equip the title 「第壱の石版【V】」 to access the map area.
{
"mapAreaId": 2208802,
"length": 1,
"mapAreaConditionList": [
{
"type": MapAreaConditionType.TROPHY_EQUIPPED.value,
"conditionId": 7107,
"logicalOpe": MapAreaConditionLogicalOperator.AND.value,
"startDate": start_date,
"endDate": "2099-12-31 00:00:00",
},
],
},
# Episode. _ _ X _ _ map area 3
# Equip the title 「第弐の石版【Λ】」 to access the map area.
{
"mapAreaId": 2208803,
"length": 1,
"mapAreaConditionList": [
{
"type": MapAreaConditionType.TROPHY_EQUIPPED.value,
"conditionId": 7104,
"logicalOpe": MapAreaConditionLogicalOperator.AND.value,
"startDate": start_date,
"endDate": "2099-12-31 00:00:00",
},
],
},
# Episode. _ _ X _ _ map area 4
# Complete the 3 other map areas.
{
"mapAreaId": 2208804,
"length": 3,
"mapAreaConditionList": [
{
"type": MapAreaConditionType.MAP_AREA_CLEARED.value,
"conditionId": area_id,
"logicalOpe": MapAreaConditionLogicalOperator.AND.value,
"startDate": start_date,
"endDate": "2099-12-31 00:00:00",
}
for area_id in range(2208801, 2208804)
],
},
])
# LUMINOUS ep. Ascension
if ep_ascension := event_by_id.get(15512):
+18 -14
View File
@@ -28,16 +28,20 @@ class ChuniNew(ChuniBase):
def _interal_ver_to_intver(self) -> str:
if self.version == ChuniConstants.VER_CHUNITHM_NEW:
return "200"
if self.version == ChuniConstants.VER_CHUNITHM_NEW_PLUS:
elif self.version == ChuniConstants.VER_CHUNITHM_NEW_PLUS:
return "205"
if self.version == ChuniConstants.VER_CHUNITHM_SUN:
elif self.version == ChuniConstants.VER_CHUNITHM_SUN:
return "210"
if self.version == ChuniConstants.VER_CHUNITHM_SUN_PLUS:
elif self.version == ChuniConstants.VER_CHUNITHM_SUN_PLUS:
return "215"
if self.version == ChuniConstants.VER_CHUNITHM_LUMINOUS:
elif self.version == ChuniConstants.VER_CHUNITHM_LUMINOUS:
return "220"
if self.version == ChuniConstants.VER_CHUNITHM_LUMINOUS_PLUS:
elif self.version == ChuniConstants.VER_CHUNITHM_LUMINOUS_PLUS:
return "225"
elif self.version == ChuniConstants.VER_CHUNITHM_VERSE:
return "230"
elif self.version == ChuniConstants.VER_CHUNITHM_X_VERSE:
return "240"
async def handle_get_game_setting_api_request(self, data: Dict) -> Dict:
# use UTC time and convert it to JST time by adding +9
@@ -171,7 +175,7 @@ class ChuniNew(ChuniBase):
}
return data1
async def handle_cm_get_user_preview_api_request(self, data: Dict) -> Dict:
async def handle_c_m_get_user_preview_api_request(self, data: Dict) -> Dict:
p = await self.data.profile.get_profile_data(data["userId"], self.version)
if p is None:
return {}
@@ -244,7 +248,7 @@ class ChuniNew(ChuniBase):
"ssrBookCalcList": [],
}
async def handle_cm_get_user_data_api_request(self, data: Dict) -> Dict:
async def handle_c_m_get_user_data_api_request(self, data: Dict) -> Dict:
p = await self.data.profile.get_profile_data(data["userId"], self.version)
if p is None:
return {}
@@ -347,10 +351,10 @@ class ChuniNew(ChuniBase):
"userCardPrintStateList": card_print_state_list,
}
async def handle_cm_get_user_character_api_request(self, data: Dict) -> Dict:
async def handle_c_m_get_user_character_api_request(self, data: Dict) -> Dict:
return await super().handle_get_user_character_api_request(data)
async def handle_cm_get_user_item_api_request(self, data: Dict) -> Dict:
async def handle_c_m_get_user_item_api_request(self, data: Dict) -> Dict:
return await super().handle_get_user_item_api_request(data)
async def handle_roll_gacha_api_request(self, data: Dict) -> Dict:
@@ -395,7 +399,7 @@ class ChuniNew(ChuniBase):
return {"length": len(rolled_cards), "gameGachaCardList": rolled_cards}
async def handle_cm_upsert_user_gacha_api_request(self, data: Dict) -> Dict:
async def handle_c_m_upsert_user_gacha_api_request(self, data: Dict) -> Dict:
upsert = data["cmUpsertUserGacha"]
user_id = data["userId"]
place_id = data["placeId"]
@@ -450,7 +454,7 @@ class ChuniNew(ChuniBase):
"userCardPrintStateList": card_print_state_list,
}
async def handle_cm_upsert_user_printlog_api_request(self, data: Dict) -> Dict:
async def handle_c_m_upsert_user_printlog_api_request(self, data: Dict) -> Dict:
return {
"returnCode": 1,
"orderId": 0,
@@ -458,7 +462,7 @@ class ChuniNew(ChuniBase):
"apiName": "CMUpsertUserPrintlogApi",
}
async def handle_cm_upsert_user_print_api_request(self, data: Dict) -> Dict:
async def handle_c_m_upsert_user_print_api_request(self, data: Dict) -> Dict:
user_print_detail = data["userPrintDetail"]
user_id = data["userId"]
@@ -483,7 +487,7 @@ class ChuniNew(ChuniBase):
"apiName": "CMUpsertUserPrintApi",
}
async def handle_cm_upsert_user_print_subtract_api_request(self, data: Dict) -> Dict:
async def handle_c_m_upsert_user_print_subtract_api_request(self, data: Dict) -> Dict:
upsert = data["userCardPrintState"]
user_id = data["userId"]
place_id = data["placeId"]
@@ -500,7 +504,7 @@ class ChuniNew(ChuniBase):
return {"returnCode": "1", "apiName": "CMUpsertUserPrintSubtractApi"}
async def handle_cm_upsert_user_print_cancel_api_request(self, data: Dict) -> Dict:
async def handle_c_m_upsert_user_print_cancel_api_request(self, data: Dict) -> Dict:
order_ids = data["orderIdList"]
user_id = data["userId"]
+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"
+127 -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,10 @@ class ChuniReader(BaseReader):
await self.read_character(f"{dir}/chara", dds_images, this_opt_id)
await self.read_map_icon(f"{dir}/mapIcon", this_opt_id)
await self.read_system_voice(f"{dir}/systemVoice", this_opt_id)
await self.read_unlock_challenge(f"{dir}/unlockChallenge")
await self.read_linked_verse(f"{dir}/linkedVerse")
if self.version >= ChuniConstants.VER_CHUNITHM_X_VERSE:
await self.read_stage(f"{dir}/stage", this_opt_id)
async def read_login_bonus(self, root_dir: str, opt_id: Optional[int] = None) -> None:
for root, dirs, files in walk(f"{root_dir}loginBonusPreset"):
@@ -499,18 +508,132 @@ class ChuniReader(BaseReader):
self.logger.info(f"Opt folder {opt_folder} (Database ID {opt_id}) contains {data_config['Version']['Name']} v{data_config['Version']['VerMajor']}.{data_config['Version']['VerMinor']}.{opt_seq}")
return opt_id
async def read_unlock_challenge(self, uc_dir: str) -> None:
for root, dirs, files in walk(uc_dir):
for dir in dirs:
if path.exists(f"{root}/{dir}/UnlockChallenge.xml"):
with open(f"{root}/{dir}/UnlockChallenge.xml", "r", encoding="utf-8") as fp:
strdata = fp.read()
xml_root = ET.fromstring(strdata)
for name in xml_root.findall("name"):
id = name.find("id").text
name = name.find("str").text
course_ids = []
for course in xml_root.find("musicList/list/UnlockChallengeMusicListSubData/unlockChallengeMusicData/courseList/list").findall("UnlockChallengeCourseListSubData"):
course_id = course.find("unlockChallengeCourseData/courseName").find("id").text
course_ids.append(course_id)
# Build keyword arguments dynamically for up to 5 course IDs
course_kwargs = {
f"course_id{i+1}": course_ids[i]
for i in range(min(5, len(course_ids)))
}
result = await self.data.static.put_unlock_challenge(
self.version, id, name,
**course_kwargs
)
if result is not None:
self.logger.info(f"Inserted unlock challenge {id}")
else:
self.logger.warning(f"Failed to unlock challenge {id}")
async def read_linked_verse(self, lv_dir: str) -> None:
for root, dirs, files in walk(lv_dir):
for dir in dirs:
if path.exists(f"{root}/{dir}/LinkedVerse.xml"):
with open(f"{root}/{dir}/LinkedVerse.xml", "r", encoding="utf-8") as fp:
strdata = fp.read()
xml_root = ET.fromstring(strdata)
for name in xml_root.findall("name"):
id = name.find("id").text
name = name.find("str").text
course_ids = []
for course in xml_root.find("musicList/list/LinkedVerseMusicListSubData/linkedVerseMusicData/courseList/list").findall("LinkedVerseCourseListSubData"):
course_id = course.find("linkedVerseCourseData/courseName").find("id").text
course_ids.append(course_id)
# Build keyword arguments dynamically for up to 5 course IDs
course_kwargs = {
f"course_id{i+1}": course_ids[i]
for i in range(min(5, len(course_ids)))
}
result = await self.data.static.put_linked_verse(
self.version, id, name,
**course_kwargs
)
if result is not None:
self.logger.info(f"Inserted Linked VERSE {id}")
else:
self.logger.warning(f"Failed to Linked VERSE {id}")
async def read_stage(self, stage_dir: str, opt_id: Optional[int] = None) -> None:
for root, dirs, files in walk(stage_dir):
for dir in dirs:
if path.exists(f"{root}/{dir}/Stage.xml"):
with open(f"{root}/{dir}/Stage.xml", "r", encoding='utf-8') as fp:
strdata = fp.read()
xml_root = ET.fromstring(strdata)
for name in xml_root.findall("name"):
id = name.find("id").text
name = name.find("str").text
for image in xml_root.findall("image"):
image_path = image.find("path").text
self.copy_image(image_path, f"{root}/{dir}", "titles/chuni/img/stage/")
default_have = xml_root.find("defaultHave").text == 'true'
disable_flag = xml_root.find("disableFlag") # may not exist in older data
is_enabled = True if (disable_flag is None or disable_flag.text == "false") else False
result = await self.data.static.put_stage(
self.version, id, name, image_path, is_enabled, default_have, opt_id
)
if result is not None:
self.logger.info(f"Inserted stage {id}")
else:
self.logger.warning(f"Failed to insert stage {id}")
def copy_image(self, filename: str, src_dir: str, dst_dir: str) -> None:
# Convert the image to png so we can easily display it in the frontend
# Convert the image to webp so we can easily display it in the frontend
file_src = path.join(src_dir, filename)
(basename, ext) = path.splitext(filename)
file_dst = path.join(dst_dir, basename) + ".png"
file_dst = path.join(dst_dir, basename) + ".webp"
if path.exists(file_src) and not path.exists(file_dst):
try:
im = Image.open(file_src)
im.save(file_dst)
except Exception:
self.logger.warning(f"Failed to convert {filename} to png")
self.logger.warning(f"Failed to convert {filename} to webp")
def ConvertOldAssets(logger: Logger):
"""
Converts any previously-imported png files to webp.
In the initial version of the userbox/avatar frontend support, png images were used, scraped via read.py.
The amount of data pushed once a lot of stuff was unlocked was noticeable so the frontend now uses webp format
for these assets. If any png files are present, convert them to webp now.
"""
# Find all pngs under the /img directory
png_files = glob.glob(f'titles/chuni/img/**/*.png', recursive=True)
if len(png_files) > 0:
logger.info(f'Found {len(png_files)} old assets. Converting to webp... (may take a few minutes)')
for img_png in png_files:
img_webp = path.splitext(img_png)[0] + '.webp'
try:
# convert to webp
im = Image.open(img_png)
im.save(img_webp)
# delete the original file
remove(img_png)
except Exception as e:
logger.warning(f'Failed to convert {img_png} to webp')
logger.info(f'Conversion complete')
def map_dds_images(self, image_dict: dict, dds_dir: str) -> None:
for root, dirs, files in walk(dds_dir):
+161 -39
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,66 @@ cmission_progress = Table(
mysql_charset="utf8mb4",
)
unlock_challenge = Table(
"chuni_item_unlock_challenge",
metadata,
Column("id", Integer, primary_key=True, nullable=False),
Column("version", Integer, nullable=False),
Column(
"user",
Integer,
ForeignKey("aime_user.id", ondelete="cascade", onupdate="cascade"),
nullable=False,
),
Column("unlockChallengeId", Integer, nullable=False),
Column("status", Integer),
Column("clearCourseId", Integer),
Column("conditionType", Integer),
Column("score", Integer),
Column("life", Integer),
Column("clearDate", TIMESTAMP, server_default=func.now()),
UniqueConstraint(
"version", "user", "unlockChallengeId", name="chuni_item_unlock_challenge_uk"
),
mysql_charset="utf8mb4",
)
linked_verse: Table = Table(
"chuni_item_linked_verse",
metadata,
Column("id", Integer, primary_key=True, nullable=False),
Column(
"user",
Integer,
ForeignKey("aime_user.id", ondelete="cascade", onupdate="cascade"),
nullable=False,
),
Column("linkedVerseId", Integer, nullable=False),
Column("progress", String(255)),
Column("statusOpen", Integer),
Column("statusUnlock", Integer),
Column("isFirstClear", Integer),
Column("numClear", Integer),
Column("clearCourseId", Integer),
Column("clearCourseLevel", Integer),
Column("clearScore", Integer),
Column("clearDate", String(25)),
Column("clearUserId1", Integer),
Column("clearUserId2", Integer),
Column("clearUserId3", Integer),
Column("clearUserName0", String(20)),
Column("clearUserName1", String(20)),
Column("clearUserName2", String(20)),
Column("clearUserName3", String(20)),
UniqueConstraint("user", "linkedVerseId", name="chuni_item_linked_verse_uk"),
mysql_charset="utf8mb4",
)
class ChuniItemData(BaseData):
async def get_oldest_free_matching(self, version: int) -> Optional[Row]:
sql = matching.select(
and_(
matching.c.version == version,
matching.c.isFull == False
)
and_(matching.c.version == version, matching.c.isFull == False)
).order_by(matching.c.roomId.asc())
result = await self.execute(sql)
@@ -289,11 +358,9 @@ class ChuniItemData(BaseData):
return result.fetchone()
async def get_newest_matching(self, version: int) -> Optional[Row]:
sql = matching.select(
and_(
matching.c.version == version
)
).order_by(matching.c.roomId.desc())
sql = matching.select(and_(matching.c.version == version)).order_by(
matching.c.roomId.desc()
)
result = await self.execute(sql)
if result is None:
@@ -301,11 +368,7 @@ class ChuniItemData(BaseData):
return result.fetchone()
async def get_all_matchings(self, version: int) -> Optional[List[Row]]:
sql = matching.select(
and_(
matching.c.version == version
)
)
sql = matching.select(and_(matching.c.version == version))
result = await self.execute(sql)
if result is None:
@@ -329,7 +392,7 @@ class ChuniItemData(BaseData):
matching_member_info_list: List,
user_id: int = None,
rest_sec: int = 60,
is_full: bool = False
is_full: bool = False,
) -> Optional[int]:
sql = insert(matching).values(
roomId=room_id,
@@ -362,7 +425,6 @@ class ChuniItemData(BaseData):
async def is_favorite(
self, user_id: int, version: int, fav_id: int, fav_kind: int = 1
) -> bool:
sql = favorite.select(
and_(
favorite.c.version == version,
@@ -452,23 +514,31 @@ class ChuniItemData(BaseData):
return None
return result.fetchone()
async def put_favorite_music(self, user_id: int, version: int, music_id: int) -> Optional[int]:
sql = insert(favorite).values(user=user_id, version=version, favId=music_id, favKind=1)
async def put_favorite_music(
self, user_id: int, version: int, music_id: int
) -> Optional[int]:
sql = insert(favorite).values(
user=user_id, version=version, favId=music_id, favKind=1
)
conflict = sql.on_duplicate_key_update(user=user_id, version=version, favId=music_id, favKind=1)
conflict = sql.on_duplicate_key_update(
user=user_id, version=version, favId=music_id, favKind=1
)
result = await self.execute(conflict)
if result is None:
return None
return result.lastrowid
async def delete_favorite_music(self, user_id: int, version: int, music_id: int) -> Optional[int]:
async def delete_favorite_music(
self, user_id: int, version: int, music_id: int
) -> Optional[int]:
sql = delete(favorite).where(
and_(
favorite.c.user==user_id,
favorite.c.version==version,
favorite.c.favId==music_id,
favorite.c.favKind==1
favorite.c.user == user_id,
favorite.c.version == version,
favorite.c.favId == music_id,
favorite.c.favKind == 1,
)
)
@@ -611,8 +681,12 @@ class ChuniItemData(BaseData):
return None
return result.lastrowid
async def get_map_areas(self, user_id: int, map_area_ids: List[int]) -> Optional[List[Row]]:
sql = select(map_area).where(map_area.c.user == user_id, map_area.c.mapAreaId.in_(map_area_ids))
async def get_map_areas(
self, user_id: int, map_area_ids: List[int]
) -> Optional[List[Row]]:
sql = select(map_area).where(
map_area.c.user == user_id, map_area.c.mapAreaId.in_(map_area_ids)
)
result = await self.execute(sql)
if result is None:
@@ -713,7 +787,7 @@ class ChuniItemData(BaseData):
)
return None
return result.lastrowid
async def put_cmission_progress(
self, user_id: int, mission_id: int, progress_data: Dict
) -> Optional[int]:
@@ -723,10 +797,10 @@ class ChuniItemData(BaseData):
sql = insert(cmission_progress).values(**progress_data)
conflict = sql.on_duplicate_key_update(**progress_data)
result = await self.execute(conflict)
if result is None:
return None
return result.lastrowid
async def get_cmission_progress(
@@ -739,21 +813,21 @@ class ChuniItemData(BaseData):
)
).order_by(cmission_progress.c.order.asc())
result = await self.execute(sql)
if result is None:
return None
return result.fetchall()
async def get_cmission(self, user_id: int, mission_id: int) -> Optional[Row]:
sql = cmission.select(
and_(cmission.c.user == user_id, cmission.c.missionId == mission_id)
)
result = await self.execute(sql)
if result is None:
return None
return result.fetchone()
async def put_cmission(self, user_id: int, mission_data: Dict) -> Optional[int]:
@@ -762,17 +836,65 @@ class ChuniItemData(BaseData):
sql = insert(cmission).values(**mission_data)
conflict = sql.on_duplicate_key_update(**mission_data)
result = await self.execute(conflict)
if result is None:
return None
return result.lastrowid
async def get_cmissions(self, user_id: int) -> Optional[List[Row]]:
sql = cmission.select(cmission.c.user == user_id)
result = await self.execute(sql)
if result is None:
return None
return result.fetchall()
async def put_unlock_challenge(
self, user_id: int, version: int, unlock_challenge_data: Dict
) -> Optional[int]:
unlock_challenge_data["user"] = user_id
unlock_challenge_data["version"] = version
sql = insert(unlock_challenge).values(**unlock_challenge_data)
conflict = sql.on_duplicate_key_update(**unlock_challenge_data)
result = await self.execute(conflict)
if result is None:
return None
return result.lastrowid
async def get_unlock_challenges(
self, user_id: int, version: int
) -> Optional[List[Row]]:
sql = unlock_challenge.select(
and_(
unlock_challenge.c.user == user_id,
unlock_challenge.c.version == version,
)
)
result = await self.execute(sql)
if result is None:
return None
return result.fetchall()
async def get_linked_verse(self, aime_id: int) -> Optional[List[Row]]:
result = await self.execute(
linked_verse.select().where(linked_verse.c.user == aime_id)
)
if result:
return result.fetchall()
async def put_linked_verse(self, aime_id: int, linked_verse_data: Dict):
linked_verse_data = self.fix_bools(linked_verse_data)
sql = insert(linked_verse).values(user=aime_id, **linked_verse_data)
conflict = sql.on_duplicate_key_update(**linked_verse_data)
result = await self.execute(conflict)
if result:
return result.inserted_primary_key["id"]
self.logger.error("Failed to put Linked Verse data for user %s", aime_id)
+31 -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, server_default="-1"),
Column("trophyIdSub2", Integer, server_default="-1"),
Column("userName", String(25)),
Column("isWebJoin", Boolean),
Column("playCount", Integer),
@@ -129,6 +132,9 @@ profile = Table(
Column("avatarFront", Integer, server_default="0"),
Column("avatarSkin", Integer, server_default="0"),
Column("avatarHead", Integer, server_default="0"),
Column(
"stageId", Integer, server_default="99999", nullable=False
), # 99999 is the pseudo stage ID for unset stage
UniqueConstraint("user", "version", name="chuni_profile_profile_uk"),
mysql_charset="utf8mb4",
)
@@ -139,6 +145,7 @@ profile_ex = Table(
Column("id", Integer, primary_key=True, nullable=False),
Column(
"user",
Integer,
ForeignKey("aime_user.id", ondelete="cascade", onupdate="cascade"),
nullable=False,
),
@@ -187,6 +194,7 @@ option = Table(
Column("id", Integer, primary_key=True, nullable=False),
Column(
"user",
Integer,
ForeignKey("aime_user.id", ondelete="cascade", onupdate="cascade"),
nullable=False,
),
@@ -250,6 +258,7 @@ option_ex = Table(
Column("id", Integer, primary_key=True, nullable=False),
Column(
"user",
Integer,
ForeignKey("aime_user.id", ondelete="cascade", onupdate="cascade"),
nullable=False,
),
@@ -283,6 +292,7 @@ recent_rating = Table(
Column("id", Integer, primary_key=True, nullable=False),
Column(
"user",
Integer,
ForeignKey("aime_user.id", ondelete="cascade", onupdate="cascade"),
nullable=False,
),
@@ -297,6 +307,7 @@ region = Table(
Column("id", Integer, primary_key=True, nullable=False),
Column(
"user",
Integer,
ForeignKey("aime_user.id", ondelete="cascade", onupdate="cascade"),
nullable=False,
),
@@ -312,6 +323,7 @@ activity = Table(
Column("id", Integer, primary_key=True, nullable=False),
Column(
"user",
Integer,
ForeignKey("aime_user.id", ondelete="cascade", onupdate="cascade"),
nullable=False,
),
@@ -334,6 +346,7 @@ charge = Table(
Column("id", Integer, primary_key=True, nullable=False),
Column(
"user",
Integer,
ForeignKey("aime_user.id", ondelete="cascade", onupdate="cascade"),
nullable=False,
),
@@ -354,6 +367,7 @@ emoney = Table(
Column("id", Integer, primary_key=True, nullable=False),
Column(
"user",
Integer,
ForeignKey("aime_user.id", ondelete="cascade", onupdate="cascade"),
nullable=False,
),
@@ -373,6 +387,7 @@ overpower = Table(
Column("id", Integer, primary_key=True, nullable=False),
Column(
"user",
Integer,
ForeignKey("aime_user.id", ondelete="cascade", onupdate="cascade"),
nullable=False,
),
@@ -400,6 +415,7 @@ rating = Table(
Column("id", Integer, primary_key=True, nullable=False),
Column(
"user",
Integer,
ForeignKey("aime_user.id", ondelete="cascade", onupdate="cascade"),
nullable=False,
),
@@ -461,10 +477,23 @@ class ChuniProfileData(BaseData):
return False
return True
async def update_userbox(self, user_id: int, version: int, new_nameplate: int, new_trophy: int, new_character: int) -> bool:
async def update_stage(self, user_id: int, version: int, new_stage: int) -> bool:
sql = profile.update((profile.c.user == user_id) & (profile.c.version == version)).values(
stageId=new_stage
)
result = await self.execute(sql)
if result is None:
self.logger.warning(f"Failed to set user {user_id} stage")
return False
return True
async def update_userbox(self, user_id: int, version: int, new_nameplate: int, new_trophy: int, new_trophy_sub_1: int, new_trophy_sub_2: int, new_character: int) -> bool:
sql = profile.update((profile.c.user == user_id) & (profile.c.version == version)).values(
nameplateId=new_nameplate,
trophyId=new_trophy,
trophyIdSub1=new_trophy_sub_1,
trophyIdSub2=new_trophy_sub_2,
charaIllustId=new_character
)
result = await self.execute(sql)
@@ -899,4 +928,4 @@ class ChuniProfileData(BaseData):
async def get_net_battle(self, aime_id: int) -> Optional[Row]:
result = await self.execute(net_battle.select(net_battle.c.user == aime_id))
if result:
return result.fetchone()
return result.fetchone()
+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]
+331 -30
View File
@@ -40,7 +40,7 @@ events = Table(
Column("name", String(255)),
Column("startDate", TIMESTAMP, server_default=func.now()),
Column("enabled", Boolean, server_default="1"),
Column("opt", ForeignKey("chuni_static_opt.id", ondelete="SET NULL", onupdate="cascade")),
Column("opt", BIGINT, ForeignKey("chuni_static_opt.id", ondelete="SET NULL", onupdate="cascade")),
UniqueConstraint("version", "eventId", name="chuni_static_events_uk"),
mysql_charset="utf8mb4",
)
@@ -58,7 +58,7 @@ music = Table(
Column("genre", String(255)),
Column("jacketPath", String(255)),
Column("worldsEndTag", String(7)),
Column("opt", ForeignKey("chuni_static_opt.id", ondelete="SET NULL", onupdate="cascade")),
Column("opt", BIGINT, ForeignKey("chuni_static_opt.id", ondelete="SET NULL", onupdate="cascade")),
UniqueConstraint("version", "songId", "chartId", name="chuni_static_music_uk"),
mysql_charset="utf8mb4",
)
@@ -74,7 +74,7 @@ charge = Table(
Column("consumeType", Integer),
Column("sellingAppeal", Boolean),
Column("enabled", Boolean, server_default="1"),
Column("opt", ForeignKey("chuni_static_opt.id", ondelete="SET NULL", onupdate="cascade")),
Column("opt", BIGINT, ForeignKey("chuni_static_opt.id", ondelete="SET NULL", onupdate="cascade")),
UniqueConstraint("version", "chargeId", name="chuni_static_charge_uk"),
mysql_charset="utf8mb4",
)
@@ -92,7 +92,7 @@ avatar = Table(
Column("isEnabled", Boolean, server_default="1"),
Column("defaultHave", Boolean, server_default="0"),
Column("sortName", String(255)),
Column("opt", ForeignKey("chuni_static_opt.id", ondelete="SET NULL", onupdate="cascade")),
Column("opt", BIGINT, ForeignKey("chuni_static_opt.id", ondelete="SET NULL", onupdate="cascade")),
UniqueConstraint("version", "avatarAccessoryId", name="chuni_static_avatar_uk"),
mysql_charset="utf8mb4",
)
@@ -108,7 +108,7 @@ nameplate = Table(
Column("isEnabled", Boolean, server_default="1"),
Column("defaultHave", Boolean, server_default="0"),
Column("sortName", String(255)),
Column("opt", ForeignKey("chuni_static_opt.id", ondelete="SET NULL", onupdate="cascade")),
Column("opt", BIGINT, ForeignKey("chuni_static_opt.id", ondelete="SET NULL", onupdate="cascade")),
UniqueConstraint("version", "nameplateId", name="chuni_static_nameplate_uk"),
mysql_charset="utf8mb4",
)
@@ -128,7 +128,7 @@ character = Table(
Column("imagePath3", String(255)),
Column("isEnabled", Boolean, server_default="1"),
Column("defaultHave", Boolean, server_default="0"),
Column("opt", ForeignKey("chuni_static_opt.id", ondelete="SET NULL", onupdate="cascade")),
Column("opt", BIGINT, ForeignKey("chuni_static_opt.id", ondelete="SET NULL", onupdate="cascade")),
UniqueConstraint("version", "characterId", name="chuni_static_character_uk"),
mysql_charset="utf8mb4",
)
@@ -143,7 +143,7 @@ trophy = Table(
Column("rareType", Integer),
Column("isEnabled", Boolean, server_default="1"),
Column("defaultHave", Boolean, server_default="0"),
Column("opt", ForeignKey("chuni_static_opt.id", ondelete="SET NULL", onupdate="cascade")),
Column("opt", BIGINT, ForeignKey("chuni_static_opt.id", ondelete="SET NULL", onupdate="cascade")),
UniqueConstraint("version", "trophyId", name="chuni_static_trophy_uk"),
mysql_charset="utf8mb4",
)
@@ -159,7 +159,7 @@ map_icon = Table(
Column("iconPath", String(255)),
Column("isEnabled", Boolean, server_default="1"),
Column("defaultHave", Boolean, server_default="0"),
Column("opt", ForeignKey("chuni_static_opt.id", ondelete="SET NULL", onupdate="cascade")),
Column("opt", BIGINT, ForeignKey("chuni_static_opt.id", ondelete="SET NULL", onupdate="cascade")),
UniqueConstraint("version", "mapIconId", name="chuni_static_mapicon_uk"),
mysql_charset="utf8mb4",
)
@@ -175,7 +175,7 @@ system_voice = Table(
Column("imagePath", String(255)),
Column("isEnabled", Boolean, server_default="1"),
Column("defaultHave", Boolean, server_default="0"),
Column("opt", ForeignKey("chuni_static_opt.id", ondelete="SET NULL", onupdate="cascade")),
Column("opt", BIGINT, ForeignKey("chuni_static_opt.id", ondelete="SET NULL", onupdate="cascade")),
UniqueConstraint("version", "voiceId", name="chuni_static_systemvoice_uk"),
mysql_charset="utf8mb4",
)
@@ -197,7 +197,7 @@ gachas = Table(
Column("endDate", TIMESTAMP, server_default="2038-01-01 00:00:00.0"),
Column("noticeStartDate", TIMESTAMP, server_default="2018-01-01 00:00:00.0"),
Column("noticeEndDate", TIMESTAMP, server_default="2038-01-01 00:00:00.0"),
Column("opt", ForeignKey("cm_static_opts.id", ondelete="SET NULL", onupdate="cascade")),
Column("opt", BIGINT,ForeignKey("cm_static_opts.id", ondelete="SET NULL", onupdate="cascade")),
UniqueConstraint("version", "gachaId", "gachaName", name="chuni_static_gachas_uk"),
mysql_charset="utf8mb4",
)
@@ -218,7 +218,7 @@ cards = Table(
Column("combo", Integer, nullable=False),
Column("chain", Integer, nullable=False),
Column("skillName", String(255), nullable=False),
Column("opt", ForeignKey("cm_static_opts.id", ondelete="SET NULL", onupdate="cascade")),
Column("opt", BIGINT, ForeignKey("cm_static_opts.id", ondelete="SET NULL", onupdate="cascade")),
UniqueConstraint("version", "cardId", name="chuni_static_cards_uk"),
mysql_charset="utf8mb4",
)
@@ -289,6 +289,65 @@ login_bonus = Table(
mysql_charset="utf8mb4",
)
unlock_challenge = Table(
"chuni_static_unlock_challenge",
metadata,
Column("id", Integer, primary_key=True, nullable=False),
Column("version", Integer, nullable=False),
Column("unlockChallengeId", Integer, nullable=False),
Column("name", String(255)),
Column("isEnabled", Boolean, server_default="1"),
Column("startDate", TIMESTAMP, server_default=func.now()),
Column("courseId1", Integer),
Column("courseId2", Integer),
Column("courseId3", Integer),
Column("courseId4", Integer),
Column("courseId5", Integer),
UniqueConstraint(
"version", "unlockChallengeId", name="chuni_static_unlock_challenge_uk"
),
mysql_charset="utf8mb4",
)
linked_verse = Table(
"chuni_static_linked_verse",
metadata,
Column("id", Integer, primary_key=True, nullable=False),
Column("version", Integer, nullable=False),
Column("linkedVerseId", Integer, nullable=False),
Column("name", String(255)),
Column("isEnabled", Boolean, server_default="1", nullable=False),
Column("startDate", TIMESTAMP, server_default=func.now()),
Column("courseId1", Integer),
Column("courseId2", Integer),
Column("courseId3", Integer),
Column("courseId4", Integer),
Column("courseId5", Integer),
UniqueConstraint(
"version", "linkedVerseId", name="chuni_static_linked_verse_uk"
),
mysql_charset="utf8mb4",
)
stage = Table(
"chuni_static_stage",
metadata,
Column("id", Integer, primary_key=True, nullable=False),
Column("version", Integer, nullable=False),
Column("stageId", Integer, nullable=False),
Column("name", String(255)),
Column("imagePath", String(255)),
Column("isEnabled", Boolean, server_default="1"),
Column("defaultHave", Boolean, server_default="0"),
Column("opt", BIGINT, ForeignKey("chuni_static_opt.id", ondelete="SET NULL", onupdate="cascade")),
UniqueConstraint(
"version", "stageId", name="chuni_static_stage_uk"
),
mysql_charset="utf8mb4",
)
class ChuniStaticData(BaseData):
async def put_login_bonus(
self,
@@ -556,7 +615,7 @@ class ChuniStaticData(BaseData):
return result.fetchall()
async def get_music(self, version: int) -> Optional[List[Row]]:
sql = music.select(music.c.version <= version)
sql = music.select(music.c.version == version)
result = await self.execute(sql)
if result is None:
@@ -586,6 +645,28 @@ class ChuniStaticData(BaseData):
if result is None:
return None
return result.fetchone()
async def get_music_by_metadata(
self, title: Optional[str] = None, artist: Optional[str] = None, genre: Optional[str] = None
) -> Optional[List[Row]]:
# all conditions should use like for partial matches
conditions = []
if title:
conditions.append(music.c.title.like(f"%{title}%"))
if artist:
conditions.append(music.c.artist.like(f"%{artist}%"))
if genre:
conditions.append(music.c.genre.like(f"%{genre}%"))
if not conditions:
return None
sql = select(music).where(and_(*conditions))
result = await self.execute(sql)
if result is None:
return None
return result.fetchall()
async def put_avatar(
self,
@@ -629,11 +710,25 @@ class ChuniStaticData(BaseData):
return None
return result.lastrowid
async def get_avatar_items(self, version: int, category: int, enabled_only: bool = True) -> Optional[List[Dict]]:
async def get_avatar_items(
self, version: int, category: int, enabled_only: bool = True
) -> Optional[List[Dict]]:
if enabled_only:
sql = select(avatar).where((avatar.c.version == version) & (avatar.c.category == category) & (avatar.c.isEnabled)).order_by(avatar.c.sortName)
sql = (
select(avatar)
.where(
(avatar.c.version == version)
& (avatar.c.category == category)
& (avatar.c.isEnabled)
)
.order_by(avatar.c.sortName)
)
else:
sql = select(avatar).where((avatar.c.version == version) & (avatar.c.category == category)).order_by(avatar.c.sortName)
sql = (
select(avatar)
.where((avatar.c.version == version) & (avatar.c.category == category))
.order_by(avatar.c.sortName)
)
result = await self.execute(sql)
if result is None:
@@ -676,11 +771,21 @@ class ChuniStaticData(BaseData):
return None
return result.lastrowid
async def get_nameplates(self, version: int, enabled_only: bool = True) -> Optional[List[Dict]]:
async def get_nameplates(
self, version: int, enabled_only: bool = True
) -> Optional[List[Dict]]:
if enabled_only:
sql = select(nameplate).where((nameplate.c.version == version) & (nameplate.c.isEnabled)).order_by(nameplate.c.sortName)
sql = (
select(nameplate)
.where((nameplate.c.version == version) & (nameplate.c.isEnabled))
.order_by(nameplate.c.sortName)
)
else:
sql = select(nameplate).where(nameplate.c.version == version).order_by(nameplate.c.sortName)
sql = (
select(nameplate)
.where(nameplate.c.version == version)
.order_by(nameplate.c.sortName)
)
result = await self.execute(sql)
if result is None:
@@ -720,11 +825,21 @@ class ChuniStaticData(BaseData):
return None
return result.lastrowid
async def get_trophies(self, version: int, enabled_only: bool = True) -> Optional[List[Dict]]:
async def get_trophies(
self, version: int, enabled_only: bool = True
) -> Optional[List[Dict]]:
if enabled_only:
sql = select(trophy).where((trophy.c.version == version) & (trophy.c.isEnabled)).order_by(trophy.c.name)
sql = (
select(trophy)
.where((trophy.c.version == version) & (trophy.c.isEnabled))
.order_by(trophy.c.name)
)
else:
sql = select(trophy).where(trophy.c.version == version).order_by(trophy.c.name)
sql = (
select(trophy)
.where(trophy.c.version == version)
.order_by(trophy.c.name)
)
result = await self.execute(sql)
if result is None:
@@ -767,11 +882,21 @@ class ChuniStaticData(BaseData):
return None
return result.lastrowid
async def get_map_icons(self, version: int, enabled_only: bool = True) -> Optional[List[Dict]]:
async def get_map_icons(
self, version: int, enabled_only: bool = True
) -> Optional[List[Dict]]:
if enabled_only:
sql = select(map_icon).where((map_icon.c.version == version) & (map_icon.c.isEnabled)).order_by(map_icon.c.sortName)
sql = (
select(map_icon)
.where((map_icon.c.version == version) & (map_icon.c.isEnabled))
.order_by(map_icon.c.sortName)
)
else:
sql = select(map_icon).where(map_icon.c.version == version).order_by(map_icon.c.sortName)
sql = (
select(map_icon)
.where(map_icon.c.version == version)
.order_by(map_icon.c.sortName)
)
result = await self.execute(sql)
if result is None:
@@ -814,11 +939,21 @@ class ChuniStaticData(BaseData):
return None
return result.lastrowid
async def get_system_voices(self, version: int, enabled_only: bool = True) -> Optional[List[Dict]]:
async def get_system_voices(
self, version: int, enabled_only: bool = True
) -> Optional[List[Dict]]:
if enabled_only:
sql = select(system_voice).where((system_voice.c.version == version) & (system_voice.c.isEnabled)).order_by(system_voice.c.sortName)
sql = (
select(system_voice)
.where((system_voice.c.version == version) & (system_voice.c.isEnabled))
.order_by(system_voice.c.sortName)
)
else:
sql = select(system_voice).where(system_voice.c.version == version).order_by(system_voice.c.sortName)
sql = (
select(system_voice)
.where(system_voice.c.version == version)
.order_by(system_voice.c.sortName)
)
result = await self.execute(sql)
if result is None:
@@ -873,11 +1008,21 @@ class ChuniStaticData(BaseData):
return None
return result.lastrowid
async def get_characters(self, version: int, enabled_only: bool = True) -> Optional[List[Dict]]:
async def get_characters(
self, version: int, enabled_only: bool = True
) -> Optional[List[Dict]]:
if enabled_only:
sql = select(character).where((character.c.version == version) & (character.c.isEnabled)).order_by(character.c.sortName)
sql = (
select(character)
.where((character.c.version == version) & (character.c.isEnabled))
.order_by(character.c.sortName)
)
else:
sql = select(character).where(character.c.version == version).order_by(character.c.sortName)
sql = (
select(character)
.where(character.c.version == version)
.order_by(character.c.sortName)
)
result = await self.execute(sql)
if result is None:
@@ -1074,3 +1219,159 @@ class ChuniStaticData(BaseData):
self.logger.error(f"Failed to set opt enabled status to {enabled} for opt {opt_id}")
return False
return True
async def put_unlock_challenge(
self,
version: int,
unlock_challenge_id: int,
name: str,
course_id1: Optional[int] = None,
course_id2: Optional[int] = None,
course_id3: Optional[int] = None,
course_id4: Optional[int] = None,
course_id5: Optional[int] = None,
) -> Optional[int]:
sql = insert(unlock_challenge).values(
version=version,
unlockChallengeId=unlock_challenge_id,
name=name,
courseId1=course_id1,
courseId2=course_id2,
courseId3=course_id3,
courseId4=course_id4,
courseId5=course_id5,
)
conflict = sql.on_duplicate_key_update(
name=name,
courseId1=course_id1,
courseId2=course_id2,
courseId3=course_id3,
courseId4=course_id4,
courseId5=course_id5,
)
result = await self.execute(conflict)
if result is None:
return None
return result.lastrowid
async def get_unlock_challenges(self, version: int) -> Optional[List[Dict]]:
sql = unlock_challenge.select(
and_(
unlock_challenge.c.version == version,
unlock_challenge.c.isEnabled == True,
)
).order_by(unlock_challenge.c.startDate.asc())
result = await self.execute(sql)
if result is None:
return None
return result.fetchall()
async def put_linked_verse(
self,
version: int,
linked_verse_id: int,
name: str,
course_id1: Optional[int] = None,
course_id2: Optional[int] = None,
course_id3: Optional[int] = None,
course_id4: Optional[int] = None,
course_id5: Optional[int] = None,
) -> Optional[int]:
sql = insert(linked_verse).values(
version=version,
linkedVerseId=linked_verse_id,
name=name,
courseId1=course_id1,
courseId2=course_id2,
courseId3=course_id3,
courseId4=course_id4,
courseId5=course_id5,
)
conflict = sql.on_duplicate_key_update(
name=name,
courseId1=course_id1,
courseId2=course_id2,
courseId3=course_id3,
courseId4=course_id4,
courseId5=course_id5,
)
result = await self.execute(conflict)
if result is None:
return None
return result.lastrowid
async def get_linked_verses(self, version: int) -> Optional[List[Dict]]:
sql = linked_verse.select(
and_(
linked_verse.c.version == version,
linked_verse.c.isEnabled == True,
)
).order_by(linked_verse.c.startDate.asc())
result = await self.execute(sql)
if result is None:
return None
return result.fetchall()
async def put_stage(
self,
version: int,
stage_id: int,
name: str,
image_path: str,
is_enabled: int,
default_have: int,
opt_id: int = None
) -> Optional[int]:
sql = insert(stage).values(
version=version,
stageId=stage_id,
name=name,
imagePath=image_path,
isEnabled=is_enabled,
defaultHave=default_have,
opt=coalesce(stage.c.opt, opt_id)
)
conflict = sql.on_duplicate_key_update(
name=name,
imagePath=image_path,
isEnabled=is_enabled,
defaultHave=default_have,
opt=coalesce(stage.c.opt, opt_id)
)
result = await self.execute(conflict)
if result is None:
return None
return result.lastrowid
async def get_stages(self, version: int) -> Optional[List[Dict]]:
sql = stage.select(
and_(
stage.c.version == version,
stage.c.isEnabled == True,
)
)
result = await self.execute(sql)
if result is None:
return None
return result.fetchall()
+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>
+25
View File
@@ -79,6 +79,12 @@
<td><div id="system-voice-name">{{ system_voices[profile.voiceId]["name"] if system_voices|length > 0 else "Server DB needs upgraded or is not populated with necessary data" }}</div></td>
</tr>
{% endif %}
{% if cur_version >= 18 %} <!-- STAGE introduced in X-VERSE -->
<tr>
<td>Stage:</td>
<td><div id="stage-name">{{ stages[profile.stageId]["name"] if stages|length > 0 else "Server DB needs upgraded or is not populated with necessary data" }}</div></td>
</tr>
{% endif %}
</table>
</div>
</div>
@@ -111,6 +117,21 @@
</div>
{% endif %}
{% if cur_version >= 18 %} <!-- STAGE introduced in X-VERSE -->
<!-- STAGE SELECTION -->
<div class="col-lg-8 m-auto mt-3 scrolling-lists">
<div class="card bg-card rounded">
<button class="collapsible">Stage:&nbsp;&nbsp;&nbsp;{{ stages|length }}/{{ total_stages }}</button>
<div id="scrollable-stage" class="collapsible-content">
{% for item in stages.values() %}
<img id="stage-{{ item["id"] }}" style="padding: 8px 8px;" onclick="saveItem('stage', '{{ item["id"] }}', '{{ item["name"] }}')" src="img/stage/{{ item["imagePath"] }}" alt="{{ item["name"] }}">
<span id="stage-br-{{ loop.index }}"></span>
{% endfor %}
</div>
</div>
</div>
{% endif %}
<div class="col-lg-8 m-auto mt-3">
<div class="card bg-card rounded">
<table class="table-large table-rowdistinct">
@@ -201,6 +222,10 @@ items = {
"map-icon": ["{{ map_icons|length }}", "{{ profile.mapIconId }}"],
"system-voice":["{{ system_voices|length }}", "{{ profile.voiceId }}"]
};
// STAGE introduced in X-VERSE
if ({{cur_version}} >= 18) {
items.stage = ["{{ stages|length }}", "{{ profile.stageId }}"]
}
types = Object.keys(items);
function changeItem(type, id, name) {
+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 () {
+281
View File
@@ -0,0 +1,281 @@
from datetime import datetime, timedelta
from typing import Dict, List, Set
from core.config import CoreConfig
from titles.chuni.config import ChuniConfig
from titles.chuni.const import (
ChuniConstants,
MapAreaConditionLogicalOperator,
MapAreaConditionType,
)
from titles.chuni.luminous import MysticAreaConditions
from titles.chuni.luminousplus import ChuniLuminousPlus
class ChuniVerse(ChuniLuminousPlus):
def __init__(self, core_cfg: CoreConfig, game_cfg: ChuniConfig) -> None:
super().__init__(core_cfg, game_cfg)
self.version = ChuniConstants.VER_CHUNITHM_VERSE
async def handle_c_m_get_user_preview_api_request(self, data: Dict) -> Dict:
user_data = await super().handle_c_m_get_user_preview_api_request(data)
# Does CARD MAKER 1.35 work this far up?
user_data["lastDataVersion"] = "2.30.00"
return user_data
async def handle_get_game_map_area_condition_api_request(self, data: Dict) -> Dict:
# There is no game data for this, everything is server side.
# However, we can selectively show/hide events as data is imported into the server.
events = await self.data.static.get_enabled_events(self.version)
event_by_id = {evt["eventId"]: evt for evt in events}
conditions = []
mystic_conditions = MysticAreaConditions(
event_by_id,
3230401,
self.date_time_format,
)
# Mystic Rainbow of VERSE - VERSE ep. I
mystic_conditions.add_condition(16006, 3020798, 3230402)
# Mystic Rainbow of VERSE - VERSE ep. II
mystic_conditions.add_condition(16204, 3020799, 3230403)
# Mystic Rainbow of VERSE - VERSE ep. III
mystic_conditions.add_condition(16455, 3020800, 3230404)
# Mystic Rainbow of VERSE - VERSE ep. IV
mystic_conditions.add_condition(16607, 3020802, 3230405)
conditions += mystic_conditions.conditions
return {
"length": len(conditions),
"gameMapAreaConditionList": conditions,
}
async def handle_get_game_course_level_api_request(self, data: Dict) -> Dict:
unlock_challenges = await self.data.static.get_unlock_challenges(self.version)
game_course_level_list = []
for unlock_challenge in unlock_challenges:
course_ids = [
unlock_challenge[f"courseId{i}"]
for i in range(1, 6)
if unlock_challenge[f"courseId{i}"] is not None
]
start_date = unlock_challenge["startDate"].replace(
hour=0, minute=0, second=0
)
for i, course_id in enumerate(course_ids):
start = start_date + timedelta(days=7 * i)
end = start_date + timedelta(days=7 * (i + 1)) - timedelta(seconds=1)
if i == len(course_ids) - 1:
# If this is the last course, set end date to a far future date
end = datetime(2099, 1, 1)
game_course_level_list.append(
{
"courseId": course_id,
"startDate": start.strftime(self.date_time_format),
"endDate": end.strftime(self.date_time_format),
}
)
return {
"length": len(game_course_level_list),
"gameCourseLevelList": game_course_level_list,
}
async def handle_get_game_u_c_condition_api_request(self, data: Dict) -> Dict:
unlock_challenges = await self.data.static.get_unlock_challenges(self.version)
game_unlock_challenge_condition_list = []
conditions = {
# unlock Theatore Creatore (ULTIMA) after clearing map VERSE ep. I
10001: {
"type": MapAreaConditionType.MAP_CLEARED.value,
"conditionId": 3020798,
},
# unlock Crossmythos Rhapsodia after clearing map VERSE ep. IV
10006: {
"type": MapAreaConditionType.MAP_CLEARED.value,
"conditionId": 3020802,
},
}
for unlock_challenge in unlock_challenges:
unlock_challenge_id = unlock_challenge["unlockChallengeId"]
unlock_condition = conditions.get(
unlock_challenge_id,
# default is to unlock for players above 5.00 rating
{
"type": MapAreaConditionType.MINIMUM_RATING.value,
"conditionId": 500,
},
)
game_unlock_challenge_condition_list.append(
{
"unlockChallengeId": unlock_challenge_id,
"length": 1,
"conditionList": [
{
"type": unlock_condition["type"],
"conditionId": unlock_condition["conditionId"],
"logicalOpe": MapAreaConditionLogicalOperator.AND.value,
"startDate": unlock_challenge["startDate"].strftime(
self.date_time_format
),
"endDate": datetime(2099, 1, 1).strftime(
self.date_time_format
),
}
],
}
)
return {
"length": len(game_unlock_challenge_condition_list),
"gameUnlockChallengeConditionList": game_unlock_challenge_condition_list,
}
async def handle_get_user_u_c_api_request(self, data: Dict) -> Dict:
user_id = data["userId"]
user_unlock_challenges = await self.data.item.get_unlock_challenges(
user_id, self.version
)
user_unlock_challenge_list = [
{
"unlockChallengeId": user_uc["unlockChallengeId"],
"status": user_uc["status"],
"clearCourseId": user_uc["clearCourseId"],
"conditionType": user_uc["conditionType"],
"score": user_uc["score"],
"life": user_uc["life"],
"clearDate": user_uc["clearDate"].strftime(self.date_time_format),
}
for user_uc in user_unlock_challenges
]
return {
"userId": user_id,
"userUnlockChallengeList": user_unlock_challenge_list,
}
async def handle_get_user_rec_music_api_request(self, data: Dict) -> Dict:
rec_limit = 25 # limit for recommendations
user_id = data["userId"]
user_rec_music_set = set()
recent_rating = await self.data.profile.get_profile_recent_rating(user_id)
if not recent_rating:
# If no recent ratings, return an empty list
return {
"length": 0,
"userRecMusicList": [],
}
recent_ratings = recent_rating["recentRating"]
# cache music info
music_info_list = []
for recent_rating in recent_ratings:
music_id = recent_rating["musicId"]
music_info = await self.data.static.get_song(music_id)
if music_info:
music_info_list.append(music_info)
# use a set to avoid duplicates
user_rec_music_set = set()
# try adding recommendations in order of: title → artist → genre
for field in ("title", "artist", "genre"):
await self._add_recommendations(
field, user_rec_music_set, music_info_list, rec_limit
)
if len(user_rec_music_set) >= rec_limit:
break
user_rec_music_list = [
{
"musicId": 1, # a song the player recently played
# recMusicList is a semi colon-separated list of music IDs and their order comma separated
# for some reason, not all music ids are shown in game?!
"recMusicList": ";".join(
f"{music_id},{index + 1}"
for index, music_id in enumerate(user_rec_music_set)
),
},
]
return {
"length": len(user_rec_music_list),
"userRecMusicList": user_rec_music_list,
}
async def handle_get_user_rec_rating_api_request(self, data: Dict) -> Dict:
class GetUserRecRatingApi:
class UserRecRating:
ratingMin: int
ratingMax: int
# semicolon-delimited list of (musicId, level, sortingKey, score), in the
# same format as GetUserRecMusicApi
recMusicList: str
length: int
userRecRatingList: list[UserRecRating]
user_id = data["userId"]
user_rec_rating_list = []
return {
"length": len(user_rec_rating_list),
"userRecRatingList": user_rec_rating_list,
}
async def _add_recommendations(
self,
field: str,
user_rec_music_set: Set[int],
music_info_list: List[Dict],
limit: int = 25,
) -> None:
"""
Adds music recommendations based on a specific metadata field (title/artist/genre),
excluding music IDs already in the user's recent ratings and recommendations.
"""
# Collect all existing songId to exclude from recommendations
existing_music_ids = {info["songId"] for info in music_info_list}
for music_info in music_info_list:
if len(user_rec_music_set) >= limit:
break
metadata_value = music_info[field]
if not metadata_value:
continue
recs = await self.data.static.get_music_by_metadata(
**{field: metadata_value}
)
for rec in recs or []:
song_id = rec["songId"]
# skip if the song is already in the user's recent ratings
# or if the song is already in the user's recommendations
if (
len(user_rec_music_set) >= limit
or song_id in existing_music_ids
or song_id in user_rec_music_set
):
continue
user_rec_music_set.add(song_id)
+317
View File
@@ -0,0 +1,317 @@
import asyncio
from datetime import datetime, timedelta, timezone
from typing import Dict
from core.config import CoreConfig
from .config import ChuniConfig
from .const import (
ChuniConstants,
LinkedVerseUnlockConditionType,
MapAreaConditionLogicalOperator,
MapAreaConditionType,
)
from .luminous import MysticAreaConditions
from .verse import ChuniVerse
class ChuniXVerse(ChuniVerse):
def __init__(self, core_cfg: CoreConfig, game_cfg: ChuniConfig) -> None:
super().__init__(core_cfg, game_cfg)
self.version = ChuniConstants.VER_CHUNITHM_X_VERSE
async def handle_c_m_get_user_preview_api_request(self, data: Dict) -> Dict:
user_data = await super().handle_c_m_get_user_preview_api_request(data)
# Does CARD MAKER 1.35 work this far up?
user_data["lastDataVersion"] = "2.40.00"
return user_data
async def handle_get_game_map_area_condition_api_request(self, data: Dict) -> Dict:
events = await self.data.static.get_enabled_events(self.version)
if events is None:
return {"length": 0, "gameMapAreaConditionList": []}
events_by_id = {event["eventId"]: event for event in events}
mystic_conditions = MysticAreaConditions(
events_by_id, 3239201, self.date_time_format
)
# Mystic Rainbow of X-VERSE Area 2 unlocks when VERSE ep. ORIGIN is finished.
mystic_conditions.add_condition(17021, 3020803, 3239202)
# Mystic Rainbow of X-VERSE Area 3 unlocks when VERSE ep. AIR is finished.
mystic_conditions.add_condition(17104, 3020804, 3239203)
# Mystic Rainbow of X-VERSE Area 4 unlocks when VERSE ep. STAR is finished.
mystic_conditions.add_condition(17208, 3020805, 3239204)
# Mystic Rainbow of X-VERSE Area 5 unlocks when VERSE ep. AMAZON is finished.
mystic_conditions.add_condition(17304, 3020806, 3239205)
# Mystic Rainbow of X-VERSE Area 6 unlocks when VERSE ep. CRYSTAL is finished.
mystic_conditions.add_condition(17407, 3020807, 3239206)
# Mystic Rainbow of X-VERSE Area 7 unlocks when VERSE ep. PARADISE is finished.
mystic_conditions.add_condition(17483, 3020808, 3239207)
return {
"length": len(mystic_conditions.conditions),
"gameMapAreaConditionList": mystic_conditions.conditions,
}
async def handle_get_game_course_level_api_request(self, data: Dict) -> Dict:
uc_likes = [] # includes both UCs and LVs, though the former doesn't show up at all in X-VERSE
unlock_challenges, linked_verses = await asyncio.gather(
self.data.static.get_unlock_challenges(self.version),
self.data.static.get_linked_verses(self.version),
)
if unlock_challenges:
uc_likes.extend(unlock_challenges)
if linked_verses:
uc_likes.extend(linked_verses)
if not uc_likes:
return {"length": 0, "gameCourseLevelList": []}
course_level_list = []
current_time = datetime.now(timezone.utc).replace(tzinfo=None)
for uc_like in uc_likes:
course_ids = [
uc_like[f"courseId{i}"]
for i in range(1, 6)
if uc_like[f"courseId{i}"] is not None
]
event_start_date = uc_like["startDate"].replace(hour=0, minute=0, second=0)
for i, course_id in enumerate(course_ids):
start_date = event_start_date + timedelta(days=7 * i)
if i == len(course_ids) - 1:
end_date = datetime(2099, 12, 31, 23, 59, 59)
else:
end_date = (
event_start_date
+ timedelta(days=7 * (i + 1))
- timedelta(seconds=1)
)
if start_date <= current_time <= end_date:
course_level_list.append(
{
"courseId": course_id,
"startDate": start_date.strftime(self.date_time_format),
"endDate": end_date.strftime(self.date_time_format),
}
)
return {
"length": len(course_level_list),
"gameCourseLevelList": course_level_list,
}
async def handle_get_game_l_v_condition_open_api_request(self, data: Dict) -> Dict:
linked_verses = await self.data.static.get_linked_verses(self.version)
if not linked_verses:
return {"length": 0, "gameLinkedVerseConditionOpenList": []}
linked_verse_by_id = {r["linkedVerseId"]: r for r in linked_verses}
conditions = []
for lv_id, map_id in [
(10001, 3020803), # ORIGIN
(10002, 3020804), # AIR
(10003, 3020805), # STAR
(10004, 3020806), # AMAZON
(10005, 3020807), # CRYSTAL
(10006, 3020808), # PARADISE
]:
if (lv := linked_verse_by_id.get(lv_id)) is None:
continue
conditions.append(
{
"linkedVerseId": lv["linkedVerseId"],
"length": 1,
"conditionList": [
{
"type": MapAreaConditionType.MAP_CLEARED.value,
"conditionId": map_id,
"logicalOpe": MapAreaConditionLogicalOperator.AND.value,
"startDate": lv["startDate"].strftime(
self.date_time_format
),
"endDate": "2099-12-31 23:59:59",
}
],
}
)
return {
"length": len(conditions),
"gameLinkedVerseConditionOpenList": conditions,
}
async def handle_get_game_l_v_condition_unlock_api_request(
self, data: Dict
) -> Dict:
linked_verses = await self.data.static.get_linked_verses(self.version)
if not linked_verses:
return {
"length": 0,
"gameLinkedVerseConditionUnlockList": [],
}
linked_verse_by_id = {r["linkedVerseId"]: r for r in linked_verses}
conditions = []
# For reference on official Linked VERSE conditions:
# https://docs.google.com/spreadsheets/d/1j7kmCR0-R5W3uivwkw-6A_eUCXttnJLnkTO0Qf7dya0/edit?usp=sharing
# Linked GATE ORIGIN - Play 30 ORIGIN Fables songs
if gate_origin := linked_verse_by_id.get(10001):
conditions.append(
{
"linkedVerseId": gate_origin["linkedVerseId"],
"length": 1,
"conditionList": [
{
"type": LinkedVerseUnlockConditionType.PLAY_SONGS.value,
"conditionList": "59;79;148;71;75;140;163;80;51;64;65;74;95;67;53;100;108;107;105;82;76;141;63;147;69;151;70;101;152;180",
"logicalOpe": MapAreaConditionLogicalOperator.AND.value,
"startDate": gate_origin["startDate"].strftime(
self.date_time_format
),
"endDate": "2099-12-31 00:00:00",
}
],
}
)
# Linked GATE AIR - Obtain class banner
if gate_air := linked_verse_by_id.get(10002):
conditions.append(
{
"linkedVerseId": gate_air["linkedVerseId"],
"length": 1,
"conditionList": [
{
"type": LinkedVerseUnlockConditionType.COURSE_CLEAR_AND_CLASS_EMBLEM.value,
"conditionList": "1_2_3_4_5_6",
"logicalOpe": MapAreaConditionLogicalOperator.AND.value,
"startDate": gate_air["startDate"].strftime(
self.date_time_format
),
"endDate": "2099-12-31 00:00:00",
}
],
}
)
# Linked GATE STAR - Obtain a trophy by leveling a character to level 15
if gate_star := linked_verse_by_id.get(10003):
conditions.append(
{
"linkedVerseId": gate_star["linkedVerseId"],
"length": 1,
"conditionList": [
{
"type": LinkedVerseUnlockConditionType.TROPHY_OBTAINED.value,
"conditionList": "9718",
"logicalOpe": MapAreaConditionLogicalOperator.AND.value,
"startDate": gate_star["startDate"].strftime(
self.date_time_format
),
"endDate": "2099-12-31 00:00:00",
}
],
}
)
# Linked GATE AMAZON - Play Killing Rhythm and Climax from the favorites folder
if gate_amazon := linked_verse_by_id.get(10004):
conditions.append(
{
"linkedVerseId": gate_amazon["linkedVerseId"],
"length": 1,
"conditionList": [
{
"type": LinkedVerseUnlockConditionType.PLAY_SONGS_IN_FAVORITE.value,
"conditionList": "712;777",
"logicalOpe": MapAreaConditionLogicalOperator.AND.value,
"startDate": gate_amazon["startDate"].strftime(
self.date_time_format
),
"endDate": "2099-12-31 00:00:00",
}
],
}
)
# Linked GATE CRYSTAL - Clear team course while equipping a character of minimum rank 26
if gate_crystal := linked_verse_by_id.get(10005):
conditions.append(
{
"linkedVerseId": gate_crystal["linkedVerseId"],
"length": 1,
"conditionList": [
{
"type": LinkedVerseUnlockConditionType.CLEAR_TEAM_COURSE_WITH_CHARACTER_OF_MINIMUM_RANK.value,
"conditionList": "26",
"logicalOpe": MapAreaConditionLogicalOperator.AND.value,
"startDate": gate_crystal["startDate"].strftime(
self.date_time_format
),
"endDate": "2099-12-31 00:00:00",
}
],
}
)
# Linked GATE PARADISE - Play one solo song by each of the artists in Inori
if gate_paradise := linked_verse_by_id.get(10006):
conditions.append(
{
"linkedVerseId": gate_paradise["linkedVerseId"],
"length": 1,
"conditionList": [
{
"type": LinkedVerseUnlockConditionType.PLAY_SONGS.value,
"conditionList": "180_384_2355;407_2353;788_629_600;2704;2050_2354",
"logicalOpe": MapAreaConditionLogicalOperator.AND.value,
"startDate": gate_paradise["startDate"].strftime(
self.date_time_format
),
"endDate": "2099-12-31 00:00:00",
}
],
}
)
return {
"length": len(conditions),
"gameLinkedVerseConditionUnlockList": conditions,
}
async def handle_get_user_l_v_api_request(self, data: Dict) -> Dict:
user_id = int(data["userId"])
rows = await self.data.item.get_linked_verse(user_id) or []
linked_verses = []
for row in rows:
data = row._asdict()
data.pop("id")
data.pop("user")
linked_verses.append(data)
return {
"userId": user_id,
"userLinkedVerseList": linked_verses,
}
+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
}
+82 -44
View File
@@ -53,6 +53,10 @@ class Mai2Reader(BaseReader):
self.logger.error(f"tables directory not found in {self.bin_dir}")
return
if not os.path.exists(f"{self.opt_dir}/tables"):
self.logger.warning(f"tables directory not found in {self.opt_dir}, not using")
self.opt_dir = None
if self.version >= Mai2Constants.VER_MAIMAI_MILK:
if self.extra is None:
self.logger.error("Milk - Finale requre an AES key via a hex string send as the --extra flag")
@@ -63,45 +67,34 @@ class Mai2Reader(BaseReader):
else:
key = None
evt_table = self.load_table_raw(f"{self.bin_dir}/tables", "mmEvent.bin", key)
txt_table = self.load_table_raw(f"{self.bin_dir}/tables", "mmtextout_jp.bin", key)
score_table = self.load_table_raw(f"{self.bin_dir}/tables", "mmScore.bin", key)
jp_table = self.parse_textout(f"{self.bin_dir}/tables", "mmtextout_jp.bin", key, f"{self.opt_dir}/tables" if self.opt_dir else None)
en_table = self.parse_textout(f"{self.bin_dir}/tables", "mmtextout_ex.bin", key, f"{self.opt_dir}/tables" if self.opt_dir else None)
evt_table = self.parse_table(f"{self.bin_dir}/tables", "mmEvent.bin", key, f"{self.opt_dir}/tables" if self.opt_dir else None)
score_table = self.parse_table(f"{self.bin_dir}/tables", "mmScore.bin", key, f"{self.opt_dir}/tables" if self.opt_dir else None)
music_table = self.parse_table(f"{self.bin_dir}/tables", "mmMusic.bin", key, f"{self.opt_dir}/tables" if self.opt_dir else None)
genre_table = self.parse_table(f"{self.bin_dir}/tables", "mmGenre.bin", key, f"{self.opt_dir}/tables" if self.opt_dir else None)
genre_lookup = {}
for entry in genre_table:
genre_lookup[entry['ID']] = jp_table[entry['名前テキスト']]
self.logger.info(f"Insert {len(evt_table)} events")
await self.read_old_events(evt_table)
await self.read_old_music(score_table, txt_table)
if self.opt_dir is not None:
evt_table = self.load_table_raw(f"{self.opt_dir}/tables", "mmEvent.bin", key)
txt_table = self.load_table_raw(f"{self.opt_dir}/tables", "mmtextout_jp.bin", key)
score_table = self.load_table_raw(f"{self.opt_dir}/tables", "mmScore.bin", key)
await self.read_old_events(evt_table)
await self.read_old_music(score_table, txt_table)
return
self.logger.info(f"Insert {len(score_table)} charts")
await self.read_old_music(music_table, score_table, jp_table, genre_lookup)
def load_table_raw(self, dir: str, file: str, key: Optional[bytes]) -> Optional[List[Dict[str, str]]]:
if not os.path.exists(f"{dir}/{file}"):
self.logger.warning(f"file {file} does not exist in directory {dir}, skipping")
return
def parse_textout(self, dir: str, file: str, key: Optional[bytes], opt_dir: Optional[str] = None) -> Dict[str, str]:
f_decoded = self.load_table_raw(dir, file, key, opt_dir)
out = {}
for line in f_decoded.splitlines():
matcher = re.match(r"^[A-Z]+\( L\"(.+)\" ,L\"(.*)\" \)$", line)
if not matcher: continue
out[matcher.group(1)] = matcher.group(2)
self.logger.info(f"Load table {file} from {dir}")
if key is not None:
cipher = AES.new(key, AES.MODE_CBC)
with open(f"{dir}/{file}", "rb") as f:
f_encrypted = f.read()
f_data = cipher.decrypt(f_encrypted)[0x10:]
else:
with open(f"{dir}/{file}", "rb") as f:
f_data = f.read()[0x10:]
if f_data is None or not f_data:
self.logger.warning(f"file {dir} could not be read, skipping")
return
f_data_deflate = zlib.decompress(f_data, wbits = zlib.MAX_WBITS | 16)[0x12:] # lop off the junk at the beginning
f_decoded = codecs.utf_16_le_decode(f_data_deflate)[0]
return out
def parse_table(self, dir: str, file: str, key: Optional[bytes], opt_dir: Optional[str] = None) -> Optional[List[Dict[str, str]]]:
f_decoded = self.load_table_raw(dir, file, key, opt_dir)
f_split = f_decoded.splitlines()
has_struct_def = "struct " in f_decoded
@@ -176,6 +169,34 @@ class Mai2Reader(BaseReader):
self.logger.warning("Failed load table content, skipping")
return
def load_table_raw(self, dir: str, file: str, key: Optional[bytes], opt_dir: Optional[str] = None) -> str:
if opt_dir is not None and os.path.exists(f"{opt_dir}/{file}"):
fpath = f"{opt_dir}/{file}"
else:
fpath = f"{dir}/{file}"
if not os.path.exists(fpath):
self.logger.warning(f"file {file} does not exist in directory {dir}, skipping")
return
self.logger.info(f"Load table {fpath}")
if key is not None:
cipher = AES.new(key, AES.MODE_CBC)
with open(fpath, "rb") as f:
f_encrypted = f.read()
f_data = cipher.decrypt(f_encrypted)[0x10:]
else:
with open(fpath, "rb") as f:
f_data = f.read()[0x10:]
if f_data is None or not f_data:
self.logger.warning(f"file {fpath} could not be read, skipping")
return
f_data_deflate = zlib.decompress(f_data, wbits = zlib.MAX_WBITS | 16)[0x12:] # lop off the junk at the beginning
return codecs.utf_16_le_decode(f_data_deflate)[0]
async def get_events(self, base_dir: str, opt_id: int = None) -> None:
self.logger.info(f"Reading events from {base_dir}...")
@@ -325,20 +346,37 @@ class Mai2Reader(BaseReader):
for event in events:
evt_id = int(event.get('イベントID', '0'))
evt_expire_time = float(event.get('オフ時強制時期', '0.0'))
is_exp = bool(int(event.get('海外許可', '0')))
is_aou = bool(int(event.get('AOU許可', '0')))
name = event.get('comment', f'evt_{evt_id}')
await self.data.static.put_game_event(self.version, 0, evt_id, name)
if not (is_exp or is_aou):
await self.data.static.toggle_game_event(self.version, evt_id, False)
async def read_old_music(self, scores: Optional[List[Dict[str, str]]], text: Optional[List[Dict[str, str]]]) -> None:
if scores is None or text is None:
async def read_old_music(self, music: Optional[List[Dict[str, str]]], scores: Optional[List[Dict[str, str]]], text: Optional[Dict[str, str]], genre: Dict[str, str]) -> None:
if music is None or scores is None or text is None:
return
# TODO
last_music = music[0]
for score in scores:
mid = score['ID'][:-2]
cid = score['ID'][-2:]
if last_music['ID'] != mid:
for x in range(len(music)):
if music[x]['ID'] == mid:
last_music = music[x]
break
await self.data.static.put_game_music(
self.version,
int(mid),
int(cid),
text[last_music['タイトル']],
text[last_music['アーティスト']],
genre[last_music['GenreID']],
last_music['BPM'],
last_music['Ver'],
float(score['LV']),
text[f"RST_SCORECREATOR_{int(score['譜面作者ID']):04d}"]
)
async def read_opt_info(self, directory: str) -> Optional[int]:
datacfg_file = os.path.join(directory, "DataConfig.xml")

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