Merge branch 'feature/chunithm/xverse' into develop

This commit is contained in:
Dniel97
2026-01-01 20:47:29 +01:00
16 changed files with 841 additions and 22 deletions

View File

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

View File

@@ -69,6 +69,7 @@ Games listed below have been tested and confirmed working.
| 15 | CHUNITHM LUMINOUS | | 15 | CHUNITHM LUMINOUS |
| 16 | CHUNITHM LUMINOUS PLUS | | 16 | CHUNITHM LUMINOUS PLUS |
| 17 | CHUNITHM VERSE | | 17 | CHUNITHM VERSE |
| 18 | CHUNITHM X-VERSE |
### Importer ### Importer

View File

@@ -24,6 +24,7 @@ mods:
nameplates: False nameplates: False
trophies: False trophies: False
character_icons: False character_icons: False
stages: False
version: version:
11: 11:
@@ -47,6 +48,9 @@ version:
17: 17:
rom: 2.30.00 rom: 2.30.00
data: 2.30.00 data: 2.30.00
18:
rom: 2.40.00
data: 2.40.00
crypto: crypto:
encrypted_only: False encrypted_only: False

View File

@@ -39,6 +39,7 @@ Games listed below have been tested and confirmed working. Only game versions ol
+ LUMINOUS + LUMINOUS
+ LUMINOUS PLUS + LUMINOUS PLUS
+ VERSE + VERSE
+ X-VERSE
+ crossbeats REV. + crossbeats REV.
+ Crossbeats REV. + Crossbeats REV.

View File

@@ -1104,6 +1104,10 @@ class ChuniBase:
user_id, self.version, 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"} return {"returnCode": "1"}

View File

