#!/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())