main #2

Merged
juno merged 2 commits from zhetadelta/HollowKnightAltEntrancesAPMod:main into main 2025-10-12 23:24:17 +00:00
19 changed files with 4345 additions and 1 deletions

1
.gitignore vendored
View file

@ -400,3 +400,4 @@ FodyWeavers.xsd
*.sln.iml
LocalOverrides.targets
World/Resources/

47
World/Charms.py Normal file
View file

@ -0,0 +1,47 @@
import typing
vanilla_costs: typing.List[int] = [1, 1, 1, 2, 2, 2, 3, 2, 3, 1, 3, 1, 3, 1, 2, 2, 1, 2, 3, 2,
4, 2, 2, 2, 3, 1, 4, 2, 4, 1, 2, 3, 2, 4, 3, 5, 1, 3, 2, 2]
names: typing.List[str] = [
"Gathering Swarm",
"Wayward Compass",
"Grubsong",
"Stalwart Shell",
"Baldur Shell",
"Fury of the Fallen",
"Quick Focus",
"Lifeblood Heart",
"Lifeblood Core",
"Defender's Crest",
"Flukenest",
"Thorns of Agony",
"Mark of Pride",
"Steady Body",
"Heavy Blow",
"Sharp Shadow",
"Spore Shroom",
"Longnail",
"Shaman Stone",
"Soul Catcher",
"Soul Eater",
"Glowing Womb",
"Fragile Heart",
"Fragile Greed",
"Fragile Strength",
"Nailmaster's Glory",
"Joni's Blessing",
"Shape of Unn",
"Hiveblood",
"Dream Wielder",
"Dashmaster",
"Quick Slash",
"Spell Twister",
"Deep Focus",
"Grubberfly's Elegy",
"Kingsoul",
"Sprintmaster",
"Dreamshield",
"Weaversong",
"Grimmchild"
]

18
World/ExtractedData.py Normal file

File diff suppressed because one or more lines are too long

464
World/Extractor.py Normal file
View file

@ -0,0 +1,464 @@
"""
Logic Extractor designed for "Randomizer 4".
Place a Randomizer 4 compatible "Resources" folder next to this script, then run the script, to create AP data.
"""
import os
import json
import typing
import ast
import jinja2
from ast import unparse
from Utils import get_text_between
def put_digits_at_end(text: str) -> str:
for x in range(len(text)):
if text[0].isdigit():
text = text[1:] + text[0]
else:
break
return text
def hk_loads(file: str) -> typing.Any:
with open(file, encoding="utf-8-sig") as f:
data = f.read()
new_data = []
for row in data.split("\n"):
if not row.strip().startswith(r"//"):
new_data.append(row)
return json.loads("\n".join(new_data))
def hk_convert(text: str) -> str:
parts = text.replace("(", "( ").replace(")", " )").replace(">", " > ").replace("=", "==").split()
new_parts = []
for part in parts:
part = put_digits_at_end(part)
if part in items or part in effect_names or part in event_names or part in connectors:
new_parts.append(f"\"{part}\"")
else:
new_parts.append(part)
text = " ".join(new_parts)
result = ""
parts = text.split("$StartLocation[")
for i, part in enumerate(parts[:-1]):
result += part + "StartLocation[\""
parts[i+1] = parts[i+1].replace("]", "\"]", 1)
text = result + parts[-1]
result = ""
parts = text.split("COMBAT[")
for i, part in enumerate(parts[:-1]):
result += part + "COMBAT[\""
parts[i+1] = parts[i+1].replace("]", "\"]", 1)
text = result + parts[-1]
return text.replace("+", "and").replace("|", "or").replace("$", "").strip()
class Absorber(ast.NodeTransformer):
additional_truths = set()
additional_falses = set()
def __init__(self, truth_values, false_values):
self.truth_values = truth_values
self.truth_values |= {"True", "None", "ANY", "ITEMRANDO"}
self.false_values = false_values
self.false_values |= {"False", "NONE"}
super(Absorber, self).__init__()
def generic_visit(self, node: ast.AST) -> ast.AST:
# Need to call super() in any case to visit child nodes of the current one.
node = super().generic_visit(node)
return node
def visit_BoolOp(self, node: ast.BoolOp) -> ast.AST:
if type(node.op) == ast.And:
if self.is_always_true(node.values[0]):
return self.visit(node.values[1])
if self.is_always_true(node.values[1]):
return self.visit(node.values[0])
if self.is_always_false(node.values[0]) or self.is_always_false(node.values[1]):
return ast.Constant(False, ctx=ast.Load())
elif type(node.op) == ast.Or:
if self.is_always_true(node.values[0]) or self.is_always_true(node.values[1]):
return ast.Constant(True, ctx=ast.Load())
if self.is_always_false(node.values[0]):
return self.visit(node.values[1])
if self.is_always_false(node.values[1]):
return self.visit(node.values[0])
return self.generic_visit(node)
def visit_Name(self, node: ast.Name) -> ast.AST:
if node.id in self.truth_values:
return ast.Constant(True, ctx=node.ctx)
if node.id in self.false_values:
return ast.Constant(False, ctx=node.ctx)
if node.id in logic_options:
return ast.Call(
func=ast.Attribute(value=ast.Name(id='state', ctx=ast.Load()), attr='_hk_option', ctx=ast.Load()),
args=[ast.Name(id="player", ctx=ast.Load()), ast.Constant(value=logic_options[node.id])], keywords=[])
if node.id in macros:
return macros[node.id].body
if node.id in region_names:
raise Exception(f"Should be event {node.id}")
# You'd think this means reach Scene/Region of that name, but is actually waypoint/event
# if node.id in region_names:
# return ast.Call(
# func=ast.Attribute(value=ast.Name(id='state', ctx=ast.Load()), attr='can_reach', ctx=ast.Load()),
# args=[ast.Constant(value=node.id),
# ast.Constant(value="Region"),
# ast.Name(id="player", ctx=ast.Load())],
# keywords=[])
return self.generic_visit(node)
def visit_Constant(self, node: ast.Constant) -> ast.AST:
if type(node.value) == str:
logic_items.add(node.value)
return ast.Call(
func=ast.Attribute(value=ast.Name(id='state', ctx=ast.Load()), attr='count', ctx=ast.Load()),
args=[ast.Constant(value=node.value), ast.Name(id="player", ctx=ast.Load())], keywords=[])
return node
def visit_Subscript(self, node: ast.Subscript) -> ast.AST:
if node.value.id == "NotchCost":
notches = [ast.Constant(value=notch.value - 1) for notch in node.slice.elts] # apparently 1-indexed
return ast.Call(
func=ast.Attribute(value=ast.Name(id='state', ctx=ast.Load()), attr='_hk_notches', ctx=ast.Load()),
args=[ast.Name(id="player", ctx=ast.Load())] + notches, keywords=[])
elif node.value.id == "StartLocation":
node.slice.value = node.slice.value.replace(" ", "_").lower()
if node.slice.value in removed_starts:
return ast.Constant(False, ctx=node.ctx)
return ast.Call(
func=ast.Attribute(value=ast.Name(id='state', ctx=ast.Load()), attr='_hk_start', ctx=ast.Load()),
args=[ast.Name(id="player", ctx=ast.Load()), node.slice], keywords=[])
elif node.value.id == "COMBAT":
return macros[unparse(node)].body
else:
name = unparse(node)
if name in self.additional_truths:
return ast.Constant(True, ctx=ast.Load())
elif name in self.additional_falses:
return ast.Constant(False, ctx=ast.Load())
elif name in macros:
# macro such as "COMBAT[White_Palace_Arenas]"
return macros[name].body
else:
# assume Entrance
entrance = unparse(node)
assert entrance in connectors, entrance
return ast.Call(
func=ast.Attribute(value=ast.Name(id='state', ctx=ast.Load()), attr='can_reach', ctx=ast.Load()),
args=[ast.Constant(value=entrance),
ast.Constant(value="Entrance"),
ast.Name(id="player", ctx=ast.Load())],
keywords=[])
return node
def is_always_true(self, node):
if isinstance(node, ast.Name) and (node.id in self.truth_values or node.id in self.additional_truths):
return True
if isinstance(node, ast.Subscript) and unparse(node) in self.additional_truths:
return True
def is_always_false(self, node):
if isinstance(node, ast.Name) and (node.id in self.false_values or node.id in self.additional_falses):
return True
if isinstance(node, ast.Subscript) and unparse(node) in self.additional_falses:
return True
def get_parser(truths: typing.Set[str] = frozenset(), falses: typing.Set[str] = frozenset()):
return Absorber(truths, falses)
def ast_parse(parser, rule_text, truths: typing.Set[str] = frozenset(), falses: typing.Set[str] = frozenset()):
tree = ast.parse(hk_convert(rule_text), mode='eval')
parser.additional_truths = truths
parser.additional_falses = falses
new_tree = parser.visit(tree)
parser.additional_truths = set()
parser.additional_truths = set()
return new_tree
world_folder = os.path.dirname(__file__)
resources_source = os.path.join(world_folder, "Resources")
data_folder = os.path.join(resources_source, "Data")
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]
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:
if option["Path"] != "False":
items: typing.List[str] = []
locations: typing.List[str] = []
for pairing in option["Vanilla"]:
items.append(pairing["item"])
location_name = pairing["location"]
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
basename = option["Path"].split(".", 1)[-1]
if not basename.startswith("Randomize"):
basename = "Randomize" + basename
assert len(items) == len(locations)
if items: # skip empty pools
if basename in pool_options:
pool_options[basename] = pool_options[basename][0]+items, pool_options[basename][1]+locations
else:
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()
for item_name in sorted(items):
item = items[item_name]
items[item_name] = item["Pool"]
items: typing.Dict[str, str]
extra_item_data: typing.List[typing.Dict[str, typing.Any]] = hk_loads(os.path.join(logic_folder, "items.json"))
item_effects: typing.Dict[str, typing.Dict[str, int]] = {}
effect_names: typing.Set[str] = set()
for item_data in extra_item_data:
if "FalseItem" in item_data:
item_data = item_data["FalseItem"]
effects = []
if "Effect" in item_data:
effects = [item_data["Effect"]]
elif "Effects" in item_data:
effects = item_data["Effects"]
for effect in effects:
effect_names.add(effect["Term"])
effects = {effect["Term"]: effect["Value"] for effect in effects if
effect["Term"] != item_data["Name"] and effect["Term"] not in {"GEO",
"HALLOWNESTSEALS",
"WANDERERSJOURNALS",
'HALLOWNESTSEALS',
"KINGSIDOLS",
'ARCANEEGGS',
'MAPS'
}}
if effects:
item_effects[item_data["Name"]] = effects
del extra_item_data
# locations
original_locations: typing.Dict[str, typing.Dict[str, typing.Any]] = hk_loads(os.path.join(data_folder, "locations.json"))
del(original_locations["Start"]) # Starting Inventory works different in AP
locations: typing.List[str] = []
locations_in_regions: typing.Dict[str, typing.List[str]] = {}
location_to_region_lookup: typing.Dict[str, str] = {}
multi_locations: typing.Dict[str, typing.List[str]] = {}
for location_name, location_data in original_locations.items():
region_name = location_data["SceneName"]
if location_data["FlexibleCount"]:
location_names = [f"{location_name}_{count}" for count in range(1, 17)]
multi_locations[location_name] = location_names
else:
location_names = [location_name]
location_to_region_lookup.update({name: region_name for name in location_names})
locations_in_regions.setdefault(region_name, []).extend(location_names)
locations.extend(location_names)
del original_locations
# regions
region_names: typing.Set[str] = set(hk_loads(os.path.join(data_folder, "rooms.json")))
connectors_data: typing.Dict[str, typing.Dict[str, typing.Any]] = hk_loads(os.path.join(data_folder, "transitions.json"))
connectors_logic: typing.List[typing.Dict[str, typing.Any]] = hk_loads(os.path.join(logic_folder, "transitions.json"))
exits: typing.Dict[str, typing.List[str]] = {}
connectors: typing.Dict[str, str] = {}
one_ways: typing.Set[str] = set()
for connector_name, connector_data in connectors_data.items():
exits.setdefault(connector_data["SceneName"], []).append(connector_name)
connectors[connector_name] = connector_data["VanillaTarget"]
if connector_data["Sides"] != "Both":
one_ways.add(connector_name)
del connectors_data
# starts
starts: typing.Dict[str, typing.Dict[str, typing.Any]] = hk_loads(os.path.join(data_folder, "starts.json"))
# only allow always valid starts for now
removed_starts: typing.Set[str] = {name.replace(" ", "_").lower() for name, data in starts.items() if
name != "King's Pass"}
starts: typing.Dict[str, str] = {
name.replace(" ", "_").lower(): data["sceneName"] for name, data in starts.items() if name == "King's Pass"}
# logic
falses = {"MAPAREARANDO", "FULLAREARANDO"}
macros: typing.Dict[str, ast.AST] = {
}
parser = get_parser(set(), falses)
extra_macros: typing.Dict[str, str] = hk_loads(os.path.join(logic_folder, "macros.json"))
raw_location_rules: typing.List[typing.Dict[str, str]] = hk_loads(os.path.join(logic_folder, "locations.json"))
events: typing.List[typing.Dict[str, typing.Any]] = hk_loads(os.path.join(logic_folder, "waypoints.json"))
event_names: typing.Set[str] = {event["name"] for event in events}
for macro_name, rule in extra_macros.items():
if macro_name not in macros:
macro_name = put_digits_at_end(macro_name)
if macro_name in items or macro_name in effect_names:
continue
assert macro_name not in events
rule = ast_parse(parser, rule)
macros[macro_name] = rule
if macro_name.startswith("COMBAT["):
name = get_text_between(macro_name, "COMBAT[", "]")
if not "'" in name:
macros[f"COMBAT['{name}']"] = rule
macros[f'COMBAT["{name}"]'] = rule
location_rules: typing.Dict[str, str] = {}
for loc_obj in raw_location_rules:
loc_name = loc_obj["name"]
rule = loc_obj["logic"]
if rule != "ANY":
rule = ast_parse(parser, rule)
location_rules[loc_name] = unparse(rule)
location_rules["Salubra_(Requires_Charms)"] = location_rules["Salubra"]
connectors_rules: typing.Dict[str, str] = {}
for connector_obj in connectors_logic:
name = connector_obj["Name"]
rule = connector_obj["logic"]
rule = ast_parse(parser, rule)
rule = unparse(rule)
if rule != "True":
connectors_rules[name] = rule
event_rules: typing.Dict[str, str] = {}
for event in events:
rule = ast_parse(parser, event["logic"])
rule = unparse(rule)
if rule != "True":
event_rules[event["name"]] = rule
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", "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)
for name in names:
var = globals()[name]
if type(var) == set:
# sort so a regen doesn't cause a file change every time
var = sorted(var)
var = "{"+str(var)[1:-1]+"}"
py.write(f"{name} = {var}\n")
template_env: jinja2.Environment = \
jinja2.Environment(loader=jinja2.FileSystemLoader([os.path.join(os.path.dirname(__file__), "templates")]))
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("GeneratedRules.py", "wt") as py:
py.write(warning)
py.write(rules)

