worldbox-observer-mod/scripts/export-event-catalog-from-cs.py
2026-07-16 14:21:48 -05:00

843 lines
27 KiB
Python
Executable file

#!/usr/bin/env python3
"""Export IdleSpectator/event-catalog.json from EventCatalog C# sources.
Parses authored catalog partials under IdleSpectator/Events/Catalogs/ and merges
with existing decisions/species/character overlays from event-catalog.json
(JSON decisions strengths/actions win).
Usage (from repo root):
python3 scripts/export-event-catalog-from-cs.py
python3 scripts/export-event-catalog-from-cs.py --out IdleSpectator/event-catalog.json
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from pathlib import Path
from typing import Any
REPO = Path(__file__).resolve().parents[1]
CATALOGS = REPO / "IdleSpectator" / "Events" / "Catalogs"
DEFAULT_OUT = REPO / "IdleSpectator" / "event-catalog.json"
DEFAULT_EXISTING = DEFAULT_OUT
MOD_VERSION = "0.25.0"
UPDATED = "2026-07-16"
NOTE = (
"Authored IdleSpectator event catalog exported from EventCatalog C# sources "
"(happiness, status, worldLog, plots, relationship, traits, books, eras, "
"disasters, warTypes, libraries defaults). decisions/species/character merge "
"from prior JSON (JSON wins). Loaded by EventCatalogConfig; scoring-model.json "
"remains the global formula."
)
# ---------------------------------------------------------------------------
# C# text helpers
# ---------------------------------------------------------------------------
def strip_csharp_comments(text: str) -> str:
"""Remove // and /* */ comments outside of string literals."""
out: list[str] = []
i = 0
n = len(text)
while i < n:
c = text[i]
if c == '"':
j = i + 1
while j < n:
if text[j] == "\\":
j += 2
continue
if text[j] == '"':
j += 1
break
j += 1
out.append(text[i:j])
i = j
continue
if c == "/" and i + 1 < n:
nxt = text[i + 1]
if nxt == "/":
while i < n and text[i] not in "\r\n":
i += 1
continue
if nxt == "*":
i += 2
while i + 1 < n and not (text[i] == "*" and text[i + 1] == "/"):
i += 1
i = min(n, i + 2)
continue
out.append(c)
i += 1
return "".join(out)
def enum_leaf(expr: str) -> str:
"""HappinessPresentationTier.Signal -> Signal."""
s = expr.strip().rstrip(",")
if "." in s:
return s.rsplit(".", 1)[-1]
return s
def parse_csharp_string(token: str) -> str:
token = token.strip()
if len(token) >= 2 and token[0] == '"' and token[-1] == '"':
inner = token[1:-1]
return (
inner.replace(r"\\", "\0")
.replace(r"\"", '"')
.replace(r"\n", "\n")
.replace(r"\t", "\t")
.replace("\0", "\\")
)
return token
def parse_number(token: str) -> float | int:
t = token.strip().rstrip("fFdDmM")
if re.fullmatch(r"-?\d+", t):
return int(t)
return float(t)
def parse_bool(token: str) -> bool:
t = token.strip().lower()
if t == "true":
return True
if t == "false":
return False
raise ValueError(f"not a bool: {token!r}")
def split_top_level_args(arg_text: str) -> list[str]:
"""Split comma-separated C# call args, respecting strings/parens/braces."""
args: list[str] = []
buf: list[str] = []
depth_paren = depth_brace = depth_bracket = 0
in_string = False
escape = False
i = 0
while i < len(arg_text):
c = arg_text[i]
if in_string:
buf.append(c)
if escape:
escape = False
elif c == "\\":
escape = True
elif c == '"':
in_string = False
i += 1
continue
if c == '"':
in_string = True
buf.append(c)
i += 1
continue
if c == "(":
depth_paren += 1
buf.append(c)
elif c == ")":
depth_paren -= 1
buf.append(c)
elif c == "{":
depth_brace += 1
buf.append(c)
elif c == "}":
depth_brace -= 1
buf.append(c)
elif c == "[":
depth_bracket += 1
buf.append(c)
elif c == "]":
depth_bracket -= 1
buf.append(c)
elif (
c == ","
and depth_paren == 0
and depth_brace == 0
and depth_bracket == 0
):
args.append("".join(buf).strip())
buf = []
else:
buf.append(c)
i += 1
tail = "".join(buf).strip()
if tail:
args.append(tail)
return args
def parse_call_args(arg_text: str) -> tuple[list[Any], dict[str, Any]]:
"""Parse positional + named (name: value) C# call arguments."""
positional: list[Any] = []
named: dict[str, Any] = {}
for raw in split_top_level_args(arg_text):
if not raw:
continue
m = re.match(r"^([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.*)$", raw, re.S)
if m and not raw.lstrip().startswith('"'):
name, val = m.group(1), m.group(2).strip()
named[name] = coerce_literal(val)
else:
positional.append(coerce_literal(raw))
return positional, named
def is_seed_call(arg_text: str) -> bool:
"""True when the first arg is a string literal (seed), not a method signature."""
parts = split_top_level_args(arg_text)
return bool(parts) and parts[0].lstrip().startswith('"')
def coerce_literal(token: str) -> Any:
t = token.strip()
if not t:
return t
if t.startswith('"'):
return parse_csharp_string(t)
low = t.lower()
if low in ("true", "false"):
return low == "true"
if re.fullmatch(r"-?\d+(\.\d+)?[fFdDmM]?", t):
return parse_number(t)
if t.startswith("new[]") or t.startswith("new string[]"):
return parse_string_array(t)
return t
def parse_string_array(expr: str) -> list[str]:
m = re.search(r"\{(.*)\}", expr, re.S)
if not m:
return []
inner = m.group(1).strip()
if not inner:
return []
return [parse_csharp_string(p) for p in split_top_level_args(inner) if p.strip()]
def find_method_calls(text: str, method: str) -> list[tuple[int, str]]:
"""Return (index, arg_text) for method(...); calls, balanced parens."""
results: list[tuple[int, str]] = []
pattern = re.compile(rf"\b{re.escape(method)}\s*\(")
for m in pattern.finditer(text):
start = m.end()
depth = 1
i = start
in_string = False
escape = False
while i < len(text) and depth > 0:
c = text[i]
if in_string:
if escape:
escape = False
elif c == "\\":
escape = True
elif c == '"':
in_string = False
else:
if c == '"':
in_string = True
elif c == "(":
depth += 1
elif c == ")":
depth -= 1
if depth == 0:
results.append((m.start(), text[start:i]))
break
i += 1
return results
def read_catalog(name: str) -> str:
path = CATALOGS / name
if not path.is_file():
raise FileNotFoundError(path)
return strip_csharp_comments(path.read_text(encoding="utf-8"))
# ---------------------------------------------------------------------------
# Section parsers
# ---------------------------------------------------------------------------
_FAILURES: list[str] = []
def fail(msg: str) -> None:
_FAILURES.append(msg)
print(f"PARSE FAIL: {msg}", file=sys.stderr)
def parse_happiness(text: str) -> list[dict[str, Any]]:
entries: list[dict[str, Any]] = []
# Match ["id"] = new HappinessCatalogEntry { ... },
pattern = re.compile(
r'\["([^"]+)"\]\s*=\s*new\s+HappinessCatalogEntry\s*\{',
re.M,
)
for m in pattern.finditer(text):
entry_id = m.group(1)
body_start = m.end()
depth = 1
i = body_start
in_string = False
escape = False
while i < len(text) and depth > 0:
c = text[i]
if in_string:
if escape:
escape = False
elif c == "\\":
escape = True
elif c == '"':
in_string = False
else:
if c == '"':
in_string = True
elif c == "{":
depth += 1
elif c == "}":
depth -= 1
if depth == 0:
break
i += 1
if depth != 0:
fail(f"happiness: unclosed block for {entry_id}")
continue
body = text[body_start:i]
try:
entries.append(parse_happiness_body(entry_id, body))
except Exception as ex: # noqa: BLE001
fail(f"happiness[{entry_id}]: {ex}")
return entries
def parse_happiness_body(entry_id: str, body: str) -> dict[str, Any]:
props: dict[str, str] = {}
# Property = value; (value may be new[] { ... })
prop_re = re.compile(
r"(\w+)\s*=\s*"
r"("
r"new\s*(?:string\s*)?\[\s*\]\s*\{(?:[^{}]|\{[^{}]*\})*\}"
r'|"[^"\\]*(?:\\.[^"\\]*)*"'
r"|[^,\n]+"
r")\s*,?",
re.M,
)
for m in prop_re.finditer(body):
props[m.group(1)] = m.group(2).strip().rstrip(",")
def req(name: str) -> str:
if name not in props:
raise ValueError(f"missing {name}")
return props[name]
strength = parse_number(req("EventStrength"))
row: dict[str, Any] = {
"id": parse_csharp_string(props["Id"]) if "Id" in props else entry_id,
"strength": strength,
"presentation": enum_leaf(req("Presentation")),
"context": enum_leaf(req("Context")),
"life": enum_leaf(req("Life")),
"overlap": enum_leaf(req("Overlap")),
"relationRole": enum_leaf(req("RelationRole")),
"category": parse_csharp_string(req("InterestCategory"))
if req("InterestCategory").startswith('"')
else req("InterestCategory").strip('"'),
"variantsWithRelated": parse_string_array(req("VariantsWithRelated"))
if "VariantsWithRelated" in props
else [],
"variantsWithoutRelated": parse_string_array(req("VariantsWithoutRelated"))
if "VariantsWithoutRelated" in props
else [],
}
if "CanonicalMilestoneKey" in props:
key = props["CanonicalMilestoneKey"]
val = parse_csharp_string(key) if key.startswith('"') else key
if val:
row["canonicalMilestoneKey"] = val
if "StatusOverlapId" in props:
key = props["StatusOverlapId"]
val = parse_csharp_string(key) if key.startswith('"') else key
if val:
row["statusOverlapId"] = val
return row
def parse_status(text: str) -> list[dict[str, Any]]:
entries: list[dict[str, Any]] = []
for _, args in find_method_calls(text, "AddNoInterest"):
if not is_seed_call(args):
continue
pos, _ = parse_call_args(args)
if not pos:
fail(f"status AddNoInterest: empty args ({args!r})")
continue
entries.append(
{
"id": pos[0],
"strength": 20,
"category": "StatusAmbient",
"camera": False,
"extendsGrief": False,
}
)
for _, args in find_method_calls(text, "Add"):
if not is_seed_call(args):
continue
pos, named = parse_call_args(args)
if len(pos) < 4:
continue
try:
entry = {
"id": pos[0],
"strength": _num(pos[1]),
"category": pos[2],
"camera": bool(pos[3]),
"extendsGrief": bool(named.get("extendsGrief", False)),
}
entries.append(entry)
except Exception as ex: # noqa: BLE001
fail(f"status Add({args[:60]}…): {ex}")
return entries
def parse_world_log(text: str) -> list[dict[str, Any]]:
entries: list[dict[str, Any]] = []
for _, args in find_method_calls(text, "Add"):
if not is_seed_call(args):
continue
pos, _ = parse_call_args(args)
# Seed calls: id, strength, category, createsInterest, label, minWatch, maxWatch
if len(pos) < 7:
fail(f"worldLog Add: expected 7 args, got {len(pos)} ({args[:60]}…)")
continue
try:
strength = float(pos[1])
camera = bool(pos[3])
chronicle = camera and strength >= 65.0
row: dict[str, Any] = {
"id": pos[0],
"strength": _num(pos[1]),
"category": pos[2],
"camera": camera,
"label": pos[4],
"minWatch": _num(pos[5]),
"maxWatch": _num(pos[6]),
"chronicle": chronicle,
}
entries.append(row)
except Exception as ex: # noqa: BLE001
fail(f"worldLog Add({args[:60]}…): {ex}")
return entries
def parse_simple_add(
text: str,
*,
default_category: str | None = None,
default_label: str | None = None,
default_camera: bool = True,
min_pos: int = 2,
style: str = "id_strength_category_label",
) -> list[dict[str, Any]]:
"""Parse Add(...) seed calls for DiscreteEventEntry-style catalogs."""
entries: list[dict[str, Any]] = []
for _, args in find_method_calls(text, "Add"):
if not is_seed_call(args):
continue
pos, named = parse_call_args(args)
if len(pos) < min_pos:
continue
try:
if style == "id_strength_only":
# Trait: Add(id, strength) - apply defaults
if len(pos) != 2 or not isinstance(pos[0], str):
continue
row = {
"id": pos[0],
"strength": _num(pos[1]),
"category": default_category or "Trait",
"camera": default_camera,
"label": default_label or "Trait: {id} ({a})",
}
elif style == "id_strength_category_label":
# Plot/Book/Era/Relationship: Add(id, strength, category, label [, createsInterest])
if len(pos) < 4:
continue
camera = bool(named.get("createsInterest", pos[4] if len(pos) > 4 else default_camera))
row = {
"id": pos[0],
"strength": _num(pos[1]),
"category": pos[2],
"camera": camera,
"label": pos[3],
}
elif style == "disaster":
# Add(id, strength, worldLogId)
if len(pos) < 3:
continue
row = {
"id": pos[0],
"strength": _num(pos[1]),
"category": "Disaster",
"camera": True,
"worldLogId": pos[2],
}
elif style == "war_type":
# Add(id, strength, labelTemplate)
if len(pos) < 3:
continue
row = {
"id": pos[0],
"strength": _num(pos[1]),
"category": "Politics",
"camera": True,
"label": pos[2],
}
else:
fail(f"unknown add style {style}")
continue
entries.append(row)
except Exception as ex: # noqa: BLE001
fail(f"{style} Add({args[:60]}…): {ex}")
return entries
def _num(v: Any) -> int | float:
if isinstance(v, float) and v.is_integer():
return int(v)
return v
def parse_libraries(text: str) -> dict[str, Any]:
"""Emit defaults per library class; include signalIds only when HashSet listed."""
# Class blocks: public static class Spell { ... }
class_re = re.compile(
r"public\s+static\s+class\s+(\w+)\s*\{",
re.M,
)
classes = list(class_re.finditer(text))
wanted_order = [
("Spell", "spell"),
("Power", "power"),
("Item", "item"),
("Gene", "gene"),
("Phenotype", "phenotype"),
("WorldLaw", "worldLaw"),
("SubspeciesTrait", "subspeciesTrait"),
("Biome", "biome"),
]
by_cs: dict[str, dict[str, Any]] = {}
for idx, m in enumerate(classes):
name = m.group(1)
if name not in {cs for cs, _ in wanted_order}:
continue
start = m.end()
end = classes[idx + 1].start() if idx + 1 < len(classes) else len(text)
block = text[start:end]
defaults = extract_ensure_seeded_defaults(name, block)
if defaults is None:
fail(f"libraries[{name}]: could not parse EnsureSeeded defaults")
continue
by_cs[name] = defaults
libs: dict[str, Any] = {}
for cs_name, json_key in wanted_order:
if cs_name in by_cs:
libs[json_key] = by_cs[cs_name]
return libs
def extract_ensure_seeded_defaults(name: str, block: str) -> dict[str, Any] | None:
# Find EnsureSeeded( ... ) call args
calls = find_method_calls(block, "EnsureSeeded")
if not calls:
# LiveLibraryInterest.EnsureSeeded via expression body
calls = find_method_calls(block, "LiveLibraryInterest.EnsureSeeded")
if not calls:
return None
_, arg_text = calls[0]
pos, named = parse_call_args(arg_text)
# Signature (positional):
# Entries, enumerate, category, label, ambientStrength, signalIds, signalStrength,
# [signalPredicate], ambientCreatesInterest
# Named kwargs also used: ambientStrength, signalIds, signalStrength,
# signalPredicate, ambientCreatesInterest
category = None
label = None
ambient_strength = named.get("ambientStrength")
signal_strength = named.get("signalStrength")
ambient_creates = named.get("ambientCreatesInterest")
signal_ids_expr = named.get("signalIds")
# Positional layout from LiveLibraryInterest.EnsureSeeded - read that file if needed.
# From usage:
# Entries, Enumerate..., "Category", "label", ambientStrength: N, SignalIds|null,
# signalStrength: N, [signalPredicate: ...], ambientCreatesInterest: bool
if len(pos) >= 4:
category = pos[2] if isinstance(pos[2], str) else category
label = pos[3] if isinstance(pos[3], str) else label
if ambient_strength is None and len(pos) >= 5 and isinstance(pos[4], (int, float)):
ambient_strength = pos[4]
# signalIds may be positional after ambientStrength
if signal_ids_expr is None and len(pos) >= 6:
signal_ids_expr = pos[5]
if signal_strength is None:
# often named; sometimes after signalIds
for p in pos[6:]:
if isinstance(p, (int, float)):
signal_strength = p
break
if ambient_creates is None:
for p in reversed(pos):
if isinstance(p, bool):
ambient_creates = p
break
# Also pull named ambientStrength etc. from the raw call via regex for robustness
am = re.search(r"ambientStrength\s*:\s*([\d.]+)f?", block)
if am and ambient_strength is None:
ambient_strength = parse_number(am.group(1))
sm = re.search(r"signalStrength\s*:\s*([\d.]+)f?", block)
if sm and signal_strength is None:
signal_strength = parse_number(sm.group(1))
acm = re.search(r"ambientCreatesInterest\s*:\s*(true|false)", block)
if acm and ambient_creates is None:
ambient_creates = acm.group(1) == "true"
# Category / label from EnsureSeeded positional strings
cat_m = re.search(
r"EnsureSeeded\s*\(\s*Entries\s*,\s*[^,]+,\s*\"([^\"]+)\"\s*,\s*\"([^\"]*)\"",
block,
)
if cat_m:
category = cat_m.group(1)
label = cat_m.group(2)
if category is None or label is None or ambient_strength is None or signal_strength is None:
return None
if ambient_creates is None:
ambient_creates = True
out: dict[str, Any] = {
"ambientStrength": _num(ambient_strength),
"signalStrength": _num(signal_strength),
"category": category,
"label": label,
"ambientCreatesInterest": bool(ambient_creates),
}
# signalIds HashSet initializer if present
hs = re.search(
r"HashSet\s*<\s*string\s*>\s+\w+\s*=\s*new\s+HashSet\s*<\s*string\s*>\s*"
r"(?:\([^)]*\))?\s*\{([^}]*)\}",
block,
re.S,
)
if hs:
ids = []
for sm in re.finditer(r'"([^"]+)"', hs.group(1)):
ids.append(sm.group(1))
if ids:
out["signalIds"] = ids
return out
def load_existing_overlays(path: Path) -> dict[str, Any]:
if not path.is_file():
return {"decisions": [], "species": [], "character": []}
data = json.loads(path.read_text(encoding="utf-8"))
return {
"decisions": data.get("decisions") or [],
"species": data.get("species") or [],
"character": data.get("character") or [],
}
def merge_decisions(
existing: list[dict[str, Any]],
decision_cs: str,
) -> list[dict[str, Any]]:
"""Keep JSON decisions (strength/action win). Fill gaps from BuiltinActionPhrases."""
by_id: dict[str, dict[str, Any]] = {}
# Builtin phrases from C#
m = re.search(
r"BuiltinActionPhrases\s*=\s*new\s+Dictionary[^\{]*\{(.*)\};\s*\n\s*///",
decision_cs,
re.S,
)
if not m:
# fallback: grab until closing of static field
m = re.search(
r"BuiltinActionPhrases\s*=\s*new\s+Dictionary[^\{]*\{(.*?)^\s*\};",
decision_cs,
re.S | re.M,
)
builtins: dict[str, str] = {}
if m:
for km in re.finditer(r'\["([^"]+)"\]\s*=\s*"([^"]*)"', m.group(1)):
builtins[km.group(1)] = km.group(2)
else:
fail("decisions: could not parse BuiltinActionPhrases")
for row in existing:
rid = row.get("id")
if not rid:
continue
by_id[rid] = {
"id": rid,
"action": row.get("action") or builtins.get(rid, ""),
"strength": row.get("strength", 86),
}
# Do not invent strengths for builtin-only ids not in JSON (user: JSON wins / keep from existing)
# Optionally add builtin ids missing from JSON with no strength? User said keep from existing JSON.
# So only existing decisions list.
return list(by_id.values()) if by_id else [
{"id": k, "action": v, "strength": 86} for k, v in builtins.items()
]
def build_document(existing_path: Path) -> dict[str, Any]:
overlays = load_existing_overlays(existing_path)
happiness = parse_happiness(read_catalog("EventCatalog.Happiness.cs"))
status = parse_status(read_catalog("EventCatalog.Status.cs"))
world_log = parse_world_log(read_catalog("EventCatalog.WorldLog.cs"))
plots = parse_simple_add(
read_catalog("EventCatalog.Plot.cs"),
style="id_strength_category_label",
min_pos=4,
)
relationship = parse_simple_add(
read_catalog("EventCatalog.Relationship.cs"),
style="id_strength_category_label",
min_pos=4,
)
traits = parse_simple_add(
read_catalog("EventCatalog.Trait.cs"),
style="id_strength_only",
default_category="Trait",
default_label="Trait: {id} ({a})",
default_camera=True,
min_pos=2,
)
books = parse_simple_add(
read_catalog("EventCatalog.Book.cs"),
style="id_strength_category_label",
min_pos=4,
)
eras = parse_simple_add(
read_catalog("EventCatalog.Era.cs"),
style="id_strength_category_label",
min_pos=4,
)
disasters = parse_simple_add(
read_catalog("EventCatalog.Disaster.cs"),
style="disaster",
min_pos=3,
)
war_types = parse_simple_add(
read_catalog("EventCatalog.WarType.cs"),
style="war_type",
min_pos=3,
)
libraries = parse_libraries(read_catalog("EventCatalog.Libraries.cs"))
decision_cs = read_catalog("EventCatalog.Decision.cs")
decisions = merge_decisions(overlays["decisions"], decision_cs)
doc: dict[str, Any] = {
"modVersion": MOD_VERSION,
"title": "IdleSpectator event catalog",
"updated": UPDATED,
"note": NOTE,
"decisions": decisions,
"species": overlays["species"],
"character": overlays["character"],
"happiness": happiness,
"status": status,
"worldLog": world_log,
"plots": plots,
"relationship": relationship,
"traits": traits,
"books": books,
"eras": eras,
"disasters": disasters,
"warTypes": war_types,
"libraries": libraries,
}
return doc
def print_counts(doc: dict[str, Any]) -> None:
sections = [
"decisions",
"species",
"character",
"happiness",
"status",
"worldLog",
"plots",
"relationship",
"traits",
"books",
"eras",
"disasters",
"warTypes",
]
print("Counts:")
for key in sections:
val = doc.get(key)
n = len(val) if isinstance(val, list) else 0
print(f" {key}: {n}")
libs = doc.get("libraries") or {}
print(f" libraries: {len(libs)} keys ({', '.join(libs.keys())})")
for k, v in libs.items():
sig = v.get("signalIds")
extra = f", signalIds={len(sig)}" if isinstance(sig, list) else ""
print(
f" {k}: ambient={v.get('ambientStrength')} signal={v.get('signalStrength')} "
f"category={v.get('category')}{extra}"
)
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument(
"--out",
type=Path,
default=DEFAULT_OUT,
help=f"Output JSON path (default: {DEFAULT_OUT})",
)
ap.add_argument(
"--existing",
type=Path,
default=DEFAULT_EXISTING,
help="Existing event-catalog.json to merge decisions/species/character from",
)
args = ap.parse_args()
doc = build_document(args.existing)
args.out.parent.mkdir(parents=True, exist_ok=True)
args.out.write_text(
json.dumps(doc, indent=2, ensure_ascii=False) + "\n",
encoding="utf-8",
)
print(f"Wrote {args.out}")
print_counts(doc)
if _FAILURES:
print(f"\nParse failures ({len(_FAILURES)}):", file=sys.stderr)
for f in _FAILURES:
print(f" - {f}", file=sys.stderr)
return 1
print("\nParse failures: none")
return 0
if __name__ == "__main__":
sys.exit(main())