Compare commits

8 Commits

Author SHA1 Message Date
Dniel97
d75f62bcb4 Merge pull request 'importer fix + extras' (#241) from Keeboy/artemis:develop into develop
Reviewed-on: https://gitea.tendokyu.moe/Hay1tsme/artemis/pulls/241
Reviewed-by: Dniel97 <dniel97@noreply.gitea.tendokyu.moe>
2026-01-29 05:00:47 +00:00
Keeboy99
04019da9ac importer fix + extras 2026-01-29 11:43:10 +13:00
Hay1tsme
d91c21d047 Merge pull request '[chuni[ Bad profile subtrophy id defaults fix (issue #235)' (#239) from daydensteve/artemis_chuni_webui_improvements:chuni_subtrophy_db_fix into develop
Reviewed-on: https://gitea.tendokyu.moe/Hay1tsme/artemis/pulls/239
2026-01-09 02:16:20 +00:00
daydensteve
b3824f038f Fixed bad default subtrophy values (issue #235) 2026-01-08 20:34:28 -05:00
Dniel97
f2afb3cff5 Merge branch 'feature/chunithm/xverse' into develop 2026-01-01 20:47:29 +01:00
Dniel97
8408d30dc5 chuni: add stage import and frontend config 2026-01-01 20:40:27 +01:00
beerpsi
2cbf34dc28 CHUNITHM X-VERSE support (#238)
Reviewed-on: https://gitea.tendokyu.moe/Hay1tsme/artemis/pulls/238
Co-authored-by: beerpsi <beerpsi@duck.com>
Co-committed-by: beerpsi <beerpsi@duck.com>
2026-01-01 21:35:23 +00:00
beerpsi
29a52d2712 chuni: fix map area/unlock challenge conditions (#237)
- Document all map area/unlock challenge condition IDs
- Add conditions for missing secret maps in LUMINOUS PLUS/VERSE

Reviewed-on: https://gitea.tendokyu.moe/Hay1tsme/artemis/pulls/237
Reviewed-by: Dniel97 <dniel97@noreply.gitea.tendokyu.moe>
Co-authored-by: beerpsi <beerpsi@duck.com>
Co-committed-by: beerpsi <beerpsi@duck.com>
2025-12-31 14:37:46 +00:00
20 changed files with 1275 additions and 162 deletions

View File

@@ -0,0 +1,31 @@
"""chuni_subtrophy_db_fix
Revision ID: 318d52559e83
Revises: 8b57e9646449
Create Date: 2026-01-08 19:13:29.803912
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = '318d52559e83'
down_revision = '8b57e9646449'
branch_labels = None
depends_on = None
def upgrade():
op.alter_column('chuni_profile_data', 'trophyIdSub1', existing_type=mysql.INTEGER(), server_default='-1')
op.alter_column('chuni_profile_data', 'trophyIdSub2', existing_type=mysql.INTEGER(), server_default='-1')
# fix any current profiles where the bad defaults were used
op.execute("UPDATE chuni_profile_data SET trophyIdSub1=-1 WHERE trophyIdSub1 IS NULL")
op.execute("UPDATE chuni_profile_data SET trophyIdSub2=-1 WHERE trophyIdSub2 IS NULL")
def downgrade():
# dont bother "unfixing" the table
pass

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
@@ -112,31 +116,177 @@ class ChuniConstants:
return cls.VERSION_LUT.get(str(floor_to_nearest_005(ver)), None)
class MapAreaConditionType(IntEnum):
"""Condition types for the GetGameMapAreaConditionApi endpoint. Incomplete.
"""
Condition IDs for the `GetGameMapAreaConditionApi` and `GetGameUCConditionApi` requests.
For the MAP_CLEARED/MAP_AREA_CLEARED/TROPHY_OBTAINED conditions, the conditionId
is the map/map area/trophy.
For the RANK_*/ALL_JUSTICE conditions, the conditionId is songId * 100 + difficultyId.
For example, Halcyon [ULTIMA] would be 173 * 100 + 4 = 17304.
- "Item" or "locked item" refers to the map area, unlock challenge or
Linked VERSE locked using this system.
- "Chart ID" refers to musicID \\* 100 + difficulty, where difficulty is 0 for BASIC
up to 6 for WORLD'S END. For example, Halcyon ULTIMA is 17305.
"""
INVALID = 0
"""
Invalid condition type. Should cause the hidden item to be automatically unlocked,
but seemingly only works with map areas.
"""
ALWAYS_UNLOCKED = 0
MAP_CLEARED = 1
"""Finish the map with ID `conditionId`."""
MAP_AREA_CLEARED = 2
"""Finish the map area with ID `conditionId`."""
TROPHY_OBTAINED = 3
"""Unlock the trophy with ID `conditionId`."""
TROPHY_EQUIPPED = 4
"""
Equip the trophy with ID `conditionId`. The item is locked again when the trophy is
unequipped.
"""
NAMEPLATE_OBTAINED = 5
"""Unlock the nameplate with ID `conditionId`."""
NAMEPLATE_EQUIPPED = 6
"""
Equip the nameplate with ID `conditionId`. The item is locked again when the nameplate
is unequipped.
"""
CHARACTER_OBTAINED = 7
"""Unlock the character with ID `conditionId`."""
CHARACTER_EQUIPPED = 8
"""
Equip the character with ID `conditionId`. The item is locked again when the character
is unequipped.
"""
CHARACTER_TRANSFORM_EQUIPPED = 9
"""
Equip the character, with the character transform ID `conditionId`. The item is locked again
if the incorrect character is equipped, or the correct character is equipped with the wrong
transform.
"""
MUSIC_OBTAINED = 10
"""Unlock the music with ID `conditionId`."""
AVATAR_ACCESSORY_OBTAINED = 11
"""Unlock the avatar accessory with ID `conditionId`."""
AVATAR_ACCESSORY_EQUIPPED = 12
"""
Equip the avatar accessory with ID `conditionId`. The item is locked again when the avatar
accessory is unequipped.
"""
MAP_ICON_OBTAINED = 13
"""Unlock the map icon with ID `conditionId`."""
MAP_ICON_EQUIPPED = 14
"""
Equip the map icon with ID `conditionId`. The item is locked again when the map icon is
unequipped.
"""
SYSTEM_VOICE_OBTAINED = 15
"""Unlock the system voice with ID `conditionId`."""
SYSTEM_VOICE_EQUIPPED = 16
"""
Equip the system voice with ID `conditionId`. The item is locked again when the system voice
is unequipped.
"""
ALL_JUSTICE_CRITICAL = 17
"""Obtain ALL JUSTICE CRITICAL on the chart given by `conditionId`."""
RANK_SSSP = 18
"""Obtain rank SSS+ on the chart given by `conditionId`."""
RANK_SSS = 19
"""Obtain rank SSS on the chart given by `conditionId`."""
RANK_SSP = 20
"""Obtain rank SS+ on the chart given by `conditionId`."""
RANK_SS = 21
"""Obtain rank SS on the chart given by `conditionId`."""
RANK_SP = 22
"""Obtain rank S+ on the chart given by `conditionId`."""
RANK_S = 23
"""Obtain rank S on the chart given by `conditionId`."""
RANK_AAA = 24
"""Obtain rank AAA on the chart given by `conditionId`."""
RANK_AA = 25
"""Obtain rank AA on the chart given by `conditionId`."""
RANK_A = 26
"""Obtain rank A on the chart given by `conditionId`."""
MINIMUM_BEST_30_AVERAGE = 27
"""Obtain a best 30 average of at least `conditionId / 100`."""
ALL_JUSTICE = 28
"""Obtain ALL JUSTICE on the chart given by `conditionId`."""
FULL_COMBO = 29
"""Obtain FULL COMBO on the chart given by `conditionId`."""
UNLOCK_CHALLENGE_DISCOVERED = 30
"""Discover/unlock the unlock challenge with ID `conditionId`."""
UNLOCK_CHALLENGE_CLEARED = 31
"""Clear the unlock challenge with ID `conditionId`."""
MINIMUM_RATING = 32
"""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
@@ -179,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

@@ -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,9 @@ class ChuniServlet(BaseServlet):
f"{ChuniConstants.VER_CHUNITHM_LUMINOUS}_chn": 8,
ChuniConstants.VER_CHUNITHM_LUMINOUS_PLUS: 56,
ChuniConstants.VER_CHUNITHM_VERSE: 42,
f"{ChuniConstants.VER_CHUNITHM_VERSE}_chn": 37,
ChuniConstants.VER_CHUNITHM_X_VERSE: 14,
f"{ChuniConstants.VER_CHUNITHM_X_VERSE}_int": 96,
}
for version, keys in self.game_cfg.crypto.keys.items():
@@ -280,8 +285,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 +306,12 @@ 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 and version < 150: # VERSE
internal_ver = ChuniConstants.VER_CHUNITHM_VERSE
elif version >= 150: # X-VERSE
internal_ver = ChuniConstants.VER_CHUNITHM_X_VERSE
elif game_code == "SDHJ": # Chn
if version < 110: # NEW
internal_ver = ChuniConstants.VER_CHUNITHM_NEW
@@ -308,8 +319,10 @@ class ChuniServlet(BaseServlet):
version >= 110 and version < 120
): # NEW *Cursed but needed due to different encryption key
internal_ver = ChuniConstants.VER_CHUNITHM_NEW_PLUS
elif version >= 120: # LUMINOUS
elif version >= 120 and version < 130: # LUMINOUS
internal_ver = ChuniConstants.VER_CHUNITHM_LUMINOUS
elif version >= 130: # VERSE
internal_ver = ChuniConstants.VER_CHUNITHM_VERSE
if all(c in string.hexdigits for c in endpoint) and len(endpoint) == 32:
# If we get a 32 character long hex string, it's a hash and we're

View File

@@ -1,6 +1,8 @@
from datetime import timedelta
from typing import Dict
from sqlalchemy.engine import Row
from core.config import CoreConfig
from titles.chuni.config import ChuniConfig
from titles.chuni.const import (
@@ -11,6 +13,73 @@ from titles.chuni.const import (
from titles.chuni.sunplus import ChuniSunPlus
class MysticAreaConditions:
"""The "Mystic Rainbow of <VERSION>" map is a special reward map for obtaining
rainbow statues. There's one gold statue area that's unlocked when at least one
original map is finished, and additional rainbow statue areas are added as new
original maps are added.
"""
def __init__(
self, events_by_id: dict[int, Row], map_area_1_id: int, date_time_format: str
):
self.events_by_id = events_by_id
self.date_time_format = date_time_format
self._map_area_1_conditions = {
"mapAreaId": map_area_1_id,
"length": 0,
"mapAreaConditionList": [],
}
self._map_area_1_added = False
self._conditions = []
@property
def conditions(self):
return self._conditions
def add_condition(
self, map_flag_event_id: int, condition_map_id: int, mystic_map_area_id: int
):
if (event := self.events_by_id.get(map_flag_event_id)) is None:
return
start_date = event["startDate"].strftime(self.date_time_format)
self._map_area_1_conditions["mapAreaConditionList"].append(
{
"type": MapAreaConditionType.MAP_CLEARED.value,
"conditionId": condition_map_id,
"logicalOpe": MapAreaConditionLogicalOperator.OR.value,
"startDate": start_date,
"endDate": "2099-12-31 00:00:00",
}
)
self._map_area_1_conditions["length"] = len(
self._map_area_1_conditions["mapAreaConditionList"]
)
if not self._map_area_1_added:
self._conditions.append(self._map_area_1_conditions)
self._map_area_1_added = True
self._conditions.append(
{
"mapAreaId": mystic_map_area_id,
"length": 1,
"mapAreaConditionList": [
{
"type": MapAreaConditionType.MAP_CLEARED.value,
"conditionId": condition_map_id,
"logicalOpe": MapAreaConditionLogicalOperator.AND.value,
"startDate": start_date,
"endDate": "2099-12-31 00:00:00",
}
],
}
)
class ChuniLuminous(ChuniSunPlus):
def __init__(self, core_cfg: CoreConfig, game_cfg: ChuniConfig) -> None:
super().__init__(core_cfg, game_cfg)
@@ -77,18 +146,18 @@ class ChuniLuminous(ChuniSunPlus):
async def handle_get_game_map_area_condition_api_request(self, data: Dict) -> Dict:
# There is no game data for this, everything is server side.
# However, we can selectively show/hide events as data is imported into the server.
events = await self.data.static.get_enabled_events(self.version)
events = await self.data.static.get_enabled_events(self.version) or []
event_by_id = {evt["eventId"]: evt for evt in events}
conditions = []
# The Mystic Rainbow of LUMINOUS map unlocks when any mainline LUMINOUS area
# (ep. I, ep. II, ep. III) are completed.
mystic_area_1_conditions = {
"mapAreaId": 3229301, # Mystic Rainbow of LUMINOUS Area 1
"length": 0,
"mapAreaConditionList": [],
}
mystic_area_1_added = False
mystic_conditions = MysticAreaConditions(
event_by_id, 3229301, self.date_time_format
)
mystic_conditions.add_condition(14005, 3020701, 3229302)
mystic_conditions.add_condition(14251, 3020702, 3229303)
mystic_conditions.add_condition(14481, 3020703, 3229304)
conditions += mystic_conditions.conditions
# Secret AREA: MUSIC GAME
if 14029 in event_by_id:
@@ -229,114 +298,6 @@ class ChuniLuminous(ChuniSunPlus):
]
)
# LUMINOUS ep. I
if 14005 in event_by_id:
start_date = event_by_id[14005]["startDate"].strftime(self.date_time_format)
if not mystic_area_1_added:
conditions.append(mystic_area_1_conditions)
mystic_area_1_added = True
mystic_area_1_conditions["length"] += 1
mystic_area_1_conditions["mapAreaConditionList"].append(
{
"type": MapAreaConditionType.MAP_CLEARED.value,
"conditionId": 3020701,
"logicalOpe": MapAreaConditionLogicalOperator.OR.value,
"startDate": start_date,
"endDate": "2099-12-31 00:00:00.0",
}
)
conditions.append(
{
"mapAreaId": 3229302, # Mystic Rainbow of LUMINOUS Area 2,
"length": 1,
# Unlocks when LUMINOUS ep. I is completed.
"mapAreaConditionList": [
{
"type": MapAreaConditionType.MAP_CLEARED.value,
"conditionId": 3020701,
"logicalOpe": MapAreaConditionLogicalOperator.AND.value,
"startDate": start_date,
"endDate": "2099-12-31 00:00:00.0",
},
],
}
)
# LUMINOUS ep. II
if 14251 in event_by_id:
start_date = event_by_id[14251]["startDate"].strftime(self.date_time_format)
if not mystic_area_1_added:
conditions.append(mystic_area_1_conditions)
mystic_area_1_added = True
mystic_area_1_conditions["length"] += 1
mystic_area_1_conditions["mapAreaConditionList"].append(
{
"type": MapAreaConditionType.MAP_CLEARED.value,
"conditionId": 3020702,
"logicalOpe": MapAreaConditionLogicalOperator.OR.value,
"startDate": start_date,
"endDate": "2099-12-31 00:00:00.0",
}
)
conditions.append(
{
"mapAreaId": 3229303, # Mystic Rainbow of LUMINOUS Area 3,
"length": 1,
# Unlocks when LUMINOUS ep. II is completed.
"mapAreaConditionList": [
{
"type": MapAreaConditionType.MAP_CLEARED.value,
"conditionId": 3020702,
"logicalOpe": MapAreaConditionLogicalOperator.AND.value,
"startDate": start_date,
"endDate": "2099-12-31 00:00:00.0",
},
],
}
)
# LUMINOUS ep. III
if 14481 in event_by_id:
start_date = event_by_id[14481]["startDate"].strftime(self.date_time_format)
if not mystic_area_1_added:
conditions.append(mystic_area_1_conditions)
mystic_area_1_added = True
mystic_area_1_conditions["length"] += 1
mystic_area_1_conditions["mapAreaConditionList"].append(
{
"type": MapAreaConditionType.MAP_CLEARED.value,
"conditionId": 3020703,
"logicalOpe": MapAreaConditionLogicalOperator.OR.value,
"startDate": start_date,
"endDate": "2099-12-31 00:00:00.0",
}
)
conditions.append(
{
"mapAreaId": 3229304, # Mystic Rainbow of LUMINOUS Area 4,
"length": 1,
# Unlocks when LUMINOUS ep. III is completed.
"mapAreaConditionList": [
{
"type": MapAreaConditionType.MAP_CLEARED.value,
"conditionId": 3020703,
"logicalOpe": MapAreaConditionLogicalOperator.AND.value,
"startDate": start_date,
"endDate": "2099-12-31 00:00:00.0",
},
],
}
)
# 1UM1N0U5 ep. 111
if 14483 in event_by_id:
start_date = event_by_id[14483]["startDate"].replace(
@@ -381,14 +342,14 @@ class ChuniLuminous(ChuniSunPlus):
MapAreaConditionType.RANK_SSP.value,
MapAreaConditionType.RANK_SP.value,
MapAreaConditionType.RANK_S.value,
MapAreaConditionType.ALWAYS_UNLOCKED.value,
MapAreaConditionType.INVALID.value,
]
):
start = (start_date + timedelta(days=14 * (i + 1))).strftime(
self.date_time_format
)
if typ != MapAreaConditionType.ALWAYS_UNLOCKED.value:
if typ != MapAreaConditionType.INVALID.value:
end = (
start_date + timedelta(days=14 * (i + 2)) - timedelta(seconds=1)
).strftime(self.date_time_format)
@@ -407,7 +368,7 @@ class ChuniLuminous(ChuniSunPlus):
)
else:
end = "2099-12-31 00:00:00"
title_conditions.append(
{
"type": typ,
@@ -431,7 +392,7 @@ class ChuniLuminous(ChuniSunPlus):
# Ultimate Force
# For the first 14 days, the condition is to obtain all 9 "Key of ..." titles
# Afterwards, the condition is the 6 "Key of ..." titles that you can obtain
# by playing the 6 areas, as well as obtaining specific ranks on
# by playing the 6 areas, as well as obtaining specific ranks on
# [CRYSTAL_ACCESS] / Strange Love / βlαnoir
ultimate_force_conditions = []
@@ -473,7 +434,7 @@ class ChuniLuminous(ChuniSunPlus):
start = (start_date + timedelta(days=14 * (i + 1))).strftime(
self.date_time_format
)
end = (
start_date + timedelta(days=14 * (i + 2)) - timedelta(seconds=1)
).strftime(self.date_time_format)
@@ -487,7 +448,7 @@ class ChuniLuminous(ChuniSunPlus):
"startDate": start,
"endDate": end,
}
for condition_id in {109403, 212103, 244203}
for condition_id in {109403, 212103, 244203}
]
)

View File

@@ -4,7 +4,7 @@ from typing import Dict
from core.config import CoreConfig
from titles.chuni.config import ChuniConfig
from titles.chuni.const import ChuniConstants, MapAreaConditionLogicalOperator, MapAreaConditionType
from titles.chuni.luminous import ChuniLuminous
from titles.chuni.luminous import ChuniLuminous, MysticAreaConditions
class ChuniLuminousPlus(ChuniLuminous):
@@ -66,6 +66,158 @@ class ChuniLuminousPlus(ChuniLuminous):
events = await self.data.static.get_enabled_events(self.version)
event_by_id = {evt["eventId"]: evt for evt in events}
conditions = []
mystic_conditions = MysticAreaConditions(
event_by_id,
3229601,
self.date_time_format,
)
# Mystic Rainbow of LUMINOUS PLUS - LUMINOUS ep. IV
mystic_conditions.add_condition(15005, 3020704, 3229602)
# Mystic Rainbow of LUMINOUS PLUS - LUMINOUS ep. V
mystic_conditions.add_condition(15306, 3020705, 3229603)
# Mystic Rainbow of LUMINOUS PLUS - LUMINOUS ep. VI
mystic_conditions.add_condition(15451, 3020706, 3229604)
# Mystic Rainbow of LUMINOUS PLUS - LUMINOUS ep. VII
mystic_conditions.add_condition(15506, 3020707, 3229605)
conditions += mystic_conditions.conditions
# 1UM1N0U5 ep. 111 continues. The map is automatically unlocked after finishing
# LUMINOUS ep. III in LUMINOUS PLUS.
if ep_111 := event_by_id.get(15009):
start_date = ep_111["startDate"].strftime(self.date_time_format)
conditions.append({
"mapAreaId": 3229207,
"length": 1,
"mapAreaConditionList": [
{
"type": MapAreaConditionType.MAP_CLEARED.value,
"conditionId": 3020703,
"logicalOpe": MapAreaConditionLogicalOperator.AND.value,
"startDate": start_date,
"endDate": "2099-12-31 00:00:00",
},
],
})
# ■・■■■■■■・■
# Finish LUMINOUS ep. IV and obtain the title 「ここは…何処なんだ…?」.
if re_fiction_o := event_by_id.get(15032):
start_date = re_fiction_o["startDate"].strftime(self.date_time_format)
conditions.append({
"mapAreaId": 3229501,
"length": 2,
"mapAreaConditionList": [
{
"type": MapAreaConditionType.MAP_CLEARED.value,
"conditionId": 3020704,
"logicalOpe": MapAreaConditionLogicalOperator.AND.value,
"startDate": start_date,
"endDate": "2099-12-31 00:00:00",
},
{
"type": MapAreaConditionType.TROPHY_OBTAINED.value,
"conditionId": 7105,
"logicalOpe": MapAreaConditionLogicalOperator.AND.value,
"startDate": start_date,
"endDate": "2099-12-31 00:00:00",
}
]
})
# The Conductor's Path
# ALL JUSTICE CRITICAL 其のエメラルドを見よ MASTER.
if the_conductors_path := event_by_id.get(15033):
start_date = the_conductors_path["startDate"].strftime(self.date_time_format)
conditions.append({
"mapAreaId": 3229701,
"length": 1,
"mapAreaConditionList": [
{
"type": MapAreaConditionType.ALL_JUSTICE_CRITICAL.value,
"conditionId": 260003,
"logicalOpe": MapAreaConditionLogicalOperator.AND.value,
"startDate": start_date,
"endDate": "2099-12-31 00:00:00",
}
]
})
# Cave of RVESE
if episode__x__ := event_by_id.get(15254):
start_date = episode__x__["startDate"].strftime(self.date_time_format)
conditions.extend([
# Episode. _ _ X _ _ map area 1
# Finish the HARDCORE TANO*C collaboration map.
{
"mapAreaId": 2208801,
"length": 1,
"mapAreaConditionList": [
{
"type": MapAreaConditionType.MAP_CLEARED.value,
"conditionId": 2006533,
"logicalOpe": MapAreaConditionLogicalOperator.AND.value,
"startDate": start_date,
"endDate": "2099-12-31 00:00:00",
},
],
},
# Episode. _ _ X _ _ map area 2
# Equip the title 「第壱の石版【V】」 to access the map area.
{
"mapAreaId": 2208802,
"length": 1,
"mapAreaConditionList": [
{
"type": MapAreaConditionType.TROPHY_EQUIPPED.value,
"conditionId": 7107,
"logicalOpe": MapAreaConditionLogicalOperator.AND.value,
"startDate": start_date,
"endDate": "2099-12-31 00:00:00",
},
],
},
# Episode. _ _ X _ _ map area 3
# Equip the title 「第弐の石版【Λ】」 to access the map area.
{
"mapAreaId": 2208803,
"length": 1,
"mapAreaConditionList": [
{
"type": MapAreaConditionType.TROPHY_EQUIPPED.value,
"conditionId": 7104,
"logicalOpe": MapAreaConditionLogicalOperator.AND.value,
"startDate": start_date,
"endDate": "2099-12-31 00:00:00",
},
],
},
# Episode. _ _ X _ _ map area 4
# Complete the 3 other map areas.
{
"mapAreaId": 2208804,
"length": 3,
"mapAreaConditionList": [
{
"type": MapAreaConditionType.MAP_AREA_CLEARED.value,
"conditionId": area_id,
"logicalOpe": MapAreaConditionLogicalOperator.AND.value,
"startDate": start_date,
"endDate": "2099-12-31 00:00:00",
}
for area_id in range(2208801, 2208804)
],
},
])
# LUMINOUS ep. Ascension
if ep_ascension := event_by_id.get(15512):

View File

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

View File

@@ -68,6 +68,9 @@ 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")
if self.version >= ChuniConstants.VER_CHUNITHM_X_VERSE:
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 +539,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

View File

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

View File

@@ -26,8 +26,8 @@ profile = Table(
Column("frameId", Integer),
Column("isMaimai", Boolean),
Column("trophyId", Integer),
Column("trophyIdSub1", Integer),
Column("trophyIdSub2", Integer),
Column("trophyIdSub1", Integer, server_default="-1"),
Column("trophyIdSub2", Integer, server_default="-1"),
Column("userName", String(25)),
Column("isWebJoin", Boolean),
Column("playCount", Integer),
@@ -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,

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

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

View File

@@ -8,6 +8,7 @@ from titles.chuni.const import (
MapAreaConditionLogicalOperator,
MapAreaConditionType,
)
from titles.chuni.luminous import MysticAreaConditions
from titles.chuni.luminousplus import ChuniLuminousPlus
@@ -22,6 +23,38 @@ class ChuniVerse(ChuniLuminousPlus):
# Does CARD MAKER 1.35 work this far up?
user_data["lastDataVersion"] = "2.30.00"
return user_data
async def handle_get_game_map_area_condition_api_request(self, data: Dict) -> Dict:
# There is no game data for this, everything is server side.
# However, we can selectively show/hide events as data is imported into the server.
events = await self.data.static.get_enabled_events(self.version)
event_by_id = {evt["eventId"]: evt for evt in events}
conditions = []
mystic_conditions = MysticAreaConditions(
event_by_id,
3230401,
self.date_time_format,
)
# Mystic Rainbow of VERSE - VERSE ep. I
mystic_conditions.add_condition(16006, 3020798, 3230402)
# Mystic Rainbow of VERSE - VERSE ep. II
mystic_conditions.add_condition(16204, 3020799, 3230403)
# Mystic Rainbow of VERSE - VERSE ep. III
mystic_conditions.add_condition(16455, 3020800, 3230404)
# Mystic Rainbow of VERSE - VERSE ep. IV
mystic_conditions.add_condition(16607, 3020802, 3230405)
conditions += mystic_conditions.conditions
return {
"length": len(conditions),
"gameMapAreaConditionList": conditions,
}
async def handle_get_game_course_level_api_request(self, data: Dict) -> Dict:
unlock_challenges = await self.data.static.get_unlock_challenges(self.version)
@@ -81,9 +114,10 @@ class ChuniVerse(ChuniLuminousPlus):
unlock_condition = conditions.get(
unlock_challenge_id,
# default is to unlock for players above 5.00 rating
{
"type": MapAreaConditionType.TROPHY_OBTAINED.value, # always unlocked
"conditionId": 0,
"type": MapAreaConditionType.MINIMUM_RATING.value,
"conditionId": 500,
},
)
@@ -173,7 +207,7 @@ class ChuniVerse(ChuniLuminousPlus):
user_rec_music_list = [
{
"musicId": 1, # no idea
"musicId": 1, # a song the player recently played
# recMusicList is a semi colon-separated list of music IDs and their order comma separated
# for some reason, not all music ids are shown in game?!
"recMusicList": ";".join(
@@ -193,7 +227,8 @@ class ChuniVerse(ChuniLuminousPlus):
class UserRecRating:
ratingMin: int
ratingMax: int
# same as recMusicList in get_user_rec_music_api_request
# semicolon-delimited list of (musicId, level, sortingKey, score), in the
# same format as GetUserRecMusicApi
recMusicList: str
length: int

317
titles/chuni/xverse.py Normal file
View File

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