using System; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Text; using NeoModLoader.services; namespace IdleSpectator; /// One decision row from event-catalog.json (action phrase + EventStrength). public sealed class DecisionCatalogRow { public string Id = ""; public string Action = ""; public float Strength; } /// Library dial defaults from event-catalog.json libraries. public sealed class LibraryCatalogDefaults { public float AmbientStrength = 40f; public float SignalStrength = 80f; public string Category = "Event"; public string Label = "{a} · {id}"; public bool AmbientCreatesInterest = true; public readonly HashSet SignalIds = new HashSet(StringComparer.OrdinalIgnoreCase); } /// /// Loads event-catalog.json — SoT for per-event strength/prose overlays. /// action is decision-only (clause after "decides to"); other domains use label / happiness variants. /// public static class EventCatalogConfig { public const string FileName = "event-catalog.json"; public const float DefaultDecisionStrength = 86f; private static readonly Dictionary Decisions = new Dictionary(StringComparer.OrdinalIgnoreCase); private static readonly Dictionary SpeciesBonuses = new Dictionary(StringComparer.OrdinalIgnoreCase); private static readonly Dictionary CharacterBonuses = new Dictionary(StringComparer.OrdinalIgnoreCase); private static readonly Dictionary Happiness = new Dictionary(StringComparer.OrdinalIgnoreCase); private static readonly Dictionary Status = new Dictionary(StringComparer.OrdinalIgnoreCase); private static readonly Dictionary WorldLog = new Dictionary(StringComparer.OrdinalIgnoreCase); private static readonly Dictionary Plots = new Dictionary(StringComparer.OrdinalIgnoreCase); private static readonly Dictionary Relationship = new Dictionary(StringComparer.OrdinalIgnoreCase); private static readonly Dictionary Traits = new Dictionary(StringComparer.OrdinalIgnoreCase); private static readonly Dictionary Books = new Dictionary(StringComparer.OrdinalIgnoreCase); private static readonly Dictionary Eras = new Dictionary(StringComparer.OrdinalIgnoreCase); private static readonly Dictionary Disasters = new Dictionary(StringComparer.OrdinalIgnoreCase); private static readonly Dictionary WarTypes = new Dictionary(StringComparer.OrdinalIgnoreCase); private static readonly Dictionary Libraries = new Dictionary(StringComparer.OrdinalIgnoreCase); private static string _loadedPath = ""; private static string _loadedVersion = ""; private static bool _loadedFromFile; private static int _generation; public static bool LoadedFromFile => _loadedFromFile; public static string LoadedPath => _loadedPath; public static string LoadedVersion => _loadedVersion; public static int Generation => _generation; public static IEnumerable DecisionIds => Decisions.Keys; public static int DecisionCount => Decisions.Count; public static int SpeciesCount => SpeciesBonuses.Count; public static int CharacterCount => CharacterBonuses.Count; public static int HappinessCount => Happiness.Count; public static int StatusCount => Status.Count; public static int WorldLogCount => WorldLog.Count; public static int PlotCount => Plots.Count; public static int RelationshipCount => Relationship.Count; public static int TraitCount => Traits.Count; public static int BookCount => Books.Count; public static int EraCount => Eras.Count; public static int DisasterCount => Disasters.Count; public static int WarTypeCount => WarTypes.Count; public static int LibraryCount => Libraries.Count; public static void LoadFromModFolder(string modFolder) { ClearAll(); _loadedFromFile = false; _loadedPath = ""; _loadedVersion = ""; if (string.IsNullOrEmpty(modFolder)) { LogService.LogInfo("[IdleSpectator] event-catalog.json: no mod folder, using C# fallbacks"); BumpAndInvalidate(); return; } string path = Path.Combine(modFolder, FileName); if (!File.Exists(path)) { LogService.LogInfo($"[IdleSpectator] event-catalog.json missing at {path}, using C# fallbacks"); BumpAndInvalidate(); return; } try { string json = File.ReadAllText(path); if (!TryParseDocument(json, out string version)) { LogService.LogInfo("[IdleSpectator] event-catalog.json parse failed, using C# fallbacks"); ClearAll(); BumpAndInvalidate(); return; } _loadedFromFile = Happiness.Count > 0 || Decisions.Count > 0 || Status.Count > 0; _loadedPath = path; _loadedVersion = version ?? ""; BumpAndInvalidate(); LogService.LogInfo( $"[IdleSpectator] event-catalog.json loaded v={_loadedVersion} " + $"happiness={Happiness.Count} status={Status.Count} worldLog={WorldLog.Count} " + $"plots={Plots.Count} relationship={Relationship.Count} traits={Traits.Count} " + $"books={Books.Count} eras={Eras.Count} disasters={Disasters.Count} " + $"warTypes={WarTypes.Count} decisions={Decisions.Count} libraries={Libraries.Count} " + $"species={SpeciesBonuses.Count} character={CharacterBonuses.Count}"); } catch (Exception ex) { ClearAll(); LogService.LogInfo($"[IdleSpectator] event-catalog.json failed ({ex.Message}), using C# fallbacks"); BumpAndInvalidate(); } } private static void ClearAll() { Decisions.Clear(); SpeciesBonuses.Clear(); CharacterBonuses.Clear(); Happiness.Clear(); Status.Clear(); WorldLog.Clear(); Plots.Clear(); Relationship.Clear(); Traits.Clear(); Books.Clear(); Eras.Clear(); Disasters.Clear(); WarTypes.Clear(); Libraries.Clear(); } private static void BumpAndInvalidate() { _generation++; EventCatalog.InvalidateAllOverlays(); } internal static bool TryParseDocument(string json, out string version) { version = ""; if (string.IsNullOrEmpty(json)) { return false; } version = ExtractJsonString(json, "modVersion") ?? ""; if (TryExtractArray(json, "decisions", out string a)) IngestDecisionObjects(a); if (TryExtractArray(json, "species", out a)) IngestBonusObjects(a, SpeciesBonuses); if (TryExtractArray(json, "character", out a)) IngestBonusObjects(a, CharacterBonuses); if (TryExtractArray(json, "happiness", out a)) IngestHappiness(a); if (TryExtractArray(json, "status", out a)) IngestStatus(a); if (TryExtractArray(json, "worldLog", out a)) IngestWorldLog(a); if (TryExtractArray(json, "plots", out a)) IngestDiscrete(a, Plots); if (TryExtractArray(json, "relationship", out a)) IngestDiscrete(a, Relationship); if (TryExtractArray(json, "traits", out a)) IngestDiscrete(a, Traits); if (TryExtractArray(json, "books", out a)) IngestDiscrete(a, Books); if (TryExtractArray(json, "eras", out a)) IngestDiscrete(a, Eras); if (TryExtractArray(json, "disasters", out a)) IngestDisasters(a); if (TryExtractArray(json, "warTypes", out a)) IngestWarTypes(a); IngestLibraries(json); return Happiness.Count > 0 || Decisions.Count > 0 || Status.Count > 0 || Plots.Count > 0; } public static int FillHappiness(Dictionary into) { into.Clear(); foreach (KeyValuePair kv in Happiness) { into[kv.Key] = kv.Value; } return into.Count; } public static int FillStatus(Dictionary into) { into.Clear(); foreach (KeyValuePair kv in Status) { into[kv.Key] = kv.Value; } return into.Count; } public static int FillWorldLog(Dictionary into) { into.Clear(); foreach (KeyValuePair kv in WorldLog) { into[kv.Key] = kv.Value; } return into.Count; } public static int FillPlots(Dictionary into) => FillDiscrete(Plots, into); public static int FillRelationship(Dictionary into) => FillDiscrete(Relationship, into); public static int FillTraits(Dictionary into) => FillDiscrete(Traits, into); public static int FillBooks(Dictionary into) => FillDiscrete(Books, into); public static int FillEras(Dictionary into) => FillDiscrete(Eras, into); public static int FillDisasters(Dictionary into) { into.Clear(); foreach (KeyValuePair kv in Disasters) { into[kv.Key] = kv.Value; } return into.Count; } public static int FillWarTypes(Dictionary into) { into.Clear(); foreach (KeyValuePair kv in WarTypes) { into[kv.Key] = kv.Value; } return into.Count; } private static int FillDiscrete( Dictionary src, Dictionary into) { into.Clear(); foreach (KeyValuePair kv in src) { into[kv.Key] = kv.Value; } return into.Count; } public static bool TryGetLibraryDefaults(string key, out LibraryCatalogDefaults defaults) { defaults = null; if (string.IsNullOrEmpty(key)) { return false; } return Libraries.TryGetValue(key.Trim(), out defaults) && defaults != null; } private static void IngestHappiness(string arrayJson) { foreach (string obj in EnumerateObjects(arrayJson)) { string id = ExtractJsonString(obj, "id"); if (string.IsNullOrEmpty(id)) continue; var entry = new HappinessCatalogEntry { Id = id.Trim(), EventStrength = ExtractJsonFloat(obj, "strength", 40f), Presentation = ParseEnum(obj, "presentation", HappinessPresentationTier.Signal), Context = ParseEnum(obj, "context", HappinessContextRequirement.None), Life = ParseEnum(obj, "life", HappinessLifePolicy.ActivityOnly), Overlap = ParseEnum(obj, "overlap", HappinessOverlapPolicy.Independent), RelationRole = ParseEnum(obj, "relationRole", HappinessRelationRole.None), InterestCategory = ExtractJsonString(obj, "category") ?? "", CanonicalMilestoneKey = ExtractJsonString(obj, "canonicalMilestoneKey") ?? "", StatusOverlapId = ExtractJsonString(obj, "statusOverlapId") ?? "", StatusOverlapKind = ParseStatusOverlapKind(obj, ExtractJsonString(obj, "statusOverlapId")), VariantsWithRelated = ExtractStringArray(obj, "variantsWithRelated"), VariantsWithoutRelated = ExtractStringArray(obj, "variantsWithoutRelated") }; Happiness[entry.Id] = entry; } } private static void IngestStatus(string arrayJson) { foreach (string obj in EnumerateObjects(arrayJson)) { string id = ExtractJsonString(obj, "id"); if (string.IsNullOrEmpty(id)) continue; Status[id.Trim()] = new StatusInterestEntry { Id = id.Trim(), EventStrength = ExtractJsonFloat(obj, "strength", 40f), Category = ExtractJsonString(obj, "category") ?? "Status", CreatesInterest = ExtractJsonBool(obj, "camera", false), ExtendsGrief = ExtractJsonBool(obj, "extendsGrief", false) }; } } private static void IngestWorldLog(string arrayJson) { foreach (string obj in EnumerateObjects(arrayJson)) { string id = ExtractJsonString(obj, "id"); if (string.IsNullOrEmpty(id)) continue; bool camera = ExtractJsonBool(obj, "camera", true); float strength = ExtractJsonFloat(obj, "strength", 40f); bool chronicle = ExtractJsonBool(obj, "chronicle", camera && strength >= 65f); WorldLog[id.Trim()] = new WorldLogEventEntry { Id = id.Trim(), EventStrength = strength, Category = ExtractJsonString(obj, "category") ?? "WorldLog", CreatesInterest = camera, ChronicleEligible = chronicle, LabelTemplate = ExtractJsonString(obj, "label") ?? "{id}: {a}", MinWatch = ExtractJsonFloat(obj, "minWatch", 6f), MaxWatch = ExtractJsonFloat(obj, "maxWatch", 28f) }; } } private static void IngestDiscrete(string arrayJson, Dictionary into) { foreach (string obj in EnumerateObjects(arrayJson)) { string id = ExtractJsonString(obj, "id"); if (string.IsNullOrEmpty(id)) continue; into[id.Trim()] = new DiscreteEventEntry { Id = id.Trim(), EventStrength = ExtractJsonFloat(obj, "strength", 40f), Category = ExtractJsonString(obj, "category") ?? "Event", CreatesInterest = ExtractJsonBool(obj, "camera", true), LabelTemplate = ExtractJsonString(obj, "label") ?? "{a} · {id}", ActionPhrase = ExtractJsonString(obj, "action") ?? "" }; } } private static void IngestDisasters(string arrayJson) { foreach (string obj in EnumerateObjects(arrayJson)) { string id = ExtractJsonString(obj, "id"); if (string.IsNullOrEmpty(id)) continue; Disasters[id.Trim()] = new DisasterInterestEntry { Id = id.Trim(), EventStrength = ExtractJsonFloat(obj, "strength", 90f), Category = ExtractJsonString(obj, "category") ?? "Disaster", CreatesInterest = ExtractJsonBool(obj, "camera", true), WorldLogId = ExtractJsonString(obj, "worldLogId") ?? "" }; } } private static void IngestWarTypes(string arrayJson) { foreach (string obj in EnumerateObjects(arrayJson)) { string id = ExtractJsonString(obj, "id"); if (string.IsNullOrEmpty(id)) continue; WarTypes[id.Trim()] = new WarTypeInterestEntry { Id = id.Trim(), EventStrength = ExtractJsonFloat(obj, "strength", 85f), Category = ExtractJsonString(obj, "category") ?? "Politics", CreatesInterest = ExtractJsonBool(obj, "camera", true), LabelTemplate = ExtractJsonString(obj, "label") ?? "War ({id})" }; } } private static void IngestLibraries(string json) { if (!TryExtractObject(json, "libraries", out string libsJson)) { return; } string[] keys = { "spell", "power", "item", "gene", "phenotype", "worldLaw", "subspeciesTrait", "biome" }; for (int i = 0; i < keys.Length; i++) { string key = keys[i]; if (!TryExtractObject(libsJson, key, out string obj)) { continue; } var d = new LibraryCatalogDefaults { AmbientStrength = ExtractJsonFloat(obj, "ambientStrength", 40f), SignalStrength = ExtractJsonFloat(obj, "signalStrength", 80f), Category = ExtractJsonString(obj, "category") ?? "Event", Label = ExtractJsonString(obj, "label") ?? "{a} · {id}", AmbientCreatesInterest = ExtractJsonBool(obj, "ambientCreatesInterest", true) }; string[] ids = ExtractStringArray(obj, "signalIds"); for (int j = 0; j < ids.Length; j++) { if (!string.IsNullOrEmpty(ids[j])) { d.SignalIds.Add(ids[j]); } } Libraries[key] = d; } } private static int IngestDecisionObjects(string arrayJson) { int n = 0; foreach (string obj in EnumerateObjects(arrayJson)) { string id = ExtractJsonString(obj, "id"); string action = ExtractJsonString(obj, "action"); if (string.IsNullOrEmpty(id) || string.IsNullOrEmpty(action)) continue; float strength = ExtractJsonFloat(obj, "strength", DefaultDecisionStrength); Decisions[id.Trim()] = new DecisionCatalogRow { Id = id.Trim(), Action = action, Strength = strength > 0f ? strength : DefaultDecisionStrength }; n++; } return n; } private static int IngestBonusObjects(string arrayJson, Dictionary into) { int n = 0; foreach (string obj in EnumerateObjects(arrayJson)) { string id = ExtractJsonString(obj, "id"); if (string.IsNullOrEmpty(id)) continue; into[id.Trim()] = ExtractJsonFloat(obj, "bonus", 0f); n++; } return n; } private static TEnum ParseEnum(string json, string key, TEnum fallback) where TEnum : struct { string raw = ExtractJsonString(json, key); if (string.IsNullOrEmpty(raw)) return fallback; return Enum.TryParse(raw, true, out TEnum value) ? value : fallback; } private static HappinessStatusOverlapKind ParseStatusOverlapKind(string json, string statusOverlapId) { if (string.IsNullOrEmpty(statusOverlapId)) { return HappinessStatusOverlapKind.None; } HappinessStatusOverlapKind parsed = ParseEnum( json, "statusOverlapKind", HappinessStatusOverlapKind.None); if (parsed != HappinessStatusOverlapKind.None) { return parsed; } // Legacy rows without statusOverlapKind: offset end-pings vs onset holds. string id = ExtractJsonString(json, "id") ?? ""; if (IsLegacyOffsetOverlap(id)) { return HappinessStatusOverlapKind.Offset; } return HappinessStatusOverlapKind.Hold; } private static bool IsLegacyOffsetOverlap(string effectId) { if (string.IsNullOrEmpty(effectId)) { return false; } switch (effectId.Trim().ToLowerInvariant()) { case "just_laughed": case "just_sang": case "just_swore": case "just_cried": case "just_had_tantrum": case "just_inspired": return true; default: return false; } } private static string[] ExtractStringArray(string json, string key) { if (!TryExtractArray(json, key, out string arr) || string.IsNullOrEmpty(arr)) { return Array.Empty(); } var list = new List(); int i = 0; while (i < arr.Length) { int q = arr.IndexOf('"', i); if (q < 0) break; int end = q + 1; var sb = new StringBuilder(); while (end < arr.Length) { char c = arr[end++]; if (c == '\\' && end < arr.Length) { sb.Append(arr[end++]); continue; } if (c == '"') break; sb.Append(c); } list.Add(sb.ToString()); i = end; } return list.ToArray(); } private static bool ExtractJsonBool(string json, string key, bool fallback) { string needle = "\"" + key + "\""; int keyAt = json.IndexOf(needle, StringComparison.Ordinal); if (keyAt < 0) return fallback; int colon = json.IndexOf(':', keyAt + needle.Length); if (colon < 0) return fallback; int i = colon + 1; while (i < json.Length && char.IsWhiteSpace(json[i])) i++; if (i + 4 <= json.Length && string.Compare(json, i, "true", 0, 4, StringComparison.OrdinalIgnoreCase) == 0) return true; if (i + 5 <= json.Length && string.Compare(json, i, "false", 0, 5, StringComparison.OrdinalIgnoreCase) == 0) return false; return fallback; } private static IEnumerable EnumerateObjects(string arrayJson) { if (string.IsNullOrEmpty(arrayJson)) yield break; int i = 0; while (i < arrayJson.Length) { int start = arrayJson.IndexOf('{', i); if (start < 0) yield break; int depth = 0; bool inString = false; bool escape = false; for (int j = start; j < arrayJson.Length; j++) { char c = arrayJson[j]; if (inString) { if (escape) { escape = false; continue; } if (c == '\\') { escape = true; continue; } if (c == '"') inString = false; continue; } if (c == '"') { inString = true; continue; } if (c == '{') depth++; else if (c == '}') { depth--; if (depth == 0) { yield return arrayJson.Substring(start, j - start + 1); i = j + 1; break; } } } if (depth != 0) yield break; } } private static string ExtractJsonString(string json, string key) { string needle = "\"" + key + "\""; int keyAt = json.IndexOf(needle, StringComparison.Ordinal); if (keyAt < 0) return null; int colon = json.IndexOf(':', keyAt + needle.Length); if (colon < 0) return null; int i = colon + 1; while (i < json.Length && char.IsWhiteSpace(json[i])) i++; if (i >= json.Length || json[i] != '"') return null; i++; var sb = new StringBuilder(); while (i < json.Length) { char c = json[i++]; if (c == '\\' && i < json.Length) { sb.Append(json[i++]); continue; } if (c == '"') break; sb.Append(c); } return sb.ToString(); } private static float ExtractJsonFloat(string json, string key, float fallback) { string needle = "\"" + key + "\""; int keyAt = json.IndexOf(needle, StringComparison.Ordinal); if (keyAt < 0) return fallback; int colon = json.IndexOf(':', keyAt + needle.Length); if (colon < 0) return fallback; int i = colon + 1; while (i < json.Length && char.IsWhiteSpace(json[i])) i++; int start = i; while (i < json.Length && (char.IsDigit(json[i]) || json[i] == '.' || json[i] == '-' || json[i] == '+')) i++; if (i <= start) return fallback; string token = json.Substring(start, i - start); return float.TryParse(token, NumberStyles.Float, CultureInfo.InvariantCulture, out float value) ? value : fallback; } private static bool TryExtractArray(string json, string key, out string arrayJson) { arrayJson = null; string keyColon = "\"" + key + "\""; int searchFrom = 0; int keyAt = -1; while (true) { int at = json.IndexOf(keyColon, searchFrom, StringComparison.Ordinal); if (at < 0) break; int after = at + keyColon.Length; while (after < json.Length && char.IsWhiteSpace(json[after])) after++; if (after < json.Length && json[after] == ':') { keyAt = at; break; } searchFrom = at + 1; } if (keyAt < 0) return false; int bracket = json.IndexOf('[', keyAt + keyColon.Length); if (bracket < 0) return false; int depth = 0; bool inString = false; bool escape = false; for (int i = bracket; i < json.Length; i++) { char c = json[i]; if (inString) { if (escape) { escape = false; continue; } if (c == '\\') { escape = true; continue; } if (c == '"') inString = false; continue; } if (c == '"') { inString = true; continue; } if (c == '[') depth++; else if (c == ']') { depth--; if (depth == 0) { arrayJson = json.Substring(bracket, i - bracket + 1); return true; } } } return false; } private static bool TryExtractObject(string json, string key, out string objectJson) { objectJson = null; string keyColon = "\"" + key + "\""; int searchFrom = 0; int keyAt = -1; while (true) { int at = json.IndexOf(keyColon, searchFrom, StringComparison.Ordinal); if (at < 0) break; int after = at + keyColon.Length; while (after < json.Length && char.IsWhiteSpace(json[after])) after++; if (after < json.Length && json[after] == ':') { keyAt = at; break; } searchFrom = at + 1; } if (keyAt < 0) return false; int brace = json.IndexOf('{', keyAt + keyColon.Length); if (brace < 0) return false; int depth = 0; bool inString = false; bool escape = false; for (int i = brace; i < json.Length; i++) { char c = json[i]; if (inString) { if (escape) { escape = false; continue; } if (c == '\\') { escape = true; continue; } if (c == '"') inString = false; continue; } if (c == '"') { inString = true; continue; } if (c == '{') depth++; else if (c == '}') { depth--; if (depth == 0) { objectJson = json.Substring(brace, i - brace + 1); return true; } } } return false; } public static bool TryGetDecision(string id, out DecisionCatalogRow row) { row = null; if (string.IsNullOrEmpty(id)) return false; return Decisions.TryGetValue(id.Trim(), out row) && row != null; } public static bool TryGetDecisionAction(string id, out string action) { action = ""; if (!TryGetDecision(id, out DecisionCatalogRow row)) return false; action = row.Action ?? ""; return !string.IsNullOrEmpty(action); } public static float DecisionStrengthOrDefault(string id, float fallback = DefaultDecisionStrength) { if (TryGetDecision(id, out DecisionCatalogRow row) && row.Strength > 0f) return row.Strength; return fallback; } public static float SpeciesBonus(string speciesId) { if (string.IsNullOrEmpty(speciesId)) return 0f; return SpeciesBonuses.TryGetValue(speciesId.Trim(), out float bonus) ? bonus : 0f; } public static float CharacterBonus(string roleId, float scoringModelFallback) { if (string.IsNullOrEmpty(roleId)) return scoringModelFallback; if (CharacterBonuses.TryGetValue(roleId.Trim(), out float bonus)) return bonus; return scoringModelFallback; } public static bool Reload() { string folder = ModClass.ModFolder; if (string.IsNullOrEmpty(folder)) return false; LoadFromModFolder(folder); return _loadedFromFile; } }