CHUNITHM X-VERSE support (#238)

Reviewed-on: https://gitea.tendokyu.moe/Hay1tsme/artemis/pulls/238
Co-authored-by: beerpsi <beerpsi@duck.com>
Co-committed-by: beerpsi <beerpsi@duck.com>
This commit is contained in:
beerpsi
2026-01-01 21:35:23 +00:00
committed by Dniel97
parent 29a52d2712
commit 2cbf34dc28
13 changed files with 628 additions and 9 deletions

View File

@@ -0,0 +1,82 @@
"""Linked 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.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.add_column(
"chuni_profile_data",
sa.Column("stageId", sa.Integer(), nullable=False, server_default="99999"),
)
# ### 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")
# ### 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

@@ -11,7 +11,7 @@ mods:
use_login_bonus: True 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 # 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 # note: quanity is not refreshed on "continue" after set - only on subsequent login
stock_tickets: stock_tickets:
stock_count: 99 stock_count: 99
# Allow use of all available customization items in frontend web ui # Allow use of all available customization items in frontend web ui
@@ -19,12 +19,12 @@ mods:
# warning: This can result in pushing a lot of data, especially the userbox items. Recommended for local network use only. # warning: This can result in pushing a lot of data, especially the userbox items. Recommended for local network use only.
forced_item_unlocks: forced_item_unlocks:
map_icons: False map_icons: False
system_voices: False system_voices: False
avatar_accessories: False avatar_accessories: False
nameplates: False nameplates: False
trophies: False trophies: False
character_icons: False character_icons: False
version: version:
11: 11:
rom: 2.00.00 rom: 2.00.00
@@ -47,6 +47,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

@@ -1096,7 +1096,7 @@ class ChuniBase:
for fav_id in added_ids: for fav_id in added_ids:
await self.data.item.put_favorite_music(user_id, self.version, fav_id) await self.data.item.put_favorite_music(user_id, self.version, fav_id)
# added in CHUNITHM VERSE # added in CHUNITHM VERSE
if "userUnlockChallengeList" in upsert: if "userUnlockChallengeList" in upsert:
for unlock_challenge in upsert["userUnlockChallengeList"]: for unlock_challenge in upsert["userUnlockChallengeList"]:
@@ -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

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,7 @@ 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")
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"):
@@ -536,6 +537,38 @@ class ChuniReader(BaseReader):
self.logger.info(f"Inserted unlock challenge {id}") self.logger.info(f"Inserted unlock challenge {id}")
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}")
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

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",
) )

View File

@@ -310,6 +310,27 @@ unlock_challenge = Table(
) )
linked_verse: Table = 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_pk"
),
mysql_charset="utf8mb4",
)
class ChuniStaticData(BaseData): class ChuniStaticData(BaseData):
async def put_login_bonus( async def put_login_bonus(
self, self,
@@ -1232,3 +1253,57 @@ 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()

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,
}