391 lines
16 KiB
Python
391 lines
16 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
|
|
|
|
try:
|
|
from ast import unparse
|
|
except ImportError:
|
|
# Py 3.8 and earlier compatibility module
|
|
from astunparse 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) 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", "RANDOMELEVATORS"}
|
|
|
|
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='_kh_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='_kh_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]
|
|
del (logic_options["RANDOMELEVATORS"])
|
|
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"]
|
|
if any(cost_entry["term"] == "CHARMS" for cost_entry in pairing.get("costs", [])):
|
|
location_name += "_(Requires_Charms)"
|
|
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
|
|
|
|
# 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 = {}
|
|
|
|
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"})
|
|
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("Rules.py", "wt") as py:
|
|
py.write(warning)
|
|
py.write(rules)
|