1699
World/GeneratedRules.py Normal file

File diff suppressed because it is too large Load diff

55
World/GodhomeData.py Normal file
View file

@ -0,0 +1,55 @@
from functools import partial
godhome_event_names = ["Godhome_Flower_Quest", "Defeated_Pantheon_5", "GG_Atrium_Roof", "Defeated_Pantheon_1", "Defeated_Pantheon_2", "Defeated_Pantheon_3", "Opened_Pantheon_4", "Defeated_Pantheon_4", "GG_Atrium", "Hit_Pantheon_5_Unlock_Orb", "GG_Workshop", "Can_Damage_Crystal_Guardian", 'Defeated_Any_Soul_Warrior', "Defeated_Colosseum_3", "COMBAT[Radiance]", "COMBAT[Pantheon_1]", "COMBAT[Pantheon_2]", "COMBAT[Pantheon_3]", "COMBAT[Pantheon_4]", "COMBAT[Pantheon_5]", "COMBAT[Colosseum_3]", 'Warp-Junk_Pit_to_Godhome', 'Bench-Godhome_Atrium', 'Bench-Hall_of_Gods', "GODTUNERUNLOCK", "GG_Waterways", "Warp-Godhome_to_Junk_Pit", "NAILCOMBAT", "BOSS", "AERIALMINIBOSS"]
def set_godhome_rules(hk_world, hk_set_rule):
player = hk_world.player
fn = partial(hk_set_rule, hk_world)
required_events = {
"Godhome_Flower_Quest": lambda state: state.count('Defeated_Pantheon_5', player) and state.count('Room_Mansion[left1]', player) and state.count('Fungus3_49[right1]', player) and state.has('Godtuner', player),
"Defeated_Pantheon_5": lambda state: state.has('GG_Atrium_Roof', player) and state.has('WINGS', player) and (state.has('LEFTCLAW', player) or state.has('RIGHTCLAW', player)) and ((state.has('Defeated_Pantheon_1', player) and state.has('Defeated_Pantheon_2', player) and state.has('Defeated_Pantheon_3', player) and state.has('Defeated_Pantheon_4', player) and state.has('COMBAT[Radiance]', player))),
"GG_Atrium_Roof": lambda state: state.has('GG_Atrium', player) and state.has('Hit_Pantheon_5_Unlock_Orb', player) and state.has('LEFTCLAW', player),
"Defeated_Pantheon_1": lambda state: state.has('GG_Atrium', player) and ((state.has('Defeated_Gruz_Mother', player) and state.has('Defeated_False_Knight', player) and (state.has('Fungus1_29[left1]', player) or state.has('Fungus1_29[right1]', player)) and state.has('Defeated_Hornet_1', player) and state.has('Defeated_Gorb', player) and state.has('Defeated_Dung_Defender', player) and state.has('Defeated_Any_Soul_Warrior', player) and state.has('Defeated_Brooding_Mawlek', player))),
"Defeated_Pantheon_2": lambda state: state.has('GG_Atrium', player) and ((state.has('Defeated_Xero', player) and state.has('Defeated_Crystal_Guardian', player) and state.has('Defeated_Soul_Master', player) and state.has('Defeated_Colosseum_2', player) and state.has('Defeated_Mantis_Lords', player) and state.has('Defeated_Marmu', player) and state.has('Defeated_Nosk', player) and state.has('Defeated_Flukemarm', player) and state.has('Defeated_Broken_Vessel', player))),
"Defeated_Pantheon_3": lambda state: state.has('GG_Atrium', player) and ((state.has('Defeated_Hive_Knight', player) and state.has('Defeated_Elder_Hu', player) and state.has('Defeated_Collector', player) and state.has('Defeated_Colosseum_2', player) and state.has('Defeated_Grimm', player) and state.has('Defeated_Galien', player) and state.has('Defeated_Uumuu', player) and state.has('Defeated_Hornet_2', player))),
"Opened_Pantheon_4": lambda state: state.has('GG_Atrium', player) and (state.has('Defeated_Pantheon_1', player) and state.has('Defeated_Pantheon_2', player) and state.has('Defeated_Pantheon_3', player)),
"Defeated_Pantheon_4": lambda state: state.has('GG_Atrium', player) and state.has('Opened_Pantheon_4', player) and ((state.has('Defeated_Enraged_Guardian', player) and state.has('Defeated_Broken_Vessel', player) and state.has('Defeated_No_Eyes', player) and state.has('Defeated_Traitor_Lord', player) and state.has('Defeated_Dung_Defender', player) and state.has('Defeated_False_Knight', player) and state.has('Defeated_Markoth', player) and state.has('Defeated_Watcher_Knights', player) and state.has('Defeated_Soul_Master', player))),
"GG_Atrium": lambda state: state.has('Warp-Junk_Pit_to_Godhome', player) and (state.has('RIGHTCLAW', player) or state.has('WINGS', player) or state.has('LEFTCLAW', player) and state.has('RIGHTSUPERDASH', player)) or state.has('GG_Workshop', player) and (state.has('LEFTCLAW', player) or state.has('RIGHTCLAW', player) and state.has('WINGS', player)) or state.has('Bench-Godhome_Atrium', player),
"Hit_Pantheon_5_Unlock_Orb": lambda state: state.has('GG_Atrium', player) and state.has('WINGS', player) and (state.has('LEFTCLAW', player) or state.has('RIGHTCLAW', player)) and (((state.has('Queen_Fragment', player) and state.has('King_Fragment', player) and state.has('Void_Heart', player)) and state.has('Defeated_Pantheon_1', player) and state.has('Defeated_Pantheon_2', player) and state.has('Defeated_Pantheon_3', player) and state.has('Defeated_Pantheon_4', player))),
"GG_Workshop": lambda state: state.has('GG_Atrium', player) or state.has('Bench-Hall_of_Gods', player),
"Can_Damage_Crystal_Guardian": lambda state: state.has('UPSLASH', player) or state.has('LEFTSLASH', player) or state.has('RIGHTSLASH', player) or state._hk_option(player, 'ProficientCombat') and (state.has('CYCLONE', player) or state.has('Great_Slash', player)) or (state._hk_option(player, 'DifficultSkips') and state._hk_option(player, 'ProficientCombat')) and (state.has('CYCLONE', player) or state.has('Great_Slash', player)) and (state.has('DREAMNAIL', player) and (state.has('SPELLS', player) or state.has('FOCUS', player) and state.has('Spore_Shroom', player) or state.has('Glowing_Womb', player)) or state.has('Weaversong', player)),
'Defeated_Any_Soul_Warrior': lambda state: state.has('Defeated_Sanctum_Warrior', player) or state.has('Defeated_Elegant_Warrior', player) or state.has('Room_Colosseum_01[left1]', player) and state.has('Defeated_Colosseum_3', player),
"Defeated_Colosseum_3": lambda state: state.has('Room_Colosseum_01[left1]', player) and state.has('Can_Replenish_Geo', player) and ((state.has('LEFTCLAW', player) or state.has('RIGHTCLAW', player)) or ((state._hk_option(player, 'DifficultSkips') and state._hk_option(player, 'ProficientCombat')) and state.has('WINGS', player))) and state.has('COMBAT[Colosseum_3]', player),
# MACROS
"COMBAT[Radiance]": lambda state: (state.has('LEFTDASH', player) and state.has('RIGHTDASH', player)) and ((((state.count('LEFTDASH', player) > 1 or state.count('RIGHTDASH', player) > 1) and state.has('LEFTDASH', player)) and ((state.count('LEFTDASH', player) > 1 or state.count('RIGHTDASH', player) > 1) and state.has('RIGHTDASH', player))) or state.has('QUAKE', player)) and (state.count('FIREBALL', player) > 1 and state.has('UPSLASH', player) or state.count('SCREAM', player) > 1 and state.has('UPSLASH', player) or state._hk_option(player, 'RemoveSpellUpgrades') and (state.has('FIREBALL', player) or state.has('SCREAM', player)) and state.has('UPSLASH', player) or state._hk_option(player, 'ProficientCombat') and (state.has('FIREBALL', player) or state.has('SCREAM', player)) and (state.has('UPSLASH', player) or (state.has('LEFTSLASH', player) or state.has('RIGHTSLASH', player)) or state.has('Great_Slash', player)) or (state._hk_option(player, 'DifficultSkips') and state._hk_option(player, 'ProficientCombat'))),
"COMBAT[Pantheon_1]": lambda state: state.has('AERIALMINIBOSS', player) and state.count('SPELLS', player) > 1 and (state.has('FOCUS', player) or (state._hk_option(player, 'DifficultSkips') and state._hk_option(player, 'ProficientCombat'))),
"COMBAT[Pantheon_2]": lambda state: state.has('AERIALMINIBOSS', player) and state.count('SPELLS', player) > 1 and (state.has('FOCUS', player) or (state._hk_option(player, 'DifficultSkips') and state._hk_option(player, 'ProficientCombat'))) and state.has('Can_Damage_Crystal_Guardian', player),
"COMBAT[Pantheon_3]": lambda state: state.has('AERIALMINIBOSS', player) and state.count('SPELLS', player) > 1 and (state.has('FOCUS', player) or (state._hk_option(player, 'DifficultSkips') and state._hk_option(player, 'ProficientCombat'))),
"COMBAT[Pantheon_4]": lambda state: state.has('AERIALMINIBOSS', player) and (state.has('FOCUS', player) or (state._hk_option(player, 'DifficultSkips') and state._hk_option(player, 'ProficientCombat'))) and state.has('Can_Damage_Crystal_Guardian', player) and (state.has('LEFTDASH', player) and state.has('RIGHTDASH', player)) and ((((state.count('LEFTDASH', player) > 1 or state.count('RIGHTDASH', player) > 1) and state.has('LEFTDASH', player)) and ((state.count('LEFTDASH', player) > 1 or state.count('RIGHTDASH', player) > 1) and state.has('RIGHTDASH', player))) or state.has('QUAKE', player)) and (state.count('FIREBALL', player) > 1 and state.has('UPSLASH', player) or state.count('SCREAM', player) > 1 and state.has('UPSLASH', player) or state._hk_option(player, 'RemoveSpellUpgrades') and (state.has('FIREBALL', player) or state.has('SCREAM', player)) and state.has('UPSLASH', player) or state._hk_option(player, 'ProficientCombat') and (state.has('FIREBALL', player) or state.has('SCREAM', player)) and (state.has('UPSLASH', player) or (state.has('LEFTSLASH', player) or state.has('RIGHTSLASH', player)) or state.has('Great_Slash', player)) or (state._hk_option(player, 'DifficultSkips') and state._hk_option(player, 'ProficientCombat'))),
"COMBAT[Pantheon_5]": lambda state: state.has('AERIALMINIBOSS', player) and state.has('FOCUS', player) and state.has('Can_Damage_Crystal_Guardian', player) and (state.has('LEFTDASH', player) and state.has('RIGHTDASH', player)) and ((((state.count('LEFTDASH', player) > 1 or state.count('RIGHTDASH', player) > 1) and state.has('LEFTDASH', player)) and ((state.count('LEFTDASH', player) > 1 or state.count('RIGHTDASH', player) > 1) and state.has('RIGHTDASH', player))) or state.has('QUAKE', player)) and (state.count('FIREBALL', player) > 1 and state.has('UPSLASH', player) or state.count('SCREAM', player) > 1 and state.has('UPSLASH', player) or state._hk_option(player, 'RemoveSpellUpgrades') and (state.has('FIREBALL', player) or state.has('SCREAM', player)) and state.has('UPSLASH', player) or state._hk_option(player, 'ProficientCombat') and (state.has('FIREBALL', player) or state.has('SCREAM', player)) and (state.has('UPSLASH', player) or (state.has('LEFTSLASH', player) or state.has('RIGHTSLASH', player)) or state.has('Great_Slash', player)) or (state._hk_option(player, 'DifficultSkips') and state._hk_option(player, 'ProficientCombat'))),
"COMBAT[Colosseum_3]": lambda state: state.has('BOSS', player) and (state.has('FOCUS', player) or (state._hk_option(player, 'DifficultSkips') and state._hk_option(player, 'ProficientCombat'))),
# MISC
'Warp-Junk_Pit_to_Godhome': lambda state: state.has('GG_Waterways', player) and state.has('GODTUNERUNLOCK', player) and state.has('DREAMNAIL', player),
'Bench-Godhome_Atrium': lambda state: state.has('GG_Atrium', player) and (state.has('RIGHTCLAW', player) and (state.has('RIGHTDASH', player) or state.has('LEFTCLAW', player) and state.has('RIGHTSUPERDASH', player) or state.has('WINGS', player)) or state.has('LEFTCLAW', player) and state.has('WINGS', player)),
'Bench-Hall_of_Gods': lambda state: state.has('GG_Workshop', player) and ((state.has('LEFTCLAW', player) or state.has('RIGHTCLAW', player))),
"GODTUNERUNLOCK": lambda state: state.count('SIMPLE', player) > 3,
"GG_Waterways": lambda state: state.has('GG_Waterways[door1]', player) or state.has('GG_Waterways[right1]', player) and (state.has('LEFTSUPERDASH', player) or state.has('SWIM', player)) or state.has('Warp-Godhome_to_Junk_Pit', player),
"Warp-Godhome_to_Junk_Pit": lambda state: state.has('Warp-Junk_Pit_to_Godhome', player) or state.has('GG_Atrium', player),
# COMBAT MACROS
"NAILCOMBAT": lambda state: (state.has('LEFTSLASH', player) or state.has('RIGHTSLASH', player)) or state.has('UPSLASH', player) or state._hk_option(player, 'ProficientCombat') and (state.has('CYCLONE', player) or state.has('Great_Slash', player)) or (state._hk_option(player, 'DifficultSkips') and state._hk_option(player, 'ProficientCombat')),
"BOSS": lambda state: state.count('SPELLS', player) > 1 and ((state.has('LEFTDASH', player) or state.has('RIGHTDASH', player)) and ((state.has('LEFTSLASH', player) or state.has('RIGHTSLASH', player)) or state.has('UPSLASH', player)) or state._hk_option(player, 'ProficientCombat') and state.has('NAILCOMBAT', player)),
"AERIALMINIBOSS": lambda state: (state.has('FIREBALL', player) or state.has('SCREAM', player)) and (state.has('LEFTDASH', player) or state.has('RIGHTDASH', player)) and ((state.has('LEFTSLASH', player) or state.has('RIGHTSLASH', player)) or state.has('UPSLASH', player)) or state._hk_option(player, 'ProficientCombat') and (state.has('FIREBALL', player) or state.has('SCREAM', player)) and ((state.has('LEFTSLASH', player) or state.has('RIGHTSLASH', player)) or state.has('UPSLASH', player)) or (state._hk_option(player, 'DifficultSkips') and state._hk_option(player, 'ProficientCombat')) and ((state.has('LEFTSLASH', player) or state.has('RIGHTSLASH', player)) or state.has('UPSLASH', player) or state.has('CYCLONE', player) or state.has('Great_Slash', player)),
}
for item, rule in required_events.items():
fn(item, rule)

