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);
}
}
}