worldbox-observer-mod/IdleSpectator/Events/Catalogs/EventCatalog.Decision.cs
2026-07-16 14:21:48 -05:00

406 lines
14 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",
};
/// <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();
// Pure AI ticks / daily fluff - not story beats.
if (s.Contains("idle")
|| s.Contains("walking")
|| s.Contains("check")
|| s.Contains("wait")
|| s.Contains("sleep")
|| s.Contains("random")
|| s.Contains("play")
|| s.Contains("jump")
|| s.Contains("flip")
|| s.Contains("diet_")
|| s.Contains("move")
|| s.Contains("swim")
|| s.Contains("follow_parent")
|| s.Contains("follow_desire")
|| s.Contains("reflection")
|| s.Contains("poop"))
{
return false;
}
return HasToken(s, "war")
|| 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("found")
|| s.Contains("city_foundation")
|| s.Contains("army")
|| s.Contains("lover")
|| s.Contains("plot")
|| s.Contains("marry")
|| s.Contains("banish")
|| s.Contains("steal")
|| s.Contains("kill_unruly")
|| HasToken(s, "king")
|| HasToken(s, "leader")
|| s.Contains("clan")
|| s.Contains("religion")
|| s.Contains("culture")
|| s.Contains("baby_make")
|| s.Contains("have_child")
|| s.Contains("birth");
}
/// <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>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);
}
}
}