68
World/Items.py Normal file
View file

@ -0,0 +1,68 @@
from typing import Dict, Set, NamedTuple
from .ExtractedData import items, logic_items, item_effects
from .GodhomeData import godhome_event_names
item_table = {}
class HKItemData(NamedTuple):
advancement: bool
id: int
type: str
for i, (item_name, item_type) in enumerate(items.items(), start=0x1000000):
item_table[item_name] = HKItemData(advancement=item_name in logic_items or item_name in item_effects,
id=i, type=item_type)
for item_name in godhome_event_names:
item_table[item_name] = HKItemData(advancement=True, id=None, type=None)
lookup_id_to_name: Dict[int, str] = {data.id: item_name for item_name, data in item_table.items()}
lookup_type_to_names: Dict[str, Set[str]] = {}
for item, item_data in item_table.items():
lookup_type_to_names.setdefault(item_data.type, set()).add(item)
directionals = ('', 'Left_', 'Right_')
item_name_groups = ({
"BossEssence": lookup_type_to_names["DreamWarrior"] | lookup_type_to_names["DreamBoss"],
"BossGeo": lookup_type_to_names["Boss_Geo"],
"CDash": {x + "Crystal_Heart" for x in directionals},
"Charms": lookup_type_to_names["Charm"],
"CharmNotches": lookup_type_to_names["Notch"],
"Claw": {x + "Mantis_Claw" for x in directionals},
"Cloak": {x + "Mothwing_Cloak" for x in directionals} | {"Shade_Cloak", "Split_Shade_Cloak"},
"Dive": {"Desolate_Dive", "Descending_Dark"},
"LifebloodCocoons": lookup_type_to_names["Cocoon"],
"Dreamers": {"Herrah", "Monomon", "Lurien"},
"Fireball": {"Vengeful_Spirit", "Shade_Soul"},
"GeoChests": lookup_type_to_names["Geo"],
"GeoRocks": lookup_type_to_names["Rock"],
"GrimmkinFlames": lookup_type_to_names["Flame"],
"Grimmchild": {"Grimmchild1", "Grimmchild2"},
"Grubs": lookup_type_to_names["Grub"],
"JournalEntries": lookup_type_to_names["Journal"],
"JunkPitChests": lookup_type_to_names["JunkPitChest"],
"Keys": lookup_type_to_names["Key"],
"LoreTablets": lookup_type_to_names["Lore"] | lookup_type_to_names["PalaceLore"],
"Maps": lookup_type_to_names["Map"],
"MaskShards": lookup_type_to_names["Mask"],
"Mimics": lookup_type_to_names["Mimic"],
"Nail": lookup_type_to_names["CursedNail"],
"PalaceJournal": {"Journal_Entry-Seal_of_Binding"},
"PalaceLore": lookup_type_to_names["PalaceLore"],
"PalaceTotem": {"Soul_Totem-Palace", "Soul_Totem-Path_of_Pain"},
"RancidEggs": lookup_type_to_names["Egg"],
"Relics": lookup_type_to_names["Relic"],
"Scream": {"Howling_Wraiths", "Abyss_Shriek"},
"Skills": lookup_type_to_names["Skill"],
"SoulTotems": lookup_type_to_names["Soul"],
"Stags": lookup_type_to_names["Stag"],
"VesselFragments": lookup_type_to_names["Vessel"],
"WhisperingRoots": lookup_type_to_names["Root"],
"WhiteFragments": {"Queen_Fragment", "King_Fragment", "Void_Heart"},
"DreamNails": {"Dream_Nail", "Dream_Gate", "Awoken_Dream_Nail"},
})
item_name_groups['Horizontal'] = item_name_groups['Cloak'] | item_name_groups['CDash']
item_name_groups['Vertical'] = item_name_groups['Claw'] | {'Monarch_Wings'}
item_name_groups['Skills'] |= item_name_groups['Vertical'] | item_name_groups['Horizontal']

