Archipelago/worlds/hk/__init__.py

692 lines
29 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
import logging
import typing
from copy import deepcopy
import itertools
import operator
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, _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, 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
from BaseClasses import Region, Location, MultiWorld, Item, LocationProgressType, Tutorial, ItemClassification
from worlds.AutoWorld import World, LogicMixin, WebWorld
path_of_pain_locations = {
"Soul_Totem-Path_of_Pain_Below_Thornskip",
"Lore_Tablet-Path_of_Pain_Entrance",
"Soul_Totem-Path_of_Pain_Left_of_Lever",
"Soul_Totem-Path_of_Pain_Hidden",
"Soul_Totem-Path_of_Pain_Entrance",
"Soul_Totem-Path_of_Pain_Final",
"Soul_Totem-Path_of_Pain_Below_Lever",
"Soul_Totem-Path_of_Pain_Second",
"Journal_Entry-Seal_of_Binding",
"Warp-Path_of_Pain_Complete",
"Defeated_Path_of_Pain_Arena",
"Completed_Path_of_Pain",
# Path of Pain transitions
"White_Palace_17[right1]", "White_Palace_17[bot1]",
"White_Palace_18[top1]", "White_Palace_18[right1]",
"White_Palace_19[left1]", "White_Palace_19[top1]",
"White_Palace_20[bot1]",
}
white_palace_transitions = {
# Event-Transitions:
# "Grubfather_2",
"White_Palace_01[left1]", "White_Palace_01[right1]", "White_Palace_01[top1]",
"White_Palace_02[left1]",
"White_Palace_03_hub[bot1]", "White_Palace_03_hub[left1]", "White_Palace_03_hub[left2]",
"White_Palace_03_hub[right1]", "White_Palace_03_hub[top1]",
"White_Palace_04[right2]", "White_Palace_04[top1]",
"White_Palace_05[left1]", "White_Palace_05[left2]", "White_Palace_05[right1]", "White_Palace_05[right2]",
"White_Palace_06[bot1]", "White_Palace_06[left1]", "White_Palace_06[top1]", "White_Palace_07[bot1]",
"White_Palace_07[top1]", "White_Palace_08[left1]", "White_Palace_08[right1]",
"White_Palace_09[right1]",
"White_Palace_11[door2]",
"White_Palace_12[bot1]", "White_Palace_12[right1]",
"White_Palace_13[left1]", "White_Palace_13[left2]", "White_Palace_13[left3]", "White_Palace_13[right1]",
"White_Palace_14[bot1]", "White_Palace_14[right1]",
"White_Palace_15[left1]", "White_Palace_15[right1]", "White_Palace_15[right2]",
"White_Palace_16[left1]", "White_Palace_16[left2]",
}
white_palace_checks = {
"Soul_Totem-White_Palace_Final",
"Soul_Totem-White_Palace_Entrance",
"Lore_Tablet-Palace_Throne",
"Soul_Totem-White_Palace_Left",
"Lore_Tablet-Palace_Workshop",
"Soul_Totem-White_Palace_Hub",
"Soul_Totem-White_Palace_Right"
}
white_palace_events = {
"White_Palace_03_hub",
"White_Palace_13",
"White_Palace_01",
"Palace_Entrance_Lantern_Lit",
"Palace_Left_Lantern_Lit",
"Palace_Right_Lantern_Lit",
"Palace_Atrium_Gates_Opened",
"Warp-White_Palace_Atrium_to_Palace_Grounds",
"Warp-White_Palace_Entrance_to_Palace_Grounds",
}
progression_charms = {
# Baldur Killers
"Grubberfly's_Elegy", "Weaversong", "Glowing_Womb",
# Spore Shroom spots in fungal wastes and elsewhere
"Spore_Shroom",
# Tuk gives egg,
"Defender's_Crest",
# Unlocks Grimm Troupe
"Grimmchild1", "Grimmchild2"
}
# Vanilla placements of the following items have no impact on logic, thus we can avoid creating these items and
# locations entirely when the option to randomize them is disabled.
logicless_options = {
"RandomizeVesselFragments", "RandomizeGeoChests", "RandomizeJunkPitChests", "RandomizeRelics",
"RandomizeMaps", "RandomizeJournalEntries", "RandomizeGeoRocks", "RandomizeBossGeo",
"RandomizeLoreTablets", "RandomizeSoulTotems",
}
# Options that affect vanilla starting items
randomizable_starting_items: typing.Dict[str, typing.Tuple[str, ...]] = {
"RandomizeFocus": ("Focus",),
"RandomizeSwim": ("Swim",),
"RandomizeNail": ('Upslash', 'Leftslash', 'Rightslash')
}
# Shop cost types.
shop_cost_types: typing.Dict[str, typing.Tuple[str, ...]] = {
"Egg_Shop": ("RANCIDEGGS",),
"Grubfather": ("GRUBS",),
"Seer": ("ESSENCE",),
"Salubra_(Requires_Charms)": ("CHARMS", "GEO"),
"Sly": ("GEO",),
"Sly_(Key)": ("GEO",),
"Iselda": ("GEO",),
"Salubra": ("GEO",),
"Leg_Eater": ("GEO",),
}
class HKWeb(WebWorld):
tutorials = [Tutorial(
"Mod Setup and Use Guide",
"A guide to playing Hollow Knight with Archipelago.",
"English",
"setup_en.md",
"setup/en",
["Ijwu"]
)]
bug_report_page = "https://github.com/Ijwu/Archipelago.HollowKnight/issues/new?assignees=&labels=bug%2C+needs+investigation&template=bug_report.md&title="
class HKWorld(World):
"""Beneath the fading town of Dirtmouth sleeps a vast, ancient kingdom. Many are drawn beneath the surface,
searching for riches, or glory, or answers to old secrets.
As the enigmatic Knight, youll traverse the depths, unravel its mysteries and conquer its evils.
""" # from https://www.hollowknight.com
game: str = "Hollow Knight"
options_dataclass = HKOptions
options: HKOptions
web = HKWeb()
item_name_to_id = {name: data.id for name, data in item_table.items()}
location_name_to_id = {location_name: location_id for location_id, location_name in
enumerate(locations, start=0x1000000)}
item_name_groups = item_name_groups
ranges: typing.Dict[str, typing.Tuple[int, int]]
charm_costs: typing.List[int]
cached_filler_items = {}
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
}
self.ranges = {}
self.created_shop_items = 0
self.vanilla_shop_costs = deepcopy(vanilla_shop_costs)
def generate_early(self):
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(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
self.multiworld.push_precollected(HKItem(starts[options.StartLocation.current_key],
True, None, "Event", self.player))
def white_palace_exclusions(self):
exclusions = set()
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.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)
return exclusions
def create_regions(self):
menu_region: Region = create_region(self.multiworld, self.player, 'Menu')
self.multiworld.regions.append(menu_region)
# wp_exclusions = self.white_palace_exclusions()
# check for any goal that godhome events are relevant to
all_event_names = event_names.copy()
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))
# Link regions
for event_name in all_event_names:
#if event_name in wp_exclusions:
# continue
loc = HKLocation(self.player, event_name, None, menu_region)
loc.place_locked_item(HKItem(event_name,
True, #event_name not in wp_exclusions,
None, "Event", self.player))
menu_region.locations.append(loc)
for entry_transition, exit_transition in connectors.items():
#if entry_transition in wp_exclusions:
# continue
if exit_transition:
# if door logic fulfilled -> award vanilla target as event
loc = HKLocation(self.player, entry_transition, None, menu_region)
loc.place_locked_item(HKItem(exit_transition,
True, #exit_transition not in wp_exclusions,
None, "Event", self.player))
menu_region.locations.append(loc)
def create_items(self):
unfilled_locations = 0
# Generate item pool and associated locations (paired in HK)
pool: typing.List[HKItem] = []
wp_exclusions = self.white_palace_exclusions()
junk_replace: typing.Set[str] = set()
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.options, attr):
randomized_starting_items.update(items)
# noinspection PyShadowingNames
def _add(item_name: str, location_name: str, randomized: bool):
"""
Adds a pairing of an item and location, doing appropriate checks to see if it should be vanilla or not.
"""
nonlocal unfilled_locations
vanilla = not randomized
excluded = False
if not vanilla and location_name in wp_exclusions:
if location_name == 'King_Fragment':
excluded = True
else:
vanilla = True
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.options.AddUnshuffledLocations else self.create_event(item_name)
if location_name == "Start":
if item_name in randomized_starting_items:
if item_name == "Focus":
self.create_location("Focus")
unfilled_locations += 1
pool.append(item)
else:
self.multiworld.push_precollected(item)
return
if vanilla:
location = self.create_vanilla_location(location_name, item)
else:
pool.append(item)
if location_name in multi_locations: # Create shop locations later.
return
location = self.create_location(location_name)
unfilled_locations += 1
if excluded:
location.progress_type = LocationProgressType.EXCLUDED
for option_key, option in hollow_knight_randomize_options.items():
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.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.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.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.options.RandomizeGrimmkinFlames and self.options.RandomizeCharms:
_add("Grimmchild1", location_name, randomized)
continue
_add(item_name, location_name, randomized)
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.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.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.options.EggShopSlots: # No eggshop, so don't place items there
shops.remove('Egg_Shop')
if shops:
for _ in range(additional_shop_items):
shop = self.random.choice(shops)
loc = self.create_location(shop)
unfilled_locations += 1
if len(self.created_multi_locations[shop]) >= 16:
shops.remove(shop)
if not shops:
break
# Create filler items, if needed
if item_count < unfilled_locations:
pool.extend(self.create_item(self.get_filler_item_name()) for _ in range(unfilled_locations - item_count))
self.multiworld.itempool += pool
self.apply_costsanity()
self.sort_shops_by_cost()
def sort_shops_by_cost(self):
for shop, locations in self.created_multi_locations.items():
randomized_locations = list(loc for loc in locations if not loc.vanilla)
prices = sorted(
(loc.costs for loc in randomized_locations),
key=lambda costs: (len(costs),) + tuple(costs.values())
)
for loc, costs in zip(randomized_locations, prices):
loc.costs = costs
def apply_costsanity(self):
setting = self.options.CostSanity.value
if not setting:
return # noop
def _compute_weights(weights: dict, desc: str) -> typing.Dict[str, int]:
if all(x == 0 for x in weights.values()):
logger.warning(
f"All {desc} weights were zero for {self.multiworld.player_name[self.player]}."
f" Setting them to one instead."
)
weights = {k: 1 for k in weights}
return {k: v for k, v in weights.items() if v}
random = self.random
hybrid_chance = getattr(self.options, f"CostSanityHybridChance").value
weights = {
data.term: getattr(self.options, f"CostSanity{data.option}Weight").value
for data in cost_terms.values()
}
weights_geoless = dict(weights)
del weights_geoless["GEO"]
weights = _compute_weights(weights, "CostSanity")
weights_geoless = _compute_weights(weights_geoless, "Geoless CostSanity")
if hybrid_chance > 0:
if len(weights) == 1:
logger.warning(
f"Only one cost type is available for CostSanity in {self.multiworld.player_name[self.player]}'s world."
f" CostSanityHybridChance will not trigger."
)
if len(weights_geoless) == 1:
logger.warning(
f"Only one cost type is available for CostSanity in {self.multiworld.player_name[self.player]}'s world."
f" CostSanityHybridChance will not trigger in geoless locations."
)
for region in self.multiworld.get_regions(self.player):
for location in region.locations:
if location.vanilla:
continue
if not location.costs:
continue
if location.name == "Vessel_Fragment-Basin":
continue
if setting == CostSanity.option_notshops and location.basename in multi_locations:
continue
if setting == CostSanity.option_shopsonly and location.basename not in multi_locations:
continue
if location.basename in {'Grubfather', 'Seer', 'Egg_Shop'}:
our_weights = dict(weights_geoless)
else:
our_weights = dict(weights)
rolls = 1
if random.randrange(100) < hybrid_chance:
rolls = 2
if rolls > len(our_weights):
terms = list(our_weights.keys()) # Can't randomly choose cost types, using all of them.
else:
terms = []
for _ in range(rolls):
term = random.choices(list(our_weights.keys()), list(our_weights.values()))[0]
del our_weights[term]
terms.append(term)
location.costs = {term: random.randint(*self.ranges[term]) for term in terms}
location.sort_costs()
def set_rules(self):
multiworld = self.multiworld
player = self.player
goal = self.options.Goal
if goal == Goal.option_hollowknight:
multiworld.completion_condition[player] = lambda state: _hk_can_beat_thk(state, player)
elif goal == Goal.option_siblings:
multiworld.completion_condition[player] = lambda state: _hk_siblings_ending(state, player)
elif goal == Goal.option_radiance:
multiworld.completion_condition[player] = lambda state: _hk_can_beat_radiance(state, player)
elif goal == Goal.option_godhome:
multiworld.completion_condition[player] = lambda state: state.count("Defeated_Pantheon_5", player)
elif goal == Goal.option_godhome_flower:
multiworld.completion_condition[player] = lambda state: state.count("Godhome_Flower_Quest", player)
else:
# Any goal
multiworld.completion_condition[player] = lambda state: _hk_can_beat_thk(state, player) or _hk_can_beat_radiance(state, player)
set_rules(self)
def fill_slot_data(self):
slot_data = {}
options = slot_data["options"] = {}
for option_name in hollow_knight_options:
option = getattr(self.options, option_name)
try:
optionvalue = int(option.value)
except TypeError:
pass # C# side is currently typed as dict[str, int], drop what doesn't fit
else:
options[option_name] = optionvalue
# 32 bit int
slot_data["seed"] = self.random.randint(-2147483647, 2147483646)
# Backwards compatibility for shop cost data (HKAP < 0.1.0)
if not self.options.CostSanity:
for shop, terms in shop_cost_types.items():
unit = cost_terms[next(iter(terms))].option
if unit == "Geo":
continue
slot_data[f"{unit}_costs"] = {
loc.name: next(iter(loc.costs.values()))
for loc in self.created_multi_locations[shop]
}
# HKAP 0.1.0 and later cost data.
location_costs = {}
for region in self.multiworld.get_regions(self.player):
for location in region.locations:
if location.costs:
location_costs[location.name] = location.costs
slot_data["location_costs"] = location_costs
slot_data["notch_costs"] = self.charm_costs
return slot_data
def create_item(self, name: str) -> HKItem:
item_data = item_table[name]
return HKItem(name, item_data.advancement, item_data.id, item_data.type, self.player)
def create_event(self, name: str) -> HKItem:
item_data = item_table[name]
return HKItem(name, item_data.advancement, None, item_data.type, self.player)
def create_location(self, name: str, vanilla=False) -> HKLocation:
costs = None
basename = name
if name in shop_cost_types:
costs = {
term: self.random.randint(*self.ranges[term])
for term in shop_cost_types[name]
}
elif name in vanilla_location_costs:
costs = vanilla_location_costs[name]
multi = self.created_multi_locations.get(name)
if multi is not None:
i = len(multi) + 1
name = f"{name}_{i}"
region = self.multiworld.get_region("Menu", self.player)
if vanilla and not self.options.AddUnshuffledLocations:
loc = HKLocation(self.player, name,
None, region, costs=costs, vanilla=vanilla,
basename=basename)
else:
loc = HKLocation(self.player, name,
self.location_name_to_id[name], region, costs=costs, vanilla=vanilla,
basename=basename)
if multi is not None:
multi.append(loc)
region.locations.append(loc)
return loc
def create_vanilla_location(self, location: str, item: Item):
costs = self.vanilla_shop_costs.get((location, item.name))
location = self.create_location(location, vanilla=True)
location.place_locked_item(item)
if costs:
location.costs = costs.pop()
return location
def collect(self, state, item: HKItem) -> bool:
change = super(HKWorld, self).collect(state, item)
if change:
for effect_name, effect_value in item_effects.get(item.name, {}).items():
state.prog_items[item.player][effect_name] += effect_value
if item.name in {"Left_Mothwing_Cloak", "Right_Mothwing_Cloak"}:
if state.prog_items[item.player].get('RIGHTDASH', 0) and \
state.prog_items[item.player].get('LEFTDASH', 0):
(state.prog_items[item.player]["RIGHTDASH"], state.prog_items[item.player]["LEFTDASH"]) = \
([max(state.prog_items[item.player]["RIGHTDASH"], state.prog_items[item.player]["LEFTDASH"])] * 2)
return change
def remove(self, state, item: HKItem) -> bool:
change = super(HKWorld, self).remove(state, item)
if change:
for effect_name, effect_value in item_effects.get(item.name, {}).items():
if state.prog_items[item.player][effect_name] == effect_value:
del state.prog_items[item.player][effect_name]
else:
state.prog_items[item.player][effect_name] -= effect_value
return change
@classmethod
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 = multiworld.get_player_name(player)
spoiler_handle.write(f'\n{name}\n')
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 = multiworld.get_player_name(player)
spoiler_handle.write(f'\n{name}\n')
hk_world: HKWorld = multiworld.worlds[player]
if hk_world.options.CostSanity:
for loc in sorted(
(
loc for loc in itertools.chain(*(region.locations for region in multiworld.get_regions(player)))
if loc.costs
), key=operator.attrgetter('name')
):
spoiler_handle.write(f"\n{loc}: {loc.item} costing {loc.cost_text()}")
else:
for shop_name, locations in hk_world.created_multi_locations.items():
for loc in locations:
spoiler_handle.write(f"\n{loc}: {loc.item} costing {loc.cost_text()}")
def get_multi_location_name(self, base: str, i: typing.Optional[int]) -> str:
if i is None:
i = len(self.created_multi_locations[base]) + 1
assert 1 <= 16, "limited number of multi location IDs reserved."
return f"{base}_{i}"
def get_filler_item_name(self) -> str:
if self.player not in self.cached_filler_items:
fillers = ["One_Geo", "Soul_Refill"]
exclusions = self.white_palace_exclusions()
for group in (
'RandomizeGeoRocks', 'RandomizeSoulTotems', 'RandomizeLoreTablets', 'RandomizeJunkPitChests',
'RandomizeRancidEggs'
):
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.random.choice(self.cached_filler_items[self.player])
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)
location = HKLocation(player, location, loc_id, ret)
ret.locations.append(location)
return ret
class HKLocation(Location):
game: str = "Hollow Knight"
costs: typing.Dict[str, int] = None
unit: typing.Optional[str] = None
vanilla = False
basename: str
def sort_costs(self):
if self.costs is None:
return
self.costs = {k: self.costs[k] for k in sorted(self.costs.keys(), key=lambda x: cost_terms[x].sort)}
def __init__(
self, player: int, name: str, code=None, parent=None,
costs: typing.Dict[str, int] = None, vanilla: bool = False, basename: str = None
):
self.basename = basename or name
super(HKLocation, self).__init__(player, name, code if code else None, parent)
self.vanilla = vanilla
if costs:
self.costs = dict(costs)
self.sort_costs()
def cost_text(self, separator=" and "):
if self.costs is None:
return None
return separator.join(
f"{value} {cost_terms[term].singular if value == 1 else cost_terms[term].plural}"
for term, value in self.costs.items()
)
class HKItem(Item):
game = "Hollow Knight"
type: str
def __init__(self, name, advancement, code, type: str, player: int = None):
if name == "Mimic_Grub":
classification = ItemClassification.trap
elif name == "Godtuner":
classification = ItemClassification.progression
elif type in ("Grub", "DreamWarrior", "Root", "Egg", "Dreamer"):
classification = ItemClassification.progression_skip_balancing
elif type == "Charm" and name not in progression_charms:
classification = ItemClassification.progression_skip_balancing
elif type in ("Map", "Journal"):
classification = ItemClassification.filler
elif type in ("Ore", "Vessel"):
classification = ItemClassification.useful
elif advancement:
classification = ItemClassification.progression
else:
classification = ItemClassification.filler
super(HKItem, self).__init__(name, classification, code if code else None, player)
self.type = type
class HKLogicMixin(LogicMixin):
multiworld: MultiWorld
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.worlds[player].options, option_name).value
def _hk_start(self, player, start_location: str) -> bool:
return self.multiworld.worlds[player].options.StartLocation == start_location