- 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
567 lines
21 KiB
C#
567 lines
21 KiB
C#
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);
|
||
}
|
||
}
|
||
}
|