Hollow Knight June 2022 Updates (#720)

This is a combined PR for assorted Hollow Knight updates for June 2022 that have cleared testing. It supersedes any HK-exclusive PRs open by myself or @Alchav unless stated otherwise.

Summary of changes below:

 * Implement Split Claw, Split Cloak, Split Superdash, Randomize Nail, Randomize Focus, Randomize Swim and Elevator 
 * Pass options (@Alchav)
 * Add support for Deathlink with three different modes (@dewiniaid)
 * Add customizable additional shop slots per-shop (@Alchav) and overall (@dewiniaid)
 * Overhaul shop cost output to be more generic and account for all locations with standard costs (such as Stag Stations, Cornifer, and Divine) (@dewiniaid)
 * Add "CostSanity", allowing random prices using any cost type to be chosen for any location with a cost. (e.g. a Stag station requiring 15 grubs to obtain an item)
 * Item classification fixes (Map and Journal items are fillter, Mask Shards/Pale Ore/Vessel Fragments are useful) (@Alchav)
 * Fix Ijii -> Jiji (@Alchav )
 * General code quality updates

The above changes are only for the HK world.
This commit is contained in:
Daniel Grace 2022-07-03 08:10:10 -07:00 committed by GitHub
parent 7d85ab471a
commit 8870b577d0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 2354 additions and 1876 deletions

File diff suppressed because one or more lines are too long

View File

@ -74,7 +74,7 @@ class Absorber(ast.NodeTransformer):
self.truth_values = truth_values
self.truth_values |= {"True", "None", "ANY", "ITEMRANDO"}
self.false_values = false_values
self.false_values |= {"False", "NONE", "RANDOMELEVATORS"}
self.false_values |= {"False", "NONE"}
super(Absorber, self).__init__()
@ -203,7 +203,58 @@ logic_folder = os.path.join(resources_source, "Logic")
logic_options: typing.Dict[str, str] = hk_loads(os.path.join(data_folder, "logic_settings.json"))
for logic_key, logic_value in logic_options.items():
logic_options[logic_key] = logic_value.split(".", 1)[-1]
del (logic_options["RANDOMELEVATORS"])
vanilla_cost_data: typing.Dict[str, typing.Dict[str, typing.Any]] = hk_loads(os.path.join(data_folder, "costs.json"))
vanilla_location_costs = {
key: {
value["term"]: int(value["amount"])
}
for key, value in vanilla_cost_data.items()
if value["amount"] > 0 and value["term"] == "GEO"
}
salubra_geo_costs_by_charm_count = {
5: 120,
10: 500,
18: 900,
25: 1400,
40: 800
}
# Can't extract this data, so supply it ourselves. Source: the wiki
vanilla_shop_costs = {
('Sly', 'Simple_Key'): [{'GEO': 950}],
('Sly', 'Rancid_Egg'): [{'GEO': 60}],
('Sly', 'Lumafly_Lantern'): [{'GEO': 1800}],
('Sly', 'Gathering_Swarm'): [{'GEO': 300}],
('Sly', 'Stalwart_Shell'): [{'GEO': 200}],
('Sly', 'Mask_Shard'): [
{'GEO': 150},
{'GEO': 500},
],
('Sly', 'Vessel_Fragment'): [{'GEO': 550}],
('Sly_(Key)', 'Heavy_Blow'): [{'GEO': 350}],
('Sly_(Key)', 'Elegant_Key'): [{'GEO': 800}],
('Sly_(Key)', 'Mask_Shard'): [
{'GEO': 800},
{'GEO': 1500},
],
('Sly_(Key)', 'Vessel_Fragment'): [{'GEO': 900}],
('Sly_(Key)', 'Sprintmaster'): [{'GEO': 400}],
('Iselda', 'Wayward_Compass'): [{'GEO': 220}],
('Iselda', 'Quill'): [{'GEO': 120}],
('Salubra', 'Lifeblood_Heart'): [{'GEO': 250}],
('Salubra', 'Longnail'): [{'GEO': 300}],
('Salubra', 'Steady_Body'): [{'GEO': 120}],
('Salubra', 'Shaman_Stone'): [{'GEO': 220}],
('Salubra', 'Quick_Focus'): [{'GEO': 800}],
('Leg_Eater', 'Fragile_Heart'): [{'GEO': 350}],
('Leg_Eater', 'Fragile_Greed'): [{'GEO': 250}],
('Leg_Eater', 'Fragile_Strength'): [{'GEO': 600}],
}
extra_pool_options: typing.List[typing.Dict[str, typing.Any]] = hk_loads(os.path.join(data_folder, "pools.json"))
pool_options: typing.Dict[str, typing.Tuple[typing.List[str], typing.List[str]]] = {}
for option in extra_pool_options:
@ -213,8 +264,23 @@ for option in extra_pool_options:
for pairing in option["Vanilla"]:
items.append(pairing["item"])
location_name = pairing["location"]
if any(cost_entry["term"] == "CHARMS" for cost_entry in pairing.get("costs", [])):
location_name += "_(Requires_Charms)"
item_costs = pairing.get("costs", [])
if item_costs:
if any(cost_entry["term"] == "CHARMS" for cost_entry in item_costs):
location_name += "_(Requires_Charms)"
#vanilla_shop_costs[pairing["location"], pairing["item"]] = \
cost = {
entry["term"]: int(entry["amount"]) for entry in item_costs
}
# Rando4 doesn't include vanilla geo costs for Salubra charms, so dirty hardcode here.
if 'CHARMS' in cost:
geo = salubra_geo_costs_by_charm_count.get(cost['CHARMS'])
if geo:
cost['GEO'] = geo
key = (pairing["location"], pairing["item"])
vanilla_shop_costs.setdefault(key, []).append(cost)
locations.append(location_name)
if option["Path"]:
# basename carries over from prior entry if no Path given
@ -229,6 +295,12 @@ for option in extra_pool_options:
pool_options[basename] = items, locations
del extra_pool_options
# reverse all the vanilla shop costs (really, this is just for Salubra).
# When we use these later, we pop off the end of the list so this ensures they are still sorted.
vanilla_shop_costs = {
k: list(reversed(v)) for k, v in vanilla_shop_costs.items()
}
# items
items: typing.Dict[str, typing.Dict] = hk_loads(os.path.join(data_folder, "items.json"))
logic_items: typing.Set[str] = set()
@ -364,9 +436,15 @@ for event in events:
event_rules.update(connectors_rules)
connectors_rules = {}
# Apply some final fixes
item_effects.update({
'Left_Mothwing_Cloak': {'LEFTDASH': 1},
'Right_Mothwing_Cloak': {'RIGHTDASH': 1},
})
names = sorted({"logic_options", "starts", "pool_options", "locations", "multi_locations", "location_to_region_lookup",
"event_names", "item_effects", "items", "logic_items", "region_names",
"exits", "connectors", "one_ways"})
"exits", "connectors", "one_ways", "vanilla_shop_costs", "vanilla_location_costs"})
warning = "# This module is written by Extractor.py, do not edit manually!.\n\n"
with open(os.path.join(os.path.dirname(__file__), "ExtractedData.py"), "wt") as py:
py.write(warning)
@ -385,6 +463,6 @@ rules_template = template_env.get_template("RulesTemplate.pyt")
rules = rules_template.render(location_rules=location_rules, one_ways=one_ways, connectors_rules=connectors_rules,
event_rules=event_rules)
with open("Rules.py", "wt") as py:
with open("GeneratedRules.py", "wt") as py:
py.write(warning)
py.write(rules)

