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>
This commit is contained in:
beerpsi
2025-12-31 14:37:46 +00:00
committed by Dniel97
parent 5ba0c8b04c
commit 29a52d2712
4 changed files with 393 additions and 137 deletions

View File

@@ -112,30 +112,138 @@ 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 MapAreaConditionLogicalOperator(Enum):

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

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