Archipelago/worlds/hk/Extractor.py

465 lines
19 KiB
Python

"""
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)