1699
worlds/hk/GeneratedRules.py Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,6 @@
import typing
from .ExtractedData import logic_options, starts, pool_options
from .Rules import cost_terms
from Options import Option, DefaultOnToggle, Toggle, Choice, Range, OptionDict, SpecialRange
from .Charms import vanilla_costs, names as charm_names
@ -11,19 +12,6 @@ else:
Random = typing.Any
class Disabled(Toggle):
def __init__(self, value: int):
super(Disabled, self).__init__(0)
@classmethod
def from_text(cls, text: str) -> Toggle:
return cls(0)
@classmethod
def from_any(cls, data: typing.Any):
return cls(0)
locations = {"option_" + start: i for i, start in enumerate(starts)}
# This way the dynamic start names are picked up by the MetaClass Choice belongs to
StartLocation = type("StartLocation", (Choice,), {"__module__": __name__, "auto_display_name": False, **locations,
@ -36,6 +24,8 @@ option_docstrings = {
"randomization.",
"RandomizeSkills": "Allow for Skills, such as Mantis Claw or Shade Soul, to be randomized into the item pool. "
"Also opens their locations for receiving randomized items.",
"RandomizeFocus": "Removes the ability to focus and randomizes it into the item pool.",
"RandomizeSwim": "Removes the ability to swim in water and randomizes it into the item pool.",
"RandomizeCharms": "Allow for Charms to be randomized into the item pool and open their locations for "
"randomization. Includes Charms sold in shops.",
"RandomizeKeys": "Allow for Keys to be randomized into the item pool. Includes those sold in shops.",
@ -59,6 +49,8 @@ option_docstrings = {
"RandomizeBossEssence": "Randomize boss essence drops, such as those for defeating Warrior Dreams, into the item "
"pool and open their locations for randomization.",
"RandomizeGrubs": "Randomize Grubs into the item pool and open their locations for randomization.",
"RandomizeMimics": "Randomize Mimic Grubs into the item pool and open their locations for randomization."
"Mimic Grubs are always placed in your own game.",
"RandomizeMaps": "Randomize Maps into the item pool. This causes Cornifer to give you a message allowing you to see"
" and buy an item that is randomized into that location as well.",
"RandomizeStags": "Randomize Stag Stations unlocks into the item pool as well as placing randomized items "
@ -70,6 +62,7 @@ option_docstrings = {
"RandomizeJournalEntries": "Randomize the Hunter's Journal as well as the findable journal entries into the item "
"pool, and open their locations for randomization. Does not include journal entries "
"gained by killing enemies.",
"RandomizeNail": "Removes the ability to swing the nail left, right and up, and shuffles these into the item pool.",
"RandomizeGeoRocks": "Randomize Geo Rock rewards into the item pool and open their locations for randomization.",
"RandomizeBossGeo": "Randomize boss Geo drops into the item pool and open those locations for randomization.",
"RandomizeSoulTotems": "Randomize Soul Refill items into the item pool and open the Soul Totem locations for"
@ -110,12 +103,16 @@ default_on = {
"RandomizeRelics"
}
# not supported at this time
disabled = {
"RandomizeFocus",
"RandomizeSwim",
"RandomizeMimics",
"RandomizeNail",
shop_to_option = {
"Seer": "SeerRewardSlots",
"Grubfather": "GrubfatherRewardSlots",
"Sly": "SlyShopSlots",
"Sly_(Key)": "SlyKeyShopSlots",
"Iselda": "IseldaShopSlots",
"Salubra": "SalubraShopSlots",
"Leg_Eater": "LegEaterShopSlots",
"Salubra_(Requires_Charms)": "IseldaShopSlots",
"Egg_Shop": "EggShopSlots",
}
hollow_knight_randomize_options: typing.Dict[str, type(Option)] = {}
@ -124,9 +121,6 @@ for option_name, option_data in pool_options.items():
extra_data = {"__module__": __name__, "items": option_data[0], "locations": option_data[1]}
if option_name in option_docstrings:
extra_data["__doc__"] = option_docstrings[option_name]
if option_name in disabled:
extra_data["__doc__"] = "Disabled Option. Not implemented."
option = type(option_name, (Disabled,), extra_data)
if option_name in default_on:
option = type(option_name, (DefaultOnToggle,), extra_data)
else:
@ -142,13 +136,36 @@ for option_name in logic_options.values():
if option_name in option_docstrings:
extra_data["__doc__"] = option_docstrings[option_name]
option = type(option_name, (Toggle,), extra_data)
if option_name in disabled:
extra_data["__doc__"] = "Disabled Option. Not implemented."
option = type(option_name, (Disabled,), extra_data)
globals()[option.__name__] = option
hollow_knight_logic_options[option.__name__] = option
class RandomizeElevatorPass(Toggle):
"""Adds an Elevator Pass item to the item pool, which is then required to use the large elevators connecting
City of Tears to the Forgotten Crossroads and Resting Grounds."""
display_name = "Randomize Elevator Pass"
default = False
class SplitMothwingCloak(Toggle):
"""Splits the Mothwing Cloak into left- and right-only versions of the item. Randomly adds a second left or
right Mothwing cloak item which functions as the upgrade to Shade Cloak."""
display_name = "Split Mothwing Cloak"
default = False
class SplitMantisClaw(Toggle):
"""Splits the Mantis Claw into left- and right-only versions of the item."""
display_name = "Split Mantis Claw"
default = False
class SplitCrystalHeart(Toggle):
"""Splits the Crystal Heart into left- and right-only versions of the item."""
display_name = "Split Crystal Heart"
default = False
class MinimumGrubPrice(Range):
"""The minimum grub price in the range of prices that an item should cost from Grubfather."""
display_name = "Minimum Grub Price"
@ -178,7 +195,7 @@ class MaximumEssencePrice(MinimumEssencePrice):
class MinimumEggPrice(Range):
"""The minimum rancid egg price in the range of prices that an item should cost from Ijii.
"""The minimum rancid egg price in the range of prices that an item should cost from Jiji.
Only takes effect if the EggSlotShops option is greater than 0."""
display_name = "Minimum Egg Price"
range_start = 1
@ -187,7 +204,7 @@ class MinimumEggPrice(Range):
class MaximumEggPrice(MinimumEggPrice):
"""The maximum rancid egg price in the range of prices that an item should cost from Ijii.
"""The maximum rancid egg price in the range of prices that an item should cost from Jiji.
Only takes effect if the EggSlotShops option is greater than 0."""
display_name = "Maximum Egg Price"
default = 10
@ -208,6 +225,22 @@ class MaximumCharmPrice(MinimumCharmPrice):
default = 20
class MinimumGeoPrice(Range):
"""The minimum geo price for items in geo shops."""
display_name = "Minimum Geo Price"
range_start = 1
range_end = 200
default = 1
class MaximumGeoPrice(Range):
"""The maximum geo price for items in geo shops."""
display_name = "Minimum Geo Price"
range_start = 1
range_end = 2000
default = 400
class RandomCharmCosts(SpecialRange):
"""Total Notch Cost of all Charms together. Vanilla sums to 90.
This value is distributed among all charms in a random fashion.
@ -256,13 +289,91 @@ class PlandoCharmCosts(OptionDict):
return charm_costs
class SlyShopSlots(Range):
"""For each extra slot, add a location to the Sly Shop and a filler item to the item pool."""
display_name = "Sly Shop Slots"
default = 8
range_end = 16
class SlyKeyShopSlots(Range):
"""For each extra slot, add a location to the Sly Shop (requiring Shopkeeper's Key) and a filler item to the item pool."""
display_name = "Sly Key Shop Slots"
default = 6
range_end = 16
class IseldaShopSlots(Range):
"""For each extra slot, add a location to the Iselda Shop and a filler item to the item pool."""
display_name = "Iselda Shop Slots"
default = 2
range_end = 16
class SalubraShopSlots(Range):
"""For each extra slot, add a location to the Salubra Shop, and a filler item to the item pool."""
display_name = "Salubra Shop Slots"
default = 5
range_start = 0
range_end = 16
class SalubraCharmShopSlots(Range):
"""For each extra slot, add a location to the Salubra Shop (requiring Charms), and a filler item to the item pool."""
display_name = "Salubra Charm Shop Slots"
default = 5
range_end = 16
class LegEaterShopSlots(Range):
"""For each extra slot, add a location to the Leg Eater Shop and a filler item to the item pool."""
display_name = "Leg Eater Shop Slots"
default = 3
range_end = 16
class GrubfatherRewardSlots(Range):
"""For each extra slot, add a location to the Grubfather and a filler item to the item pool."""
display_name = "Grubfather Reward Slots"
default = 7
range_end = 16
class SeerRewardSlots(Range):
"""For each extra slot, add a location to the Seer and a filler item to the item pool."""
display_name = "Seer Reward Reward Slots"
default = 8
range_end = 16
class EggShopSlots(Range):
"""For each slot, add a location to the Egg Shop and a Geo drop to the item pool."""
"""For each slot, add a location to the Egg Shop and a filler item to the item pool."""
display_name = "Egg Shop Item Slots"
range_end = 16
class ExtraShopSlots(Range):
"""For each extra slot, add a location to a randomly chosen shop a filler item to the item pool.
The Egg Shop will be excluded from this list unless it has at least one item.
Shops are capped at 16 items each.
"""
display_name = "Additional Shop Slots"
default = 0
range_end = 9 * 16 # Number of shops x max slots per shop.
class Goal(Choice):
"""The goal required of you in order to complete your run in Archipelago."""
display_name = "Goal"
@ -315,19 +426,70 @@ class StartingGeo(Range):
default = 0
class CostSanity(Choice):
"""If enabled, most locations with costs (like stag stations) will have randomly determined costs.
If set to shopsonly, CostSanity will only apply to shops (including Grubfather, Seer and Egg Shop).
If set to notshops, CostSanity will only apply to non-shops (e.g. Stag stations and Cornifer locations)
These costs can be in Geo (except Grubfather, Seer and Eggshop), Grubs, Charms, Essence and/or Rancid Eggs
"""
option_off = 0
alias_false = 0
alias_no = 0
option_on = 1
alias_true = 1
alias_yes = 1
option_shopsonly = 2
option_notshops = 3
display_name = "Cost Sanity"
class CostSanityHybridChance(Range):
"""The chance that a CostSanity cost will include two components instead of one, e.g. Grubs + Essence"""
range_end = 100
default = 10
cost_sanity_weights: typing.Dict[str, type(Option)] = {}
for term, cost in cost_terms.items():
option_name = f"CostSanity{cost.option}Weight"
extra_data = {
"__module__": __name__, "range_end": 1000,
"__doc__": (
f"The likelihood of Costsanity choosing a {cost.option} cost."
" Chosen as a sum of all weights from other types."
),
"default": cost.weight
}
if cost == 'GEO':
extra_data["__doc__"] += " Geo costs will never be chosen for Grubfather, Seer, or Egg Shop."
option = type(option_name, (Range,), extra_data)
globals()[option.__name__] = option
cost_sanity_weights[option.__name__] = option
hollow_knight_options: typing.Dict[str, type(Option)] = {
**hollow_knight_randomize_options,
RandomizeElevatorPass.__name__: RandomizeElevatorPass,
**hollow_knight_logic_options,
**{
option.__name__: option
for option in (
StartLocation, Goal, WhitePalace, StartingGeo, DeathLink,
MinimumGeoPrice, MaximumGeoPrice,
MinimumGrubPrice, MaximumGrubPrice,
MinimumEssencePrice, MaximumEssencePrice,
MinimumCharmPrice, MaximumCharmPrice,
RandomCharmCosts, PlandoCharmCosts,
MinimumEggPrice, MaximumEggPrice, EggShopSlots,
# Add your new options where it makes sense?
SlyShopSlots, SlyKeyShopSlots, IseldaShopSlots,
SalubraShopSlots, SalubraCharmShopSlots,
LegEaterShopSlots, GrubfatherRewardSlots,
SeerRewardSlots, ExtraShopSlots,
SplitCrystalHeart, SplitMothwingCloak, SplitMantisClaw,
CostSanity, CostSanityHybridChance,
)
}
},
**cost_sanity_weights
}

File diff suppressed because it is too large Load Diff

View File

@ -2,16 +2,19 @@ from __future__ import annotations
import logging
import typing
from collections import Counter
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
from .Options import hollow_knight_options, hollow_knight_randomize_options, disabled, Goal, WhitePalace
from .Rules import set_rules, cost_terms
from .Options import hollow_knight_options, hollow_knight_randomize_options, Goal, WhitePalace, CostSanity, \
shop_to_option
from .ExtractedData import locations, starts, multi_locations, location_to_region_lookup, \
event_names, item_effects, connectors, one_ways
event_names, item_effects, connectors, one_ways, vanilla_shop_costs, vanilla_location_costs
from .Charms import names as charm_names
from BaseClasses import Region, Entrance, Location, MultiWorld, Item, RegionType, LocationProgressType, Tutorial, ItemClassification
@ -98,6 +101,25 @@ logicless_options = {
"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(
@ -127,19 +149,18 @@ class HKWorld(World):
item_name_groups = item_name_groups
ranges: typing.Dict[str, typing.Tuple[int, int]]
shops: typing.Dict[str, str] = {
"Egg_Shop": "Egg",
"Grubfather": "Grub",
"Seer": "Essence",
"Salubra_(Requires_Charms)": "Charm"
}
charm_costs: typing.List[int]
cached_filler_items = {}
data_version = 2
def __init__(self, world, player):
super(HKWorld, self).__init__(world, player)
self.created_multi_locations: typing.Dict[str, int] = Counter()
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):
world = self.world
@ -147,16 +168,14 @@ class HKWorld(World):
self.charm_costs = world.PlandoCharmCosts[self.player].get_costs(charm_costs)
# world.exclude_locations[self.player].value.update(white_palace_locations)
world.local_items[self.player].value.add("Mimic_Grub")
for vendor, unit in self.shops.items():
mini = getattr(world, f"Minimum{unit}Price")[self.player]
maxi = getattr(world, f"Maximum{unit}Price")[self.player]
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]
# if minimum > maximum, set minimum to maximum
mini.value = min(mini.value, maxi.value)
self.ranges[unit] = mini.value, maxi.value
self.ranges[term] = mini.value, maxi.value
world.push_precollected(HKItem(starts[world.StartLocation[self.player].current_key],
True, None, "Event", self.player))
for option_name in disabled:
getattr(world, option_name)[self.player].value = 0
def white_palace_exclusions(self):
exclusions = set()
@ -199,55 +218,197 @@ class HKWorld(World):
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] = []
geo_replace: typing.Set[str] = set()
if self.world.RemoveSpellUpgrades[self.player]:
geo_replace.add("Abyss_Shriek")
geo_replace.add("Shade_Soul")
geo_replace.add("Descending_Dark")
wp_exclusions = self.white_palace_exclusions()
junk_replace: typing.Set[str] = set()
if self.world.RemoveSpellUpgrades[self.player]:
junk_replace.update(("Abyss_Shriek", "Shade_Soul", "Descending_Dark"))
randomized_starting_items = set()
for attr, items in randomizable_starting_items.items():
if getattr(self.world, attr)[self.player]:
randomized_starting_items.update(items)
# noinspection PyShadowingNames
def _add(item_name: str, location_name: str):
"""
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 location_name == "Start":
if item_name in randomized_starting_items:
pool.append(item)
else:
self.world.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.world, option_key)[self.player]
for item_name, location_name in zip(option.items, option.locations):
vanilla = not randomized
excluded = False
if item_name in geo_replace:
item_name = "Geo_Rock-Default"
item = self.create_item(item_name)
if location_name == "Start":
self.world.push_precollected(item)
if item_name in junk_replace:
item_name = self.get_filler_item_name()
if (item_name == "Crystal_Heart" and self.world.SplitCrystalHeart[self.player]) or \
(item_name == "Mothwing_Cloak" and self.world.SplitMothwingCloak[self.player]):
_add("Left_" + item_name, location_name)
_add("Right_" + item_name, "Split_" + location_name)
continue
location = self.create_location(location_name)
if not vanilla and location_name in wp_exclusions:
if location_name == 'King_Fragment':
excluded = True
if item_name == "Mantis_Claw" and self.world.SplitMantisClaw[self.player]:
_add("Left_" + item_name, "Left_" + location_name)
_add("Right_" + item_name, "Right_" + location_name)
continue
if item_name == "Shade_Cloak" and self.world.SplitMothwingCloak[self.player]:
if self.world.random.randint(0, 1):
item_name = "Left_Mothwing_Cloak"
else:
vanilla = True
if excluded:
location.progress_type = LocationProgressType.EXCLUDED
if vanilla:
location.place_locked_item(item)
else:
pool.append(item)
item_name = "Right_Mothwing_Cloak"
for i in range(self.world.EggShopSlots[self.player].value):
self.create_location("Egg_Shop")
pool.append(self.create_item("Geo_Rock-Default"))
_add(item_name, location_name)
if self.world.RandomizeElevatorPass[self.player]:
randomized = True
_add("Elevator_Pass", "Elevator_Pass")
for shop, locations in self.created_multi_locations.items():
for _ in range(len(locations), getattr(self.world, shop_to_option[shop])[self.player].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.world.ExtraShopSlots[self.player].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.world.EggShopSlots[self.player].value: # No eggshop, so don't place items there
shops.remove('Egg_Shop')
for _ in range(additional_shop_items):
shop = self.world.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.world.itempool += pool
self.apply_costsanity()
self.sort_shops_by_cost()
for shopname in self.shops:
prices: typing.List[int] = []
locations: typing.List[HKLocation] = []
for x in range(1, self.created_multi_locations[shopname]+1):
loc = self.world.get_location(self.get_multi_location_name(shopname, x), self.player)
locations.append(loc)
prices.append(loc.cost)
prices.sort()
for loc, price in zip(locations, prices):
loc.cost = price
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.world.CostSanity[self.player].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.world.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.world.random
hybrid_chance = getattr(self.world, f"CostSanityHybridChance")[self.player].value
weights = {
data.term: getattr(self.world, f"CostSanity{data.option}Weight")[self.player].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.world.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.world.player_name[self.player]}'s world."
f" CostSanityHybridChance will not trigger in geoless locations."
)
for region in self.world.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', 'Eggshop'}:
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):
world = self.world
@ -280,12 +441,24 @@ class HKWorld(World):
# 32 bit int
slot_data["seed"] = self.world.slot_seeds[self.player].randint(-2147483647, 2147483646)
for shop, unit in self.shops.items():
slot_data[f"{unit}_costs"] = {
f"{shop}_{i}":
self.world.get_location(f"{shop}_{i}", self.player).cost
for i in range(1, 1 + self.created_multi_locations[shop])
}
# Backwards compatibility for shop cost data (HKAP < 0.1.0)
if not self.world.CostSanity[self.player]:
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.world.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
@ -295,30 +468,51 @@ class HKWorld(World):
item_data = item_table[name]
return HKItem(name, item_data.advancement, item_data.id, item_data.type, self.player)
def create_location(self, name: str) -> HKLocation:
unit = self.shops.get(name, None)
if unit:
cost = self.world.random.randint(*self.ranges[unit])
else:
cost = 0
if name in multi_locations:
self.created_multi_locations[name] += 1
name = self.get_multi_location_name(name, self.created_multi_locations[name])
def create_location(self, name: str, vanilla=False) -> HKLocation:
costs = None
basename = name
if name in shop_cost_types:
costs = {
term: self.world.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.world.get_region("Menu", self.player)
loc = HKLocation(self.player, name, self.location_name_to_id[name], region)
if unit:
loc.unit = unit
loc.cost = cost
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()
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[effect_name, item.player] += effect_value
if item.name in {"Left_Mothwing_Cloak", "Right_Mothwing_Cloak"}:
if state.prog_items.get(('RIGHTDASH', item.player), 0) and \
state.prog_items.get(('LEFTDASH', item.player), 0):
(state.prog_items["RIGHTDASH", item.player], state.prog_items["LEFTDASH", item.player]) = \
([max(state.prog_items["RIGHTDASH", item.player], state.prog_items["LEFTDASH", item.player])] * 2)
return change
def remove(self, state, item: HKItem) -> bool:
@ -348,17 +542,40 @@ class HKWorld(World):
name = world.get_player_name(player)
spoiler_handle.write(f'\n{name}\n')
hk_world: HKWorld = world.worlds[player]
for shop_name, unit_name in cls.shops.items():
for x in range(1, hk_world.created_multi_locations[shop_name]+1):
loc = world.get_location(hk_world.get_multi_location_name(shop_name, x), player)
spoiler_handle.write(f"\n{loc}: {loc.item} costing {loc.cost} {unit_name}")
if world.CostSanity[player].value:
for loc in sorted(
(
loc for loc in itertools.chain(*(region.locations for region in world.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 = self.created_multi_locations[base]
assert 0 < i < 18, "limited number of multi location IDs reserved."
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.world, 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.world.random.choice(self.cached_filler_items[self.player])
def create_region(world: MultiWorld, player: int, name: str, location_names=None, exits=None) -> Region:
ret = Region(name, RegionType.Generic, name, player)
@ -376,11 +593,34 @@ def create_region(world: MultiWorld, player: int, name: str, location_names=None
class HKLocation(Location):
game: str = "Hollow Knight"
cost: int = 0
costs: typing.Dict[str, int] = None
unit: typing.Optional[str] = None
vanilla = False
basename: str
def __init__(self, player: int, name: str, code=None, parent=None):
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):
@ -393,6 +633,10 @@ class HKItem(Item):
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 ("Mask", "Ore", "Vessel"):
classification = ItemClassification.useful
elif advancement:
classification = ItemClassification.progression
else:

View File

@ -1,50 +1,20 @@
from ..generic.Rules import set_rule, add_rule
# This module is written by Extractor.py, do not edit manually!.
from functools import partial
units = {
"Egg": "RANCIDEGGS",
"Grub": "GRUBS",
"Essence": "ESSENCE",
"Charm": "CHARMS",
}
def hk_set_rule(hk_world, location: str, rule):
count = hk_world.created_multi_locations[location]
if count:
locations = [f"{location}_{x}" for x in range(1, count+1)]
elif (location, hk_world.player) in hk_world.world._location_cache:
locations = [location]
else:
return
for location in locations:
set_rule(hk_world.world.get_location(location, hk_world.player), rule)
def set_shop_prices(hk_world):
def set_generated_rules(hk_world, hk_set_rule):
player = hk_world.player
for shop, unit in hk_world.shops.items():
for i in range(1, 1 + hk_world.created_multi_locations[shop]):
loc = hk_world.world.get_location(f"{shop}_{i}", hk_world.player)
add_rule(loc, lambda state, unit=units[unit], cost=loc.cost: state.count(unit, player) >= cost)
def set_rules(hk_world):
player = hk_world.player
world = hk_world.world
fn = partial(hk_set_rule, hk_world)
# Events
{% for location, rule_text in event_rules.items() %}
hk_set_rule(hk_world, "{{location}}", lambda state: {{rule_text}})
fn("{{location}}", lambda state: {{rule_text}})
{%- endfor %}
# Locations
{% for location, rule_text in location_rules.items() %}
hk_set_rule(hk_world, "{{location}}", lambda state: {{rule_text}})
fn("{{location}}", lambda state: {{rule_text}})
{%- endfor %}
# Shop prices
set_shop_prices(hk_world)
# Connectors
{% for entrance, rule_text in connectors_rules.items() %}
rule = lambda state: {{rule_text}}
@ -54,4 +24,4 @@ def set_rules(hk_world):
world.get_entrance("{{entrance}}_R", player).access_rule = lambda state, entrance= entrance: \
rule(state) and entrance.can_reach(state)
{%- endif %}
{% endfor %}
{%- endfor %}