sots-engine/tests/game_data/oracle/compare.py

428 lines
22 KiB
Python

#!/usr/bin/env python3
"""compare.py <ours.json> <oracle-dir>
Compares the dump_catalog output with the reference catalogs produced by the
RE repo's verify.py (tech_tree.json, weapons.json, shipsections.json,
strings.json, crosslink.json), field by field.
Canonicalisation (both sides):
* raw bodies: exact, type-aware (bool != int != float != str), after the
oracle's identity keys are removed. This proves the block snapshot.
* typed fields: the oracle value under the same key, with these rules --
- repeated scalar keys -> the LAST value (the loader's rule);
- repeatable list keys (requires, exclude, ...) -> always a list;
- numbers compare numerically (int 50 == float 50.0);
- bools accept the oracle's 0/1 ints;
- a typed field that is absent (null / "" / []) matches a missing key;
- a typed field that is null while the oracle holds a non-numeric
value is an "untyped" divergence: the data carries a token the
engine schema does not accept. These are listed and must match the
documented set exactly.
Exit 1 on any unexpected difference.
"""
import json
import os
import sys
# Values in the shipped data that do not have the shape the typed schema
# expects. The loader records a Problem and keeps the text in `raw`.
KNOWN_UNTYPED = {
("weapon", "Weapons/las_red.weapon", "trackspeed_mod", "1.0f"),
("section", "Species/Hiver/sections/CRFusion.shipsection", "mount.min_inclination", "0-5"),
("section", "Species/Morrigi/sections/DNAIC.shipsection", "mount.max_inclination", "90\\"),
}
# `crew false` on the nine NPC hull sections
for f in ("_CRRipperCommand", "_CRRipperEngine", "_CRRipperMission", "_DECommand", "_DEEngine", "_DEMission",
"_VNMFrontSection", "_VNMMidSection", "_VNMRearSection"):
KNOWN_UNTYPED.add(("section", f"Species/_NPC/sections/{f}.shipsection", "crew", False))
# `force_right o` (a letter o for zero) on 22 sections
for race, stems in {
"Hiver": ("CRDeflector", "CRDisruptor", "DEFireControl"),
"Human": ("CRAbsorber", "CRAssault", "CRBattleBridge", "CRDeepScan", "CRFireControl"),
"Liir": ("CRDeepScan", "CRFireControl", "DEDeepScan"),
"Morrigi": ("CRAbsorber", "CRAssault", "CRBattleBridge", "CRDeepScan", "CRFireControl"),
"Tarkas": ("CRCommand", "CRDeepScan"),
"Zuul": ("CRAssault", "CRBattleBridge", "CRDeepScan", "CRFireControl"),
}.items():
for st in stems:
KNOWN_UNTYPED.add(("section", f"Species/{race}/sections/{st}.shipsection", "netforcelimits.force_right", "o"))
MAX_DIFFS = 40
def L(v):
"""get_list: missing -> [], scalar -> [x], list -> list."""
if v is None:
return []
return v if isinstance(v, list) else [v]
def last(v):
return v[-1] if isinstance(v, list) else v
class Cmp:
def __init__(self):
self.diffs = []
self.untyped = set()
self.checked = 0
def diff(self, path, msg):
self.diffs.append(f"{path}: {msg}")
# --- exact structural compare (raw bodies) --------------------------
def exact(self, a, b, path):
self.checked += 1
if type(a) is not type(b):
self.diff(path, f"type {type(a).__name__} != {type(b).__name__} ({a!r} vs {b!r})")
return
if isinstance(a, dict):
for k in sorted(set(a) | set(b)):
if k not in a:
self.diff(f"{path}.{k}", "missing in ours")
elif k not in b:
self.diff(f"{path}.{k}", "extra in ours")
else:
self.exact(a[k], b[k], f"{path}.{k}")
elif isinstance(a, list):
if len(a) != len(b):
self.diff(path, f"length {len(a)} != {len(b)}")
for i, (x, y) in enumerate(zip(a, b)):
self.exact(x, y, f"{path}[{i}]")
elif a != b:
self.diff(path, f"{a!r} != {b!r}")
# --- typed field compare ---------------------------------------------
def scalar(self, kind, file, key, ours, oracle, path):
"""ours: typed value (None/str/number/bool). oracle: raw oracle value (scalar or list)."""
self.checked += 1
oracle = last(oracle)
if ours is None or ours == "" or ours == []:
if oracle is None:
return
if isinstance(oracle, (str, bool)) and ours is None:
self.untyped.add((kind, file, key, oracle))
return
if ours == "" and oracle == "":
return
self.diff(path, f"ours absent, oracle {oracle!r}")
return
if oracle is None:
self.diff(path, f"ours {ours!r}, oracle missing")
return
if isinstance(ours, bool):
if isinstance(oracle, bool):
ok = ours == oracle
elif isinstance(oracle, int):
ok = ours == (oracle != 0)
else:
ok = False
elif isinstance(ours, (int, float)):
ok = isinstance(oracle, (int, float)) and not isinstance(oracle, bool) and float(ours) == float(oracle)
elif isinstance(ours, str):
if isinstance(oracle, str):
ok = ours == oracle
elif isinstance(oracle, bool):
ok = ours.lower() == str(oracle).lower()
else:
try:
ok = float(ours) == float(oracle)
except ValueError:
ok = False
else:
ok = ours == oracle
if not ok:
self.diff(path, f"{ours!r} != {oracle!r}")
def strlist(self, ours, oracle, path):
self.checked += 1
exp = [str(x) for x in L(oracle)]
if list(ours) != exp:
self.diff(path, f"{ours!r} != {exp!r}")
WEAPON_TYPED_SCALARS = [
"name", "weaponclass", "weaponfamily", "weapondamagetype", "exclusive_species", "cost", "turretsize",
"turretclass", "trackspeed_mod", "burst_volleys", "recharge_time", "volley_period", "volley_duration",
"buildup_delay", "solution_tolerance", "range", "range_planet", "muzzle_speed", "hpbonus", "dam_est",
"hidden", "pinpoint", "blindfire", "secondary_pd", "model1", "model2", "model3", "muzzle_effect",
"muzzle_sound", "icon_file", "icon_rect", "fc_requires_los", "fc_requires_inrange",
"fc_requires_enemycolony", "fc_manual_target", "fc_manual_toggle", "fc_manual_launch", "fc_controllable",
"fc_holdsfire", "fc_explicit_target", "fc_exclusive_launch", "fc_targets_expire", "rating_frate",
"rating_dam", "rating_acc", "rating_range",
]
BEHAVIOR_BLOCKS = ["bolt", "beam", "torpedo", "rider", "missile", "chainlightning", "col", "mine",
"disintegrator", "grapple", "projectedshield", "mirv", "nodecannon", "siege",
"mesonprojector", "spyship", "wraith"]
RANGE_KEYS = ["pb_range", "pb_range_dev", "pb_range_dam", "eff_range", "eff_range_dev", "eff_range_dam",
"max_range", "max_range_dev", "max_range_dam"]
BOLT_SCALARS = ["dam_pop", "dam_infra", "dam_terra", "mass", "beam_origin", "beam_length", "ricochet_mod",
"effect", "impact_effect", "expire_effect"]
SECTION_TYPED_SCALARS = [
"model", "dam_model", "section_type", "section_class", "design_class", "entity_class", "health", "mass",
"cost", "cpoints", "crew", "command_cost", "maintenance_cost", "command_quota", "socket_fore",
"socket_aft", "dam_socket_fore", "dam_socket_aft", "ftlspeed", "nodespeed", "range", "scanrange",
"tacticalsensorrange", "engine_techera", "explicit_command_section", "explicit_engine_section",
"explicit_section", "autonomous", "nodesign",
]
MOUNT_KEYS = ["min_azimuth", "max_azimuth", "min_inclination", "max_inclination", "home_azimuth", "home_inclination"]
NFL_KEYS = ["force_forward", "force_right", "force_up", "torque_yaw", "torque_pitch", "torque_roll", "speed", "rotspeed"]
WEAPON_ID_KEYS = {"stem", "file", "scope", "id", "display_name"}
SECTION_ID_KEYS = {"race", "stem", "file", "id", "display_name", "description", "unlocked_by"}
def compare_weapons(c, ours, oracle):
ob = {w["file"].lower(): w for w in oracle["weapons"]}
if len(ours) != len(oracle["weapons"]):
c.diff("weapons", f"count {len(ours)} != {len(oracle['weapons'])}")
for w in ours:
p = f"weapon[{w['file']}]"
o = ob.get(w["file"].lower())
if o is None:
c.diff(p, "not in oracle")
continue
for k in ("stem", "scope", "id", "display_name"):
if w[k] != o[k]:
c.diff(f"{p}.{k}", f"{w[k]!r} != {o[k]!r}")
body = {k: v for k, v in o.items() if k not in WEAPON_ID_KEYS}
c.exact(w["raw"], body, f"{p}.raw")
t = w["typed"]
for k in WEAPON_TYPED_SCALARS:
c.scalar("weapon", w["file"], k, t[k], body.get(k), f"{p}.{k}")
c.strlist(t["requires"], body.get("requires"), f"{p}.requires")
c.strlist(t["compatible_section"], body.get("compatible_section"), f"{p}.compatible_section")
kind = next((b for b in BEHAVIOR_BLOCKS if b in body), "")
if t["behavior_kind"] != kind:
c.diff(f"{p}.behavior_kind", f"{t['behavior_kind']!r} != {kind!r}")
beh = body.get(kind, {}) if kind else {}
for k in ("dam_pop", "dam_infra", "dam_terra"):
c.scalar("weapon", w["file"], f"{kind}.{k}", t["planet_damage"][k], beh.get(k), f"{p}.planet_damage.{k}")
rt = beh.get("rangetable")
if (t["rangetable"] is None) != (rt is None):
c.diff(f"{p}.rangetable", f"presence {t['rangetable'] is not None} != {rt is not None}")
elif rt is not None:
for k in RANGE_KEYS:
c.scalar("weapon", w["file"], f"rangetable.{k}", t["rangetable"][k], rt.get(k), f"{p}.rangetable.{k}")
if (t["bolt"] is None) != (kind != "bolt"):
c.diff(f"{p}.bolt", f"presence mismatch (kind {kind})")
elif t["bolt"] is not None:
for k in BOLT_SCALARS:
c.scalar("weapon", w["file"], f"bolt.{k}", t["bolt"][k], beh.get(k), f"{p}.bolt.{k}")
for k in RANGE_KEYS:
c.scalar("weapon", w["file"], f"bolt.rangetable.{k}", t["bolt"]["rangetable"][k], (rt or {}).get(k),
f"{p}.bolt.rangetable.{k}")
def norm_options(v):
out = []
for e in L(v):
if isinstance(e, dict):
out.append({"members": [str(x) for x in L(e.get("option"))], "scalar": False})
else:
out.append({"members": [str(e)], "scalar": True})
return out
def compare_sections(c, ours, oracle):
ob = {s["file"].lower(): s for s in oracle["sections"]}
if len(ours) != len(oracle["sections"]):
c.diff("sections", f"count {len(ours)} != {len(oracle['sections'])}")
for s in ours:
p = f"section[{s['file']}]"
o = ob.get(s["file"].lower())
if o is None:
c.diff(p, "not in oracle")
continue
for k in ("race", "stem", "id", "display_name", "description", "unlocked_by"):
if s[k] != o[k]:
c.diff(f"{p}.{k}", f"{s[k]!r} != {o[k]!r}")
body = {k: v for k, v in o.items() if k not in SECTION_ID_KEYS}
c.exact(s["raw"], body, f"{p}.raw")
t = s["typed"]
for k in SECTION_TYPED_SCALARS:
c.scalar("section", s["file"], k, t[k], body.get(k), f"{p}.{k}")
c.strlist(t["requires"], body.get("requires"), f"{p}.requires")
c.strlist(t["exclude"], body.get("exclude"), f"{p}.exclude")
exp_opts = norm_options(body.get("option"))
if t["option"] != exp_opts:
c.diff(f"{p}.option", f"{t['option']!r} != {exp_opts!r}")
od = body.get("optiondef")
exp_od = [str(x) for x in L(od.get("option"))] if isinstance(od, dict) else None
if t["optiondef"] != exp_od:
c.diff(f"{p}.optiondef", f"{t['optiondef']!r} != {exp_od!r}")
banks = L(body.get("bank"))
if len(t["bank"]) != len(banks):
c.diff(f"{p}.bank", f"count {len(t['bank'])} != {len(banks)}")
for i, (tb, ob_) in enumerate(zip(t["bank"], banks)):
bp = f"{p}.bank[{i}]"
c.scalar("section", s["file"], "bank.turretclass", tb["turretclass"], ob_.get("turretclass"), f"{bp}.turretclass")
c.scalar("section", s["file"], "bank.turretsize", tb["turretsize"], ob_.get("turretsize"), f"{bp}.turretsize")
c.scalar("section", s["file"], "bank.weapon", tb["weapon"], ob_.get("weapon"), f"{bp}.weapon")
c.scalar("section", s["file"], "bank.showturrets", tb["showturrets"], ob_.get("showturrets"), f"{bp}.showturrets")
c.scalar("section", s["file"], "bank.invincible", tb["invincible"], ob_.get("invincible"), f"{bp}.invincible")
rep = len(L(ob_.get("turretsize"))) > 1 or len(L(ob_.get("turretclass"))) > 1
if tb["repeated_turret_spec"] != rep:
c.diff(f"{bp}.repeated_turret_spec", f"{tb['repeated_turret_spec']} != {rep}")
mounts = L(ob_.get("mount"))
if len(tb["mount"]) != len(mounts):
c.diff(f"{bp}.mount", f"count {len(tb['mount'])} != {len(mounts)}")
for j, (tm, om) in enumerate(zip(tb["mount"], mounts)):
mp = f"{bp}.mount[{j}]"
c.scalar("section", s["file"], "mount.node", tm["node"], om.get("node"), f"{mp}.node")
for k in MOUNT_KEYS:
c.scalar("section", s["file"], f"mount.{k}", tm[k], om.get(k), f"{mp}.{k}")
nfl = body.get("netforcelimits")
if (t["netforcelimits"] is None) != (nfl is None):
c.diff(f"{p}.netforcelimits", "presence mismatch")
elif nfl is not None:
for k in NFL_KEYS:
c.scalar("section", s["file"], f"netforcelimits.{k}", t["netforcelimits"][k], last(nfl).get(k),
f"{p}.netforcelimits.{k}")
th = L(body.get("thruster"))
if len(t["thruster"]) != len(th):
c.diff(f"{p}.thruster", f"count {len(t['thruster'])} != {len(th)}")
for i, (tt, ot) in enumerate(zip(t["thruster"], th)):
for k in ("node", "effect", "idle_effect"):
c.scalar("section", s["file"], f"thruster.{k}", tt[k], ot.get(k), f"{p}.thruster[{i}].{k}")
def compare_tech(c, ours, oracle):
on = {n["name"]: n for n in oracle["nodes"]}
if len(ours["nodes"]) != len(oracle["nodes"]):
c.diff("tech.nodes", f"count {len(ours['nodes'])} != {len(oracle['nodes'])}")
for n in ours["nodes"]:
p = f"tech[{n['name']}]"
o = on.get(n["name"])
if o is None:
c.diff(p, "not in oracle")
continue
for k in ("display_name", "description", "family", "family_inferred", "type", "threat", "group",
"option_cost", "requires", "benefits_inc", "benefits_dec", "sections", "weapons", "allows"):
c.checked += 1
a, b = n[k], o[k]
if isinstance(a, (int, float)) and isinstance(b, (int, float)) and not isinstance(a, bool):
ok = float(a) == float(b)
else:
ok = a == b
if not ok:
c.diff(f"{p}.{k}", f"{a!r} != {b!r}")
if len(ours["edges"]) != len(oracle["edges"]):
c.diff("tech.edges", f"count {len(ours['edges'])} != {len(oracle['edges'])}")
for i, (a, b) in enumerate(zip(ours["edges"], oracle["edges"])):
c.checked += 1
mine = {"from": a["from"], "to": a["to"], "rp": a["rp"], "pct": a["pct"]}
if mine != b:
c.diff(f"tech.edges[{i}]", f"{mine!r} != {b!r}")
if a["unparsed"]:
c.diff(f"tech.edges[{i}]", f"unparsed tokens {a['unparsed']}")
for race, v in a["pct_effective"].items():
exp = b["pct"].get(race, 100)
if v != exp:
c.diff(f"tech.edges[{i}].pct_effective.{race}", f"{v} != {exp}")
c.checked += 1
if ours["groups"] != oracle["groups"]:
c.diff("tech.groups", f"{ours['groups']!r} != {oracle['groups']!r}")
def compare_crosslink(c, ours, dump, oracle):
x = ours
def pairs(v):
return sorted((a, b) for a, b in v)
def eq(path, a, b):
c.checked += 1
if a != b:
c.diff(path, f"{a!r} != {b!r}")
eq("crosslink.weapon_requires_dangling", pairs(x["weapon_requires_dangling"]), pairs(oracle["weapon_requires_dangling"]))
eq("crosslink.weapon_requires_case_mismatch", pairs(x["weapon_requires_case_mismatch"]),
pairs(oracle["weapon_requires_case_mismatch"]))
eq("crosslink.weapon_without_requires", sorted(x["weapons_without_requires"]), sorted(oracle["weapon_without_requires"]))
eq("crosslink.shipsection_requires_dangling", pairs(x["section_requires_dangling"]),
pairs(oracle["shipsection_requires_dangling"]))
eq("crosslink.shipsection_requires_case_mismatch", pairs(x["section_requires_case_mismatch"]),
pairs(oracle["shipsection_requires_case_mismatch"]))
eq("crosslink.shipsection_option_dangling", pairs(x["section_option_dangling"]), pairs(oracle["shipsection_option_dangling"]))
scalar = sorted((s["file"], m) for s in dump["sections"] for g in s["typed"]["option"] if g["scalar"] for m in g["members"])
eq("crosslink.shipsection_scalar_option", scalar, pairs(oracle["shipsection_scalar_option"]))
eq("crosslink.tech_ship_section_dangling", pairs(x["tech_ship_section_dangling"]), pairs(oracle["tech_ship_section_dangling"]))
eq("crosslink.tech_weapon_filename_dangling", pairs(x["tech_weapon_file_dangling"]), pairs(oracle["tech_weapon_filename_dangling"]))
eq("crosslink.tech_requires_dangling", pairs(x["tech_requires_dangling"]), pairs(oracle["tech_requires_dangling"]))
eq("crosslink.tech_allows_dangling", pairs(x["tech_allows_dangling"]), pairs(oracle["tech_allows_dangling"]))
eq("crosslink.tech_allows_unparsed", pairs(x["tech_allows_unparsed"]), pairs(oracle["tech_allows_unparsed"]))
eq("crosslink.bank_weapon_dangling", pairs(x["bank_weapon_dangling"]), pairs(oracle["bank_weapon_dangling"]))
for scope, om in oracle["manifests"].items():
m = dump["manifests"].get(scope)
if m is None:
c.diff(f"crosslink.manifests.{scope}", "missing in ours")
continue
eq(f"crosslink.manifests.{scope}.ids", len(m["ids"]), om["ids"])
eq(f"crosslink.manifests.{scope}.deleted", sorted(m["deleted"]), sorted(om["deleted"]))
gaps = sorted(g["name"].lower() for g in x["manifest_ids_without_file"] if g["scope"] == scope)
eq(f"crosslink.manifests.{scope}.listed_but_no_file", gaps, sorted(om["listed_but_no_file"]))
unl = sorted(r for s_, r in x["files_without_manifest_id"] if s_ == scope)
eq(f"crosslink.manifests.{scope}.file_but_unlisted", unl, sorted(om["file_but_unlisted"]))
os_ = oracle["strings"]
eq("crosslink.strings.missing_techname", sorted(x["missing_techname"]), sorted(os_["missing_techname"]))
eq("crosslink.strings.missing_techdesc", sorted(x["missing_techdesc"]), sorted(os_["missing_techdesc"]))
eq("crosslink.strings.missing_sectionname", sorted(x["missing_sectionname"]), sorted(os_["missing_sectionname"]))
eq("crosslink.strings.missing_sectiondesc", sorted(x["missing_sectiondesc"]), sorted(os_["missing_sectiondesc"]))
eq("crosslink.strings.unresolved_weapon_name", pairs(x["unresolved_weapon_names"]), pairs(os_["unresolved_weapon_name"]))
ot = oracle["turrets"]
eq("crosslink.turrets.turret_rows", len(dump["turrets"]), ot["turret_rows"])
eq("crosslink.turrets.weapon_pairs_without_turret", pairs(x["weapon_turret_pairs_without_row"]),
pairs(ot["weapon_size_class_pairs_without_turret"]))
eq("crosslink.turrets.bank_pairs_without_turret", pairs(x["bank_turret_pairs_without_row"]),
pairs(ot["bank_size_class_pairs_without_turret"]))
banks = [b for s in dump["sections"] for b in s["typed"]["bank"]]
eq("crosslink.turrets.banks_without_turretsize", sum(1 for b in banks if b["turretsize"] == ""), ot["banks_without_turretsize"])
eq("crosslink.turrets.banks_with_repeated_size_or_class", sum(1 for b in banks if b["repeated_turret_spec"]),
ot["banks_with_repeated_size_or_class"])
def compare_strings(c, dump, oracle):
c.checked += 1
if dump["string_count"] != len(oracle):
c.diff("strings.count", f"{dump['string_count']} != {len(oracle)}")
# display names already compared per weapon / section / tech
def main(argv):
ours = json.load(open(argv[1], encoding="ascii"))
odir = argv[2]
oracle = {n: json.load(open(os.path.join(odir, n + ".json"), encoding="utf-8"))
for n in ("tech_tree", "weapons", "shipsections", "strings", "crosslink")}
c = Cmp()
compare_weapons(c, ours["weapons"], oracle["weapons"])
compare_sections(c, ours["sections"], oracle["shipsections"])
compare_tech(c, ours["tech"], oracle["tech_tree"])
compare_strings(c, ours, oracle["strings"])
compare_crosslink(c, ours["cross_check"], ours, oracle["crosslink"])
print(f"compared {c.checked} values: {len(ours['weapons'])} weapons, {len(ours['sections'])} sections, "
f"{len(ours['tech']['nodes'])} techs, {len(ours['tech']['edges'])} edges, crosslink")
rc = 0
if c.untyped != KNOWN_UNTYPED:
print("untyped values differ from the documented set:")
for u in sorted(c.untyped - KNOWN_UNTYPED, key=str):
print(" unexpected:", u)
for u in sorted(KNOWN_UNTYPED - c.untyped, key=str):
print(" missing: ", u)
rc = 1
else:
print(f"documented untyped values: {len(c.untyped)} (as expected)")
if c.diffs:
print(f"DIFF: {len(c.diffs)} differences")
for d in c.diffs[:MAX_DIFFS]:
print(" ", d)
if len(c.diffs) > MAX_DIFFS:
print(f" ... {len(c.diffs) - MAX_DIFFS} more")
rc = 1
else:
print("OK: 100% agreement with the oracle catalogs")
return rc
if __name__ == "__main__":
sys.exit(main(sys.argv))