main #2
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -399,4 +399,5 @@ FodyWeavers.xsd
|
||||||
# JetBrains Rider
|
# JetBrains Rider
|
||||||
*.sln.iml
|
*.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