From 2cbf34dc287c66846c6cfac7363d34bbaa550eb2 Mon Sep 17 00:00:00 2001 From: beerpsi Date: Thu, 1 Jan 2026 21:35:23 +0000 Subject: [PATCH 1/2] CHUNITHM X-VERSE support (#238) Reviewed-on: https://gitea.tendokyu.moe/Hay1tsme/artemis/pulls/238 Co-authored-by: beerpsi Co-committed-by: beerpsi --- .../versions/8b57e9646449_linked_verse.py | 82 +++++ docs/game_specific_info.md | 1 + example_config/chuni.yaml | 9 +- readme.md | 1 + titles/chuni/base.py | 6 +- titles/chuni/const.py | 44 ++- titles/chuni/index.py | 13 +- titles/chuni/new.py | 2 + titles/chuni/read.py | 33 ++ titles/chuni/schema/item.py | 51 ++- titles/chuni/schema/profile.py | 3 + titles/chuni/schema/static.py | 75 +++++ titles/chuni/xverse.py | 317 ++++++++++++++++++ 13 files changed, 628 insertions(+), 9 deletions(-) create mode 100644 core/data/alembic/versions/8b57e9646449_linked_verse.py create mode 100644 titles/chuni/xverse.py 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, + } From 8408d30dc5511abc1b6b8dcf515bd81793570b44 Mon Sep 17 00:00:00 2001 From: Dniel97 Date: Thu, 1 Jan 2026 20:40:27 +0100 Subject: [PATCH 2/2] chuni: add stage import and frontend config --- ...rse.py => 8b57e9646449_chunithm_xverse.py} | 24 ++++- example_config/chuni.yaml | 1 + titles/chuni/const.py | 2 + titles/chuni/frontend.py | 47 ++++++++- titles/chuni/img/stage/.gitignore | 4 + titles/chuni/read.py | 28 ++++++ titles/chuni/schema/profile.py | 11 +++ titles/chuni/schema/static.py | 96 ++++++++++++++++--- titles/chuni/templates/chuni_index.jinja | 25 +++++ 9 files changed, 219 insertions(+), 19 deletions(-) rename core/data/alembic/versions/{8b57e9646449_linked_verse.py => 8b57e9646449_chunithm_xverse.py} (80%) create mode 100644 titles/chuni/img/stage/.gitignore diff --git a/core/data/alembic/versions/8b57e9646449_linked_verse.py b/core/data/alembic/versions/8b57e9646449_chunithm_xverse.py similarity index 80% rename from core/data/alembic/versions/8b57e9646449_linked_verse.py rename to core/data/alembic/versions/8b57e9646449_chunithm_xverse.py index 3f218ab..9ff77da 100644 --- a/core/data/alembic/versions/8b57e9646449_linked_verse.py +++ b/core/data/alembic/versions/8b57e9646449_chunithm_xverse.py @@ -1,4 +1,4 @@ -"""Linked VERSE +"""CHUNITHM X-VERSE Revision ID: 8b57e9646449 Revises: bdf710616ba4 @@ -18,6 +18,10 @@ 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), @@ -67,9 +71,20 @@ def upgrade(): 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"), + 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 ### @@ -79,4 +94,5 @@ def downgrade(): 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 ### diff --git a/example_config/chuni.yaml b/example_config/chuni.yaml index 088f23f..a4d7047 100644 --- a/example_config/chuni.yaml +++ b/example_config/chuni.yaml @@ -24,6 +24,7 @@ mods: nameplates: False trophies: False character_icons: False + stages: False version: 11: diff --git a/titles/chuni/const.py b/titles/chuni/const.py index a5dd0a1..582d239 100644 --- a/titles/chuni/const.py +++ b/titles/chuni/const.py @@ -329,6 +329,8 @@ class ItemKind(IntEnum): """This only applies to ULTIMA difficulties that are *not* unlocked by reaching S rank on EXPERT difficulty or above. """ + + STAGE = 13 class FavoriteItemKind(IntEnum): diff --git a/titles/chuni/frontend.py b/titles/chuni/frontend.py index aef8c1a..5745594 100644 --- a/titles/chuni/frontend.py +++ b/titles/chuni/frontend.py @@ -108,6 +108,7 @@ class ChuniFrontend(FE_Base): Route("/avatar", self.render_GET_avatar, methods=['GET']), Route("/update.map-icon", self.update_map_icon, methods=['POST']), Route("/update.system-voice", self.update_system_voice, methods=['POST']), + Route("/update.stage", self.update_stage, methods=['POST']), Route("/update.userbox", self.update_userbox, methods=['POST']), Route("/update.avatar", self.update_avatar, methods=['POST']), Route("/update.name", self.update_name, methods=['POST']), @@ -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. map_icons, total_map_icons = await self.get_available_map_icons(version, profile) system_voices, total_system_voices = await self.get_available_system_voices(version, profile) + stages, total_stages = await self.get_available_stages(version, profile) resp = Response(template.render( title=f"{self.core_config.server.name} | {self.nav_name}", @@ -155,7 +157,9 @@ class ChuniFrontend(FE_Base): map_icons=map_icons, system_voices=system_voices, total_map_icons=total_map_icons, - total_system_voices=total_system_voices + total_system_voices=total_system_voices, + stages=stages, + total_stages=total_stages ), media_type="text/html; charset=utf-8") if usr_sesh.chunithm_version >= 0: @@ -404,6 +408,31 @@ class ChuniFrontend(FE_Base): return (items, len(rows)) + async def get_available_stages(self, version: int, profile: Row) -> Tuple[List[Dict], int]: + if profile is None: + return ([], 0) + items = dict() + rows = await self.data.static.get_stages(version) + if rows is None: + return (items, 0) # can only happen with old db + + force_unlocked = self.game_cfg.mods.forced_item_unlocks("stages") + + user_stages = [] + if not force_unlocked: + user_stages = await self.data.item.get_items(profile.user, ItemKind.STAGE.value) + user_stages = [icon["itemId"] for icon in user_stages] + [profile.stageId] + + for row in rows: + if force_unlocked or row["defaultHave"] or row["stageId"] in user_stages: + item = dict() + item["id"] = row["stageId"] + item["name"] = row["name"] + item["imagePath"] = path.splitext(row["imagePath"])[0] + ".webp" + items[row["stageId"]] = item + + return (items, len(rows)) + async def get_available_nameplates(self, version: int, profile: Row) -> Tuple[List[Dict], int]: items = dict() rows = await self.data.static.get_nameplates(version) @@ -650,6 +679,22 @@ class ChuniFrontend(FE_Base): return RedirectResponse("/gate/?e=999", 303) return RedirectResponse("/game/chuni/", 303) + + async def update_stage(self, request: Request) -> bytes: + usr_sesh = self.validate_session(request) + if not usr_sesh: + return RedirectResponse("/gate/", 303) + + form_data = await request.form() + new_system_voice: str = form_data.get("id") + + if not new_system_voice: + return RedirectResponse("/gate/?e=4", 303) + + if not await self.data.profile.update_stage(usr_sesh.user_id, usr_sesh.chunithm_version, new_system_voice): + return RedirectResponse("/gate/?e=999", 303) + + return RedirectResponse("/game/chuni/", 303) async def update_userbox(self, request: Request) -> bytes: usr_sesh = self.validate_session(request) diff --git a/titles/chuni/img/stage/.gitignore b/titles/chuni/img/stage/.gitignore new file mode 100644 index 0000000..86d0cb2 --- /dev/null +++ b/titles/chuni/img/stage/.gitignore @@ -0,0 +1,4 @@ +# Ignore everything in this directory +* +# Except this file +!.gitignore \ No newline at end of file diff --git a/titles/chuni/read.py b/titles/chuni/read.py index b880094..84432c9 100644 --- a/titles/chuni/read.py +++ b/titles/chuni/read.py @@ -69,6 +69,7 @@ class ChuniReader(BaseReader): 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") + await self.read_stage(f"{dir}/stage", this_opt_id) async def read_login_bonus(self, root_dir: str, opt_id: Optional[int] = None) -> None: for root, dirs, files in walk(f"{root_dir}loginBonusPreset"): @@ -569,6 +570,33 @@ class ChuniReader(BaseReader): self.logger.info(f"Inserted Linked VERSE {id}") else: self.logger.warning(f"Failed to Linked VERSE {id}") + + async def read_stage(self, stage_dir: str, opt_id: Optional[int] = None) -> None: + for root, dirs, files in walk(stage_dir): + for dir in dirs: + if path.exists(f"{root}/{dir}/Stage.xml"): + with open(f"{root}/{dir}/Stage.xml", "r", encoding='utf-8') as fp: + strdata = fp.read() + + xml_root = ET.fromstring(strdata) + for name in xml_root.findall("name"): + id = name.find("id").text + name = name.find("str").text + for image in xml_root.findall("image"): + image_path = image.find("path").text + self.copy_image(image_path, f"{root}/{dir}", "titles/chuni/img/stage/") + default_have = xml_root.find("defaultHave").text == 'true' + disable_flag = xml_root.find("disableFlag") # may not exist in older data + is_enabled = True if (disable_flag is None or disable_flag.text == "false") else False + + result = await self.data.static.put_stage( + self.version, id, name, image_path, is_enabled, default_have, opt_id + ) + + if result is not None: + self.logger.info(f"Inserted stage {id}") + else: + self.logger.warning(f"Failed to insert stage {id}") def copy_image(self, filename: str, src_dir: str, dst_dir: str) -> None: # Convert the image to webp so we can easily display it in the frontend diff --git a/titles/chuni/schema/profile.py b/titles/chuni/schema/profile.py index da29da2..8bc719f 100644 --- a/titles/chuni/schema/profile.py +++ b/titles/chuni/schema/profile.py @@ -477,6 +477,17 @@ class ChuniProfileData(BaseData): return False 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: sql = profile.update((profile.c.user == user_id) & (profile.c.version == version)).values( nameplateId=new_nameplate, diff --git a/titles/chuni/schema/static.py b/titles/chuni/schema/static.py index d85d3f4..6db5374 100644 --- a/titles/chuni/schema/static.py +++ b/titles/chuni/schema/static.py @@ -40,7 +40,7 @@ events = Table( Column("name", String(255)), Column("startDate", TIMESTAMP, server_default=func.now()), Column("enabled", Boolean, server_default="1"), - Column("opt", ForeignKey("chuni_static_opt.id", ondelete="SET NULL", onupdate="cascade")), + Column("opt", BIGINT, ForeignKey("chuni_static_opt.id", ondelete="SET NULL", onupdate="cascade")), UniqueConstraint("version", "eventId", name="chuni_static_events_uk"), mysql_charset="utf8mb4", ) @@ -58,7 +58,7 @@ music = Table( Column("genre", String(255)), Column("jacketPath", String(255)), Column("worldsEndTag", String(7)), - Column("opt", ForeignKey("chuni_static_opt.id", ondelete="SET NULL", onupdate="cascade")), + Column("opt", BIGINT, ForeignKey("chuni_static_opt.id", ondelete="SET NULL", onupdate="cascade")), UniqueConstraint("version", "songId", "chartId", name="chuni_static_music_uk"), mysql_charset="utf8mb4", ) @@ -74,7 +74,7 @@ charge = Table( Column("consumeType", Integer), Column("sellingAppeal", Boolean), Column("enabled", Boolean, server_default="1"), - Column("opt", ForeignKey("chuni_static_opt.id", ondelete="SET NULL", onupdate="cascade")), + Column("opt", BIGINT, ForeignKey("chuni_static_opt.id", ondelete="SET NULL", onupdate="cascade")), UniqueConstraint("version", "chargeId", name="chuni_static_charge_uk"), mysql_charset="utf8mb4", ) @@ -92,7 +92,7 @@ avatar = Table( Column("isEnabled", Boolean, server_default="1"), Column("defaultHave", Boolean, server_default="0"), Column("sortName", String(255)), - Column("opt", ForeignKey("chuni_static_opt.id", ondelete="SET NULL", onupdate="cascade")), + Column("opt", BIGINT, ForeignKey("chuni_static_opt.id", ondelete="SET NULL", onupdate="cascade")), UniqueConstraint("version", "avatarAccessoryId", name="chuni_static_avatar_uk"), mysql_charset="utf8mb4", ) @@ -108,7 +108,7 @@ nameplate = Table( Column("isEnabled", Boolean, server_default="1"), Column("defaultHave", Boolean, server_default="0"), Column("sortName", String(255)), - Column("opt", ForeignKey("chuni_static_opt.id", ondelete="SET NULL", onupdate="cascade")), + Column("opt", BIGINT, ForeignKey("chuni_static_opt.id", ondelete="SET NULL", onupdate="cascade")), UniqueConstraint("version", "nameplateId", name="chuni_static_nameplate_uk"), mysql_charset="utf8mb4", ) @@ -128,7 +128,7 @@ character = Table( Column("imagePath3", String(255)), Column("isEnabled", Boolean, server_default="1"), Column("defaultHave", Boolean, server_default="0"), - Column("opt", ForeignKey("chuni_static_opt.id", ondelete="SET NULL", onupdate="cascade")), + Column("opt", BIGINT, ForeignKey("chuni_static_opt.id", ondelete="SET NULL", onupdate="cascade")), UniqueConstraint("version", "characterId", name="chuni_static_character_uk"), mysql_charset="utf8mb4", ) @@ -143,7 +143,7 @@ trophy = Table( Column("rareType", Integer), Column("isEnabled", Boolean, server_default="1"), Column("defaultHave", Boolean, server_default="0"), - Column("opt", ForeignKey("chuni_static_opt.id", ondelete="SET NULL", onupdate="cascade")), + Column("opt", BIGINT, ForeignKey("chuni_static_opt.id", ondelete="SET NULL", onupdate="cascade")), UniqueConstraint("version", "trophyId", name="chuni_static_trophy_uk"), mysql_charset="utf8mb4", ) @@ -159,7 +159,7 @@ map_icon = Table( Column("iconPath", String(255)), Column("isEnabled", Boolean, server_default="1"), Column("defaultHave", Boolean, server_default="0"), - Column("opt", ForeignKey("chuni_static_opt.id", ondelete="SET NULL", onupdate="cascade")), + Column("opt", BIGINT, ForeignKey("chuni_static_opt.id", ondelete="SET NULL", onupdate="cascade")), UniqueConstraint("version", "mapIconId", name="chuni_static_mapicon_uk"), mysql_charset="utf8mb4", ) @@ -175,7 +175,7 @@ system_voice = Table( Column("imagePath", String(255)), Column("isEnabled", Boolean, server_default="1"), Column("defaultHave", Boolean, server_default="0"), - Column("opt", ForeignKey("chuni_static_opt.id", ondelete="SET NULL", onupdate="cascade")), + Column("opt", BIGINT, ForeignKey("chuni_static_opt.id", ondelete="SET NULL", onupdate="cascade")), UniqueConstraint("version", "voiceId", name="chuni_static_systemvoice_uk"), mysql_charset="utf8mb4", ) @@ -197,7 +197,7 @@ gachas = Table( Column("endDate", TIMESTAMP, server_default="2038-01-01 00:00:00.0"), Column("noticeStartDate", TIMESTAMP, server_default="2018-01-01 00:00:00.0"), Column("noticeEndDate", TIMESTAMP, server_default="2038-01-01 00:00:00.0"), - Column("opt", ForeignKey("cm_static_opts.id", ondelete="SET NULL", onupdate="cascade")), + Column("opt", BIGINT,ForeignKey("cm_static_opts.id", ondelete="SET NULL", onupdate="cascade")), UniqueConstraint("version", "gachaId", "gachaName", name="chuni_static_gachas_uk"), mysql_charset="utf8mb4", ) @@ -218,7 +218,7 @@ cards = Table( Column("combo", Integer, nullable=False), Column("chain", Integer, nullable=False), Column("skillName", String(255), nullable=False), - Column("opt", ForeignKey("cm_static_opts.id", ondelete="SET NULL", onupdate="cascade")), + Column("opt", BIGINT, ForeignKey("cm_static_opts.id", ondelete="SET NULL", onupdate="cascade")), UniqueConstraint("version", "cardId", name="chuni_static_cards_uk"), mysql_charset="utf8mb4", ) @@ -304,13 +304,13 @@ unlock_challenge = Table( Column("courseId4", Integer), Column("courseId5", Integer), UniqueConstraint( - "version", "unlockChallengeId", name="chuni_static_unlock_challenge_pk" + "version", "unlockChallengeId", name="chuni_static_unlock_challenge_uk" ), mysql_charset="utf8mb4", ) -linked_verse: Table = Table( +linked_verse = Table( "chuni_static_linked_verse", metadata, Column("id", Integer, primary_key=True, nullable=False), @@ -325,7 +325,24 @@ linked_verse: Table = Table( Column("courseId4", Integer), Column("courseId5", Integer), UniqueConstraint( - "version", "linkedVerseId", name="chuni_static_linked_verse_pk" + "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", ) @@ -1307,3 +1324,54 @@ class ChuniStaticData(BaseData): 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() diff --git a/titles/chuni/templates/chuni_index.jinja b/titles/chuni/templates/chuni_index.jinja index 417b04f..02a5c62 100644 --- a/titles/chuni/templates/chuni_index.jinja +++ b/titles/chuni/templates/chuni_index.jinja @@ -79,6 +79,12 @@
{{ system_voices[profile.voiceId]["name"] if system_voices|length > 0 else "Server DB needs upgraded or is not populated with necessary data" }}
{% endif %} + {% if cur_version >= 18 %} + + Stage: +
{{ stages[profile.stageId]["name"] if stages|length > 0 else "Server DB needs upgraded or is not populated with necessary data" }}
+ + {% endif %} @@ -111,6 +117,21 @@ {% endif %} + {% if cur_version >= 18 %} + +
+
+ +
+ {% for item in stages.values() %} + {{ item[ + + {% endfor %} +
+
+
+ {% endif %} +
@@ -201,6 +222,10 @@ items = { "map-icon": ["{{ map_icons|length }}", "{{ profile.mapIconId }}"], "system-voice":["{{ system_voices|length }}", "{{ profile.voiceId }}"] }; +// STAGE introduced in X-VERSE +if ({{cur_version}} >= 18) { + items.stage = ["{{ stages|length }}", "{{ profile.stageId }}"] +} types = Object.keys(items); function changeItem(type, id, name) {