@@ -29,6 +29,7 @@ class ChuniConstants:
VER_CHUNITHM_LUMINOUS = 15 VER_CHUNITHM_LUMINOUS = 15
VER_CHUNITHM_LUMINOUS_PLUS = 16 VER_CHUNITHM_LUMINOUS_PLUS = 16
VER_CHUNITHM_VERSE = 17 VER_CHUNITHM_VERSE = 17
VER_CHUNITHM_X_VERSE = 18
VERSION_NAMES = [ VERSION_NAMES = [
"CHUNITHM", "CHUNITHM",
@@ -48,7 +49,8 @@ class ChuniConstants:
"CHUNITHM SUN PLUS", "CHUNITHM SUN PLUS",
"CHUNITHM LUMINOUS", "CHUNITHM LUMINOUS",
"CHUNITHM LUMINOUS PLUS", "CHUNITHM LUMINOUS PLUS",
"CHUNITHM VERSE" "CHUNITHM VERSE",
"CHUNITHM X-VERSE",
] ]
SCORE_RANK_INTERVALS_OLD = [ SCORE_RANK_INTERVALS_OLD = [
@@ -100,6 +102,8 @@ class ChuniConstants:
"215": VER_CHUNITHM_SUN_PLUS, "215": VER_CHUNITHM_SUN_PLUS,
"220": VER_CHUNITHM_LUMINOUS, "220": VER_CHUNITHM_LUMINOUS,
"225": VER_CHUNITHM_LUMINOUS_PLUS, "225": VER_CHUNITHM_LUMINOUS_PLUS,
"230": VER_CHUNITHM_VERSE,
"240": VER_CHUNITHM_X_VERSE,
} }
@classmethod @classmethod
@@ -246,6 +250,44 @@ class MapAreaConditionType(IntEnum):
"""Obtain a rating of at least `conditionId / 100`.""" """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): class MapAreaConditionLogicalOperator(Enum):
AND = 1 AND = 1
OR = 2 OR = 2
@@ -288,6 +330,8 @@ class ItemKind(IntEnum):
reaching S rank on EXPERT difficulty or above. reaching S rank on EXPERT difficulty or above.
""" """
STAGE = 13
class FavoriteItemKind(IntEnum): class FavoriteItemKind(IntEnum):
MUSIC = 1 MUSIC = 1

View File

@@ -108,6 +108,7 @@ class ChuniFrontend(FE_Base):
Route("/avatar", self.render_GET_avatar, methods=['GET']), Route("/avatar", self.render_GET_avatar, methods=['GET']),
Route("/update.map-icon", self.update_map_icon, methods=['POST']), Route("/update.map-icon", self.update_map_icon, methods=['POST']),
Route("/update.system-voice", self.update_system_voice, 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.userbox", self.update_userbox, methods=['POST']),
Route("/update.avatar", self.update_avatar, methods=['POST']), Route("/update.avatar", self.update_avatar, methods=['POST']),
Route("/update.name", self.update_name, methods=['POST']), Route("/update.name", self.update_name, methods=['POST']),
@@ -141,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. # 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) 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) 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( resp = Response(template.render(
title=f"{self.core_config.server.name} | {self.nav_name}", title=f"{self.core_config.server.name} | {self.nav_name}",
@@ -155,7 +157,9 @@ class ChuniFrontend(FE_Base):
map_icons=map_icons, map_icons=map_icons,
system_voices=system_voices, system_voices=system_voices,
total_map_icons=total_map_icons, 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") ), media_type="text/html; charset=utf-8")
if usr_sesh.chunithm_version >= 0: if usr_sesh.chunithm_version >= 0:
@@ -404,6 +408,31 @@ class ChuniFrontend(FE_Base):
return (items, len(rows)) 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]: async def get_available_nameplates(self, version: int, profile: Row) -> Tuple[List[Dict], int]:
items = dict() items = dict()
rows = await self.data.static.get_nameplates(version) rows = await self.data.static.get_nameplates(version)
@@ -651,6 +680,22 @@ class ChuniFrontend(FE_Base):
return RedirectResponse("/game/chuni/", 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: async def update_userbox(self, request: Request) -> bytes:
usr_sesh = self.validate_session(request) usr_sesh = self.validate_session(request)
if not usr_sesh: if not usr_sesh:

4
titles/chuni/img/stage/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
# Ignore everything in this directory
*
# Except this file
!.gitignore

View File

@@ -40,6 +40,7 @@ from .sunplus import ChuniSunPlus
from .luminous import ChuniLuminous from .luminous import ChuniLuminous
from .luminousplus import ChuniLuminousPlus from .luminousplus import ChuniLuminousPlus
from .verse import ChuniVerse from .verse import ChuniVerse
from .xverse import ChuniXVerse
class ChuniServlet(BaseServlet): class ChuniServlet(BaseServlet):
@@ -70,7 +71,8 @@ class ChuniServlet(BaseServlet):
ChuniSunPlus, ChuniSunPlus,
ChuniLuminous, ChuniLuminous,
ChuniLuminousPlus, ChuniLuminousPlus,
ChuniVerse ChuniVerse,
ChuniXVerse,
] ]
self.logger = logging.getLogger("chuni") self.logger = logging.getLogger("chuni")
@@ -119,6 +121,7 @@ class ChuniServlet(BaseServlet):
f"{ChuniConstants.VER_CHUNITHM_LUMINOUS}_chn": 8, f"{ChuniConstants.VER_CHUNITHM_LUMINOUS}_chn": 8,
ChuniConstants.VER_CHUNITHM_LUMINOUS_PLUS: 56, ChuniConstants.VER_CHUNITHM_LUMINOUS_PLUS: 56,
ChuniConstants.VER_CHUNITHM_VERSE: 42, ChuniConstants.VER_CHUNITHM_VERSE: 42,
ChuniConstants.VER_CHUNITHM_X_VERSE: 14,
} }
for version, keys in self.game_cfg.crypto.keys.items(): for version, keys in self.game_cfg.crypto.keys.items():
@@ -280,8 +283,10 @@ class ChuniServlet(BaseServlet):
internal_ver = ChuniConstants.VER_CHUNITHM_LUMINOUS internal_ver = ChuniConstants.VER_CHUNITHM_LUMINOUS
elif version >= 225 and version < 230: # LUMINOUS PLUS elif version >= 225 and version < 230: # LUMINOUS PLUS
internal_ver = ChuniConstants.VER_CHUNITHM_LUMINOUS_PLUS internal_ver = ChuniConstants.VER_CHUNITHM_LUMINOUS_PLUS
elif version >= 230: # VERSE elif version >= 230 and version < 240: # VERSE
internal_ver = ChuniConstants.VER_CHUNITHM_VERSE internal_ver = ChuniConstants.VER_CHUNITHM_VERSE
elif version >= 240: # X-VERSE
internal_ver = ChuniConstants.VER_CHUNITHM_X_VERSE
elif game_code == "SDGS": # Int elif game_code == "SDGS": # Int
if version < 105: # SUPERSTAR if version < 105: # SUPERSTAR
internal_ver = ChuniConstants.VER_CHUNITHM_CRYSTAL_PLUS internal_ver = ChuniConstants.VER_CHUNITHM_CRYSTAL_PLUS
@@ -299,8 +304,10 @@ class ChuniServlet(BaseServlet):
internal_ver = ChuniConstants.VER_CHUNITHM_SUN_PLUS internal_ver = ChuniConstants.VER_CHUNITHM_SUN_PLUS
elif version >= 130 and version < 135: # LUMINOUS elif version >= 130 and version < 135: # LUMINOUS
internal_ver = ChuniConstants.VER_CHUNITHM_LUMINOUS internal_ver = ChuniConstants.VER_CHUNITHM_LUMINOUS
elif version >= 135: # LUMINOUS PLUS elif version >= 135 and version < 140: # LUMINOUS PLUS
internal_ver = ChuniConstants.VER_CHUNITHM_LUMINOUS_PLUS internal_ver = ChuniConstants.VER_CHUNITHM_LUMINOUS_PLUS
elif version >= 140:
internal_ver = ChuniConstants.VER_CHUNITHM_VERSE
elif game_code == "SDHJ": # Chn elif game_code == "SDHJ": # Chn
if version < 110: # NEW if version < 110: # NEW
internal_ver = ChuniConstants.VER_CHUNITHM_NEW internal_ver = ChuniConstants.VER_CHUNITHM_NEW

View File

@@ -40,6 +40,8 @@ class ChuniNew(ChuniBase):
return "225" return "225"
elif self.version == ChuniConstants.VER_CHUNITHM_VERSE: elif self.version == ChuniConstants.VER_CHUNITHM_VERSE:
return "230" return "230"
elif self.version == ChuniConstants.VER_CHUNITHM_X_VERSE:
return "240"
async def handle_get_game_setting_api_request(self, data: Dict) -> Dict: async def handle_get_game_setting_api_request(self, data: Dict) -> Dict:
# use UTC time and convert it to JST time by adding +9 # use UTC time and convert it to JST time by adding +9

View File

@@ -68,6 +68,8 @@ class ChuniReader(BaseReader):
await self.read_map_icon(f"{dir}/mapIcon", 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_system_voice(f"{dir}/systemVoice", this_opt_id)
await self.read_unlock_challenge(f"{dir}/unlockChallenge") await self.read_unlock_challenge(f"{dir}/unlockChallenge")
await self.read_linked_verse(f"{dir}/linkedVerse")
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: 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"): for root, dirs, files in walk(f"{root_dir}loginBonusPreset"):
@@ -537,6 +539,65 @@ class ChuniReader(BaseReader):
else: else:
self.logger.warning(f"Failed to unlock challenge {id}") 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: def copy_image(self, filename: str, src_dir: str, dst_dir: str) -> None:
# Convert the image to webp 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) file_src = path.join(src_dir, filename)

View File

@@ -314,6 +314,37 @@ unlock_challenge = Table(
mysql_charset="utf8mb4", 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): class ChuniItemData(BaseData):
async def get_oldest_free_matching(self, version: int) -> Optional[Row]: async def get_oldest_free_matching(self, version: int) -> Optional[Row]:
@@ -394,7 +425,6 @@ class ChuniItemData(BaseData):
async def is_favorite( async def is_favorite(
self, user_id: int, version: int, fav_id: int, fav_kind: int = 1 self, user_id: int, version: int, fav_id: int, fav_kind: int = 1
) -> bool: ) -> bool:
sql = favorite.select( sql = favorite.select(
and_( and_(
favorite.c.version == version, favorite.c.version == version,
@@ -849,3 +879,22 @@ class ChuniItemData(BaseData):
if result is None: if result is None:
return None return None
return result.fetchall() 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)

View File

@@ -132,6 +132,9 @@ profile = Table(
Column("avatarFront", Integer, server_default="0"), Column("avatarFront", Integer, server_default="0"),
Column("avatarSkin", Integer, server_default="0"), Column("avatarSkin", Integer, server_default="0"),
Column("avatarHead", 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"), UniqueConstraint("user", "version", name="chuni_profile_profile_uk"),
mysql_charset="utf8mb4", mysql_charset="utf8mb4",
) )
@@ -474,6 +477,17 @@ class ChuniProfileData(BaseData):
return False return False
return True return True
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: 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( sql = profile.update((profile.c.user == user_id) & (profile.c.version == version)).values(
nameplateId=new_nameplate, nameplateId=new_nameplate,

View File

@@ -40,7 +40,7 @@ events = Table(
Column("name", String(255)), Column("name", String(255)),
Column("startDate", TIMESTAMP, server_default=func.now()), Column("startDate", TIMESTAMP, server_default=func.now()),
Column("enabled", Boolean, server_default="1"), 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"), UniqueConstraint("version", "eventId", name="chuni_static_events_uk"),
mysql_charset="utf8mb4", mysql_charset="utf8mb4",
) )
@@ -58,7 +58,7 @@ music = Table(
Column("genre", String(255)), Column("genre", String(255)),
Column("jacketPath", String(255)), Column("jacketPath", String(255)),
Column("worldsEndTag", String(7)), 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"), UniqueConstraint("version", "songId", "chartId", name="chuni_static_music_uk"),
mysql_charset="utf8mb4", mysql_charset="utf8mb4",
) )
@@ -74,7 +74,7 @@ charge = Table(
Column("consumeType", Integer), Column("consumeType", Integer),
Column("sellingAppeal", Boolean), Column("sellingAppeal", Boolean),
Column("enabled", Boolean, server_default="1"), 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"), UniqueConstraint("version", "chargeId", name="chuni_static_charge_uk"),
mysql_charset="utf8mb4", mysql_charset="utf8mb4",
) )
@@ -92,7 +92,7 @@ avatar = Table(
Column("isEnabled", Boolean, server_default="1"), Column("isEnabled", Boolean, server_default="1"),
Column("defaultHave", Boolean, server_default="0"), Column("defaultHave", Boolean, server_default="0"),
Column("sortName", String(255)), 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"), UniqueConstraint("version", "avatarAccessoryId", name="chuni_static_avatar_uk"),
mysql_charset="utf8mb4", mysql_charset="utf8mb4",
) )
@@ -108,7 +108,7 @@ nameplate = Table(
Column("isEnabled", Boolean, server_default="1"), Column("isEnabled", Boolean, server_default="1"),
Column("defaultHave", Boolean, server_default="0"), Column("defaultHave", Boolean, server_default="0"),
Column("sortName", String(255)), 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"), UniqueConstraint("version", "nameplateId", name="chuni_static_nameplate_uk"),
mysql_charset="utf8mb4", mysql_charset="utf8mb4",
) )
@@ -128,7 +128,7 @@ character = Table(
Column("imagePath3", String(255)), Column("imagePath3", String(255)),
Column("isEnabled", Boolean, server_default="1"), Column("isEnabled", Boolean, server_default="1"),
Column("defaultHave", Boolean, server_default="0"), 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"), UniqueConstraint("version", "characterId", name="chuni_static_character_uk"),
mysql_charset="utf8mb4", mysql_charset="utf8mb4",
) )
@@ -143,7 +143,7 @@ trophy = Table(
Column("rareType", Integer), Column("rareType", Integer),
Column("isEnabled", Boolean, server_default="1"), Column("isEnabled", Boolean, server_default="1"),
Column("defaultHave", Boolean, server_default="0"), 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"), UniqueConstraint("version", "trophyId", name="chuni_static_trophy_uk"),
mysql_charset="utf8mb4", mysql_charset="utf8mb4",
) )
@@ -159,7 +159,7 @@ map_icon = Table(
Column("iconPath", String(255)), Column("iconPath", String(255)),
Column("isEnabled", Boolean, server_default="1"), Column("isEnabled", Boolean, server_default="1"),
Column("defaultHave", Boolean, server_default="0"), 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"), UniqueConstraint("version", "mapIconId", name="chuni_static_mapicon_uk"),
mysql_charset="utf8mb4", mysql_charset="utf8mb4",
) )
@@ -175,7 +175,7 @@ system_voice = Table(
Column("imagePath", String(255)), Column("imagePath", String(255)),
Column("isEnabled", Boolean, server_default="1"), Column("isEnabled", Boolean, server_default="1"),
Column("defaultHave", Boolean, server_default="0"), 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"), UniqueConstraint("version", "voiceId", name="chuni_static_systemvoice_uk"),
mysql_charset="utf8mb4", mysql_charset="utf8mb4",
) )
@@ -197,7 +197,7 @@ gachas = Table(
Column("endDate", TIMESTAMP, server_default="2038-01-01 00:00:00.0"), 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("noticeStartDate", TIMESTAMP, server_default="2018-01-01 00:00:00.0"),
Column("noticeEndDate", TIMESTAMP, server_default="2038-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"), UniqueConstraint("version", "gachaId", "gachaName", name="chuni_static_gachas_uk"),
mysql_charset="utf8mb4", mysql_charset="utf8mb4",
) )
@@ -218,7 +218,7 @@ cards = Table(
Column("combo", Integer, nullable=False), Column("combo", Integer, nullable=False),
Column("chain", Integer, nullable=False), Column("chain", Integer, nullable=False),
Column("skillName", String(255), 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"), UniqueConstraint("version", "cardId", name="chuni_static_cards_uk"),
mysql_charset="utf8mb4", mysql_charset="utf8mb4",
) )
@@ -304,7 +304,45 @@ unlock_challenge = Table(
Column("courseId4", Integer), Column("courseId4", Integer),
Column("courseId5", Integer), Column("courseId5", Integer),
UniqueConstraint( UniqueConstraint(
"version", "unlockChallengeId", name="chuni_static_unlock_challenge_pk" "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", mysql_charset="utf8mb4",
) )
@@ -1232,3 +1270,108 @@ class ChuniStaticData(BaseData):
if result is None: if result is None:
return None return None
return result.fetchall() 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()

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> <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> </tr>
{% endif %} {% 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> </table>
</div> </div>
</div> </div>
@@ -111,6 +117,21 @@
</div> </div>
{% endif %} {% 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="col-lg-8 m-auto mt-3">
<div class="card bg-card rounded"> <div class="card bg-card rounded">
<table class="table-large table-rowdistinct"> <table class="table-large table-rowdistinct">
@@ -201,6 +222,10 @@ items = {
"map-icon": ["{{ map_icons|length }}", "{{ profile.mapIconId }}"], "map-icon": ["{{ map_icons|length }}", "{{ profile.mapIconId }}"],
"system-voice":["{{ system_voices|length }}", "{{ profile.voiceId }}"] "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); types = Object.keys(items);
function changeItem(type, id, name) { function changeItem(type, id, name) {

317
titles/chuni/xverse.py Normal file
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,
}