596
World/Options.py Normal file
View file

@ -0,0 +1,596 @@
import typing
import re
from dataclasses import 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, PerGameCommonOptions
from .Charms import vanilla_costs, names as charm_names
if typing.TYPE_CHECKING:
# avoid import during runtime
from random import Random
else:
Random = typing.Any
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,
"display_name": "Start Location",
"__doc__": "Choose your start location. "
"This is currently only locked to King's Pass.",
**locations,
})
del (locations)
option_docstrings = {
"RandomizeDreamers": "Allow for Dreamers to be randomized into the item pool and opens their locations for "
"randomization.",
"RandomizeSkills": "Allow for Skills, such as Mantis Claw or Shade Soul, to be randomized into the item pool. "
"Also opens their locations\n 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\n sold in shops.",
"RandomizeKeys": "Allow for Keys to be randomized into the item pool. Includes those sold in shops.",
"RandomizeMaskShards": "Allow for Mask Shard to be randomized into the item pool and open their locations for"
" randomization.",
"RandomizeVesselFragments": "Allow for Vessel Fragments to be randomized into the item pool and open their "
"locations for randomization.",
"RandomizeCharmNotches": "Allow for Charm Notches to be randomized into the item pool. "
"Includes those sold by Salubra.",
"RandomizePaleOre": "Randomize Pale Ores into the item pool and open their locations for randomization.",
"RandomizeGeoChests": "Allow for Geo Chests to contain randomized items, "
"as well as their Geo reward being randomized into the item pool.",
"RandomizeJunkPitChests": "Randomize the contents of junk pit chests into the item pool and open their locations "
"for randomization.",
"RandomizeRancidEggs": "Randomize Rancid Eggs into the item pool and open their locations for randomization",
"RandomizeRelics": "Randomize Relics (King's Idol, et al.) into the item pool and open their locations for"
" randomization.",
"RandomizeWhisperingRoots": "Randomize the essence rewards from Whispering Roots into the item pool. Whispering "
"Roots will now grant a randomized\n item when completed. This can be previewed by "
"standing on the root.",
"RandomizeBossEssence": "Randomize boss essence drops, such as those for defeating Warrior Dreams, into the item "
"pool and open their locations\n 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.",
"RandomizeMaps": "Randomize Maps into the item pool. This causes Cornifer to give you a message allowing you to see"
" and buy an item\n that is randomized into that location as well.",
"RandomizeStags": "Randomize Stag Stations unlocks into the item pool as well as placing randomized items "
"on the stag station bell/toll.",
"RandomizeLifebloodCocoons": "Randomize Lifeblood Cocoon grants into the item pool and open their locations"
" for randomization.",
"RandomizeGrimmkinFlames": "Randomize Grimmkin Flames into the item pool and open their locations for "
"randomization.",
"RandomizeJournalEntries": "Randomize the Hunter's Journal as well as the findable journal entries into the item "
"pool, and open their locations\n 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"
" randomization.",
"RandomizeLoreTablets": "Randomize Lore items into the itempool, one per Lore Tablet, and place randomized item "
"grants on the tablets themselves.\n You must still read the tablet to get the item.",
"AltBlackEgg": "Access the Black Egg with any 3 spells/spell upgrades intead of dreamers.\n If Randomize Dreamers"
" is on, either one will open the Black Egg.",
"AltRadiance": "Access The Radiance after the player gets 8 total masks.",
"PreciseMovement": "Places skips into logic which require extremely precise player movement, possibly without "
"movement skills such as\n dash or claw.",
"ProficientCombat": "Places skips into logic which require proficient combat, possibly with limited items.",
"BackgroundObjectPogos": "Places skips into logic for locations which are reachable via pogoing off of "
"background objects.",
"EnemyPogos": "Places skips into logic for locations which are reachable via pogos off of enemies.",
"ObscureSkips": "Places skips into logic which are considered obscure enough that a beginner is not expected "
"to know them.",
"ShadeSkips": "Places shade skips into logic which utilize the player's shade for pogoing or damage boosting.",
"InfectionSkips": "Places skips into logic which are only possible after the crossroads become infected.",
"FireballSkips": "Places skips into logic which require the use of spells to reset fall speed while in mid-air.",
"SpikeTunnels": "Places skips into logic which require the navigation of narrow tunnels filled with spikes.",
"AcidSkips": "Places skips into logic which require crossing a pool of acid without Isma's Tear, or water if swim "
"is disabled.",
"DamageBoosts": "Places skips into logic which require you to take damage from an enemy or hazard to progress.",
"DangerousSkips": "Places skips into logic which contain a high risk of taking damage.",
"DarkRooms": "Places skips into logic which require navigating dark rooms without the use of the Lumafly Lantern.",
"ComplexSkips": "Places skips into logic which require intense setup or are obscure even beyond advanced skip "
"standards.",
"DifficultSkips": "Places skips into logic which are considered more difficult than typical.",
"RemoveSpellUpgrades": "Removes the second level of all spells from the item pool.",
}
default_on = {
"RandomizeDreamers",
"RandomizeSkills",
"RandomizeCharms",
"RandomizeKeys",
"RandomizeMaskShards",
"RandomizeVesselFragments",
"RandomizeCharmNotches",
"RandomizePaleOre",
"RandomizeRancidEggs",
"RandomizeRelics",
"RandomizeStags",
"RandomizeLifebloodCocoons"
}
shop_to_option = {
"Seer": "SeerRewardSlots",
"Grubfather": "GrubfatherRewardSlots",
"Sly": "SlyShopSlots",
"Sly_(Key)": "SlyKeyShopSlots",
"Iselda": "IseldaShopSlots",
"Salubra": "SalubraShopSlots",
"Leg_Eater": "LegEaterShopSlots",
"Salubra_(Requires_Charms)": "SalubraCharmShopSlots",
"Egg_Shop": "EggShopSlots",
}
hollow_knight_randomize_options: typing.Dict[str, type(Option)] = {}
splitter_pattern = re.compile(r'(?<!^)(?=[A-Z])')
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:
if option_name == "RandomizeFocus":
# pool options for focus are just lying
count = 1
else:
count = len([loc for loc in option_data[1] if loc != "Start"])
extra_data["__doc__"] = option_docstrings[option_name] + \
f"\n This option adds approximately {count} location{'s' if count != 1 else ''}."
if option_name in default_on:
option = type(option_name, (DefaultOnToggle,), extra_data)
else:
option = type(option_name, (Toggle,), extra_data)
option.display_name = splitter_pattern.sub(" ", option_name)
globals()[option.__name__] = option
hollow_knight_randomize_options[option.__name__] = option
hollow_knight_logic_options: typing.Dict[str, type(Option)] = {}
for option_name in logic_options.values():
if option_name in hollow_knight_randomize_options:
continue
extra_data = {"__module__": __name__}
# some options, such as elevator pass, appear in logic_options despite explicitly being
# handled below as classes.
if option_name in option_docstrings:
extra_data["__doc__"] = option_docstrings[option_name]
option = type(option_name, (Toggle,), extra_data)
option.display_name = splitter_pattern.sub(" ", option_name)
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"
range_start = 1
range_end = 46
default = 1
class MaximumGrubPrice(MinimumGrubPrice):
"""The maximum grub price in the range of prices that an item should cost from Grubfather."""
display_name = "Maximum Grub Price"
default = 23
class MinimumEssencePrice(Range):
"""The minimum essence price in the range of prices that an item should cost from Seer."""
display_name = "Minimum Essence Price"
range_start = 1
range_end = 2800
default = 1
class MaximumEssencePrice(MinimumEssencePrice):
"""The maximum essence price in the range of prices that an item should cost from Seer."""
display_name = "Maximum Essence Price"
default = 1400
class MinimumEggPrice(Range):
"""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."""
rich_text_doc = False
display_name = "Minimum Egg Price"
range_start = 1
range_end = 20
default = 1
class MaximumEggPrice(MinimumEggPrice):
"""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."""
rich_text_doc = False
display_name = "Maximum Egg Price"
default = 10
class MinimumCharmPrice(Range):
"""The minimum charm price in the range of prices that an item should cost for Salubra's shop item which also
carry a charm cost."""
display_name = "Minimum Charm Requirement"
range_start = 1
range_end = 40
default = 1
class MaximumCharmPrice(MinimumCharmPrice):
"""The maximum charm price in the range of prices that an item should cost for Salubra's shop item which also
carry a charm cost."""
display_name = "Maximum Charm Requirement"
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 = "Maximum Geo Price"
range_start = 1
range_end = 2000
default = 400
class RandomCharmCosts(NamedRange):
"""Total Notch Cost of all Charms together. Vanilla sums to 90.
This value is distributed among all charms in a random fashion.
Special Cases:
Set to -1 or vanilla for vanilla costs.
Set to -2 or shuffle to shuffle around the vanilla costs to different charms."""
rich_text_doc = False
display_name = "Randomize Charm Notch Costs"
range_start = 0
range_end = 240
default = -1
vanilla_costs: typing.List[int] = vanilla_costs
charm_count: int = len(vanilla_costs)
special_range_names = {
"vanilla": -1,
"shuffle": -2
}
def get_costs(self, random_source: Random) -> typing.List[int]:
charms: typing.List[int]
if -1 == self.value:
return self.vanilla_costs.copy()
elif -2 == self.value:
charms = self.vanilla_costs.copy()
random_source.shuffle(charms)
return charms
else:
charms = [0] * self.charm_count
for x in range(self.value):
index = random_source.randint(0, self.charm_count - 1)
while charms[index] > 5:
index = random_source.randint(0, self.charm_count - 1)
charms[index] += 1
return charms
class CharmCost(Range):
range_end = 6
class PlandoCharmCosts(OptionDict):
"""Allows setting a Charm's Notch costs directly, mapping {name: cost}.
This is set after any random Charm Notch costs, if applicable."""
display_name = "Charm Notch Cost Plando"
valid_keys = frozenset(charm_names)
schema = Schema({
Optional(name): And(int, lambda n: 6 >= n >= 0, error="Charm costs must be integers in the range 0-6.") for name in charm_names
})
def __init__(self, value):
# To handle keys of random like other options, create an option instance from their values
# Additionally a vanilla keyword is added to plando individual charms to vanilla costs
# and default is disabled so as to not cause confusion
self.value = {}
for key, data in value.items():
if isinstance(data, str):
if data.lower() == "vanilla" and key in self.valid_keys:
self.value[key] = vanilla_costs[charm_names.index(key)]
continue
elif data.lower() == "default":
# default is too easily confused with vanilla but actually 0
# skip CharmCost resolution to fail schema afterwords
self.value[key] = data
continue
try:
self.value[key] = CharmCost.from_any(data).value
except ValueError:
# will fail schema afterwords
self.value[key] = data
def get_costs(self, charm_costs: typing.List[int]) -> typing.List[int]:
for name, cost in self.value.items():
charm_costs[charm_names.index(name)] = cost
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 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"
option_any = 0
option_hollowknight = 1
option_siblings = 2
option_radiance = 3
option_godhome = 4
option_godhome_flower = 5
option_grub_hunt = 6
default = 0
class GrubHuntGoal(NamedRange):
"""The amount of grubs required to finish Grub Hunt.
On 'All' any grubs from item links replacements etc. will be counted"""
rich_text_doc = False
display_name = "Grub Hunt Goal"
range_start = 1
range_end = 46
special_range_names = {"all": -1, "forty_six": 46}
default = 46
class WhitePalace(Choice):
"""
Whether or not to include White Palace or not. Note: Even if excluded, the King Fragment check may still be
required if charms are vanilla.
"""
display_name = "White Palace"
option_exclude = 0 # No White Palace at all
option_kingfragment = 1 # Include King Fragment check only
option_nopathofpain = 2 # Exclude Path of Pain locations.
option_include = 3 # Include all White Palace locations, including Path of Pain.
default = 0
class ExtraPlatforms(DefaultOnToggle):
"""Places additional platforms to make traveling throughout Hallownest more convenient."""
display_name = "Extra Platforms"
class AddUnshuffledLocations(Toggle):
"""Adds non-randomized locations to the location pool, which allows syncing
of location state with co-op or automatic collection via collect.
Note: This will increase the number of location checks required to purchase
hints to the total maximum.
"""
display_name = "Add Unshuffled Locations"
class DeathLinkShade(Choice):
"""Sets whether to create a shade when you are killed by a DeathLink and how to handle your existing shade, if any.
vanilla: DeathLink deaths function like any other death and overrides your existing shade (including geo), if any.
shadeless: DeathLink deaths do not spawn shades. Your existing shade (including geo), if any, is untouched.
shade: DeathLink deaths spawn a shade if you do not have an existing shade. Otherwise, it acts like shadeless.
* This option has no effect if DeathLink is disabled.
** Self-death shade behavior is not changed; if a self-death normally creates a shade in vanilla, it will override
your existing shade, if any.
"""
rich_text_doc = False
option_vanilla = 0
option_shadeless = 1
option_shade = 2
default = 2
display_name = "Deathlink Shade Handling"
class DeathLinkBreaksFragileCharms(Toggle):
"""Sets if fragile charms break when you are killed by a DeathLink.
* This option has no effect if DeathLink is disabled.
** Self-death fragile charm behavior is not changed; if a self-death normally breaks fragile charms in vanilla, it
will continue to do so.
"""
rich_text_doc = False
display_name = "Deathlink Breaks Fragile Charms"
class StartingGeo(Range):
"""The amount of starting geo you have."""
display_name = "Starting Geo"
range_start = 0
range_end = 1000
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
"""
rich_text_doc = False
option_off = 0
alias_no = 0
option_on = 1
alias_yes = 1
option_shopsonly = 2
option_notshops = 3
display_name = "Costsanity"
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
display_name = "Costsanity Hybrid Chance"
cost_sanity_weights: typing.Dict[str, type(Option)] = {}
for term, cost in cost_terms.items():
option_name = f"CostSanity{cost.option}Weight"
display_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)
option.display_name = display_name
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, GrubHuntGoal, WhitePalace, ExtraPlatforms, AddUnshuffledLocations, StartingGeo,
DeathLink, DeathLinkShade, DeathLinkBreaksFragileCharms,
MinimumGeoPrice, MaximumGeoPrice,
MinimumGrubPrice, MaximumGrubPrice,
MinimumEssencePrice, MaximumEssencePrice,
MinimumCharmPrice, MaximumCharmPrice,
RandomCharmCosts, PlandoCharmCosts,
MinimumEggPrice, MaximumEggPrice, EggShopSlots,
SlyShopSlots, SlyKeyShopSlots, IseldaShopSlots,
SalubraShopSlots, SalubraCharmShopSlots,
LegEaterShopSlots, GrubfatherRewardSlots,
SeerRewardSlots, ExtraShopSlots,
SplitCrystalHeart, SplitMothwingCloak, SplitMantisClaw,
CostSanity, CostSanityHybridChance
)
},
**cost_sanity_weights
}
HKOptions = make_dataclass("HKOptions", [(name, option) for name, option in hollow_knight_options.items()], bases=(PerGameCommonOptions,))

25
World/Regions.py Normal file
View file

@ -0,0 +1,25 @@
from .ExtractedData import region_names, exits, connectors
def create_regions(world, player: int):
from . import create_region, HKLocation, HKItem
world.regions.append(create_region(world, player, 'Menu', None, ['Hollow Nest S&Q']))
for region in region_names:
world.regions.append(create_region(world, player, region, [],
exits.get(region, [])))
for entrance_name, exit_name in connectors.items():
if exit_name:
target_region = world.get_entrance(exit_name, player).parent_region
world.get_entrance(entrance_name, player).connect(target_region)
if not entrance_name.endswith("_R"):
# a traversable entrance puts the name of the target door "into logic".
loc = HKLocation(player, exit_name, None, target_region)
loc.place_locked_item(HKItem(exit_name,
not exit_name.startswith("White_Palace_"),
None, "Event", player))
target_region.locations.append(loc)
else:
ent = world.get_entrance(entrance_name, player)
ent.parent_region.exits.remove(ent)

90
World/Rules.py Normal file
View file

@ -0,0 +1,90 @@
from ..generic.Rules import set_rule, add_rule
from ..AutoWorld import World
from .GeneratedRules import set_generated_rules
from .GodhomeData import set_godhome_rules
from typing import NamedTuple
class CostTerm(NamedTuple):
term: str
option: str
singular: str
plural: str
weight: int # CostSanity
sort: int
cost_terms = {x.term: x for x in (
CostTerm("RANCIDEGGS", "Egg", "Rancid Egg", "Rancid Eggs", 1, 3),
CostTerm("GRUBS", "Grub", "Grub", "Grubs", 1, 2),
CostTerm("ESSENCE", "Essence", "Essence", "Essence", 1, 4),
CostTerm("CHARMS", "Charm", "Charm", "Charms", 1, 1),
CostTerm("GEO", "Geo", "Geo", "Geo", 8, 9999),
)}
def hk_set_rule(hk_world: World, location: str, rule):
player = hk_world.player
locations = hk_world.created_multi_locations.get(location)
if locations is None:
try:
locations = [hk_world.multiworld.get_location(location, player)]
except KeyError:
return
for location in locations:
set_rule(location, rule)
def set_rules(hk_world: World):
player = hk_world.player
set_generated_rules(hk_world, hk_set_rule)
set_godhome_rules(hk_world, hk_set_rule)
# Shop costs
for location in hk_world.multiworld.get_locations(player):
if location.costs:
for term, amount in location.costs.items():
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) or ((state._hk_option(player, 'AltRadiance') and (state.count('MASKSHARDS', player) > 11))))
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
)
)

804
World/__init__.py Normal file
View file

@ -0,0 +1,804 @@
from __future__ import annotations
import logging
import typing
from copy import deepcopy
import itertools
import operator
from collections import defaultdict, Counter
from .Items import item_table, item_name_groups
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, GrubHuntGoal
from .ExtractedData import locations, starts, multi_locations, event_names, item_effects, connectors, \
vanilla_shop_costs, vanilla_location_costs
from .Charms import names as charm_names
from BaseClasses import Region, Location, MultiWorld, Item, LocationProgressType, Tutorial, ItemClassification, \
CollectionState
from worlds.AutoWorld import World, LogicMixin, WebWorld
from settings import Group, Bool
logger = logging.getLogger("Hollow Knight Test")
class HollowKnightSettings(Group):
class DisableMapModSpoilers(Bool):
"""Disallows the APMapMod from showing spoiler placements."""
disable_spoilers: typing.Union[DisableMapModSpoilers, bool] = False
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):
rich_text_options_doc = True
setup_en = Tutorial(
"Mod Setup and Use Guide",
"A guide to playing Hollow Knight with Archipelago.",
"English",
"setup_en.md",
"setup/en",
["Ijwu"]
)
setup_pt_br = Tutorial(
setup_en.tutorial_name,
setup_en.description,
"Português Brasileiro",
"setup_pt_br.md",
"setup/pt_br",
["JoaoVictor-FA"]
)
setup_es = Tutorial(
setup_en.tutorial_name,
setup_en.description,
"Español",
"setup_es.md",
"setup/es",
["GreenMarco", "Panto UwUr"]
)
tutorials = [setup_en, setup_pt_br, setup_es]
game_info_languages = ["en", "es"]
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 Test"
options_dataclass = HKOptions
options: HKOptions
settings: typing.ClassVar[HollowKnightSettings]
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 = {}
grub_count: int
grub_player_count: typing.Dict[int, int]
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))
# defaulting so completion condition isn't incorrect before pre_fill
self.grub_count = (
46 if options.GrubHuntGoal == GrubHuntGoal.special_range_names["all"]
else options.GrubHuntGoal.value
)
self.grub_player_count = {self.player: self.grub_count}
def white_palace_exclusions(self):
exclusions = set()
wp = self.options.WhitePalace
if wp <= WhitePalace.option_nopathofpain:
exclusions.update(path_of_pain_locations)
exclusions.update((
"Soul_Totem-Path_of_Pain",
"Lore_Tablet-Path_of_Pain_Entrance",
"Journal_Entry-Seal_of_Binding",
))
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)
exclusions.update(item_name_groups["PalaceJournal"])
exclusions.update(item_name_groups["PalaceLore"])
exclusions.update(item_name_groups["PalaceTotem"])
return exclusions
def create_regions(self):
menu_region: Region = create_region(self.multiworld, self.player, 'Menu')
self.multiworld.regions.append(menu_region)
# 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, Goal.option_any]:
from .GodhomeData import godhome_event_names
all_event_names.update(set(godhome_event_names))
# Link regions
for event_name in sorted(all_event_names):
loc = HKLocation(self.player, event_name, None, menu_region)
loc.place_locked_item(HKItem(event_name,
True,
None, "Event", self.player))
menu_region.locations.append(loc)
for entry_transition, exit_transition in connectors.items():
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,
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"))
# If we have the Alt Black Egg entrance, we shouldn't put dreamers in the pool
# Unless we want extra dreamers
if self.options.AltBlackEgg and not self.options.RandomizeDreamers:
junk_replace.update(('Lurien', 'Monomon', 'Herrah'))
# If we have the Alt Randiance entrance, we shouldn't royal fragments in the pool
if self.options.AltRadiance:
junk_replace.update(('Queen_Fragment','King_Fragment','Void_Heart'))
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, shop_locations in self.created_multi_locations.items():
for _ in range(len(shop_locations), getattr(self.options, shop_to_option[shop]).value):
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 = [shop for shop, shop_locations in self.created_multi_locations.items() if len(shop_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)
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, shop_locations in self.created_multi_locations.items():
randomized_locations = [loc for loc in shop_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, "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)
elif goal == Goal.option_grub_hunt:
multiworld.completion_condition[player] = lambda state: self.can_grub_goal(state)
else:
# Any goal
multiworld.completion_condition[player] = lambda state: _hk_siblings_ending(state, player) and \
_hk_can_beat_radiance(state, player) and state.count("Godhome_Flower_Quest", player) and \
self.can_grub_goal(state)
set_rules(self)
def can_grub_goal(self, state: CollectionState) -> bool:
return all(state.has("Grub", owner, count) for owner, count in self.grub_player_count.items())
@classmethod
def stage_pre_fill(cls, multiworld: "MultiWorld"):
worlds = [world for world in multiworld.get_game_worlds(cls.game) if world.options.Goal in ["any", "grub_hunt"]]
if worlds:
grubs = [item for item in multiworld.get_items() if item.name == "Grub"]
all_grub_players = [
world.player
for world in worlds
if world.options.GrubHuntGoal == GrubHuntGoal.special_range_names["all"]
]
if all_grub_players:
group_lookup = defaultdict(set)
for group_id, group in multiworld.groups.items():
for player in group["players"]:
group_lookup[group_id].add(player)
grub_count_per_player = Counter()
per_player_grubs_per_player = defaultdict(Counter)
for grub in grubs:
player = grub.player
if player in group_lookup:
for real_player in group_lookup[player]:
per_player_grubs_per_player[real_player][player] += 1
else:
per_player_grubs_per_player[player][player] += 1
if grub.location and grub.location.player in group_lookup.keys():
# will count the item linked grub instead
pass
elif player in group_lookup:
for real_player in group_lookup[player]:
grub_count_per_player[real_player] += 1
else:
# for non-linked grubs
grub_count_per_player[player] += 1
for player, count in grub_count_per_player.items():
multiworld.worlds[player].grub_count = count
for player, grub_player_count in per_player_grubs_per_player.items():
if player in all_grub_players:
multiworld.worlds[player].grub_player_count = grub_player_count
for world in worlds:
if world.player not in all_grub_players:
world.grub_count = world.options.GrubHuntGoal.value
player = world.player
world.grub_player_count = {player: world.grub_count}
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:
# exclude more complex types - we only care about int, bool, enum for player options; the client
# can get them back to the necessary type.
optionvalue = int(option.value)
options[option_name] = optionvalue
except TypeError:
pass
# 32 bit int
slot_data["seed"] = self.random.randint(-2147483647, 2147483646)
# 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
slot_data["grub_count"] = self.grub_count
slot_data["is_race"] = self.settings.disable_spoilers or self.multiworld.is_race
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, self.options.AltRadiance)
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, shop_locations in hk_world.created_multi_locations.items():
for loc in shop_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 Test"
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 Test"
type: str
def __init__(self, name, advancement, code, type: str, player: int = None, alt_radiance = False):
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
elif alt_radiance and type == "Mask":
classification = ItemClassification.progression_skip_balancing
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

View file

@ -0,0 +1,24 @@
# Hollow Knight
## Where is the options page?
The [player options page for this game](../player-options) contains all the options you need to configure and export a
config file.
## What does randomization do to this game?
Randomization swaps around the locations of items. The items being swapped around are chosen within your YAML.
Shop costs are presently always randomized. Items which could be randomized, but are not, will remain unmodified in
their usual locations. In particular, when the items at Grubfather and Seer are partially randomized, randomized items
will be obtained from a chest in the room, while unrandomized items will be given by the NPC as normal.
## What Hollow Knight items can appear in other players' worlds?
This is dependent entirely upon your YAML options. Some examples include: charms, grubs, lifeblood cocoons, geo, etc.
## What does another world's item look like in Hollow Knight?
When the Hollow Knight player picks up an item from a location and it is an item for another game it will appear in that
player's recent items display as an item being sent to another player. If the item is for another Hollow Knight player
then the sprite will be that of the item's original sprite. If the item belongs to a player that is not playing Hollow
Knight then the sprite will be the Archipelago logo.

View file

@ -0,0 +1,25 @@
# Hollow Knight
## ¿Dónde está la página de opciones?
La [página de opciones de jugador para este juego](../player-options) contiene todas las opciones que necesitas para
configurar y exportar un archivo de configuración.
## ¿Qué se randomiza en este juego?
El randomizer cambia la ubicación de los objetos. Los objetos que se intercambian se eligen dentro de tu YAML.
Los costes de las tiendas son aleatorios. Los objetos que podrían ser aleatorios, pero no lo son, permanecerán sin
modificar en sus ubicaciones habituales. En particular, cuando los ítems con el PadreLarva y la Vidente están
parcialmente randomizados, los ítems randomizados se obtendrán de un cofre en la habitación, mientras que los ítems no
randomizados serán dados por el NPC de forma normal.
## ¿Qué objetos de Hollow Knight pueden aparecer en los mundos de otros jugadores?
Esto depende enteramente de tus opciones YAML. Algunos ejemplos son: amuletos, larvas, capullos de saviavida, geo, etc.
## ¿Qué aspecto tienen los objetos de otro mundo en Hollow Knight?
Cuando el jugador de Hollow Knight recoja un objeto de un lugar y sea un objeto para otro juego, aparecerá en la
pantalla de objetos recientes de ese jugador como un objeto enviado a otro jugador. Si el objeto es para otro jugador
de Hollow Knight entonces el sprite será el del sprite original del objeto. Si el objeto pertenece a un jugador que no
está jugando a Hollow Knight, el sprite será el logo del Archipiélago.

58
World/docs/setup_en.md Normal file
View file

@ -0,0 +1,58 @@
# Hollow Knight for Archipelago Setup Guide
## Required Software
* Download and unzip the Lumafly Mod Manager from the [Lumafly website](https://themulhima.github.io/Lumafly/).
* A legal copy of Hollow Knight.
* Steam, Gog, and Xbox Game Pass versions of the game are supported.
* Windows, Mac, and Linux (including Steam Deck) are supported.
## Installing the Archipelago Mod using Lumafly
1. Launch Lumafly and ensure it locates your Hollow Knight installation directory.
2. Install the Archipelago mods by doing either of the following:
* Click one of the links below to allow Lumafly to install the mods. Lumafly will prompt for confirmation.
* [Archipelago and dependencies only](https://themulhima.github.io/Lumafly/commands/download/?mods=Archipelago)
* [Archipelago with rando essentials](https://themulhima.github.io/Lumafly/commands/download/?mods=Archipelago/Archipelago%20Map%20Mod/RecentItemsDisplay/DebugMod/RandoStats/Additional%20Timelines/CompassAlwaysOn/AdditionalMaps/)
(includes Archipelago Map Mod, RecentItemsDisplay, DebugMod, RandoStats, AdditionalTimelines, CompassAlwaysOn,
and AdditionalMaps).
* Click the "Install" button near the "Archipelago" mod entry. If desired, also install "Archipelago Map Mod"
to use as an in-game tracker.
3. Launch the game, you're all set!
### What to do if Lumafly fails to find your installation directory
1. Find the directory manually.
* Xbox Game Pass:
1. Enter the Xbox app and move your mouse over "Hollow Knight" on the left sidebar.
2. Click the three points then click "Manage".
3. Go to the "Files" tab and select "Browse...".
4. Click "Hollow Knight", then "Content", then click the path bar and copy it.
* Steam:
1. You likely put your Steam library in a non-standard place. If this is the case, you probably know where
it is. Find your steam library and then find the Hollow Knight folder and copy the path.
* Windows - `C:\Program Files (x86)\Steam\steamapps\common\Hollow Knight`
* Linux/Steam Deck - ~/.local/share/Steam/steamapps/common/Hollow Knight
* Mac - ~/Library/Application Support/Steam/steamapps/common/Hollow Knight/hollow_knight.app
2. Run Lumafly as an administrator and, when it asks you for the path, paste the path you copied.
## Configuring your YAML File
### What is a YAML and why do I need one?
An YAML file is the way that you provide your player options to Archipelago.
See the [basic multiworld setup guide](/tutorial/Archipelago/setup/en) here on the Archipelago website to learn more.
### Where do I get a YAML?
You can use the [game options page for Hollow Knight](/games/Hollow%20Knight/player-options) here on the Archipelago
website to generate a YAML using a graphical interface.
## Joining an Archipelago Game in Hollow Knight
1. Start the game after installing all necessary mods.
2. Create a **new save game.**
3. Select the **Archipelago** game mode from the mode selection screen.
4. Enter the correct settings for your Archipelago server.
5. Hit **Start** to begin the game. The game will stall for a few seconds while it does all item placements.
6. The game will immediately drop you into the randomized game.
* If you are waiting for a countdown then wait for it to lapse before hitting Start.
* Or hit Start then pause the game once you're in it.
## Hints and other commands
While playing in a multiworld, you can interact with the server using various commands listed in the
[commands guide](/tutorial/Archipelago/commands/en). You can use the Archipelago Text Client to do this,
which is included in the latest release of the [Archipelago software](https://github.com/ArchipelagoMW/Archipelago/releases/latest).

64
World/docs/setup_es.md Normal file
View file

@ -0,0 +1,64 @@
# Hollow Knight Archipelago
## Software requerido
* Descarga y descomprime Lumafly Mod manager desde el [sitio web de Lumafly](https://themulhima.github.io/Lumafly/)
* Tener una copia legal de Hollow Knight.
* Las versiones de Steam, GOG y Xbox Game Pass son compatibles
* Las versiones de Windows, Mac y Linux (Incluyendo Steam Deck) son compatibles
## Instalación del mod de Archipelago con Lumafly
1. Ejecuta Lumafly y asegurate de localizar la carpeta de instalación de Hollow Knight
2. Instala el mod de Archipiélago haciendo click en cualquiera de los siguientes:
* Haz clic en uno de los enlaces de abajo para permitir Lumafly para instalar los mods. Lumafly pedirá
confirmación.
* [Archipiélago y dependencias solamente](https://themulhima.github.io/Lumafly/commands/download/?mods=Archipelago)
* [Archipelago con rando essentials](https://themulhima.github.io/Lumafly/commands/download/?mods=Archipelago/Archipelago%20Map%20Mod/RecentItemsDisplay/DebugMod/RandoStats/Additional%20Timelines/CompassAlwaysOn/AdditionalMaps/)
(incluye Archipelago Map Mod, RecentItemsDisplay, DebugMod, RandoStats, AdditionalTimelines, CompassAlwaysOn,
y AdditionalMaps).
* Haz clic en el botón "Instalar" situado junto a la entrada del mod "Archipiélago". Si lo deseas, instala también
"Archipelago Map Mod" para utilizarlo como rastreador en el juego.
Si lo requieres (Y recomiendo hacerlo) busca e instala Archipelago Map Mod para usar un tracker in-game
3. Ejecuta el juego desde el apartado de inicio haciendo click en el botón Launch with Mods
## Que hago si Lumafly no encontro la ruta de instalación de mi juego?
1. Busca el directorio manualmente
* En Xbox Game pass:
1. Entra a la Xbox App y dirigete sobre el icono de Hollow Knight que esta a la izquierda.
2. Haz click en los 3 puntitos y elige el apartado Administrar
3. Dirigete al apartado Archivos Locales y haz click en Buscar
4. Abre en Hollow Knight, luego Content y copia la ruta de archivos que esta en la barra de navegación.
* En Steam:
1. Si instalaste Hollow Knight en algún otro disco que no sea el predeterminado, ya sabrás donde se encuentra
el juego, ve a esa carpeta, abrela y copia la ruta de archivos que se encuentra en la barra de navegación.
* En Windows, la ruta predeterminada suele ser:`C:\Program Files (x86)\Steam\steamapps\common\Hollow Knight`
* En linux/Steam Deck suele ser: ~/.local/share/Steam/steamapps/common/Hollow Knight
* En Mac suele ser: ~/Library/Application Support/Steam/steamapps/common/Hollow Knight/hollow_knight.app
2. Ejecuta Lumafly como administrador y, cuando te pregunte por la ruta de instalación, pega la ruta que copeaste
anteriormente.
## Configuración de tu fichero YAML
### ¿Qué es un YAML y por qué necesito uno?
Un archivo YAML es la forma en la que proporcionas tus opciones de jugador a Archipelago.
Mira la [guía básica de configuración multiworld](/tutorial/Archipelago/setup/en) aquí en la web de Archipelago para
aprender más, (solo se encuentra en Inglés).
### ¿Dónde consigo un YAML?
Puedes usar la [página de opciones de juego para Hollow Knight](/games/Hollow%20Knight/player-options) aquí en la web
de Archipelago para generar un YAML usando una interfaz gráfica.
## Unete a una partida de Archipelago en Hollow Knight
1. Inicia el juego con los mods necesarios indicados anteriormente.
2. Crea una **nueva partida.**
3. Elige el modo **Archipelago** en la selección de modos de partida.
4. Introduce la configuración correcta para tu servidor de Archipelago.
5. Pulsa **Iniciar** para iniciar la partida. El juego se quedará con la pantalla en negro unos segundos mientras
coloca todos los objetos.
6. El juego debera comenzar y ya estaras dentro del servidor.
* Si estas esperando a que termine un contador/timer, procura presionar el boton Start cuando el contador/timer
termine.
* Otra manera es pausar el juego y esperar a que el contador/timer termine cuando ingreses a la partida.
## Consejos y otros comandos
Mientras juegas en un multiworld, puedes interactuar con el servidor usando varios comandos listados en la
[guía de comandos](/tutorial/Archipelago/commands/en). Puedes usar el Cliente de Texto Archipelago para hacer esto,
que está incluido en la última versión del [software de Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases/latest).

52
World/docs/setup_pt_br.md Normal file
View file

@ -0,0 +1,52 @@
# Guia de configuração para Hollow Knight no Archipelago
## Programas obrigatórios
* Baixe e extraia o Lumafly Mod Manager (gerenciador de mods Lumafly) do [Site Lumafly](https://themulhima.github.io/Lumafly/).
* Uma cópia legal de Hollow Knight.
* Versões Steam, Gog, e Xbox Game Pass do jogo são suportadas.
* Windows, Mac, e Linux (incluindo Steam Deck) são suportados.
## Instalando o mod Archipelago Mod usando Lumafly
1. Abra o Lumafly e confirme que ele localizou sua pasta de instalação do Hollow Knight.
2. Clique em "Install (instalar)" perto da opção "Archipelago" mod.
* Se quiser, instale também o "Archipelago Map Mod (mod do mapa do archipelago)" para usá-lo como rastreador dentro do jogo.
3. Abra o jogo, tudo preparado!
### O que fazer se o Lumafly falha em encontrar a sua pasta de instalação
1. Encontre a pasta manualmente.
* Xbox Game Pass:
1. Entre no seu aplicativo Xbox e mova seu mouse em cima de "Hollow Knight" na sua barra da esquerda.
2. Clique nos 3 pontos depois clique gerenciar.
3. Vá nos arquivos e selecione procurar.
4. Clique em "Hollow Knight", depois em "Content (Conteúdo)", depois clique na barra com o endereço e a copie.
* Steam:
1. Você provavelmente colocou sua biblioteca Steam num local não padrão. Se esse for o caso você provavelmente sabe onde está.
Encontre sua biblioteca Steam, depois encontre a pasta do Hollow Knight e copie seu endereço.
* Windows - `C:\Program Files (x86)\Steam\steamapps\common\Hollow Knight`
* Linux/Steam Deck - `~/.local/share/Steam/steamapps/common/Hollow Knight`
* Mac - `~/Library/Application Support/Steam/steamapps/common/Hollow Knight/hollow_knight.app`
2. Rode o Lumafly como administrador e, quando ele perguntar pelo endereço do arquivo, cole o endereço do arquivo que você copiou.
## Configurando seu arquivo YAML
### O que é um YAML e por que eu preciso de um?
Um arquivo YAML é a forma que você informa suas configurações do jogador para o Archipelago.
Olhe o [guia de configuração básica de um multiworld](/tutorial/Archipelago/setup/en) aqui no site do Archipelago para aprender mais.
### Onde eu consigo o YAML?
Você pode usar a [página de configurações do jogador para Hollow Knight](/games/Hollow%20Knight/player-options) aqui no site do Archipelago
para gerar o YAML usando a interface gráfica.
## Entrando numa partida de Archipelago no Hollow Knight
1. Começe o jogo depois de instalar todos os mods necessários.
2. Crie um **novo jogo salvo.**
3. Selecione o modo de jogo **Archipelago** do menu de seleção.
4. Coloque as configurações corretas do seu servidor Archipelago.
5. Aperte em **Começar**. O jogo vai travar por uns segundos enquanto ele coloca todos itens.
6. O jogo vai te colocar imediatamente numa partida randomizada.
* Se você está esperando uma contagem então espere ele cair antes de apertar começar.
* Ou clique em começar e pause o jogo enquanto estiver nele.
## Dicas e outros comandos
Enquanto jogar um multiworld, você pode interagir com o servidor usando vários comandos listados no
[Guia de comandos](/tutorial/Archipelago/commands/en). Você pode usar o cliente de texto do Archipelago para isso,
que está incluido na ultima versão do [Archipelago software](https://github.com/ArchipelagoMW/Archipelago/releases/latest).

View file

@ -0,0 +1,27 @@
# This module is written by Extractor.py, do not edit manually!.
from functools import partial
def set_generated_rules(hk_world, hk_set_rule):
player = hk_world.player
fn = partial(hk_set_rule, hk_world)
# Events
{% for location, rule_text in event_rules.items() %}
fn("{{location}}", lambda state: {{rule_text}})
{%- endfor %}
# Locations
{% for location, rule_text in location_rules.items() %}
fn("{{location}}", lambda state: {{rule_text}})
{%- endfor %}
# Connectors
{% for entrance, rule_text in connectors_rules.items() %}
rule = lambda state: {{rule_text}}
entrance = world.get_entrance("{{entrance}}", player)
entrance.access_rule = rule
{%- if entrance not in one_ways %}
world.get_entrance("{{entrance}}_R", player).access_rule = lambda state, entrance= entrance: \
rule(state) and entrance.can_reach(state)
{%- endif %}
{%- endfor %}

61
World/test/__init__.py Normal file
View file

@ -0,0 +1,61 @@
import typing
from argparse import Namespace
from BaseClasses import CollectionState, MultiWorld
from Options import ItemLinks
from worlds.AutoWorld import AutoWorldRegister, call_all
from .. import HKWorld
class linkedTestHK():
run_default_tests = False
game = "Hollow Knight"
world: HKWorld
expected_grubs: int
item_link_group: typing.List[typing.Dict[str, typing.Any]]
def setup_item_links(self, args):
setattr(args, "item_links",
{
1: ItemLinks.from_any(self.item_link_group),
2: ItemLinks.from_any([{
"name": "ItemLinkTest",
"item_pool": ["Grub"],
"link_replacement": False,
"replacement_item": "One_Geo",
}])
})
return args
def world_setup(self) -> None:
"""
Create a multiworld with two players that share an itemlink
"""
self.multiworld = MultiWorld(2)
self.multiworld.game = {1: self.game, 2: self.game}
self.multiworld.player_name = {1: "Linker 1", 2: "Linker 2"}
self.multiworld.set_seed()
args = Namespace()
options_dataclass = AutoWorldRegister.world_types[self.game].options_dataclass
for name, option in options_dataclass.type_hints.items():
setattr(args, name, {
1: option.from_any(self.options.get(name, option.default)),
2: option.from_any(self.options.get(name, option.default))
})
args = self.setup_item_links(args)
self.multiworld.set_options(args)
self.multiworld.set_item_links()
# groups get added to state during its constructor so this has to be after item links are set
self.multiworld.state = CollectionState(self.multiworld)
gen_steps = ("generate_early", "create_regions", "create_items", "set_rules", "generate_basic")
for step in gen_steps:
call_all(self.multiworld, step)
# link the items together and stop at prefill
self.multiworld.link_items()
self.multiworld._all_state = None
call_all(self.multiworld, "pre_fill")
self.world = self.multiworld.worlds[self.player]
def test_grub_count(self) -> None:
assert self.world.grub_count == self.expected_grubs, \
f"Expected {self.expected_grubs} but found {self.world.grub_count}"

View file

@ -0,0 +1,166 @@
from test.bases import WorldTestBase
from Options import ItemLinks
from . import linkedTestHK
class test_grubcount_limited(linkedTestHK, WorldTestBase):
options = {
"RandomizeGrubs": True,
"GrubHuntGoal": 20,
"Goal": "any",
}
item_link_group = [{
"name": "ItemLinkTest",
"item_pool": ["Grub"],
"link_replacement": True,
"replacement_item": "Grub",
}]
expected_grubs = 20
class test_grubcount_default(linkedTestHK, WorldTestBase):
options = {
"RandomizeGrubs": True,
"Goal": "any",
}
item_link_group = [{
"name": "ItemLinkTest",
"item_pool": ["Grub"],
"link_replacement": True,
"replacement_item": "Grub",
}]
expected_grubs = 46
class test_grubcount_all_unlinked(linkedTestHK, WorldTestBase):
options = {
"RandomizeGrubs": True,
"GrubHuntGoal": "all",
"Goal": "any",
}
item_link_group = []
expected_grubs = 46
class test_grubcount_all_linked(linkedTestHK, WorldTestBase):
options = {
"RandomizeGrubs": True,
"GrubHuntGoal": "all",
"Goal": "any",
}
item_link_group = [{
"name": "ItemLinkTest",
"item_pool": ["Grub"],
"link_replacement": True,
"replacement_item": "Grub",
}]
expected_grubs = 46 + 23
class test_replacement_only(linkedTestHK, WorldTestBase):
options = {
"RandomizeGrubs": True,
"GrubHuntGoal": "all",
"Goal": "any",
}
expected_grubs = 46 + 18 # the count of grubs + skills removed from item links
def setup_item_links(self, args):
setattr(args, "item_links",
{
1: ItemLinks.from_any([{
"name": "ItemLinkTest",
"item_pool": ["Skills"],
"link_replacement": True,
"replacement_item": "Grub",
}]),
2: ItemLinks.from_any([{
"name": "ItemLinkTest",
"item_pool": ["Skills"],
"link_replacement": True,
"replacement_item": "Grub",
}])
})
return args
class test_replacement_only_unlinked(linkedTestHK, WorldTestBase):
options = {
"RandomizeGrubs": True,
"GrubHuntGoal": "all",
"Goal": "any",
}
expected_grubs = 46 + 9 # Player1s replacement Grubs
def setup_item_links(self, args):
setattr(args, "item_links",
{
1: ItemLinks.from_any([{
"name": "ItemLinkTest",
"item_pool": ["Skills"],
"link_replacement": False,
"replacement_item": "Grub",
}]),
2: ItemLinks.from_any([{
"name": "ItemLinkTest",
"item_pool": ["Skills"],
"link_replacement": False,
"replacement_item": "Grub",
}])
})
return args
class test_ignore_others(linkedTestHK, WorldTestBase):
options = {
"RandomizeGrubs": True,
"GrubHuntGoal": "all",
"Goal": "any",
}
# player2 has more than 46 grubs but they are unlinked so player1s grubs are vanilla
expected_grubs = 46
def setup_item_links(self, args):
setattr(args, "item_links",
{
1: ItemLinks.from_any([{
"name": "ItemLinkTest",
"item_pool": ["Skills"],
"link_replacement": False,
"replacement_item": "One_Geo",
}]),
2: ItemLinks.from_any([{
"name": "ItemLinkTest",
"item_pool": ["Skills"],
"link_replacement": False,
"replacement_item": "Grub",
}])
})
return args
class test_replacement_only_linked(linkedTestHK, WorldTestBase):
options = {
"RandomizeGrubs": True,
"GrubHuntGoal": "all",
"Goal": "any",
}
expected_grubs = 46 + 9 # Player2s linkreplacement grubs
def setup_item_links(self, args):
setattr(args, "item_links",
{
1: ItemLinks.from_any([{
"name": "ItemLinkTest",
"item_pool": ["Skills"],
"link_replacement": True,
"replacement_item": "One_Geo",
}]),
2: ItemLinks.from_any([{
"name": "ItemLinkTest",
"item_pool": ["Skills"],
"link_replacement": True,
"replacement_item": "Grub",
}])
})
return args