diff --git a/titles/chuni/const.py b/titles/chuni/const.py index f5642e3..6d8c167 100644 --- a/titles/chuni/const.py +++ b/titles/chuni/const.py @@ -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): diff --git a/titles/chuni/luminous.py b/titles/chuni/luminous.py index 9af38cd..48f31e7 100644 --- a/titles/chuni/luminous.py +++ b/titles/chuni/luminous.py @@ -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 " 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} ] ) diff --git a/titles/chuni/luminousplus.py b/titles/chuni/luminousplus.py index d33833f..045e4e5 100644 --- a/titles/chuni/luminousplus.py +++ b/titles/chuni/luminousplus.py @@ -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): diff --git a/titles/chuni/verse.py b/titles/chuni/verse.py index 3c933c3..55a4058 100644 --- a/titles/chuni/verse.py +++ b/titles/chuni/verse.py @@ -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