chuni: add stage import and frontend config

This commit is contained in:
Dniel97
2026-01-01 20:40:27 +01:00
parent 2cbf34dc28
commit 8408d30dc5
9 changed files with 219 additions and 19 deletions

View File

@@ -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 ###

View File

@@ -24,6 +24,7 @@ mods:
nameplates: False
trophies: False
character_icons: False
stages: False
version:
11:

View File

@@ -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):

View File

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

4
titles/chuni/img/stage/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
# Ignore everything in this directory
*
# Except this file
!.gitignore

View File

@@ -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

View File

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

View File

@@ -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()

View File

@@ -79,6 +79,12 @@
<td><div id="system-voice-name">{{ system_voices[profile.voiceId]["name"] if system_voices|length > 0 else "Server DB needs upgraded or is not populated with necessary data" }}</div></td>
</tr>
{% endif %}
{% if cur_version >= 18 %} <!-- STAGE introduced in X-VERSE -->
<tr>
<td>Stage:</td>
<td><div id="stage-name">{{ stages[profile.stageId]["name"] if stages|length > 0 else "Server DB needs upgraded or is not populated with necessary data" }}</div></td>
</tr>
{% endif %}
</table>
</div>
</div>
@@ -111,6 +117,21 @@
</div>
{% endif %}
{% if cur_version >= 18 %} <!-- STAGE introduced in X-VERSE -->
<!-- STAGE SELECTION -->
<div class="col-lg-8 m-auto mt-3 scrolling-lists">
<div class="card bg-card rounded">
<button class="collapsible">Stage:&nbsp;&nbsp;&nbsp;{{ stages|length }}/{{ total_stages }}</button>
<div id="scrollable-stage" class="collapsible-content">
{% for item in stages.values() %}
<img id="stage-{{ item["id"] }}" style="padding: 8px 8px;" onclick="saveItem('stage', '{{ item["id"] }}', '{{ item["name"] }}')" src="img/stage/{{ item["imagePath"] }}" alt="{{ item["name"] }}">
<span id="stage-br-{{ loop.index }}"></span>
{% endfor %}
</div>
</div>
</div>
{% endif %}
<div class="col-lg-8 m-auto mt-3">
<div class="card bg-card rounded">
<table class="table-large table-rowdistinct">
@@ -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) {