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
This commit is contained in:
qwint 2024-07-28 16:27:39 -05:00 committed by GitHub
parent ab0903679c
commit e764da3dc6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 108 additions and 99 deletions

View File

@ -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 @@ hollow_knight_options: typing.Dict[str, type(Option)] = {
},
**cost_sanity_weights
}
HKOptions = make_dataclass("HKOptions", [(name, option) for name, option in hollow_knight_options.items()], bases=(PerGameCommonOptions,))

View File

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

View File

@ -10,9 +10,9 @@ logger = logging.getLogger("Hollow Knight")
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, youll 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 @@ class HKWorld(World):
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 @@ class HKWorld(World):
# 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 @@ class HKWorld(World):
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 @@ class HKWorld(World):
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 @@ class HKWorld(World):
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 @@ class HKWorld(World):
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 @@ class HKWorld(World):
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 @@ class HKWorld(World):
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 @@ class HKWorld(World):
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 @@ class HKWorld(World):
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 @@ class HKWorld(World):
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 @@ class HKWorld(World):
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 @@ class HKWorld(World):
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 @@ class HKWorld(World):
'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 @@ class HKLogicMixin(LogicMixin):
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