diff --git a/core/data/alembic/versions/8b57e9646449_linked_verse.py b/core/data/alembic/versions/8b57e9646449_linked_verse.py new file mode 100644 index 0000000..3f218ab --- /dev/null +++ b/core/data/alembic/versions/8b57e9646449_linked_verse.py @@ -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 ### diff --git a/docs/game_specific_info.md b/docs/game_specific_info.md index 423e36d..9682bb0 100644 --- a/docs/game_specific_info.md +++ b/docs/game_specific_info.md @@ -69,6 +69,7 @@ Games listed below have been tested and confirmed working. | 15 | CHUNITHM LUMINOUS | | 16 | CHUNITHM LUMINOUS PLUS | | 17 | CHUNITHM VERSE | +| 18 | CHUNITHM X-VERSE | ### Importer diff --git a/example_config/chuni.yaml b/example_config/chuni.yaml index f9c2f20..088f23f 100644 --- a/example_config/chuni.yaml +++ b/example_config/chuni.yaml @@ -11,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 @@ -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. forced_item_unlocks: map_icons: False - system_voices: False + system_voices: False avatar_accessories: False nameplates: False trophies: False character_icons: False - + version: 11: rom: 2.00.00 @@ -47,6 +47,9 @@ version: 17: rom: 2.30.00 data: 2.30.00 + 18: + rom: 2.40.00 + data: 2.40.00 crypto: encrypted_only: False diff --git a/readme.md b/readme.md index 055bd81..ab405ef 100644 --- a/readme.md +++ b/readme.md @@ -39,6 +39,7 @@ Games listed below have been tested and confirmed working. Only game versions ol + LUMINOUS + LUMINOUS PLUS + VERSE + + X-VERSE + crossbeats REV. + Crossbeats REV. diff --git a/titles/chuni/base.py b/titles/chuni/base.py index d28a799..56f442a 100644 --- a/titles/chuni/base.py +++ b/titles/chuni/base.py @@ -1096,7 +1096,7 @@ class ChuniBase: 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"]: @@ -1104,6 +1104,10 @@ class ChuniBase: 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"} diff --git a/titles/chuni/const.py b/titles/chuni/const.py index 6d8c167..a5dd0a1 100644 --- a/titles/chuni/const.py +++ b/titles/chuni/const.py @@ -29,6 +29,7 @@ class ChuniConstants: VER_CHUNITHM_LUMINOUS = 15 VER_CHUNITHM_LUMINOUS_PLUS = 16 VER_CHUNITHM_VERSE = 17 + VER_CHUNITHM_X_VERSE = 18 VERSION_NAMES = [ "CHUNITHM", @@ -48,7 +49,8 @@ class ChuniConstants: "CHUNITHM SUN PLUS", "CHUNITHM LUMINOUS", "CHUNITHM LUMINOUS PLUS", - "CHUNITHM VERSE" + "CHUNITHM VERSE", + "CHUNITHM X-VERSE", ] SCORE_RANK_INTERVALS_OLD = [ @@ -100,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 @@ -246,6 +250,44 @@ class MapAreaConditionType(IntEnum): """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 OR = 2 diff --git a/titles/chuni/index.py b/titles/chuni/index.py index ce4e02e..ce331f1 100644 --- a/titles/chuni/index.py +++ b/titles/chuni/index.py @@ -40,6 +40,7 @@ from .sunplus import ChuniSunPlus from .luminous import ChuniLuminous from .luminousplus import ChuniLuminousPlus from .verse import ChuniVerse +from .xverse import ChuniXVerse class ChuniServlet(BaseServlet): @@ -70,7 +71,8 @@ class ChuniServlet(BaseServlet): ChuniSunPlus, ChuniLuminous, ChuniLuminousPlus, - ChuniVerse + ChuniVerse, + ChuniXVerse, ] self.logger = logging.getLogger("chuni") @@ -119,6 +121,7 @@ class ChuniServlet(BaseServlet): f"{ChuniConstants.VER_CHUNITHM_LUMINOUS}_chn": 8, ChuniConstants.VER_CHUNITHM_LUMINOUS_PLUS: 56, ChuniConstants.VER_CHUNITHM_VERSE: 42, + ChuniConstants.VER_CHUNITHM_X_VERSE: 14, } for version, keys in self.game_cfg.crypto.keys.items(): @@ -280,8 +283,10 @@ class ChuniServlet(BaseServlet): internal_ver = ChuniConstants.VER_CHUNITHM_LUMINOUS elif version >= 225 and version < 230: # LUMINOUS PLUS internal_ver = ChuniConstants.VER_CHUNITHM_LUMINOUS_PLUS - elif version >= 230: # VERSE + 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 @@ -299,8 +304,10 @@ class ChuniServlet(BaseServlet): 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 + elif version >= 135 and version < 140: # LUMINOUS PLUS internal_ver = ChuniConstants.VER_CHUNITHM_LUMINOUS_PLUS + elif version >= 140: + internal_ver = ChuniConstants.VER_CHUNITHM_VERSE elif game_code == "SDHJ": # Chn if version < 110: # NEW internal_ver = ChuniConstants.VER_CHUNITHM_NEW diff --git a/titles/chuni/new.py b/titles/chuni/new.py index 45e525d..76221cf 100644 --- a/titles/chuni/new.py +++ b/titles/chuni/new.py @@ -40,6 +40,8 @@ class ChuniNew(ChuniBase): 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 diff --git a/titles/chuni/read.py b/titles/chuni/read.py index 0b96b7b..b880094 100644 --- a/titles/chuni/read.py +++ b/titles/chuni/read.py @@ -68,6 +68,7 @@ class ChuniReader(BaseReader): 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") 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"): @@ -536,6 +537,38 @@ class ChuniReader(BaseReader): 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}") 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 diff --git a/titles/chuni/schema/item.py b/titles/chuni/schema/item.py index 138ef3b..d48f55b 100644 --- a/titles/chuni/schema/item.py +++ b/titles/chuni/schema/item.py @@ -314,6 +314,37 @@ unlock_challenge = Table( 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]: @@ -394,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, @@ -849,3 +879,22 @@ class ChuniItemData(BaseData): 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) diff --git a/titles/chuni/schema/profile.py b/titles/chuni/schema/profile.py index 45afbf6..da29da2 100644 --- a/titles/chuni/schema/profile.py +++ b/titles/chuni/schema/profile.py @@ -132,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", ) diff --git a/titles/chuni/schema/static.py b/titles/chuni/schema/static.py index 0e0f2ca..d85d3f4 100644 --- a/titles/chuni/schema/static.py +++ b/titles/chuni/schema/static.py @@ -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): async def put_login_bonus( self, @@ -1232,3 +1253,57 @@ class ChuniStaticData(BaseData): 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() diff --git a/titles/chuni/xverse.py b/titles/chuni/xverse.py new file mode 100644 index 0000000..56f51d1 --- /dev/null +++ b/titles/chuni/xverse.py @@ -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, + }