using System; using System.Collections.Generic; namespace IdleSpectator; /// /// Decision interest dials + authored action phrases. /// /// Source of truth: event-catalog.json (decisions rows: action + strength). /// is the offline fallback when that file is missing. /// Live ids still seed via ; camera Signal uses /// ; missing ActionPhrase falls back to a generic humanize. /// /// public static partial class EventCatalog { public static class Decision { private static readonly Dictionary Entries = new Dictionary(StringComparer.OrdinalIgnoreCase); private static int _appliedOverlayGeneration = -1; /// /// Offline fallback infinitive clauses when event-catalog.json is missing a row. /// Prefer editing event-catalog.json instead. /// private static readonly Dictionary BuiltinActionPhrases = new Dictionary(StringComparer.OrdinalIgnoreCase) { // Social / crime ["find_lover"] = "find a lover", ["try_to_steal_money"] = "try to steal money", ["banish_unruly_clan_members"] = "banish unruly clan members", ["kill_unruly_clan_members"] = "kill unruly clan members", // Plots ["try_new_plot"] = "try a new plot", // King kingdom policy ["king_change_kingdom_culture"] = "change the kingdom's culture", ["king_change_kingdom_language"] = "change the kingdom's language", ["king_change_kingdom_religion"] = "change the kingdom's religion", // City leader policy ["leader_change_city_culture"] = "change the city's culture", ["leader_change_city_language"] = "change the city's language", ["leader_change_city_religion"] = "change the city's religion", // Army / warrior (keep train-with-dummy authored for if we ever Signal it) ["warrior_army_follow_leader"] = "follow their leader", ["warrior_train_with_dummy"] = "train with a dummy", ["warrior_try_join_army_group"] = "try to join an army", ["warrior_army_leader_move_to_attack_target"] = "lead the army to attack", // Founding / territory ["try_to_start_new_civilization"] = "start a new civilization", ["build_civ_city_here"] = "found a city here", ["claim_land"] = "claim this land", ["king_check_new_city_foundation"] = "found a new city", ["bee_create_hive"] = "create a hive", // Life / pack ["sexual_reproduction_try"] = "try to reproduce", ["asexual_reproduction_budding"] = "reproduce by budding", ["asexual_reproduction_divine"] = "reproduce by divine means", ["asexual_reproduction_fission"] = "reproduce by fission", ["asexual_reproduction_parthenogenesis"] = "reproduce by parthenogenesis", ["asexual_reproduction_spores"] = "reproduce by spores", ["asexual_reproduction_vegetative"] = "reproduce vegetatively", ["family_group_follow"] = "follow their family group", ["family_group_join_or_new_herd"] = "join or form a herd", ["family_group_leave"] = "leave their family group", // Spectacle / dark / civic ["attack_golden_brain"] = "attack the golden brain", ["make_skeleton"] = "raise a skeleton", ["status_soul_harvested"] = "have their soul harvested", ["possessed_following"] = "follow under possession", ["try_to_take_city_item"] = "try to take a city item", ["put_out_fire"] = "put out a fire", ["burn_tumors"] = "burn tumors", ["try_to_launch_fireworks"] = "launch fireworks", ["try_affect_dreams"] = "affect dreams", }; /// Drop cached rows so the next Ensure re-applies JSON overlays. public static void InvalidateOverlays() { Entries.Clear(); _appliedOverlayGeneration = -1; } private static bool IsInterestingDecision(string id) { if (string.IsNullOrEmpty(id)) { return false; } string s = id.ToLowerInvariant(); // Intent-only: setDecisionCooldown fires before the outcome exists. // Plots → PlotInterestFeed; city/civ founding → MetaEventPatches (new_city / new_kingdom). if (IsIntentOnlyGenesisDecision(s)) { return false; } // Daily AI / check ticks. if (IsFluffDecision(s)) { return false; } // Soft intent chatter - outcomes already covered by other feeds, or not story-worthy. if (IsSoftDecisionNoise(s)) { return false; } return HasToken(s, "war") || HasToken(s, "king") || HasToken(s, "leader") || s.Contains("rebellion") || s.Contains("revolt") || s.Contains("alliance") || s.Contains("coup") || s.Contains("betray") || s.Contains("declare") || s.Contains("invade") || s.Contains("siege") || s.Contains("army") || s.Contains("lover") || s.Contains("plot") || s.Contains("marry") || s.Contains("banish") || s.Contains("kill_unruly") || s.Contains("clan") || s.Contains("religion") || s.Contains("culture") || s.Contains("baby_make") || s.Contains("have_child") || s.Contains("birth") || s.Contains("sexual_reproduction") || s.Contains("golden_brain") || s.Contains("make_skeleton") || s.Contains("soul_harvest") || s.Contains("possessed") || s.Contains("create_hive"); } /// /// Soft AI chatter that distracts the story camera. Outcomes (hatch, pack sticky, fires) /// already have better feeds when they matter. /// private static bool IsSoftDecisionNoise(string s) { if (string.IsNullOrEmpty(s)) { return true; } if (s.Contains("family_group") || s.Contains("affect_dreams") || s.Contains("fireworks") || s.Contains("put_out_fire") || s.Contains("burn_tumors") || s.Contains("sexual_reproduction") || s.Contains("asexual_reproduction") || s.Contains("take_city_item") || s.Contains("try_to_steal") || s.Contains("steal_money")) { return true; } // Army follow/join intent - keep only lead-to-attack. if (s == "warrior_army_follow_leader" || s == "warrior_try_join_army_group") { return true; } return false; } /// /// Decision cooldown is AI intent, not the world outcome. /// Real camera tips come from plot / meta genesis feeds after confirm. /// private static bool IsIntentOnlyGenesisDecision(string s) { if (string.IsNullOrEmpty(s)) { return false; } if (s == "try_new_plot" || s.StartsWith("try_new_plot_", StringComparison.Ordinal)) { return true; } if (s.Contains("start_new_civilization") || s.Contains("start_new_civ") || s.Contains("build_civ_city") || s.Contains("city_foundation") || s == "claim_land" || s.StartsWith("claim_land_", StringComparison.Ordinal)) { return true; } return false; } /// Pure AI ticks / daily fluff. Not story beats. private static bool IsFluffDecision(string s) { if (string.IsNullOrEmpty(s)) { return true; } if (s.StartsWith("check_", StringComparison.Ordinal) || s.Contains("_check_") || s.EndsWith("_check", StringComparison.Ordinal)) { return true; } return s.Contains("idle") || s.Contains("walking") || s.Contains("sleep") || s.Contains("play") || s.Contains("jump") || s.Contains("flip") || s.Contains("diet_") || s.Contains("reflection") || s.Contains("poop") || s.Contains("follow_parent") || s.Contains("follow_desire") || s.Contains("move_random") || s.Contains("random_move") || s.Contains("random_fun") || s.Contains("random_swim") || s.Contains("random_teleport") || s.Contains("random_emotion") || s.Contains("warrior_random") || s.Contains("captain_waiting") || s.Contains("captain_idle") || s.Contains("_waiting") || s == "wait5" || s.Contains("swim_to") || s.Contains("move_to_water") || s.Contains("bored_sleep") || s.Contains("monophasic") || s.Contains("polyphasic"); } /// Underscore-token match so war does not hit warrior. private static bool HasToken(string haystack, string token) { if (string.IsNullOrEmpty(haystack) || string.IsNullOrEmpty(token)) { return false; } if (haystack == token) { return true; } return haystack.StartsWith(token + "_", StringComparison.Ordinal) || haystack.EndsWith("_" + token, StringComparison.Ordinal) || haystack.Contains("_" + token + "_"); } /// True when a decision id should be allowed to own the idle camera. public static bool IsCameraWorthy(string id) => IsInterestingDecision(id); /// /// Life-cycle intent decisions: AI cooldown fires before the outcome tip. /// Outcomes (pregnancy, child, seeking/smitten, lovers) already own the story - /// these crumbs soft-cool as a class after one show. /// Derived from live decisions_library token shape, not tip strings. /// public static bool IsLifeIntentDecision(string id) { if (string.IsNullOrEmpty(id)) { return false; } string s = id.Trim().ToLowerInvariant(); return s.IndexOf("reproduction", StringComparison.Ordinal) >= 0 || s == "find_lover" || s.StartsWith("find_lover_", StringComparison.Ordinal) || s.IndexOf("baby_make", StringComparison.Ordinal) >= 0 || s.IndexOf("have_child", StringComparison.Ordinal) >= 0; } /// Authored action-phrase ids (JSON catalog ∪ builtin fallback). public static IEnumerable AuthoredActionPhraseIds { get { var seen = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (string id in EventCatalogConfig.DecisionIds) { if (seen.Add(id)) { yield return id; } } foreach (string id in BuiltinActionPhrases.Keys) { if (seen.Add(id)) { yield return id; } } } } public static bool TryGetAuthoredActionPhrase(string id, out string actionPhrase) { actionPhrase = ""; if (string.IsNullOrEmpty(id)) { return false; } string key = id.Trim(); if (EventCatalogConfig.TryGetDecisionAction(key, out actionPhrase)) { return true; } return BuiltinActionPhrases.TryGetValue(key, out actionPhrase) && !string.IsNullOrEmpty(actionPhrase); } /// /// Infinitive clause for decides to …. Prefers event-catalog.json / builtin, /// else a generic humanize of the asset id. /// public static string ActionPhraseFor(string decisionId) { Ensure(); if (string.IsNullOrEmpty(decisionId)) { return ""; } string key = decisionId.Trim(); if (Entries.TryGetValue(key, out DiscreteEventEntry entry) && !string.IsNullOrEmpty(entry.ActionPhrase)) { return entry.ActionPhrase; } if (TryGetAuthoredActionPhrase(key, out string authored)) { return authored; } return FallbackHumanize(key); } private static void Ensure() { if (_appliedOverlayGeneration != EventCatalogConfig.Generation) { Entries.Clear(); _appliedOverlayGeneration = EventCatalogConfig.Generation; } LiveLibraryInterest.EnsureSeeded( Entries, ActivityAssetCatalog.EnumerateLiveDecisionIds, "Politics", "{a} decides to act", ambientStrength: 40f, signalIds: null, signalStrength: EventCatalogConfig.DefaultDecisionStrength, signalPredicate: IsInterestingDecision, ambientCreatesInterest: false); // Apply authored prose + per-id strength (JSON wins over builtin). foreach (string id in AuthoredActionPhraseIds) { if (!TryGetAuthoredActionPhrase(id, out string action) || string.IsNullOrEmpty(action)) { continue; } float strength = EventCatalogConfig.DecisionStrengthOrDefault( id, IsInterestingDecision(id) ? EventCatalogConfig.DefaultDecisionStrength : 40f); string label = "{a} decides to " + action; if (Entries.TryGetValue(id, out DiscreteEventEntry existing)) { existing.ActionPhrase = action; existing.LabelTemplate = label; if (EventCatalogConfig.TryGetDecision(id, out _)) { existing.EventStrength = strength; } continue; } Entries[id] = new DiscreteEventEntry { Id = id, EventStrength = strength, Category = "Politics", CreatesInterest = IsInterestingDecision(id), ActionPhrase = action, LabelTemplate = label }; } } public static IEnumerable AuthoredIds { get { Ensure(); return Entries.Keys; } } public static DiscreteEventEntry GetOrFallback(string id) { Ensure(); DiscreteEventEntry entry = LiveLibraryInterest.Lookup( Entries, id, "Politics", "{a} decides to act", 35f); if (!entry.IsFallback && string.IsNullOrEmpty(entry.ActionPhrase)) { // Attach fallback humanize so callers can still read a clause. entry.ActionPhrase = FallbackHumanize(entry.Id); if (!string.IsNullOrEmpty(entry.ActionPhrase)) { entry.LabelTemplate = "{a} decides to " + entry.ActionPhrase; } } return entry; } /// /// Generic id → infinitive clause when no authored row exists. /// Prefer adding a row to event-catalog.json instead of relying on this. /// public static string FallbackHumanize(string decisionId) { if (string.IsNullOrEmpty(decisionId)) { return ""; } string raw = decisionId.Trim().Replace('-', '_').ToLowerInvariant(); if (raw.StartsWith("type_", StringComparison.Ordinal)) { raw = raw.Substring(5); } string[] parts = raw.Split(new[] { '_' }, StringSplitOptions.RemoveEmptyEntries); if (parts.Length == 0) { return ""; } int start = 0; while (start < parts.Length && (parts[start] == "child" || parts[start] == "baby" || parts[start] == "adult" || parts[start] == "warrior" || parts[start] == "city" || parts[start] == "actor" || parts[start] == "animal" || parts[start] == "herd" || parts[start] == "civ" || parts[start] == "mob" || parts[start] == "king" || parts[start] == "leader")) { start++; } if (start >= parts.Length) { start = 0; } var words = new List(parts.Length - start); for (int i = start; i < parts.Length; i++) { words.Add(parts[i]); } if (words.Count == 0) { return ""; } if (words.Count >= 3 && words[0] == "change" && (words[1] == "kingdom" || words[1] == "city" || words[1] == "clan")) { return "change the " + words[1] + "'s " + string.Join(" ", words.GetRange(2, words.Count - 2)); } if (words[0] == "diet" && words.Count >= 2) { words[0] = "eat"; } if (words.Count >= 2 && words[0] == "try" && words[1] == "new") { words.Insert(1, "a"); return string.Join(" ", words); } if (words.Count == 2 && words[0] == "find") { return "find a " + words[1]; } if (words[0] == "join" && words.Count >= 2 && words[1] == "army") { return "join an army"; } if (words[0] == "try" && words.Count >= 2 && words[1] != "to") { words.Insert(1, "to"); } if (words.Count >= 4 && words[0] == "try" && words[1] == "to" && words[2] == "join" && words[3] == "army") { return "try to join an army"; } if (words.Count >= 3 && words[0] == "train" && words[1] == "with") { return "train with a " + string.Join(" ", words.GetRange(2, words.Count - 2)); } if (words.Count >= 2 && words[words.Count - 1] == "leader" && (words[0] == "follow" || (words[0] == "army" && words[1] == "follow"))) { return "follow their leader"; } return string.Join(" ", words); } } }