From e764da3dc6c476be7bd69267eebb8ad511bc3fbb Mon Sep 17 00:00:00 2001 From: qwint Date: Sun, 28 Jul 2024 16:27:39 -0500 Subject: [PATCH] HK: Options API updates, et al. (#3428) * updates HK to consistently use world.random, use world.options, don't use world = self.multiworld, and remove some things from the logicMixin * Update HK to new options dataclass * Move completion condition helpers to Rules.py * updates from review --- worlds/hk/Options.py | 6 +- worlds/hk/Rules.py | 39 ++++++++++ worlds/hk/__init__.py | 162 +++++++++++++++++------------------------- 3 files changed, 108 insertions(+), 99 deletions(-) diff --git a/worlds/hk/Options.py b/worlds/hk/Options.py index 38be2cd794a1..e2602036a24e 100644 --- a/worlds/hk/Options.py +++ b/worlds/hk/Options.py @@ -1,10 +1,12 @@ import typing import re +from dataclasses import dataclass, make_dataclass + from .ExtractedData import logic_options, starts, pool_options from .Rules import cost_terms from schema import And, Schema, Optional -from Options import Option, DefaultOnToggle, Toggle, Choice, Range, OptionDict, NamedRange, DeathLink +from Options import Option, DefaultOnToggle, Toggle, Choice, Range, OptionDict, NamedRange, DeathLink, PerGameCommonOptions from .Charms import vanilla_costs, names as charm_names if typing.TYPE_CHECKING: @@ -538,3 +540,5 @@ class CostSanityHybridChance(Range): }, **cost_sanity_weights } + +HKOptions = make_dataclass("HKOptions", [(name, option) for name, option in hollow_knight_options.items()], bases=(PerGameCommonOptions,)) diff --git a/worlds/hk/Rules.py b/worlds/hk/Rules.py index a3c7e13cf02b..e162e1dfa81c 100644 --- a/worlds/hk/Rules.py +++ b/worlds/hk/Rules.py @@ -49,3 +49,42 @@ def set_rules(hk_world: World): if term == "GEO": # No geo logic! continue add_rule(location, lambda state, term=term, amount=amount: state.count(term, player) >= amount) + + +def _hk_nail_combat(state, player) -> bool: + return state.has_any({'LEFTSLASH', 'RIGHTSLASH', 'UPSLASH'}, player) + + +def _hk_can_beat_thk(state, player) -> bool: + return ( + state.has('Opened_Black_Egg_Temple', player) + and (state.count('FIREBALL', player) + state.count('SCREAM', player) + state.count('QUAKE', player)) > 1 + and _hk_nail_combat(state, player) + and ( + state.has_any({'LEFTDASH', 'RIGHTDASH'}, player) + or state._hk_option(player, 'ProficientCombat') + ) + and state.has('FOCUS', player) + ) + + +def _hk_siblings_ending(state, player) -> bool: + return _hk_can_beat_thk(state, player) and state.has('WHITEFRAGMENT', player, 3) + + +def _hk_can_beat_radiance(state, player) -> bool: + return ( + state.has('Opened_Black_Egg_Temple', player) + and _hk_nail_combat(state, player) + and state.has('WHITEFRAGMENT', player, 3) + and state.has('DREAMNAIL', player) + and ( + (state.has('LEFTCLAW', player) and state.has('RIGHTCLAW', player)) + or state.has('WINGS', player) + ) + and (state.count('FIREBALL', player) + state.count('SCREAM', player) + state.count('QUAKE', player)) > 1 + and ( + (state.has('LEFTDASH', player, 2) and state.has('RIGHTDASH', player, 2)) # Both Shade Cloaks + or (state._hk_option(player, 'ProficientCombat') and state.has('QUAKE', player)) # or Dive + ) + ) diff --git a/worlds/hk/__init__.py b/worlds/hk/__init__.py index fbc6461f6aab..e5065876ddf3 100644 --- a/worlds/hk/__init__.py +++ b/worlds/hk/__init__.py @@ -10,9 +10,9 @@ from .Items import item_table, lookup_type_to_names, item_name_groups from .Regions import create_regions -from .Rules import set_rules, cost_terms +from .Rules import set_rules, cost_terms, _hk_can_beat_thk, _hk_siblings_ending, _hk_can_beat_radiance from .Options import hollow_knight_options, hollow_knight_randomize_options, Goal, WhitePalace, CostSanity, \ - shop_to_option + shop_to_option, HKOptions from .ExtractedData import locations, starts, multi_locations, location_to_region_lookup, \ event_names, item_effects, connectors, one_ways, vanilla_shop_costs, vanilla_location_costs from .Charms import names as charm_names @@ -142,7 +142,8 @@ class HKWorld(World): As the enigmatic Knight, you’ll traverse the depths, unravel its mysteries and conquer its evils. """ # from https://www.hollowknight.com game: str = "Hollow Knight" - option_definitions = hollow_knight_options + options_dataclass = HKOptions + options: HKOptions web = HKWeb() @@ -155,8 +156,8 @@ class HKWorld(World): charm_costs: typing.List[int] cached_filler_items = {} - def __init__(self, world, player): - super(HKWorld, self).__init__(world, player) + def __init__(self, multiworld, player): + super(HKWorld, self).__init__(multiworld, player) self.created_multi_locations: typing.Dict[str, typing.List[HKLocation]] = { location: list() for location in multi_locations } @@ -165,29 +166,29 @@ def __init__(self, world, player): self.vanilla_shop_costs = deepcopy(vanilla_shop_costs) def generate_early(self): - world = self.multiworld - charm_costs = world.RandomCharmCosts[self.player].get_costs(world.random) - self.charm_costs = world.PlandoCharmCosts[self.player].get_costs(charm_costs) - # world.exclude_locations[self.player].value.update(white_palace_locations) + options = self.options + charm_costs = options.RandomCharmCosts.get_costs(self.random) + self.charm_costs = options.PlandoCharmCosts.get_costs(charm_costs) + # options.exclude_locations.value.update(white_palace_locations) for term, data in cost_terms.items(): - mini = getattr(world, f"Minimum{data.option}Price")[self.player] - maxi = getattr(world, f"Maximum{data.option}Price")[self.player] + mini = getattr(options, f"Minimum{data.option}Price") + maxi = getattr(options, f"Maximum{data.option}Price") # if minimum > maximum, set minimum to maximum mini.value = min(mini.value, maxi.value) self.ranges[term] = mini.value, maxi.value - world.push_precollected(HKItem(starts[world.StartLocation[self.player].current_key], + self.multiworld.push_precollected(HKItem(starts[options.StartLocation.current_key], True, None, "Event", self.player)) def white_palace_exclusions(self): exclusions = set() - wp = self.multiworld.WhitePalace[self.player] + wp = self.options.WhitePalace if wp <= WhitePalace.option_nopathofpain: exclusions.update(path_of_pain_locations) if wp <= WhitePalace.option_kingfragment: exclusions.update(white_palace_checks) if wp == WhitePalace.option_exclude: exclusions.add("King_Fragment") - if self.multiworld.RandomizeCharms[self.player]: + if self.options.RandomizeCharms: # If charms are randomized, this will be junk-filled -- so transitions and events are not progression exclusions.update(white_palace_transitions) exclusions.update(white_palace_events) @@ -200,7 +201,7 @@ def create_regions(self): # check for any goal that godhome events are relevant to all_event_names = event_names.copy() - if self.multiworld.Goal[self.player] in [Goal.option_godhome, Goal.option_godhome_flower]: + if self.options.Goal in [Goal.option_godhome, Goal.option_godhome_flower]: from .GodhomeData import godhome_event_names all_event_names.update(set(godhome_event_names)) @@ -230,12 +231,12 @@ def create_items(self): pool: typing.List[HKItem] = [] wp_exclusions = self.white_palace_exclusions() junk_replace: typing.Set[str] = set() - if self.multiworld.RemoveSpellUpgrades[self.player]: + if self.options.RemoveSpellUpgrades: junk_replace.update(("Abyss_Shriek", "Shade_Soul", "Descending_Dark")) randomized_starting_items = set() for attr, items in randomizable_starting_items.items(): - if getattr(self.multiworld, attr)[self.player]: + if getattr(self.options, attr): randomized_starting_items.update(items) # noinspection PyShadowingNames @@ -257,7 +258,7 @@ def _add(item_name: str, location_name: str, randomized: bool): if item_name in junk_replace: item_name = self.get_filler_item_name() - item = self.create_item(item_name) if not vanilla or location_name == "Start" or self.multiworld.AddUnshuffledLocations[self.player] else self.create_event(item_name) + item = self.create_item(item_name) if not vanilla or location_name == "Start" or self.options.AddUnshuffledLocations else self.create_event(item_name) if location_name == "Start": if item_name in randomized_starting_items: @@ -281,55 +282,55 @@ def _add(item_name: str, location_name: str, randomized: bool): location.progress_type = LocationProgressType.EXCLUDED for option_key, option in hollow_knight_randomize_options.items(): - randomized = getattr(self.multiworld, option_key)[self.player] - if all([not randomized, option_key in logicless_options, not self.multiworld.AddUnshuffledLocations[self.player]]): + randomized = getattr(self.options, option_key) + if all([not randomized, option_key in logicless_options, not self.options.AddUnshuffledLocations]): continue for item_name, location_name in zip(option.items, option.locations): if item_name in junk_replace: item_name = self.get_filler_item_name() - if (item_name == "Crystal_Heart" and self.multiworld.SplitCrystalHeart[self.player]) or \ - (item_name == "Mothwing_Cloak" and self.multiworld.SplitMothwingCloak[self.player]): + if (item_name == "Crystal_Heart" and self.options.SplitCrystalHeart) or \ + (item_name == "Mothwing_Cloak" and self.options.SplitMothwingCloak): _add("Left_" + item_name, location_name, randomized) _add("Right_" + item_name, "Split_" + location_name, randomized) continue - if item_name == "Mantis_Claw" and self.multiworld.SplitMantisClaw[self.player]: + if item_name == "Mantis_Claw" and self.options.SplitMantisClaw: _add("Left_" + item_name, "Left_" + location_name, randomized) _add("Right_" + item_name, "Right_" + location_name, randomized) continue - if item_name == "Shade_Cloak" and self.multiworld.SplitMothwingCloak[self.player]: - if self.multiworld.random.randint(0, 1): + if item_name == "Shade_Cloak" and self.options.SplitMothwingCloak: + if self.random.randint(0, 1): item_name = "Left_Mothwing_Cloak" else: item_name = "Right_Mothwing_Cloak" - if item_name == "Grimmchild2" and self.multiworld.RandomizeGrimmkinFlames[self.player] and self.multiworld.RandomizeCharms[self.player]: + if item_name == "Grimmchild2" and self.options.RandomizeGrimmkinFlames and self.options.RandomizeCharms: _add("Grimmchild1", location_name, randomized) continue _add(item_name, location_name, randomized) - if self.multiworld.RandomizeElevatorPass[self.player]: + if self.options.RandomizeElevatorPass: randomized = True _add("Elevator_Pass", "Elevator_Pass", randomized) for shop, locations in self.created_multi_locations.items(): - for _ in range(len(locations), getattr(self.multiworld, shop_to_option[shop])[self.player].value): + for _ in range(len(locations), getattr(self.options, shop_to_option[shop]).value): loc = self.create_location(shop) unfilled_locations += 1 # Balance the pool item_count = len(pool) - additional_shop_items = max(item_count - unfilled_locations, self.multiworld.ExtraShopSlots[self.player].value) + additional_shop_items = max(item_count - unfilled_locations, self.options.ExtraShopSlots.value) # Add additional shop items, as needed. if additional_shop_items > 0: shops = list(shop for shop, locations in self.created_multi_locations.items() if len(locations) < 16) - if not self.multiworld.EggShopSlots[self.player].value: # No eggshop, so don't place items there + if not self.options.EggShopSlots: # No eggshop, so don't place items there shops.remove('Egg_Shop') if shops: for _ in range(additional_shop_items): - shop = self.multiworld.random.choice(shops) + shop = self.random.choice(shops) loc = self.create_location(shop) unfilled_locations += 1 if len(self.created_multi_locations[shop]) >= 16: @@ -355,7 +356,7 @@ def sort_shops_by_cost(self): loc.costs = costs def apply_costsanity(self): - setting = self.multiworld.CostSanity[self.player].value + setting = self.options.CostSanity.value if not setting: return # noop @@ -369,10 +370,10 @@ def _compute_weights(weights: dict, desc: str) -> typing.Dict[str, int]: return {k: v for k, v in weights.items() if v} - random = self.multiworld.random - hybrid_chance = getattr(self.multiworld, f"CostSanityHybridChance")[self.player].value + random = self.random + hybrid_chance = getattr(self.options, f"CostSanityHybridChance").value weights = { - data.term: getattr(self.multiworld, f"CostSanity{data.option}Weight")[self.player].value + data.term: getattr(self.options, f"CostSanity{data.option}Weight").value for data in cost_terms.values() } weights_geoless = dict(weights) @@ -427,22 +428,22 @@ def _compute_weights(weights: dict, desc: str) -> typing.Dict[str, int]: location.sort_costs() def set_rules(self): - world = self.multiworld + multiworld = self.multiworld player = self.player - goal = world.Goal[player] + goal = self.options.Goal if goal == Goal.option_hollowknight: - world.completion_condition[player] = lambda state: state._hk_can_beat_thk(player) + multiworld.completion_condition[player] = lambda state: _hk_can_beat_thk(state, player) elif goal == Goal.option_siblings: - world.completion_condition[player] = lambda state: state._hk_siblings_ending(player) + multiworld.completion_condition[player] = lambda state: _hk_siblings_ending(state, player) elif goal == Goal.option_radiance: - world.completion_condition[player] = lambda state: state._hk_can_beat_radiance(player) + multiworld.completion_condition[player] = lambda state: _hk_can_beat_radiance(state, player) elif goal == Goal.option_godhome: - world.completion_condition[player] = lambda state: state.count("Defeated_Pantheon_5", player) + multiworld.completion_condition[player] = lambda state: state.count("Defeated_Pantheon_5", player) elif goal == Goal.option_godhome_flower: - world.completion_condition[player] = lambda state: state.count("Godhome_Flower_Quest", player) + multiworld.completion_condition[player] = lambda state: state.count("Godhome_Flower_Quest", player) else: # Any goal - world.completion_condition[player] = lambda state: state._hk_can_beat_thk(player) or state._hk_can_beat_radiance(player) + multiworld.completion_condition[player] = lambda state: _hk_can_beat_thk(state, player) or _hk_can_beat_radiance(state, player) set_rules(self) @@ -450,8 +451,8 @@ def fill_slot_data(self): slot_data = {} options = slot_data["options"] = {} - for option_name in self.option_definitions: - option = getattr(self.multiworld, option_name)[self.player] + for option_name in hollow_knight_options: + option = getattr(self.options, option_name) try: optionvalue = int(option.value) except TypeError: @@ -460,10 +461,10 @@ def fill_slot_data(self): options[option_name] = optionvalue # 32 bit int - slot_data["seed"] = self.multiworld.per_slot_randoms[self.player].randint(-2147483647, 2147483646) + slot_data["seed"] = self.random.randint(-2147483647, 2147483646) # Backwards compatibility for shop cost data (HKAP < 0.1.0) - if not self.multiworld.CostSanity[self.player]: + if not self.options.CostSanity: for shop, terms in shop_cost_types.items(): unit = cost_terms[next(iter(terms))].option if unit == "Geo": @@ -498,7 +499,7 @@ def create_location(self, name: str, vanilla=False) -> HKLocation: basename = name if name in shop_cost_types: costs = { - term: self.multiworld.random.randint(*self.ranges[term]) + term: self.random.randint(*self.ranges[term]) for term in shop_cost_types[name] } elif name in vanilla_location_costs: @@ -512,7 +513,7 @@ def create_location(self, name: str, vanilla=False) -> HKLocation: region = self.multiworld.get_region("Menu", self.player) - if vanilla and not self.multiworld.AddUnshuffledLocations[self.player]: + if vanilla and not self.options.AddUnshuffledLocations: loc = HKLocation(self.player, name, None, region, costs=costs, vanilla=vanilla, basename=basename) @@ -560,26 +561,26 @@ def remove(self, state, item: HKItem) -> bool: return change @classmethod - def stage_write_spoiler(cls, world: MultiWorld, spoiler_handle): - hk_players = world.get_game_players(cls.game) + def stage_write_spoiler(cls, multiworld: MultiWorld, spoiler_handle): + hk_players = multiworld.get_game_players(cls.game) spoiler_handle.write('\n\nCharm Notches:') for player in hk_players: - name = world.get_player_name(player) + name = multiworld.get_player_name(player) spoiler_handle.write(f'\n{name}\n') - hk_world: HKWorld = world.worlds[player] + hk_world: HKWorld = multiworld.worlds[player] for charm_number, cost in enumerate(hk_world.charm_costs): spoiler_handle.write(f"\n{charm_names[charm_number]}: {cost}") spoiler_handle.write('\n\nShop Prices:') for player in hk_players: - name = world.get_player_name(player) + name = multiworld.get_player_name(player) spoiler_handle.write(f'\n{name}\n') - hk_world: HKWorld = world.worlds[player] + hk_world: HKWorld = multiworld.worlds[player] - if world.CostSanity[player].value: + if hk_world.options.CostSanity: for loc in sorted( ( - loc for loc in itertools.chain(*(region.locations for region in world.get_regions(player))) + loc for loc in itertools.chain(*(region.locations for region in multiworld.get_regions(player))) if loc.costs ), key=operator.attrgetter('name') ): @@ -603,15 +604,15 @@ def get_filler_item_name(self) -> str: 'RandomizeGeoRocks', 'RandomizeSoulTotems', 'RandomizeLoreTablets', 'RandomizeJunkPitChests', 'RandomizeRancidEggs' ): - if getattr(self.multiworld, group): + if getattr(self.options, group): fillers.extend(item for item in hollow_knight_randomize_options[group].items if item not in exclusions) self.cached_filler_items[self.player] = fillers - return self.multiworld.random.choice(self.cached_filler_items[self.player]) + return self.random.choice(self.cached_filler_items[self.player]) -def create_region(world: MultiWorld, player: int, name: str, location_names=None) -> Region: - ret = Region(name, player, world) +def create_region(multiworld: MultiWorld, player: int, name: str, location_names=None) -> Region: + ret = Region(name, player, multiworld) if location_names: for location in location_names: loc_id = HKWorld.location_name_to_id.get(location, None) @@ -684,42 +685,7 @@ def _hk_notches(self, player: int, *notches: int) -> int: return sum(self.multiworld.worlds[player].charm_costs[notch] for notch in notches) def _hk_option(self, player: int, option_name: str) -> int: - return getattr(self.multiworld, option_name)[player].value + return getattr(self.multiworld.worlds[player].options, option_name).value def _hk_start(self, player, start_location: str) -> bool: - return self.multiworld.StartLocation[player] == start_location - - def _hk_nail_combat(self, player: int) -> bool: - return self.has_any({'LEFTSLASH', 'RIGHTSLASH', 'UPSLASH'}, player) - - def _hk_can_beat_thk(self, player: int) -> bool: - return ( - self.has('Opened_Black_Egg_Temple', player) - and (self.count('FIREBALL', player) + self.count('SCREAM', player) + self.count('QUAKE', player)) > 1 - and self._hk_nail_combat(player) - and ( - self.has_any({'LEFTDASH', 'RIGHTDASH'}, player) - or self._hk_option(player, 'ProficientCombat') - ) - and self.has('FOCUS', player) - ) - - def _hk_siblings_ending(self, player: int) -> bool: - return self._hk_can_beat_thk(player) and self.has('WHITEFRAGMENT', player, 3) - - def _hk_can_beat_radiance(self, player: int) -> bool: - return ( - self.has('Opened_Black_Egg_Temple', player) - and self._hk_nail_combat(player) - and self.has('WHITEFRAGMENT', player, 3) - and self.has('DREAMNAIL', player) - and ( - (self.has('LEFTCLAW', player) and self.has('RIGHTCLAW', player)) - or self.has('WINGS', player) - ) - and (self.count('FIREBALL', player) + self.count('SCREAM', player) + self.count('QUAKE', player)) > 1 - and ( - (self.has('LEFTDASH', player, 2) and self.has('RIGHTDASH', player, 2)) # Both Shade Cloaks - or (self._hk_option(player, 'ProficientCombat') and self.has('QUAKE', player)) # or Dive - ) - ) + return self.multiworld.worlds[player].options.StartLocation == start_location