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) {