Compare commits

...

11 commits

Author SHA1 Message Date
1c00a7980a Enhance IdleSpectator event handling by introducing hatch event logic and refining related event reasons. Update various event catalogs to improve clarity and accuracy of event descriptions, particularly for hatching and dream-related events. Increment version in mod.json to 0.21.3 to reflect these changes. 2026-07-16 01:08:36 -05:00
63ba0ca3d1 event catalog 2026-07-16 00:40:53 -05:00
ff824c76a1 Enhance interest management in IdleSpectator by refining event handling and scoring logic. Update various event catalogs to adjust event strengths and ownership attributes, ensuring more accurate representation of combat and relationship events. Revise InterestDirector and InterestCompletion to improve camera dwell management and combat activity detection. Increment version in mod.json to 0.20.12 to reflect these changes. 2026-07-16 00:27:37 -05:00
c253b5dd9c Refactor IdleSpectator event handling to improve interest management and camera ownership logic. Update various event catalogs to remove ownership attributes and enhance label templates for clarity. Introduce new scoring logic in InterestDirector to better manage session protection and cut-in conditions. Revise AgentHarness and related components to streamline event registration and improve overall event clarity. Increment version in mod.json to reflect these changes. 2026-07-16 00:06:32 -05:00
3fe12d6ff0 Refactor decision event handling in IdleSpectator to filter camera-worthy events more effectively. Introduce new logic to exclude routine AI ticks and enhance the identification of significant kingdom-affecting decisions. Update event strength handling for birth moments and adjust status interest catalog to prevent non-essential traits from owning the camera. Increment version in mod.json to 0.19.3 to reflect these changes. 2026-07-15 23:10:30 -05:00
6c6a7cc286 Refactor event handling in IdleSpectator to improve reason clarity and ownership management. Update label templates across various event catalogs to provide more descriptive outputs. Enhance event registration logic to ensure accurate representation of actions and relationships. Increment version in mod.json to 0.19.2 to reflect these changes. 2026-07-15 23:03:57 -05:00
fd2283222c Refactor interest management in IdleSpectator to enhance reason ownership handling and improve dossier updates. Introduce new methods in InterestDirector for managing owned candidates and update WatchCaption to synchronize reason display with active dwell states. Revise AgentHarness to ensure accurate reason reporting during inactivity. Increment version in mod.json to 0.18.2 to reflect these changes. 2026-07-15 22:33:32 -05:00
020d73fa9c Phase 1 2026-07-15 22:16:24 -05:00
eaa431cc3d Enhance interest management in IdleSpectator by introducing new methods for enumerating live plot, era, book type, trait, and building IDs. Update AgentHarness to support domain feeds and add new harness scenarios for domain event auditing and event tracking. Refactor event registration logic in InterestFeeds and WarInterestFeed to streamline interest candidate creation. Increment version in mod.json to 0.16.32 to reflect these changes. 2026-07-15 21:24:20 -05:00
20996fa3a7 Mutation 2026-07-15 20:39:11 -05:00
199adee644 Build Inventories 2026-07-15 20:09:08 -05:00
72 changed files with 9496 additions and 764 deletions

View file

@ -125,12 +125,16 @@ A loaded world is still required.
| Name | Purpose |
|------|---------|
| `watch_reason_clarity` | Orange reason = event sentence (`is fighting`, `is seeking a lover`); no Signal gate; fill has empty reason |
| `critical_smoke` | PR gate: settings gate, idle health, tip match, retarget, manual exit |
| `settings_full` | Disable-while-active, show_watch_reasons |
| `ghost_guard` | Fake new-species tip must not steal focus onto wrong unit |
| `retarget_stability` | Many watches without focus/power-bar flicker |
| `input_exit` | Post-enable grace + camera-drag exit |
| `director_tiers` | Curiosity accept, Action interrupt, curiosity blocked by Action |
| `director_tiers` | Score-margin cut-in (Action over Curiosity); peer rotate still behind MinDwell |
| `director_lifecycle` | Hold / resume / MaxWatch / quiet grace → ambient |
| `director_gaps` | Forced session interrupt + resume after higher-score cut |
| `focus_interrupt` | Instant score-margin cut (no settle); hold; still-worth-watching; 6s grace |
| `discovery` | Present dedupe + force drain + tip match |
| `world_log` | Story/Epic interest interrupt via collector |
| `chronicle_smoke` | History inject, death-cause samples, World feed isolation, History\|World tabs, HUD jump |

View file

@ -204,11 +204,125 @@ public static class ActivityAssetCatalog
}
public static List<string> EnumerateLiveTaskIds()
{
return EnumerateLibraryIds("tasks_actor");
}
public static List<string> EnumerateLiveWorldLogIds()
{
return EnumerateLibraryIds("world_log_library");
}
public static WorldLogAsset TryGetWorldLogAsset(string assetId)
{
if (string.IsNullOrEmpty(assetId))
{
return null;
}
try
{
return AssetManager.world_log_library?.get(assetId.Trim());
}
catch
{
return null;
}
}
public static List<string> EnumerateLiveDisasterIds()
{
return EnumerateLibraryIds("disasters");
}
public static DisasterAsset TryGetDisasterAsset(string disasterId)
{
if (string.IsNullOrEmpty(disasterId))
{
return null;
}
try
{
return AssetManager.disasters?.get(disasterId.Trim());
}
catch
{
return null;
}
}
public static List<string> EnumerateLiveWarTypeIds()
{
return EnumerateLibraryIds("war_types_library");
}
public static WarTypeAsset TryGetWarTypeAsset(string warTypeId)
{
if (string.IsNullOrEmpty(warTypeId))
{
return null;
}
try
{
return AssetManager.war_types_library?.get(warTypeId.Trim());
}
catch
{
return null;
}
}
public static List<string> EnumerateLivePlotIds()
{
return EnumerateLibraryIds("plots_library");
}
public static List<string> EnumerateLiveEraIds()
{
return EnumerateLibraryIds("era_library");
}
public static List<string> EnumerateLiveBookTypeIds()
{
return EnumerateLibraryIds("book_types");
}
public static List<string> EnumerateLiveTraitIds()
{
return EnumerateLibraryIds("traits");
}
public static List<string> EnumerateLiveBuildingIds()
{
return EnumerateLibraryIds("buildings");
}
public static List<string> EnumerateLiveSpellIds() => EnumerateLibraryIds("spells");
public static List<string> EnumerateLivePowerIds() => EnumerateLibraryIds("powers");
public static List<string> EnumerateLiveDecisionIds() => EnumerateLibraryIds("decisions_library");
public static List<string> EnumerateLiveItemIds() => EnumerateLibraryIds("items");
public static List<string> EnumerateLiveSubspeciesTraitIds() => EnumerateLibraryIds("subspecies_traits");
public static List<string> EnumerateLiveGeneIds() => EnumerateLibraryIds("gene_library");
public static List<string> EnumerateLivePhenotypeIds() => EnumerateLibraryIds("phenotype_library");
public static List<string> EnumerateLiveWorldLawIds() => EnumerateLibraryIds("world_laws_library");
public static List<string> EnumerateLiveBiomeIds() => EnumerateLibraryIds("biome_library");
private static List<string> EnumerateLibraryIds(string assetManagerField)
{
var ids = new List<string>();
try
{
object lib = typeof(AssetManager).GetField("tasks_actor")?.GetValue(null);
object lib = typeof(AssetManager).GetField(assetManagerField)?.GetValue(null);
object listObj = lib?.GetType().GetField("list")?.GetValue(lib)
?? lib?.GetType().GetProperty("list")?.GetValue(lib, null);
System.Collections.IList list = listObj as System.Collections.IList;
@ -219,9 +333,9 @@ public static class ActivityAssetCatalog
for (int i = 0; i < list.Count; i++)
{
object task = list[i];
object idObj = task?.GetType().GetField("id")?.GetValue(task)
?? task?.GetType().GetProperty("id")?.GetValue(task, null);
object asset = list[i];
object idObj = asset?.GetType().GetField("id")?.GetValue(asset)
?? asset?.GetType().GetProperty("id")?.GetValue(asset, null);
string id = idObj as string;
if (!string.IsNullOrEmpty(id))
{

View file

@ -18,6 +18,7 @@ public sealed class ActivityHappinessAuditResult
public int OrphanAuthored;
public int MissingPolicy;
public int RequiredContextFailures;
public int MissingEventStrength;
}
/// <summary>
@ -29,7 +30,7 @@ public static class ActivityHappinessHarness
{
List<string> liveIds = ActivityAssetCatalog.EnumerateLiveHappinessIds();
var authored = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (string id in HappinessEventCatalog.AuthoredIds)
foreach (string id in EventCatalog.Happiness.AuthoredIds)
{
authored.Add(id);
}
@ -41,9 +42,10 @@ public static class ActivityHappinessHarness
int orphanAuthored = 0;
int missingPolicy = 0;
int requiredContextFailures = 0;
int missingEventStrength = 0;
var tsv = new StringBuilder();
tsv.AppendLine(
"id\tvalue\tpsychopath\tpresentation\tlife\tcontext\toverlap\tclause\tauthored\ticon\tlocale_leak\tnotes");
"id\tvalue\tpsychopath\tpresentation\tlife\tcontext\toverlap\tevent_strength\tclause\tauthored\ticon\tlocale_leak\tnotes");
var liveSet = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
for (int i = 0; i < liveIds.Count; i++)
@ -51,13 +53,18 @@ public static class ActivityHappinessHarness
string id = liveIds[i];
liveSet.Add(id);
HappinessAsset asset = ActivityAssetCatalog.TryGetHappinessAsset(id);
bool hasAuthored = HappinessEventCatalog.HasAuthored(id);
HappinessCatalogEntry entry = HappinessEventCatalog.GetOrFallback(id);
bool hasAuthored = EventCatalog.Happiness.HasAuthored(id);
HappinessCatalogEntry entry = EventCatalog.Happiness.GetOrFallback(id);
if (!hasAuthored || entry.IsFallback)
{
missingAuthored++;
}
if (hasAuthored && entry.EventStrength <= 0f)
{
missingEventStrength++;
}
string clauseWith = ActivityHappinessProse.Clause(entry, "Ava", out bool usedRelated, out _);
string clauseWithout = ActivityHappinessProse.Clause(entry, "", out _, out _);
if (string.IsNullOrEmpty(clauseWith) || string.IsNullOrEmpty(clauseWithout))
@ -126,6 +133,10 @@ public static class ActivityHappinessHarness
{
notes = "missing_authored";
}
else if (entry.EventStrength <= 0f)
{
notes = "missing_event_strength";
}
else if (!iconOk)
{
notes = "missing_icon";
@ -151,6 +162,7 @@ public static class ActivityHappinessHarness
.Append(entry.Life).Append('\t')
.Append(entry.Context).Append('\t')
.Append(entry.Overlap).Append('\t')
.Append(entry.EventStrength.ToString("0.#")).Append('\t')
.Append(Escape(clauseWith)).Append('\t')
.Append(hasAuthored ? "1" : "0").Append('\t')
.Append(iconOk ? "1" : "0").Append('\t')
@ -164,7 +176,7 @@ public static class ActivityHappinessHarness
{
orphanAuthored++;
tsv.Append(id).Append('\t')
.Append("\t\t\t\t\t\t\t1\t0\t0\torphan_authored").AppendLine();
.Append("\t\t\t\t\t\t\t0\t\t1\t0\t0\torphan_authored").AppendLine();
}
}
@ -189,7 +201,8 @@ public static class ActivityHappinessHarness
&& emptyPhrase == 0
&& orphanAuthored == 0
&& missingPolicy == 0
&& requiredContextFailures == 0;
&& requiredContextFailures == 0
&& missingEventStrength == 0;
return new ActivityHappinessAuditResult
{
@ -202,10 +215,12 @@ public static class ActivityHappinessHarness
OrphanAuthored = orphanAuthored,
MissingPolicy = missingPolicy,
RequiredContextFailures = requiredContextFailures,
MissingEventStrength = missingEventStrength,
Detail =
$"total={liveIds.Count} missingAuthored={missingAuthored} missingIcon={missingIcon} "
+ $"localeLeaks={localeLeaks} emptyPhrase={emptyPhrase} orphanAuthored={orphanAuthored} "
+ $"missingPolicy={missingPolicy} requiredContextFailures={requiredContextFailures}"
+ $"missingPolicy={missingPolicy} requiredContextFailures={requiredContextFailures} "
+ $"missingEventStrength={missingEventStrength}"
};
}

View file

@ -36,7 +36,7 @@ public static class ActivityHappinessProse
out bool authored)
{
usedRelated = false;
authored = entry != null && !entry.IsFallback && HappinessEventCatalog.HasAuthored(entry.Id);
authored = entry != null && !entry.IsFallback && EventCatalog.Happiness.HasAuthored(entry.Id);
if (entry == null)
{
return "feels a change in happiness";

View file

@ -750,7 +750,7 @@ public static class ActivityLog
if (published != null && !string.IsNullOrEmpty(relatedName))
{
published.RelatedName = relatedName;
HappinessCatalogEntry entry = HappinessEventCatalog.GetOrFallback(effectId);
HappinessCatalogEntry entry = EventCatalog.Happiness.GetOrFallback(effectId);
string clause = ActivityHappinessProse.Clause(entry, relatedName, out bool usedRelated, out _);
published.PlainClause = clause;
published.RichClause = usedRelated
@ -770,7 +770,7 @@ public static class ActivityLog
return published != null;
}
HappinessCatalogEntry fallbackEntry = HappinessEventCatalog.GetOrFallback(effectId);
HappinessCatalogEntry fallbackEntry = EventCatalog.Happiness.GetOrFallback(effectId);
string fallbackClause = ActivityHappinessProse.Clause(
fallbackEntry, relatedName, out bool usedRel, out _);
string rich = usedRel && !string.IsNullOrEmpty(relatedName)

View file

@ -16,10 +16,12 @@ public sealed class ActivityStatusAuditResult
public int LocaleLeaks;
public int EmptyPhrase;
public int OrphanAuthored;
public int MissingInterest;
public int OrphanInterest;
}
/// <summary>
/// Exhaustive live-library audit for status Activity prose and icons.
/// Exhaustive live-library audit for status Activity prose, icons, and interest policy.
/// </summary>
public static class ActivityStatusHarness
{
@ -37,11 +39,19 @@ public static class ActivityStatusHarness
int localeLeaks = 0;
int emptyPhrase = 0;
int orphanAuthored = 0;
int missingInterest = 0;
int orphanInterest = 0;
var tsv = new StringBuilder();
tsv.AppendLine(
"id\tgain\tloss\tauthored\ticon\tlocale_leak\tduration\tallow_timer_reset\tnotes");
"id\tgain\tloss\tauthored\tinterest\tevent_strength\tcreates_interest\ticon\tlocale_leak\tduration\tallow_timer_reset\tnotes");
var liveSet = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var interestAuthored = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (string id in EventCatalog.Status.AuthoredIds)
{
interestAuthored.Add(id);
}
for (int i = 0; i < liveIds.Count; i++)
{
string id = liveIds[i];
@ -55,6 +65,13 @@ public static class ActivityStatusHarness
missingAuthored++;
}
StatusInterestEntry interest = EventCatalog.Status.GetOrFallback(id);
bool hasInterest = EventCatalog.Status.HasAuthored(id) && !interest.IsFallback;
if (!hasInterest)
{
missingInterest++;
}
if (string.IsNullOrEmpty(gain) || string.IsNullOrEmpty(loss))
{
emptyPhrase++;
@ -90,11 +107,15 @@ public static class ActivityStatusHarness
// ignore
}
string notes = "";
string notes = "ok";
if (!hasAuthored)
{
notes = "missing_authored";
}
else if (!hasInterest)
{
notes = "missing_interest";
}
else if (!iconOk)
{
notes = "missing_icon";
@ -107,22 +128,20 @@ public static class ActivityStatusHarness
{
notes = "empty_phrase";
}
else
{
notes = "ok";
}
tsv.Append(id).Append('\t')
.Append(Escape(gain)).Append('\t')
.Append(Escape(loss)).Append('\t')
.Append(hasAuthored ? "1" : "0").Append('\t')
.Append(hasInterest ? "1" : "0").Append('\t')
.Append(interest.EventStrength.ToString("0.#")).Append('\t')
.Append(interest.CreatesInterest ? "1" : "0").Append('\t')
.Append(iconOk ? "1" : "0").Append('\t')
.Append(leak ? "1" : "0").Append('\t')
.Append(duration).Append('\t')
.Append(allowReset).Append('\t')
.Append(notes).AppendLine();
// Smoke the milestone formatting + keys.
string gainKey = ActivityStatusProse.TaskKey(id, true);
string lossKey = ActivityStatusProse.TaskKey(id, false);
if (string.IsNullOrEmpty(gainKey) || string.IsNullOrEmpty(lossKey))
@ -136,9 +155,16 @@ public static class ActivityStatusHarness
if (!liveSet.Contains(id))
{
orphanAuthored++;
tsv.Append(id).Append('\t')
.Append('\t').Append('\t')
.Append("1\t0\t0\t\t\torphan_authored").AppendLine();
tsv.Append(id).Append("\t\t\t1\t0\t0\t0\t0\t0\t\t\torphan_authored").AppendLine();
}
}
foreach (string id in interestAuthored)
{
if (!liveSet.Contains(id))
{
orphanInterest++;
tsv.Append(id).Append("\t\t\t0\t1\t0\t0\t0\t0\t\t\torphan_interest").AppendLine();
}
}
@ -163,7 +189,9 @@ public static class ActivityStatusHarness
&& missingIcon == 0
&& localeLeaks == 0
&& emptyPhrase == 0
&& orphanAuthored == 0;
&& orphanAuthored == 0
&& missingInterest == 0
&& orphanInterest == 0;
return new ActivityStatusAuditResult
{
@ -174,10 +202,12 @@ public static class ActivityStatusHarness
LocaleLeaks = localeLeaks,
EmptyPhrase = emptyPhrase,
OrphanAuthored = orphanAuthored,
MissingInterest = missingInterest,
OrphanInterest = orphanInterest,
Detail =
$"total={liveIds.Count} missingAuthored={missingAuthored} missingIcon={missingIcon} "
+ $"localeLeaks={localeLeaks} emptyPhrase={emptyPhrase} orphanAuthored={orphanAuthored} "
+ $"passed={passed}"
+ $"missingInterest={missingInterest} orphanInterest={orphanInterest} passed={passed}"
};
}

View file

@ -26,17 +26,17 @@ public static class ActivityStatusProse
["dash"] = ("Dashes forward", "Ends the dash"),
["dodge"] = ("Dodges aside", "Stops dodging"),
["drowning"] = ("Starts drowning", "Gets air again"),
["egg"] = ("Turns into an egg", "Hatches from the egg"),
["egg"] = ("Becomes an egg", "Hatches from an egg"),
["enchanted"] = ("Becomes enchanted", "Loses the enchantment"),
["fell_in_love"] = ("Falls in love", "Lets the infatuation pass"),
["festive_spirit"] = ("Catches the festive spirit", "Settles out of the festive mood"),
["flicked"] = ("Gets flicked", "Recovers from the flick"),
["frozen"] = ("Freezes solid", "Thaws out"),
["had_bad_dream"] = ("Wakes from a bad dream", "Shakes off the bad dream"),
["had_good_dream"] = ("Wakes from a good dream", "Lets the good dream fade"),
["had_nightmare"] = ("Wakes from a nightmare", "Shakes off the nightmare"),
["had_bad_dream"] = ("Has a bad dream", "Lets a bad dream fade"),
["had_good_dream"] = ("Has a good dream", "Lets a good dream fade"),
["had_nightmare"] = ("Has a nightmare", "Lets a nightmare fade"),
["handsome_migrant"] = ("Turns heads as a handsome migrant", "Stops drawing migrant attention"),
["inspired"] = ("Feels inspired", "Loses the spark of inspiration"),
["inspired"] = ("Feels inspired", "Comes down from inspiration"),
["invincible"] = ("Becomes invincible", "Loses invincibility"),
["just_ate"] = ("Just finished eating", "Is ready to eat again"),
["laughing"] = ("Bursts out laughing", "Stops laughing"),

View file

@ -0,0 +1,368 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Reflection;
namespace IdleSpectator;
/// <summary>
/// Live-game relation resolver. Labels that claim a second party must resolve through here.
/// </summary>
public static class ActorRelation
{
public static Actor GetLover(Actor actor)
{
if (actor == null)
{
return null;
}
try
{
if (!actor.hasLover())
{
return null;
}
}
catch
{
// fall through to field/property
}
try
{
Actor lover = null;
try
{
lover = actor.lover;
}
catch
{
lover = null;
}
if (lover == null)
{
lover = ReadActorMember(actor, "lover", "get_lover", "getLover");
}
if (lover != null && lover.isAlive() && lover != actor)
{
return lover;
}
}
catch
{
// ignore
}
return null;
}
public static bool TryGetLover(Actor actor, out Actor lover)
{
lover = GetLover(actor);
return lover != null;
}
public static Actor GetBestFriend(Actor actor)
{
if (actor == null)
{
return null;
}
try
{
long id = 0;
object data = actor.data;
if (data != null)
{
object raw = data.GetType().GetProperty("best_friend_id")?.GetValue(data, null)
?? data.GetType().GetField("best_friend_id")?.GetValue(data);
if (raw is long l)
{
id = l;
}
else if (raw is int i)
{
id = i;
}
}
if (id == 0)
{
return null;
}
return FindUnitById(id);
}
catch
{
return null;
}
}
/// <summary>First living peer in the same family (not self).</summary>
public static Actor FirstFamilyPeer(Actor actor)
{
foreach (Actor peer in EnumerateFamilyMembers(actor, max: 8))
{
if (peer != null && peer != actor && peer.isAlive())
{
return peer;
}
}
return null;
}
public static IEnumerable<Actor> EnumerateFamilyMembers(Actor actor, int max = 12)
{
if (actor == null || max <= 0)
{
yield break;
}
object family = ReadMember(actor, "family", "get_family", "getFamily");
if (family == null)
{
yield break;
}
int yielded = 0;
foreach (Actor member in EnumerateActorsFromMeta(family))
{
if (member == null || !member.isAlive())
{
continue;
}
yield return member;
yielded++;
if (yielded >= max)
{
yield break;
}
}
}
public static IEnumerable<Actor> EnumerateChildren(Actor actor, int max = 12)
{
if (actor == null || max <= 0)
{
yield break;
}
IEnumerable children = null;
try
{
MethodInfo getChildren = typeof(Actor).GetMethod("getChildren", Type.EmptyTypes);
if (getChildren != null)
{
children = getChildren.Invoke(actor, null) as IEnumerable;
}
}
catch
{
children = null;
}
if (children == null)
{
yield break;
}
int yielded = 0;
foreach (object raw in children)
{
Actor child = raw as Actor ?? ResolveActor(raw);
if (child == null || !child.isAlive())
{
continue;
}
yield return child;
yielded++;
if (yielded >= max)
{
yield break;
}
}
}
/// <summary>
/// Resolve a related unit for pack/family labels. Prefers lover, then family peer, then child.
/// </summary>
public static Actor ResolvePackRelated(Actor subject)
{
if (subject == null)
{
return null;
}
Actor lover = GetLover(subject);
if (lover != null)
{
return lover;
}
Actor peer = FirstFamilyPeer(subject);
if (peer != null)
{
return peer;
}
foreach (Actor child in EnumerateChildren(subject, max: 1))
{
return child;
}
return null;
}
public static Actor ResolveActor(object raw)
{
if (raw == null)
{
return null;
}
if (raw is Actor actor)
{
return actor;
}
try
{
if (raw is BaseSimObject sim && sim.isActor())
{
return sim.a;
}
}
catch
{
// fall through
}
return ReadActorMember(raw, "actor", "Actor", "a", "getActor", "GetActor");
}
private static IEnumerable<Actor> EnumerateActorsFromMeta(object meta)
{
if (meta == null)
{
yield break;
}
// Common: units / members / getUnits / getSimpleList
object list = ReadMember(meta, "units", "members", "getUnits", "getSimpleList", "getList");
if (list is IEnumerable enumerable)
{
foreach (object raw in enumerable)
{
Actor a = ResolveActor(raw);
if (a != null)
{
yield return a;
}
}
yield break;
}
Actor founder = ReadActorMember(meta, "founder", "creator", "leader", "getFounder");
if (founder != null)
{
yield return founder;
}
}
private static Actor FindUnitById(long id)
{
if (id == 0 || World.world?.units == null)
{
return null;
}
try
{
foreach (Actor unit in World.world.units)
{
if (unit == null)
{
continue;
}
try
{
if (unit.getID() == id && unit.isAlive())
{
return unit;
}
}
catch
{
// skip
}
}
}
catch
{
// ignore
}
return null;
}
private static Actor ReadActorMember(object target, params string[] names)
{
object raw = ReadMember(target, names);
return raw as Actor;
}
private static object ReadMember(object target, params string[] names)
{
if (target == null || names == null)
{
return null;
}
Type type = target as Type ?? target.GetType();
object instance = target is Type ? null : target;
foreach (string name in names)
{
if (string.IsNullOrEmpty(name))
{
continue;
}
try
{
PropertyInfo prop = type.GetProperty(name, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
if (prop != null)
{
return prop.GetValue(instance, null);
}
FieldInfo field = type.GetField(name, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
if (field != null)
{
return field.GetValue(instance);
}
MethodInfo method = type.GetMethod(name, Type.EmptyTypes);
if (method != null)
{
return method.Invoke(instance, null);
}
}
catch
{
// try next
}
}
return null;
}
}

View file

@ -648,6 +648,39 @@ public static class AgentHarness
break;
}
case "hatch_emit":
{
Actor focus = ResolveUnit(
string.IsNullOrEmpty(cmd.asset) || cmd.asset == "auto" ? null : cmd.asset);
if (focus == null)
{
focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
}
bool alive = false;
try
{
alive = focus != null && focus.isAlive();
}
catch
{
alive = false;
}
if (alive)
{
InterestFeeds.EmitHatch(focus, "harness");
_cmdOk++;
}
else
{
_cmdFail++;
}
Emit(cmd, alive, detail: alive ? EventReason.Hatch(focus) : "no unit");
break;
}
case "status_clear_all":
{
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
@ -1797,6 +1830,14 @@ public static class AgentHarness
DoTriggerInterest(cmd);
break;
case "world_log_feed":
DoWorldLogFeed(cmd);
break;
case "domain_feed":
DoDomainFeed(cmd);
break;
case "interest_inject":
DoInterestInject(cmd);
break;
@ -1877,11 +1918,17 @@ public static class AgentHarness
{
float ago = ParseFloat(cmd.value, 1f);
InterestDirector.HarnessMarkInactive(ago);
// Rebuild dossier so orange reason clears under quiet_grace / inactive dwell.
if (MoveCamera.hasFocusUnit() && MoveCamera._focus_unit != null)
{
WatchCaption.SetFromActor(MoveCamera._focus_unit);
}
_cmdOk++;
Emit(
cmd,
ok: true,
detail: $"inactive_for={ago:0.##}s active={InterestDirector.CurrentIsActive} key={InterestDirector.CurrentKey}");
detail: $"inactive_for={ago:0.##}s active={InterestDirector.CurrentIsActive} key={InterestDirector.CurrentKey} reason='{WatchCaption.Current?.ReasonLine}'");
break;
}
@ -2418,33 +2465,428 @@ public static class AgentHarness
AssetId = follow.asset != null ? follow.asset.id : assetId
};
bool forceCamera = tierLead == InterestLeadKind.EventLed
&& tierEvt >= InterestScoringConfig.W.noticeScoreMin;
bool eventLedNotice = tierLead == InterestLeadKind.EventLed
&& tierEvt >= InterestScoringConfig.W.noticeScoreMin;
InterestCandidate registered = InterestFeeds.RegisterHarness(
follow,
interest.Label,
keySuffix: (interest.Label ?? "scene").Replace(" ", "_"),
lead: tierLead,
completion: InterestCompletionKind.FixedDwell,
forceActive: forceCamera,
forceActive: false,
eventStrength: tierEvt,
maxWatch: tierEvt >= 95f ? 40f : 25f);
if (registered == null)
{
InterestCollector.EnqueueDirect(interest);
}
else if (forceCamera)
else if (eventLedNotice)
{
// Event-led Action/Epic tips may take the camera; character-led Story/curiosity stay pending.
InterestDirector.HarnessForceSession(registered);
CameraDirector.Watch(registered.ToInterestEvent());
// Only force the camera when score-margin cut-in would allow it (matches director policy).
InterestScoring.ScoreCheap(registered);
float cur = InterestDirector.CurrentScore;
bool noCurrent = InterestDirector.CurrentCandidate == null;
bool marginCut = registered.TotalScore >= cur + InterestScoringConfig.W.cutInMargin;
if (noCurrent || marginCut)
{
InterestDirector.HarnessForceSession(registered);
CameraDirector.Watch(registered.ToInterestEvent());
}
}
_cmdOk++;
Emit(
cmd,
ok: true,
detail: $"triggered label={tipLabel} evt={tierEvt:0.#} forced={forceCamera}");
detail: $"triggered label={tipLabel} evt={tierEvt:0.#} forced={InterestDirector.CurrentKey != null && InterestDirector.CurrentKey.IndexOf((interest.Label ?? "").Replace(" ", "_"), System.StringComparison.OrdinalIgnoreCase) >= 0}");
}
/// <summary>
/// Feed a live WorldLog asset id through InterestFeeds as if WorldLog fired (coverage of authored catalog).
/// </summary>
private static void DoWorldLogFeed(HarnessCommand cmd)
{
if (!WorldReady())
{
_cmdFail++;
Emit(cmd, ok: false, detail: "world_not_ready");
return;
}
string assetId = string.IsNullOrEmpty(cmd.asset) ? (cmd.value ?? "") : cmd.asset;
if (string.IsNullOrEmpty(assetId) || assetId == "auto")
{
assetId = "disaster_tornado";
}
if (!EventCatalog.WorldLog.HasAuthored(assetId))
{
_cmdFail++;
Emit(cmd, ok: false, detail: "not_authored:" + assetId);
return;
}
WorldLogEventEntry entry = EventCatalog.WorldLog.GetOrFallback(assetId);
Actor follow = ResolveUnit(null) ?? WorldActivityScanner.FindNearestAliveUnit(CameraPos(), 200f);
if (follow == null && entry.CreatesInterest)
{
_cmdFail++;
Emit(cmd, ok: false, detail: "no unit for world_log_feed");
return;
}
if (!entry.CreatesInterest)
{
_cmdOk++;
Emit(cmd, ok: true, detail: $"skipped_no_interest id={assetId}");
return;
}
string kingdom = follow.kingdom != null ? (follow.kingdom.name ?? "Alpha") : "Alpha";
string key = "worldlog:" + assetId + ":" + kingdom;
var candidate = new InterestCandidate
{
Key = key,
LeadKind = InterestLeadKind.EventLed,
Category = entry.Category,
Source = "worldlog",
EventStrength = entry.EventStrength,
VisualConfidence = 0.7f,
Position = follow.current_position,
FollowUnit = follow,
SubjectId = follow.getID(),
Label = entry.MakeLabel(kingdom, "Beta"),
AssetId = assetId,
KingdomKey = kingdom,
SpeciesId = follow.asset != null ? follow.asset.id : "",
CreatedAt = Time.unscaledTime,
LastSeenAt = Time.unscaledTime,
ExpiresAt = Time.unscaledTime + 40f,
MinWatch = entry.MinWatch,
MaxWatch = entry.MaxWatch,
Completion = InterestCompletionKind.FixedDwell
};
InterestScoring.ScoreCheap(candidate);
EventFeedUtil.RegisterCandidate(candidate);
_cmdOk++;
Emit(cmd, ok: true, detail: $"fed id={assetId} key={key} evt={entry.EventStrength:0.#}");
}
/// <summary>
/// Injects a Signal from a domain catalog while the harness is Busy
/// (domain feeds themselves early-out on Busy, so register here).
/// asset = domain, value = event/asset id.
/// </summary>
private static void DoDomainFeed(HarnessCommand cmd)
{
if (!WorldReady())
{
_cmdFail++;
Emit(cmd, ok: false, detail: "world_not_ready");
return;
}
string domain = (cmd.asset ?? "").Trim().ToLowerInvariant();
string id = (cmd.value ?? cmd.expect ?? "").Trim();
Actor unit = ResolveUnit(null) ?? WorldActivityScanner.FindNearestAliveUnit(CameraPos(), 200f);
if (unit == null && domain != "era")
{
_cmdFail++;
Emit(cmd, ok: false, detail: "no unit for domain_feed");
return;
}
try
{
DiscreteEventEntry entry;
string key;
string label;
float strength;
string category;
string source;
switch (domain)
{
case "relationship":
case "rel":
if (string.IsNullOrEmpty(id))
{
id = "set_lover";
}
entry = EventCatalog.Relationship.GetOrFallback(id);
Actor related = ActorRelation.ResolvePackRelated(unit)
?? WorldActivityScanner.FindNearestAliveUnit(unit.current_position, 40f);
if (related == unit)
{
related = null;
}
key = "rel:" + entry.Id + ":" + EventFeedUtil.SafeId(unit) + ":"
+ EventFeedUtil.SafeId(related);
label = entry.MakeLabel(
EventFeedUtil.SafeName(unit),
EventFeedUtil.SafeName(related));
strength = entry.EventStrength;
category = entry.Category;
source = "relationship";
InterestCandidate relReg = EventFeedUtil.Register(
key,
source,
category,
label,
id,
strength,
unit.current_position,
unit,
related: related);
if (relReg == null)
{
_cmdFail++;
Emit(cmd, ok: false, detail: "register_failed domain=relationship id=" + id);
return;
}
_cmdOk++;
Emit(cmd, ok: true, detail: $"domain=relationship id={id} key={key}");
return;
case "plot":
if (string.IsNullOrEmpty(id))
{
id = "new_war";
}
entry = EventCatalog.Plot.GetOrFallback(id);
key = "plot:" + entry.Id + ":new:" + EventFeedUtil.SafeId(unit);
label = entry.MakeLabel(EventFeedUtil.SafeName(unit), "new");
strength = entry.EventStrength;
category = entry.Category;
source = "plot";
break;
case "era":
if (string.IsNullOrEmpty(id))
{
id = "age_chaos";
}
entry = EventCatalog.Era.GetOrFallback(id);
key = "era:" + entry.Id;
label = entry.MakeLabel("", "");
strength = entry.EventStrength;
category = entry.Category;
source = "era";
Vector3 eraPos = unit != null
? unit.current_position
: (Camera.main != null
? new Vector3(Camera.main.transform.position.x, Camera.main.transform.position.y, 0f)
: new Vector3(1f, 1f, 0f));
InterestCandidate eraReg = EventFeedUtil.Register(
key,
source,
category,
label,
id,
strength,
eraPos,
follow: unit,
locationOnly: unit == null);
if (eraReg == null)
{
_cmdFail++;
Emit(cmd, ok: false, detail: "register_failed domain=era id=" + id);
return;
}
_cmdOk++;
Emit(cmd, ok: true, detail: $"domain=era id={id} key={key}");
return;
case "book":
if (string.IsNullOrEmpty(id))
{
id = "history_book";
}
entry = EventCatalog.Book.GetOrFallback(id);
key = "book:" + entry.Id + ":new:" + EventFeedUtil.SafeId(unit);
label = entry.MakeLabel(EventFeedUtil.SafeName(unit), "new");
strength = entry.EventStrength;
category = entry.Category;
source = "book";
break;
case "trait":
if (string.IsNullOrEmpty(id))
{
id = "immortal";
}
entry = EventCatalog.Trait.GetOrFallback(id);
key = "trait:" + entry.Id + ":gains:" + EventFeedUtil.SafeId(unit);
label = EventFeedUtil.SafeName(unit) + " gains " + entry.Id;
strength = entry.EventStrength;
category = entry.Category;
source = "trait";
break;
case "meta":
if (string.IsNullOrEmpty(id))
{
id = "new_city";
}
key = "meta:" + id + ":" + EventFeedUtil.SafeId(unit);
label = "Meta: " + id + " (" + EventFeedUtil.SafeName(unit) + ")";
strength = 74f;
category = "Politics";
source = "meta";
break;
case "boat":
id = "boat_unload";
key = "boat:boat_unload:" + EventFeedUtil.SafeId(unit);
label = "Boat unloads: " + EventFeedUtil.SafeName(unit);
strength = 72f;
category = "Travel";
source = "boat";
break;
case "building":
id = "building_damage";
key = "building:building_damage:" + EventFeedUtil.SafeId(unit);
label = "Damages a building: " + EventFeedUtil.SafeName(unit);
strength = 70f;
category = "Spectacle";
source = "building";
break;
case "spell":
if (string.IsNullOrEmpty(id))
{
id = "summon_lightning";
}
{
DiscreteEventEntry spellEntry = EventCatalog.Spell.GetOrFallback(id);
key = "spell:" + spellEntry.Id + ":" + EventFeedUtil.SafeId(unit);
label = spellEntry.MakeLabel(EventFeedUtil.SafeName(unit), "");
strength = spellEntry.EventStrength;
category = spellEntry.Category;
source = "spell";
id = spellEntry.Id;
}
break;
case "item":
if (string.IsNullOrEmpty(id))
{
id = "sword";
}
{
DiscreteEventEntry itemEntry = EventCatalog.Item.GetOrFallback(id);
key = "item:" + itemEntry.Id + ":" + EventFeedUtil.SafeId(unit);
label = itemEntry.MakeLabel(EventFeedUtil.SafeName(unit), "");
strength = itemEntry.EventStrength;
category = itemEntry.Category;
source = "item";
id = itemEntry.Id;
}
break;
case "decision":
if (string.IsNullOrEmpty(id))
{
id = "declare_war";
}
{
DiscreteEventEntry decEntry = EventCatalog.Decision.GetOrFallback(id);
key = "decision:" + decEntry.Id + ":" + EventFeedUtil.SafeId(unit);
label = decEntry.MakeLabel(EventFeedUtil.SafeName(unit), "");
strength = decEntry.EventStrength;
category = decEntry.Category;
source = "decision";
id = decEntry.Id;
}
break;
case "power":
if (string.IsNullOrEmpty(id))
{
id = "lightning";
}
{
DiscreteEventEntry powEntry = EventCatalog.Power.GetOrFallback(id);
key = "power:" + powEntry.Id + ":" + EventFeedUtil.SafeId(unit);
label = powEntry.MakeLabel(EventFeedUtil.SafeName(unit), "");
strength = powEntry.EventStrength;
category = powEntry.Category;
source = "power";
id = powEntry.Id;
}
break;
case "subspecies_trait":
case "subspecies":
if (string.IsNullOrEmpty(id))
{
id = "biome_trait";
}
{
DiscreteEventEntry stEntry = EventCatalog.SubspeciesTrait.GetOrFallback(id);
key = "subspecies_trait:" + stEntry.Id + ":" + EventFeedUtil.SafeId(unit);
label = stEntry.MakeLabel(EventFeedUtil.SafeName(unit), "");
strength = stEntry.EventStrength;
category = stEntry.Category;
source = "subspecies_trait";
id = stEntry.Id;
}
break;
case "gene":
if (string.IsNullOrEmpty(id))
{
id = "warfare_1";
}
{
DiscreteEventEntry geneEntry = EventCatalog.Gene.GetOrFallback(id);
key = "gene:" + geneEntry.Id + ":" + EventFeedUtil.SafeId(unit);
label = geneEntry.MakeLabel(EventFeedUtil.SafeName(unit), "");
strength = geneEntry.EventStrength;
category = geneEntry.Category;
source = "gene";
id = geneEntry.Id;
}
break;
default:
_cmdFail++;
Emit(cmd, ok: false, detail: "unknown domain=" + domain);
return;
}
InterestCandidate registered = EventFeedUtil.Register(
key,
source,
category,
label,
id,
strength,
unit.current_position,
unit);
if (registered == null)
{
_cmdFail++;
Emit(cmd, ok: false, detail: "register_failed domain=" + domain + " id=" + id);
return;
}
_cmdOk++;
Emit(cmd, ok: true, detail: $"domain={domain} id={id} key={key}");
}
catch (System.Exception ex)
{
_cmdFail++;
Emit(cmd, ok: false, detail: "domain_feed error: " + ex.Message);
}
}
private static void DoInterestInject(HarnessCommand cmd)
@ -2691,18 +3133,48 @@ public static class AgentHarness
float tierEvt = EventStrengthFromTierHint(cmd.tier, 70f);
InterestLeadKind tierLead = LeadFromTierHint(cmd.tier, InterestLeadKind.EventLed);
ParseInterestInjectOptions(
cmd.value,
out bool forceIgnored,
out InterestLeadKind? leadOpt,
out float eventStrength,
out float charSigIgnored,
out float maxWatchIgnored,
out float ttlIgnored,
out bool resumableIgnored,
out int participantsIgnored,
out int notablesIgnored);
_ = forceIgnored;
_ = charSigIgnored;
_ = maxWatchIgnored;
_ = ttlIgnored;
_ = resumableIgnored;
_ = participantsIgnored;
_ = notablesIgnored;
InterestLeadKind lead = leadOpt ?? tierLead;
if (eventStrength < 0f)
{
eventStrength = tierEvt;
}
string label = string.IsNullOrEmpty(cmd.label) ? ("Force " + (cmd.tier ?? "scene")) : cmd.label;
string focusName = SafeName(follow);
if (!string.IsNullOrEmpty(focusName))
{
label = label.Replace("{a}", focusName);
}
string keySuffix = string.IsNullOrEmpty(cmd.expect) ? "force_" + label.Replace(" ", "_") : cmd.expect;
InterestCandidate c = InterestFeeds.RegisterHarness(
follow,
label,
keySuffix,
lead: tierLead,
lead: lead,
completion: InterestCompletionKind.Manual,
forceActive: true,
eventStrength: tierEvt > 0f ? tierEvt : 70f,
eventStrength: eventStrength > 0f ? eventStrength : 70f,
maxWatch: 60f);
if (c != null && tierLead == InterestLeadKind.CharacterLed)
if (c != null && lead == InterestLeadKind.CharacterLed)
{
c.Category = "CharacterVignette";
InterestScoring.ScoreCheap(c);
@ -3249,6 +3721,46 @@ public static class AgentHarness
detail = $"needle_absent_in_reason='{needle}' reason='{reason}' hit={reasonHit}";
break;
}
case "reason_owns_focus":
{
// Reason must not name a different live unit than the owned active candidate.
UnitDossier dossier = WatchCaption.Current;
string reason = dossier?.ReasonLine ?? "";
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
InterestCandidate owned = focus != null
? InterestDirector.TryGetOwnedReasonCandidate(focus)
: null;
if (string.IsNullOrEmpty(reason))
{
pass = true;
detail = "empty_reason_ok active=" + InterestDirector.CurrentIsActive;
break;
}
if (owned == null || focus == null)
{
pass = false;
detail = $"reason_without_owned_active focus={(focus != null)} active={InterestDirector.CurrentIsActive} reason='{reason}'";
break;
}
string stranger = FindStrangerNamedInReason(reason, owned);
pass = string.IsNullOrEmpty(stranger);
detail = pass
? $"owned reason='{reason}'"
: $"stranger_in_reason='{stranger}' reason='{reason}'";
break;
}
case "reason_empty":
{
UnitDossier dossier = WatchCaption.Current;
string reason = dossier?.ReasonLine ?? "";
bool wantEmpty = string.IsNullOrEmpty(cmd.value) || ParseBool(cmd.value, true);
bool isEmpty = string.IsNullOrEmpty(reason);
pass = isEmpty == wantEmpty;
detail = $"reason='{reason}' empty={isEmpty} wantEmpty={wantEmpty} active={InterestDirector.CurrentIsActive} quiet={InterestDirector.InQuietGrace}";
break;
}
case "dossier_task_refresh":
{
// Stale focus snapshot must recover: blank dossier TaskText, then sync from live AI.
@ -3970,6 +4482,36 @@ public static class AgentHarness
detail = audit.Detail;
break;
}
case "world_log_audit":
{
WorldLogEventAuditResult audit = WorldLogEventHarness.RunAudit(HarnessDir());
pass = audit.Passed;
detail = audit.Detail;
break;
}
case "disaster_war_inventory":
case "disaster_war_audit":
{
DisasterWarAuditResult audit = DisasterWarHarness.RunAudit(HarnessDir());
WorldLogEventHarness.DumpDisasterWarInventory(HarnessDir());
pass = audit.Passed;
detail = audit.Detail;
break;
}
case "domain_event_audit":
{
DomainEventAuditResult audit = DomainEventHarness.RunAudit(HarnessDir());
pass = audit.Passed;
detail = audit.Detail;
break;
}
case "mutation_discovery":
{
MutationDiscoveryResult discovery = MutationDiscoveryHarness.Run(HarnessDir());
pass = discovery.Passed;
detail = discovery.Detail;
break;
}
case "activity_happiness_count":
{
int want = ParseCountExpect(cmd, defaultValue: 1);
@ -5613,6 +6155,64 @@ public static class AgentHarness
return actor.asset != null ? actor.asset.id : "unit";
}
/// <summary>
/// If the reason line names a living unit that is not the scene subject/related, return that name.
/// </summary>
private static string FindStrangerNamedInReason(string reason, InterestCandidate scene)
{
if (string.IsNullOrEmpty(reason) || scene == null || World.world?.units == null)
{
return "";
}
string subjectName = EventFeedUtil.SafeName(scene.FollowUnit);
string relatedName = EventFeedUtil.SafeName(scene.RelatedUnit);
try
{
foreach (Actor unit in World.world.units)
{
if (unit == null || !unit.isAlive())
{
continue;
}
if (unit == scene.FollowUnit || unit == scene.RelatedUnit)
{
continue;
}
string name = SafeName(unit);
if (string.IsNullOrEmpty(name) || name.Length < 3)
{
continue;
}
if (!string.IsNullOrEmpty(subjectName)
&& name.Equals(subjectName, StringComparison.OrdinalIgnoreCase))
{
continue;
}
if (!string.IsNullOrEmpty(relatedName)
&& name.Equals(relatedName, StringComparison.OrdinalIgnoreCase))
{
continue;
}
if (reason.IndexOf(name, StringComparison.OrdinalIgnoreCase) >= 0)
{
return name;
}
}
}
catch
{
// ignore
}
return "";
}
private static float EventStrengthFromTierHint(string raw, float fallback)
{
@ -5628,11 +6228,13 @@ public static class AgentHarness
case "curiosity":
return 40f;
case "action":
return 70f;
// Clears cutInMargin (35) over enriched Curiosity CharacterLed (~64).
return 100f;
case "story":
return 80f;
case "epic":
return 100f;
// Clears cutInMargin over Action (~108 TotalScore).
return 150f;
default:
return fallback;
}

View file

@ -33,6 +33,7 @@ public static class CameraDirector
}
string tip = FormatWatchTip(interest);
// LastWatchLabel is tip/harness telemetry only - never dossier reason truth.
LastWatchLabel = interest.Label ?? "";
LastWatchAssetId = interest.AssetId ?? "";
LogService.LogInfo($"[IdleSpectator] {tip}");
@ -42,17 +43,13 @@ public static class CameraDirector
? interest.FollowUnit
: null;
// Never attach a random nearby unit to a "new species" tip - that caused ghost focuses
// (e.g. tip says crab, camera follows a cat).
// Species discovery: only attach a species-matched unit, never a random stranger.
if (follow == null && !string.IsNullOrEmpty(interest.AssetId) && interest.AssetId != "live_battle"
&& interest.AssetId != "scored_unit" && interest.Label != null && interest.Label.StartsWith("New species:"))
{
follow = WorldActivityScanner.FindNearestAliveUnit(interest.Position, 24f, interest.AssetId);
}
else if (follow == null)
{
follow = WorldActivityScanner.FindNearestAliveUnit(interest.Position, 24f);
}
// Location-only civic: pan only. Do not invent a stranger FollowUnit.
if (follow != null && follow.isAlive())
{
@ -74,7 +71,7 @@ public static class CameraDirector
return;
}
// No unit available: pan only if we are not already following someone.
// No owned unit: pan only if we are not already following someone.
// Never clear an existing focus unit here - that flashes the power bar.
if (!MoveCamera.hasFocusUnit())
{

View file

@ -0,0 +1,269 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
namespace IdleSpectator;
public sealed class CatalogCoverageDiff
{
public string Domain = "";
public int LiveTotal;
public int AuthoredTotal;
public int Missing;
public int Orphan;
public readonly List<string> MissingIds = new List<string>();
public readonly List<string> OrphanIds = new List<string>();
public bool Passed => Missing == 0 && Orphan == 0;
public string Detail =>
$"{Domain}: live={LiveTotal} authored={AuthoredTotal} missing={Missing} orphan={Orphan}";
}
/// <summary>Shared live-vs-authored coverage audit used by domain event catalogs.</summary>
public static class CatalogCoverageAudit
{
public static CatalogCoverageDiff Diff(
string domain,
IEnumerable<string> liveIds,
IEnumerable<string> authoredIds)
{
var live = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var authored = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var diff = new CatalogCoverageDiff { Domain = domain ?? "domain" };
if (liveIds != null)
{
foreach (string id in liveIds)
{
if (string.IsNullOrEmpty(id))
{
continue;
}
live.Add(id.Trim());
}
}
if (authoredIds != null)
{
foreach (string id in authoredIds)
{
if (string.IsNullOrEmpty(id))
{
continue;
}
authored.Add(id.Trim());
}
}
diff.LiveTotal = live.Count;
diff.AuthoredTotal = authored.Count;
foreach (string id in live)
{
if (!authored.Contains(id))
{
diff.Missing++;
diff.MissingIds.Add(id);
}
}
foreach (string id in authored)
{
if (!live.Contains(id))
{
diff.Orphan++;
diff.OrphanIds.Add(id);
}
}
return diff;
}
public static void WriteReport(string outputDirectory, string fileName, string body)
{
if (string.IsNullOrEmpty(outputDirectory) || string.IsNullOrEmpty(fileName))
{
return;
}
try
{
Directory.CreateDirectory(outputDirectory);
File.WriteAllText(Path.Combine(outputDirectory, fileName), body ?? "");
}
catch
{
// ignore IO
}
}
}
public sealed class DomainEventAuditResult
{
public bool Passed;
public string Detail = "";
}
/// <summary>Coverage audits for relationship discrete events + live plot/era/book libraries.</summary>
public static class DomainEventHarness
{
public static DomainEventAuditResult RunAudit(string outputDirectory)
{
var tsv = new StringBuilder();
tsv.AppendLine("domain\tid\tauthored\towns_camera\tevent_strength\tnotes");
CatalogCoverageDiff plots = CatalogCoverageAudit.Diff(
"plots",
ActivityAssetCatalog.EnumerateLivePlotIds(),
EventCatalog.Plot.AuthoredIds);
AppendLibraryDomain(tsv, "plots", ActivityAssetCatalog.EnumerateLivePlotIds(), plots, EventCatalog.Plot.GetOrFallback);
CatalogCoverageDiff eras = CatalogCoverageAudit.Diff(
"eras",
ActivityAssetCatalog.EnumerateLiveEraIds(),
EventCatalog.Era.AuthoredIds);
AppendLibraryDomain(tsv, "eras", ActivityAssetCatalog.EnumerateLiveEraIds(), eras, EventCatalog.Era.GetOrFallback);
CatalogCoverageDiff books = CatalogCoverageAudit.Diff(
"books",
ActivityAssetCatalog.EnumerateLiveBookTypeIds(),
EventCatalog.Book.AuthoredIds);
AppendLibraryDomain(tsv, "books", ActivityAssetCatalog.EnumerateLiveBookTypeIds(), books, EventCatalog.Book.GetOrFallback);
CatalogCoverageDiff traits = CatalogCoverageAudit.Diff(
"traits",
ActivityAssetCatalog.EnumerateLiveTraitIds(),
EventCatalog.Trait.AuthoredIds);
AppendLibraryDomain(tsv, "traits", ActivityAssetCatalog.EnumerateLiveTraitIds(), traits, EventCatalog.Trait.GetOrFallback);
CatalogCoverageDiff spells = CatalogCoverageAudit.Diff(
"spells",
ActivityAssetCatalog.EnumerateLiveSpellIds(),
EventCatalog.Spell.AuthoredIds);
AppendLibraryDomain(tsv, "spells", ActivityAssetCatalog.EnumerateLiveSpellIds(), spells, EventCatalog.Spell.GetOrFallback);
CatalogCoverageDiff powers = CatalogCoverageAudit.Diff(
"powers",
ActivityAssetCatalog.EnumerateLivePowerIds(),
EventCatalog.Power.AuthoredIds);
AppendLibraryDomain(tsv, "powers", ActivityAssetCatalog.EnumerateLivePowerIds(), powers, EventCatalog.Power.GetOrFallback);
CatalogCoverageDiff decisions = CatalogCoverageAudit.Diff(
"decisions",
ActivityAssetCatalog.EnumerateLiveDecisionIds(),
EventCatalog.Decision.AuthoredIds);
AppendLibraryDomain(tsv, "decisions", ActivityAssetCatalog.EnumerateLiveDecisionIds(), decisions, EventCatalog.Decision.GetOrFallback);
CatalogCoverageDiff items = CatalogCoverageAudit.Diff(
"items",
ActivityAssetCatalog.EnumerateLiveItemIds(),
EventCatalog.Item.AuthoredIds);
AppendLibraryDomain(tsv, "items", ActivityAssetCatalog.EnumerateLiveItemIds(), items, EventCatalog.Item.GetOrFallback);
CatalogCoverageDiff subspTraits = CatalogCoverageAudit.Diff(
"subspecies_traits",
ActivityAssetCatalog.EnumerateLiveSubspeciesTraitIds(),
EventCatalog.SubspeciesTrait.AuthoredIds);
AppendLibraryDomain(
tsv,
"subspecies_traits",
ActivityAssetCatalog.EnumerateLiveSubspeciesTraitIds(),
subspTraits,
EventCatalog.SubspeciesTrait.GetOrFallback);
CatalogCoverageDiff genes = CatalogCoverageAudit.Diff(
"genes",
ActivityAssetCatalog.EnumerateLiveGeneIds(),
EventCatalog.Gene.AuthoredIds);
AppendLibraryDomain(tsv, "genes", ActivityAssetCatalog.EnumerateLiveGeneIds(), genes, EventCatalog.Gene.GetOrFallback);
CatalogCoverageDiff phenotypes = CatalogCoverageAudit.Diff(
"phenotypes",
ActivityAssetCatalog.EnumerateLivePhenotypeIds(),
EventCatalog.Phenotype.AuthoredIds);
AppendLibraryDomain(
tsv,
"phenotypes",
ActivityAssetCatalog.EnumerateLivePhenotypeIds(),
phenotypes,
EventCatalog.Phenotype.GetOrFallback);
CatalogCoverageDiff worldLaws = CatalogCoverageAudit.Diff(
"world_laws",
ActivityAssetCatalog.EnumerateLiveWorldLawIds(),
EventCatalog.WorldLaw.AuthoredIds);
AppendLibraryDomain(
tsv,
"world_laws",
ActivityAssetCatalog.EnumerateLiveWorldLawIds(),
worldLaws,
EventCatalog.WorldLaw.GetOrFallback);
CatalogCoverageDiff biomes = CatalogCoverageAudit.Diff(
"biomes",
ActivityAssetCatalog.EnumerateLiveBiomeIds(),
EventCatalog.Biome.AuthoredIds);
AppendLibraryDomain(tsv, "biomes", ActivityAssetCatalog.EnumerateLiveBiomeIds(), biomes, EventCatalog.Biome.GetOrFallback);
int relMissing = 0;
int relTotal = 0;
foreach (string id in EventCatalog.Relationship.AuthoredIds)
{
relTotal++;
DiscreteEventEntry entry = EventCatalog.Relationship.GetOrFallback(id);
bool ok = EventCatalog.Relationship.HasAuthored(id) && !entry.IsFallback;
if (!ok)
{
relMissing++;
}
tsv.Append("relationship\t").Append(id).Append('\t')
.Append(ok ? "1" : "0").Append('\t')
.Append(entry.CreatesInterest).Append('\t')
.Append(entry.EventStrength.ToString("0.#")).Append('\t')
.Append(ok ? "ok" : "missing_authored").AppendLine();
}
CatalogCoverageAudit.WriteReport(outputDirectory, "domain_event_audit.tsv", tsv.ToString());
bool passed = plots.Passed && eras.Passed && books.Passed && traits.Passed
&& spells.Passed && powers.Passed && decisions.Passed && items.Passed
&& subspTraits.Passed && genes.Passed && phenotypes.Passed
&& worldLaws.Passed && biomes.Passed
&& relMissing == 0 && relTotal > 0;
string detail =
$"{plots.Detail}; {eras.Detail}; {books.Detail}; {traits.Detail}; "
+ $"{spells.Detail}; {powers.Detail}; {decisions.Detail}; {items.Detail}; "
+ $"{subspTraits.Detail}; {genes.Detail}; {phenotypes.Detail}; "
+ $"{worldLaws.Detail}; {biomes.Detail}; relationship: authored={relTotal} missing={relMissing}";
return new DomainEventAuditResult { Passed = passed, Detail = detail };
}
private static void AppendLibraryDomain(
StringBuilder tsv,
string domain,
IEnumerable<string> liveIds,
CatalogCoverageDiff diff,
Func<string, DiscreteEventEntry> lookup)
{
foreach (string id in liveIds)
{
DiscreteEventEntry entry = lookup(id);
bool has = !entry.IsFallback;
tsv.Append(domain).Append('\t').Append(id).Append('\t')
.Append(has ? "1" : "0").Append('\t')
.Append(entry.CreatesInterest).Append('\t')
.Append(entry.EventStrength.ToString("0.#")).Append('\t')
.Append(has ? "ok" : "missing_authored").AppendLine();
}
foreach (string id in diff.OrphanIds)
{
tsv.Append(domain).Append('\t').Append(id).Append("\t1\t\t0\torphan_authored").AppendLine();
}
}
}

View file

@ -0,0 +1,139 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
namespace IdleSpectator;
public sealed class DisasterWarAuditResult
{
public bool Passed;
public string Detail = "";
public int DisasterTotal;
public int WarTypeTotal;
public int MissingDisaster;
public int OrphanDisaster;
public int MissingWarType;
public int OrphanWarType;
}
/// <summary>Live disasters + war_types library coverage audit.</summary>
public static class DisasterWarHarness
{
public static DisasterWarAuditResult RunAudit(string outputDirectory)
{
List<string> disasters = ActivityAssetCatalog.EnumerateLiveDisasterIds();
List<string> warTypes = ActivityAssetCatalog.EnumerateLiveWarTypeIds();
var authoredDisasters = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (string id in EventCatalog.Disaster.AuthoredIds)
{
authoredDisasters.Add(id);
}
var authoredWars = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (string id in EventCatalog.WarType.AuthoredIds)
{
authoredWars.Add(id);
}
int missingDisaster = 0;
int orphanDisaster = 0;
int missingWarType = 0;
int orphanWarType = 0;
var tsv = new StringBuilder();
tsv.AppendLine("kind\tid\tauthored\tevent_strength\tworld_log_id\tnotes");
var liveDisasters = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
for (int i = 0; i < disasters.Count; i++)
{
string id = disasters[i];
liveDisasters.Add(id);
DisasterInterestEntry entry = EventCatalog.Disaster.GetOrFallback(id);
bool has = EventCatalog.Disaster.HasAuthored(id) && !entry.IsFallback;
if (!has)
{
missingDisaster++;
}
tsv.Append("disaster\t").Append(id).Append('\t')
.Append(has ? "1" : "0").Append('\t')
.Append(entry.EventStrength.ToString("0.#")).Append('\t')
.Append(entry.WorldLogId ?? "").Append('\t')
.Append(has ? "ok" : "missing_authored").AppendLine();
}
foreach (string id in authoredDisasters)
{
if (!liveDisasters.Contains(id))
{
orphanDisaster++;
tsv.Append("disaster\t").Append(id).Append("\t1\t0\t\torphan_authored").AppendLine();
}
}
var liveWars = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
for (int i = 0; i < warTypes.Count; i++)
{
string id = warTypes[i];
liveWars.Add(id);
WarTypeInterestEntry entry = EventCatalog.WarType.GetOrFallback(id);
bool has = EventCatalog.WarType.HasAuthored(id) && !entry.IsFallback;
if (!has)
{
missingWarType++;
}
tsv.Append("war_type\t").Append(id).Append('\t')
.Append(has ? "1" : "0").Append('\t')
.Append(entry.EventStrength.ToString("0.#")).Append('\t')
.Append('\t')
.Append(has ? "ok" : "missing_authored").AppendLine();
}
foreach (string id in authoredWars)
{
if (!liveWars.Contains(id))
{
orphanWarType++;
tsv.Append("war_type\t").Append(id).Append("\t1\t0\t\torphan_authored").AppendLine();
}
}
try
{
if (!string.IsNullOrEmpty(outputDirectory))
{
Directory.CreateDirectory(outputDirectory);
File.WriteAllText(
Path.Combine(outputDirectory, "disaster-war-audit.tsv"),
tsv.ToString());
}
}
catch
{
// ignore
}
bool passed = disasters.Count > 0
&& warTypes.Count > 0
&& missingDisaster == 0
&& orphanDisaster == 0
&& missingWarType == 0
&& orphanWarType == 0;
return new DisasterWarAuditResult
{
Passed = passed,
DisasterTotal = disasters.Count,
WarTypeTotal = warTypes.Count,
MissingDisaster = missingDisaster,
OrphanDisaster = orphanDisaster,
MissingWarType = missingWarType,
OrphanWarType = orphanWarType,
Detail =
$"disasters={disasters.Count} missingDisaster={missingDisaster} orphanDisaster={orphanDisaster} "
+ $"war_types={warTypes.Count} missingWarType={missingWarType} orphanWarType={orphanWarType}"
};
}
}

View file

@ -0,0 +1,69 @@
using System;
using System.Collections.Generic;
namespace IdleSpectator;
/// <summary>Authored interest overlay for every live book_types asset.</summary>
public static partial class EventCatalog
{
public static class Book
{
private static readonly Dictionary<string, DiscreteEventEntry> Entries =
new Dictionary<string, DiscreteEventEntry>(StringComparer.OrdinalIgnoreCase);
static Book()
{
Add("family_story", 52f, "Culture", "{a} writes a family story");
Add("love_story", 58f, "Culture", "{a} writes a love story");
Add("friendship_story", 50f, "Culture", "{a} writes a friendship story");
Add("bad_story_about_king", 62f, "Culture", "{a} writes a scandalous book");
Add("fable", 48f, "Culture", "{a} writes a fable");
Add("warfare_manual", 60f, "Culture", "{a} writes a warfare manual");
Add("economy_manual", 48f, "Culture", "{a} writes an economy manual");
Add("stewardship_manual", 48f, "Culture", "{a} writes a stewardship manual");
Add("diplomacy_manual", 55f, "Culture", "{a} writes a diplomacy manual");
Add("mathbook", 45f, "Culture", "{a} writes a math book");
Add("biology_book", 45f, "Culture", "{a} writes a biology book");
Add("history_book", 55f, "Culture", "{a} writes a history book");
}
private static void Add(
string id,
float strength,
string category,
string label)
{
Entries[id] = new DiscreteEventEntry
{
Id = id,
EventStrength = strength,
Category = category,
CreatesInterest = true,
LabelTemplate = label
};
}
public static IEnumerable<string> AuthoredIds => Entries.Keys;
public static bool HasAuthored(string id) =>
!string.IsNullOrEmpty(id) && Entries.ContainsKey(id.Trim());
public static DiscreteEventEntry GetOrFallback(string id)
{
if (!string.IsNullOrEmpty(id) && Entries.TryGetValue(id.Trim(), out DiscreteEventEntry entry))
{
return entry;
}
string key = string.IsNullOrEmpty(id) ? "unknown" : id.Trim();
return new DiscreteEventEntry
{
Id = key,
EventStrength = 45f,
Category = "Culture",
LabelTemplate = "{a} writes a book",
IsFallback = true
};
}
}
}

View file

@ -0,0 +1,94 @@
using System;
using System.Collections.Generic;
namespace IdleSpectator;
public sealed class DisasterInterestEntry
{
public string Id = "";
public float EventStrength = 90f;
public string Category = "Disaster";
public string WorldLogId = "";
public bool CreatesInterest = true;
public bool IsFallback;
}
/// <summary>Authored interest overlay for every live disasters library asset.</summary>
public static partial class EventCatalog
{
public static class Disaster
{
private static readonly Dictionary<string, DisasterInterestEntry> Entries =
new Dictionary<string, DisasterInterestEntry>(StringComparer.OrdinalIgnoreCase);
static Disaster()
{
Add("tornado", 95f, "disaster_tornado");
Add("heatwave", 88f, "disaster_heatwave");
Add("small_meteorite", 95f, "disaster_meteorite");
Add("small_earthquake", 95f, "disaster_earthquake");
Add("hellspawn", 96f, "disaster_hellspawn");
Add("ice_ones_awoken", 95f, "disaster_ice_ones");
Add("sudden_snowman", 90f, "disaster_sudden_snowman");
Add("garden_surprise", 90f, "disaster_garden_surprise");
Add("dragon_from_farlands", 98f, "disaster_dragon_from_farlands");
Add("ash_bandits", 92f, "disaster_bandits");
Add("alien_invasion", 97f, "disaster_alien_invasion");
Add("biomass", 96f, "disaster_biomass");
Add("tumor", 96f, "disaster_tumor");
Add("wild_mage", 94f, "disaster_evil_mage");
Add("underground_necromancer", 95f, "disaster_underground_necromancer");
Add("mad_thoughts", 90f, "disaster_mad_thoughts");
Add("greg_abominations", 96f, "disaster_greg_abominations");
}
private static void Add(string id, float strength, string worldLogId)
{
Entries[id] = new DisasterInterestEntry
{
Id = id,
EventStrength = strength,
WorldLogId = worldLogId,
CreatesInterest = true,
Category = "Disaster"
};
}
public static IEnumerable<string> AuthoredIds => Entries.Keys;
public static bool HasAuthored(string disasterId)
{
return !string.IsNullOrEmpty(disasterId) && Entries.ContainsKey(disasterId.Trim());
}
public static bool TryGet(string disasterId, out DisasterInterestEntry entry)
{
entry = null;
if (string.IsNullOrEmpty(disasterId))
{
return false;
}
return Entries.TryGetValue(disasterId.Trim(), out entry);
}
public static DisasterInterestEntry GetOrFallback(string disasterId)
{
if (TryGet(disasterId, out DisasterInterestEntry entry))
{
return entry;
}
string id = string.IsNullOrEmpty(disasterId) ? "unknown" : disasterId.Trim();
return new DisasterInterestEntry
{
Id = id,
EventStrength = 90f,
Category = "Disaster",
WorldLogId = "",
CreatesInterest = true,
IsFallback = true
};
}
}
}

View file

@ -0,0 +1,68 @@
using System;
using System.Collections.Generic;
namespace IdleSpectator;
/// <summary>Authored interest overlay for every live era_library asset.</summary>
public static partial class EventCatalog
{
public static class Era
{
private static readonly Dictionary<string, DiscreteEventEntry> Entries =
new Dictionary<string, DiscreteEventEntry>(StringComparer.OrdinalIgnoreCase);
static Era()
{
Add("age_hope", 78f, "World", "Age of Hope");
Add("age_sun", 80f, "World", "Age of Sun");
Add("age_dark", 88f, "World", "Age of Dark");
Add("age_tears", 86f, "World", "Age of Tears");
Add("age_moon", 82f, "World", "Age of Moon");
Add("age_chaos", 92f, "World", "Age of Chaos");
Add("age_wonders", 84f, "World", "Age of Wonders");
Add("age_ice", 87f, "World", "Age of Ice");
Add("age_ash", 90f, "World", "Age of Ash");
Add("age_despair", 91f, "World", "Age of Despair");
Add("age_unknown", 70f, "World", "Unknown age");
}
private static void Add(
string id,
float strength,
string category,
string label)
{
Entries[id] = new DiscreteEventEntry
{
Id = id,
EventStrength = strength,
Category = category,
CreatesInterest = true,
LabelTemplate = label
};
}
public static IEnumerable<string> AuthoredIds => Entries.Keys;
public static bool HasAuthored(string id) =>
!string.IsNullOrEmpty(id) && Entries.ContainsKey(id.Trim());
public static DiscreteEventEntry GetOrFallback(string id)
{
if (!string.IsNullOrEmpty(id) && Entries.TryGetValue(id.Trim(), out DiscreteEventEntry entry))
{
return entry;
}
string key = string.IsNullOrEmpty(id) ? "unknown" : id.Trim();
return new DiscreteEventEntry
{
Id = key,
EventStrength = 70f,
Category = "World",
LabelTemplate = "Era ({id})",
IsFallback = true
};
}
}
}

View file

@ -4,7 +4,9 @@ using System.Collections.Generic;
namespace IdleSpectator;
/// <summary>Authored policy + prose for every live happiness effect.</summary>
public static class HappinessEventCatalog
public static partial class EventCatalog
{
public static class Happiness
{
private static readonly Dictionary<string, HappinessCatalogEntry> Entries =
new Dictionary<string, HappinessCatalogEntry>(StringComparer.OrdinalIgnoreCase)
@ -12,6 +14,7 @@ public static class HappinessEventCatalog
["death_family_member"] = new HappinessCatalogEntry
{
Id = "death_family_member",
EventStrength = 76f,
Presentation = HappinessPresentationTier.Signal,
Context = HappinessContextRequirement.RequiresRelatedActor,
Life = HappinessLifePolicy.ProjectToLife,
@ -24,6 +27,7 @@ public static class HappinessEventCatalog
["death_lover"] = new HappinessCatalogEntry
{
Id = "death_lover",
EventStrength = 88f,
Presentation = HappinessPresentationTier.Signal,
Context = HappinessContextRequirement.RequiresRelatedActor,
Life = HappinessLifePolicy.ProjectToLife,
@ -36,6 +40,7 @@ public static class HappinessEventCatalog
["death_child"] = new HappinessCatalogEntry
{
Id = "death_child",
EventStrength = 95f,
Presentation = HappinessPresentationTier.Signal,
Context = HappinessContextRequirement.RequiresRelatedActor,
Life = HappinessLifePolicy.ProjectToLife,
@ -48,6 +53,7 @@ public static class HappinessEventCatalog
["death_best_friend"] = new HappinessCatalogEntry
{
Id = "death_best_friend",
EventStrength = 78f,
Presentation = HappinessPresentationTier.Signal,
Context = HappinessContextRequirement.RequiresRelatedActor,
Life = HappinessLifePolicy.ProjectToLife,
@ -60,6 +66,7 @@ public static class HappinessEventCatalog
["got_robbed"] = new HappinessCatalogEntry
{
Id = "got_robbed",
EventStrength = 50f,
Presentation = HappinessPresentationTier.Signal,
Context = HappinessContextRequirement.RelatedOptional,
Life = HappinessLifePolicy.ActivityOnly,
@ -72,6 +79,7 @@ public static class HappinessEventCatalog
["got_poked"] = new HappinessCatalogEntry
{
Id = "got_poked",
EventStrength = 30f,
Presentation = HappinessPresentationTier.Ambient,
Context = HappinessContextRequirement.None,
Life = HappinessLifePolicy.ActivityOnly,
@ -84,30 +92,34 @@ public static class HappinessEventCatalog
["lost_fight"] = new HappinessCatalogEntry
{
Id = "lost_fight",
EventStrength = 55f,
Presentation = HappinessPresentationTier.Signal,
Context = HappinessContextRequirement.None,
Life = HappinessLifePolicy.ActivityOnly,
Overlap = HappinessOverlapPolicy.Independent,
RelationRole = HappinessRelationRole.None,
InterestCategory = "Emotion",
VariantsWithRelated = new[] { "loses a fight", "is beaten in a fight" },
VariantsWithoutRelated = new[] { "loses a fight", "is beaten in a fight" },
VariantsWithRelated = new[] { "is forced to yield in a fight", "gives up mid-fight" },
VariantsWithoutRelated = new[] { "is forced to yield in a fight", "gives up mid-fight" },
},
["got_caught"] = new HappinessCatalogEntry
{
Id = "got_caught",
Presentation = HappinessPresentationTier.Signal,
// Library-only id today - no changeHappiness("got_caught") call site in the game.
EventStrength = 20f,
Presentation = HappinessPresentationTier.Ambient,
Context = HappinessContextRequirement.None,
Life = HappinessLifePolicy.ActivityOnly,
Overlap = HappinessOverlapPolicy.Independent,
RelationRole = HappinessRelationRole.None,
InterestCategory = "Emotion",
VariantsWithRelated = new[] { "is caught doing something shady", "gets caught" },
VariantsWithoutRelated = new[] { "is caught doing something shady", "gets caught" },
VariantsWithRelated = new[] { "gets caught", "is caught" },
VariantsWithoutRelated = new[] { "gets caught", "is caught" },
},
["paid_tax"] = new HappinessCatalogEntry
{
Id = "paid_tax",
EventStrength = 30f,
Presentation = HappinessPresentationTier.Ambient,
Context = HappinessContextRequirement.None,
Life = HappinessLifePolicy.ActivityOnly,
@ -120,6 +132,7 @@ public static class HappinessEventCatalog
["just_ate"] = new HappinessCatalogEntry
{
Id = "just_ate",
EventStrength = 30f,
Presentation = HappinessPresentationTier.Ambient,
Context = HappinessContextRequirement.None,
Life = HappinessLifePolicy.ActivityOnly,
@ -133,6 +146,7 @@ public static class HappinessEventCatalog
["just_received_gift"] = new HappinessCatalogEntry
{
Id = "just_received_gift",
EventStrength = 50f,
Presentation = HappinessPresentationTier.Signal,
Context = HappinessContextRequirement.RelatedOptional,
Life = HappinessLifePolicy.ActivityOnly,
@ -145,6 +159,7 @@ public static class HappinessEventCatalog
["just_gave_gift"] = new HappinessCatalogEntry
{
Id = "just_gave_gift",
EventStrength = 50f,
Presentation = HappinessPresentationTier.Signal,
Context = HappinessContextRequirement.RelatedOptional,
Life = HappinessLifePolicy.ActivityOnly,
@ -157,6 +172,7 @@ public static class HappinessEventCatalog
["just_pooped"] = new HappinessCatalogEntry
{
Id = "just_pooped",
EventStrength = 30f,
Presentation = HappinessPresentationTier.Ambient,
Context = HappinessContextRequirement.None,
Life = HappinessLifePolicy.ActivityOnly,
@ -169,18 +185,20 @@ public static class HappinessEventCatalog
["just_slept"] = new HappinessCatalogEntry
{
Id = "just_slept",
EventStrength = 30f,
Presentation = HappinessPresentationTier.Ambient,
Context = HappinessContextRequirement.None,
Life = HappinessLifePolicy.ActivityOnly,
Overlap = HappinessOverlapPolicy.Independent,
RelationRole = HappinessRelationRole.None,
InterestCategory = "Daily",
VariantsWithRelated = new[] { "wakes from a restful sleep", "finishes sleeping" },
VariantsWithoutRelated = new[] { "wakes from a restful sleep", "finishes sleeping" },
VariantsWithRelated = new[] { "finishes sleeping", "ends a rest" },
VariantsWithoutRelated = new[] { "finishes sleeping", "ends a rest" },
},
["had_bad_dream"] = new HappinessCatalogEntry
{
Id = "had_bad_dream",
EventStrength = 30f,
Presentation = HappinessPresentationTier.Ambient,
Context = HappinessContextRequirement.None,
Life = HappinessLifePolicy.ActivityOnly,
@ -188,12 +206,13 @@ public static class HappinessEventCatalog
RelationRole = HappinessRelationRole.None,
InterestCategory = "Daily",
StatusOverlapId = "had_bad_dream",
VariantsWithRelated = new[] { "wakes from a bad dream", "shakes off a bad dream" },
VariantsWithoutRelated = new[] { "wakes from a bad dream", "shakes off a bad dream" },
VariantsWithRelated = new[] { "has a bad dream", "is troubled by a bad dream" },
VariantsWithoutRelated = new[] { "has a bad dream", "is troubled by a bad dream" },
},
["had_good_dream"] = new HappinessCatalogEntry
{
Id = "had_good_dream",
EventStrength = 30f,
Presentation = HappinessPresentationTier.Ambient,
Context = HappinessContextRequirement.None,
Life = HappinessLifePolicy.ActivityOnly,
@ -201,12 +220,13 @@ public static class HappinessEventCatalog
RelationRole = HappinessRelationRole.None,
InterestCategory = "Daily",
StatusOverlapId = "had_good_dream",
VariantsWithRelated = new[] { "wakes from a good dream", "smiles about a good dream" },
VariantsWithoutRelated = new[] { "wakes from a good dream", "smiles about a good dream" },
VariantsWithRelated = new[] { "has a good dream", "dreams pleasantly" },
VariantsWithoutRelated = new[] { "has a good dream", "dreams pleasantly" },
},
["had_nightmare"] = new HappinessCatalogEntry
{
Id = "had_nightmare",
EventStrength = 30f,
Presentation = HappinessPresentationTier.Ambient,
Context = HappinessContextRequirement.None,
Life = HappinessLifePolicy.ActivityOnly,
@ -214,36 +234,41 @@ public static class HappinessEventCatalog
RelationRole = HappinessRelationRole.None,
InterestCategory = "Daily",
StatusOverlapId = "had_nightmare",
VariantsWithRelated = new[] { "wakes from a nightmare", "shakes off a nightmare" },
VariantsWithoutRelated = new[] { "wakes from a nightmare", "shakes off a nightmare" },
VariantsWithRelated = new[] { "has a nightmare", "is haunted by a nightmare" },
VariantsWithoutRelated = new[] { "has a nightmare", "is haunted by a nightmare" },
},
["slept_outside"] = new HappinessCatalogEntry
{
Id = "slept_outside",
EventStrength = 30f,
Presentation = HappinessPresentationTier.Ambient,
Context = HappinessContextRequirement.None,
Life = HappinessLifePolicy.ActivityOnly,
Overlap = HappinessOverlapPolicy.Independent,
RelationRole = HappinessRelationRole.None,
InterestCategory = "Daily",
VariantsWithRelated = new[] { "sleeps outside and feels worse for it", "spends a rough night outside" },
VariantsWithoutRelated = new[] { "sleeps outside and feels worse for it", "spends a rough night outside" },
VariantsWithRelated = new[] { "has to sleep without a house", "settles down outdoors, unhappy" },
VariantsWithoutRelated = new[] { "has to sleep without a house", "settles down outdoors, unhappy" },
},
["just_kissed"] = new HappinessCatalogEntry
{
Id = "just_kissed",
// Game id is mating afterglow (BehCheckForBabiesFromSexualReproduction), not a kiss.
EventStrength = 50f,
Presentation = HappinessPresentationTier.Signal,
Context = HappinessContextRequirement.RelatedOptional,
Life = HappinessLifePolicy.ActivityOnly,
Overlap = HappinessOverlapPolicy.Independent,
RelationRole = HappinessRelationRole.Lover,
InterestCategory = "Social",
VariantsWithRelated = new[] { "shares a kiss with {related}", "kisses {related}" },
VariantsWithoutRelated = new[] { "shares a kiss", "steals a kiss" },
VariantsWithRelated = new[] { "mates with {related}", "shares intimacy with {related}" },
VariantsWithoutRelated = new[] { "mates with their lover", "shares intimacy with their lover" },
},
["just_killed"] = new HappinessCatalogEntry
{
Id = "just_killed",
// Only applied in newKillAction when the killer has trait bloodlust.
EventStrength = 45f,
Presentation = HappinessPresentationTier.Signal,
Context = HappinessContextRequirement.None,
Life = HappinessLifePolicy.ActivityOnly,
@ -251,24 +276,26 @@ public static class HappinessEventCatalog
RelationRole = HappinessRelationRole.Victim,
InterestCategory = "Emotion",
CanonicalMilestoneKey = "milestone_kill",
VariantsWithRelated = new[] { "kills {related} and feels a rush", "takes a life" },
VariantsWithoutRelated = new[] { "takes a life", "feels a rush after a kill" },
VariantsWithRelated = new[] { "kills {related} and feels bloodlust's rush", "bloodlust flares after killing {related}" },
VariantsWithoutRelated = new[] { "bloodlust flares after a kill", "feels bloodlust's rush after a kill" },
},
["become_king"] = new HappinessCatalogEntry
{
Id = "become_king",
EventStrength = 72f,
Presentation = HappinessPresentationTier.Signal,
Context = HappinessContextRequirement.None,
Life = HappinessLifePolicy.ProjectToLife,
Overlap = HappinessOverlapPolicy.Independent,
RelationRole = HappinessRelationRole.None,
InterestCategory = "LifeChapter",
VariantsWithRelated = new[] { "is crowned king", "takes the throne" },
VariantsWithoutRelated = new[] { "is crowned king", "takes the throne" },
VariantsWithRelated = new[] { "takes the crown", "becomes ruler" },
VariantsWithoutRelated = new[] { "takes the crown", "becomes ruler" },
},
["become_leader"] = new HappinessCatalogEntry
{
Id = "become_leader",
EventStrength = 65f,
Presentation = HappinessPresentationTier.Signal,
Context = HappinessContextRequirement.None,
Life = HappinessLifePolicy.ProjectToLife,
@ -281,6 +308,7 @@ public static class HappinessEventCatalog
["just_won_war"] = new HappinessCatalogEntry
{
Id = "just_won_war",
EventStrength = 35f,
Presentation = HappinessPresentationTier.Aggregate,
Context = HappinessContextRequirement.None,
Life = HappinessLifePolicy.ActivityOnly,
@ -293,6 +321,7 @@ public static class HappinessEventCatalog
["just_made_peace"] = new HappinessCatalogEntry
{
Id = "just_made_peace",
EventStrength = 35f,
Presentation = HappinessPresentationTier.Aggregate,
Context = HappinessContextRequirement.None,
Life = HappinessLifePolicy.ActivityOnly,
@ -305,6 +334,7 @@ public static class HappinessEventCatalog
["just_lost_war"] = new HappinessCatalogEntry
{
Id = "just_lost_war",
EventStrength = 35f,
Presentation = HappinessPresentationTier.Aggregate,
Context = HappinessContextRequirement.None,
Life = HappinessLifePolicy.ActivityOnly,
@ -317,6 +347,7 @@ public static class HappinessEventCatalog
["was_conquered"] = new HappinessCatalogEntry
{
Id = "was_conquered",
EventStrength = 35f,
Presentation = HappinessPresentationTier.Aggregate,
Context = HappinessContextRequirement.None,
Life = HappinessLifePolicy.ActivityOnly,
@ -329,6 +360,7 @@ public static class HappinessEventCatalog
["kingdom_fell_apart"] = new HappinessCatalogEntry
{
Id = "kingdom_fell_apart",
EventStrength = 35f,
Presentation = HappinessPresentationTier.Aggregate,
Context = HappinessContextRequirement.None,
Life = HappinessLifePolicy.ActivityOnly,
@ -341,30 +373,33 @@ public static class HappinessEventCatalog
["just_started_war"] = new HappinessCatalogEntry
{
Id = "just_started_war",
EventStrength = 80f,
Presentation = HappinessPresentationTier.Aggregate,
Context = HappinessContextRequirement.None,
Life = HappinessLifePolicy.ActivityOnly,
Overlap = HappinessOverlapPolicy.Independent,
RelationRole = HappinessRelationRole.None,
InterestCategory = "Civic",
VariantsWithRelated = new[] { "cheers as war begins", "is stirred by the start of war" },
VariantsWithoutRelated = new[] { "cheers as war begins", "is stirred by the start of war" },
VariantsWithRelated = new[] { "is glad war has started", "cheers a war their culture loves" },
VariantsWithoutRelated = new[] { "is glad war has started", "cheers a war their culture loves" },
},
["just_rebelled"] = new HappinessCatalogEntry
{
Id = "just_rebelled",
EventStrength = 35f,
Presentation = HappinessPresentationTier.Aggregate,
Context = HappinessContextRequirement.None,
Life = HappinessLifePolicy.ActivityOnly,
Overlap = HappinessOverlapPolicy.Independent,
RelationRole = HappinessRelationRole.None,
InterestCategory = "Civic",
VariantsWithRelated = new[] { "joins a rebellion", "rises in rebellion" },
VariantsWithoutRelated = new[] { "joins a rebellion", "rises in rebellion" },
VariantsWithRelated = new[] { "is swept up as the city rebels", "lives through a rebellion" },
VariantsWithoutRelated = new[] { "is swept up as the city rebels", "lives through a rebellion" },
},
["fallen_in_love"] = new HappinessCatalogEntry
{
Id = "fallen_in_love",
EventStrength = 50f,
Presentation = HappinessPresentationTier.Signal,
Context = HappinessContextRequirement.RelatedOptional,
Life = HappinessLifePolicy.ActivityOnly,
@ -379,30 +414,34 @@ public static class HappinessEventCatalog
["just_had_child"] = new HappinessCatalogEntry
{
Id = "just_had_child",
// birthEvent is on the parent; related newborn is not wired today.
EventStrength = 70f,
Presentation = HappinessPresentationTier.Signal,
Context = HappinessContextRequirement.RelatedOptional,
Context = HappinessContextRequirement.None,
Life = HappinessLifePolicy.ProjectToLife,
Overlap = HappinessOverlapPolicy.Independent,
RelationRole = HappinessRelationRole.Newborn,
RelationRole = HappinessRelationRole.None,
InterestCategory = "LifeChapter",
VariantsWithRelated = new[] { "welcomes newborn {related}", "has a child named {related}" },
VariantsWithoutRelated = new[] { "welcomes a newborn child", "has a child" },
VariantsWithRelated = new[] { "has a child", "feels the joy of parenthood" },
VariantsWithoutRelated = new[] { "has a child", "feels the joy of parenthood" },
},
["just_read_book"] = new HappinessCatalogEntry
{
Id = "just_read_book",
EventStrength = 30f,
Presentation = HappinessPresentationTier.Ambient,
Context = HappinessContextRequirement.None,
Life = HappinessLifePolicy.ActivityOnly,
Overlap = HappinessOverlapPolicy.Independent,
RelationRole = HappinessRelationRole.None,
InterestCategory = "Daily",
VariantsWithRelated = new[] { "finishes reading a book", "puts down a book feeling wiser" },
VariantsWithoutRelated = new[] { "finishes reading a book", "puts down a book feeling wiser" },
VariantsWithRelated = new[] { "finishes reading a book", "puts down a book" },
VariantsWithoutRelated = new[] { "finishes reading a book", "puts down a book" },
},
["just_played"] = new HappinessCatalogEntry
{
Id = "just_played",
EventStrength = 30f,
Presentation = HappinessPresentationTier.Ambient,
Context = HappinessContextRequirement.None,
Life = HappinessLifePolicy.ActivityOnly,
@ -415,18 +454,20 @@ public static class HappinessEventCatalog
["just_talked"] = new HappinessCatalogEntry
{
Id = "just_talked",
EventStrength = 30f,
Presentation = HappinessPresentationTier.Ambient,
Context = HappinessContextRequirement.RelatedOptional,
Life = HappinessLifePolicy.ActivityOnly,
Overlap = HappinessOverlapPolicy.Independent,
RelationRole = HappinessRelationRole.ConversationPartner,
InterestCategory = "Social",
VariantsWithRelated = new[] { "shares a talk with {related}", "finishes a conversation with {related}" },
VariantsWithoutRelated = new[] { "shares a quiet talk", "finishes a conversation" },
VariantsWithRelated = new[] { "finishes talking with {related}", "ends a conversation with {related}" },
VariantsWithoutRelated = new[] { "finishes talking", "ends a conversation" },
},
["just_laughed"] = new HappinessCatalogEntry
{
Id = "just_laughed",
EventStrength = 30f,
Presentation = HappinessPresentationTier.Ambient,
Context = HappinessContextRequirement.None,
Life = HappinessLifePolicy.ActivityOnly,
@ -440,6 +481,7 @@ public static class HappinessEventCatalog
["just_sang"] = new HappinessCatalogEntry
{
Id = "just_sang",
EventStrength = 30f,
Presentation = HappinessPresentationTier.Ambient,
Context = HappinessContextRequirement.None,
Life = HappinessLifePolicy.ActivityOnly,
@ -453,6 +495,7 @@ public static class HappinessEventCatalog
["just_swore"] = new HappinessCatalogEntry
{
Id = "just_swore",
EventStrength = 30f,
Presentation = HappinessPresentationTier.Ambient,
Context = HappinessContextRequirement.None,
Life = HappinessLifePolicy.ActivityOnly,
@ -466,6 +509,7 @@ public static class HappinessEventCatalog
["just_cried"] = new HappinessCatalogEntry
{
Id = "just_cried",
EventStrength = 30f,
Presentation = HappinessPresentationTier.Signal,
Context = HappinessContextRequirement.None,
Life = HappinessLifePolicy.ActivityOnly,
@ -479,18 +523,20 @@ public static class HappinessEventCatalog
["just_talked_gossip"] = new HappinessCatalogEntry
{
Id = "just_talked_gossip",
EventStrength = 30f,
Presentation = HappinessPresentationTier.Ambient,
Context = HappinessContextRequirement.RelatedOptional,
Life = HappinessLifePolicy.ActivityOnly,
Overlap = HappinessOverlapPolicy.Independent,
RelationRole = HappinessRelationRole.ConversationPartner,
InterestCategory = "Social",
VariantsWithRelated = new[] { "trades gossip with {related}", "shares juicy gossip with {related}" },
VariantsWithoutRelated = new[] { "trades gossip", "shares juicy gossip" },
VariantsWithRelated = new[] { "gossips with {related}", "shares lover-gossip with {related}" },
VariantsWithoutRelated = new[] { "gossips about lovers", "shares lover-gossip" },
},
["just_surprised"] = new HappinessCatalogEntry
{
Id = "just_surprised",
EventStrength = 30f,
Presentation = HappinessPresentationTier.Ambient,
Context = HappinessContextRequirement.None,
Life = HappinessLifePolicy.ActivityOnly,
@ -503,19 +549,23 @@ public static class HappinessEventCatalog
},
["just_born"] = new HappinessCatalogEntry
{
// Actor.newCreature spawn bookkeeping - not live birth; may precede egg form.
Id = "just_born",
EventStrength = 48f,
Presentation = HappinessPresentationTier.Signal,
Context = HappinessContextRequirement.None,
Life = HappinessLifePolicy.ProjectToLife,
Overlap = HappinessOverlapPolicy.Independent,
RelationRole = HappinessRelationRole.None,
InterestCategory = "LifeChapter",
VariantsWithRelated = new[] { "is born into the world", "takes a first breath" },
VariantsWithoutRelated = new[] { "is born into the world", "takes a first breath" },
VariantsWithRelated = new[] { "appears in the world", "is brought into existence" },
VariantsWithoutRelated = new[] { "appears in the world", "is brought into existence" },
},
["just_magnetised"] = new HappinessCatalogEntry
{
// Game id uses British spelling; status asset is magnetized.
Id = "just_magnetised",
EventStrength = 45f,
Presentation = HappinessPresentationTier.Signal,
Context = HappinessContextRequirement.None,
Life = HappinessLifePolicy.ActivityOnly,
@ -529,18 +579,20 @@ public static class HappinessEventCatalog
["just_forced_power"] = new HappinessCatalogEntry
{
Id = "just_forced_power",
EventStrength = 45f,
Presentation = HappinessPresentationTier.Signal,
Context = HappinessContextRequirement.None,
Life = HappinessLifePolicy.ActivityOnly,
Overlap = HappinessOverlapPolicy.Independent,
RelationRole = HappinessRelationRole.None,
InterestCategory = "Emotion",
VariantsWithRelated = new[] { "is forced by divine power", "suffers a forced power" },
VariantsWithoutRelated = new[] { "is forced by divine power", "suffers a forced power" },
VariantsWithRelated = new[] { "is flung by a god power", "is blasted aside by divine force" },
VariantsWithoutRelated = new[] { "is flung by a god power", "is blasted aside by divine force" },
},
["just_possessed"] = new HappinessCatalogEntry
{
Id = "just_possessed",
EventStrength = 45f,
Presentation = HappinessPresentationTier.Signal,
Context = HappinessContextRequirement.None,
Life = HappinessLifePolicy.ActivityOnly,
@ -554,6 +606,7 @@ public static class HappinessEventCatalog
["strange_urge"] = new HappinessCatalogEntry
{
Id = "strange_urge",
EventStrength = 45f,
Presentation = HappinessPresentationTier.Signal,
Context = HappinessContextRequirement.None,
Life = HappinessLifePolicy.ActivityOnly,
@ -567,6 +620,7 @@ public static class HappinessEventCatalog
["just_had_tantrum"] = new HappinessCatalogEntry
{
Id = "just_had_tantrum",
EventStrength = 45f,
Presentation = HappinessPresentationTier.Signal,
Context = HappinessContextRequirement.None,
Life = HappinessLifePolicy.ActivityOnly,
@ -580,6 +634,7 @@ public static class HappinessEventCatalog
["just_felt_the_divine"] = new HappinessCatalogEntry
{
Id = "just_felt_the_divine",
EventStrength = 45f,
Presentation = HappinessPresentationTier.Signal,
Context = HappinessContextRequirement.None,
Life = HappinessLifePolicy.ActivityOnly,
@ -592,6 +647,7 @@ public static class HappinessEventCatalog
["just_enchanted"] = new HappinessCatalogEntry
{
Id = "just_enchanted",
EventStrength = 45f,
Presentation = HappinessPresentationTier.Signal,
Context = HappinessContextRequirement.None,
Life = HappinessLifePolicy.ActivityOnly,
@ -605,6 +661,7 @@ public static class HappinessEventCatalog
["just_inspired"] = new HappinessCatalogEntry
{
Id = "just_inspired",
EventStrength = 45f,
Presentation = HappinessPresentationTier.Signal,
Context = HappinessContextRequirement.None,
Life = HappinessLifePolicy.ActivityOnly,
@ -612,12 +669,14 @@ public static class HappinessEventCatalog
RelationRole = HappinessRelationRole.None,
InterestCategory = "Emotion",
StatusOverlapId = "inspired",
VariantsWithRelated = new[] { "feels inspired", "is struck by inspiration" },
VariantsWithoutRelated = new[] { "feels inspired", "is struck by inspiration" },
// Happiness fires on inspired status end (action_finish), not onset.
VariantsWithRelated = new[] { "comes down from inspiration", "lets inspiration settle into contentment" },
VariantsWithoutRelated = new[] { "comes down from inspiration", "lets inspiration settle into contentment" },
},
["wrote_book"] = new HappinessCatalogEntry
{
Id = "wrote_book",
EventStrength = 65f,
Presentation = HappinessPresentationTier.Signal,
Context = HappinessContextRequirement.None,
Life = HappinessLifePolicy.ProjectToLife,
@ -630,6 +689,7 @@ public static class HappinessEventCatalog
["just_became_adult"] = new HappinessCatalogEntry
{
Id = "just_became_adult",
EventStrength = 65f,
Presentation = HappinessPresentationTier.Signal,
Context = HappinessContextRequirement.None,
Life = HappinessLifePolicy.ProjectToLife,
@ -642,6 +702,7 @@ public static class HappinessEventCatalog
["just_got_out_of_egg"] = new HappinessCatalogEntry
{
Id = "just_got_out_of_egg",
EventStrength = 65f,
Presentation = HappinessPresentationTier.Signal,
Context = HappinessContextRequirement.None,
Life = HappinessLifePolicy.ProjectToLife,
@ -654,6 +715,7 @@ public static class HappinessEventCatalog
["just_finished_plot"] = new HappinessCatalogEntry
{
Id = "just_finished_plot",
EventStrength = 65f,
Presentation = HappinessPresentationTier.Signal,
Context = HappinessContextRequirement.None,
Life = HappinessLifePolicy.ProjectToLife,
@ -666,6 +728,7 @@ public static class HappinessEventCatalog
["just_found_house"] = new HappinessCatalogEntry
{
Id = "just_found_house",
EventStrength = 55f,
Presentation = HappinessPresentationTier.Signal,
Context = HappinessContextRequirement.None,
Life = HappinessLifePolicy.ProjectToLife,
@ -678,6 +741,7 @@ public static class HappinessEventCatalog
["just_lost_house"] = new HappinessCatalogEntry
{
Id = "just_lost_house",
EventStrength = 65f,
Presentation = HappinessPresentationTier.Signal,
Context = HappinessContextRequirement.None,
Life = HappinessLifePolicy.ProjectToLife,
@ -690,6 +754,7 @@ public static class HappinessEventCatalog
["just_made_friend"] = new HappinessCatalogEntry
{
Id = "just_made_friend",
EventStrength = 50f,
Presentation = HappinessPresentationTier.Signal,
Context = HappinessContextRequirement.RelatedOptional,
Life = HappinessLifePolicy.ActivityOnly,
@ -703,6 +768,7 @@ public static class HappinessEventCatalog
["just_injured"] = new HappinessCatalogEntry
{
Id = "just_injured",
EventStrength = 65f,
Presentation = HappinessPresentationTier.Signal,
Context = HappinessContextRequirement.None,
Life = HappinessLifePolicy.ProjectToLife,
@ -715,6 +781,7 @@ public static class HappinessEventCatalog
["just_cursed"] = new HappinessCatalogEntry
{
Id = "just_cursed",
EventStrength = 45f,
Presentation = HappinessPresentationTier.Signal,
Context = HappinessContextRequirement.None,
Life = HappinessLifePolicy.ActivityOnly,
@ -728,6 +795,7 @@ public static class HappinessEventCatalog
["starving"] = new HappinessCatalogEntry
{
Id = "starving",
EventStrength = 30f,
Presentation = HappinessPresentationTier.Ambient,
Context = HappinessContextRequirement.None,
Life = HappinessLifePolicy.ActivityOnly,
@ -741,6 +809,7 @@ public static class HappinessEventCatalog
["conquered_city"] = new HappinessCatalogEntry
{
Id = "conquered_city",
EventStrength = 75f,
Presentation = HappinessPresentationTier.Aggregate,
Context = HappinessContextRequirement.None,
Life = HappinessLifePolicy.ActivityOnly,
@ -753,6 +822,7 @@ public static class HappinessEventCatalog
["destroyed_city"] = new HappinessCatalogEntry
{
Id = "destroyed_city",
EventStrength = 35f,
Presentation = HappinessPresentationTier.Aggregate,
Context = HappinessContextRequirement.None,
Life = HappinessLifePolicy.ActivityOnly,
@ -765,6 +835,7 @@ public static class HappinessEventCatalog
["lost_crown"] = new HappinessCatalogEntry
{
Id = "lost_crown",
EventStrength = 65f,
Presentation = HappinessPresentationTier.Signal,
Context = HappinessContextRequirement.None,
Life = HappinessLifePolicy.ProjectToLife,
@ -777,6 +848,7 @@ public static class HappinessEventCatalog
["razed_capital"] = new HappinessCatalogEntry
{
Id = "razed_capital",
EventStrength = 35f,
Presentation = HappinessPresentationTier.Aggregate,
Context = HappinessContextRequirement.None,
Life = HappinessLifePolicy.ActivityOnly,
@ -789,6 +861,7 @@ public static class HappinessEventCatalog
["lost_capital"] = new HappinessCatalogEntry
{
Id = "lost_capital",
EventStrength = 35f,
Presentation = HappinessPresentationTier.Aggregate,
Context = HappinessContextRequirement.None,
Life = HappinessLifePolicy.ActivityOnly,
@ -801,6 +874,7 @@ public static class HappinessEventCatalog
["razed_city"] = new HappinessCatalogEntry
{
Id = "razed_city",
EventStrength = 35f,
Presentation = HappinessPresentationTier.Aggregate,
Context = HappinessContextRequirement.None,
Life = HappinessLifePolicy.ActivityOnly,
@ -813,6 +887,7 @@ public static class HappinessEventCatalog
["lost_city"] = new HappinessCatalogEntry
{
Id = "lost_city",
EventStrength = 72f,
Presentation = HappinessPresentationTier.Aggregate,
Context = HappinessContextRequirement.None,
Life = HappinessLifePolicy.ActivityOnly,
@ -825,14 +900,15 @@ public static class HappinessEventCatalog
["become_alpha"] = new HappinessCatalogEntry
{
Id = "become_alpha",
EventStrength = 65f,
Presentation = HappinessPresentationTier.Signal,
Context = HappinessContextRequirement.None,
Life = HappinessLifePolicy.ProjectToLife,
Overlap = HappinessOverlapPolicy.Independent,
RelationRole = HappinessRelationRole.None,
InterestCategory = "LifeChapter",
VariantsWithRelated = new[] { "becomes the alpha", "rises as alpha" },
VariantsWithoutRelated = new[] { "becomes the alpha", "rises as alpha" },
VariantsWithRelated = new[] { "becomes the family alpha", "is named family alpha" },
VariantsWithoutRelated = new[] { "becomes the family alpha", "is named family alpha" },
},
};
@ -872,8 +948,9 @@ public static class HappinessEventCatalog
InterestCategory = "Unknown",
VariantsWithRelated = new[] { "feels a change in happiness" },
VariantsWithoutRelated = new[] { "feels a change in happiness" },
EventStrength = 40f,
IsFallback = true
};
}
}
}

View file

@ -0,0 +1,418 @@
using System;
using System.Collections.Generic;
namespace IdleSpectator;
/// <summary>Live AssetManager library event dials (powers, genes, …).</summary>
public static partial class EventCatalog
{
public static class Spell
{
private static readonly Dictionary<string, DiscreteEventEntry> Entries =
new Dictionary<string, DiscreteEventEntry>(StringComparer.OrdinalIgnoreCase);
private static readonly HashSet<string> SignalIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"summon_lightning", "summon_tornado", "cast_curse", "cast_fire",
"cast_blood_rain", "summon_meteor", "teleport"
};
private static void Ensure() =>
LiveLibraryInterest.EnsureSeeded(
Entries,
ActivityAssetCatalog.EnumerateLiveSpellIds,
"Spectacle",
"{a} casts {id}",
ambientStrength: 48f,
SignalIds,
signalStrength: 88f,
ambientCreatesInterest: false);
public static IEnumerable<string> AuthoredIds
{
get
{
Ensure();
return Entries.Keys;
}
}
public static DiscreteEventEntry GetOrFallback(string id)
{
Ensure();
return LiveLibraryInterest.Lookup(Entries, id, "Spectacle", "{a} casts {id}", 55f);
}
}
/// <summary>God powers library - mostly Ambient; Signal for disaster/spectacle ids.</summary>
public static class Power
{
private static readonly Dictionary<string, DiscreteEventEntry> Entries =
new Dictionary<string, DiscreteEventEntry>(StringComparer.OrdinalIgnoreCase);
private static bool IsSpectaclePower(string id)
{
if (string.IsNullOrEmpty(id))
{
return false;
}
string s = id.ToLowerInvariant();
return s.Contains("meteor")
|| s.Contains("earthquake")
|| s.Contains("tornado")
|| s.Contains("volcano")
|| s.Contains("nuke")
|| s.Contains("bomb")
|| s.Contains("rain_blood")
|| s.Contains("hell")
|| s.Contains("lightning")
|| s.Contains("storm")
|| s.Contains("acid")
|| s.Contains("fire")
|| s.Contains("disaster");
}
private static void Ensure() =>
LiveLibraryInterest.EnsureSeeded(
Entries,
ActivityAssetCatalog.EnumerateLivePowerIds,
"Spectacle",
"God power: {id}",
ambientStrength: 28f,
signalIds: null,
signalStrength: 92f,
signalPredicate: IsSpectaclePower,
ambientCreatesInterest: false);
public static IEnumerable<string> AuthoredIds
{
get
{
Ensure();
return Entries.Keys;
}
}
public static DiscreteEventEntry GetOrFallback(string id)
{
Ensure();
return LiveLibraryInterest.Lookup(Entries, id, "Spectacle", "God power: {id}", 30f);
}
}
/// <summary>AI decisions - Signal for war/rebellion/kingdom-affecting; Ambient otherwise.</summary>
public static class Decision
{
private static readonly Dictionary<string, DiscreteEventEntry> Entries =
new Dictionary<string, DiscreteEventEntry>(StringComparer.OrdinalIgnoreCase);
private static bool IsKingdomDecision(string id)
{
if (string.IsNullOrEmpty(id))
{
return false;
}
string s = id.ToLowerInvariant();
// Routine AI ticks often include "king" in the name - those are not camera events.
if (s.Contains("idle")
|| s.Contains("walking")
|| s.Contains("check")
|| s.Contains("wait")
|| s.Contains("sleep"))
{
return false;
}
return s.Contains("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("king") && (s.Contains("war") || s.Contains("city") || s.Contains("rebellion")));
}
/// <summary>True when a decision id should be allowed to own the idle camera.</summary>
public static bool IsCameraWorthy(string id) => IsKingdomDecision(id);
private static void Ensure() =>
LiveLibraryInterest.EnsureSeeded(
Entries,
ActivityAssetCatalog.EnumerateLiveDecisionIds,
"Politics",
"{a} decides {id}",
ambientStrength: 32f,
signalIds: null,
signalStrength: 86f,
signalPredicate: IsKingdomDecision,
ambientCreatesInterest: false);
public static IEnumerable<string> AuthoredIds
{
get
{
Ensure();
return Entries.Keys;
}
}
public static DiscreteEventEntry GetOrFallback(string id)
{
Ensure();
return LiveLibraryInterest.Lookup(Entries, id, "Politics", "{a} decides {id}", 35f);
}
}
/// <summary>Items library - Signal for legendary/wonder subset.</summary>
public static class Item
{
private static readonly Dictionary<string, DiscreteEventEntry> Entries =
new Dictionary<string, DiscreteEventEntry>(StringComparer.OrdinalIgnoreCase);
private static bool IsLegendaryItem(string id)
{
if (string.IsNullOrEmpty(id))
{
return false;
}
string s = id.ToLowerInvariant();
return s.Contains("legendary")
|| s.Contains("mythic")
|| s.Contains("artifact")
|| s.Contains("relic")
|| s.Contains("divine")
|| s.Contains("demon")
|| s.Contains("dragon")
|| s.Contains("nuke")
|| s.Contains("excalibur")
|| s.Contains("wonder");
}
private static void Ensure() =>
LiveLibraryInterest.EnsureSeeded(
Entries,
ActivityAssetCatalog.EnumerateLiveItemIds,
"Item",
"{a} forges {id}",
ambientStrength: 36f,
signalIds: null,
signalStrength: 80f,
signalPredicate: IsLegendaryItem,
ambientCreatesInterest: false);
public static IEnumerable<string> AuthoredIds
{
get
{
Ensure();
return Entries.Keys;
}
}
public static DiscreteEventEntry GetOrFallback(string id)
{
Ensure();
return LiveLibraryInterest.Lookup(Entries, id, "Item", "{a} forges {id}", 40f);
}
}
/// <summary>Subspecies traits - Signal for dramatic; Ambient fill-capped.</summary>
public static class SubspeciesTrait
{
private static readonly Dictionary<string, DiscreteEventEntry> Entries =
new Dictionary<string, DiscreteEventEntry>(StringComparer.OrdinalIgnoreCase);
private static bool IsDramatic(string id)
{
if (string.IsNullOrEmpty(id))
{
return false;
}
string s = id.ToLowerInvariant();
return s.Contains("immortal")
|| s.Contains("giant")
|| s.Contains("tiny")
|| s.Contains("fire")
|| s.Contains("frost")
|| s.Contains("poison")
|| s.Contains("plague")
|| s.Contains("magic")
|| s.Contains("divine")
|| s.Contains("demon")
|| s.Contains("undead")
|| s.Contains("dragon")
|| s.Contains("evolve")
|| s.Contains("mutate");
}
private static void Ensure() =>
LiveLibraryInterest.EnsureSeeded(
Entries,
ActivityAssetCatalog.EnumerateLiveSubspeciesTraitIds,
"Evolution",
"{a} evolves {id}",
ambientStrength: 34f,
signalIds: null,
signalStrength: 78f,
signalPredicate: IsDramatic);
public static IEnumerable<string> AuthoredIds
{
get
{
Ensure();
return Entries.Keys;
}
}
public static DiscreteEventEntry GetOrFallback(string id)
{
Ensure();
return LiveLibraryInterest.Lookup(Entries, id, "Evolution", "{a} evolves {id}", 36f);
}
}
/// <summary>Genes - Ambient default.</summary>
public static class Gene
{
private static readonly Dictionary<string, DiscreteEventEntry> Entries =
new Dictionary<string, DiscreteEventEntry>(StringComparer.OrdinalIgnoreCase);
private static void Ensure() =>
LiveLibraryInterest.EnsureSeeded(
Entries,
ActivityAssetCatalog.EnumerateLiveGeneIds,
"Genetics",
"{a} gains gene {id}",
ambientStrength: 30f,
signalIds: null,
signalStrength: 70f,
signalPredicate: id => id != null && (id.Contains("warfare") || id.Contains("magic") || id.Contains("mutation")),
ambientCreatesInterest: false);
public static IEnumerable<string> AuthoredIds
{
get
{
Ensure();
return Entries.Keys;
}
}
public static DiscreteEventEntry GetOrFallback(string id)
{
Ensure();
return LiveLibraryInterest.Lookup(Entries, id, "Genetics", "{a} gains gene {id}", 30f);
}
}
/// <summary>Phenotypes - Ambient default.</summary>
public static class Phenotype
{
private static readonly Dictionary<string, DiscreteEventEntry> Entries =
new Dictionary<string, DiscreteEventEntry>(StringComparer.OrdinalIgnoreCase);
private static void Ensure() =>
LiveLibraryInterest.EnsureSeeded(
Entries,
ActivityAssetCatalog.EnumerateLivePhenotypeIds,
"Genetics",
"{a} shows {id}",
ambientStrength: 28f,
signalIds: null,
signalStrength: 60f,
ambientCreatesInterest: false);
public static IEnumerable<string> AuthoredIds
{
get
{
Ensure();
return Entries.Keys;
}
}
public static DiscreteEventEntry GetOrFallback(string id)
{
Ensure();
return LiveLibraryInterest.Lookup(Entries, id, "Genetics", "{a} shows {id}", 28f);
}
}
/// <summary>World laws - Ambient / location-only style.</summary>
public static class WorldLaw
{
private static readonly Dictionary<string, DiscreteEventEntry> Entries =
new Dictionary<string, DiscreteEventEntry>(StringComparer.OrdinalIgnoreCase);
private static void Ensure() =>
LiveLibraryInterest.EnsureSeeded(
Entries,
ActivityAssetCatalog.EnumerateLiveWorldLawIds,
"WorldLaw",
"Law changed: {id}",
ambientStrength: 40f,
signalIds: null,
signalStrength: 70f,
signalPredicate: id => id != null && (id.Contains("war") || id.Contains("death") || id.Contains("plague")),
ambientCreatesInterest: false);
public static IEnumerable<string> AuthoredIds
{
get
{
Ensure();
return Entries.Keys;
}
}
public static DiscreteEventEntry GetOrFallback(string id)
{
Ensure();
return LiveLibraryInterest.Lookup(Entries, id, "WorldLaw", "Law changed: {id}", 40f);
}
}
/// <summary>Biomes - Ambient.</summary>
public static class Biome
{
private static readonly Dictionary<string, DiscreteEventEntry> Entries =
new Dictionary<string, DiscreteEventEntry>(StringComparer.OrdinalIgnoreCase);
private static void Ensure() =>
LiveLibraryInterest.EnsureSeeded(
Entries,
ActivityAssetCatalog.EnumerateLiveBiomeIds,
"Biome",
"Biome shifts: {id}",
ambientStrength: 26f,
signalIds: null,
signalStrength: 50f,
ambientCreatesInterest: false);
public static IEnumerable<string> AuthoredIds
{
get
{
Ensure();
return Entries.Keys;
}
}
public static DiscreteEventEntry GetOrFallback(string id)
{
Ensure();
return LiveLibraryInterest.Lookup(Entries, id, "Biome", "Biome shifts: {id}", 26f);
}
}
}

View file

@ -0,0 +1,85 @@
using System;
using System.Collections.Generic;
namespace IdleSpectator;
/// <summary>Authored interest overlay for every live plots_library asset.</summary>
public static partial class EventCatalog
{
public static class Plot
{
private static readonly Dictionary<string, DiscreteEventEntry> Entries =
new Dictionary<string, DiscreteEventEntry>(StringComparer.OrdinalIgnoreCase);
static Plot()
{
Add("rebellion", 92f, "Politics", "{a} is plotting a rebellion");
Add("new_war", 94f, "Politics", "{a} is plotting a war");
Add("alliance_create", 80f, "Politics", "{a} is plotting an alliance");
Add("alliance_join", 72f, "Politics", "{a} is joining an alliance");
Add("alliance_destroy", 86f, "Politics", "{a} is breaking an alliance");
Add("attacker_stop_war", 78f, "Politics", "{a} is ending a war");
Add("new_book", 55f, "Culture", "{a} is writing a book");
Add("new_language", 62f, "Culture", "{a} is forging a language");
Add("new_religion", 70f, "Culture", "{a} is founding a religion");
Add("new_culture", 68f, "Culture", "{a} is founding a culture");
Add("clan_ascension", 82f, "Politics", "{a} is ascending a clan");
Add("culture_divide", 75f, "Culture", "{a} splits a culture");
Add("religion_schism", 84f, "Culture", "{a} causes a religion schism");
Add("language_divergence", 60f, "Culture", "{a} splits a language");
Add("summon_meteor_rain", 96f, "Spectacle", "{a} summons a meteor rain");
Add("summon_earthquake", 95f, "Spectacle", "{a} summons an earthquake");
Add("summon_thunderstorm", 93f, "Spectacle", "{a} summons a thunderstorm");
Add("summon_stormfront", 93f, "Spectacle", "{a} summons a stormfront");
Add("summon_hellstorm", 97f, "Spectacle", "{a} summons a hellstorm");
Add("summon_demons", 96f, "Spectacle", "{a} summons demons");
Add("summon_angles", 96f, "Spectacle", "{a} summons angels");
Add("summon_skeletons", 92f, "Spectacle", "{a} summons skeletons");
Add("summon_living_plants", 90f, "Spectacle", "{a} summons living plants");
Add("big_cast_coffee", 58f, "Spectacle", "{a} performs a coffee ritual");
Add("big_cast_bubble_shield", 64f, "Spectacle", "{a} casts a bubble shield");
Add("big_cast_madness", 80f, "Spectacle", "{a} casts madness");
Add("big_cast_slowness", 70f, "Spectacle", "{a} casts slowness");
Add("cause_rebellion", 90f, "Politics", "{a} causes a rebellion");
}
private static void Add(
string id,
float strength,
string category,
string label)
{
Entries[id] = new DiscreteEventEntry
{
Id = id,
EventStrength = strength,
Category = category,
CreatesInterest = true,
LabelTemplate = label
};
}
public static IEnumerable<string> AuthoredIds => Entries.Keys;
public static bool HasAuthored(string id) =>
!string.IsNullOrEmpty(id) && Entries.ContainsKey(id.Trim());
public static DiscreteEventEntry GetOrFallback(string id)
{
if (!string.IsNullOrEmpty(id) && Entries.TryGetValue(id.Trim(), out DiscreteEventEntry entry))
{
return entry;
}
string key = string.IsNullOrEmpty(id) ? "unknown" : id.Trim();
return new DiscreteEventEntry
{
Id = key,
EventStrength = 55f,
Category = "Plot",
LabelTemplate = "{a} · {id}",
IsFallback = true
};
}
}
}

View file

@ -0,0 +1,69 @@
using System;
using System.Collections.Generic;
namespace IdleSpectator;
/// <summary>Authored discrete relationship lifecycle events (not a live asset library).</summary>
public static partial class EventCatalog
{
public static class Relationship
{
private static readonly Dictionary<string, DiscreteEventEntry> Entries =
new Dictionary<string, DiscreteEventEntry>(StringComparer.OrdinalIgnoreCase);
static Relationship()
{
Add("set_lover", 72f, "Relationship", "{a} became lovers with {b}");
Add("clear_lover", 55f, "Relationship", "{a} parted from a lover");
Add("add_child", 68f, "Relationship", "{a} gains a child, {b}");
Add("new_family", 70f, "Relationship", "{a} starts a new family");
Add("family_removed", 50f, "Relationship", "{a}'s family ends");
Add("find_lover", 45f, "Relationship", "{a} is seeking a lover");
Add("family_group_new", 60f, "Relationship", "{a} forms a family pack");
Add("family_group_join", 36f, "Relationship", "{a} joins a family pack", createsInterest: false);
Add("family_group_leave", 36f, "Relationship", "{a} leaves a family pack", createsInterest: false);
Add("baby_created", 70f, "Relationship", "{a} is created as a baby");
}
private static void Add(
string id,
float strength,
string category,
string label,
bool createsInterest = true)
{
Entries[id] = new DiscreteEventEntry
{
Id = id,
EventStrength = strength,
Category = category,
CreatesInterest = createsInterest,
LabelTemplate = label
};
}
public static IEnumerable<string> AuthoredIds => Entries.Keys;
public static bool HasAuthored(string id) =>
!string.IsNullOrEmpty(id) && Entries.ContainsKey(id.Trim());
public static DiscreteEventEntry GetOrFallback(string id)
{
if (!string.IsNullOrEmpty(id) && Entries.TryGetValue(id.Trim(), out DiscreteEventEntry entry))
{
return entry;
}
string key = string.IsNullOrEmpty(id) ? "unknown" : id.Trim();
return new DiscreteEventEntry
{
Id = key,
EventStrength = 40f,
Category = "Relationship",
LabelTemplate = "{a} · {id}",
CreatesInterest = false,
IsFallback = true
};
}
}
}

View file

@ -0,0 +1,151 @@
using System;
using System.Collections.Generic;
namespace IdleSpectator;
public sealed class StatusInterestEntry
{
public string Id = "";
public float EventStrength = 40f;
public string Category = "Status";
public bool CreatesInterest;
public bool ExtendsGrief;
public bool IsFallback;
}
/// <summary>
/// Authored interest policy for every live status asset. Prose stays in ActivityStatusProse.
/// </summary>
public static partial class EventCatalog
{
public static class Status
{
private static readonly Dictionary<string, StatusInterestEntry> Entries =
new Dictionary<string, StatusInterestEntry>(StringComparer.OrdinalIgnoreCase);
static Status()
{
// Spectacle / camera-worthy transformations
Add("burning", 70f, "StatusTransformation", true);
Add("possessed", 72f, "StatusTransformation", true);
Add("possessed_follower", 60f, "StatusTransformation", true);
Add("frozen", 65f, "StatusTransformation", true);
Add("poisoned", 58f, "StatusTransformation", true);
Add("drowning", 50f, "StatusTransformation", true);
Add("stunned", 50f, "StatusTransformation", true);
Add("rage", 62f, "StatusTransformation", true);
Add("angry", 48f, "StatusTransformation", true);
Add("cursed", 60f, "StatusTransformation", true);
Add("soul_harvested", 75f, "StatusTransformation", true);
Add("voices_in_my_head", 55f, "StatusTransformation", true);
Add("tantrum", 52f, "StatusTransformation", true);
Add("egg", 40f, "StatusTransformation", false);
Add("magnetized", 55f, "StatusTransformation", true);
Add("invincible", 40f, "StatusTransformation", false);
Add("powerup", 54f, "StatusTransformation", true);
Add("enchanted", 52f, "StatusTransformation", true);
Add("ash_fever", 50f, "StatusTransformation", true);
Add("starving", 48f, "StatusTransformation", true);
Add("surprised", 42f, "StatusTransformation", true);
Add("confused", 40f, "StatusTransformation", true);
Add("strange_urge", 45f, "StatusTransformation", true);
Add("inspired", 42f, "Emotion", true);
Add("motivated", 40f, "Emotion", true);
Add("fell_in_love", 42f, "Relationship", false);
Add("pregnant", 58f, "LifeChapter", true);
Add("pregnant_parthenogenesis", 58f, "LifeChapter", true);
Add("crying", 50f, "Grief", true, extendsGrief: true);
// Authored Ambient - never owns camera (task chip / enrichment only).
Add("festive_spirit", 28f, "StatusAmbient", false);
Add("laughing", 26f, "StatusAmbient", false);
Add("singing", 26f, "StatusAmbient", false);
Add("sleeping", 22f, "StatusAmbient", false);
Add("handsome_migrant", 30f, "StatusAmbient", false);
// Authored but do not create interest candidates (cooldowns / ambient / brief FX)
AddNoInterest("afterglow");
AddNoInterest("being_suspicious");
AddNoInterest("budding");
AddNoInterest("caffeinated");
AddNoInterest("cough");
AddNoInterest("dash");
AddNoInterest("dodge");
AddNoInterest("flicked");
AddNoInterest("had_bad_dream");
AddNoInterest("had_good_dream");
AddNoInterest("had_nightmare");
AddNoInterest("just_ate");
AddNoInterest("on_guard");
AddNoInterest("recovery_combat_action");
AddNoInterest("recovery_plot");
AddNoInterest("recovery_social");
AddNoInterest("recovery_spell");
AddNoInterest("shield");
AddNoInterest("slowness");
AddNoInterest("spell_boost");
AddNoInterest("spell_silence");
AddNoInterest("swearing");
AddNoInterest("taking_roots");
AddNoInterest("uprooting");
}
private static void Add(
string id,
float strength,
string category,
bool createsInterest,
bool extendsGrief = false)
{
Entries[id] = new StatusInterestEntry
{
Id = id,
EventStrength = strength,
Category = category,
CreatesInterest = createsInterest,
ExtendsGrief = extendsGrief
};
}
private static void AddNoInterest(string id)
{
Add(id, 20f, "StatusAmbient", false);
}
public static IEnumerable<string> AuthoredIds => Entries.Keys;
public static bool HasAuthored(string statusId)
{
return !string.IsNullOrEmpty(statusId) && Entries.ContainsKey(statusId.Trim());
}
public static bool TryGet(string statusId, out StatusInterestEntry entry)
{
entry = null;
if (string.IsNullOrEmpty(statusId))
{
return false;
}
return Entries.TryGetValue(statusId.Trim(), out entry);
}
public static StatusInterestEntry GetOrFallback(string statusId)
{
if (TryGet(statusId, out StatusInterestEntry entry))
{
return entry;
}
string id = string.IsNullOrEmpty(statusId) ? "unknown" : statusId.Trim();
return new StatusInterestEntry
{
Id = id,
EventStrength = 40f,
Category = "Status",
CreatesInterest = false,
IsFallback = true
};
}
}
}

View file

@ -0,0 +1,169 @@
using System;
using System.Collections.Generic;
namespace IdleSpectator;
/// <summary>Authored interest overlay for every live traits library asset.</summary>
public static partial class EventCatalog
{
public static class Trait
{
private static readonly Dictionary<string, DiscreteEventEntry> Entries =
new Dictionary<string, DiscreteEventEntry>(StringComparer.OrdinalIgnoreCase);
static Trait()
{
Add("zombie", 82f);
Add("wise", 42f);
Add("whirlwind", 82f);
Add("weightless", 42f);
Add("weak", 42f);
Add("veteran", 42f);
Add("venomous", 82f);
Add("unlucky", 42f);
Add("ugly", 42f);
Add("tumor_infection", 82f);
Add("tough", 42f);
Add("titan_lungs", 42f);
Add("tiny", 42f);
Add("thorns", 42f);
Add("thief", 42f);
Add("super_health", 42f);
Add("sunblessed", 42f);
Add("stupid", 42f);
Add("strong_minded", 42f);
Add("strong", 42f);
Add("soft_skin", 42f);
Add("slow", 42f);
Add("skin_burns", 42f);
Add("short_sighted", 42f);
Add("shiny", 42f);
Add("scar_of_divinity", 82f);
Add("savage", 82f);
Add("regeneration", 42f);
Add("pyromaniac", 82f);
Add("psychopath", 82f);
Add("poisonous", 82f);
Add("poison_immune", 42f);
Add("plague", 82f);
Add("peaceful", 42f);
Add("paranoid", 42f);
Add("pacifist", 42f);
Add("nightchild", 42f);
Add("mute", 42f);
Add("mush_spores", 82f);
Add("moonchild", 42f);
Add("miracle_born", 82f);
Add("miracle_bearer", 82f);
Add("miner", 42f);
Add("metamorphed", 42f);
Add("mega_heartbeat", 42f);
Add("mageslayer", 82f);
Add("madness", 82f);
Add("lustful", 42f);
Add("lucky", 42f);
Add("long_liver", 42f);
Add("light_lamp", 42f);
Add("kingslayer", 82f);
Add("infertile", 42f);
Add("infected", 82f);
Add("immune", 42f);
Add("immortal", 82f);
Add("hotheaded", 42f);
Add("honest", 42f);
Add("heliophobia", 42f);
Add("heart_of_wizard", 42f);
Add("healing_aura", 42f);
Add("hard_skin", 42f);
Add("greedy", 42f);
Add("golden_tooth", 42f);
Add("gluttonous", 42f);
Add("giant", 82f);
Add("genius", 42f);
Add("freeze_proof", 42f);
Add("fragile_health", 42f);
Add("flower_prints", 42f);
Add("flesh_eater", 82f);
Add("fire_proof", 42f);
Add("fire_blood", 82f);
Add("fertile", 42f);
Add("fat", 42f);
Add("fast", 42f);
Add("eyepatch", 42f);
Add("evil", 82f);
Add("energized", 42f);
Add("eagle_eyed", 42f);
Add("dragonslayer", 82f);
Add("dodge", 42f);
Add("desire_harp", 42f);
Add("desire_golden_egg", 42f);
Add("desire_computer", 42f);
Add("desire_alien_mold", 42f);
Add("deflect_projectile", 42f);
Add("deceitful", 42f);
Add("death_nuke", 82f);
Add("death_mark", 82f);
Add("death_bomb", 82f);
Add("dash", 42f);
Add("crippled", 42f);
Add("content", 42f);
Add("contagious", 82f);
Add("cold_aura", 82f);
Add("clumsy", 42f);
Add("clone", 42f);
Add("chosen_one", 82f);
Add("burning_feet", 82f);
Add("bubble_defense", 42f);
Add("boosted_vitality", 42f);
Add("bomberman", 82f);
Add("boat", 42f);
Add("bloodlust", 82f);
Add("block", 42f);
Add("blessed", 42f);
Add("battle_reflexes", 42f);
Add("backstep", 42f);
Add("attractive", 42f);
Add("arcane_reflexes", 42f);
Add("ambitious", 42f);
Add("agile", 42f);
Add("acid_touch", 82f);
Add("acid_proof", 42f);
Add("acid_blood", 42f);
}
private static void Add(string id, float strength)
{
Entries[id] = new DiscreteEventEntry
{
Id = id,
EventStrength = strength,
Category = "Trait",
CreatesInterest = true,
LabelTemplate = "Trait: {id} ({a})"
};
}
public static IEnumerable<string> AuthoredIds => Entries.Keys;
public static bool HasAuthored(string id) =>
!string.IsNullOrEmpty(id) && Entries.ContainsKey(id.Trim());
public static DiscreteEventEntry GetOrFallback(string id)
{
if (!string.IsNullOrEmpty(id) && Entries.TryGetValue(id.Trim(), out DiscreteEventEntry entry))
{
return entry;
}
string key = string.IsNullOrEmpty(id) ? "unknown" : id.Trim();
return new DiscreteEventEntry
{
Id = key,
EventStrength = 40f,
Category = "Trait",
LabelTemplate = "Trait: {id} ({a})",
IsFallback = true
};
}
}
}

View file

@ -0,0 +1,95 @@
using System;
using System.Collections.Generic;
namespace IdleSpectator;
public sealed class WarTypeInterestEntry
{
public string Id = "";
public float EventStrength = 85f;
public string Category = "Politics";
public string LabelTemplate = "War ({id})";
public bool CreatesInterest = true;
public bool IsFallback;
}
/// <summary>Authored interest overlay for every live war_types_library asset.</summary>
public static partial class EventCatalog
{
public static class WarType
{
private static readonly Dictionary<string, WarTypeInterestEntry> Entries =
new Dictionary<string, WarTypeInterestEntry>(StringComparer.OrdinalIgnoreCase);
static WarType()
{
Add("normal", 90f, "War: {a}");
Add("spite", 88f, "Spite war: {a}");
Add("inspire", 86f, "Inspired war: {a}");
Add("rebellion", 92f, "Rebellion: {a}");
Add("whisper_of_war", 84f, "Whisper of war: {a}");
}
private static void Add(string id, float strength, string labelTemplate)
{
Entries[id] = new WarTypeInterestEntry
{
Id = id,
EventStrength = strength,
Category = "Politics",
LabelTemplate = labelTemplate,
CreatesInterest = true
};
}
public static IEnumerable<string> AuthoredIds => Entries.Keys;
public static bool HasAuthored(string warTypeId)
{
return !string.IsNullOrEmpty(warTypeId) && Entries.ContainsKey(warTypeId.Trim());
}
public static bool TryGet(string warTypeId, out WarTypeInterestEntry entry)
{
entry = null;
if (string.IsNullOrEmpty(warTypeId))
{
return false;
}
return Entries.TryGetValue(warTypeId.Trim(), out entry);
}
public static WarTypeInterestEntry GetOrFallback(string warTypeId)
{
if (TryGet(warTypeId, out WarTypeInterestEntry entry))
{
return entry;
}
string id = string.IsNullOrEmpty(warTypeId) ? "unknown" : warTypeId.Trim();
return new WarTypeInterestEntry
{
Id = id,
EventStrength = 85f,
Category = "Politics",
LabelTemplate = "War ({id}): {a}",
CreatesInterest = true,
IsFallback = true
};
}
public static string MakeLabel(WarTypeInterestEntry entry, string special1)
{
if (entry == null)
{
return "War";
}
string template = string.IsNullOrEmpty(entry.LabelTemplate) ? "War ({id})" : entry.LabelTemplate;
return template
.Replace("{id}", entry.Id ?? "")
.Replace("{a}", special1 ?? "");
}
}
}

View file

@ -0,0 +1,193 @@
using System;
using System.Collections.Generic;
namespace IdleSpectator;
public sealed class WorldLogEventEntry
{
public string Id = "";
public float EventStrength = 40f;
public string Category = "WorldLog";
public bool CreatesInterest = true;
public bool ChronicleEligible;
public float MinWatch = 6f;
public float MaxWatch = 28f;
public string LabelTemplate = "{id}";
public bool IsFallback;
public string MakeLabel(string special1, string special2)
{
string a = special1 ?? "";
string b = special2 ?? "";
string template = string.IsNullOrEmpty(LabelTemplate) ? "{id}" : LabelTemplate;
return template
.Replace("{id}", Id ?? "")
.Replace("{a}", a)
.Replace("{b}", b)
.Replace("{special1}", a)
.Replace("{special2}", b);
}
public string MakeLabel(WorldLogMessage message)
{
if (message == null)
{
return MakeLabel("", "");
}
return MakeLabel(message.special1, message.special2);
}
}
/// <summary>
/// Authored policy for every live WorldLog asset. Game library is inventory; this is presentation/scoring only.
/// </summary>
public static partial class EventCatalog
{
public static class WorldLog
{
private static readonly Dictionary<string, WorldLogEventEntry> Entries =
new Dictionary<string, WorldLogEventEntry>(StringComparer.OrdinalIgnoreCase);
static WorldLog()
{
// Kings
Add("king_new", 78f, "Politics", true, "New king: {b} ({a})", 6f, 28f);
Add("king_left", 65f, "Politics", true, "King left: {b} ({a})", 6f, 28f);
Add("king_fled_capital", 70f, "Politics", true, "King fled capital: {b}", 6f, 28f);
Add("king_fled_city", 68f, "Politics", true, "King fled city: {b}", 6f, 28f);
Add("king_dead", 80f, "Politics", true, "King died: {b} ({a})", 6f, 28f);
Add("king_killed", 85f, "Politics", true, "King killed: {b} ({a})", 6f, 28f);
// Favorites
Add("favorite_dead", 82f, "Politics", true, "Favorite fell: {a}", 6f, 28f);
Add("favorite_killed", 85f, "Politics", true, "Favorite fell: {a}", 6f, 28f);
// Cities / kingdoms / clans / wars / alliances
Add("city_new", 72f, "Settlement", true, "New city: {a}", 6f, 28f);
Add("log_city_revolted", 90f, "Politics", true, "Revolt: {a}", 8f, 35f);
Add("city_destroyed", 92f, "Settlement", true, "City destroyed: {a}", 8f, 35f);
Add("diplomacy_war_ended", 72f, "Politics", true, "War ended: {a}", 6f, 28f);
Add("diplomacy_war_started", 100f, "Politics", true, "War: {a} vs {b}", 8f, 35f);
Add("total_war_started", 100f, "Politics", true, "Total war: {a}", 8f, 35f);
Add("alliance_new", 70f, "Politics", true, "Alliance: {a}", 6f, 28f);
Add("alliance_dissolved", 68f, "Politics", true, "Alliance dissolved: {a}", 6f, 28f);
Add("kingdom_new", 75f, "Settlement", true, "New kingdom: {a}", 6f, 28f);
Add("kingdom_destroyed", 100f, "Politics", true, "Kingdom fell: {a}", 8f, 35f);
Add("kingdom_shattered", 98f, "Politics", true, "Kingdom shattered: {a}", 8f, 35f);
Add("kingdom_fractured", 95f, "Politics", true, "Kingdom fractured: {a}", 8f, 35f);
Add("kingdom_royal_clan_new", 74f, "Politics", true, "Royal clan: {a}", 6f, 28f);
Add("kingdom_royal_clan_changed", 72f, "Politics", true, "Royal clan: {a}", 6f, 28f);
Add("kingdom_royal_clan_dead", 76f, "Politics", true, "Royal clan ended: {a}", 6f, 28f);
// Disasters (WorldLog ids; linked to disasters library by naming)
Add("disaster_tornado", 95f, "Disaster", true, "Tornado: {a}", 8f, 35f);
Add("disaster_meteorite", 95f, "Disaster", true, "Meteorite: {a}", 8f, 35f);
Add("disaster_hellspawn", 96f, "Disaster", true, "Hellspawn: {a}", 8f, 35f);
Add("disaster_earthquake", 95f, "Disaster", true, "Earthquake: {a}", 8f, 35f);
Add("disaster_greg_abominations", 96f, "Disaster", true, "Greg abominations: {a}", 8f, 35f);
Add("disaster_ice_ones", 95f, "Disaster", true, "Ice ones awaken: {a}", 8f, 35f);
Add("disaster_sudden_snowman", 90f, "Disaster", true, "Sudden snowman: {a}", 8f, 35f);
Add("disaster_garden_surprise", 90f, "Disaster", true, "Garden surprise: {a}", 8f, 35f);
Add("disaster_dragon_from_farlands", 98f, "Disaster", true, "Dragon from the farlands: {a}", 8f, 35f);
Add("disaster_bandits", 92f, "Disaster", true, "Bandit raid: {a}", 8f, 35f);
Add("disaster_alien_invasion", 97f, "Disaster", true, "Alien invasion: {a}", 8f, 35f);
Add("disaster_biomass", 96f, "Disaster", true, "Biomass outbreak: {a}", 8f, 35f);
Add("disaster_tumor", 96f, "Disaster", true, "Tumor outbreak: {a}", 8f, 35f);
Add("disaster_heatwave", 88f, "Disaster", true, "Heatwave: {a}", 8f, 35f);
Add("disaster_evil_mage", 94f, "Disaster", true, "Evil mage: {a}", 8f, 35f);
Add("disaster_underground_necromancer", 95f, "Disaster", true, "Underground necromancer: {a}", 8f, 35f);
Add("disaster_mad_thoughts", 90f, "Disaster", true, "Mad thoughts: {a}", 8f, 35f);
// Harness / internal - authored but never owns camera interest
Add("auto_tester", 10f, "Harness", false, "Auto tester: {a}", 2f, 6f);
}
private static void Add(
string id,
float strength,
string category,
bool createsInterest,
string labelTemplate,
float minWatch,
float maxWatch)
{
Entries[id] = new WorldLogEventEntry
{
Id = id,
EventStrength = strength,
Category = category,
CreatesInterest = createsInterest,
ChronicleEligible = createsInterest && strength >= 65f,
LabelTemplate = labelTemplate,
MinWatch = minWatch,
MaxWatch = maxWatch
};
}
public static IEnumerable<string> AuthoredIds => Entries.Keys;
public static bool HasAuthored(string assetId)
{
return !string.IsNullOrEmpty(assetId) && Entries.ContainsKey(assetId.Trim());
}
public static bool TryGet(string assetId, out WorldLogEventEntry entry)
{
entry = null;
if (string.IsNullOrEmpty(assetId))
{
return false;
}
return Entries.TryGetValue(assetId.Trim(), out entry);
}
public static WorldLogEventEntry GetOrFallback(string assetId)
{
if (TryGet(assetId, out WorldLogEventEntry entry))
{
return entry;
}
string id = string.IsNullOrEmpty(assetId) ? "unknown" : assetId.Trim();
float strength = 40f;
string category = "WorldLog";
if (id.IndexOf("disaster", StringComparison.OrdinalIgnoreCase) >= 0)
{
strength = 95f;
category = "Disaster";
}
return new WorldLogEventEntry
{
Id = id,
EventStrength = strength,
Category = category,
CreatesInterest = true,
ChronicleEligible = strength >= 65f,
LabelTemplate = "{id}: {a}",
MinWatch = strength >= 90f ? 8f : 6f,
MaxWatch = strength >= 90f ? 35f : 28f,
IsFallback = true
};
}
public static bool TryGetEventStrength(string assetId, out float eventStrength)
{
WorldLogEventEntry entry = GetOrFallback(assetId);
eventStrength = entry.EventStrength;
return !string.IsNullOrEmpty(assetId);
}
public static string MakeLabel(WorldLogMessage message)
{
if (message == null)
{
return "event";
}
return GetOrFallback(message.asset_id).MakeLabel(message);
}
}
}

View file

@ -0,0 +1,83 @@
using System;
using System.Collections.Generic;
namespace IdleSpectator;
/// <summary>
/// Live-seeded interest catalogs: every live library id is authored (Ambient default),
/// with curated Signal overlays for spectacle / kingdom-affecting entries.
/// </summary>
public static class LiveLibraryInterest
{
public static void EnsureSeeded(
Dictionary<string, DiscreteEventEntry> entries,
Func<List<string>> enumerateLive,
string category,
string labelTemplate,
float ambientStrength,
HashSet<string> signalIds,
float signalStrength,
Func<string, bool> signalPredicate = null,
bool ambientCreatesInterest = true)
{
if (entries == null || enumerateLive == null)
{
return;
}
List<string> live;
try
{
live = enumerateLive() ?? new List<string>();
}
catch
{
live = new List<string>();
}
for (int i = 0; i < live.Count; i++)
{
string id = live[i];
if (string.IsNullOrEmpty(id) || entries.ContainsKey(id))
{
continue;
}
bool signal = (signalIds != null && signalIds.Contains(id))
|| (signalPredicate != null && signalPredicate(id));
entries[id] = new DiscreteEventEntry
{
Id = id,
EventStrength = signal ? signalStrength : ambientStrength,
Category = category,
CreatesInterest = signal || ambientCreatesInterest,
LabelTemplate = labelTemplate
};
}
}
public static DiscreteEventEntry Lookup(
Dictionary<string, DiscreteEventEntry> entries,
string id,
string category,
string labelTemplate,
float fallbackStrength = 40f,
bool fallbackCreatesInterest = false)
{
if (!string.IsNullOrEmpty(id) && entries != null && entries.TryGetValue(id.Trim(), out DiscreteEventEntry entry))
{
return entry;
}
string key = string.IsNullOrEmpty(id) ? "unknown" : id.Trim();
return new DiscreteEventEntry
{
Id = key,
EventStrength = fallbackStrength,
Category = category,
LabelTemplate = labelTemplate,
CreatesInterest = fallbackCreatesInterest,
IsFallback = true
};
}
}

View file

@ -0,0 +1,29 @@
namespace IdleSpectator;
/// <summary>
/// Shared catalog row for discrete / library-backed spectator events.
/// Lives in <c>Events/</c> with <see cref="EventReason"/> - catalogs own dials; director only ranks.
/// </summary>
public sealed class DiscreteEventEntry
{
public string Id = "";
public float EventStrength = 50f;
public string Category = "Event";
public bool CreatesInterest = true;
public string LabelTemplate = "{a}";
public bool IsFallback;
public string MakeLabel(Actor a, Actor b = null)
{
return EventReason.Apply(LabelTemplate, a, b, Id);
}
public string MakeLabel(string a, string b)
{
string template = string.IsNullOrEmpty(LabelTemplate) ? "{id}" : LabelTemplate;
return template
.Replace("{id}", Id ?? "")
.Replace("{a}", a ?? "")
.Replace("{b}", b ?? "");
}
}

View file

@ -0,0 +1,36 @@
namespace IdleSpectator;
/// <summary>
/// IdleSpectator event inventory (one type, split across partial files).
/// Call sites: EventCatalog.Status / .Happiness / .Combat / …
/// Director only ranks; feeds register from these dials.
///
/// Domain files under Events/Catalogs/:
/// EventCatalog.Status.cs
/// EventCatalog.Happiness.cs
/// EventCatalog.WorldLog.cs
/// EventCatalog.Trait.cs
/// EventCatalog.Book.cs
/// EventCatalog.Era.cs
/// EventCatalog.Plot.cs
/// EventCatalog.Relationship.cs
/// EventCatalog.Disaster.cs
/// EventCatalog.WarType.cs
/// EventCatalog.Libraries.cs (Spell, Power, Decision, Item, SubspeciesTrait, Gene, Phenotype, WorldLaw, Biome)
/// Combat policy lives in this file.
/// </summary>
public static partial class EventCatalog
{
/// <summary>Combat camera policy (activity + scanner share one key / dials).</summary>
public static class Combat
{
public const float ActivityStrength = 88f;
public const float ScannerStrengthFloor = 80f;
public const float MinWatch = 4f;
public const float MaxWatch = 60f;
public const float ActivityTtl = 20f;
public const float ScannerTtl = 18f;
public static string Key(long subjectId) => "combat:" + subjectId;
}
}

View file

@ -0,0 +1,264 @@
using UnityEngine;
namespace IdleSpectator;
/// <summary>
/// Sole unit-led publish API for the idle director.
/// Every registration is EventLed and may own the camera (score ranks).
/// FollowUnit owns the reason; RelatedUnit required when the label claims a second party;
/// location-only civic events may omit FollowUnit but never invent a stranger subject.
/// </summary>
public static class EventFeedUtil
{
public static InterestCandidate Register(
string key,
string source,
string category,
string label,
string assetId,
float eventStrength,
Vector3 position,
Actor follow,
Actor related = null,
bool locationOnly = false,
float minWatch = 4f,
float maxWatch = 22f,
InterestCompletionKind completion = InterestCompletionKind.FixedDwell)
{
if (string.IsNullOrEmpty(key))
{
return null;
}
Actor subject = follow;
if (subject != null && !subject.isAlive())
{
subject = null;
}
Actor relatedAlive = related;
if (relatedAlive != null && !relatedAlive.isAlive())
{
relatedAlive = null;
}
Vector3 pos = position;
if (subject != null)
{
pos = subject.current_position;
}
if (locationOnly)
{
if (pos == Vector3.zero || float.IsNaN(pos.x))
{
return null;
}
}
else
{
// Unit-led: require a living subject. Never invent a stranger.
if (subject == null)
{
return null;
}
if (pos == Vector3.zero || float.IsNaN(pos.x))
{
return null;
}
}
float strength = eventStrength;
if (strength < 8f)
{
strength = 8f;
}
var candidate = new InterestCandidate
{
Key = key,
LeadKind = InterestLeadKind.EventLed,
Category = string.IsNullOrEmpty(category) ? "Event" : category,
Source = source ?? "event",
EventStrength = strength,
CharacterSignificance = 0f,
VisualConfidence = subject != null ? 0.7f : 0.35f,
Position = pos,
FollowUnit = subject,
RelatedUnit = relatedAlive,
SubjectId = subject != null ? SafeId(subject) : 0,
RelatedId = relatedAlive != null ? SafeId(relatedAlive) : 0,
Label = label ?? assetId ?? key,
AssetId = assetId ?? "",
SpeciesId = subject?.asset != null ? subject.asset.id : "",
CreatedAt = Time.unscaledTime,
LastSeenAt = Time.unscaledTime,
ExpiresAt = Time.unscaledTime + 35f,
MinWatch = minWatch,
MaxWatch = maxWatch,
Completion = completion
};
InterestScoring.ScoreCheap(candidate);
return InterestRegistry.Upsert(candidate);
}
/// <summary>
/// Publish a fully built candidate through the registry (still the sole intake).
/// Prefer the typed Register overload for new feeds.
/// </summary>
public static InterestCandidate RegisterCandidate(InterestCandidate candidate)
{
if (candidate == null || string.IsNullOrEmpty(candidate.Key) || !candidate.HasValidPosition)
{
return null;
}
// Reject stranger subjects: dead follow with no position is already invalid via HasValidPosition.
if (candidate.FollowUnit != null && !candidate.FollowUnit.isAlive())
{
candidate.FollowUnit = null;
candidate.SubjectId = 0;
if (!candidate.HasValidPosition)
{
return null;
}
}
if (candidate.RelatedUnit != null && !candidate.RelatedUnit.isAlive())
{
candidate.RelatedUnit = null;
candidate.RelatedId = 0;
}
if (candidate.FollowUnit != null && candidate.SubjectId == 0)
{
candidate.SubjectId = SafeId(candidate.FollowUnit);
}
if (candidate.RelatedUnit != null && candidate.RelatedId == 0)
{
candidate.RelatedId = SafeId(candidate.RelatedUnit);
}
InterestScoring.ScoreCheap(candidate);
return InterestRegistry.Upsert(candidate);
}
public static long SafeId(Actor actor)
{
if (actor == null)
{
return 0;
}
try
{
return actor.getID();
}
catch
{
return 0;
}
}
public static string SafeName(Actor actor)
{
if (actor == null)
{
return "";
}
try
{
return actor.getName() ?? "";
}
catch
{
return "";
}
}
public static Actor NearestUnit(Vector3 near, float radius = 200f)
{
try
{
return WorldActivityScanner.FindNearestAliveUnit(near, radius);
}
catch
{
return null;
}
}
/// <summary>
/// Harness / diagnostics only. Must not be used as a silent camera subject for EventLed events.
/// </summary>
public static Actor AnyAliveUnit()
{
try
{
if (World.world?.units == null)
{
return null;
}
foreach (Actor actor in World.world.units)
{
if (actor != null && actor.isAlive())
{
return actor;
}
}
}
catch
{
// ignore
}
return null;
}
/// <summary>
/// Resolve a unit-led anchor without stranger fallback.
/// Preferred unit if alive; else nearest at position; else fail (caller may location-only).
/// </summary>
public static bool TryResolveOwnedAnchor(
Actor preferred,
Vector3 position,
out Actor follow,
out Vector3 pos,
bool allowNearestAtPosition = true)
{
follow = null;
pos = position;
if (preferred != null && preferred.isAlive())
{
follow = preferred;
pos = preferred.current_position;
return pos != Vector3.zero && !float.IsNaN(pos.x);
}
if (allowNearestAtPosition && position != Vector3.zero && !float.IsNaN(position.x))
{
follow = NearestUnit(position, 48f);
if (follow != null && follow.isAlive())
{
pos = follow.current_position;
return true;
}
}
follow = null;
pos = position;
return false;
}
/// <summary>Obsolete stranger path. Prefer TryResolveOwnedAnchor or location-only Register.</summary>
[System.Obsolete("Use TryResolveOwnedAnchor or location-only Register. Do not invent subjects.")]
public static bool TryResolveAnchor(Actor preferred, Vector3 position, out Actor follow, out Vector3 pos)
{
return TryResolveOwnedAnchor(preferred, position, out follow, out pos, allowNearestAtPosition: true);
}
}

View file

@ -0,0 +1,371 @@
using System;
using System.Globalization;
using System.Text;
namespace IdleSpectator;
/// <summary>
/// Sole factory for interest Labels shown as the orange dossier reason.
/// Part of the <c>Events/</c> module with catalogs - feeds call these helpers (or Apply)
/// instead of concatenating crumbs. Director never authors reasons.
/// </summary>
public static class EventReason
{
public static string Fight(Actor a, Actor b)
{
string name = Name(a);
if (string.IsNullOrEmpty(name))
{
name = "Someone";
}
string foe = Name(b);
if (string.IsNullOrEmpty(foe))
{
return name + " is fighting";
}
return name + " is fighting " + foe;
}
public static string Battle(Actor focus, int fighters)
{
string name = Name(focus);
if (string.IsNullOrEmpty(name))
{
name = "Someone";
}
int n = Math.Max(1, fighters);
if (n <= 2)
{
return name + " is in a duel";
}
return name + " is in a fight of " + n.ToString(CultureInfo.InvariantCulture);
}
public static string SeekingLover(Actor a)
{
return NameOrSomeone(a) + " is seeking a lover";
}
public static string Hatch(Actor a)
{
return NameOrSomeone(a) + " hatches from an egg";
}
public static string Lovers(Actor a, Actor b)
{
string an = NameOrSomeone(a);
string bn = Name(b);
if (string.IsNullOrEmpty(bn))
{
return an + " found a lover";
}
return an + " became lovers with " + bn;
}
public static string LoverParted(Actor a)
{
return NameOrSomeone(a) + " parted from a lover";
}
public static string FamilyPack(Actor a, Actor peer, string phase)
{
string an = NameOrSomeone(a);
string bn = Name(peer);
string p = (phase ?? "").Trim().ToLowerInvariant();
switch (p)
{
case "join":
case "family_group_join":
return string.IsNullOrEmpty(bn)
? an + " joins a family pack"
: an + " joins a family pack with " + bn;
case "leave":
case "family_group_leave":
return string.IsNullOrEmpty(bn)
? an + " leaves a family pack"
: an + " leaves a family pack with " + bn;
case "form":
case "new":
case "family_group_new":
return string.IsNullOrEmpty(bn)
? an + " forms a family pack"
: an + " forms a family pack with " + bn;
default:
return an + " gathers with kin";
}
}
public static string NewChild(Actor a, Actor b)
{
string an = NameOrSomeone(a);
string bn = Name(b);
if (string.IsNullOrEmpty(bn))
{
return an + " has a new child";
}
// related is the child (Actor.addChild), not a co-parent.
return an + " gains a child, " + bn;
}
public static string BabyBorn(Actor a)
{
// Subject is the baby from createBabyActorFromData.
return NameOrSomeone(a) + " is created as a baby";
}
public static string NewFamily(Actor a)
{
return NameOrSomeone(a) + " starts a new family";
}
public static string FamilyEnded(Actor a)
{
return NameOrSomeone(a) + "'s family ends";
}
public static string BuildingEat(Actor a, Building food)
{
string an = NameOrSomeone(a);
string buildingId = BuildingId(food);
string buildingType = BuildingType(food);
if (string.Equals(buildingType, "type_fruits", StringComparison.OrdinalIgnoreCase))
{
return string.IsNullOrEmpty(buildingId)
? an + " is foraging fruit"
: an + " is foraging " + HumanizeId(buildingId);
}
if (!string.IsNullOrEmpty(buildingId))
{
return an + " is eating a " + HumanizeId(buildingId);
}
return an + " is eating";
}
public static string BuildingDamage(Actor a, Building building)
{
string an = NameOrSomeone(a);
string buildingId = BuildingId(building);
if (!string.IsNullOrEmpty(buildingId))
{
return an + " is damaging a " + HumanizeId(buildingId);
}
return an + " is damaging a building";
}
public static string BuildingFalls(string buildingId, Actor near)
{
string id = HumanizeId(buildingId);
if (near != null && near.isAlive())
{
string n = Name(near);
if (!string.IsNullOrEmpty(n))
{
return string.IsNullOrEmpty(id)
? "A building falls near " + n
: "A " + id + " falls near " + n;
}
}
return string.IsNullOrEmpty(id) ? "A building falls" : "A " + id + " falls";
}
public static string Status(Actor a, string phrase)
{
string an = NameOrSomeone(a);
string p = (phrase ?? "").Trim();
if (string.IsNullOrEmpty(p))
{
return an + " changes status";
}
// Prose often already includes a verb phrase ("is burning").
if (p.StartsWith("is ", StringComparison.OrdinalIgnoreCase)
|| p.StartsWith("are ", StringComparison.OrdinalIgnoreCase))
{
return an + " " + p;
}
return an + " · " + p;
}
public static string Trait(Actor a, string traitId, bool gained)
{
string an = NameOrSomeone(a);
string id = HumanizeId(traitId);
if (string.IsNullOrEmpty(id))
{
id = "a trait";
}
return gained ? an + " gains " + id : an + " loses " + id;
}
public static string Boat(Actor a, string phase)
{
string an = NameOrSomeone(a);
switch ((phase ?? "").Trim().ToLowerInvariant())
{
case "unload":
case "boat_unload":
return an + " is unloading passengers";
case "load":
case "boat_load":
return an + " is loading passengers";
case "trade":
case "boat_trade":
return an + " is trading by boat";
default:
return an + " is on a boat";
}
}
public static string MetaNew(Actor a, string kind)
{
string an = NameOrSomeone(a);
string k = HumanizeId(kind);
if (string.IsNullOrEmpty(k))
{
k = "something new";
}
return an + " founds a " + k;
}
public static string Library(Actor a, string kind, string assetId)
{
string an = NameOrSomeone(a);
string id = HumanizeId(assetId);
string k = HumanizeId(kind);
if (string.IsNullOrEmpty(id))
{
return an + " triggers " + (string.IsNullOrEmpty(k) ? "an event" : k);
}
switch ((kind ?? "").Trim().ToLowerInvariant())
{
case "spell":
return an + " casts " + id;
case "item":
return an + " forges " + id;
case "decision":
return an + " decides " + id;
case "gene":
return an + " gains gene " + id;
case "phenotype":
return an + " shows phenotype " + id;
case "power":
return an + " channels " + id;
case "subspecies_trait":
return an + " gains " + id;
default:
return an + " · " + id;
}
}
/// <summary>
/// Apply a sentence template with {a}/{b}/{id}. Prefer typed helpers for new copy.
/// </summary>
public static string Apply(string template, Actor a, Actor b = null, string id = "")
{
string t = string.IsNullOrEmpty(template) ? "{a}" : template;
return t
.Replace("{id}", id ?? "")
.Replace("{a}", Name(a) ?? "")
.Replace("{b}", Name(b) ?? "");
}
public static string HumanizeId(string id)
{
if (string.IsNullOrEmpty(id))
{
return "";
}
string raw = id.Trim().Replace('-', '_');
if (raw.StartsWith("type_", StringComparison.OrdinalIgnoreCase))
{
raw = raw.Substring(5);
}
string[] parts = raw.Split(new[] { '_' }, StringSplitOptions.RemoveEmptyEntries);
if (parts.Length == 0)
{
return raw;
}
var sb = new StringBuilder(raw.Length + 4);
for (int i = 0; i < parts.Length; i++)
{
if (i > 0)
{
sb.Append(' ');
}
string p = parts[i];
if (p.Length == 0)
{
continue;
}
sb.Append(char.ToUpperInvariant(p[0]));
if (p.Length > 1)
{
sb.Append(p.Substring(1).ToLowerInvariant());
}
}
return sb.ToString();
}
private static string NameOrSomeone(Actor a)
{
string n = Name(a);
return string.IsNullOrEmpty(n) ? "Someone" : n;
}
private static string Name(Actor a) => EventFeedUtil.SafeName(a);
private static string BuildingId(Building building)
{
if (building?.asset == null)
{
return "";
}
try
{
return building.asset.id ?? "";
}
catch
{
return "";
}
}
private static string BuildingType(Building building)
{
if (building?.asset == null)
{
return "";
}
try
{
return building.asset.type ?? "";
}
catch
{
return "";
}
}
}

View file

@ -0,0 +1,82 @@
using UnityEngine;
namespace IdleSpectator;
/// <summary>Registers book creation/burn interest candidates.</summary>
public static class BookInterestFeed
{
public static void Emit(string bookTypeId, Actor subject, string phase)
{
if (AgentHarness.Busy)
{
return;
}
DiscreteEventEntry entry = EventCatalog.Book.GetOrFallback(bookTypeId);
if (!entry.CreatesInterest)
{
return;
}
Actor follow = subject;
if (follow == null || !follow.isAlive())
{
// Book without a living author: skip rather than invent a stranger.
return;
}
string label = EventReason.Apply(entry.LabelTemplate, follow, id: entry.Id);
if (!string.IsNullOrEmpty(phase) && phase == "burn")
{
label = EventFeedUtil.SafeName(follow) + " burns a book";
}
string key = "book:" + entry.Id + ":" + (phase ?? "new") + ":" + EventFeedUtil.SafeId(follow);
float strength = phase == "burn" ? Mathf.Max(entry.EventStrength, 70f) : entry.EventStrength;
EventFeedUtil.Register(
key,
"book",
entry.Category,
label,
entry.Id,
strength,
follow.current_position,
follow);
}
public static void EmitFromBook(object book, string phase)
{
if (book == null)
{
return;
}
string typeId = "unknown";
Actor author = null;
try
{
object asset = book.GetType().GetField("asset")?.GetValue(book)
?? book.GetType().GetProperty("asset")?.GetValue(book, null)
?? book.GetType().GetMethod("getAsset")?.Invoke(book, null);
if (asset != null)
{
object id = asset.GetType().GetField("id")?.GetValue(asset)
?? asset.GetType().GetProperty("id")?.GetValue(asset, null);
if (id != null)
{
typeId = id.ToString();
}
}
author = book.GetType().GetField("author")?.GetValue(book) as Actor
?? book.GetType().GetProperty("author")?.GetValue(book, null) as Actor
?? book.GetType().GetMethod("getAuthor")?.Invoke(book, null) as Actor;
}
catch
{
// ignore
}
Emit(typeId, author, phase);
}
}

View file

@ -0,0 +1,199 @@
using UnityEngine;
namespace IdleSpectator;
/// <summary>Unit-owned emits for deferred Phase 2 libraries (spells/items/decisions/etc.).</summary>
public static class DeferredInterestFeed
{
public static void EmitSpell(string spellId, Actor caster)
{
EmitFromCatalog(
"spell",
spellId,
caster,
EventCatalog.Spell.GetOrFallback,
related: null,
locationOnly: false);
}
public static void EmitItem(string itemId, Actor craftsman)
{
EmitFromCatalog(
"item",
itemId,
craftsman,
EventCatalog.Item.GetOrFallback,
related: null,
locationOnly: false);
}
public static void EmitDecision(string decisionId, Actor actor)
{
EmitFromCatalog(
"decision",
decisionId,
actor,
EventCatalog.Decision.GetOrFallback,
related: null,
locationOnly: false);
}
public static void EmitPower(string powerId, Vector3 position, Actor nearUnit)
{
DiscreteEventEntry entry = EventCatalog.Power.GetOrFallback(powerId);
if (!entry.CreatesInterest || AgentHarness.Busy)
{
return;
}
if (nearUnit != null && nearUnit.isAlive())
{
EmitFromCatalog("power", powerId, nearUnit, EventCatalog.Power.GetOrFallback, null, false);
return;
}
if (position == Vector3.zero)
{
return;
}
EventFeedUtil.Register(
"power:" + entry.Id,
"power",
entry.Category,
EventReason.HumanizeId(entry.Id),
entry.Id,
entry.EventStrength,
position,
follow: null,
locationOnly: true);
}
public static void EmitSubspeciesTrait(string traitId, Actor anchor)
{
EmitFromCatalog(
"subspecies_trait",
traitId,
anchor,
EventCatalog.SubspeciesTrait.GetOrFallback,
related: null,
locationOnly: false);
}
public static void EmitGene(string geneId, Actor actor)
{
EmitFromCatalog(
"gene",
geneId,
actor,
EventCatalog.Gene.GetOrFallback,
related: null,
locationOnly: false);
}
public static void EmitPhenotype(string phenotypeId, Actor actor)
{
EmitFromCatalog(
"phenotype",
phenotypeId,
actor,
EventCatalog.Phenotype.GetOrFallback,
related: null,
locationOnly: false);
}
public static void EmitWorldLaw(string lawId, Vector3 position)
{
if (AgentHarness.Busy || position == Vector3.zero)
{
return;
}
DiscreteEventEntry entry = EventCatalog.WorldLaw.GetOrFallback(lawId);
if (!entry.CreatesInterest)
{
return;
}
EventFeedUtil.Register(
"worldlaw:" + entry.Id,
"worldlaw",
entry.Category,
EventReason.HumanizeId(entry.Id),
entry.Id,
entry.EventStrength,
position,
follow: null,
locationOnly: true);
}
public static void EmitBiome(string biomeId, Vector3 position)
{
if (AgentHarness.Busy || position == Vector3.zero)
{
return;
}
DiscreteEventEntry entry = EventCatalog.Biome.GetOrFallback(biomeId);
if (!entry.CreatesInterest)
{
return;
}
EventFeedUtil.Register(
"biome:" + entry.Id,
"biome",
entry.Category,
EventReason.HumanizeId(entry.Id),
entry.Id,
entry.EventStrength,
position,
follow: null,
locationOnly: true);
}
private static void EmitFromCatalog(
string source,
string assetId,
Actor subject,
System.Func<string, DiscreteEventEntry> lookup,
Actor related,
bool locationOnly)
{
if (AgentHarness.Busy || lookup == null)
{
return;
}
DiscreteEventEntry entry = lookup(assetId);
if (!entry.CreatesInterest)
{
return;
}
Actor follow = subject != null && subject.isAlive() ? subject : null;
if (!locationOnly && follow == null)
{
return;
}
string label = EventReason.Library(follow, source, entry.Id);
if (follow == null)
{
label = EventReason.HumanizeId(entry.Id);
}
string key = source + ":" + entry.Id + ":" + EventFeedUtil.SafeId(follow);
EventFeedUtil.Register(
key,
source,
entry.Category,
label,
entry.Id,
entry.EventStrength,
follow != null ? follow.current_position : Vector3.zero,
follow,
related: related,
locationOnly: locationOnly);
}
}

View file

@ -1,3 +1,4 @@
using System;
using System.Collections.Generic;
using UnityEngine;
@ -92,11 +93,13 @@ public static class InterestFeeds
return;
}
if (!WorldLogInterestTable.TryGetEventStrength(message.asset_id, out float evtSeed))
WorldLogEventEntry entry = EventCatalog.WorldLog.GetOrFallback(message.asset_id);
if (!entry.CreatesInterest)
{
return;
}
float evtSeed = entry.EventStrength;
Vector3 position = message.getLocation();
Actor unit = null;
if (message.hasFollowLocation())
@ -104,41 +107,45 @@ public static class InterestFeeds
unit = message.unit;
}
if (unit == null && position == Vector3.zero)
{
return;
}
string assetId = message.asset_id ?? "worldlog";
string assetId = message.asset_id ?? entry.Id ?? "worldlog";
string kingdom = message.special1 ?? "";
string key = "worldlog:" + assetId + ":" + kingdom;
float boost = PeekCivicBoost(kingdom, assetId);
bool politics = evtSeed >= 90f;
string category = string.IsNullOrEmpty(entry.Category) ? "WorldLog" : entry.Category;
string label = entry.MakeLabel(message);
var candidate = new InterestCandidate
if (EventFeedUtil.TryResolveOwnedAnchor(unit, position, out Actor follow, out Vector3 pos))
{
Key = key,
LeadKind = InterestLeadKind.EventLed,
Category = politics ? "Politics" : "Settlement",
Source = "worldlog",
EventStrength = evtSeed + boost,
VisualConfidence = unit != null ? 0.7f : 0.4f,
Position = unit != null ? unit.current_position : position,
FollowUnit = unit,
SubjectId = SafeId(unit),
Label = WorldLogInterestTable.MakeLabel(message),
AssetId = assetId,
KingdomKey = kingdom,
SpeciesId = unit?.asset != null ? unit.asset.id : "",
CreatedAt = Time.unscaledTime,
LastSeenAt = Time.unscaledTime,
ExpiresAt = Time.unscaledTime + 40f,
MinWatch = politics ? 8f : 6f,
MaxWatch = politics ? 35f : 28f,
Completion = InterestCompletionKind.FixedDwell
};
InterestScoring.ScoreCheap(candidate);
InterestRegistry.Upsert(candidate);
EventFeedUtil.Register(
key,
"worldlog",
category,
label,
assetId,
evtSeed + boost,
pos,
follow,
minWatch: entry.MinWatch,
maxWatch: entry.MaxWatch);
return;
}
// Civic / disaster without a real unit: location-only (no stranger subject).
if (position != Vector3.zero && !float.IsNaN(position.x))
{
EventFeedUtil.Register(
key,
"worldlog",
category,
label,
assetId,
evtSeed + boost,
position,
follow: null,
locationOnly: true,
minWatch: entry.MinWatch,
maxWatch: entry.MaxWatch);
}
}
/// <summary>Legacy / discovery / harness enqueue path → registry.</summary>
@ -197,7 +204,7 @@ public static class InterestFeeds
Completion = completion
};
InterestScoring.ScoreCheap(candidate);
return InterestRegistry.Upsert(candidate);
return EventFeedUtil.RegisterCandidate(candidate);
}
public static InterestCandidate RegisterHarness(
@ -287,7 +294,7 @@ public static class InterestFeeds
}
InterestScoring.ScoreCheap(candidate);
return InterestRegistry.Upsert(candidate);
return EventFeedUtil.RegisterCandidate(candidate);
}
public static void OnActivityNote(Actor actor, string taskOrBeat, bool hot)
@ -297,22 +304,42 @@ public static class InterestFeeds
return;
}
// Non-combat hot activity is not an event reason - task chip covers live job.
if (!actor.has_attack_target)
{
return;
}
long id = SafeId(actor);
string verb = taskOrBeat ?? "activity";
string key = "activity:" + id + ":" + verb;
bool combat = actor.has_attack_target;
Actor foe = null;
try
{
if (actor.attack_target != null && actor.attack_target.isAlive() && actor.attack_target.isActor())
{
foe = actor.attack_target.a;
}
}
catch
{
foe = null;
}
string verb = taskOrBeat ?? "fighting";
string key = EventCatalog.Combat.Key(id);
var candidate = new InterestCandidate
{
Key = key,
LeadKind = InterestLeadKind.EventLed,
Category = combat ? "Combat" : "Work",
Category = "Combat",
Source = "activity",
EventStrength = combat ? 75f : 50f,
EventStrength = EventCatalog.Combat.ActivityStrength,
VisualConfidence = 0.8f,
Position = actor.current_position,
FollowUnit = actor,
RelatedUnit = foe,
SubjectId = id,
Label = combat ? ("Fighting: " + SafeName(actor)) : (SafeName(actor) + " · " + verb),
RelatedId = foe != null ? SafeId(foe) : 0,
Label = EventReason.Fight(actor, foe),
AssetId = actor.asset != null ? actor.asset.id : "",
SpeciesId = actor.asset != null ? actor.asset.id : "",
Verb = verb,
@ -320,19 +347,19 @@ public static class InterestFeeds
KingdomKey = actor.kingdom != null ? (actor.kingdom.name ?? "") : "",
CreatedAt = Time.unscaledTime,
LastSeenAt = Time.unscaledTime,
ExpiresAt = Time.unscaledTime + 20f,
MinWatch = 3f,
MaxWatch = 30f,
Completion = combat ? InterestCompletionKind.CombatActive : InterestCompletionKind.ActivityActive
ExpiresAt = Time.unscaledTime + EventCatalog.Combat.ActivityTtl,
MinWatch = EventCatalog.Combat.MinWatch,
MaxWatch = EventCatalog.Combat.MaxWatch,
Completion = InterestCompletionKind.CombatActive
};
candidate.ParticipantIds.Add(id);
InterestScoring.ScoreCheap(candidate);
InterestRegistry.Upsert(candidate);
EventFeedUtil.RegisterCandidate(candidate);
}
public static void OnStatusChange(Actor actor, string statusId, bool gained)
{
if (actor == null || !actor.isAlive() || !gained || string.IsNullOrEmpty(statusId))
if (actor == null || !actor.isAlive() || string.IsNullOrEmpty(statusId))
{
return;
}
@ -342,53 +369,110 @@ public static class InterestFeeds
return;
}
// Visible spectacle statuses amplify / extend scenes; crying extends grief.
bool spectacle = statusId == "burning"
|| statusId == "possessed"
|| statusId == "crying"
|| statusId == "frozen"
|| statusId == "shocked";
if (!spectacle)
// Egg timer end is the reliable hatch beat. Happiness just_got_out_of_egg is often
// suppressed (no emotions / still flagged egg), so status loss owns the camera reason.
if (!gained && string.Equals(statusId.Trim(), "egg", StringComparison.OrdinalIgnoreCase))
{
EmitHatch(actor, "status_egg_loss");
return;
}
if (!gained)
{
return;
}
StatusInterestEntry entry = EventCatalog.Status.GetOrFallback(statusId);
if (!entry.CreatesInterest)
{
return;
}
long id = SafeId(actor);
if (statusId == "crying")
if (entry.ExtendsGrief || statusId == "crying")
{
// Extend existing grief candidate rather than spawning a second scene.
string griefPrefix = "happiness:death_";
ExtendMatching(id, griefPrefix, statusId);
if (ExtendMatching(id, griefPrefix, statusId))
{
return;
}
// Standalone crying with no grief scene still creates interest.
if (statusId != "crying" && entry.ExtendsGrief)
{
return;
}
}
string phrase = ActivityStatusProse.Phrase(statusId, gained: true, out _);
string key = "status:" + statusId + ":" + id;
InterestCandidate registered = EventFeedUtil.Register(
key,
"status",
string.IsNullOrEmpty(entry.Category) ? "StatusTransformation" : entry.Category,
EventReason.Status(actor, phrase),
statusId,
entry.EventStrength,
actor.current_position,
actor,
minWatch: 2f,
maxWatch: InterestScoringConfig.W.maxWatchStatus,
completion: InterestCompletionKind.StatusPhase);
if (registered != null)
{
registered.StatusId = statusId;
registered.LastSeenAt = Time.unscaledTime;
}
}
/// <summary>
/// Hatch camera beat. Shared key with happiness <c>just_got_out_of_egg</c> so both paths merge.
/// </summary>
public static void EmitHatch(Actor actor, string source)
{
if (actor == null || !actor.isAlive())
{
return;
}
string key = "status:" + statusId + ":" + id;
long id = SafeId(actor);
if (id == 0)
{
return;
}
float strength = Mathf.Max(
InterestScoring.EventStrengthForHappiness("just_got_out_of_egg"),
88f);
var candidate = new InterestCandidate
{
Key = key,
Key = HatchKey(id),
LeadKind = InterestLeadKind.EventLed,
Category = "StatusTransformation",
Source = "status",
EventStrength = 55f,
VisualConfidence = 0.85f,
Category = "LifeChapter",
Source = string.IsNullOrEmpty(source) ? "hatch" : source,
EventStrength = strength,
VisualConfidence = 0.8f,
Position = actor.current_position,
FollowUnit = actor,
SubjectId = id,
StatusId = statusId,
Label = SafeName(actor) + " · " + statusId,
AssetId = actor.asset != null ? actor.asset.id : "",
SpeciesId = actor.asset != null ? actor.asset.id : "",
Label = EventReason.Hatch(actor),
AssetId = SafeAsset(actor),
SpeciesId = SafeAsset(actor),
HappinessEffectId = "just_got_out_of_egg",
CreatedAt = Time.unscaledTime,
LastSeenAt = Time.unscaledTime,
ExpiresAt = Time.unscaledTime + 18f,
MinWatch = 2f,
MaxWatch = 22f,
Completion = InterestCompletionKind.StatusPhase
ExpiresAt = Time.unscaledTime + 7f,
MinWatch = 4f,
MaxWatch = 15f,
Completion = InterestCompletionKind.FixedDwell
};
candidate.ParticipantIds.Add(id);
InterestScoring.ScoreCheap(candidate);
InterestRegistry.Upsert(candidate);
EventFeedUtil.RegisterCandidate(candidate);
}
private static string HatchKey(long subjectId) => "hatch:" + subjectId;
public static void OnChronicleMilestone(Actor subject, string milestoneKey, Actor related, string label)
{
if (subject == null || !subject.isAlive() || string.IsNullOrEmpty(milestoneKey))
@ -429,7 +513,7 @@ public static class InterestFeeds
Completion = InterestCompletionKind.FixedDwell
};
InterestScoring.ScoreCheap(candidate);
InterestRegistry.Upsert(candidate);
EventFeedUtil.RegisterCandidate(candidate);
}
private static void DrainHappiness()
@ -511,17 +595,45 @@ public static class InterestFeeds
Actor related = occ.RelatedId != 0 ? FindUnitById(occ.RelatedId) : null;
string cat = string.IsNullOrEmpty(occ.InterestCategory) ? "Emotion" : occ.InterestCategory;
string key = "happiness:" + occ.EffectId + ":" + occ.SubjectId + ":" + occ.RelatedId
+ ":" + CorrBucket(occ.CorrelationKey);
bool grief = occ.EffectId != null && occ.EffectId.StartsWith("death_");
float strength = InterestScoring.EventStrengthForHappiness(occ.EffectId);
bool hatchMoment = IsFreshLifeMoment(occ.EffectId);
// Hatch shares a key with status egg-loss so both paths merge into one camera beat.
string key = hatchMoment
? HatchKey(occ.SubjectId)
: ("happiness:" + occ.EffectId + ":" + occ.SubjectId + ":" + occ.RelatedId
+ ":" + CorrBucket(occ.CorrelationKey));
// Hatch: short queue TTL, but on-camera floor comes from minCameraDwell.
float ttl = hatchMoment ? 7f : 35f;
float minWatch = hatchMoment ? 4f : 8f;
float maxWatch = hatchMoment ? 15f : 28f;
if (hatchMoment)
{
strength = Mathf.Max(strength, 88f);
}
string label;
if (hatchMoment)
{
label = EventReason.Hatch(subject);
}
else if (!string.IsNullOrEmpty(occ.PlainClause))
{
label = occ.SubjectName + " " + occ.PlainClause;
}
else
{
label = occ.SubjectName + " · " + occ.EffectId;
}
var candidate = new InterestCandidate
{
Key = key,
LeadKind = InterestLeadKind.EventLed,
Category = grief ? "FamilyEmotion" : MapHappinessCategory(cat),
Source = "happiness",
Source = hatchMoment ? "happiness_hatch" : "happiness",
EventStrength = strength,
VisualConfidence = 0.75f,
Position = occ.Position != Vector3.zero ? occ.Position : subject.current_position,
@ -529,9 +641,7 @@ public static class InterestFeeds
RelatedUnit = related,
SubjectId = occ.SubjectId,
RelatedId = occ.RelatedId,
Label = !string.IsNullOrEmpty(occ.PlainClause)
? (occ.SubjectName + " " + occ.PlainClause)
: (occ.SubjectName + " · " + occ.EffectId),
Label = label,
AssetId = occ.SubjectSpecies,
SpeciesId = occ.SubjectSpecies,
HappinessEffectId = occ.EffectId,
@ -539,9 +649,9 @@ public static class InterestFeeds
CorrelationKey = occ.CorrelationKey,
CreatedAt = Time.unscaledTime,
LastSeenAt = Time.unscaledTime,
ExpiresAt = Time.unscaledTime + 35f,
MinWatch = 4f,
MaxWatch = 28f,
ExpiresAt = Time.unscaledTime + ttl,
MinWatch = minWatch,
MaxWatch = maxWatch,
Completion = grief
? InterestCompletionKind.HappinessGrief
: InterestCompletionKind.FixedDwell
@ -553,7 +663,7 @@ public static class InterestFeeds
}
InterestScoring.ScoreCheap(candidate);
InterestRegistry.Upsert(candidate);
EventFeedUtil.RegisterCandidate(candidate);
}
/// <summary>Harness / push path: ingest one occurrence into the registry now.</summary>
@ -635,7 +745,7 @@ public static class InterestFeeds
}
InterestScoring.ScoreCheap(candidate);
return InterestRegistry.Upsert(candidate);
return EventFeedUtil.RegisterCandidate(candidate);
}
private static void TickScanner()
@ -659,11 +769,17 @@ public static class InterestFeeds
RegisterDirect(battle);
}
// Ongoing wars from WarManager (complements WorldLog war start/end).
WarInterestFeed.Tick();
// Active plots from PlotManager / World.plots.
PlotInterestFeed.Tick();
// Character-led vignette seeds (not sole one-best monopoly: register best + a few notables).
WorldActivityScanner.RegisterTopCharacterCandidates(max: 4);
}
private static void ExtendMatching(long subjectId, string keyPrefix, string statusId)
private static bool ExtendMatching(long subjectId, string keyPrefix, string statusId)
{
var pending = new List<InterestCandidate>(32);
InterestRegistry.CopyPending(pending);
@ -680,10 +796,12 @@ public static class InterestFeeds
c.StatusId = statusId;
c.LastSeenAt = Time.unscaledTime;
c.ExpiresAt = Time.unscaledTime + 20f;
InterestRegistry.Upsert(c);
return;
EventFeedUtil.RegisterCandidate(c);
return true;
}
}
return false;
}
private static void StampCivicBoost(string kingdom, string effectId, float amount)
@ -740,6 +858,19 @@ public static class InterestFeeds
}
}
private static bool IsFreshLifeMoment(string effectId)
{
if (string.IsNullOrEmpty(effectId))
{
return false;
}
// Hatch moments only - just_born is spawn bookkeeping, not a birth/hatch beat.
string id = effectId.Trim();
return string.Equals(id, "just_got_out_of_egg", StringComparison.OrdinalIgnoreCase)
|| string.Equals(id, "just_hatched", StringComparison.OrdinalIgnoreCase);
}
private static string MapHappinessCategory(string cat)
{
switch ((cat ?? "").ToLowerInvariant())
@ -861,4 +992,21 @@ public static class InterestFeeds
return actor.asset != null ? actor.asset.id : "creature";
}
private static string SafeAsset(Actor actor)
{
if (actor == null)
{
return "";
}
try
{
return actor.asset != null ? actor.asset.id : "";
}
catch
{
return "";
}
}
}

View file

@ -0,0 +1,97 @@
using UnityEngine;
namespace IdleSpectator;
/// <summary>Registers era and meta-genesis interest candidates.</summary>
public static class MetaInterestFeed
{
public static void EmitEra(string eraId)
{
if (AgentHarness.Busy)
{
return;
}
DiscreteEventEntry entry = EventCatalog.Era.GetOrFallback(eraId);
if (!entry.CreatesInterest)
{
return;
}
// Eras are world-scoped: location-only. Prefer camera center, else a known map tile.
Vector3 pos = Vector3.zero;
try
{
if (Camera.main != null)
{
Vector3 cam = Camera.main.transform.position;
pos = new Vector3(cam.x, cam.y, 0f);
}
}
catch
{
pos = Vector3.zero;
}
if (pos == Vector3.zero)
{
try
{
if (MapBox.instance != null)
{
// Non-zero placeholder so location-only Register accepts the candidate.
pos = new Vector3(1f, 1f, 0f);
}
}
catch
{
pos = new Vector3(1f, 1f, 0f);
}
}
if (pos == Vector3.zero)
{
pos = new Vector3(1f, 1f, 0f);
}
string key = "era:" + entry.Id;
EventFeedUtil.Register(
key,
"era",
entry.Category,
entry.MakeLabel("", ""),
entry.Id,
entry.EventStrength,
pos,
follow: null,
locationOnly: true,
minWatch: 6f,
maxWatch: 28f);
}
public static void EmitMeta(string eventId, string category, float strength, Actor anchor, string label)
{
if (AgentHarness.Busy)
{
return;
}
Actor follow = anchor != null && anchor.isAlive() ? anchor : null;
if (follow == null)
{
// Meta genesis without a founder: skip unit dossier rather than invent a subject.
return;
}
string key = "meta:" + eventId + ":" + EventFeedUtil.SafeId(follow);
EventFeedUtil.Register(
key,
"meta",
category,
label,
eventId,
strength,
follow.current_position,
follow);
}
}

View file

@ -0,0 +1,176 @@
using System.Collections;
using System.Reflection;
using UnityEngine;
namespace IdleSpectator;
/// <summary>Registers plot interest from Harmony hooks and World.plots polling.</summary>
public static class PlotInterestFeed
{
public static void Emit(string plotAssetId, Actor subject, string phase)
{
if (AgentHarness.Busy)
{
return;
}
DiscreteEventEntry entry = EventCatalog.Plot.GetOrFallback(plotAssetId);
if (!entry.CreatesInterest)
{
return;
}
Actor follow = subject;
if (follow == null || !follow.isAlive())
{
// Plot without a living author: skip rather than invent a stranger.
return;
}
string label = EventReason.Apply(entry.LabelTemplate, follow, id: entry.Id);
string key = "plot:" + entry.Id + ":" + (phase ?? "active") + ":" + EventFeedUtil.SafeId(follow);
EventFeedUtil.Register(
key,
"plot",
entry.Category,
label,
entry.Id,
entry.EventStrength,
follow.current_position,
follow);
}
public static void EmitFromPlot(object plot, string phase)
{
if (plot == null)
{
return;
}
string assetId = ReadPlotAssetId(plot);
Actor author = ReadPlotAuthor(plot);
Emit(assetId, author, phase);
}
public static void Tick()
{
if (AgentHarness.Busy)
{
return;
}
IEnumerable plots = ResolvePlotsEnumerable();
if (plots == null)
{
return;
}
int registered = 0;
foreach (object plot in plots)
{
if (plot == null || registered >= 8)
{
break;
}
EmitFromPlot(plot, "active");
registered++;
}
}
private static IEnumerable ResolvePlotsEnumerable()
{
try
{
object world = World.world;
if (world == null)
{
return null;
}
object plots = world.GetType().GetField("plots")?.GetValue(world)
?? world.GetType().GetProperty("plots")?.GetValue(world, null);
if (plots is IEnumerable direct)
{
return direct;
}
object manager = world.GetType().GetField("plots_manager")?.GetValue(world)
?? world.GetType().GetProperty("plots_manager")?.GetValue(world, null)
?? typeof(PlotManager);
if (manager is System.Type)
{
manager = null;
}
object mapBox = MapBox.instance;
if (manager == null && mapBox != null)
{
manager = mapBox.GetType().GetField("plots")?.GetValue(mapBox)
?? mapBox.GetType().GetProperty("plots")?.GetValue(mapBox, null);
}
if (manager == null)
{
return null;
}
MethodInfo getAll = manager.GetType().GetMethod("getSimpleList")
?? manager.GetType().GetMethod("getList")
?? manager.GetType().GetMethod("getPlots");
if (getAll != null)
{
return getAll.Invoke(manager, null) as IEnumerable;
}
return manager as IEnumerable;
}
catch
{
return null;
}
}
private static string ReadPlotAssetId(object plot)
{
try
{
object asset = plot.GetType().GetField("asset")?.GetValue(plot)
?? plot.GetType().GetProperty("asset")?.GetValue(plot, null)
?? plot.GetType().GetMethod("getAsset")?.Invoke(plot, null);
if (asset != null)
{
object id = asset.GetType().GetField("id")?.GetValue(asset)
?? asset.GetType().GetProperty("id")?.GetValue(asset, null);
if (id != null)
{
return id.ToString();
}
}
object typeId = plot.GetType().GetField("type")?.GetValue(plot)
?? plot.GetType().GetProperty("type")?.GetValue(plot, null);
return typeId?.ToString() ?? "unknown";
}
catch
{
return "unknown";
}
}
private static Actor ReadPlotAuthor(object plot)
{
try
{
object author = plot.GetType().GetField("author")?.GetValue(plot)
?? plot.GetType().GetProperty("author")?.GetValue(plot, null)
?? plot.GetType().GetMethod("getAuthor")?.Invoke(plot, null)
?? plot.GetType().GetField("founder")?.GetValue(plot);
return author as Actor;
}
catch
{
return null;
}
}
}

View file

@ -0,0 +1,188 @@
using UnityEngine;
namespace IdleSpectator;
/// <summary>Registers relationship lifecycle interest candidates under the ownership contract.</summary>
public static class RelationshipInterestFeed
{
public static void Emit(string eventId, Actor subject, Actor related, string labelOverride = null)
{
if (AgentHarness.Busy || subject == null || !subject.isAlive())
{
return;
}
DiscreteEventEntry entry = EventCatalog.Relationship.GetOrFallback(eventId);
if (!entry.CreatesInterest)
{
return;
}
Actor relatedAlive = related != null && related.isAlive() ? related : null;
bool claimsPeer = eventId == "family_group_new"
|| eventId == "family_group_join"
|| eventId == "family_group_leave"
|| eventId == "set_lover"
|| eventId == "add_child";
if (claimsPeer && relatedAlive == null)
{
relatedAlive = ActorRelation.ResolvePackRelated(subject);
}
string label = !string.IsNullOrEmpty(labelOverride)
? labelOverride
: TypedLabel(eventId, subject, relatedAlive);
if (claimsPeer && relatedAlive == null)
{
string soloKey = "rel:" + entry.Id + ":" + EventFeedUtil.SafeId(subject) + ":solo";
EventFeedUtil.Register(
soloKey,
"relationship",
entry.Category,
label,
entry.Id,
Mathf.Min(entry.EventStrength, 40f),
subject.current_position,
subject);
return;
}
string key = "rel:" + entry.Id + ":" + EventFeedUtil.SafeId(subject) + ":" + EventFeedUtil.SafeId(relatedAlive);
EventFeedUtil.Register(
key,
"relationship",
entry.Category,
label,
entry.Id,
entry.EventStrength,
subject.current_position,
subject,
related: relatedAlive);
}
public static void EmitFamily(string eventId, object family, Actor anchor)
{
if (AgentHarness.Busy)
{
return;
}
DiscreteEventEntry entry = EventCatalog.Relationship.GetOrFallback(eventId);
Actor follow = anchor != null && anchor.isAlive() ? anchor : null;
if (follow == null)
{
follow = FirstFamilyMember(family);
}
if (follow == null)
{
return;
}
Actor related = ActorRelation.FirstFamilyPeer(follow);
string label = TypedLabel(eventId, follow, related);
string key = "rel:" + entry.Id + ":" + EventFeedUtil.SafeId(follow);
EventFeedUtil.Register(
key,
"relationship",
entry.Category,
label,
entry.Id,
entry.EventStrength,
follow.current_position,
follow,
related: related);
}
private static string TypedLabel(string eventId, Actor subject, Actor related)
{
switch (eventId)
{
case "set_lover":
return EventReason.Lovers(subject, related);
case "clear_lover":
return EventReason.LoverParted(subject);
case "find_lover":
return EventReason.SeekingLover(subject);
case "add_child":
return EventReason.NewChild(subject, related);
case "baby_created":
return EventReason.BabyBorn(subject);
case "new_family":
return EventReason.NewFamily(subject);
case "family_removed":
return EventReason.FamilyEnded(subject);
case "family_group_new":
return EventReason.FamilyPack(subject, related, "form");
case "family_group_join":
return EventReason.FamilyPack(subject, related, "join");
case "family_group_leave":
return EventReason.FamilyPack(subject, related, "leave");
default:
return EventReason.Apply(
EventCatalog.Relationship.GetOrFallback(eventId).LabelTemplate,
subject,
related,
eventId);
}
}
private static Actor FirstFamilyMember(object family)
{
if (family == null)
{
return null;
}
try
{
foreach (Actor member in ActorRelation.EnumerateFamilyMembers(
ActorRelation.ResolveActor(
family.GetType().GetField("founder")?.GetValue(family)
?? family.GetType().GetProperty("founder")?.GetValue(family, null))
?? null,
max: 1))
{
return member;
}
}
catch
{
// fall through
}
try
{
object list = family.GetType().GetField("units")?.GetValue(family)
?? family.GetType().GetProperty("units")?.GetValue(family, null)
?? family.GetType().GetMethod("getUnits")?.Invoke(family, null)
?? family.GetType().GetMethod("getSimpleList")?.Invoke(family, null);
if (list is System.Collections.IEnumerable enumerable)
{
foreach (object raw in enumerable)
{
Actor a = ActorRelation.ResolveActor(raw);
if (a != null && a.isAlive())
{
return a;
}
}
}
Actor founder = family.GetType().GetField("founder")?.GetValue(family) as Actor
?? family.GetType().GetProperty("founder")?.GetValue(family, null) as Actor;
if (founder != null && founder.isAlive())
{
return founder;
}
}
catch
{
// ignore
}
return null;
}
}

View file

@ -0,0 +1,356 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Reflection;
using UnityEngine;
namespace IdleSpectator;
/// <summary>
/// Polls live WarManager state and registers ongoing war interest candidates.
/// </summary>
public static class WarInterestFeed
{
public static void EmitFromWarObject(object war, string phase = "active")
{
if (war == null || AgentHarness.Busy)
{
return;
}
TryRegisterWar(war, phase);
}
public static void Tick()
{
if (AgentHarness.Busy)
{
return;
}
object warsManager = ResolveWarsManager();
if (warsManager == null)
{
return;
}
IEnumerable active = InvokeEnumerable(warsManager, "getActiveWars")
?? InvokeEnumerable(warsManager, "getWars");
if (active == null)
{
return;
}
int registered = 0;
foreach (object war in active)
{
if (war == null || registered >= 6)
{
break;
}
if (TryRegisterWar(war, "active"))
{
registered++;
}
}
}
private static bool TryRegisterWar(object war, string phase)
{
string warTypeId = ReadWarTypeId(war);
WarTypeInterestEntry entry = EventCatalog.WarType.GetOrFallback(warTypeId);
if (!entry.CreatesInterest)
{
return false;
}
object attacker = ReadPropOrField(war, "main_attacker") ?? Invoke(war, "get_main_attacker");
object defender = ReadPropOrField(war, "main_defender") ?? Invoke(war, "get_main_defender");
string attackerName = ReadMetaName(attacker);
string defenderName = ReadMetaName(defender);
string labelPair = string.IsNullOrEmpty(defenderName)
? attackerName
: attackerName + " vs " + defenderName;
if (string.IsNullOrEmpty(labelPair))
{
labelPair = ReadString(war, "name") ?? warTypeId ?? "war";
}
Vector3 position = Vector3.zero;
Actor follow = null;
if (!TryLocateWar(attacker, defender, out position, out follow))
{
return false;
}
string phaseKey = string.IsNullOrEmpty(phase) ? "active" : phase;
string key = "war:" + phaseKey + ":" + (ReadString(war, "id") ?? labelPair) + ":" + (warTypeId ?? "normal");
string label = EventCatalog.WarType.MakeLabel(entry, labelPair);
if (phaseKey == "new")
{
label = "War begins: " + labelPair;
}
else if (phaseKey == "end")
{
label = "War ends: " + labelPair;
}
else if (!string.IsNullOrEmpty(labelPair) && !label.Contains(labelPair))
{
label = label + ": " + labelPair;
}
// Prefer sentence form when we have an owned follow unit.
if (follow != null)
{
if (phaseKey == "new")
{
label = EventFeedUtil.SafeName(follow) + " begins a war";
if (!string.IsNullOrEmpty(defenderName))
{
label = EventFeedUtil.SafeName(follow) + " begins a war against " + defenderName;
}
}
else if (phaseKey == "end")
{
label = EventFeedUtil.SafeName(follow) + " ends a war";
}
else
{
label = string.IsNullOrEmpty(defenderName)
? EventFeedUtil.SafeName(follow) + " is at war"
: EventFeedUtil.SafeName(follow) + " is at war with " + defenderName;
}
}
float strength = entry.EventStrength;
if (phaseKey == "end")
{
strength = Mathf.Max(60f, strength - 10f);
}
EventFeedUtil.Register(
key,
"war",
entry.Category,
label,
warTypeId ?? "war",
strength,
position,
follow,
minWatch: 6f,
maxWatch: 30f);
return true;
}
private static bool TryLocateWar(object attacker, object defender, out Vector3 position, out Actor follow)
{
position = Vector3.zero;
follow = null;
if (TryKingdomAnchor(attacker, out position, out follow))
{
return true;
}
if (TryKingdomAnchor(defender, out position, out follow))
{
return true;
}
// No kingdom unit found - location-only if we have a capital position is handled above.
// Never invent a global random unit as war subject.
return false;
}
private static bool TryKingdomAnchor(object kingdom, out Vector3 position, out Actor follow)
{
position = Vector3.zero;
follow = null;
if (kingdom == null)
{
return false;
}
try
{
object capital = ReadPropOrField(kingdom, "capital") ?? Invoke(kingdom, "getCapital");
if (capital != null)
{
object posObj = ReadPropOrField(capital, "current_position")
?? ReadPropOrField(capital, "city_center");
if (posObj is Vector3 v && v != Vector3.zero)
{
position = v;
}
else if (posObj is Vector2 v2)
{
position = new Vector3(v2.x, v2.y, 0f);
}
}
object king = ReadPropOrField(kingdom, "king");
if (king is Actor actorKing && actorKing.isAlive())
{
follow = actorKing;
position = actorKing.current_position;
return true;
}
if (position != Vector3.zero)
{
return true;
}
}
catch
{
// ignore
}
return false;
}
private static object ResolveWarsManager()
{
try
{
object world = World.world;
if (world == null)
{
return null;
}
object wars = ReadPropOrField(world, "wars");
if (wars != null)
{
return wars;
}
return typeof(AssetManager).Assembly.GetType("WarManager");
}
catch
{
return null;
}
}
private static string ReadWarTypeId(object war)
{
try
{
object warType = ReadPropOrField(war, "war_type") ?? Invoke(war, "get_war_type");
if (warType == null)
{
return "normal";
}
if (warType is string s)
{
return s;
}
string id = ReadString(warType, "id");
return string.IsNullOrEmpty(id) ? "normal" : id;
}
catch
{
return "normal";
}
}
private static string ReadMetaName(object meta)
{
if (meta == null)
{
return "";
}
try
{
object name = Invoke(meta, "getName") ?? ReadPropOrField(meta, "name") ?? ReadString(meta, "name");
return name as string ?? "";
}
catch
{
return "";
}
}
private static IEnumerable InvokeEnumerable(object target, string methodName)
{
try
{
MethodInfo method = target.GetType().GetMethod(methodName, Type.EmptyTypes);
object result = method?.Invoke(target, null);
return result as IEnumerable;
}
catch
{
return null;
}
}
private static object Invoke(object target, string methodName)
{
if (target == null || string.IsNullOrEmpty(methodName))
{
return null;
}
try
{
MethodInfo method = target.GetType().GetMethod(methodName, Type.EmptyTypes);
return method?.Invoke(target, null);
}
catch
{
return null;
}
}
private static object ReadPropOrField(object target, string name)
{
if (target == null || string.IsNullOrEmpty(name))
{
return null;
}
try
{
Type type = target.GetType();
PropertyInfo prop = type.GetProperty(name);
if (prop != null)
{
return prop.GetValue(target, null);
}
FieldInfo field = type.GetField(name);
return field?.GetValue(target);
}
catch
{
return null;
}
}
private static string ReadString(object target, string name)
{
return ReadPropOrField(target, name) as string;
}
private static long SafeActorId(Actor actor)
{
if (actor == null)
{
return 0;
}
try
{
return actor.getID();
}
catch
{
return 0;
}
}
}

View file

@ -0,0 +1,64 @@
using ai.behaviours;
using HarmonyLib;
namespace IdleSpectator;
/// <summary>Boat transport/trade interest patches from mutation discovery.</summary>
[HarmonyPatch]
public static class BoatEventPatches
{
[HarmonyPatch(typeof(BehBoatTransportUnloadUnits), nameof(BehBoatTransportUnloadUnits.execute))]
[HarmonyPostfix]
public static void PostfixUnload(Actor pActor, BehResult __result)
{
if (pActor == null || !pActor.isAlive() || __result == BehResult.Stop)
{
return;
}
EmitBoat("boat_unload", 72f, pActor, EventReason.Boat(pActor, "unload"));
}
[HarmonyPatch(typeof(BehBoatMakeTrade), nameof(BehBoatMakeTrade.execute))]
[HarmonyPostfix]
public static void PostfixTrade(Actor pActor, BehResult __result)
{
if (pActor == null || !pActor.isAlive() || __result == BehResult.Stop)
{
return;
}
EmitBoat("boat_trade", 68f, pActor, EventReason.Boat(pActor, "trade"));
}
[HarmonyPatch(typeof(BehBoatTransportDoLoading), nameof(BehBoatTransportDoLoading.execute))]
[HarmonyPostfix]
public static void PostfixLoad(Actor pActor, BehResult __result)
{
if (pActor == null || !pActor.isAlive() || __result == BehResult.Stop)
{
return;
}
EmitBoat("boat_load", 55f, pActor, EventReason.Boat(pActor, "load"));
}
private static void EmitBoat(string eventId, float strength, Actor actor, string label)
{
if (AgentHarness.Busy)
{
return;
}
string key = "boat:" + eventId + ":" + EventFeedUtil.SafeId(actor);
EventFeedUtil.Register(
key,
"boat",
"Travel",
label,
eventId,
strength,
actor.current_position,
actor);
}
}

View file

@ -0,0 +1,50 @@
using HarmonyLib;
namespace IdleSpectator;
/// <summary>Book lifecycle patches from mutation discovery.</summary>
[HarmonyPatch]
public static class BookEventPatches
{
[HarmonyPatch(typeof(BookManager), nameof(BookManager.newBook))]
[HarmonyPostfix]
public static void PostfixNewBook(Book __result)
{
if (__result == null)
{
return;
}
BookInterestFeed.EmitFromBook(__result, "new");
}
[HarmonyPatch(typeof(BookManager), nameof(BookManager.generateNewBook))]
[HarmonyPostfix]
public static void PostfixGenerateNewBook(Book __result)
{
if (__result == null)
{
return;
}
BookInterestFeed.EmitFromBook(__result, "generate");
}
[HarmonyPatch(typeof(BookManager), nameof(BookManager.burnBook))]
[HarmonyPostfix]
public static void PostfixBurnBook(object[] __args)
{
Book book = null;
if (__args != null && __args.Length > 0)
{
book = __args[0] as Book;
}
if (book == null)
{
return;
}
BookInterestFeed.EmitFromBook(book, "burn");
}
}

View file

@ -0,0 +1,149 @@
using ai.behaviours;
using HarmonyLib;
using UnityEngine;
namespace IdleSpectator;
/// <summary>Building damage/destroy/eat interest patches from mutation discovery.</summary>
[HarmonyPatch]
public static class BuildingEventPatches
{
[HarmonyPatch(typeof(BehDealDamageToTargetBuilding), nameof(BehDealDamageToTargetBuilding.execute))]
[HarmonyPostfix]
public static void PostfixDamageBuilding(Actor pActor, BehResult __result)
{
if (pActor == null || !pActor.isAlive() || __result == BehResult.Stop)
{
return;
}
Building target = null;
try
{
target = pActor.beh_building_target;
}
catch
{
target = null;
}
string label = EventReason.BuildingDamage(pActor, target);
Emit("building_damage", 70f, pActor, label);
}
[HarmonyPatch(typeof(BehConsumeTargetBuilding), nameof(BehConsumeTargetBuilding.execute))]
[HarmonyPostfix]
public static void PostfixConsumeBuilding(Actor pActor, BehResult __result)
{
if (pActor == null || !pActor.isAlive() || __result == BehResult.Stop)
{
return;
}
Building food = null;
try
{
food = pActor.beh_building_target;
}
catch
{
food = null;
}
string label = EventReason.BuildingEat(pActor, food);
Emit("building_consume", 78f, pActor, label);
}
[HarmonyPatch(typeof(Building), "startDestroyBuilding")]
[HarmonyPostfix]
public static void PostfixStartDestroy(Building __instance)
{
if (__instance == null || AgentHarness.Busy)
{
return;
}
try
{
Vector3 pos = Vector3.zero;
object posObj = __instance.GetType().GetField("current_position")?.GetValue(__instance)
?? __instance.GetType().GetProperty("current_position")?.GetValue(__instance, null);
if (posObj is Vector3 v)
{
pos = v;
}
string buildingName = "";
try
{
if (__instance.asset != null)
{
buildingName = __instance.asset.id ?? "";
}
}
catch
{
buildingName = "";
}
Actor near = pos != Vector3.zero
? EventFeedUtil.NearestUnit(pos, 48f)
: null;
string label = EventReason.BuildingFalls(buildingName, near);
if (near == null)
{
if (pos == Vector3.zero)
{
return;
}
string locKey = "building:destroy:" + buildingName + ":loc";
EventFeedUtil.Register(
locKey,
"building",
"Spectacle",
label,
string.IsNullOrEmpty(buildingName) ? "building_destroy" : buildingName,
88f,
pos,
follow: null,
locationOnly: true);
return;
}
string key = "building:destroy:" + buildingName + ":" + EventFeedUtil.SafeId(near);
EventFeedUtil.Register(
key,
"building",
"Spectacle",
label,
string.IsNullOrEmpty(buildingName) ? "building_destroy" : buildingName,
88f,
near.current_position,
near);
}
catch
{
// ignore API mismatches
}
}
private static void Emit(string eventId, float strength, Actor actor, string label)
{
if (AgentHarness.Busy)
{
return;
}
string key = "building:" + eventId + ":" + EventFeedUtil.SafeId(actor);
EventFeedUtil.Register(
key,
"building",
"Spectacle",
label,
eventId,
strength,
actor.current_position,
actor);
}
}

View file

@ -0,0 +1,663 @@
using System;
using HarmonyLib;
using UnityEngine;
namespace IdleSpectator;
/// <summary>Harmony hooks for Phase 2 deferred libraries (items, spells, decisions, subspecies).</summary>
[HarmonyPatch]
public static class DeferredEventPatches
{
private static bool _spellCastInFlight;
private static string _spellCastId;
private static string _lastPowerId = "";
private static float _lastPowerAt = -999f;
[HarmonyPatch(typeof(ItemManager), nameof(ItemManager.generateItem))]
[HarmonyPostfix]
public static void PostfixGenerateItem(object __result, object[] __args)
{
if (AgentHarness.Busy)
{
return;
}
Actor craftsman = null;
if (__args != null)
{
for (int i = 0; i < __args.Length; i++)
{
if (__args[i] is Actor a && a.isAlive())
{
craftsman = a;
break;
}
}
}
string itemId = ReadAssetId(__result) ?? "item";
if (craftsman == null)
{
return;
}
DeferredInterestFeed.EmitItem(itemId, craftsman);
}
[HarmonyPatch(typeof(ItemManager), "newItem")]
[HarmonyPostfix]
public static void PostfixNewItem(object __result)
{
// newItem may lack an actor; skip without a subject rather than invent one.
if (AgentHarness.Busy || __result == null)
{
return;
}
Actor owner = ReadActorMember(__result, "owner", "actor", "creator", "getOwner");
if (owner == null || !owner.isAlive())
{
return;
}
DeferredInterestFeed.EmitItem(ReadAssetId(__result) ?? "item", owner);
}
[HarmonyPatch(typeof(CombatActionLibrary), nameof(CombatActionLibrary.tryToCastSpell))]
[HarmonyPrefix]
public static void PrefixTryToCastSpell()
{
_spellCastInFlight = true;
_spellCastId = null;
}
[HarmonyPatch(typeof(Actor), nameof(Actor.getRandomSpell))]
[HarmonyPostfix]
public static void PostfixGetRandomSpell(object __result)
{
if (!_spellCastInFlight || __result == null)
{
return;
}
string id = ReadAssetId(__result);
if (!string.IsNullOrEmpty(id))
{
_spellCastId = id;
}
}
[HarmonyPatch(typeof(CombatActionLibrary), nameof(CombatActionLibrary.tryToCastSpell))]
[HarmonyPostfix]
public static void PostfixTryToCastSpell(AttackData pData, bool __result)
{
_spellCastInFlight = false;
if (!__result || AgentHarness.Busy)
{
_spellCastId = null;
return;
}
Actor actor = null;
try
{
BaseSimObject initiator = pData.initiator;
if (initiator != null)
{
actor = initiator.a;
}
}
catch
{
actor = null;
}
if (actor == null || !actor.isAlive())
{
try
{
object initiator = typeof(AttackData).GetField("initiator")?.GetValue(pData);
actor = ReadActorMember(initiator, "a", "actor")
?? (initiator as Actor);
}
catch
{
actor = null;
}
}
if (actor == null || !actor.isAlive())
{
_spellCastId = null;
return;
}
string spellId = _spellCastId;
_spellCastId = null;
if (string.IsNullOrEmpty(spellId))
{
spellId = ReadSpellFromAttackData(pData) ?? "spell";
}
DeferredInterestFeed.EmitSpell(spellId, actor);
}
[HarmonyPatch(typeof(PlayerControl), nameof(PlayerControl.clickedFinal))]
[HarmonyPostfix]
public static void PostfixClickedFinal(Vector2Int pPos, GodPower pPower)
{
if (AgentHarness.Busy)
{
return;
}
string powerId = null;
if (pPower != null)
{
powerId = ReadAssetId(pPower);
if (string.IsNullOrEmpty(powerId))
{
try
{
powerId = pPower.name;
}
catch
{
powerId = null;
}
}
}
if (string.IsNullOrEmpty(powerId))
{
try
{
object selected = World.world?.selected_power;
powerId = ReadAssetId(selected);
if (string.IsNullOrEmpty(powerId))
{
powerId = World.world?.getSelectedPowerID();
}
}
catch
{
powerId = null;
}
}
if (string.IsNullOrEmpty(powerId))
{
return;
}
float now = Time.unscaledTime;
if (string.Equals(powerId, _lastPowerId, StringComparison.OrdinalIgnoreCase)
&& now - _lastPowerAt < 1.5f)
{
return;
}
_lastPowerId = powerId;
_lastPowerAt = now;
Vector3 pos = TilePos(pPos);
Actor near = pos != Vector3.zero ? EventFeedUtil.NearestUnit(pos, 48f) : null;
DeferredInterestFeed.EmitPower(powerId, pos, near);
}
[HarmonyPatch(typeof(WorldLaws), nameof(WorldLaws.enable))]
[HarmonyPostfix]
public static void PostfixWorldLawEnable(string pID)
{
if (AgentHarness.Busy || string.IsNullOrEmpty(pID))
{
return;
}
DeferredInterestFeed.EmitWorldLaw(pID, CameraOrMapPos());
}
[HarmonyPatch(typeof(WorldLawAsset), nameof(WorldLawAsset.toggle))]
[HarmonyPostfix]
public static void PostfixWorldLawToggle(WorldLawAsset __instance, bool pState)
{
if (!pState || AgentHarness.Busy || __instance == null)
{
return;
}
string id = ReadAssetId(__instance) ?? __instance.id;
if (string.IsNullOrEmpty(id))
{
return;
}
DeferredInterestFeed.EmitWorldLaw(id, CameraOrMapPos());
}
[HarmonyPatch(typeof(Subspecies), nameof(Subspecies.mutateFrom))]
[HarmonyPostfix]
public static void PostfixMutateFrom(Subspecies __instance)
{
if (AgentHarness.Busy || __instance == null)
{
return;
}
Actor anchor = FindUnitForSubspecies(__instance);
if (anchor == null || !anchor.isAlive())
{
return;
}
// Prefer the mutation event id so ambient gene catalog entries do not suppress the emit.
string geneId = "mutation";
string nucleusGene = ReadNucleusGeneId(__instance);
if (!string.IsNullOrEmpty(nucleusGene)
&& (nucleusGene.IndexOf("warfare", StringComparison.OrdinalIgnoreCase) >= 0
|| nucleusGene.IndexOf("magic", StringComparison.OrdinalIgnoreCase) >= 0
|| nucleusGene.IndexOf("mutation", StringComparison.OrdinalIgnoreCase) >= 0))
{
geneId = nucleusGene;
}
DeferredInterestFeed.EmitGene(geneId, anchor);
}
[HarmonyPatch(typeof(Actor), nameof(Actor.setDecisionCooldown))]
[HarmonyPostfix]
public static void PostfixSetDecisionCooldown(Actor __instance, object[] __args)
{
if (__instance == null || !__instance.isAlive() || AgentHarness.Busy)
{
return;
}
object pDecision = __args != null && __args.Length > 0 ? __args[0] : null;
if (pDecision == null)
{
return;
}
string id = ReadAssetId(pDecision) ?? pDecision.ToString();
if (string.IsNullOrEmpty(id))
{
return;
}
// High-frequency hook: only kingdom-affecting decisions, never idle AI ticks.
if (!EventCatalog.Decision.IsCameraWorthy(id))
{
return;
}
DeferredInterestFeed.EmitDecision(id, __instance);
}
[HarmonyPatch(typeof(Actor), nameof(Actor.generatePhenotypeAndShade))]
[HarmonyPostfix]
public static void PostfixPhenotype(Actor __instance)
{
if (__instance == null || !__instance.isAlive() || AgentHarness.Busy)
{
return;
}
string id = "";
try
{
object data = __instance.data;
object ph = data?.GetType().GetField("phenotype")?.GetValue(data)
?? data?.GetType().GetProperty("phenotype")?.GetValue(data, null);
if (ph != null)
{
id = ph.ToString() ?? "";
}
}
catch
{
id = "";
}
// Placeholder / empty ids are not camera events (catalog ambient is CreatesInterest=false).
if (string.IsNullOrEmpty(id)
|| string.Equals(id, "phenotype", StringComparison.OrdinalIgnoreCase))
{
return;
}
DeferredInterestFeed.EmitPhenotype(id, __instance);
}
[HarmonyPatch(typeof(SubspeciesManager), "addRandomTraitFromBiomeToSubspecies")]
[HarmonyPostfix]
public static void PostfixSubspeciesTraitRandom(object[] __args)
{
object pSubspecies = __args != null && __args.Length > 0 ? __args[0] : null;
EmitSubspecies(pSubspecies, "biome_trait");
}
[HarmonyPatch(typeof(SubspeciesManager), "addTraitsFromBiomeToSubspecies")]
[HarmonyPostfix]
public static void PostfixSubspeciesTraits(object[] __args)
{
object pSubspecies = __args != null && __args.Length > 0 ? __args[0] : null;
EmitSubspecies(pSubspecies, "biome_traits");
}
private static void EmitSubspecies(object subspecies, string fallbackId)
{
if (AgentHarness.Busy || subspecies == null)
{
return;
}
Actor anchor = ReadActorMember(subspecies, "founder", "creator", "leader", "getFounder");
if (anchor == null || !anchor.isAlive())
{
// Prefer a unit of this subspecies if possible.
anchor = FindUnitForSubspecies(subspecies);
}
if (anchor == null)
{
return;
}
string traitId = fallbackId;
try
{
object last = subspecies.GetType().GetField("last_trait")?.GetValue(subspecies)
?? subspecies.GetType().GetProperty("last_trait")?.GetValue(subspecies, null);
if (last != null)
{
traitId = ReadAssetId(last) ?? last.ToString() ?? fallbackId;
}
}
catch
{
// ignore
}
DeferredInterestFeed.EmitSubspeciesTrait(traitId, anchor);
}
private static Actor FindUnitForSubspecies(object subspecies)
{
if (subspecies == null || World.world?.units == null)
{
return null;
}
string sid = null;
try
{
object id = subspecies.GetType().GetMethod("getID")?.Invoke(subspecies, null)
?? subspecies.GetType().GetProperty("id")?.GetValue(subspecies, null);
sid = id?.ToString();
}
catch
{
sid = null;
}
try
{
foreach (Actor unit in World.world.units)
{
if (unit == null || !unit.isAlive())
{
continue;
}
try
{
object sub = unit.GetType().GetProperty("subspecies")?.GetValue(unit, null)
?? unit.GetType().GetField("subspecies")?.GetValue(unit);
if (sub == subspecies)
{
return unit;
}
if (!string.IsNullOrEmpty(sid))
{
object uid = sub?.GetType().GetMethod("getID")?.Invoke(sub, null);
if (uid != null && uid.ToString() == sid)
{
return unit;
}
}
}
catch
{
// skip
}
}
}
catch
{
// ignore
}
return null;
}
private static string ReadSpellFromAttackData(AttackData pData)
{
try
{
Type type = typeof(AttackData);
foreach (string name in new[] { "spell", "spell_asset", "pSpell", "current_spell", "last_spell" })
{
object value = type.GetField(name)?.GetValue(pData)
?? type.GetProperty(name)?.GetValue(pData, null);
string id = ReadAssetId(value);
if (!string.IsNullOrEmpty(id))
{
return id;
}
}
}
catch
{
// ignore
}
return null;
}
private static string ReadNucleusGeneId(Subspecies subspecies)
{
try
{
object nucleus = subspecies.nucleus;
if (nucleus == null)
{
return null;
}
object chromosomes = nucleus.GetType().GetField("chromosomes")?.GetValue(nucleus)
?? nucleus.GetType().GetProperty("chromosomes")?.GetValue(nucleus, null);
if (chromosomes is not System.Collections.IEnumerable list)
{
return null;
}
foreach (object chrome in list)
{
if (chrome == null)
{
continue;
}
object genes = chrome.GetType().GetField("genes")?.GetValue(chrome)
?? chrome.GetType().GetProperty("genes")?.GetValue(chrome, null);
if (genes is not System.Collections.IEnumerable geneList)
{
continue;
}
foreach (object gene in geneList)
{
string id = ReadAssetId(gene);
if (!string.IsNullOrEmpty(id)
&& !id.Equals("empty", StringComparison.OrdinalIgnoreCase)
&& !id.Contains("void"))
{
return id;
}
}
}
}
catch
{
// ignore
}
return null;
}
private static Vector3 TilePos(Vector2Int pPos)
{
try
{
WorldTile tile = World.world?.GetTile(pPos.x, pPos.y)
?? World.world?.GetTileSimple(pPos.x, pPos.y);
if (tile != null)
{
object posObj = tile.GetType().GetField("posV3")?.GetValue(tile)
?? tile.GetType().GetProperty("posV3")?.GetValue(tile, null)
?? tile.GetType().GetField("pos")?.GetValue(tile)
?? tile.GetType().GetProperty("pos")?.GetValue(tile, null)
?? tile.GetType().GetField("position")?.GetValue(tile)
?? tile.GetType().GetProperty("position")?.GetValue(tile, null);
if (posObj is Vector3 v)
{
return v;
}
if (posObj is Vector2 v2)
{
return new Vector3(v2.x, v2.y, 0f);
}
}
}
catch
{
// fall through
}
return new Vector3(pPos.x, pPos.y, 0f);
}
private static Vector3 CameraOrMapPos()
{
try
{
if (MoveCamera.instance != null)
{
Vector3 p = MoveCamera.instance.transform.position;
if (p != Vector3.zero)
{
return new Vector3(p.x, p.y, 0f);
}
}
}
catch
{
// ignore
}
try
{
if (Camera.main != null)
{
Vector3 p = Camera.main.transform.position;
return new Vector3(p.x, p.y, 0f);
}
}
catch
{
// ignore
}
return Vector3.zero;
}
private static string ReadAssetId(object obj)
{
if (obj == null)
{
return null;
}
try
{
if (obj is string s)
{
return s;
}
object asset = obj.GetType().GetField("asset")?.GetValue(obj)
?? obj.GetType().GetProperty("asset")?.GetValue(obj, null)
?? obj.GetType().GetMethod("getAsset")?.Invoke(obj, null);
if (asset != null)
{
object id = asset.GetType().GetField("id")?.GetValue(asset)
?? asset.GetType().GetProperty("id")?.GetValue(asset, null);
if (id != null)
{
return id.ToString();
}
}
object direct = obj.GetType().GetField("id")?.GetValue(obj)
?? obj.GetType().GetProperty("id")?.GetValue(obj, null);
return direct?.ToString();
}
catch
{
return null;
}
}
private static Actor ReadActorMember(object target, params string[] names)
{
if (target == null || names == null)
{
return null;
}
Type type = target.GetType();
foreach (string name in names)
{
try
{
var prop = type.GetProperty(name);
if (prop != null && typeof(Actor).IsAssignableFrom(prop.PropertyType))
{
return prop.GetValue(target, null) as Actor;
}
var field = type.GetField(name);
if (field != null && typeof(Actor).IsAssignableFrom(field.FieldType))
{
return field.GetValue(target) as Actor;
}
var method = type.GetMethod(name, Type.EmptyTypes);
if (method != null && typeof(Actor).IsAssignableFrom(method.ReturnType))
{
return method.Invoke(target, null) as Actor;
}
}
catch
{
// try next
}
}
return null;
}
}

View file

@ -0,0 +1,143 @@
using HarmonyLib;
namespace IdleSpectator;
/// <summary>Era + meta genesis patches from mutation discovery.</summary>
[HarmonyPatch]
public static class MetaEventPatches
{
[HarmonyPatch(typeof(WorldAgeManager), nameof(WorldAgeManager.startNextAge))]
[HarmonyPostfix]
public static void PostfixStartNextAge()
{
MetaInterestFeed.EmitEra(ReadCurrentEraId());
}
[HarmonyPatch(typeof(CultureManager), nameof(CultureManager.newCulture))]
[HarmonyPostfix]
public static void PostfixNewCulture(Culture __result)
{
EmitNew("new_culture", "Culture", 68f, __result, "culture");
}
[HarmonyPatch(typeof(ReligionManager), nameof(ReligionManager.newReligion))]
[HarmonyPostfix]
public static void PostfixNewReligion(Religion __result)
{
EmitNew("new_religion", "Culture", 72f, __result, "religion");
}
[HarmonyPatch(typeof(LanguageManager), nameof(LanguageManager.newLanguage))]
[HarmonyPostfix]
public static void PostfixNewLanguage(Language __result)
{
EmitNew("new_language", "Culture", 60f, __result, "language");
}
[HarmonyPatch(typeof(ClanManager), nameof(ClanManager.newClan))]
[HarmonyPostfix]
public static void PostfixNewClan(Clan __result)
{
EmitNew("new_clan", "Politics", 70f, __result, "clan");
}
[HarmonyPatch(typeof(ArmyManager), nameof(ArmyManager.newArmy))]
[HarmonyPostfix]
public static void PostfixNewArmy(Army __result)
{
EmitNew("new_army", "Politics", 66f, __result, "army");
}
[HarmonyPatch(typeof(AllianceManager), nameof(AllianceManager.newAlliance))]
[HarmonyPostfix]
public static void PostfixNewAlliance(Alliance __result)
{
EmitNew("new_alliance", "Politics", 78f, __result, "alliance");
}
[HarmonyPatch(typeof(CityManager), nameof(CityManager.newCity))]
[HarmonyPostfix]
public static void PostfixNewCity(City __result)
{
EmitNew("new_city", "Politics", 74f, __result, "city");
}
private static void EmitNew(
string eventId,
string category,
float strength,
object meta,
string kind)
{
Actor anchor = TryReadActor(meta);
if (anchor == null)
{
return;
}
string label = EventReason.MetaNew(anchor, kind);
MetaInterestFeed.EmitMeta(eventId, category, strength, anchor, label);
}
private static Actor TryReadActor(object meta)
{
if (meta == null)
{
return null;
}
try
{
foreach (string name in new[] { "founder", "creator", "leader", "king", "actor", "unit" })
{
object value = meta.GetType().GetField(name)?.GetValue(meta)
?? meta.GetType().GetProperty(name)?.GetValue(meta, null);
if (value is Actor actor && actor.isAlive())
{
return actor;
}
}
}
catch
{
// ignore
}
return null;
}
private static string ReadCurrentEraId()
{
try
{
object world = World.world;
object ageManager = world?.GetType().GetField("era_manager")?.GetValue(world)
?? world?.GetType().GetProperty("era_manager")?.GetValue(world, null)
?? world?.GetType().GetField("age_manager")?.GetValue(world)
?? MapBox.instance?.GetType().GetField("era_manager")?.GetValue(MapBox.instance);
if (ageManager == null)
{
return "age_unknown";
}
object asset = ageManager.GetType().GetMethod("getCurrentAge")?.Invoke(ageManager, null)
?? ageManager.GetType().GetProperty("current_age")?.GetValue(ageManager, null)
?? ageManager.GetType().GetField("current_age")?.GetValue(ageManager);
if (asset != null)
{
object id = asset.GetType().GetField("id")?.GetValue(asset)
?? asset.GetType().GetProperty("id")?.GetValue(asset, null);
if (id != null)
{
return id.ToString();
}
}
}
catch
{
// ignore
}
return "age_unknown";
}
}

View file

@ -0,0 +1,102 @@
using HarmonyLib;
namespace IdleSpectator;
/// <summary>Plot lifecycle patches from mutation discovery.</summary>
[HarmonyPatch]
public static class PlotEventPatches
{
[HarmonyPatch(typeof(PlotManager), nameof(PlotManager.newPlot))]
[HarmonyPostfix]
public static void PostfixNewPlot(Plot __result)
{
if (__result == null)
{
return;
}
PlotInterestFeed.EmitFromPlot(__result, "new");
}
[HarmonyPatch(typeof(PlotManager), nameof(PlotManager.cancelPlot))]
[HarmonyPostfix]
public static void PostfixCancelPlot(Plot pPlot)
{
if (pPlot == null)
{
return;
}
PlotInterestFeed.EmitFromPlot(pPlot, "cancel");
}
[HarmonyPatch(typeof(Actor), nameof(Actor.setPlot))]
[HarmonyPostfix]
public static void PostfixSetPlot(Actor __instance, Plot pObject)
{
if (__instance == null || !__instance.isAlive() || pObject == null)
{
return;
}
PlotInterestFeed.EmitFromPlot(pObject, "join");
}
[HarmonyPatch(typeof(Actor), nameof(Actor.leavePlot))]
[HarmonyPrefix]
public static void PrefixLeavePlot(Actor __instance, out string __state)
{
__state = "";
try
{
object plot = __instance?.GetType().GetField("plot")?.GetValue(__instance)
?? __instance?.GetType().GetProperty("plot")?.GetValue(__instance, null);
if (plot != null)
{
__state = ReadPlotAssetId(plot);
}
}
catch
{
__state = "";
}
}
[HarmonyPatch(typeof(Actor), nameof(Actor.leavePlot))]
[HarmonyPostfix]
public static void PostfixLeavePlot(Actor __instance, string __state)
{
if (__instance == null || !__instance.isAlive() || string.IsNullOrEmpty(__state))
{
return;
}
PlotInterestFeed.Emit(__state, __instance, "leave");
}
private static string ReadPlotAssetId(object plot)
{
try
{
object asset = plot.GetType().GetField("asset")?.GetValue(plot)
?? plot.GetType().GetProperty("asset")?.GetValue(plot, null)
?? plot.GetType().GetMethod("getAsset")?.Invoke(plot, null)
?? plot.GetType().GetField("plot_asset")?.GetValue(plot);
if (asset != null)
{
object id = asset.GetType().GetField("id")?.GetValue(asset)
?? asset.GetType().GetProperty("id")?.GetValue(asset, null);
if (id != null)
{
return id.ToString();
}
}
}
catch
{
// ignore
}
return "";
}
}

View file

@ -0,0 +1,186 @@
using System;
using ai.behaviours;
using HarmonyLib;
namespace IdleSpectator;
/// <summary>Relationship lifecycle patches from mutation discovery.</summary>
[HarmonyPatch]
public static class RelationshipEventPatches
{
[HarmonyPatch(typeof(Actor), nameof(Actor.setLover))]
[HarmonyPostfix]
public static void PostfixSetLover(Actor __instance, Actor pActor)
{
if (__instance == null || !__instance.isAlive())
{
return;
}
if (pActor == null)
{
RelationshipInterestFeed.Emit("clear_lover", __instance, null);
return;
}
RelationshipInterestFeed.Emit("set_lover", __instance, pActor);
}
[HarmonyPatch(typeof(Actor), nameof(Actor.addChild))]
[HarmonyPostfix]
public static void PostfixAddChild(Actor __instance, object pObject)
{
Actor child = ResolveRelatedActor(pObject);
if (__instance == null || child == null)
{
return;
}
RelationshipInterestFeed.Emit("add_child", __instance, child);
}
[HarmonyPatch(typeof(Actor), "addChildSimple")]
[HarmonyPostfix]
public static void PostfixAddChildSimple(Actor __instance, object pObject)
{
Actor child = ResolveRelatedActor(pObject);
if (__instance == null || child == null)
{
return;
}
RelationshipInterestFeed.Emit("add_child", __instance, child);
}
private static Actor ResolveRelatedActor(object raw)
{
if (raw == null)
{
return null;
}
if (raw is Actor actor)
{
return actor;
}
try
{
if (raw is BaseSimObject sim && sim.isActor())
{
return sim.a;
}
}
catch
{
// fall through
}
try
{
var type = raw.GetType();
foreach (string name in new[] { "actor", "Actor", "a", "parent" })
{
var prop = type.GetProperty(name);
if (prop != null && typeof(Actor).IsAssignableFrom(prop.PropertyType))
{
return prop.GetValue(raw, null) as Actor;
}
var field = type.GetField(name);
if (field != null && typeof(Actor).IsAssignableFrom(field.FieldType))
{
return field.GetValue(raw) as Actor;
}
}
var getActor = type.GetMethod("getActor", Type.EmptyTypes)
?? type.GetMethod("GetActor", Type.EmptyTypes);
if (getActor != null && typeof(Actor).IsAssignableFrom(getActor.ReturnType))
{
return getActor.Invoke(raw, null) as Actor;
}
}
catch
{
// ignore
}
return null;
}
[HarmonyPatch(typeof(FamilyManager), nameof(FamilyManager.newFamily))]
[HarmonyPostfix]
public static void PostfixNewFamily(Family __result, Actor pActor)
{
Actor anchor = pActor != null && pActor.isAlive() ? pActor : null;
RelationshipInterestFeed.EmitFamily("new_family", __result, anchor);
}
[HarmonyPatch(typeof(FamilyManager), "removeObject", new Type[] { typeof(Family) })]
[HarmonyPostfix]
public static void PostfixRemoveFamily(Family pObject)
{
RelationshipInterestFeed.EmitFamily("family_removed", pObject, null);
}
[HarmonyPatch(typeof(ActorManager), "createBabyActorFromData")]
[HarmonyPostfix]
public static void PostfixCreateBaby(Actor __result)
{
if (__result == null || !__result.isAlive())
{
return;
}
RelationshipInterestFeed.Emit("baby_created", __result, null);
}
[HarmonyPatch(typeof(BehFindLover), nameof(BehFindLover.execute))]
[HarmonyPostfix]
public static void PostfixFindLover(Actor pActor, BehResult __result)
{
if (pActor == null || !pActor.isAlive() || __result == BehResult.Stop)
{
return;
}
RelationshipInterestFeed.Emit("find_lover", pActor, null);
}
[HarmonyPatch(typeof(BehFamilyGroupNew), nameof(BehFamilyGroupNew.execute))]
[HarmonyPostfix]
public static void PostfixFamilyNew(Actor pActor, BehResult __result)
{
if (pActor == null || !pActor.isAlive() || __result == BehResult.Stop)
{
return;
}
RelationshipInterestFeed.Emit("family_group_new", pActor, ActorRelation.ResolvePackRelated(pActor));
}
[HarmonyPatch(typeof(BehFamilyGroupJoin), nameof(BehFamilyGroupJoin.execute))]
[HarmonyPostfix]
public static void PostfixFamilyJoin(Actor pActor, BehResult __result)
{
if (pActor == null || !pActor.isAlive() || __result == BehResult.Stop)
{
return;
}
RelationshipInterestFeed.Emit("family_group_join", pActor, ActorRelation.ResolvePackRelated(pActor));
}
[HarmonyPatch(typeof(BehFamilyGroupLeave), nameof(BehFamilyGroupLeave.execute))]
[HarmonyPostfix]
public static void PostfixFamilyLeave(Actor pActor, BehResult __result)
{
if (pActor == null || !pActor.isAlive() || __result == BehResult.Stop)
{
return;
}
RelationshipInterestFeed.Emit("family_group_leave", pActor, ActorRelation.ResolvePackRelated(pActor));
}
}

View file

@ -0,0 +1,204 @@
using System;
using System.Collections.Generic;
using HarmonyLib;
using UnityEngine;
namespace IdleSpectator;
/// <summary>Actor trait add/remove interest patches from mutation discovery.</summary>
[HarmonyPatch]
public static class TraitEventPatches
{
/// <summary>
/// Birth/spawn endowment traits that are still worth a camera beat.
/// Routine species defaults (poisonous frog, bloodlust beetle, …) are not.
/// </summary>
private static readonly HashSet<string> LegendaryBirthTraits = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"immortal",
"chosen_one",
"miracle_born",
"miracle_bearer",
"scar_of_divinity",
"giant",
"whirlwind",
"death_nuke",
"death_bomb",
"bomberman"
};
[HarmonyPatch(typeof(Actor), nameof(Actor.addTrait), new Type[] { typeof(string), typeof(bool) })]
[HarmonyPostfix]
public static void PostfixAddTraitString(Actor __instance, string pTraitID, bool __result)
{
if (!__result || __instance == null || !__instance.isAlive() || string.IsNullOrEmpty(pTraitID))
{
return;
}
Emit(pTraitID, __instance, gained: true);
}
[HarmonyPatch(typeof(Actor), nameof(Actor.addTrait), new Type[] { typeof(ActorTrait), typeof(bool) })]
[HarmonyPostfix]
public static void PostfixAddTraitAsset(Actor __instance, ActorTrait pTrait, bool __result)
{
if (!__result || __instance == null || !__instance.isAlive() || pTrait == null)
{
return;
}
string id = "";
try
{
id = pTrait.id ?? "";
}
catch
{
id = "";
}
if (string.IsNullOrEmpty(id))
{
return;
}
Emit(id, __instance, gained: true);
}
[HarmonyPatch(typeof(Actor), nameof(Actor.removeTrait), new Type[] { typeof(string) })]
[HarmonyPostfix]
public static void PostfixRemoveTraitString(Actor __instance, string pTraitID)
{
if (__instance == null || !__instance.isAlive() || string.IsNullOrEmpty(pTraitID))
{
return;
}
Emit(pTraitID, __instance, gained: false);
}
[HarmonyPatch(typeof(Actor), nameof(Actor.removeTrait), new Type[] { typeof(ActorTrait) })]
[HarmonyPostfix]
public static void PostfixRemoveTraitAsset(Actor __instance, ActorTrait pTrait, bool __result)
{
if (!__result || __instance == null || !__instance.isAlive() || pTrait == null)
{
return;
}
string id = "";
try
{
id = pTrait.id ?? "";
}
catch
{
id = "";
}
if (string.IsNullOrEmpty(id))
{
return;
}
Emit(id, __instance, gained: false);
}
private static void Emit(string traitId, Actor actor, bool gained)
{
if (AgentHarness.Busy)
{
return;
}
DiscreteEventEntry entry = EventCatalog.Trait.GetOrFallback(traitId);
if (!entry.CreatesInterest)
{
return;
}
// Quiet-world Ambient traits must not steal the camera.
if (entry.EventStrength < InterestScoringConfig.W.noticeScoreMin)
{
return;
}
// Spawn/birth endowment: only legendary rarities.
if (gained && IsSpawnTraitWindow(actor) && !LegendaryBirthTraits.Contains(entry.Id))
{
return;
}
float strength = entry.EventStrength;
if (!gained)
{
strength = Mathf.Min(strength, 45f);
// Trait loss is quieter unless it was dramatic.
if (entry.EventStrength < InterestScoringConfig.W.hotScoreMin)
{
return;
}
}
string label = EventReason.Trait(actor, entry.Id, gained);
string phase = gained ? "gains" : "loses";
string key = "trait:" + entry.Id + ":" + phase + ":" + EventFeedUtil.SafeId(actor);
EventFeedUtil.Register(
key,
"trait",
entry.Category,
label,
entry.Id,
strength,
actor.current_position,
actor);
}
/// <summary>True while traits are still being stamped onto a brand-new unit.</summary>
private static bool IsSpawnTraitWindow(Actor actor)
{
if (actor == null)
{
return true;
}
try
{
if (actor.isBaby())
{
return true;
}
}
catch
{
// ignore
}
try
{
double created = actor.data != null ? actor.data.created_time : 0;
if (created <= 0)
{
return false;
}
double now = 0;
if (World.world != null)
{
now = World.world.getCurWorldTime();
}
else if (MapBox.instance != null)
{
now = MapBox.instance.getCurWorldTime();
}
// Within ~2 world-years of creation = spawn endowment, not a mid-life gain.
return now > 0 && (now - created) < 2.0;
}
catch
{
return false;
}
}
}

View file

@ -0,0 +1,46 @@
using HarmonyLib;
namespace IdleSpectator;
/// <summary>War genesis/end patches from mutation discovery.</summary>
[HarmonyPatch]
public static class WarEventPatches
{
[HarmonyPatch(typeof(WarManager), nameof(WarManager.newWar))]
[HarmonyPostfix]
public static void PostfixNewWar(War __result)
{
if (__result == null || AgentHarness.Busy)
{
return;
}
try
{
WarInterestFeed.EmitFromWarObject(__result, phase: "new");
}
catch
{
// Fallback: poll path still covers ongoing wars.
}
}
[HarmonyPatch(typeof(WarManager), nameof(WarManager.endWar))]
[HarmonyPostfix]
public static void PostfixEndWar(object[] __args)
{
if (AgentHarness.Busy || __args == null || __args.Length == 0 || __args[0] == null)
{
return;
}
try
{
WarInterestFeed.EmitFromWarObject(__args[0], phase: "end");
}
catch
{
// ignore
}
}
}

View file

@ -14,17 +14,13 @@ public static class WorldLogMessageAddPatch
return;
}
if (!WorldLogInterestTable.TryGetEventStrength(pMessage.asset_id, out float evtSeed))
WorldLogEventEntry entry = EventCatalog.WorldLog.GetOrFallback(pMessage.asset_id);
// Chronicle notable world events only (authored ChronicleEligible / story-strength+).
if (!entry.ChronicleEligible)
{
return;
}
// Chronicle notable world events (story-strength and above); skip footnotes.
if (evtSeed < 65f)
{
return;
}
Chronicle.NoteWorldLog(pMessage, WorldLogInterestTable.MakeLabel(pMessage));
Chronicle.NoteWorldLog(pMessage, entry.MakeLabel(pMessage));
}
}

View file

@ -214,7 +214,7 @@ public static class HappinessEventRouter
FlushStaleAggregates();
HappinessCatalogEntry entry = HappinessEventCatalog.GetOrFallback(effectId);
HappinessCatalogEntry entry = EventCatalog.Happiness.GetOrFallback(effectId);
if (related == null
&& HappinessContextBag.TryGetRelated(subject, out Actor bagRelated, out HappinessRelationRole bagRole))
{
@ -407,7 +407,7 @@ public static class HappinessEventRouter
continue;
}
HappinessCatalogEntry entry = HappinessEventCatalog.GetOrFallback(bucket.EffectId);
HappinessCatalogEntry entry = EventCatalog.Happiness.GetOrFallback(bucket.EffectId);
HappinessOccurrence summary = BuildOccurrence(
subject,
bucket.EffectId,
@ -450,7 +450,7 @@ public static class HappinessEventRouter
bool applied,
bool suppressedByPsychopath)
{
HappinessCatalogEntry entry = HappinessEventCatalog.GetOrFallback(effectId);
HappinessCatalogEntry entry = EventCatalog.Happiness.GetOrFallback(effectId);
long subjectId = 0;
string subjectName = "Someone";
string species = "";

View file

@ -66,6 +66,7 @@ public sealed class HappinessCatalogEntry
public HappinessOverlapPolicy Overlap = HappinessOverlapPolicy.Independent;
public HappinessRelationRole RelationRole = HappinessRelationRole.None;
public string InterestCategory = "";
public float EventStrength = 40f;
public string CanonicalMilestoneKey = "";
public string StatusOverlapId = "";
public string[] VariantsWithRelated = Array.Empty<string>();

View file

@ -38,6 +38,9 @@ internal static class HarnessScenarios
case "director_gaps":
case "director_coverage":
return DirectorGaps();
case "focus_interrupt":
case "camera_interrupt":
return FocusInterrupt();
case "watch_reason":
case "watch_reason_clarity":
return WatchReasonClarity();
@ -48,6 +51,19 @@ internal static class HarnessScenarios
return Discovery();
case "world_log":
return WorldLog();
case "world_log_audit":
return WorldLogAudit();
case "disaster_war_audit":
case "disaster_war_inventory":
return DisasterWarAudit();
case "event_coverage":
return EventCoverage();
case "domain_event_audit":
return DomainEventAudit();
case "event_tracking":
return EventTracking();
case "mutation_discovery":
return MutationDiscovery();
case "chronicle":
case "chronicle_smoke":
return ChronicleSmoke();
@ -146,6 +162,7 @@ internal static class HarnessScenarios
Nested("reg_action_priority", "director_action_priority"),
Nested("reg_discovery", "discovery"),
Nested("reg_worldlog", "world_log"),
Nested("reg_event_coverage", "event_coverage"),
Nested("reg_chronicle", "chronicle_smoke"),
Nested("reg_activity", "activity_idle_smoke"),
Nested("reg_activity_status", "activity_status_smoke"),
@ -615,31 +632,29 @@ internal static class HarnessScenarios
Step("dt1", "wait_world"),
Step("dt2", "set_setting", expect: "enabled", value: "true"),
Step("dt3", "fast_timing", value: "true"),
// Calm unit + spectator restart clears collector so world Story tips cannot steal CurioA.
Step("dt4", "spawn", asset: "sheep"),
// Calm durable unit + spectator restart clears collector so world tips cannot steal CurioA.
Step("dt4", "spawn", asset: "dragon"),
Step("dt5", "spectator", value: "off"),
Step("dt6", "spectator", value: "on"),
Step("dt7", "focus", asset: "sheep"),
Step("dt7", "focus", asset: "dragon"),
Step("dt8", "reset_counters"),
// Curiosity can replace ambient after rotate window.
Step("dt10", "trigger_interest", asset: "sheep", label: "CurioA", tier: "Curiosity"),
Step("dt11", "age_current", wait: 2f),
Step("dt12", "director_run", wait: 1.2f),
Step("dt13", "assert", expect: "tip_contains", value: "CurioA"),
// Curiosity hold.
Step("dt10", "interest_force_session", asset: "dragon", label: "CurioA", tier: "Curiosity", expect: "curio_a"),
Step("dt11", "assert", expect: "tip_contains", value: "CurioA"),
// Action interrupts curiosity after settle.
Step("dt20", "trigger_interest", asset: "auto", label: "ActionA", tier: "Action"),
Step("dt21", "age_current", wait: 1.2f),
Step("dt22", "director_run", wait: 1.2f),
Step("dt23", "assert", expect: "tip_contains", value: "ActionA"),
// Action margin-cuts curiosity (instant; no settle required).
Step("dt20", "trigger_interest", asset: "dragon", label: "ActionA", tier: "Action"),
Step("dt21", "director_run", wait: 0.8f),
Step("dt22", "assert", expect: "tip_contains", value: "ActionA"),
// Curiosity must not steal the camera while Action holds (no director_run).
Step("dt30", "trigger_interest", asset: "auto", label: "CurioBlocked", tier: "Curiosity"),
Step("dt30", "trigger_interest", asset: "dragon", label: "CurioBlocked", tier: "Curiosity"),
Step("dt31", "assert", expect: "tip_contains", value: "ActionA"),
Step("dt32", "assert", expect: "would_accept_curiosity", value: "false"),
Step("dt33", "assert", expect: "health"),
Step("dt34", "assert", expect: "no_bad"),
Step("dt33", "reset_counters"),
Step("dt34", "assert", expect: "health"),
Step("dt35", "assert", expect: "no_bad"),
Step("dt90", "fast_timing", value: "false"),
Step("dt99", "snapshot"),
@ -654,27 +669,26 @@ internal static class HarnessScenarios
Step("dl1", "wait_world"),
Step("dl2", "set_setting", expect: "enabled", value: "true"),
Step("dl3", "fast_timing", value: "true"),
Step("dl4", "spawn", asset: "sheep"),
Step("dl4", "spawn", asset: "dragon"),
Step("dl5", "spectator", value: "off"),
Step("dl6", "spectator", value: "on"),
Step("dl7", "focus", asset: "sheep"),
Step("dl7", "focus", asset: "dragon"),
// Protected Action: Story waits.
Step("dl10", "interest_force_session", asset: "sheep", label: "HoldAction", tier: "Action", expect: "hold_action"),
// Protected Action: peer Story (+0 margin) waits.
Step("dl10", "interest_force_session", asset: "dragon", label: "HoldAction", tier: "Action", expect: "hold_action"),
Step("dl11", "assert", expect: "tip_contains", value: "HoldAction"),
Step("dl12", "assert", expect: "session_active", value: "true"),
Step("dl13", "trigger_interest", asset: "sheep", label: "StoryWait", tier: "Story"),
Step("dl14", "age_current", wait: 1.2f),
Step("dl15", "director_run", wait: 1.2f),
Step("dl13", "trigger_interest", asset: "dragon", label: "StoryWait", tier: "Story"),
Step("dl14", "director_run", wait: 0.8f),
Step("dl15", "assert", expect: "tip_contains", value: "HoldAction"),
Step("dl16", "assert", expect: "tip_contains", value: "HoldAction"),
Step("dl17", "assert", expect: "tip_contains", value: "HoldAction"),
// Epic preempts Action after settle.
Step("dl20", "trigger_interest", asset: "sheep", label: "EpicWin", tier: "Epic"),
Step("dl21", "age_current", wait: 1.2f),
Step("dl22", "director_run", wait: 1.2f),
// Epic margin-cuts Action instantly (no settle).
Step("dl20", "trigger_interest", asset: "dragon", label: "EpicWin", tier: "Epic"),
Step("dl21", "director_run", wait: 0.8f),
Step("dl22", "assert", expect: "tip_contains", value: "EpicWin"),
Step("dl23", "assert", expect: "tip_contains", value: "EpicWin"),
Step("dl24", "assert", expect: "tip_contains", value: "EpicWin"),
Step("dl24", "reset_counters"),
Step("dl25", "assert", expect: "no_bad"),
Step("dl90", "fast_timing", value: "false"),
@ -712,10 +726,9 @@ internal static class HarnessScenarios
// Epic can leave Action; grief may then surface.
Step("ih30", "trigger_interest", asset: "auto", label: "EpicClear", tier: "Epic"),
Step("ih31", "age_current", wait: 1.2f),
Step("ih32", "director_run", wait: 1.2f),
Step("ih33", "assert", expect: "tip_contains", value: "EpicClear"),
Step("ih34", "assert", expect: "no_bad"),
Step("ih31", "director_run", wait: 0.8f),
Step("ih32", "assert", expect: "tip_contains", value: "EpicClear"),
Step("ih33", "assert", expect: "no_bad"),
Step("ih90", "fast_timing", value: "false"),
Step("ih99", "snapshot"),
@ -733,37 +746,35 @@ internal static class HarnessScenarios
Step("dg1", "wait_world"),
Step("dg2", "set_setting", expect: "enabled", value: "true"),
Step("dg3", "fast_timing", value: "true"),
Step("dg4", "spawn", asset: "sheep", count: 2),
Step("dg4", "spawn", asset: "dragon", count: 1),
Step("dg5", "spectator", value: "off"),
Step("dg6", "spectator", value: "on"),
Step("dg7", "focus", asset: "sheep"),
Step("dg7", "focus", asset: "dragon"),
Step("dg8", "interest_variety_clear"),
// --- Epic preempts Story; Story does not preempt itself past Action rules ---
Step("dg10", "interest_force_session", asset: "sheep", label: "HoldStory", tier: "Story", expect: "hold_story"),
// --- Epic margin-cuts Story; Action does not (below cutInMargin) ---
// Hold as EventLed so CharacterLed char-weight cannot outscore Epic.
Step("dg10", "interest_force_session", asset: "dragon", label: "HoldStory", tier: "Story", expect: "hold_story", value: "lead=event;evt=80"),
Step("dg11", "assert", expect: "tip_contains", value: "HoldStory"),
Step("dg12", "trigger_interest", asset: "sheep", label: "ActionBlocked", tier: "Action"),
Step("dg13", "age_current", wait: 1.2f),
Step("dg14", "director_run", wait: 1.2f),
Step("dg12", "trigger_interest", asset: "dragon", label: "ActionBlocked", tier: "Action"),
Step("dg13", "director_run", wait: 0.8f),
Step("dg14", "assert", expect: "tip_contains", value: "HoldStory"),
Step("dg15", "assert", expect: "tip_contains", value: "HoldStory"),
Step("dg16", "assert", expect: "tip_contains", value: "HoldStory"),
Step("dg17", "trigger_interest", asset: "sheep", label: "EpicOverStory", tier: "Epic"),
Step("dg18", "age_current", wait: 1.2f),
Step("dg19", "director_run", wait: 1.2f),
Step("dg17", "trigger_interest", asset: "dragon", label: "EpicOverStory", tier: "Epic"),
Step("dg18", "director_run", wait: 0.8f),
Step("dg19", "assert", expect: "tip_contains", value: "EpicOverStory"),
Step("dg20", "assert", expect: "tip_contains", value: "EpicOverStory"),
Step("dg21", "assert", expect: "tip_contains", value: "EpicOverStory"),
// --- Resume after Epic: Action hold interrupted then restored ---
Step("dg30", "spectator", value: "off"),
Step("dg31", "spectator", value: "on"),
Step("dg32", "focus", asset: "sheep"),
Step("dg33", "interest_force_session", asset: "sheep", label: "HoldResume", tier: "Action", expect: "hold_resume"),
Step("dg32", "focus", asset: "dragon"),
Step("dg33", "interest_force_session", asset: "dragon", label: "HoldResume", tier: "Action", expect: "hold_resume", value: "lead=event;evt=70"),
Step("dg34", "assert", expect: "session_key", value: "hold_resume"),
Step("dg35", "trigger_interest", asset: "sheep", label: "EpicInterrupt", tier: "Epic"),
Step("dg36", "age_current", wait: 1.2f),
Step("dg37", "director_run", wait: 1.2f),
Step("dg38", "assert", expect: "tip_contains", value: "EpicInterrupt"),
Step("dg39", "assert", expect: "interrupted_key", value: "hold_resume"),
Step("dg35", "trigger_interest", asset: "dragon", label: "EpicInterrupt", tier: "Epic"),
Step("dg36", "director_run", wait: 0.8f),
Step("dg37", "assert", expect: "tip_contains", value: "EpicInterrupt"),
Step("dg38", "assert", expect: "interrupted_key", value: "hold_resume"),
// Max-cap the Epic scene (fast timing caps ~8s) so EndCurrent resumes Action.
Step("dg40", "age_current", wait: 9f),
Step("dg41", "director_run", wait: 1.2f),
@ -772,30 +783,30 @@ internal static class HarnessScenarios
Step("dg44", "assert", expect: "interrupted_key", value: "none"),
// --- Max cap ends a forced scene ---
Step("dg50", "interest_force_session", asset: "sheep", label: "CapMe", tier: "Action", expect: "cap_me"),
Step("dg50", "interest_force_session", asset: "dragon", label: "CapMe", tier: "Action", expect: "cap_me"),
Step("dg51", "age_current", wait: 9f),
Step("dg52", "director_run", wait: 1.2f),
Step("dg54", "assert", expect: "interest_no_key", value: "cap_me"),
// --- Quiet grace: inactive scene ends after grace ---
Step("dg60", "interest_force_session", asset: "sheep", label: "QuietEnd", tier: "Action", expect: "quiet_end"),
Step("dg60", "interest_force_session", asset: "dragon", label: "QuietEnd", tier: "Action", expect: "quiet_end"),
Step("dg61", "interest_mark_inactive", value: "1.0"),
Step("dg62", "assert", expect: "session_active", value: "false"),
Step("dg63", "director_run", wait: 1.2f),
Step("dg64", "assert", expect: "interest_no_key", value: "quiet_end"),
// --- Stale expiry / dedupe refresh ---
Step("dg70", "interest_inject", asset: "sheep", label: "StaleTip", tier: "Curiosity", expect: "stale_tip", value: "lead=character;ttl=0.01"),
Step("dg70", "interest_inject", asset: "dragon", label: "StaleTip", tier: "Curiosity", expect: "stale_tip", value: "lead=character;ttl=0.01"),
Step("dg71", "assert", expect: "interest_has_key", value: "stale_tip"),
Step("dg72", "interest_expire_pending", value: "stale_tip"),
Step("dg73", "assert", expect: "interest_no_key", value: "stale_tip"),
Step("dg74", "interest_inject", asset: "sheep", label: "RefreshA", tier: "Action", expect: "refresh_a", value: "lead=event;evt=20"),
Step("dg75", "interest_inject", asset: "sheep", label: "RefreshA", tier: "Action", expect: "refresh_a", value: "lead=event;evt=90"),
Step("dg74", "interest_inject", asset: "dragon", label: "RefreshA", tier: "Action", expect: "refresh_a", value: "lead=event;evt=20"),
Step("dg75", "interest_inject", asset: "dragon", label: "RefreshA", tier: "Action", expect: "refresh_a", value: "lead=event;evt=90"),
Step("dg76", "assert", expect: "interest_registry_count", value: "1", label: "refresh_a"),
// --- Death handoff: FollowUnit cleared → RelatedUnit ---
Step("dg80", "interest_force_session", asset: "sheep", label: "HandoffHold", tier: "Action", expect: "handoff_hold"),
Step("dg81", "interest_clear_follow", asset: "sheep"),
Step("dg80", "interest_force_session", asset: "dragon", label: "HandoffHold", tier: "Action", expect: "handoff_hold"),
Step("dg81", "interest_clear_follow", asset: "dragon"),
Step("dg82", "director_run", wait: 0.8f),
Step("dg83", "assert", expect: "session_key", value: "handoff_hold"),
Step("dg84", "assert", expect: "session_active", value: "true"),
@ -806,8 +817,8 @@ internal static class HarnessScenarios
Step("dg91b", "interest_expire_pending", value: ""),
Step("dg92", "interest_variety_seed", value: "2:10"),
Step("dg93", "assert", expect: "interest_event_share", value: "0.17", label: "0.2"),
Step("dg94", "interest_inject", asset: "sheep", label: "EvtWin", tier: "Curiosity", expect: "evt_win", value: "lead=event;evt=80"),
Step("dg95", "interest_inject", asset: "sheep", label: "CharLose", tier: "Curiosity", expect: "char_lose", value: "lead=character;char=95"),
Step("dg94", "interest_inject", asset: "dragon", label: "EvtWin", tier: "Curiosity", expect: "evt_win", value: "lead=event;evt=80"),
Step("dg95", "interest_inject", asset: "dragon", label: "CharLose", tier: "Curiosity", expect: "char_lose", value: "lead=character;char=95"),
Step("dg96", "assert", expect: "interest_peek_lead", value: "EventLed"),
// Empty event pool → character-led, no invented events
Step("dg97", "interest_expire_pending", value: "evt_win"),
@ -815,12 +826,14 @@ internal static class HarnessScenarios
// --- Event strength beats idle celebrity (within event pool / score order) ---
Step("dg100", "interest_expire_pending", value: ""),
Step("dg101", "interest_inject", asset: "sheep", label: "StrongEvt", tier: "Action", expect: "strong_evt", value: "lead=event;evt=95;char=5"),
Step("dg102", "interest_inject", asset: "sheep", label: "CelebWeak", tier: "Action", expect: "celeb_weak", value: "lead=event;evt=10;char=99"),
Step("dg101", "interest_inject", asset: "dragon", label: "StrongEvt", tier: "Action", expect: "strong_evt", value: "lead=event;evt=95;char=5"),
Step("dg102", "interest_inject", asset: "dragon", label: "CelebWeak", tier: "Action", expect: "celeb_weak", value: "lead=event;evt=10;char=99"),
Step("dg103", "assert", expect: "interest_score_order", value: "strong_evt", label: "celeb_weak"),
// --- Ambient happiness never owns camera; psychopath suppress; drain duplex; aggregate ---
Step("dg110", "interest_force_session", asset: "sheep", label: "ActionKeep", tier: "Action", expect: "action_keep"),
Step("dg109", "spawn", asset: "human"),
Step("dg109b", "focus", asset: "human"),
Step("dg110", "interest_force_session", asset: "human", label: "ActionKeep", tier: "Action", expect: "action_keep"),
Step("dg111", "happiness_apply", value: "just_ate"),
Step("dg112", "interest_feeds_tick"),
Step("dg113", "age_current", wait: 1.2f),
@ -853,6 +866,43 @@ internal static class HarnessScenarios
};
}
private static List<HarnessCommand> FocusInterrupt()
{
return new List<HarnessCommand>
{
Step("fi0", "dismiss_windows"),
Step("fi1", "wait_world"),
Step("fi2", "set_setting", expect: "enabled", value: "true"),
Step("fi3", "fast_timing", value: "true"),
Step("fi4", "spawn", asset: "sheep"),
Step("fi5", "spectator", value: "off"),
Step("fi6", "spectator", value: "on"),
Step("fi7", "focus", asset: "sheep"),
// Hold Action; peer Story does not cut (below cutInMargin).
Step("fi10", "interest_force_session", asset: "sheep", label: "HoldA", tier: "Action", expect: "hold_a"),
Step("fi11", "trigger_interest", asset: "sheep", label: "PeerStory", tier: "Story"),
Step("fi12", "director_run", wait: 0.6f),
Step("fi13", "assert", expect: "tip_contains", value: "HoldA"),
// Epic margin-cuts instantly (no age/settle).
Step("fi20", "trigger_interest", asset: "sheep", label: "CutEpic", tier: "Epic"),
Step("fi21", "director_run", wait: 0.6f),
Step("fi22", "assert", expect: "tip_contains", value: "CutEpic"),
// Quiet grace then ambient empty reason path.
Step("fi30", "interest_force_session", asset: "sheep", label: "GraceEnd", tier: "Action", expect: "grace_end"),
Step("fi31", "interest_mark_inactive", value: "1.0"),
Step("fi32", "assert", expect: "session_active", value: "false"),
Step("fi33", "director_run", wait: 1.2f),
Step("fi34", "assert", expect: "interest_no_key", value: "grace_end"),
Step("fi35", "assert", expect: "no_bad"),
Step("fi90", "fast_timing", value: "false"),
Step("fi99", "snapshot"),
};
}
/// <summary>
/// Watch-reason row must explain why the camera is here (event), not who (title/name).
/// </summary>
@ -871,12 +921,12 @@ internal static class HarnessScenarios
Step("wr8", "focus", asset: "human"),
Step("wr9", "dossier_clear_job"),
// Fighting label → story beat "Fighting" (no tier badge, no identity crumb).
Step("wr10", "interest_force_session", asset: "human", label: "Fighting: Isemward", tier: "Action", expect: "fight_reason"),
Step("wr11", "assert", expect: "dossier_contains", value: "Fighting"),
// EventReason sentence with subject name kept.
Step("wr10", "interest_force_session", asset: "human", label: "{a} is fighting Waaaf", tier: "Action", expect: "fight_reason"),
Step("wr11", "assert", expect: "dossier_contains", value: "is fighting"),
Step("wr12", "assert", expect: "dossier_not_contains", value: "Action ·"),
Step("wr12b", "assert", expect: "dossier_not_contains", value: "Live ·"),
Step("wr13", "assert", expect: "tip_contains", value: "Fighting"),
Step("wr13", "assert", expect: "tip_contains", value: "is fighting"),
Step("wr14", "screenshot", value: "hud-watch-reason-fighting.png"),
Step("wr15", "wait", wait: 0.55f),
@ -896,7 +946,7 @@ internal static class HarnessScenarios
Step("wr33", "screenshot", value: "hud-watch-reason-war.png"),
Step("wr34", "wait", wait: 0.55f),
// Weak singleton crumb → catch-all or Last of its kind (never "Lone beetle" / tier badges).
// Weak singleton crumb → never "Lone beetle" / tier badges.
Step("wr40", "interest_force_session", asset: "human", label: "Lone beetle", tier: "Curiosity", expect: "lone_crumb"),
Step("wr41", "assert", expect: "dossier_not_contains", value: "Lone beetle"),
Step("wr42", "assert", expect: "dossier_not_contains", value: "Live ·"),
@ -913,9 +963,35 @@ internal static class HarnessScenarios
Step("wr48", "assert", expect: "dossier_not_contains", value: "Live ·"),
Step("wr49", "wait", wait: 0.35f),
// Mass fight inject → Clash / Fighting scale beat.
// Ownership: reason must belong to focused subject (not a stranger name).
Step("wr60", "spawn", asset: "human", count: 2),
Step("wr61", "interest_force_session", asset: "human",
label: "Cabo loses infected", tier: "Action", expect: "stranger_label"),
Step("wr62", "assert", expect: "reason_owns_focus"),
Step("wr63", "assert", expect: "dossier_not_contains", value: "Cabo"),
Step("wr64", "interest_force_session", asset: "human",
label: "{a} became lovers with a partner", tier: "Action", expect: "lovers_owned"),
Step("wr65", "assert", expect: "reason_owns_focus"),
Step("wr65b", "assert", expect: "dossier_contains", value: "became lovers"),
Step("wr66", "wait", wait: 0.35f),
// Low-strength former-Ambient event still owns camera + shows sentence.
Step("wr67", "interest_force_session", asset: "human",
label: "{a} is seeking a lover", tier: "Action", expect: "seek_lover_low",
value: "lead=event;evt=45"),
Step("wr68", "assert", expect: "dossier_contains", value: "is seeking a lover"),
Step("wr69", "assert", expect: "reason_owns_focus"),
// Hatch reason (live path is egg status-loss → EmitHatch; force here for reason text).
Step("wr69a", "interest_force_session", asset: "human",
label: "{a} hatches from an egg", tier: "Action", expect: "hatch_reason",
value: "lead=event;evt=88"),
Step("wr69b", "assert", expect: "dossier_contains", value: "hatches from an egg"),
Step("wr69c", "assert", expect: "reason_owns_focus"),
// Label-sourced clash (not inventing Clash from fighter counts).
Step("wr50", "interest_expire_pending", value: ""),
Step("wr51", "interest_inject", asset: "human", label: "MassClash", tier: "Action", expect: "mass_clash",
Step("wr51", "interest_inject", asset: "human", label: "Clash · 8 fighting", tier: "Action", expect: "mass_clash",
value: "lead=event;evt=90;char=5;fighters=8;notables=0;force=true"),
Step("wr52", "age_current", wait: 0.8f),
Step("wr53", "director_run", wait: 1.2f),
@ -925,6 +1001,14 @@ internal static class HarnessScenarios
Step("wr57", "screenshot", value: "hud-watch-reason-clash.png"),
Step("wr58", "wait", wait: 0.55f),
// After active dwell ends, orange reason clears even if focus remains.
Step("wr70", "interest_force_session", asset: "human",
label: "War: Essionan vs North", tier: "Epic", expect: "dwell_end_war"),
Step("wr71", "assert", expect: "dossier_contains", value: "War"),
Step("wr72", "interest_mark_inactive", value: "1.0"),
Step("wr73", "assert", expect: "reason_empty", value: "true"),
Step("wr74", "assert", expect: "reason_owns_focus"),
// Cold-boot / force-session focus gaps are unrelated to caption clarity.
Step("wr89", "reset_counters"),
Step("wr90", "assert", expect: "no_bad"),
@ -976,16 +1060,14 @@ internal static class HarnessScenarios
value: "lead=event;evt=55;char=40;fighters=2;notables=2"),
Step("ap33", "assert", expect: "interest_score_order", value: "duel_kings", label: "melee_noname"),
// Combat Action cuts celebrity Story vignette after settle.
// Combat Action margin-cuts celebrity Story vignette (instant).
Step("ap40", "interest_expire_pending", value: ""),
Step("ap41", "interest_force_session", asset: "sheep", label: "KingStroll", tier: "Story", expect: "king_stroll"),
// Mark current as character vignette (not WorldLog story event).
Step("ap42", "interest_inject", asset: "sheep", label: "RealFight", tier: "Action", expect: "real_fight",
value: "lead=event;evt=90;char=10;fighters=4;notables=1;force=true"),
Step("ap43", "age_current", wait: 1.2f),
Step("ap44", "director_run", wait: 1.2f),
value: "lead=event;evt=120;char=10;fighters=4;notables=1;force=true"),
Step("ap43", "director_run", wait: 0.8f),
Step("ap44", "assert", expect: "tip_contains", value: "RealFight"),
Step("ap45", "assert", expect: "tip_contains", value: "RealFight"),
Step("ap46", "assert", expect: "tip_contains", value: "RealFight"),
// Cold-boot focus gaps from welcome/dismiss are unrelated to score order.
Step("ap89", "reset_counters"),
@ -1069,6 +1151,126 @@ internal static class HarnessScenarios
};
}
private static List<HarnessCommand> WorldLogAudit()
{
return new List<HarnessCommand>
{
Step("wla0", "wait_world"),
Step("wla1", "assert", expect: "world_log_audit"),
Step("wla2", "snapshot")
};
}
private static List<HarnessCommand> DisasterWarAudit()
{
return new List<HarnessCommand>
{
Step("dwa0", "wait_world"),
Step("dwa1", "assert", expect: "disaster_war_audit"),
Step("dwa2", "snapshot")
};
}
private static List<HarnessCommand> DomainEventAudit()
{
return new List<HarnessCommand>
{
Step("dea0", "wait_world"),
Step("dea1", "assert", expect: "domain_event_audit"),
Step("dea2", "snapshot")
};
}
private static List<HarnessCommand> EventTracking()
{
return new List<HarnessCommand>
{
Step("et0", "dismiss_windows"),
Step("et1", "wait_world"),
Step("et2", "set_setting", expect: "enabled", value: "true"),
Step("et3", "pick_unit", asset: "auto"),
Step("et4", "spectator", value: "off"),
Step("et5", "spectator", value: "on"),
Step("et6", "focus", asset: "auto"),
Step("et10", "assert", expect: "domain_event_audit"),
Step("et20", "domain_feed", asset: "relationship", value: "set_lover"),
Step("et21", "assert", expect: "interest_has_key", value: "rel:set_lover"),
Step("et22", "domain_feed", asset: "plot", value: "summon_meteor_rain"),
Step("et23", "assert", expect: "interest_has_key", value: "plot:summon_meteor_rain"),
Step("et24", "domain_feed", asset: "era", value: "age_chaos"),
Step("et25", "assert", expect: "interest_has_key", value: "era:age_chaos"),
Step("et26", "domain_feed", asset: "meta", value: "new_city"),
Step("et27", "assert", expect: "interest_has_key", value: "meta:new_city"),
Step("et28", "domain_feed", asset: "book", value: "bad_story_about_king"),
Step("et29", "assert", expect: "interest_has_key", value: "book:bad_story_about_king"),
Step("et30", "domain_feed", asset: "trait", value: "immortal"),
Step("et31", "assert", expect: "interest_has_key", value: "trait:immortal"),
Step("et32", "domain_feed", asset: "boat"),
Step("et33", "assert", expect: "interest_has_key", value: "boat:"),
Step("et34", "domain_feed", asset: "building"),
Step("et35", "assert", expect: "interest_has_key", value: "building:"),
Step("et40", "domain_feed", asset: "spell", value: "summon_lightning"),
Step("et41", "assert", expect: "interest_has_key", value: "spell:summon_lightning"),
Step("et42", "domain_feed", asset: "item", value: "sword"),
Step("et43", "assert", expect: "interest_has_key", value: "item:"),
Step("et44", "domain_feed", asset: "decision", value: "declare_war"),
Step("et45", "assert", expect: "interest_has_key", value: "decision:"),
Step("et46", "domain_feed", asset: "power", value: "lightning"),
Step("et47", "assert", expect: "interest_has_key", value: "power:"),
Step("et48", "domain_feed", asset: "subspecies_trait", value: "fire_blood"),
Step("et49", "assert", expect: "interest_has_key", value: "subspecies_trait:"),
Step("et50", "domain_feed", asset: "gene", value: "warfare_1"),
Step("et51", "assert", expect: "interest_has_key", value: "gene:"),
Step("et90", "assert", expect: "health"),
Step("et91", "assert", expect: "no_bad"),
Step("et99", "snapshot")
};
}
private static List<HarnessCommand> EventCoverage()
{
return new List<HarnessCommand>
{
Step("ec0", "dismiss_windows"),
Step("ec1", "wait_world"),
Step("ec2", "set_setting", expect: "enabled", value: "true"),
Step("ec3", "pick_unit", asset: "auto"),
Step("ec4", "spectator", value: "off"),
Step("ec5", "spectator", value: "on"),
Step("ec6", "focus", asset: "auto"),
// Full authored coverage gates
Step("ec10", "assert", expect: "world_log_audit"),
Step("ec11", "assert", expect: "disaster_war_audit"),
Step("ec12", "assert", expect: "activity_status_audit"),
Step("ec13", "assert", expect: "activity_happiness_audit"),
Step("ec14", "assert", expect: "domain_event_audit"),
// Previously silent-dropped WorldLog disaster id must enter the registry
Step("ec20", "world_log_feed", asset: "disaster_tornado"),
Step("ec21", "assert", expect: "interest_has_key", value: "worldlog:disaster_tornado"),
Step("ec22", "world_log_feed", asset: "disaster_alien_invasion"),
Step("ec23", "assert", expect: "interest_has_key", value: "worldlog:disaster_alien_invasion"),
Step("ec90", "assert", expect: "health"),
Step("ec91", "assert", expect: "no_bad"),
Step("ec99", "snapshot")
};
}
private static List<HarnessCommand> MutationDiscovery()
{
return new List<HarnessCommand>
{
Step("md0", "wait_world"),
Step("md1", "assert", expect: "mutation_discovery"),
Step("md2", "snapshot")
};
}
private static List<HarnessCommand> ActivityRateSample()
{
return new List<HarnessCommand>

View file

@ -85,6 +85,7 @@ public sealed class InterestCandidate
Score = TotalScore,
Position = Position,
FollowUnit = FollowUnit,
RelatedUnit = RelatedUnit,
Label = Label,
CreatedAt = CreatedAt,
AssetId = AssetId,

View file

@ -34,7 +34,8 @@ public static class InterestCompletion
return age < candidate.MaxWatch;
case InterestCompletionKind.FixedDwell:
return age < Mathf.Max(candidate.MinWatch, candidate.MaxWatch * 0.35f);
// Hold the authored MaxWatch window (director floors this to minCameraDwell).
return age < Mathf.Max(candidate.MinWatch, candidate.MaxWatch);
case InterestCompletionKind.CombatActive:
return CombatStillActive(candidate);
@ -64,17 +65,22 @@ public static class InterestCompletion
return false;
}
float now = Time.unscaledTime;
bool hot = false;
try
{
if (unit.has_attack_target)
{
return true;
hot = true;
}
if (unit.hasTask() && unit.ai?.task != null
&& (unit.ai.task.in_combat || unit.ai.task.is_fireman))
else if (unit.hasTask() && unit.ai?.task != null
&& (unit.ai.task.in_combat || unit.ai.task.is_fireman))
{
return true;
hot = true;
}
else if (RelatedStillFightingUs(c, unit))
{
hot = true;
}
}
catch
@ -82,14 +88,50 @@ public static class InterestCompletion
// ignore
}
// Battle asset without a live fighter: keep briefly via position only if battle still hot.
if (c.AssetId == "live_battle")
if (!hot && c.AssetId == "live_battle")
{
InterestEvent battle = WorldActivityScanner.FindHottestBattle();
return battle != null;
hot = battle != null;
}
return false;
if (hot)
{
// Refresh so brief attack_target gaps do not clear reason / open quiet grace.
c.LastSeenAt = now;
return true;
}
// Hysteresis: WorldBox clears attack_target between swings.
const float combatHysteresisSeconds = 8f;
return now - c.LastSeenAt < combatHysteresisSeconds;
}
private static bool RelatedStillFightingUs(InterestCandidate c, Actor unit)
{
Actor foe = c.RelatedUnit;
if (foe == null || !foe.isAlive())
{
return false;
}
try
{
if (!foe.has_attack_target || foe.attack_target == null || !foe.attack_target.isAlive())
{
return false;
}
if (!foe.attack_target.isActor())
{
return false;
}
return foe.attack_target.a == unit;
}
catch
{
return false;
}
}
private static bool ActivityStillActive(InterestCandidate c)

View file

@ -1,3 +1,4 @@
using System;
using System.Collections.Generic;
using NeoModLoader.services;
using UnityEngine;
@ -11,7 +12,7 @@ namespace IdleSpectator;
/// </summary>
public static class InterestDirector
{
public const float MinDwellSeconds = 10f;
public const float MinDwellSeconds = 15f;
public const float CuriosityDwellSeconds = 6f;
public const float CuriosityRotateSeconds = 10f;
public const float SwitchCooldownSeconds = 5f;
@ -20,7 +21,7 @@ public static class InterestDirector
public const float AmbientRotateSeconds = 20f;
public const float ActionPollSeconds = 0.75f;
public const float InputExitGraceSeconds = 2.5f;
public const float QuietGraceSeconds = 1.2f;
public const float QuietGraceSeconds = 6f;
public const float CameraSettleGraceSeconds = 3f;
private static float _minDwell = MinDwellSeconds;
@ -74,6 +75,68 @@ public static class InterestDirector
public static InterestCandidate CurrentCandidate => _current;
/// <summary>
/// True while the current scene is past completion but still held through quiet_grace
/// (camera may remain; orange reason must be empty).
/// </summary>
public static bool InQuietGrace
{
get
{
if (_current == null || CurrentIsActive)
{
return false;
}
return _inactiveSince >= 0f;
}
}
/// <summary>
/// Candidate that may drive the orange dossier reason: owns <paramref name="unit"/>
/// while the director still holds that scene (not quiet_grace).
/// Sticky combat may briefly report !IsActive between swings - keep the reason up.
/// </summary>
public static InterestCandidate TryGetOwnedReasonCandidate(Actor unit)
{
if (unit == null || _current == null || InQuietGrace)
{
return null;
}
long unitId = 0;
try
{
unitId = unit.getID();
}
catch
{
unitId = 0;
}
if (_current.FollowUnit == unit)
{
return _current;
}
if (_current.SubjectId != 0 && unitId != 0 && _current.SubjectId == unitId)
{
return _current;
}
if (_current.RelatedUnit == unit)
{
return _current;
}
if (_current.RelatedId != 0 && unitId != 0 && _current.RelatedId == unitId)
{
return _current;
}
return null;
}
public static string InterruptedKey =>
_interrupted == null ? "" : (_interrupted.Key ?? "");
@ -134,7 +197,7 @@ public static class InterestDirector
return false;
}
InterestRegistry.Upsert(candidate);
EventFeedUtil.RegisterCandidate(candidate);
SwitchTo(candidate, Time.unscaledTime, resumableInterrupt: false);
return true;
}
@ -172,7 +235,8 @@ public static class InterestDirector
}
float now = Time.unscaledTime;
float pastActive = Mathf.Max(_current.MinWatch, _current.MaxWatch * 0.35f) + 0.5f;
// Age past FixedDwell / Manual MaxWatch so IsActive is false.
float pastActive = Mathf.Max(_current.MinWatch, _current.MaxWatch) + 0.5f;
_currentStartedAt = now - pastActive;
_lastSwitchAt = _currentStartedAt;
_inactiveSince = now - Mathf.Max(0f, inactiveSeconds);
@ -307,9 +371,8 @@ public static class InterestDirector
sinceSwitch = 0f;
}
// Queued discovery tips beat ambient fill, but never stall under Action/Story.
if (InterestRegistry.HasPendingScoreAtLeast(InterestScoringConfig.W.fillScoreMax)
&& (_current == null || InterestScoring.IsFillScore(_current.TotalScore)))
// Pending events own the camera - never Character-fill over them.
if (InterestRegistry.HasPendingEvent())
{
return;
}
@ -350,6 +413,29 @@ public static class InterestDirector
return;
}
// Ambient: end promptly when the vignette goes cold - do not hold for minCameraDwell.
if (IsAmbientShot(_current))
{
if (_inactiveSince < 0f)
{
_inactiveSince = now;
}
if (now - _inactiveSince >= _quietGrace)
{
EndCurrent(now, reason: "quiet_grace");
}
return;
}
// Condition ended early: still hold the camera until min dwell, then grace.
if (onCurrent < MinDwellFor(_current))
{
_inactiveSince = -999f;
return;
}
if (_inactiveSince < 0f)
{
_inactiveSince = now;
@ -373,20 +459,17 @@ public static class InterestDirector
return;
}
// Prefer related (grief survivor is already FollowUnit); try related then participants.
// Ownership-preserving handoff only: related survivor. Never invent a stranger.
if (_current.RelatedUnit != null && _current.RelatedUnit.isAlive())
{
_current.FollowUnit = _current.RelatedUnit;
_current.SubjectId = EventFeedUtil.SafeId(_current.RelatedUnit);
CameraDirector.Watch(_current.ToInterestEvent());
return;
}
Actor near = WorldActivityScanner.FindNearestAliveUnit(_current.Position, 18f);
if (near != null)
{
_current.FollowUnit = near;
CameraDirector.Watch(_current.ToInterestEvent());
}
// No owned related: end the scene rather than attach a nearby stranger.
EndCurrent(Time.unscaledTime, reason: "follow_lost");
}
private static void EndCurrent(float now, string reason)
@ -429,6 +512,15 @@ public static class InterestDirector
if (c == null || !CanSwitchTo(c, onCurrent, sinceSwitch))
{
PendingScratch.RemoveAt(i);
continue;
}
// Drop stale / not-worth-watching shots from the pending set.
if (!IsWorthWatchingNow(c, now, selectingDuringGrace: InQuietGrace)
|| IsStaleFreshLifeMoment(c, now))
{
InterestRegistry.Remove(c.Key);
PendingScratch.RemoveAt(i);
}
}
@ -441,6 +533,54 @@ public static class InterestDirector
return InterestVariety.Pick(PendingScratch, _current);
}
private static bool IsStaleFreshLifeMoment(InterestCandidate c, float now)
{
if (c == null || string.IsNullOrEmpty(c.HappinessEffectId))
{
return false;
}
string id = c.HappinessEffectId;
bool hatch = string.Equals(id, "just_got_out_of_egg", StringComparison.OrdinalIgnoreCase)
|| string.Equals(id, "just_hatched", StringComparison.OrdinalIgnoreCase);
bool spawn = string.Equals(id, "just_born", StringComparison.OrdinalIgnoreCase);
if (!hatch && !spawn)
{
return false;
}
// Queued too long - miss the beat.
if (now - c.CreatedAt > 5.5f)
{
return true;
}
Actor unit = c.FollowUnit;
if (unit == null || !unit.isAlive())
{
return true;
}
// Hatch: egg-loss / happiness may land after the unit is already past baby form.
// Only drop on age for spawn (just_born), not hatch.
if (spawn)
{
try
{
if (!unit.isBaby() && unit.getAge() > 1)
{
return true;
}
}
catch
{
// ignore age API mismatches
}
}
return false;
}
/// <summary>
/// True when a drained New species tip would be allowed to take the camera.
/// </summary>
@ -539,39 +679,139 @@ public static class InterestDirector
private static float MinDwellFor(InterestCandidate current)
{
ScoringWeights w = InterestScoringConfig.W;
float floor = w.minCameraDwell > 0f ? w.minCameraDwell : MinDwellSeconds;
if (current == null)
{
return _minDwell;
return _minDwell < MinDwellSeconds * 0.5f ? _minDwell : floor;
}
// Ambient / Character fill: no event floor - real events may take the camera anytime.
if (IsAmbientShot(current))
{
if (_minDwell < MinDwellSeconds * 0.5f)
{
return InterestScoring.IsFillScore(current.TotalScore) ? _curiosityDwell : _minDwell;
}
return w.dwellFill > 0f ? w.dwellFill : 6f;
}
ScoringWeights w = InterestScoringConfig.W;
float dwell = InterestScoring.IsHotScore(current.TotalScore)
? w.dwellHot
: (InterestScoring.IsFillScore(current.TotalScore) ? w.dwellFill : w.dwellNormal);
: w.dwellNormal;
if (_minDwell < MinDwellSeconds * 0.5f)
{
// Harness fast timing: keep compressed dwells.
dwell = InterestScoring.IsFillScore(current.TotalScore) ? _curiosityDwell : _minDwell;
dwell = _minDwell;
return current.MinWatch > 0f ? Mathf.Max(current.MinWatch, dwell) : dwell;
}
if (current.MinWatch > 0f)
// Live EventLed: never peer-rotate / soft-cut before the camera floor.
// Score-margin interrupts bypass this via CanSwitchTo.
float minWatch = current.MinWatch > 0f ? current.MinWatch : 0f;
return Mathf.Max(floor, dwell, minWatch);
}
/// <summary>Character fill / low-score stroll - events should cut in freely.</summary>
private static bool IsAmbientShot(InterestCandidate c)
{
if (c == null)
{
return InterestScoring.IsFillScore(current.TotalScore)
? Mathf.Min(current.MinWatch, dwell)
: Mathf.Max(current.MinWatch, dwell * 0.15f);
return true;
}
return dwell;
if (c.LeadKind == InterestLeadKind.CharacterLed
|| c.Completion == InterestCompletionKind.CharacterVignette)
{
return true;
}
// Sticky event holds are never ambient even if score is temporarily fill-band.
if (c.Completion == InterestCompletionKind.CombatActive
|| c.Completion == InterestCompletionKind.StatusPhase
|| c.Completion == InterestCompletionKind.HappinessGrief
|| c.Completion == InterestCompletionKind.ActivityActive)
{
return false;
}
return InterestScoring.IsFillScore(c.TotalScore);
}
private static float MaxWatchFor(InterestCandidate current)
{
if (current == null)
{
return 45f;
return InterestScoringConfig.W.maxWatchMoment;
}
ScoringWeights w = InterestScoringConfig.W;
float classCap = w.maxWatchMoment;
switch (current.Completion)
{
case InterestCompletionKind.CombatActive:
classCap = w.maxWatchCombat;
break;
case InterestCompletionKind.StatusPhase:
classCap = w.maxWatchStatus;
break;
case InterestCompletionKind.HappinessGrief:
classCap = w.maxWatchGrief;
break;
case InterestCompletionKind.FixedDwell:
case InterestCompletionKind.Manual:
classCap = current.EventStrength >= w.hotScoreMin
? w.maxWatchEpic
: w.maxWatchMoment;
break;
default:
if (current.EventStrength >= w.hotScoreMin)
{
classCap = w.maxWatchEpic;
}
break;
}
float cap = classCap;
if (current.MaxWatch > 0f)
{
// Sticky live holds use the class MaxWatch (e.g. combat 60s) - do not let a
// short candidate MaxWatch max_cap the fight while it is still active.
if (current.Completion == InterestCompletionKind.CombatActive
|| current.Completion == InterestCompletionKind.StatusPhase
|| current.Completion == InterestCompletionKind.HappinessGrief
|| current.Completion == InterestCompletionKind.ActivityActive)
{
cap = Mathf.Max(current.MaxWatch, classCap);
}
else
{
cap = Mathf.Min(current.MaxWatch, classCap);
}
}
// Moment / FixedDwell shots must last at least the camera floor unless interrupted.
if (current.Completion == InterestCompletionKind.FixedDwell
|| current.Completion == InterestCompletionKind.Manual)
{
float floor = w.minCameraDwell > 0f ? w.minCameraDwell : MinDwellSeconds;
cap = Mathf.Max(cap, floor);
}
// Ambient fill: no minCameraDwell floor - short vignettes are fine.
if (IsAmbientShot(current))
{
float fillCap = w.dwellFill > 0f ? w.dwellFill * 2f : 20f;
if (current.MaxWatch > 0f)
{
fillCap = Mathf.Min(fillCap, Mathf.Max(current.MaxWatch, 8f));
}
cap = fillCap;
}
float cap = current.MaxWatch > 0f ? current.MaxWatch : 45f;
// Harness fast timing compresses caps.
if (_minDwell < MinDwellSeconds * 0.5f)
{
@ -581,10 +821,7 @@ public static class InterestDirector
return cap;
}
/// <summary>
/// Protected Action+ scenes: only Epic may preempt after settle grace.
/// Ambient/Curiosity may be replaced by higher urgency after settle.
/// </summary>
/// <summary>Hold while live and under MaxWatch; quiet grace still counts as briefly protected.</summary>
private static bool SessionProtected(float now, float onCurrent)
{
if (_current == null)
@ -600,6 +837,12 @@ public static class InterestDirector
bool active = InterestCompletion.IsActive(_current, _currentStartedAt, now);
if (!active)
{
// Ambient: no minCameraDwell hold after the vignette goes cold.
if (!IsAmbientShot(_current) && onCurrent < MinDwellFor(_current))
{
return true;
}
if (_inactiveSince >= 0f && now - _inactiveSince >= _quietGrace)
{
return false;
@ -621,18 +864,55 @@ public static class InterestDirector
if (_current == null)
{
return true;
return IsWorthWatchingNow(candidate, Time.unscaledTime, selectingDuringGrace: false);
}
float now = Time.unscaledTime;
bool inGrace = InQuietGrace;
if (inGrace)
{
// Quiet grace after a sticky hold: only margin-worthy events may cut in.
// Prevents family-join / love status from stealing a fight between swings.
if (_current != null
&& (_current.Completion == InterestCompletionKind.CombatActive
|| _current.Completion == InterestCompletionKind.StatusPhase
|| _current.Completion == InterestCompletionKind.HappinessGrief))
{
float graceCur = _current.TotalScore;
float graceNext = candidate.TotalScore;
return graceNext >= graceCur + InterestScoringConfig.W.cutInMargin
&& IsWorthWatchingNow(candidate, now, selectingDuringGrace: true);
}
return IsWorthWatchingNow(candidate, now, selectingDuringGrace: true);
}
if (!IsWorthWatchingNow(candidate, now, selectingDuringGrace: false))
{
return false;
}
bool protectedScene = SessionProtected(now, onCurrent);
ScoringWeights w = InterestScoringConfig.W;
float curScore = _current.TotalScore;
float nextScore = candidate.TotalScore;
bool nextFill = InterestScoring.IsFillScore(nextScore);
bool curFill = InterestScoring.IsFillScore(curScore);
bool curAmbient = IsAmbientShot(_current);
bool curFill = curAmbient || InterestScoring.IsFillScore(curScore);
// Fill/curiosity-strength scenes never cut protected non-fill sessions.
// Ambient fill: any real event may take the camera immediately (no min dwell).
if (curAmbient && !nextFill && !IsAmbientShot(candidate))
{
return true;
}
// Instant score-margin cut - no settle grace, no MinDwell block.
if (nextScore >= curScore + w.cutInMargin)
{
return true;
}
// Fill never cuts a protected non-fill session without margin (already failed above).
if (nextFill && !curFill && protectedScene)
{
return false;
@ -644,42 +924,105 @@ public static class InterestDirector
return false;
}
bool typedCutIn = InterestScoring.IsCombatAction(candidate)
&& InterestScoring.IsCharacterVignette(_current)
&& onCurrent >= _settleGrace;
bool combatCutsNonCombat = InterestScoring.IsCombatAction(candidate)
&& !InterestScoring.IsCombatAction(_current)
&& onCurrent >= _settleGrace
&& nextScore >= curScore - w.rotateSlack;
bool scoreCutIn = onCurrent >= _settleGrace
&& nextScore >= curScore + w.cutInMargin;
if (protectedScene && !curFill)
// Protected EventLed hold: only margin cut-in (handled above).
if (protectedScene && !curAmbient)
{
if (typedCutIn || combatCutsNonCombat || scoreCutIn)
{
return true;
}
return false;
}
// Unprotected / fill current: stronger score after settle, or peer rotate after dwell.
if (nextScore > curScore && onCurrent >= _settleGrace)
// Peer rotate / stronger score after MinDwell when unprotected or on fill.
if (nextScore > curScore && onCurrent >= MinDwellFor(_current) && sinceSwitch >= _switchCooldown)
{
return true;
}
if (!protectedScene && onCurrent >= MinDwellFor(_current) && sinceSwitch >= _switchCooldown)
{
return nextScore >= curScore - w.rotateSlack;
}
return onCurrent >= MinDwellFor(_current)
&& sinceSwitch >= _switchCooldown
&& nextScore >= curScore - w.rotateSlack;
}
/// <summary>
/// Still-worth-watching filter: drop stale queued moments; keep live sticky conditions
/// and fresh FixedDwell; during grace, brand-new fires are always eligible.
/// </summary>
private static bool IsWorthWatchingNow(InterestCandidate c, float now, bool selectingDuringGrace)
{
if (c == null || !c.HasValidPosition)
{
return false;
}
if (selectingDuringGrace && c.CreatedAt >= _inactiveSince && _inactiveSince >= 0f)
{
return true;
}
// Purge moments that fired before the current scene started (stale queue).
if (_current != null && c.CreatedAt < _currentStartedAt - 0.05f)
{
if (c.Completion == InterestCompletionKind.FixedDwell
|| c.Completion == InterestCompletionKind.Manual)
{
return false;
}
// Sticky: only if still live.
return InterestCompletion.IsActive(c, c.CreatedAt, now);
}
if (IsStaleMomentCandidate(c, now))
{
return false;
}
switch (c.Completion)
{
case InterestCompletionKind.CombatActive:
case InterestCompletionKind.StatusPhase:
case InterestCompletionKind.HappinessGrief:
case InterestCompletionKind.ActivityActive:
return InterestCompletion.IsActive(c, c.CreatedAt, now)
|| now - c.LastSeenAt < 2f;
case InterestCompletionKind.FixedDwell:
case InterestCompletionKind.Manual:
// Fresh moment: age under ~4s or more than half MaxWatch remaining.
float age = now - c.CreatedAt;
if (age < 4f)
{
return true;
}
float maxW = c.MaxWatch > 0f ? c.MaxWatch : InterestScoringConfig.W.maxWatchMoment;
return age < maxW * 0.5f;
default:
return true;
}
}
private static bool IsStaleMomentCandidate(InterestCandidate c, float now)
{
if (IsStaleFreshLifeMoment(c, now))
{
return true;
}
if (c.ExpiresAt > 0f && now > c.ExpiresAt)
{
return true;
}
// Queued under an active hold: short TTL for FixedDwell moments.
if (_current != null
&& CurrentIsActive
&& (c.Completion == InterestCompletionKind.FixedDwell || c.Completion == InterestCompletionKind.Manual)
&& now - c.CreatedAt > 8f)
{
return true;
}
return false;
}
private static void SwitchTo(InterestCandidate next, float now, bool resumableInterrupt)
{
if (next == null)
@ -688,9 +1031,8 @@ public static class InterestDirector
}
if (_current != null
&& InterestScoring.IsHotScore(next.TotalScore)
&& !InterestScoring.IsHotScore(_current.TotalScore)
&& _current.Resumable)
&& _current.Resumable
&& next.TotalScore >= _current.TotalScore + InterestScoringConfig.W.cutInMargin)
{
_interrupted = _current.CloneShallow();
}
@ -757,6 +1099,8 @@ public static class InterestDirector
candidate.LeadKind = InterestLeadKind.CharacterLed;
candidate.Category = "CharacterVignette";
candidate.Completion = InterestCompletionKind.CharacterVignette;
// Fill is not an event - empty orange reason; task chip shows live job.
candidate.Label = "";
if (AgentHarness.Busy)
{
candidate.ForceActive = false;

View file

@ -11,6 +11,7 @@ public sealed class InterestEvent
public float Score;
public Vector3 Position;
public Actor FollowUnit;
public Actor RelatedUnit;
public string Label;
public float CreatedAt;
public string AssetId;
@ -20,6 +21,8 @@ public sealed class InterestEvent
public bool HasFollowUnit => FollowUnit != null && FollowUnit.isAlive();
public bool HasRelatedUnit => RelatedUnit != null && RelatedUnit.isAlive();
public bool HasValidPosition =>
HasFollowUnit || (Position != Vector3.zero && !float.IsNaN(Position.x));
}

View file

@ -84,6 +84,21 @@ public static class InterestRegistry
{
if (ByKey.TryGetValue(incoming.Key, out InterestCandidate existing) && existing != null)
{
// Ownership merge: same key must not adopt a different subject's FollowUnit/Label.
bool sameSubject = incoming.SubjectId == 0
|| existing.SubjectId == 0
|| incoming.SubjectId == existing.SubjectId;
if (!sameSubject)
{
// Reject subject hijack; keep existing ownership, still refresh TTL/scores lightly.
existing.LastSeenAt = now;
existing.ExpiresAt = Mathf.Max(existing.ExpiresAt, incoming.ExpiresAt);
existing.EventStrength = Mathf.Max(existing.EventStrength, incoming.EventStrength);
existing.TotalScore = Mathf.Max(existing.TotalScore, incoming.TotalScore);
existing.Selected = false;
return existing;
}
existing.LastSeenAt = now;
existing.ExpiresAt = Mathf.Max(existing.ExpiresAt, incoming.ExpiresAt);
existing.EventStrength = Mathf.Max(existing.EventStrength, incoming.EventStrength);
@ -100,6 +115,9 @@ public static class InterestRegistry
if (incoming.FollowUnit != null && incoming.FollowUnit.isAlive())
{
existing.FollowUnit = incoming.FollowUnit;
existing.SubjectId = incoming.SubjectId != 0
? incoming.SubjectId
: EventFeedUtil.SafeId(incoming.FollowUnit);
existing.Position = incoming.FollowUnit.current_position;
}
else if (incoming.Position != Vector3.zero)
@ -306,6 +324,29 @@ public static class InterestRegistry
}
}
/// <summary>True when any unselected EventLed candidate is waiting for the camera.</summary>
public static bool HasPendingEvent()
{
lock (Gate)
{
foreach (KeyValuePair<string, InterestCandidate> kv in ByKey)
{
InterestCandidate c = kv.Value;
if (c == null || c.Selected || !c.HasValidPosition)
{
continue;
}
if (c.LeadKind == InterestLeadKind.EventLed)
{
return true;
}
}
return false;
}
}
private static void TrimLocked(float now)
{
if (ByKey.Count <= MaxCandidates)

View file

@ -39,37 +39,12 @@ public static class InterestScoring
public static float EventStrengthForHappiness(string effectId)
{
string id = effectId ?? "";
switch (id)
{
case "death_child":
return 95f;
case "death_lover":
return 88f;
case "death_best_friend":
return 78f;
case "death_family_member":
return 76f;
case "just_born":
case "just_hatched":
case "had_child":
case "just_had_child":
return 70f;
case "become_king":
case "become_city_leader":
case "become_clan_chief":
return 72f;
case "just_found_house":
case "new_home":
return 55f;
default:
return 40f;
}
return EventCatalog.Happiness.GetOrFallback(effectId).EventStrength;
}
public static float EventStrengthForWorldLog(string assetId)
{
return WorldLogInterestTable.TryGetEventStrength(assetId, out float seed) ? seed : 0f;
return EventCatalog.WorldLog.GetOrFallback(assetId).EventStrength;
}
public static bool IsFillScore(float totalScore)

View file

@ -32,10 +32,19 @@ public class ScoringWeights
public float fillScoreMax = 55f;
public float noticeScoreMin = 70f;
public float hotScoreMin = 110f;
public float dwellFill = 6f;
public float dwellNormal = 10f;
public float dwellHot = 14f;
public float fillRotateSeconds = 10f;
public float dwellFill = 8f;
public float dwellNormal = 15f;
public float dwellHot = 15f;
public float fillRotateSeconds = 8f;
/// <summary>Minimum seconds on an EventLed shot before peer rotate / soft cut (interrupts bypass). Ambient exempt.</summary>
public float minCameraDwell = 15f;
// MaxWatch caps by completion class (director clamps candidate.MaxWatch).
public float maxWatchMoment = 15f;
public float maxWatchStatus = 30f;
public float maxWatchGrief = 45f;
public float maxWatchCombat = 60f;
public float maxWatchEpic = 50f;
public int massFighterThreshold = 4;
public float massBaseBonus = 28f;

View file

@ -1,3 +1,4 @@
using System;
using System.IO;
using HarmonyLib;
using NeoModLoader.api;
@ -56,12 +57,42 @@ public class ModClass : MonoBehaviour, IMod, IConfigurable, ILocalizable
InterestScoringConfig.LoadFromModFolder(ModFolder);
_harmony = new Harmony(pModDecl.UID);
_harmony.PatchAll(typeof(ModClass).Assembly);
ApplyHarmonyPatches(pModDecl.Name);
LogService.LogInfo($"[{pModDecl.Name}]: Harmony patches applied");
LogService.LogInfo($"[{pModDecl.Name}]: Press I for Idle Spectator, L for Lore");
Chronicle.EnsureWindow();
}
private void ApplyHarmonyPatches(string modName)
{
Exception firstFailure = null;
foreach (Type type in typeof(ModClass).Assembly.GetTypes())
{
try
{
new PatchClassProcessor(_harmony, type).Patch();
}
catch (Exception ex)
{
LogService.LogError($"[{modName}]: Harmony failed on {type.FullName}: {ex}");
if (ex.InnerException != null)
{
LogService.LogError($"[{modName}]: Inner: {ex.InnerException}");
}
if (firstFailure == null)
{
firstFailure = ex;
}
}
}
if (firstFailure != null)
{
throw firstFailure;
}
}
private void Update()
{
AgentHarness.Update();

View file

@ -0,0 +1,920 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Reflection;
using System.Text;
using UnityEngine;
namespace IdleSpectator;
public sealed class MutationDiscoveryResult
{
public bool Passed;
public string Detail = "";
public int Managers;
public int ManagerMethods;
public int ActorMethods;
public int BehTypes;
public int Libraries;
public int LibraryAssets;
public int Gaps;
}
/// <summary>
/// Discovers event-relevant mutation paths from live game assemblies and AssetManager.
/// Writes TSVs under .harness/ for the idle-camera coverage plan.
/// </summary>
public static class MutationDiscoveryHarness
{
private static readonly string[] ManagerNameNeedles =
{
"Manager", "Keeper"
};
private static readonly string[] MethodPrefixNeedles =
{
"new", "add", "remove", "set", "become", "start", "finish", "cancel",
"create", "destroy", "die", "kill", "birth", "clear", "leave", "join",
"make", "end", "load", "spawn", "generate", "burn", "write"
};
private static readonly string[] ActorMethodNeedles =
{
"lover", "friend", "family", "child", "parent", "baby", "born", "birth",
"mate", "breed", "social", "clan", "king", "leader", "trait", "status",
"happiness", "plot", "army", "war", "alliance", "culture", "religion",
"language", "book", "item", "boat", "home", "house", "city", "kingdom",
"die", "kill", "task", "job"
};
private static readonly string[] BehNeedles =
{
"Lover", "Friend", "Family", "Social", "Child", "Baby", "Birth", "Plot",
"Clan", "Army", "War", "Book", "Build", "Boat", "Trade", "Era", "Age",
"Trait", "Religion", "Culture", "Language", "Alliance", "King", "Marry"
};
private static readonly string[] LibraryFields =
{
"actor_library", "status", "happiness_library", "world_log_library",
"disasters", "war_types_library", "traits", "plots_library", "plot_category_library",
"era_library", "buildings", "architecture_library", "city_build_orders",
"culture_traits", "religion_traits", "clan_traits", "language_traits",
"subspecies_traits", "kingdoms_traits", "book_types", "items", "items_modifiers",
"spells", "powers", "combat_action_library", "decisions_library",
"loyalty_library", "opinion_library", "knowledge_library", "gene_library",
"phenotype_library", "communication_library", "story_library",
"world_laws_library", "biome_library", "citizen_job_library", "professions",
"personalities", "tasks_actor"
};
private static readonly HashSet<string> AlreadyWired =
new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"Actor.changeHappiness",
"Actor.checkHappinessChangeFromDeathEvent",
"Actor.birthEvent",
"Actor.becomeLoversWith",
"Actor.setBestFriend",
"Actor.newKillAction",
"Actor.die",
"Actor.setTask",
"Actor.setLover",
"Actor.addChild",
"Actor.addChildSimple",
"Actor.leavePlot",
"Actor.setPlot",
"Actor.addTrait",
"Actor.removeTrait",
"Actor.generatePhenotypeAndShade",
"Actor.setDecisionCooldown",
"FamilyManager.newFamily",
"FamilyManager.removeObject",
"ActorManager.createBabyActorFromData",
"PlotManager.newPlot",
"PlotManager.cancelPlot",
"WarManager.newWar",
"WarManager.endWar",
"BookManager.newBook",
"BookManager.generateNewBook",
"BookManager.burnBook",
"WorldAgeManager.startNextAge",
"CultureManager.newCulture",
"ReligionManager.newReligion",
"LanguageManager.newLanguage",
"ClanManager.newClan",
"ArmyManager.newArmy",
"AllianceManager.newAlliance",
"CityManager.newCity",
"ItemManager.generateItem",
"ItemManager.newItem",
"SubspeciesManager.newSpecies",
"SubspeciesManager.addRandomTraitFromBiomeToSubspecies",
"SubspeciesManager.addTraitsFromBiomeToSubspecies",
"Subspecies.mutateFrom",
"BehDealDamageToTargetBuilding.execute",
"BehConsumeTargetBuilding.execute",
"Building.startDestroyBuilding",
"BehBoatTransportUnloadUnits.execute",
"BehBoatMakeTrade.execute",
"BehBoatTransportDoLoading.execute",
"BehFindLover.execute",
"BehFamilyGroupNew.execute",
"BehFamilyGroupJoin.execute",
"BehFamilyGroupLeave.execute",
"BehPollinate.execute",
"BehUnloadResources.execute",
"BehAttackActorHuntingTarget.execute",
"BehCityActorRemoveFire.execute",
"BehTryToSocialize.execute",
"BehPlantCrops.execute",
"BehCityActorFertilizeCrop.execute",
"BehThrowResources.execute",
"BehBuildTarget.execute",
"BehPoopOutside.execute",
"BehPoopInside.execute",
"BehBoatFishing.execute",
"BehBoatCollectFish.execute",
"BehClaimZoneForCityActorBorder.execute",
"CombatActionLibrary.tryToCastSpell",
"PlayerControl.clickedFinal",
"WorldLaws.enable",
"WorldLawAsset.toggle",
"WorldLogMessageExtensions.add",
"BaseSimObject.addNewStatusEffect",
"Status.finish",
"StatusManager.newStatus",
"InterestFeeds.OnWorldLogMessage",
"InterestFeeds.OnStatusChange",
"InterestFeeds.OnActivityNote",
"InterestFeeds.OnChronicleMilestone",
"BattleKeeperManager.addUnitKilled",
"WarInterestFeed.Tick",
"PlotInterestFeed.Tick",
"DeferredInterestFeed",
"WorldActivityScanner.FindHottestBattle",
"SpeciesDiscovery",
// Covered by primary owners (birth / die / trait / city / WorldLog).
"ActorManager.spawnNewUnit",
"ActorManager.spawnNewUnitByPlayer",
"Actor.dieSimpleNone",
"Actor.dieAndDestroy",
"Actor.addChildren",
"Actor.removeTraits",
"Actor.addInjuryTrait",
"Actor.applyForcedKingdomTrait",
"Actor.generateRandomSpawnTraits",
"Actor.traitModifiedEvent",
"KingdomManager.makeNewCivKingdom"
};
public static MutationDiscoveryResult Run(string outputDirectory)
{
Assembly gameAsm = typeof(Actor).Assembly;
var managersTsv = new StringBuilder();
managersTsv.AppendLine("type\tmethod\tarity\tstatic\tkind_guess\talready_wired\tnotes");
var actorTsv = new StringBuilder();
actorTsv.AppendLine("type\tmethod\tarity\tstatic\tneedle\talready_wired\tnotes");
var behTsv = new StringBuilder();
behTsv.AppendLine("type\tmethod\tarity\tneedle\talready_wired\tnotes");
var libTsv = new StringBuilder();
libTsv.AppendLine("field\tcount\tsample_ids\tnotes");
var gapsTsv = new StringBuilder();
gapsTsv.AppendLine(
"kind\ttype\tmethod_or_field\tevent_worthy\tsuggested_creates_interest\talready_wired\treason");
int managerCount = 0;
int managerMethods = 0;
int actorMethods = 0;
int behTypes = 0;
int libraries = 0;
int libraryAssets = 0;
int gaps = 0;
Type[] types;
try
{
types = gameAsm.GetTypes();
}
catch (ReflectionTypeLoadException ex)
{
types = ex.Types ?? Array.Empty<Type>();
}
var managerTypes = new List<Type>();
var behTypesList = new List<Type>();
Type actorType = typeof(Actor);
Type baseSimType = typeof(BaseSimObject);
for (int i = 0; i < types.Length; i++)
{
Type t = types[i];
if (t == null || string.IsNullOrEmpty(t.Name))
{
continue;
}
string name = t.Name;
if (IsManagerish(name))
{
managerTypes.Add(t);
}
if (name.StartsWith("Beh", StringComparison.Ordinal) && MatchesAny(name, BehNeedles))
{
behTypesList.Add(t);
}
}
managerTypes.Sort((a, b) => string.CompareOrdinal(a.FullName, b.FullName));
for (int i = 0; i < managerTypes.Count; i++)
{
Type t = managerTypes[i];
managerCount++;
MethodInfo[] methods = SafeMethods(t);
for (int m = 0; m < methods.Length; m++)
{
MethodInfo method = methods[m];
if (method == null || method.IsSpecialName)
{
continue;
}
if (!MethodLooksMutating(method.Name))
{
continue;
}
managerMethods++;
string key = t.Name + "." + method.Name;
bool wired = AlreadyWired.Contains(key);
string kind = GuessManagerKind(t.Name, method.Name);
managersTsv.Append(t.Name).Append('\t')
.Append(method.Name).Append('\t')
.Append(method.GetParameters().Length).Append('\t')
.Append(method.IsStatic ? "1" : "0").Append('\t')
.Append(kind).Append('\t')
.Append(wired ? "1" : "0").Append('\t')
.Append(Escape(method.ToString())).AppendLine();
if (!wired && IsEventWorthyManager(t.Name, method.Name))
{
gaps++;
gapsTsv.Append("ManagerGenesis").Append('\t')
.Append(t.Name).Append('\t')
.Append(method.Name).Append('\t')
.Append("Yes").Append('\t')
.Append(SuggestCreatesInterest(t.Name, method.Name)).Append('\t')
.Append("0").Append('\t')
.Append("unwired_manager_mutation").AppendLine();
}
}
}
DumpTypeMethods(actorType, ActorMethodNeedles, actorTsv, gapsTsv, ref actorMethods, ref gaps);
DumpTypeMethods(baseSimType, ActorMethodNeedles, actorTsv, gapsTsv, ref actorMethods, ref gaps);
// Also scan Actor for lover/friend clear-style methods even if needle-filtered narrowly.
DumpExtraActorRelationMethods(actorType, actorTsv, gapsTsv, ref actorMethods, ref gaps);
behTypesList.Sort((a, b) => string.CompareOrdinal(a.Name, b.Name));
for (int i = 0; i < behTypesList.Count; i++)
{
Type t = behTypesList[i];
behTypes++;
string needle = FirstMatch(t.Name, BehNeedles);
MethodInfo exec = t.GetMethod("execute", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
string methodName = exec != null ? "execute" : "";
string key = t.Name + (string.IsNullOrEmpty(methodName) ? "" : "." + methodName);
bool wired = AlreadyWired.Contains(key);
int arity = exec != null ? exec.GetParameters().Length : 0;
behTsv.Append(t.Name).Append('\t')
.Append(methodName).Append('\t')
.Append(arity).Append('\t')
.Append(needle).Append('\t')
.Append(wired ? "1" : "0").Append('\t')
.Append("BehPulse").AppendLine();
if (!wired && IsEventWorthyBeh(t.Name))
{
gaps++;
gapsTsv.Append("BehPulse").Append('\t')
.Append(t.Name).Append('\t')
.Append(methodName).Append('\t')
.Append(BehSoft(t.Name) ? "Soft" : "Yes").Append('\t')
.Append(BehSoft(t.Name) ? "false" : "true").Append('\t')
.Append("0").Append('\t')
.Append("unwired_beh").AppendLine();
}
}
for (int i = 0; i < LibraryFields.Length; i++)
{
string field = LibraryFields[i];
List<string> ids = EnumerateLibraryIds(field);
libraries++;
libraryAssets += ids.Count;
string sample = "";
int sampleN = Math.Min(5, ids.Count);
for (int s = 0; s < sampleN; s++)
{
if (s > 0)
{
sample += ",";
}
sample += ids[s];
}
// Inventory is complete when a live catalog, activity chip, or WorldLog owns the domain.
// CreatesInterest dials live in catalogs / dossier - not as open mutation gaps.
libTsv.Append(field).Append('\t')
.Append(ids.Count).Append('\t')
.Append(Escape(sample)).Append('\t')
.Append(LibraryCoverageNote(field)).AppendLine();
// Full id lists for priority event catalogs.
if (!string.IsNullOrEmpty(outputDirectory)
&& (field == "plots_library"
|| field == "plot_category_library"
|| field == "era_library"
|| field == "book_types"
|| field == "traits"
|| field == "buildings"))
{
try
{
var full = new StringBuilder();
full.AppendLine("id");
for (int s = 0; s < ids.Count; s++)
{
full.AppendLine(ids[s]);
}
File.WriteAllText(
Path.Combine(outputDirectory, "library-" + field + ".tsv"),
full.ToString());
}
catch
{
// ignore
}
}
}
// World / MapBox live manager fields
AppendWorldFields(gapsTsv, managersTsv, ref gaps, ref managerMethods);
try
{
if (!string.IsNullOrEmpty(outputDirectory))
{
Directory.CreateDirectory(outputDirectory);
File.WriteAllText(Path.Combine(outputDirectory, "mutation_managers.tsv"), managersTsv.ToString());
File.WriteAllText(Path.Combine(outputDirectory, "mutation_actor.tsv"), actorTsv.ToString());
File.WriteAllText(Path.Combine(outputDirectory, "mutation_beh.tsv"), behTsv.ToString());
File.WriteAllText(Path.Combine(outputDirectory, "mutation_libraries.tsv"), libTsv.ToString());
File.WriteAllText(Path.Combine(outputDirectory, "mutation_gaps.tsv"), gapsTsv.ToString());
}
}
catch (Exception ex)
{
Debug.LogWarning("[IdleSpectator] mutation discovery write failed: " + ex.Message);
}
return new MutationDiscoveryResult
{
Passed = managerCount > 0 && libraries > 0,
Managers = managerCount,
ManagerMethods = managerMethods,
ActorMethods = actorMethods,
BehTypes = behTypes,
Libraries = libraries,
LibraryAssets = libraryAssets,
Gaps = gaps,
Detail =
$"managers={managerCount} managerMethods={managerMethods} actorMethods={actorMethods} "
+ $"beh={behTypes} libraries={libraries} libraryAssets={libraryAssets} gaps={gaps}"
};
}
private static void AppendWorldFields(
StringBuilder gapsTsv,
StringBuilder managersTsv,
ref int gaps,
ref int managerMethods)
{
object world = null;
try
{
world = World.world;
}
catch
{
// ignore
}
if (world == null)
{
return;
}
string[] fields =
{
"plots", "wars", "alliances", "clans", "cultures", "religions", "languages",
"families", "armies", "books", "buildings", "items", "subspecies", "cities",
"kingdoms", "diplomacy", "era_manager", "earthquake_manager", "units"
};
Type worldType = world.GetType();
for (int i = 0; i < fields.Length; i++)
{
string name = fields[i];
object value = null;
try
{
FieldInfo f = worldType.GetField(name);
PropertyInfo p = worldType.GetProperty(name);
value = f != null ? f.GetValue(world) : p?.GetValue(world, null);
}
catch
{
// ignore
}
string typeName = value != null ? value.GetType().Name : "null";
bool wired = name == "wars"
|| name == "units"
|| name == "subspecies"
|| name == "plots"
// Meta genesis Harmony covers new*; ongoing poll is optional fill.
|| name == "alliances"
|| name == "clans"
|| name == "cultures"
|| name == "religions"
|| name == "languages"
|| name == "families"
|| name == "armies"
|| name == "books"
|| name == "cities"
|| name == "kingdoms"
|| name == "items"
|| name == "buildings"
|| name == "diplomacy";
managersTsv.Append("World").Append('\t')
.Append(name).Append('\t')
.Append("0").Append('\t')
.Append("0").Append('\t')
.Append("PollState").Append('\t')
.Append(wired ? "1" : "0").Append('\t')
.Append(typeName).AppendLine();
managerMethods++;
if (!wired && value != null)
{
gaps++;
gapsTsv.Append("PollState").Append('\t')
.Append("World").Append('\t')
.Append(name).Append('\t')
.Append("Yes").Append('\t')
.Append("true").Append('\t')
.Append("0").Append('\t')
.Append("world_field=" + typeName).AppendLine();
}
}
}
private static void DumpTypeMethods(
Type type,
string[] needles,
StringBuilder actorTsv,
StringBuilder gapsTsv,
ref int actorMethods,
ref int gaps)
{
MethodInfo[] methods = SafeMethods(type);
for (int i = 0; i < methods.Length; i++)
{
MethodInfo method = methods[i];
if (method == null || method.IsSpecialName)
{
continue;
}
string needle = FirstMatch(method.Name, needles);
if (string.IsNullOrEmpty(needle) && !MethodLooksMutating(method.Name))
{
continue;
}
if (string.IsNullOrEmpty(needle))
{
needle = "mutate";
}
actorMethods++;
string key = type.Name + "." + method.Name;
bool wired = AlreadyWired.Contains(key);
actorTsv.Append(type.Name).Append('\t')
.Append(method.Name).Append('\t')
.Append(method.GetParameters().Length).Append('\t')
.Append(method.IsStatic ? "1" : "0").Append('\t')
.Append(needle).Append('\t')
.Append(wired ? "1" : "0").Append('\t')
.Append(Escape(method.ToString())).AppendLine();
if (!wired && IsEventWorthyActor(method.Name))
{
gaps++;
gapsTsv.Append("ActorMutation").Append('\t')
.Append(type.Name).Append('\t')
.Append(method.Name).Append('\t')
.Append("Yes").Append('\t')
.Append(SuggestCreatesInterest("Actor", method.Name)).Append('\t')
.Append("0").Append('\t')
.Append("unwired_actor_mutation").AppendLine();
}
}
}
private static void DumpExtraActorRelationMethods(
Type actorType,
StringBuilder actorTsv,
StringBuilder gapsTsv,
ref int actorMethods,
ref int gaps)
{
string[] extra =
{
"removeLover", "clearLover", "setLover", "getLover", "hasLover",
"removeBestFriend", "clearBestFriend", "getBestFriend",
"addChild", "removeChild", "makeBaby", "haveChild",
"addTrait", "removeTrait", "setPlot", "leavePlot", "finishPlot"
};
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
MethodInfo[] found = actorType.GetMethods(
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
for (int m = 0; m < found.Length; m++)
{
MethodInfo method = found[m];
if (method == null || method.IsSpecialName)
{
continue;
}
bool match = false;
for (int i = 0; i < extra.Length; i++)
{
if (method.Name.Equals(extra[i], StringComparison.OrdinalIgnoreCase)
|| method.Name.StartsWith(extra[i], StringComparison.OrdinalIgnoreCase))
{
match = true;
break;
}
}
if (!match || !seen.Add(method.Name))
{
continue;
}
actorMethods++;
string key = actorType.Name + "." + method.Name;
bool wired = AlreadyWired.Contains(key);
actorTsv.Append(actorType.Name).Append('\t')
.Append(method.Name).Append('\t')
.Append(method.GetParameters().Length).Append('\t')
.Append(method.IsStatic ? "1" : "0").Append('\t')
.Append("relation").Append('\t')
.Append(wired ? "1" : "0").Append('\t')
.Append("extra_relation_scan").AppendLine();
if (!wired && !method.Name.StartsWith("get", StringComparison.OrdinalIgnoreCase)
&& !method.Name.StartsWith("has", StringComparison.OrdinalIgnoreCase))
{
gaps++;
gapsTsv.Append("ActorMutation").Append('\t')
.Append(actorType.Name).Append('\t')
.Append(method.Name).Append('\t')
.Append("Yes").Append('\t')
.Append("true").Append('\t')
.Append("0").Append('\t')
.Append("relation_lifecycle").AppendLine();
}
}
}
private static List<string> EnumerateLibraryIds(string fieldName)
{
var ids = new List<string>();
try
{
object lib = typeof(AssetManager).GetField(fieldName)?.GetValue(null);
if (lib == null)
{
return ids;
}
object listObj = lib.GetType().GetField("list")?.GetValue(lib)
?? lib.GetType().GetProperty("list")?.GetValue(lib, null);
System.Collections.IList list = listObj as System.Collections.IList;
if (list == null)
{
return ids;
}
for (int i = 0; i < list.Count; i++)
{
object asset = list[i];
object idObj = asset?.GetType().GetField("id")?.GetValue(asset)
?? asset?.GetType().GetProperty("id")?.GetValue(asset, null);
string id = idObj as string;
if (!string.IsNullOrEmpty(id))
{
ids.Add(id);
}
}
}
catch
{
// Library missing in this build.
}
return ids;
}
private static MethodInfo[] SafeMethods(Type type)
{
try
{
return type.GetMethods(BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | BindingFlags.DeclaredOnly);
}
catch
{
return Array.Empty<MethodInfo>();
}
}
private static bool IsManagerish(string name)
{
for (int i = 0; i < ManagerNameNeedles.Length; i++)
{
if (name.IndexOf(ManagerNameNeedles[i], StringComparison.OrdinalIgnoreCase) >= 0)
{
return true;
}
}
return false;
}
private static bool MethodLooksMutating(string name)
{
string n = name ?? "";
for (int i = 0; i < MethodPrefixNeedles.Length; i++)
{
if (n.StartsWith(MethodPrefixNeedles[i], StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
private static bool MatchesAny(string value, string[] needles)
{
return !string.IsNullOrEmpty(FirstMatch(value, needles));
}
private static string FirstMatch(string value, string[] needles)
{
if (string.IsNullOrEmpty(value) || needles == null)
{
return "";
}
for (int i = 0; i < needles.Length; i++)
{
if (value.IndexOf(needles[i], StringComparison.OrdinalIgnoreCase) >= 0)
{
return needles[i];
}
}
return "";
}
private static string GuessManagerKind(string typeName, string methodName)
{
if (methodName.StartsWith("new", StringComparison.OrdinalIgnoreCase)
|| methodName.StartsWith("create", StringComparison.OrdinalIgnoreCase)
|| methodName.StartsWith("generate", StringComparison.OrdinalIgnoreCase))
{
return "ManagerGenesis";
}
if (methodName.StartsWith("remove", StringComparison.OrdinalIgnoreCase)
|| methodName.StartsWith("destroy", StringComparison.OrdinalIgnoreCase)
|| methodName.StartsWith("finish", StringComparison.OrdinalIgnoreCase)
|| methodName.StartsWith("cancel", StringComparison.OrdinalIgnoreCase)
|| methodName.StartsWith("end", StringComparison.OrdinalIgnoreCase))
{
return "ManagerEnd";
}
return "ManagerMutation";
}
private static string LibraryCoverageNote(string field)
{
switch (field ?? "")
{
case "architecture_library":
case "city_build_orders":
return "covered_by_building_activity";
case "kingdoms_traits":
return "covered_by_trait_meta_feeds";
case "items_modifiers":
return "covered_by_item_feed";
case "combat_action_library":
return "covered_by_spell_combat_feeds";
case "loyalty_library":
case "opinion_library":
case "knowledge_library":
case "communication_library":
case "story_library":
return "covered_by_worldlog_happiness";
case "citizen_job_library":
case "professions":
case "personalities":
return "covered_by_activity_dossier";
default:
return "partially_or_fully_wired";
}
}
private static bool IsEventWorthyManager(string typeName, string methodName)
{
string t = typeName ?? "";
string m = methodName ?? "";
// Persistence / teardown / UI / FX / transport bookkeeping - not spectator moments.
if (m.StartsWith("clear", StringComparison.OrdinalIgnoreCase)
|| m.StartsWith("load", StringComparison.OrdinalIgnoreCase)
|| m.Equals("setup", StringComparison.OrdinalIgnoreCase)
|| m.StartsWith("setDirty", StringComparison.OrdinalIgnoreCase)
|| m.IndexOf("startCollectHistoryData", StringComparison.OrdinalIgnoreCase) >= 0
|| m.IndexOf("setAges", StringComparison.OrdinalIgnoreCase) >= 0
|| m.IndexOf("setAgeTo", StringComparison.OrdinalIgnoreCase) >= 0
|| m.IndexOf("setCurrentSlot", StringComparison.OrdinalIgnoreCase) >= 0
|| m.IndexOf("setDefaultAges", StringComparison.OrdinalIgnoreCase) >= 0
|| m.Equals("setDirty", StringComparison.OrdinalIgnoreCase)
|| m.Equals("removeObject", StringComparison.OrdinalIgnoreCase)
|| m.Equals("createNewUnit", StringComparison.OrdinalIgnoreCase)
|| m.Equals("ClearAllDisposed", StringComparison.OrdinalIgnoreCase)
|| m.Equals("clearLastYearStats", StringComparison.OrdinalIgnoreCase)
|| m.Equals("clearAllCitiesLists", StringComparison.OrdinalIgnoreCase)
|| m.Equals("setAllLinksDirty", StringComparison.OrdinalIgnoreCase)
|| m.Equals("clearAll", StringComparison.OrdinalIgnoreCase)
|| m.Equals("clearAndClose", StringComparison.OrdinalIgnoreCase)
|| m.IndexOf("addRandomTraitFromBiome", StringComparison.OrdinalIgnoreCase) >= 0
|| m.IndexOf("generateModsFor", StringComparison.OrdinalIgnoreCase) >= 0
|| m.IndexOf("newDiplomacyTick", StringComparison.OrdinalIgnoreCase) >= 0
|| m.IndexOf("removeRelationsFor", StringComparison.OrdinalIgnoreCase) >= 0
|| m.IndexOf("removeDeadUnits", StringComparison.OrdinalIgnoreCase) >= 0
|| m.StartsWith("setLanguage", StringComparison.OrdinalIgnoreCase)
|| m.IndexOf("TextField", StringComparison.OrdinalIgnoreCase) >= 0
|| m.IndexOf("LocalizedText", StringComparison.OrdinalIgnoreCase) >= 0
|| m.IndexOf("Worldnet", StringComparison.OrdinalIgnoreCase) >= 0
|| m.IndexOf("createDB", StringComparison.OrdinalIgnoreCase) >= 0
|| m.IndexOf("loadDB", StringComparison.OrdinalIgnoreCase) >= 0
|| m.IndexOf("loadAutoTester", StringComparison.OrdinalIgnoreCase) >= 0
|| m.IndexOf("addAction", StringComparison.OrdinalIgnoreCase) >= 0)
{
return false;
}
if (t.IndexOf("LocalizedText", StringComparison.OrdinalIgnoreCase) >= 0
|| t.IndexOf("UnitText", StringComparison.OrdinalIgnoreCase) >= 0
|| t.IndexOf("MapChunk", StringComparison.OrdinalIgnoreCase) >= 0
|| t.IndexOf("TileManager", StringComparison.OrdinalIgnoreCase) >= 0
|| t.IndexOf("DBManager", StringComparison.OrdinalIgnoreCase) >= 0
|| t.IndexOf("InApp", StringComparison.OrdinalIgnoreCase) >= 0
|| t.IndexOf("DelayedActions", StringComparison.OrdinalIgnoreCase) >= 0
|| t.IndexOf("TaxiManager", StringComparison.OrdinalIgnoreCase) >= 0
|| t.IndexOf("Projectile", StringComparison.OrdinalIgnoreCase) >= 0
|| t.IndexOf("DropManager", StringComparison.OrdinalIgnoreCase) >= 0
|| t.IndexOf("ResourceThrow", StringComparison.OrdinalIgnoreCase) >= 0
|| t.IndexOf("EffectDrag", StringComparison.OrdinalIgnoreCase) >= 0
|| t.IndexOf("SignalManager", StringComparison.OrdinalIgnoreCase) >= 0
|| t.IndexOf("CorruptedTrees", StringComparison.OrdinalIgnoreCase) >= 0
|| t.StartsWith("SystemManager", StringComparison.OrdinalIgnoreCase)
|| t.StartsWith("SimSystemManager", StringComparison.OrdinalIgnoreCase)
|| t.StartsWith("CoreSystemManager", StringComparison.OrdinalIgnoreCase)
|| t.StartsWith("MetaSystemManager", StringComparison.OrdinalIgnoreCase)
|| t.StartsWith("BaseSystemManager", StringComparison.OrdinalIgnoreCase)
|| t.IndexOf("Debug", StringComparison.OrdinalIgnoreCase) >= 0
|| t.IndexOf("UI", StringComparison.OrdinalIgnoreCase) >= 0
|| t.IndexOf("Save", StringComparison.OrdinalIgnoreCase) >= 0
|| t.IndexOf("Upload", StringComparison.OrdinalIgnoreCase) >= 0
|| t.IndexOf("Tooltip", StringComparison.OrdinalIgnoreCase) >= 0
|| t.IndexOf("Nameplate", StringComparison.OrdinalIgnoreCase) >= 0
|| t.IndexOf("Locale", StringComparison.OrdinalIgnoreCase) >= 0
|| (t.IndexOf("Asset", StringComparison.OrdinalIgnoreCase) >= 0
&& t.IndexOf("Manager", StringComparison.OrdinalIgnoreCase) >= 0
&& t != "AssetManager"))
{
return false;
}
return true;
}
private static bool IsEventWorthyActor(string methodName)
{
string m = methodName ?? "";
// Pure queries / counters / trait cache churn - not camera events.
if (m.StartsWith("has", StringComparison.OrdinalIgnoreCase)
|| m.StartsWith("is", StringComparison.OrdinalIgnoreCase)
|| m.StartsWith("get", StringComparison.OrdinalIgnoreCase)
|| m.StartsWith("calc", StringComparison.OrdinalIgnoreCase)
|| m.StartsWith("check", StringComparison.OrdinalIgnoreCase)
|| m.StartsWith("count", StringComparison.OrdinalIgnoreCase)
|| m.StartsWith("sort", StringComparison.OrdinalIgnoreCase)
|| m.StartsWith("increase", StringComparison.OrdinalIgnoreCase)
|| m.StartsWith("decrease", StringComparison.OrdinalIgnoreCase)
|| m.StartsWith("u2_", StringComparison.OrdinalIgnoreCase)
|| m.StartsWith("inOwn", StringComparison.OrdinalIgnoreCase)
|| m.IndexOf("Nutrition", StringComparison.OrdinalIgnoreCase) >= 0
|| m.Equals("clearTraits", StringComparison.OrdinalIgnoreCase)
|| m.Equals("clearTraitCache", StringComparison.OrdinalIgnoreCase)
|| m.Equals("clearHomeBuilding", StringComparison.OrdinalIgnoreCase)
|| m.Equals("setHomeBuilding", StringComparison.OrdinalIgnoreCase)
|| m.Equals("setClan", StringComparison.OrdinalIgnoreCase)
|| m.Equals("setArmy", StringComparison.OrdinalIgnoreCase))
{
return false;
}
return m.IndexOf("lover", StringComparison.OrdinalIgnoreCase) >= 0
|| m.IndexOf("friend", StringComparison.OrdinalIgnoreCase) >= 0
|| m.IndexOf("child", StringComparison.OrdinalIgnoreCase) >= 0
|| m.IndexOf("birth", StringComparison.OrdinalIgnoreCase) >= 0
|| m.IndexOf("baby", StringComparison.OrdinalIgnoreCase) >= 0
|| m.IndexOf("trait", StringComparison.OrdinalIgnoreCase) >= 0
|| m.IndexOf("plot", StringComparison.OrdinalIgnoreCase) >= 0
|| m.IndexOf("die", StringComparison.OrdinalIgnoreCase) >= 0
|| m.IndexOf("kill", StringComparison.OrdinalIgnoreCase) >= 0;
}
private static bool IsEventWorthyBeh(string typeName)
{
// Beh pulses are covered by Actor.setTask → OnActivityNote / warm activity scanner.
// Only leave a gap when a Beh is a distinct narrative feed not on the activity path.
_ = typeName;
return false;
}
private static bool BehSoft(string typeName)
{
string t = typeName ?? "";
return t.IndexOf("Social", StringComparison.OrdinalIgnoreCase) >= 0
|| t.IndexOf("Talk", StringComparison.OrdinalIgnoreCase) >= 0
|| t.IndexOf("Check", StringComparison.OrdinalIgnoreCase) >= 0
|| t.IndexOf("Follow", StringComparison.OrdinalIgnoreCase) >= 0;
}
private static string SuggestCreatesInterest(string typeName, string methodName)
{
string blob = (typeName ?? "") + "." + (methodName ?? "");
if (blob.IndexOf("War", StringComparison.OrdinalIgnoreCase) >= 0
|| blob.IndexOf("Plot", StringComparison.OrdinalIgnoreCase) >= 0
|| blob.IndexOf("Age", StringComparison.OrdinalIgnoreCase) >= 0
|| blob.IndexOf("die", StringComparison.OrdinalIgnoreCase) >= 0
|| blob.IndexOf("kill", StringComparison.OrdinalIgnoreCase) >= 0
|| blob.IndexOf("lover", StringComparison.OrdinalIgnoreCase) >= 0
|| blob.IndexOf("destroy", StringComparison.OrdinalIgnoreCase) >= 0
|| blob.IndexOf("new", StringComparison.OrdinalIgnoreCase) >= 0)
{
return "true";
}
return "false";
}
private static string Escape(string value)
{
if (string.IsNullOrEmpty(value))
{
return "";
}
return value.Replace('\t', ' ').Replace('\n', ' ').Replace('\r', ' ');
}
}

View file

@ -212,11 +212,8 @@ public sealed class UnitDossier
}
/// <summary>
/// Story beat for the dossier reason row: what is happening in this shot.
/// No tier badges, no job append, no nametag repeats.
/// Fill / ambient scenes with no sharper beat leave the reason empty (row hidden).
/// Follow-up (not blocking): expand event/character tags and optional beatPhrases
/// in scoring-model.json once playtests show thin beats.
/// Orange dossier reason: tracked event that earned this shot.
/// Empty when no owned active candidate (never invent from live fighting/job).
/// </summary>
private static string BuildStoryBeat(UnitDossier d, string watchLabel, Actor actor)
{
@ -225,169 +222,50 @@ public sealed class UnitDossier
return HarnessReasonOverride.Trim();
}
InterestCandidate scene = MatchingScene(actor, d);
return OwnedEventReason(actor, d);
}
string combat = CombatBeat(d, scene);
if (!string.IsNullOrEmpty(combat))
/// <summary>
/// Public so caption can refresh reason when the director leaves active dwell
/// (quiet_grace) without rebuilding the whole dossier.
/// </summary>
public static string OwnedEventReason(Actor actor, UnitDossier dossier = null)
{
if (actor == null || !actor.isAlive())
{
return combat;
return "";
}
string griefOrLife = HappinessBeat(scene, d);
if (!string.IsNullOrEmpty(griefOrLife))
InterestCandidate scene = InterestDirector.TryGetOwnedReasonCandidate(actor);
if (scene == null)
{
return griefOrLife;
return "";
}
// Ambient filler: no story beat - hide the reason row.
if (scene != null && InterestScoring.IsFillScore(scene.TotalScore)
// Character-fill vignettes: hide reason (task chip shows live activity).
if (scene.LeadKind == InterestLeadKind.CharacterLed
&& !InterestScoring.IsCombatAction(scene))
{
return "";
}
string fromScene = SceneLabelBeat(scene, d);
if (!string.IsNullOrEmpty(fromScene))
UnitDossier d = dossier;
if (d == null)
{
return fromScene;
}
string fromWatch = EventLabelBeat(watchLabel, d);
if (!string.IsNullOrEmpty(fromWatch))
{
return fromWatch;
}
if (d.IsSpectacle)
{
return "Spectacle";
}
string activity = ActivityBeat(actor);
if (!string.IsNullOrEmpty(activity))
{
return activity;
}
if (d.IsLoneSpecies)
{
return "Last of its kind";
}
// Discovery / new species from watch label leftovers.
string discovery = CleanBeat(ShortenWatchLabel(watchLabel, d));
if (!string.IsNullOrEmpty(discovery)
&& discovery.IndexOf("New species", System.StringComparison.OrdinalIgnoreCase) >= 0)
{
return "New species";
}
return "";
}
private static InterestCandidate MatchingScene(Actor actor, UnitDossier d)
{
InterestCandidate scene = InterestDirector.CurrentCandidate;
if (scene == null)
{
return null;
}
if (scene.FollowUnit == actor)
{
return scene;
}
if (actor != null && scene.SubjectId == d.UnitId)
{
return scene;
}
if (scene.RelatedUnit == actor)
{
return scene;
}
return null;
}
private static string CombatBeat(UnitDossier d, InterestCandidate scene)
{
bool fighting = d.IsFighting
|| (scene != null && InterestScoring.IsCombatAction(scene));
if (!fighting)
{
return "";
}
int fighters = scene != null ? scene.ParticipantCount : 0;
int notables = scene != null ? scene.NotableParticipantCount : 0;
if (fighters <= 0 && d.IsFighting)
{
fighters = 1;
notables = (d.IsKing || d.IsFavorite || d.IsCityLeader) ? 1 : 0;
}
if (fighters <= 2 && notables >= 2)
{
return "King duel";
}
if (fighters >= 4)
{
return "Clash · " + fighters + " fighting";
}
if (fighters >= 2)
{
return "Fighting · " + fighters;
}
return "Fighting";
}
private static string HappinessBeat(InterestCandidate scene, UnitDossier d)
{
if (scene == null)
{
return "";
}
string label = scene.Label ?? "";
if (label.StartsWith("Slain here", System.StringComparison.OrdinalIgnoreCase))
{
return CleanBeat(label);
}
if (string.IsNullOrEmpty(scene.HappinessEffectId))
{
return "";
}
string id = scene.HappinessEffectId;
if (id.StartsWith("death_", System.StringComparison.OrdinalIgnoreCase))
{
string grief = EventLabelBeat(label, d);
if (!string.IsNullOrEmpty(grief))
d = new UnitDossier();
try
{
return grief;
d.UnitId = actor.getID();
d.Name = SafeName(actor);
d.SpeciesId = actor.asset != null ? actor.asset.id : "";
}
if (id.IndexOf("child", System.StringComparison.OrdinalIgnoreCase) >= 0)
catch
{
return "Mourning a child";
// ignore
}
if (id.IndexOf("lover", System.StringComparison.OrdinalIgnoreCase) >= 0)
{
return "Mourning a lover";
}
return "Grief";
}
string happy = EventLabelBeat(label, d);
return happy ?? "";
return SceneLabelBeat(scene, d) ?? "";
}
private static string SceneLabelBeat(InterestCandidate scene, UnitDossier d)
@ -397,7 +275,206 @@ public sealed class UnitDossier
return "";
}
return EventLabelBeat(scene.Label, d);
string beat = EventLabelBeat(scene.Label, d);
if (string.IsNullOrEmpty(beat))
{
return "";
}
// Ownership: never let the reason name a living stranger (not subject/related).
if (ReasonNamesStranger(beat, scene))
{
return "";
}
// Ownership: reject beats that lead with someone else's proper name.
if (LeadsWithForeignName(beat, scene))
{
return "";
}
return beat;
}
private static bool LeadsWithForeignName(string reason, InterestCandidate scene)
{
if (string.IsNullOrEmpty(reason) || scene == null)
{
return false;
}
string first = FirstToken(reason);
if (string.IsNullOrEmpty(first) || IsOwnedBeatKeyword(first))
{
return false;
}
string subjectName = EventFeedUtil.SafeName(scene.FollowUnit);
string relatedName = EventFeedUtil.SafeName(scene.RelatedUnit);
if (!string.IsNullOrEmpty(subjectName)
&& first.Equals(FirstToken(subjectName), System.StringComparison.OrdinalIgnoreCase))
{
return false;
}
if (!string.IsNullOrEmpty(relatedName)
&& first.Equals(FirstToken(relatedName), System.StringComparison.OrdinalIgnoreCase))
{
return false;
}
// Leading token looks like a proper name that is not the owned subject/related.
return first.Length >= 3 && char.IsLetter(first[0]);
}
private static string FirstToken(string text)
{
if (string.IsNullOrEmpty(text))
{
return "";
}
int i = 0;
while (i < text.Length && (char.IsWhiteSpace(text[i]) || text[i] == ':' || text[i] == '·'))
{
i++;
}
int start = i;
while (i < text.Length && (char.IsLetterOrDigit(text[i]) || text[i] == '_' || text[i] == '-'))
{
i++;
}
return start < i ? text.Substring(start, i - start) : "";
}
private static bool IsOwnedBeatKeyword(string token)
{
if (string.IsNullOrEmpty(token))
{
return true;
}
switch (token.ToLowerInvariant())
{
case "fighting":
case "war":
case "battle":
case "clash":
case "grief":
case "mourning":
case "mourn":
case "slain":
case "lovers":
case "lover":
case "new":
case "baby":
case "book":
case "building":
case "boat":
case "meta":
case "seeking":
case "joins":
case "leaves":
case "family":
case "starts":
case "gathers":
case "damages":
case "consumes":
case "spectacle":
case "in":
return true;
default:
return false;
}
}
private static bool ReasonNamesStranger(string reason, InterestCandidate scene)
{
if (string.IsNullOrEmpty(reason) || scene == null || World.world?.units == null)
{
return false;
}
string subjectName = EventFeedUtil.SafeName(scene.FollowUnit);
string relatedName = EventFeedUtil.SafeName(scene.RelatedUnit);
try
{
foreach (Actor unit in World.world.units)
{
if (unit == null || !unit.isAlive())
{
continue;
}
if (unit == scene.FollowUnit || unit == scene.RelatedUnit)
{
continue;
}
string name = EventFeedUtil.SafeName(unit);
// Length 3 catches English crumbs in reasons ("egg", "war", "duel").
if (string.IsNullOrEmpty(name) || name.Length < 4)
{
continue;
}
if (!string.IsNullOrEmpty(subjectName)
&& name.Equals(subjectName, System.StringComparison.OrdinalIgnoreCase))
{
continue;
}
if (!string.IsNullOrEmpty(relatedName)
&& name.Equals(relatedName, System.StringComparison.OrdinalIgnoreCase))
{
continue;
}
if (ContainsWholeName(reason, name))
{
return true;
}
}
}
catch
{
// ignore
}
return false;
}
/// <summary>True when <paramref name="name"/> appears as a whole token in <paramref name="reason"/>.</summary>
private static bool ContainsWholeName(string reason, string name)
{
if (string.IsNullOrEmpty(reason) || string.IsNullOrEmpty(name))
{
return false;
}
int start = 0;
while (start <= reason.Length - name.Length)
{
int idx = reason.IndexOf(name, start, System.StringComparison.OrdinalIgnoreCase);
if (idx < 0)
{
return false;
}
bool leftOk = idx == 0 || !char.IsLetterOrDigit(reason[idx - 1]);
int end = idx + name.Length;
bool rightOk = end >= reason.Length || !char.IsLetterOrDigit(reason[end]);
if (leftOk && rightOk)
{
return true;
}
start = idx + 1;
}
return false;
}
private static string EventLabelBeat(string label, UnitDossier d)
@ -417,61 +494,13 @@ public sealed class UnitDossier
}
}
// Keep war / fight story text with context (do not strip after first colon).
if (s.StartsWith("War", System.StringComparison.OrdinalIgnoreCase)
|| s.StartsWith("Fighting", System.StringComparison.OrdinalIgnoreCase)
|| s.StartsWith("Battle", System.StringComparison.OrdinalIgnoreCase)
|| s.StartsWith("Clash", System.StringComparison.OrdinalIgnoreCase)
|| s.StartsWith("Grief", System.StringComparison.OrdinalIgnoreCase)
|| s.StartsWith("Mourn", System.StringComparison.OrdinalIgnoreCase)
|| s.StartsWith("Slain", System.StringComparison.OrdinalIgnoreCase))
{
if (IsIdentityWhy(s, d))
{
// "Fighting: Name" → Fighting
int colon = s.IndexOf(':');
if (colon > 0)
{
string head = s.Substring(0, colon).Trim();
return string.IsNullOrEmpty(head) ? "Fighting" : head;
}
return "";
}
return CleanBeat(s);
}
string shortened = ShortenWatchLabel(s, d);
if (string.IsNullOrEmpty(shortened) || IsIdentityWhy(shortened, d))
// EventReason sentences are dossier-ready - keep names; only drop identity crumbs.
if (IsIdentityWhy(s, d) || IsWeakFlavorBeat(s, d))
{
return "";
}
// Reject weak singleton/species crumbs ("Lone beetle").
if (IsWeakFlavorBeat(shortened, d))
{
return "";
}
return shortened;
}
private static string ActivityBeat(Actor actor)
{
if (actor == null)
{
return "";
}
// Task/job already live on the nametag; only surface hot action here.
ActivityBand band = ActivityInterestTable.Classify(actor);
if (band == ActivityBand.Hot)
{
return "In action";
}
return "";
return CleanBeat(s);
}
private static bool IsWeakFlavorBeat(string text, UnitDossier d)
@ -539,6 +568,12 @@ public sealed class UnitDossier
&& t.IndexOf("mourn", System.StringComparison.OrdinalIgnoreCase) < 0
&& t.IndexOf("New species", System.StringComparison.OrdinalIgnoreCase) < 0)
{
// EventReason sentences lead with the subject name - keep them.
if (LooksLikeEventSentence(t) || StartsWithSubjectVerbPhrase(t, d.Name))
{
return false;
}
// "Isemward (human, 12 kills)" style still useful via kills - keep if kills mentioned.
if (t.IndexOf("kill", System.StringComparison.OrdinalIgnoreCase) >= 0)
{
@ -551,6 +586,76 @@ public sealed class UnitDossier
return IsRedundantWithHeadline(t, d);
}
private static bool LooksLikeEventSentence(string text)
{
if (string.IsNullOrEmpty(text))
{
return false;
}
string t = text;
return t.IndexOf(" is ", System.StringComparison.OrdinalIgnoreCase) >= 0
|| t.IndexOf(" became ", System.StringComparison.OrdinalIgnoreCase) >= 0
|| t.IndexOf(" joins ", System.StringComparison.OrdinalIgnoreCase) >= 0
|| t.IndexOf(" leaves ", System.StringComparison.OrdinalIgnoreCase) >= 0
|| t.IndexOf(" forms ", System.StringComparison.OrdinalIgnoreCase) >= 0
|| t.IndexOf(" gains ", System.StringComparison.OrdinalIgnoreCase) >= 0
|| t.IndexOf(" loses ", System.StringComparison.OrdinalIgnoreCase) >= 0
|| t.IndexOf(" writes ", System.StringComparison.OrdinalIgnoreCase) >= 0
|| t.IndexOf(" founds ", System.StringComparison.OrdinalIgnoreCase) >= 0
|| t.IndexOf(" summons ", System.StringComparison.OrdinalIgnoreCase) >= 0
|| t.IndexOf(" begins ", System.StringComparison.OrdinalIgnoreCase) >= 0
|| t.IndexOf(" ends ", System.StringComparison.OrdinalIgnoreCase) >= 0
|| t.IndexOf(" burns ", System.StringComparison.OrdinalIgnoreCase) >= 0
|| t.IndexOf(" welcomes ", System.StringComparison.OrdinalIgnoreCase) >= 0
|| t.IndexOf(" parted ", System.StringComparison.OrdinalIgnoreCase) >= 0
|| t.IndexOf(" decides ", System.StringComparison.OrdinalIgnoreCase) >= 0
|| t.IndexOf(" casts ", System.StringComparison.OrdinalIgnoreCase) >= 0
|| t.IndexOf(" forges ", System.StringComparison.OrdinalIgnoreCase) >= 0
|| t.IndexOf(" eating ", System.StringComparison.OrdinalIgnoreCase) >= 0
|| t.IndexOf(" foraging ", System.StringComparison.OrdinalIgnoreCase) >= 0
|| t.IndexOf(" damaging ", System.StringComparison.OrdinalIgnoreCase) >= 0
|| t.IndexOf(" plotting ", System.StringComparison.OrdinalIgnoreCase) >= 0
|| t.IndexOf(" lovers ", System.StringComparison.OrdinalIgnoreCase) >= 0
|| t.IndexOf(" hatches ", System.StringComparison.OrdinalIgnoreCase) >= 0
|| t.IndexOf(" mates ", System.StringComparison.OrdinalIgnoreCase) >= 0
|| t.IndexOf(" appears ", System.StringComparison.OrdinalIgnoreCase) >= 0
|| t.IndexOf(" finishes ", System.StringComparison.OrdinalIgnoreCase) >= 0
|| t.IndexOf(" bloodlust ", System.StringComparison.OrdinalIgnoreCase) >= 0;
}
/// <summary>
/// "Name verb …" EventReason form (covers hatches/mates/appears without an "is" helper).
/// Rejects identity crumbs like "Name (species, …)".
/// </summary>
private static bool StartsWithSubjectVerbPhrase(string text, string name)
{
if (string.IsNullOrEmpty(text) || string.IsNullOrEmpty(name))
{
return false;
}
if (!text.StartsWith(name, System.StringComparison.OrdinalIgnoreCase))
{
return false;
}
int i = name.Length;
if (i >= text.Length || text[i] != ' ')
{
return false;
}
int verbStart = i + 1;
if (verbStart >= text.Length)
{
return false;
}
char c = text[verbStart];
return char.IsLetter(c) && c != '(';
}
private static bool IsShownTraitName(UnitDossier d, string text)
{
if (d == null || string.IsNullOrEmpty(text))

View file

@ -413,6 +413,7 @@ public static class WatchCaption
{
if (interest != null)
{
// Location-only / no unit: tip text for logs; dossier has no owned subject reason.
LastHeadline = CameraDirector.FormatWatchTip(interest);
LastDetail = "";
LastCaptionText = LastHeadline;
@ -434,7 +435,9 @@ public static class WatchCaption
return;
}
SetFromActor(unit, interest?.Label);
// Reason ownership comes from InterestDirector.TryGetOwnedReasonCandidate
// (active dwell + owned unit), not from a sticky InterestEvent.Label.
SetFromActor(unit, label: null);
}
public static void SetFromActor(Actor actor, string label = null)
@ -508,6 +511,7 @@ public static class WatchCaption
RefreshLivePortrait();
RefreshLiveTask();
RefreshOwnedReason();
RefreshHistoryIfChanged();
return;
}
@ -558,6 +562,7 @@ public static class WatchCaption
RefreshLivePortrait();
RefreshLiveTask();
RefreshOwnedReason();
RefreshHistoryIfChanged();
}
@ -626,6 +631,77 @@ public static class WatchCaption
BringHeaderFront();
}
/// <summary>
/// Keep orange reason in sync with director active dwell (clears in quiet_grace).
/// </summary>
private static void RefreshOwnedReason()
{
if (!_visible || _current == null || HasStatusBanner())
{
return;
}
Actor actor = ResolveBoundLiveActor();
if (actor == null)
{
return;
}
string next = UnitDossier.OwnedEventReason(actor, _current);
string prev = _current.ReasonLine ?? "";
if (next == prev)
{
return;
}
_current.ReasonLine = next;
LastDetail = _current.DetailLine ?? "";
LastCaptionText = JoinCaptionLines(_current.Headline, next, _current.DetailLine);
bool hasReason = !string.IsNullOrEmpty(next);
if (_reasonText != null)
{
_reasonText.text = hasReason ? next : "";
_reasonText.gameObject.SetActive(hasReason);
}
bool hasTask = !string.IsNullOrEmpty(_current.TaskText);
int traits = _current.TopTraits != null ? _current.TopTraits.Count : 0;
int hist = CountActiveHistorySlots();
Relayout(_current.UnitId != 0, traits, hasTask, hasReason, hist);
}
private static string JoinCaptionLines(string headline, string reason, string detail)
{
var sb = new System.Text.StringBuilder();
if (!string.IsNullOrEmpty(headline))
{
sb.Append(headline);
}
if (!string.IsNullOrEmpty(reason))
{
if (sb.Length > 0)
{
sb.Append('\n');
}
sb.Append(reason);
}
if (!string.IsNullOrEmpty(detail))
{
if (sb.Length > 0)
{
sb.Append('\n');
}
sb.Append(detail);
}
return sb.ToString();
}
/// <summary>
/// Keep the nametag task chip in sync with live AI (focus snapshot can miss brief no-task gaps).
/// Never clears the chip on empty AI gaps - only replaces text when a new task is present.

View file

@ -87,23 +87,11 @@ public static class WorldActivityScanner
for (int i = 0; i < n; i++)
{
Actor actor = alive[i];
ActivityBand band = ActivityInterestTable.Classify(actor);
InterestLeadKind lead = InterestLeadKind.CharacterLed;
// Actions outrank characters: combat/hot work are events; kings walking stay vignettes.
if (band == ActivityBand.Hot || actor.has_attack_target)
if (actor == null || !actor.isAlive() || !actor.has_attack_target)
{
lead = InterestLeadKind.EventLed;
}
else if (band >= ActivityBand.Warm)
{
lead = InterestLeadKind.EventLed;
}
else if (IsSpectacle(actor) || IsSingletonSpecies(actor, speciesCounts))
{
lead = InterestLeadKind.CharacterLed;
continue;
}
SplitActorScore(actor, speciesCounts, out float actionScore, out float charScore);
long id = 0;
try
{
@ -114,66 +102,59 @@ public static class WorldActivityScanner
continue;
}
string key = lead == InterestLeadKind.CharacterLed
? "vignette:" + id + ":" + (actor.asset != null ? actor.asset.id : "unit")
: "scan:evt:" + id;
string label = FormatUnitLabel(actor, speciesCounts);
if (actor.has_attack_target)
Actor foe = null;
try
{
label = "Fighting: " + SafeName(actor);
if (actor.attack_target != null && actor.attack_target.isAlive() && actor.attack_target.isActor())
{
foe = actor.attack_target.a;
if (foe != null && !foe.isAlive())
{
foe = null;
}
}
}
else if (band == ActivityBand.Hot)
catch
{
label = "In action";
foe = null;
}
bool combat = actor.has_attack_target || band == ActivityBand.Hot;
int fighters = 1;
int notables = InterestScoring.IsNotable(actor) ? 1 : 0;
if (combat)
{
CountFightCluster(actor.current_position, 10f, out fighters, out notables, out _);
fighters = Mathf.Max(1, fighters);
}
SplitActorScore(actor, speciesCounts, out float actionScore, out float charScore);
CountFightCluster(actor.current_position, 10f, out int fighters, out int notables, out _);
fighters = Mathf.Max(1, fighters);
string label = EventReason.Fight(actor, foe);
var candidate = new InterestCandidate
{
Key = key,
LeadKind = lead,
Category = combat
? "Combat"
: (lead == InterestLeadKind.CharacterLed ? "CharacterVignette" : "Work"),
Key = EventCatalog.Combat.Key(id),
LeadKind = InterestLeadKind.EventLed,
Category = "Combat",
Source = "scanner",
EventStrength = lead == InterestLeadKind.EventLed
? actionScore
: actionScore * InterestScoringConfig.W.characterLedEventMul,
EventStrength = Mathf.Max(actionScore, EventCatalog.Combat.ScannerStrengthFloor),
CharacterSignificance = charScore,
VisualConfidence = band >= ActivityBand.Warm ? 0.7f : 0.4f,
VisualConfidence = 0.7f,
Position = actor.current_position,
FollowUnit = actor,
RelatedUnit = foe,
SubjectId = id,
RelatedId = foe != null ? EventFeedUtil.SafeId(foe) : 0,
Label = label,
Verb = actor.has_attack_target ? "fighting" : (band == ActivityBand.Hot ? "busy" : ""),
Verb = "fighting",
AssetId = actor.asset != null ? actor.asset.id : "scored_unit",
SpeciesId = actor.asset != null ? actor.asset.id : "",
CityKey = actor.city != null ? (actor.city.name ?? "") : "",
KingdomKey = actor.kingdom != null ? (actor.kingdom.name ?? "") : "",
ParticipantCount = combat ? fighters : 0,
NotableParticipantCount = combat ? notables : 0,
ParticipantCount = fighters,
NotableParticipantCount = notables,
CreatedAt = Time.unscaledTime,
LastSeenAt = Time.unscaledTime,
ExpiresAt = Time.unscaledTime + 18f,
MinWatch = 2f,
MaxWatch = 22f,
Completion = combat
? InterestCompletionKind.CombatActive
: (lead == InterestLeadKind.EventLed
? InterestCompletionKind.ActivityActive
: InterestCompletionKind.CharacterVignette)
ExpiresAt = Time.unscaledTime + EventCatalog.Combat.ScannerTtl,
MinWatch = EventCatalog.Combat.MinWatch,
MaxWatch = EventCatalog.Combat.MaxWatch,
Completion = InterestCompletionKind.CombatActive
};
InterestScoring.ScoreCheap(candidate);
InterestRegistry.Upsert(candidate);
EventFeedUtil.RegisterCandidate(candidate);
}
}
@ -258,11 +239,7 @@ public static class WorldActivityScanner
if (score > bestScore)
{
bestScore = score;
string label = fighters >= 5 || deaths >= 6
? $"Battle ({fighters} fighting, {deaths} fallen)"
: fighters <= 2
? $"Duel ({deaths} fallen)"
: $"Skirmish ({fighters} fighting)";
string label = EventReason.Battle(follow ?? FindNearestAliveUnit(pos, 14f), fighters);
best = new InterestEvent
{
Score = score,
@ -322,12 +299,13 @@ public static class WorldActivityScanner
if (score > bestScore)
{
bestScore = score;
Actor focus = follow ?? actor;
best = new InterestEvent
{
Score = score,
Position = actor.current_position,
FollowUnit = follow ?? actor,
Label = fighters <= 2 ? "Duel" : $"Fight ({fighters})",
FollowUnit = focus,
Label = EventReason.Battle(focus, fighters),
CreatedAt = Time.unscaledTime,
AssetId = "live_battle",
ParticipantCount = fighters,
@ -531,12 +509,31 @@ public static class WorldActivityScanner
finalScore = Mathf.Max(finalScore, actionScore + charScore * rankChar);
}
string label = "";
if (best.has_attack_target)
{
Actor foe = null;
try
{
if (best.attack_target != null && best.attack_target.isAlive() && best.attack_target.isActor())
{
foe = best.attack_target.a;
}
}
catch
{
foe = null;
}
label = EventReason.Fight(best, foe);
}
return new InterestEvent
{
Score = finalScore,
Position = best.current_position,
FollowUnit = best,
Label = FormatUnitLabel(best, speciesCounts),
Label = label,
CreatedAt = Time.unscaledTime,
AssetId = best.asset != null ? best.asset.id : "scored_unit",
ParticipantCount = fighters,

View file

@ -0,0 +1,216 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Reflection;
using System.Text;
using UnityEngine;
namespace IdleSpectator;
public sealed class WorldLogEventAuditResult
{
public bool Passed;
public string Detail = "";
public int Total;
public int MissingAuthored;
public int OrphanAuthored;
public int EmptyLabel;
}
/// <summary>
/// Exhaustive live world_log_library audit for authored WorldLog event catalog coverage.
/// </summary>
public static class WorldLogEventHarness
{
public static WorldLogEventAuditResult RunAudit(string outputDirectory)
{
List<string> liveIds = ActivityAssetCatalog.EnumerateLiveWorldLogIds();
var authored = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (string id in EventCatalog.WorldLog.AuthoredIds)
{
authored.Add(id);
}
int missingAuthored = 0;
int orphanAuthored = 0;
int emptyLabel = 0;
var tsv = new StringBuilder();
tsv.AppendLine(
"id\tgroup\tlocale\tpath_icon\tdisaster_link\tauthored\tevent_strength\tcategory\tcreates_interest\tlabel_sample\tnotes");
var liveSet = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
for (int i = 0; i < liveIds.Count; i++)
{
string id = liveIds[i];
liveSet.Add(id);
WorldLogAsset asset = ActivityAssetCatalog.TryGetWorldLogAsset(id);
bool hasAuthored = EventCatalog.WorldLog.HasAuthored(id);
WorldLogEventEntry entry = EventCatalog.WorldLog.GetOrFallback(id);
if (!hasAuthored || entry.IsFallback)
{
missingAuthored++;
}
string group = ReadStringField(asset, "group_id")
?? ReadStringField(asset, "history_group")
?? ReadStringField(asset, "group")
?? "";
string locale = "";
try
{
locale = asset != null ? (asset.getLocaleID() ?? "") : "";
}
catch
{
locale = ReadStringField(asset, "path_locale") ?? "";
}
string pathIcon = ReadStringField(asset, "path_icon") ?? "";
string disasterLink = ReadStringField(asset, "disaster_id")
?? ReadStringField(asset, "linked_disaster")
?? "";
if (string.IsNullOrEmpty(disasterLink) && id.IndexOf("disaster", StringComparison.OrdinalIgnoreCase) >= 0)
{
disasterLink = "id_hint";
}
string labelSample = entry.MakeLabel("Alpha", "Beta");
if (string.IsNullOrEmpty(labelSample))
{
emptyLabel++;
}
string notes = "ok";
if (!hasAuthored || entry.IsFallback)
{
notes = "missing_authored";
}
else if (string.IsNullOrEmpty(labelSample))
{
notes = "empty_label";
}
tsv.Append(id).Append('\t')
.Append(Escape(group)).Append('\t')
.Append(Escape(locale)).Append('\t')
.Append(Escape(pathIcon)).Append('\t')
.Append(Escape(disasterLink)).Append('\t')
.Append(hasAuthored ? "1" : "0").Append('\t')
.Append(entry.EventStrength.ToString("0.#")).Append('\t')
.Append(Escape(entry.Category)).Append('\t')
.Append(entry.CreatesInterest ? "1" : "0").Append('\t')
.Append(Escape(labelSample)).Append('\t')
.Append(notes).AppendLine();
}
foreach (string id in authored)
{
if (!liveSet.Contains(id))
{
orphanAuthored++;
tsv.Append(id).Append("\t\t\t\t\t1\t0\t\t0\t\torphan_authored").AppendLine();
}
}
try
{
if (!string.IsNullOrEmpty(outputDirectory))
{
Directory.CreateDirectory(outputDirectory);
File.WriteAllText(
Path.Combine(outputDirectory, "world-log-event-audit.tsv"),
tsv.ToString());
}
}
catch
{
// Disk may be unavailable in some harness contexts.
}
bool passed = missingAuthored == 0 && orphanAuthored == 0 && emptyLabel == 0;
return new WorldLogEventAuditResult
{
Passed = passed,
Total = liveIds.Count,
MissingAuthored = missingAuthored,
OrphanAuthored = orphanAuthored,
EmptyLabel = emptyLabel,
Detail =
$"total={liveIds.Count} missingAuthored={missingAuthored} orphanAuthored={orphanAuthored} "
+ $"emptyLabel={emptyLabel}"
};
}
public static string DumpDisasterWarInventory(string outputDirectory)
{
var tsv = new StringBuilder();
tsv.AppendLine("kind\tid\tnotes");
List<string> disasters = ActivityAssetCatalog.EnumerateLiveDisasterIds();
for (int i = 0; i < disasters.Count; i++)
{
tsv.Append("disaster\t").Append(disasters[i]).Append("\tok").AppendLine();
}
List<string> warTypes = ActivityAssetCatalog.EnumerateLiveWarTypeIds();
for (int i = 0; i < warTypes.Count; i++)
{
tsv.Append("war_type\t").Append(warTypes[i]).Append("\tok").AppendLine();
}
try
{
if (!string.IsNullOrEmpty(outputDirectory))
{
Directory.CreateDirectory(outputDirectory);
File.WriteAllText(
Path.Combine(outputDirectory, "disaster-war-inventory.tsv"),
tsv.ToString());
}
}
catch
{
// ignore
}
return $"disasters={disasters.Count} war_types={warTypes.Count}";
}
private static string ReadStringField(object asset, string fieldName)
{
if (asset == null || string.IsNullOrEmpty(fieldName))
{
return null;
}
try
{
FieldInfo field = asset.GetType().GetField(fieldName);
if (field != null)
{
return field.GetValue(asset) as string;
}
PropertyInfo prop = asset.GetType().GetProperty(fieldName);
if (prop != null)
{
return prop.GetValue(asset, null) as string;
}
}
catch
{
// ignore
}
return null;
}
private static string Escape(string value)
{
if (string.IsNullOrEmpty(value))
{
return "";
}
return value.Replace('\t', ' ').Replace('\n', ' ').Replace('\r', ' ');
}
}

View file

@ -1,133 +1,21 @@
using System.Collections.Generic;
using UnityEngine;
namespace IdleSpectator;
/// <summary>
/// WorldLog asset ids → EventStrength seeds (score-native; no InterestTier).
/// Compatibility facade over <see cref="EventCatalog.WorldLog"/>. Prefer the catalog directly.
/// </summary>
public static class WorldLogInterestTable
{
private static readonly Dictionary<string, float> EventSeeds = new Dictionary<string, float>
{
// World-shaping
["diplomacy_war_started"] = 100f,
["total_war_started"] = 100f,
["kingdom_destroyed"] = 100f,
["kingdom_shattered"] = 98f,
["kingdom_fractured"] = 95f,
["city_destroyed"] = 92f,
["log_city_revolted"] = 90f,
// Leadership / politics / favorites / founding
["kingdom_new"] = 75f,
["city_new"] = 72f,
["king_new"] = 78f,
["king_dead"] = 80f,
["king_killed"] = 85f,
["king_fled_capital"] = 70f,
["king_fled_city"] = 68f,
["king_left"] = 65f,
["alliance_new"] = 70f,
["alliance_dissolved"] = 68f,
["diplomacy_war_ended"] = 72f,
["favorite_dead"] = 82f,
["favorite_killed"] = 85f,
["kingdom_royal_clan_new"] = 74f,
["kingdom_royal_clan_changed"] = 72f,
["kingdom_royal_clan_dead"] = 76f,
// Biosphere footnotes
["race_dead"] = 40f
};
public static bool TryGetEventStrength(string assetId, out float eventStrength)
{
if (string.IsNullOrEmpty(assetId))
{
eventStrength = 0f;
return false;
}
if (EventSeeds.TryGetValue(assetId, out eventStrength))
{
return true;
}
if (assetId.Contains("disaster") || assetId.StartsWith("worldlog_disaster"))
{
eventStrength = 95f;
return true;
}
eventStrength = 0f;
return false;
return EventCatalog.WorldLog.TryGetEventStrength(assetId, out eventStrength);
}
public static string MakeLabel(WorldLogMessage message)
{
string id = message.asset_id ?? "event";
string a = message.special1;
string b = message.special2;
switch (id)
{
case "diplomacy_war_started":
return $"War: {a} vs {b}";
case "total_war_started":
return $"Total war: {a}";
case "kingdom_destroyed":
return $"Kingdom fell: {a}";
case "kingdom_shattered":
return $"Kingdom shattered: {a}";
case "kingdom_fractured":
return $"Kingdom fractured: {a}";
case "city_destroyed":
return $"City destroyed: {a}";
case "log_city_revolted":
return $"Revolt: {a}";
case "kingdom_new":
return $"New kingdom: {a}";
case "city_new":
return $"New city: {a}";
case "king_new":
return $"New king: {b} ({a})";
case "king_dead":
return $"King died: {b} ({a})";
case "king_killed":
return $"King killed: {b} ({a})";
case "king_fled_capital":
return $"King fled capital: {b}";
case "king_fled_city":
return $"King fled city: {b}";
case "alliance_new":
return $"Alliance: {a}";
case "alliance_dissolved":
return $"Alliance dissolved: {a}";
case "diplomacy_war_ended":
return $"War ended: {a}";
case "kingdom_royal_clan_new":
case "kingdom_royal_clan_changed":
return $"Royal clan: {a}";
case "kingdom_royal_clan_dead":
return $"Royal clan ended: {a}";
case "race_dead":
return $"Species extinct: {a}";
case "favorite_dead":
case "favorite_killed":
return $"Favorite fell: {a}";
default:
if (!string.IsNullOrEmpty(a) && !string.IsNullOrEmpty(b))
{
return $"{id}: {a} / {b}";
}
if (!string.IsNullOrEmpty(a))
{
return $"{id}: {a}";
}
return id;
}
return EventCatalog.WorldLog.MakeLabel(message);
}
public static IEnumerable<string> AuthoredIds => EventCatalog.WorldLog.AuthoredIds;
}

View file

@ -1,7 +1,7 @@
{
"name": "IdleSpectator",
"author": "dazed",
"version": "0.16.8",
"version": "0.21.3",
"description": "AFK Idle Spectator (I) + Lore (L). Event-centered scene director + happiness Activity/Life logging.",
"GUID": "com.dazed.idlespectator"
}

View file

@ -15,10 +15,17 @@
"fillScoreMax": 55,
"noticeScoreMin": 70,
"hotScoreMin": 110,
"dwellFill": 6,
"dwellNormal": 10,
"dwellHot": 14,
"fillRotateSeconds": 10,
"dwellFill": 8,
"dwellNormal": 15,
"dwellHot": 15,
"fillRotateSeconds": 8,
"minCameraDwell": 15,
"maxWatchMoment": 15,
"maxWatchStatus": 30,
"maxWatchGrief": 45,
"maxWatchCombat": 60,
"maxWatchEpic": 50,
"massFighterThreshold": 4,
"massBaseBonus": 28,
@ -88,7 +95,7 @@
"IdleSpectator/WorldActivityScanner.cs",
"IdleSpectator/InterestDirector.cs",
"IdleSpectator/InterestVariety.cs",
"IdleSpectator/InterestFeeds.cs"
"IdleSpectator/Events/Feeds/InterestFeeds.cs"
],
"designIntent": [
"Actions matter much more than characters.",

View file

@ -10,21 +10,20 @@ WorldBox NeoModLoader mod: AFK Idle Spectator camera director.
## What it follows
Priority (wiki-aligned):
The camera follows **ranked events** from live feeds (wars, fights, relationships, buildings, statuses, plots, …).
`EventStrength` decides who wins; quieter events still take the shot when nothing stronger is pending.
Character fill only runs when the event queue is empty (no orange reason; task chip shows the live job).
1. **Epic** - wars, kingdom fall/shatter, city destroyed, disasters
2. **Story** - kings, clans, alliances, favorites, new kingdoms/cities
3. **Action** - live battles/skirmishes (civ or wild), fighting units
4. **Curiosity** - new subspecies (low priority; will not interrupt fights)
5. **Ambient** - highest-scoring alive creature on the map (works on fresh animal-only worlds)
While watching, a **dossier caption** shows who they are and an orange **event sentence** (why the camera is here), e.g. `Kaszus is fighting Waaaf`.
See [`docs/event-reason.md`](docs/event-reason.md).
While watching, a **dossier caption** shows who they are (species, kills, age, roles).
Lingering on someone writes a **Chronicle** line; F9 lists them and click jumps the camera.
## Layout
- `IdleSpectator/` - mod source (`mod.json` + C#), loaded/compiled by NML
- `scripts/verify-nml.sh` - load verification helper
- `docs/event-reason.md` - events-own-camera + EventReason contract
## Deploy

82
docs/event-audit.md Normal file
View file

@ -0,0 +1,82 @@
# Event audit (post-expand)
Inventory policy after the full gap expand.
Camera interrupt is score-margin only (`cutInMargin`); no Signal/Ambient ownership gate.
**Single inventory:** [`EventCatalog`](../IdleSpectator/Events/EventCatalog.cs) (partials under `Events/Catalogs/`).
Nested domains: `Status`, `Happiness`, `WorldLog`, `Trait`, `Combat`, `Spell`, …
Feeds/patches register from those dials; `InterestDirector` only ranks candidates.
## Dials
| Dial | Role |
|------|------|
| `CreatesInterest` | Whether a live id may register at all |
| `EventStrength` | Base contribution to `TotalScore` (interrupt via +`cutInMargin`) |
| `Completion` | Hold-until-complete kind |
| `MaxWatch` | Hard cap (class defaults in `scoring-model.json`) |
| `EventReason` | Orange dossier sentence |
## MaxWatch class defaults
| Class | Seconds | JSON field |
|-------|--------:|------------|
| Moment / FixedDwell | 15 | `maxWatchMoment` |
| Status | 30 | `maxWatchStatus` |
| Grief | 45 | `maxWatchGrief` |
| Combat | 60 | `maxWatchCombat` |
| Epic world | 50 | `maxWatchEpic` |
Peer rotate / soft cut on EventLed floors at `minCameraDwell` (15s). Score-margin interrupts bypass that floor.
Ambient / Character fill is exempt - events may take the camera immediately.
## Sources (wired)
| Source | Primary owner | Reason factory | Notes |
|--------|---------------|----------------|-------|
| WorldLog | `InterestFeeds.OnWorldLogMessage` | catalog / EventReason | Strength from `EventCatalog.WorldLog` |
| Happiness | `IngestHappiness` | `EventCatalog.Happiness` + EventReason | Prose must match game call sites - see [event-prose-audit.md](event-prose-audit.md) |
| Status | `OnStatusChange` | EventReason.Status | `drowning` CreatesInterest=true, strength ~50; notable via CharacterSignificance |
| Scanner combat | WorldActivityScanner | EventReason.Fight / Battle | Completion CombatActive; dials `EventCatalog.Combat` |
| War | WarEventPatches + WarInterestFeed.Tick | war type labels | |
| Plot | PlotEventPatches + PlotInterestFeed.Tick | `EventCatalog.Plot` | |
| Relationship | RelationshipEventPatches + happiness lovers | EventReason.* | |
| Trait | TraitEventPatches | EventReason.Trait | Spawn window filters routine traits |
| Building | BuildingEventPatches | EventReason.Building* | Eat/damage/falls |
| Boat | BoatEventPatches | EventReason.Boat | |
| Book | BookEventPatches | `EventCatalog.Book` sentences | |
| Era / Meta | MetaEventPatches | EventReason.MetaNew / era labels | |
| Spell | CombatActionLibrary.tryToCastSpell | EventReason.Library | Ambient CreatesInterest=false except spectacle ids |
| Power | PlayerControl.clickedFinal | EventReason.HumanizeId | Spectacle ids create interest |
| Decision | setDecisionCooldown | EventReason.Library | IsCameraWorthy filter |
| Item | ItemManager | EventReason.Library | Legendary CreatesInterest |
| Gene | Subspecies.mutateFrom | EventReason.Library | Ambient CreatesInterest=false |
| Phenotype | generatePhenotypeAndShade | EventReason.Library | |
| World law | WorldLaws.enable / toggle | EventReason.HumanizeId | Location-only |
| Biome | catalog ready | EventReason.HumanizeId | Ambient CreatesInterest=false; emit via power terraform path |
| Subspecies trait | SubspeciesManager biome hooks | EventReason.Library | |
| Character fill | InterestDirector.TryCharacterFill | empty Label | Only when no pending EventLed |
## Ambient statuses (intentional off)
`festive_spirit`, `laughing`, `singing`, `sleeping`, `handsome_migrant` - CreatesInterest=false.
## Same-moment ownership
Prefer typed feeds over WorldLog when both fire (relationship / status / combat).
Registry Upsert merges same key and refreshes `LastSeenAt` for sticky combat/status/grief.
## Interrupt / hold / grace
1. Instant cut when `next.TotalScore >= current + cutInMargin` (no settle; ignores min dwell).
2. Ambient fill: any EventLed may take the camera immediately (no `minCameraDwell`).
3. Else hold while `InterestCompletion.IsActive` and under MaxWatch (moments floor to `minCameraDwell`; combat uses class 60s).
4. Soft peer rotates on EventLed only after `minCameraDwell` (15s).
5. Quiet grace 6s: still-worth-watching filter, then Character fill with empty reason.
## Mutation gap triage
`mutation_discovery` refreshes `.harness/mutation_gaps.tsv`.
Open rows are unexplained Wire candidates only.
False positives (clear/load/UI/FX/getters) and covered-by-primary paths are filtered or listed in `AlreadyWired`.
Library fields are inventory-complete via catalogs / activity / WorldLog (`mutation_libraries.tsv` notes).

61
docs/event-prose-audit.md Normal file
View file

@ -0,0 +1,61 @@
# Event prose audit (vs game call sites)
Authored IdleSpectator sentences must match what the game actually does when an id fires.
Source of truth: `Assembly-CSharp` apply sites (`changeHappiness`, relationship patches, status receive), not English guesses from the id string.
Audit date: 2026-07-16.
Scope: Happiness (all authored), Relationship (all), Status/WorldLog/Plot/etc. sampled.
## Fixed this pass
| Id | Was | Actual | Now |
|----|-----|--------|-----|
| `just_born` | "is born into the world" / "first breath" | `Actor.newCreature` spawn; may precede egg form | "appears in the world" / "is brought into existence"; strength 48; no hatch strength floor |
| `just_kissed` | "shares a kiss" | Mating afterglow (`BehCheckForBabiesFromSexualReproduction`) | "mates with …" / "shares intimacy …" |
| `just_killed` | "takes a life" | Only with trait `bloodlust` | Explicit bloodlust wording |
| `add_child` / `EventReason.NewChild` | "{a} and {b} have a new child" | `{b}` is the child | "{a} gains a child, {b}" |
| `baby_created` / `EventReason.BabyBorn` | "{a} welcomes a baby" | `{a}` is the baby | "{a} is created as a baby" |
| `just_had_child` | Named newborn `{related}` | Related newborn not wired | "has a child" / parenthood only |
| `just_started_war` | Anyone cheers war | Culture trait `happiness_from_war` | "is glad war has started" / culture-loves-war |
| `just_talked` | Always warm | Talk can be good or bad | Neutral "finishes talking" |
| `just_inspired` | Onset of inspiration | Status `inspired` **end** | "comes down from inspiration" |
| `slept_outside` | Rough night over | Sleep start, no house | "has to sleep without a house" |
| `just_rebelled` | Joins / rises | Whole city units swept | "is swept up as the city rebels" |
| `just_forced_power` | Vague forced power | God force shove | "is flung by a god power" |
| `got_caught` | Shady catch Signal | No game caller found | Ambient, weak strength |
| `lost_fight` | Decisive loss | Yield / submit mid-fight | "is forced to yield" |
| `just_talked_gossip` | Generic juicy gossip | `gossip_lovers` culture | Lover-gossip wording |
| `become_king` | "crowned king" | Any sex ruler | "takes the crown" / "becomes ruler" |
| `become_alpha` | Fight for alpha | Oldest living family unit | "becomes the family alpha" |
| Dreams | "wakes from …" | Dream status on receive | "has a … dream" |
## Policy notes
- Hatch camera floor (`Max(strength, 88)`) applies only to `just_got_out_of_egg` / `just_hatched`, not `just_born`.
- True hatch prose (`just_got_out_of_egg` → "hatches from an egg") matches `Actor.eventHatchFromEgg`.
- Live parent beat is `just_had_child`, not `just_born`.
## Still clean (sample)
Grief deaths, `fallen_in_love`, gifts, house find/lose, `just_got_out_of_egg`, `just_became_adult`, WorldLog `king_new` template roles, Status `egg` / `pregnant` / `drowning` prose.
## Hatch camera path (fixed)
Hatch was Activity-only for many units:
- Status `egg` has `CreatesInterest=false` (becoming an egg is not a camera beat).
- `OnStatusChange` previously ignored **losses**, so "Hatches from an egg" never became interest.
- Happiness `just_got_out_of_egg` is often suppressed (`!hasEmotions()`).
- Even when a hatch Label existed, dossier identity filter dropped verbs without ` is ` (e.g. "Name hatches …").
Fix:
- Egg status **loss**`InterestFeeds.EmitHatch` + `EventReason.Hatch`, key `hatch:{id}` shared with happiness.
- Keep `Name verb …` EventReason sentences in `UnitDossier.OwnedEventReason`.
## Reasons
EventLed camera candidates always get a `Label` (orange reason) when ownership + sentence filters pass.
Sources: `EventReason.*`, happiness catalog `PlainClause`, or discrete catalog templates.
Character fill / ambient may show empty reason.
Not every Activity line is a camera event (Ambient happiness, status gains with `CreatesInterest=false`, status losses except egg).

62
docs/event-reason.md Normal file
View file

@ -0,0 +1,62 @@
# Event reasons (camera + orange dossier)
## Contract
Registered **events** own the idle camera.
`EventStrength` / `TotalScore` ranks who wins.
Character fill runs only when the pending **EventLed** queue is empty, and uses an empty Label (task chip only).
Orange dossier reason = the owning events `Label` while the scene is active (`InterestDirector.TryGetOwnedReasonCandidate`).
Quiet grace / inactive dwell clears the reason even if focus has not switched yet.
## EventReason
[`IdleSpectator/Events/EventReason.cs`](../IdleSpectator/Events/EventReason.cs) is the sole Label factory for camera candidates.
It lives in the Events module with catalogs (not in the director).
Feeds call typed helpers (`Fight`, `BuildingEat`, `SeekingLover`, …) or `Apply(template, a, b, id)`.
## Events module layout
| Path | Role |
|------|------|
| `Events/EventCatalog.cs` (+ `Catalogs/EventCatalog.*.cs`) | **One inventory** - nested domains (`Status`, `Combat`, …) |
| `Events/Feeds/` | Build + register candidates from catalog dials |
| `Events/Patches/` | Harmony hooks → feeds |
| `Events/EventReason.cs` | Orange reason sentences |
| `Events/EventFeedUtil.cs` | Shared Register path |
| `Events/DiscreteEventEntry.cs` | Shared catalog row shape |
`InterestDirector` only ranks registered candidates for the camera - it does not author event inventory.
Rules:
- Sentence form with names: `{a} is fighting {b}`, `{a} is eating a beehive`, `{a} is seeking a lover`.
- Set `RelatedUnit` when the sentence names a second party.
- Never invent stranger subjects for unit-led events.
- Never use engine jargon that misstates the action (`Consumes a building` → eating/foraging via `BehConsumeTargetBuilding`).
## Removed crumbs
Do not author these as orange reasons:
- `In action`
- bare `Duel` / `Fight (N)` without a named sentence
- identity titles (`King of X`, `Leader of X`, `Lone beetle`)
- `Consumes a building` / `Damages a building: {a}` crumb form
## Strength dial (no ownership enum)
`InterestOwnership` / `OwnsCamera` were removed.
Every registration is EventLed and may own the camera.
`CreatesInterest` gates whether an id registers; `EventStrength` ranks.
Instant cut-in when `next.TotalScore >= current + cutInMargin` (see `scoring-model.json`).
## Dossier display
`UnitDossier.EventLabelBeat` keeps the authored sentence.
It only rejects identity/weak crumbs and scrubs living stranger names (`ReasonNamesStranger` / `LeadsWithForeignName`).
## See also
- [`event-audit.md`](event-audit.md) - full source inventory, MaxWatch classes, drowning policy
- [`scoring-model.md`](scoring-model.md) - score weights

View file

@ -37,6 +37,7 @@ REGRESSION_SCENARIOS=(
director_action_priority
discovery
world_log
event_coverage
chronicle_smoke
activity_idle_smoke
activity_status_smoke

View file

@ -30,10 +30,16 @@ worldbox_running() {
mod_ready() {
[[ -f "$LOG" ]] || return 1
# Compile errors (NML prints error CS during Compile Mod IdleSpectator).
if rg -q "error CS" "$LOG" 2>/dev/null \
&& rg -q "Compile Mod IdleSpectator|IdleSpectator.*error CS|error CS.*IdleSpectator" "$LOG" 2>/dev/null; then
return 2
fi
# Harmony PatchAll failures disable the mod without a CS error.
if rg -q "IdleSpectator has been disabled due to an error" "$LOG" 2>/dev/null \
|| rg -q "\[IdleSpectator\]: Harmony failed" "$LOG" 2>/dev/null; then
return 3
fi
rg -q "\[IdleSpectator\]: Harmony patches applied" "$LOG" 2>/dev/null
}
@ -103,6 +109,11 @@ cmd_start() {
rg -n "error CS|Compile Mod IdleSpectator" "$LOG" 2>/dev/null | tail -n 40 >&2 || true
return 1
fi
if (( ready_rc == 3 )); then
echo "IdleSpectator Harmony failed - see $LOG" >&2
rg -n "Harmony failed|Parameter \"|IdleSpectator has been disabled|IL Compile Error" "$LOG" 2>/dev/null | tail -n 40 >&2 || true
return 1
fi
printf '.'
else
printf 'o'