diff --git a/core/data/alembic/versions/8b57e9646449_chunithm_xverse.py b/core/data/alembic/versions/8b57e9646449_chunithm_xverse.py new file mode 100644 index 0000000..9ff77da --- /dev/null +++ b/core/data/alembic/versions/8b57e9646449_chunithm_xverse.py @@ -0,0 +1,98 @@ +"""CHUNITHM X-VERSE + +Revision ID: 8b57e9646449 +Revises: bdf710616ba4 +Create Date: 2025-12-12 16:09:07.530809 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "8b57e9646449" +down_revision = "bdf710616ba4" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column( + "chuni_profile_data", + sa.Column("stageId", sa.Integer(), nullable=False, server_default="99999"), + ) + op.create_table( + "chuni_static_linked_verse", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("version", sa.Integer(), nullable=False), + sa.Column("linkedVerseId", sa.Integer(), nullable=False), + sa.Column("name", sa.String(length=255), nullable=True), + sa.Column("isEnabled", sa.Boolean(), server_default="1", nullable=False), + sa.Column( + "startDate", sa.TIMESTAMP(), server_default=sa.text("now()"), nullable=True + ), + sa.Column("courseId1", sa.Integer(), nullable=True), + sa.Column("courseId2", sa.Integer(), nullable=True), + sa.Column("courseId3", sa.Integer(), nullable=True), + sa.Column("courseId4", sa.Integer(), nullable=True), + sa.Column("courseId5", sa.Integer(), nullable=True), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "version", "linkedVerseId", name="chuni_static_linked_verse_pk" + ), + mysql_charset="utf8mb4", + ) + op.create_table( + "chuni_item_linked_verse", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("user", sa.Integer(), nullable=False), + sa.Column("linkedVerseId", sa.Integer(), nullable=False), + sa.Column("progress", sa.String(length=255), nullable=True), + sa.Column("statusOpen", sa.Integer(), nullable=True), + sa.Column("statusUnlock", sa.Integer(), nullable=True), + sa.Column("isFirstClear", sa.Integer(), nullable=True), + sa.Column("numClear", sa.Integer(), nullable=True), + sa.Column("clearCourseId", sa.Integer(), nullable=True), + sa.Column("clearCourseLevel", sa.Integer(), nullable=True), + sa.Column("clearScore", sa.Integer(), nullable=True), + sa.Column("clearDate", sa.String(length=25), nullable=True), + sa.Column("clearUserId1", sa.Integer(), nullable=True), + sa.Column("clearUserId2", sa.Integer(), nullable=True), + sa.Column("clearUserId3", sa.Integer(), nullable=True), + sa.Column("clearUserName0", sa.String(length=20), nullable=True), + sa.Column("clearUserName1", sa.String(length=20), nullable=True), + sa.Column("clearUserName2", sa.String(length=20), nullable=True), + sa.Column("clearUserName3", sa.String(length=20), nullable=True), + sa.ForeignKeyConstraint( + ["user"], ["aime_user.id"], onupdate="cascade", ondelete="cascade" + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("user", "linkedVerseId", name="chuni_item_linked_verse_uk"), + mysql_charset="utf8mb4", + ) + op.create_table( + "chuni_static_stage", + sa.Column("id", sa.Integer(), primary_key=True, nullable=False), + sa.Column("version", sa.Integer(), nullable=False), + sa.Column("stageId", sa.Integer(), nullable=False), + sa.Column("name", sa.String(length=255)), + sa.Column("imagePath", sa.String(length=255)), + sa.Column("isEnabled", sa.Boolean(), server_default="1"), + sa.Column("defaultHave", sa.Boolean(), server_default="0"), + sa.Column("opt", sa.BIGINT(), sa.ForeignKey("chuni_static_opt.id", ondelete="SET NULL", onupdate="cascade")), + sa.UniqueConstraint( + "version", "stageId", name="chuni_static_stage_uk" + ), + mysql_charset="utf8mb4", + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("chuni_profile_data", "stageId") + op.drop_table("chuni_item_linked_verse") + op.drop_table("chuni_static_linked_verse") + op.drop_table("chuni_static_stage") + # ### end Alembic commands ### 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..a4d7047 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,13 @@ mods: # warning: This can result in pushing a lot of data, especially the userbox items. Recommended for local network use only. forced_item_unlocks: map_icons: False - system_voices: False + system_voices: False avatar_accessories: False nameplates: False trophies: False character_icons: False - + stages: False + version: 11: rom: 2.00.00 @@ -47,6 +48,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..582d239 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 @@ -287,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/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..84432c9 100644 --- a/titles/chuni/read.py +++ b/titles/chuni/read.py @@ -68,6 +68,8 @@ 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") + 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"): @@ -536,6 +538,65 @@ 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}") + + 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/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..8bc719f 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", ) @@ -474,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 0e0f2ca..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,7 +304,45 @@ 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( + "chuni_static_linked_verse", + metadata, + Column("id", Integer, primary_key=True, nullable=False), + Column("version", Integer, nullable=False), + Column("linkedVerseId", Integer, nullable=False), + Column("name", String(255)), + Column("isEnabled", Boolean, server_default="1", nullable=False), + Column("startDate", TIMESTAMP, server_default=func.now()), + Column("courseId1", Integer), + Column("courseId2", Integer), + Column("courseId3", Integer), + Column("courseId4", Integer), + Column("courseId5", Integer), + UniqueConstraint( + "version", "linkedVerseId", name="chuni_static_linked_verse_uk" + ), + mysql_charset="utf8mb4", +) + +stage = Table( + "chuni_static_stage", + metadata, + Column("id", Integer, primary_key=True, nullable=False), + Column("version", Integer, nullable=False), + Column("stageId", Integer, nullable=False), + Column("name", String(255)), + Column("imagePath", String(255)), + Column("isEnabled", Boolean, server_default="1"), + Column("defaultHave", Boolean, server_default="0"), + Column("opt", BIGINT, ForeignKey("chuni_static_opt.id", ondelete="SET NULL", onupdate="cascade")), + UniqueConstraint( + "version", "stageId", name="chuni_static_stage_uk" ), mysql_charset="utf8mb4", ) @@ -1232,3 +1270,108 @@ 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() + + 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 @@