Merge pull request 'main' (#2) from zhetadelta/HollowKnightAltEntrancesAPMod:main into main
Reviewed-on: juno/HollowKnightAltEntrancesAPMod#2
This commit is contained in:
commit
5dc155913f
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -399,4 +399,5 @@ FodyWeavers.xsd
|
|||
# JetBrains Rider
|
||||
*.sln.iml
|
||||
|
||||
LocalOverrides.targets
|
||||
LocalOverrides.targets
|
||||
World/Resources/
|
||||
47
World/Charms.py
Normal file
47
World/Charms.py
Normal 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
18
World/ExtractedData.py
Normal file
File diff suppressed because one or more lines are too long
464
World/Extractor.py
Normal file
464
World/Extractor.py
Normal 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
1699
World/GeneratedRules.py
Normal file
File diff suppressed because it is too large
Load diff
55
World/GodhomeData.py
Normal file
55
World/GodhomeData.py
Normal 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
68
World/Items.py
Normal 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
596
World/Options.py
Normal 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
25
World/Regions.py
Normal 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
90
World/Rules.py
Normal 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
804
World/__init__.py
Normal 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, you’ll 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
|
||||
24
World/docs/en_Hollow Knight.md
Normal file
24
World/docs/en_Hollow Knight.md
Normal 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.
|
||||
25
World/docs/es_Hollow Knight.md
Normal file
25
World/docs/es_Hollow Knight.md
Normal 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
58
World/docs/setup_en.md
Normal 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
64
World/docs/setup_es.md
Normal 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
52
World/docs/setup_pt_br.md
Normal 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).
|
||||
27
World/templates/RulesTemplate.pyt
Normal file
27
World/templates/RulesTemplate.pyt
Normal 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
61
World/test/__init__.py
Normal 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}"
|
||||
166
World/test/test_grub_count.py
Normal file
166
World/test/test_grub_count.py
Normal 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
|
||||
Loading…
Reference in a new issue