worldbox-observer-mod/IdleSpectator/Events/Catalogs/EventCatalog.Decision.cs
DazedAnon 9a059c17dd fix(spectator): cool soft life crumbs and keep aftermath victim truth
- Soft-cool kill, courtship, intent, construction, status fx, hatch, rest, and worldlog meta
- Block cooled tips from resuming after a cut-in
- Name only dead theater partners in stands-over aftermath
- Add harness gates through 0.28.54
2026-07-21 16:45:12 -05:00

567 lines
21 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Collections.Generic;
namespace IdleSpectator;
/// <summary>
/// Decision interest dials + authored action phrases.
/// <para>
/// <b>Source of truth:</b> <c>event-catalog.json</c> (<c>decisions</c> rows: action + strength).
/// <see cref="BuiltinActionPhrases"/> is the offline fallback when that file is missing.
/// Live ids still seed via <see cref="LiveLibraryInterest"/>; camera Signal uses
/// <see cref="IsCameraWorthy"/>; missing ActionPhrase falls back to a generic humanize.
/// </para>
/// </summary>
public static partial class EventCatalog
{
public static class Decision
{
private static readonly Dictionary<string, DiscreteEventEntry> Entries =
new Dictionary<string, DiscreteEventEntry>(StringComparer.OrdinalIgnoreCase);
private static int _appliedOverlayGeneration = -1;
/// <summary>
/// Offline fallback infinitive clauses when event-catalog.json is missing a row.
/// Prefer editing <c>event-catalog.json</c> instead.
/// </summary>
private static readonly Dictionary<string, string> BuiltinActionPhrases =
new Dictionary<string, string>(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",
};
/// <summary>Drop cached rows so the next Ensure re-applies JSON overlays.</summary>
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");
}
/// <summary>
/// Soft AI chatter that distracts the story camera. Outcomes (hatch, pack sticky, fires)
/// already have better feeds when they matter.
/// </summary>
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("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;
}
/// <summary>
/// Decision cooldown is AI intent, not the world outcome.
/// Real camera tips come from plot / meta genesis feeds after confirm.
/// </summary>
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;
}
/// <summary>Pure AI ticks / daily fluff. Not story beats.</summary>
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");
}
/// <summary>Underscore-token match so <c>war</c> does not hit <c>warrior</c>.</summary>
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 + "_");
}
/// <summary>True when a decision id should be allowed to own the idle camera.</summary>
public static bool IsCameraWorthy(string id) => IsInterestingDecision(id);
/// <summary>
/// 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 <c>decisions_library</c> token shape, not tip strings.
/// </summary>
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;
}
/// <summary>Authored action-phrase ids (JSON catalog builtin fallback).</summary>
public static IEnumerable<string> AuthoredActionPhraseIds
{
get
{
var seen = new HashSet<string>(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);
}
/// <summary>
/// Infinitive clause for <c>decides to …</c>. Prefers event-catalog.json / builtin,
/// else a generic humanize of the asset id.
/// </summary>
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<string> 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;
}
/// <summary>
/// Generic id → infinitive clause when no authored row exists.
/// Prefer adding a row to <c>event-catalog.json</c> instead of relying on this.
/// </summary>
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<string>(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);
}
}
}