843 lines
27 KiB
Python
Executable file
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())
|