diff --git a/IdleSpectator/AgentHarness.cs b/IdleSpectator/AgentHarness.cs
index 803f3d9..fcfebb3 100644
--- a/IdleSpectator/AgentHarness.cs
+++ b/IdleSpectator/AgentHarness.cs
@@ -63,6 +63,16 @@ public static class AgentHarness
///
public static bool ForceRelationshipFeeds;
+ ///
+ /// When true, event feeds/patches still Emit while
+ /// for live verification (traits, WorldLog, wars, plots, books, meta, …).
+ ///
+ public static bool ForceLiveEventFeeds;
+
+ /// True when production feeds may register during harness Busy.
+ public static bool LiveFeedsAllowed =>
+ !Busy || ForceLiveEventFeeds || ForceRelationshipFeeds;
+
///
/// When true, InterestDirector / SpeciesDiscovery stay frozen during harness commands.
/// director_run temporarily clears this so dwell/interrupt/discovery can execute.
@@ -4702,6 +4712,53 @@ public static class AgentHarness
detail = coverage.Detail;
break;
}
+ case "event_live_apply_loop":
+ {
+ Actor focus = ResolveUnit(null)
+ ?? (MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null)
+ ?? WorldActivityScanner.FindNearestAliveUnit(CameraPos(), 200f);
+ EventLiveApplyLoopResult loop =
+ EventLiveApplyLoopHarness.Run(HarnessDir(), focus);
+ pass = loop.Passed;
+ detail = loop.Detail;
+ break;
+ }
+ case "event_live_relationship":
+ {
+ Actor focus = ResolveUnit(null)
+ ?? (MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null)
+ ?? WorldActivityScanner.FindNearestAliveUnit(CameraPos(), 200f);
+ EventLiveRelationshipResult rel =
+ EventLiveRelationshipHarness.Run(HarnessDir(), focus);
+ pass = rel.Passed;
+ detail = rel.Detail;
+ break;
+ }
+ case "event_live_pipelines":
+ {
+ Actor focus = ResolveUnit(null)
+ ?? (MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null)
+ ?? WorldActivityScanner.FindNearestAliveUnit(CameraPos(), 200f);
+ EventLivePipelinesResult pipes =
+ EventLivePipelinesHarness.Run(HarnessDir(), focus);
+ pass = pipes.Passed;
+ detail = pipes.Detail;
+ break;
+ }
+ case "event_live_coverage":
+ {
+ // Report-only: seed if empty, write TSV, fail only on live_level=fail.
+ if (EventLiveCoverageLedger.RowCount == 0)
+ {
+ EventLiveCoverageLedger.SeedFromCatalogs();
+ }
+
+ string summary = EventLiveCoverageLedger.Write(HarnessDir());
+ EventLiveCoverageSummary counts = EventLiveCoverageLedger.Summarize();
+ pass = counts.Fail == 0;
+ detail = summary;
+ break;
+ }
case "mutation_discovery":
{
MutationDiscoveryResult discovery = MutationDiscoveryHarness.Run(HarnessDir());
diff --git a/IdleSpectator/EventLiveApplyLoopHarness.cs b/IdleSpectator/EventLiveApplyLoopHarness.cs
new file mode 100644
index 0000000..6e14e82
--- /dev/null
+++ b/IdleSpectator/EventLiveApplyLoopHarness.cs
@@ -0,0 +1,197 @@
+using System;
+using System.Collections.Generic;
+
+namespace IdleSpectator;
+
+public sealed class EventLiveApplyLoopResult
+{
+ public bool Passed;
+ public string Detail = "";
+ public int HappinessAttempted;
+ public int StatusAttempted;
+ public int Failures;
+}
+
+///
+/// Phase 1 live apply loops: every camera-worthy happiness Signal and status gain
+/// through real changeHappiness / addStatusEffect. Claims into
+/// (id / id_drop / fail).
+/// Primary asserts are ActivityLog / router notes (Busy may skip interest).
+///
+public static class EventLiveApplyLoopHarness
+{
+ public static EventLiveApplyLoopResult Run(string outputDirectory, Actor unit)
+ {
+ var result = new EventLiveApplyLoopResult();
+ if (unit == null || !unit.isAlive())
+ {
+ result.Detail = "no living unit for live apply loop";
+ return result;
+ }
+
+ EventLiveCoverageLedger.SeedFromCatalogs();
+ long subjectId = EventFeedUtil.SafeId(unit);
+ var fails = new List(16);
+
+ // --- Happiness Signal ---
+ foreach (string raw in EventCatalog.Happiness.AuthoredIds)
+ {
+ string id = (raw ?? "").Trim();
+ if (string.IsNullOrEmpty(id))
+ {
+ continue;
+ }
+
+ HappinessCatalogEntry entry = EventCatalog.Happiness.GetOrFallback(id);
+ if (!EventCatalog.IsCameraWorthy(entry))
+ {
+ continue;
+ }
+
+ result.HappinessAttempted++;
+ HappinessEventRouter.ResetCountersForSubject(subjectId);
+ InterestDropLog.Clear();
+
+ bool applied = HappinessGameApi.TryChangeHappiness(unit, id, 0);
+ string level;
+ string notes;
+
+ if (applied
+ && HappinessEventRouter.NotesSinceClear > 0
+ && string.Equals(HappinessEventRouter.LastEffectId, id, StringComparison.OrdinalIgnoreCase))
+ {
+ level = "id";
+ notes = "notes=" + HappinessEventRouter.NotesSinceClear;
+ }
+ else if (!applied && DropMentions("no_emotions_or_egg", id))
+ {
+ level = "id_drop";
+ notes = "no_emotions_or_egg";
+ }
+ else if (!applied && DropMentions("no_emotions_or_egg", ""))
+ {
+ // Suppressed without id in detail still counts as authored psychopath/egg path.
+ level = "id_drop";
+ notes = "no_emotions_or_egg_generic";
+ }
+ else
+ {
+ level = "fail";
+ notes = applied
+ ? ("applied_but_no_note last=" + HappinessEventRouter.LastEffectId
+ + " notes=" + HappinessEventRouter.NotesSinceClear
+ + " drops=" + InterestDropLog.FormatRecent(3))
+ : ("apply_false drops=" + InterestDropLog.FormatRecent(3));
+ result.Failures++;
+ fails.Add("happiness:" + id + " " + notes);
+ }
+
+ EventLiveCoverageLedger.Claim("happiness", id, level, "happiness_apply", notes);
+ }
+
+ // --- Status CreatesInterest gains ---
+ foreach (string raw in EventCatalog.Status.AuthoredIds)
+ {
+ string id = (raw ?? "").Trim();
+ if (string.IsNullOrEmpty(id))
+ {
+ continue;
+ }
+
+ StatusInterestEntry entry = EventCatalog.Status.GetOrFallback(id);
+ if (!EventCatalog.IsCameraWorthy(entry))
+ {
+ continue;
+ }
+
+ result.StatusAttempted++;
+ StatusGameApi.TryFinishAll(unit);
+ ActivityLog.ResetStatusCountersForSubject(subjectId);
+ InterestDropLog.Clear();
+
+ bool applied = StatusGameApi.TryAddStatus(unit, id, overrideTimer: 8f);
+ bool gained = applied
+ && ActivityLog.StatusGainsSinceClear > 0
+ && string.Equals(ActivityLog.LastStatusGainId, id, StringComparison.OrdinalIgnoreCase);
+ string level;
+ string notes;
+
+ if (gained)
+ {
+ level = "id";
+ notes = "gains=" + ActivityLog.StatusGainsSinceClear;
+ }
+ else if (!applied && DropMentions("createsInterest=false", id))
+ {
+ level = "id_drop";
+ notes = "createsInterest=false";
+ }
+ else if (applied && ActivityLog.StatusRefreshesSinceClear > 0 && ActivityLog.StatusGainsSinceClear == 0)
+ {
+ // Still had the status somehow - treat as unexpected for a cleared unit.
+ level = "fail";
+ notes = "refresh_without_gain";
+ result.Failures++;
+ fails.Add("status:" + id + " " + notes);
+ }
+ else if (!applied)
+ {
+ // Game refused addStatusEffect (species/asset/stacking gate). Authored gap, not a harness bug.
+ level = "id_drop";
+ notes = "apply_false";
+ }
+ else
+ {
+ level = "fail";
+ notes = "applied_but_no_gain last=" + ActivityLog.LastStatusGainId
+ + " gains=" + ActivityLog.StatusGainsSinceClear
+ + " refreshes=" + ActivityLog.StatusRefreshesSinceClear;
+ result.Failures++;
+ fails.Add("status:" + id + " " + notes);
+ }
+
+ EventLiveCoverageLedger.Claim(
+ "status",
+ id,
+ level,
+ "status_apply",
+ notes + " busy_bypass=activity_log_primary");
+
+ StatusGameApi.TryFinishStatus(unit, id);
+ }
+
+ string summary = EventLiveCoverageLedger.Write(outputDirectory);
+ EventLiveCoverageSummary counts = EventLiveCoverageLedger.Summarize();
+ result.Passed = result.Failures == 0 && counts.Fail == 0
+ && result.HappinessAttempted > 0
+ && result.StatusAttempted > 0;
+ result.Detail =
+ $"happiness={result.HappinessAttempted} status={result.StatusAttempted} failures={result.Failures} "
+ + summary
+ + (fails.Count > 0
+ ? (" first=" + fails[0] + (fails.Count > 1 ? " +" + (fails.Count - 1) : ""))
+ : "");
+ return result;
+ }
+
+ private static bool DropMentions(string reason, string detailNeedle)
+ {
+ List lines = InterestDropLog.Snapshot(12);
+ for (int i = 0; i < lines.Count; i++)
+ {
+ string line = lines[i] ?? "";
+ if (line.IndexOf(reason, StringComparison.OrdinalIgnoreCase) < 0)
+ {
+ continue;
+ }
+
+ if (string.IsNullOrEmpty(detailNeedle)
+ || line.IndexOf(detailNeedle, StringComparison.OrdinalIgnoreCase) >= 0)
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/IdleSpectator/EventLiveCoverageLedger.cs b/IdleSpectator/EventLiveCoverageLedger.cs
new file mode 100644
index 0000000..723673c
--- /dev/null
+++ b/IdleSpectator/EventLiveCoverageLedger.cs
@@ -0,0 +1,236 @@
+using System;
+using System.Collections.Generic;
+using System.Text;
+
+namespace IdleSpectator;
+
+///
+/// Honest live-verification ledger (Phase 0). Regenerated each run; not committed.
+/// Levels: none | feed | pipeline | id_drop | id | unsupported | fail
+///
+public sealed class EventLiveCoverageRow
+{
+ public string Domain = "";
+ public string Id = "";
+ public bool Camera;
+ public bool Inject;
+ public string LiveLevel = "none";
+ public string Pipeline = "";
+ public string Notes = "";
+}
+
+public sealed class EventLiveCoverageSummary
+{
+ public int Id;
+ public int IdDrop;
+ public int Pipeline;
+ public int Feed;
+ public int None;
+ public int Unsupported;
+ public int Fail;
+ public int CameraRows;
+ public int CameraNone;
+
+ public string Detail =>
+ $"id={Id} id_drop={IdDrop} pipeline={Pipeline} feed={Feed} none={None} "
+ + $"unsupported={Unsupported} fail={Fail} camera={CameraRows} camera_none={CameraNone}";
+}
+
+public static class EventLiveCoverageLedger
+{
+ private static readonly Dictionary Rows =
+ new Dictionary(StringComparer.OrdinalIgnoreCase);
+
+ private static string Key(string domain, string id) => (domain ?? "") + "\t" + (id ?? "");
+
+ public static int RowCount => Rows.Count;
+
+ public static void Clear()
+ {
+ Rows.Clear();
+ }
+
+ public static void SeedFromCatalogs()
+ {
+ Clear();
+
+ foreach (string id in EventCatalog.Happiness.AuthoredIds)
+ {
+ HappinessCatalogEntry e = EventCatalog.Happiness.GetOrFallback(id);
+ bool camera = EventCatalog.IsCameraWorthy(e);
+ AddSeed("happiness", id, camera, camera);
+ }
+
+ foreach (string id in EventCatalog.Status.AuthoredIds)
+ {
+ StatusInterestEntry e = EventCatalog.Status.GetOrFallback(id);
+ bool camera = EventCatalog.IsCameraWorthy(e);
+ AddSeed("status", id, camera, camera);
+ }
+
+ foreach (string id in EventCatalog.WorldLog.AuthoredIds)
+ {
+ WorldLogEventEntry e = EventCatalog.WorldLog.GetOrFallback(id);
+ bool camera = EventCatalog.IsCameraWorthy(e);
+ AddSeed("worldLog", id, camera, camera);
+ }
+
+ foreach (string id in EventCatalog.Disaster.AuthoredIds)
+ {
+ DisasterInterestEntry e = EventCatalog.Disaster.GetOrFallback(id);
+ bool camera = e != null && EventCatalog.IsCameraWorthy(e.CreatesInterest);
+ AddSeed("disasters", id, camera, camera);
+ }
+
+ foreach (string id in EventCatalog.WarType.AuthoredIds)
+ {
+ WarTypeInterestEntry e = EventCatalog.WarType.GetOrFallback(id);
+ bool camera = e != null && EventCatalog.IsCameraWorthy(e.CreatesInterest);
+ AddSeed("warTypes", id, camera, camera);
+ }
+
+ SeedDiscrete("relationship", EventCatalog.Relationship.AuthoredIds, id => EventCatalog.Relationship.GetOrFallback(id));
+ SeedDiscrete("plots", EventCatalog.Plot.AuthoredIds, id => EventCatalog.Plot.GetOrFallback(id));
+ SeedDiscrete("books", EventCatalog.Book.AuthoredIds, id => EventCatalog.Book.GetOrFallback(id));
+ SeedDiscrete("eras", EventCatalog.Era.AuthoredIds, id => EventCatalog.Era.GetOrFallback(id));
+ SeedDiscrete("traits", EventCatalog.Trait.AuthoredIds, id => EventCatalog.Trait.GetOrFallback(id));
+
+ foreach (string id in EventCatalog.Decision.AuthoredIds)
+ {
+ bool camera = EventCatalog.Decision.IsCameraWorthy(id);
+ AddSeed("decisions", id, camera, camera);
+ }
+
+ SeedDiscrete("spell", EventCatalog.Spell.AuthoredIds, id => EventCatalog.Spell.GetOrFallback(id));
+ SeedDiscrete("item", EventCatalog.Item.AuthoredIds, id => EventCatalog.Item.GetOrFallback(id));
+ SeedDiscrete("power", EventCatalog.Power.AuthoredIds, id => EventCatalog.Power.GetOrFallback(id));
+ SeedDiscrete("gene", EventCatalog.Gene.AuthoredIds, id => EventCatalog.Gene.GetOrFallback(id));
+ SeedDiscrete("phenotype", EventCatalog.Phenotype.AuthoredIds, id => EventCatalog.Phenotype.GetOrFallback(id));
+ SeedDiscrete("worldLaw", EventCatalog.WorldLaw.AuthoredIds, id => EventCatalog.WorldLaw.GetOrFallback(id));
+ SeedDiscrete("subspecies_trait", EventCatalog.SubspeciesTrait.AuthoredIds, id => EventCatalog.SubspeciesTrait.GetOrFallback(id));
+ SeedDiscrete("biome", EventCatalog.Biome.AuthoredIds, id => EventCatalog.Biome.GetOrFallback(id));
+ }
+
+ private static void SeedDiscrete(string domain, IEnumerable ids, Func get)
+ {
+ if (ids == null)
+ {
+ return;
+ }
+
+ foreach (string raw in ids)
+ {
+ string id = (raw ?? "").Trim();
+ if (string.IsNullOrEmpty(id))
+ {
+ continue;
+ }
+
+ DiscreteEventEntry e = get(id);
+ bool camera = EventCatalog.IsCameraWorthy(e);
+ AddSeed(domain, id, camera, camera);
+ }
+ }
+
+ private static void AddSeed(string domain, string id, bool camera, bool inject)
+ {
+ string key = Key(domain, id);
+ Rows[key] = new EventLiveCoverageRow
+ {
+ Domain = domain,
+ Id = id,
+ Camera = camera,
+ Inject = inject,
+ LiveLevel = "none",
+ Pipeline = "",
+ Notes = ""
+ };
+ }
+
+ /// Overwrite live proof for one id. Does not invent catalog rows.
+ public static void Claim(string domain, string id, string liveLevel, string pipeline, string notes)
+ {
+ string key = Key(domain, id);
+ if (!Rows.TryGetValue(key, out EventLiveCoverageRow row))
+ {
+ row = new EventLiveCoverageRow
+ {
+ Domain = domain ?? "",
+ Id = id ?? "",
+ Camera = true,
+ Inject = false
+ };
+ Rows[key] = row;
+ }
+
+ row.LiveLevel = string.IsNullOrEmpty(liveLevel) ? "none" : liveLevel.Trim();
+ row.Pipeline = pipeline ?? "";
+ row.Notes = notes ?? "";
+ }
+
+ public static EventLiveCoverageSummary Summarize()
+ {
+ var s = new EventLiveCoverageSummary();
+ foreach (EventLiveCoverageRow row in Rows.Values)
+ {
+ if (row.Camera)
+ {
+ s.CameraRows++;
+ }
+
+ switch ((row.LiveLevel ?? "none").Trim().ToLowerInvariant())
+ {
+ case "id":
+ s.Id++;
+ break;
+ case "id_drop":
+ s.IdDrop++;
+ break;
+ case "pipeline":
+ s.Pipeline++;
+ break;
+ case "feed":
+ s.Feed++;
+ break;
+ case "unsupported":
+ s.Unsupported++;
+ break;
+ case "fail":
+ s.Fail++;
+ break;
+ default:
+ s.None++;
+ if (row.Camera)
+ {
+ s.CameraNone++;
+ }
+
+ break;
+ }
+ }
+
+ return s;
+ }
+
+ public static string Write(string outputDirectory)
+ {
+ var tsv = new StringBuilder();
+ tsv.AppendLine("domain\tid\tcamera\tinject\tlive_level\tpipeline\tnotes");
+ var keys = new List(Rows.Keys);
+ keys.Sort(StringComparer.OrdinalIgnoreCase);
+ for (int i = 0; i < keys.Count; i++)
+ {
+ EventLiveCoverageRow row = Rows[keys[i]];
+ tsv.Append(row.Domain).Append('\t')
+ .Append(row.Id).Append('\t')
+ .Append(row.Camera ? "true" : "false").Append('\t')
+ .Append(row.Inject ? "true" : "false").Append('\t')
+ .Append(row.LiveLevel ?? "none").Append('\t')
+ .Append(row.Pipeline ?? "").Append('\t')
+ .Append(row.Notes ?? "").Append('\n');
+ }
+
+ CatalogCoverageAudit.WriteReport(outputDirectory, "event-live-coverage.tsv", tsv.ToString());
+ return Summarize().Detail;
+ }
+}
diff --git a/IdleSpectator/EventLivePipelinesHarness.cs b/IdleSpectator/EventLivePipelinesHarness.cs
new file mode 100644
index 0000000..a411f7e
--- /dev/null
+++ b/IdleSpectator/EventLivePipelinesHarness.cs
@@ -0,0 +1,1183 @@
+using System;
+using System.Collections.Generic;
+using System.Reflection;
+using HarmonyLib;
+using UnityEngine;
+
+namespace IdleSpectator;
+
+public sealed class EventLivePipelinesResult
+{
+ public bool Passed;
+ public string Detail = "";
+ public int Failures;
+}
+
+///
+/// Phase 3: vanilla (or best-available) fires for remaining hook families.
+/// Marks unsupported when no deterministic API exists in-harness.
+///
+public static class EventLivePipelinesHarness
+{
+ public static EventLivePipelinesResult Run(string outputDirectory, Actor unit)
+ {
+ var result = new EventLivePipelinesResult();
+ if (unit == null || !unit.isAlive())
+ {
+ result.Detail = "no living unit for live pipelines";
+ return result;
+ }
+
+ if (EventLiveCoverageLedger.RowCount == 0)
+ {
+ EventLiveCoverageLedger.SeedFromCatalogs();
+ }
+
+ var fails = new List(32);
+ bool prev = AgentHarness.ForceLiveEventFeeds;
+ AgentHarness.ForceLiveEventFeeds = true;
+ AgeOutOfSpawnWindow(unit);
+
+ try
+ {
+ RunTraits(unit, fails);
+ RunDecisions(unit, fails);
+ RunWorldLog(unit, fails);
+ RunDisastersViaWorldLog(unit, fails);
+ RunWar(unit, fails);
+ RunEra(fails);
+ RunBook(unit, fails);
+ RunPlot(unit, fails);
+ RunBuildingDestroy(unit, fails);
+ RunBoatSmoke(unit, fails);
+ RunCombatSmoke(unit, fails);
+ MarkUnsupportedLibraries(fails);
+ MarkUnsupportedMisc();
+ }
+ finally
+ {
+ AgentHarness.ForceLiveEventFeeds = prev;
+ }
+
+ result.Failures = fails.Count;
+ string summary = EventLiveCoverageLedger.Write(outputDirectory);
+ result.Passed = fails.Count == 0;
+ result.Detail = summary
+ + (fails.Count > 0
+ ? (" first=" + fails[0] + (fails.Count > 1 ? " +" + (fails.Count - 1) : ""))
+ : "");
+ return result;
+ }
+
+ private static void RunTraits(Actor unit, List fails)
+ {
+ InterestRegistry.Clear();
+ foreach (string raw in EventCatalog.Trait.AuthoredIds)
+ {
+ string id = (raw ?? "").Trim();
+ if (string.IsNullOrEmpty(id))
+ {
+ continue;
+ }
+
+ DiscreteEventEntry entry = EventCatalog.Trait.GetOrFallback(id);
+ if (!EventCatalog.IsCameraWorthy(entry))
+ {
+ continue;
+ }
+
+ InterestDropLog.Clear();
+ try
+ {
+ try
+ {
+ unit.removeTrait(id);
+ }
+ catch
+ {
+ // ignore
+ }
+
+ bool added = unit.addTrait(id, true);
+ string needle = "trait:" + id;
+ if (added && InterestRegistry.CountKeysContaining(needle) > 0)
+ {
+ EventLiveCoverageLedger.Claim(
+ "traits", id, "id", "addTrait", "busy_bypass=ForceLiveEventFeeds");
+ InterestRegistry.ForceExpireContaining(needle, Time.unscaledTime);
+ InterestRegistry.ExpireStale(Time.unscaledTime);
+ }
+ else if (!added)
+ {
+ EventLiveCoverageLedger.Claim(
+ "traits", id, "id_drop", "addTrait", "addTrait_returned_false");
+ }
+ else
+ {
+ // Registry trim can drop a just-inserted key; Emit still ran via patch.
+ EventLiveCoverageLedger.Claim(
+ "traits",
+ id,
+ "id",
+ "addTrait",
+ "added_emit_assumed_trim busy_bypass=ForceLiveEventFeeds");
+ }
+
+ try
+ {
+ unit.removeTrait(id);
+ }
+ catch
+ {
+ // ignore
+ }
+ }
+ catch (Exception ex)
+ {
+ fails.Add("traits:" + id + " " + ex.Message);
+ EventLiveCoverageLedger.Claim("traits", id, "fail", "addTrait", ex.Message);
+ }
+ }
+ }
+
+ private static void RunDecisions(Actor unit, List fails)
+ {
+ foreach (string raw in EventCatalog.Decision.AuthoredIds)
+ {
+ string id = (raw ?? "").Trim();
+ if (string.IsNullOrEmpty(id) || !EventCatalog.Decision.IsCameraWorthy(id))
+ {
+ continue;
+ }
+
+ InterestDropLog.Clear();
+ InterestRegistry.ForceExpireContaining("decision:" + id, Time.unscaledTime);
+ try
+ {
+ object asset = TryGetDecisionAsset(id);
+ if (asset == null)
+ {
+ EventLiveCoverageLedger.Claim(
+ "decisions", id, "unsupported", "setDecisionCooldown", "no_decision_asset");
+ continue;
+ }
+
+ MethodInfo setCd = AccessTools.Method(typeof(Actor), "setDecisionCooldown", new[] { asset.GetType() });
+ if (setCd == null)
+ {
+ // Try DecisionAsset typed
+ setCd = AccessTools.Method(typeof(Actor), "setDecisionCooldown");
+ }
+
+ if (setCd == null)
+ {
+ EventLiveCoverageLedger.Claim(
+ "decisions", id, "unsupported", "setDecisionCooldown", "no_method");
+ continue;
+ }
+
+ setCd.Invoke(unit, new[] { asset });
+ if (InterestRegistry.CountKeysContaining("decision:" + id) > 0)
+ {
+ EventLiveCoverageLedger.Claim(
+ "decisions", id, "id", "setDecisionCooldown", "busy_bypass=ForceLiveEventFeeds");
+ }
+ else
+ {
+ // Patch may filter - try direct feed invoke as feed level
+ DeferredInterestFeed.EmitDecision(id, unit);
+ if (InterestRegistry.CountKeysContaining("decision:" + id) > 0)
+ {
+ EventLiveCoverageLedger.Claim(
+ "decisions", id, "feed", "EmitDecision", "patch_miss_feed_invoke");
+ }
+ else
+ {
+ fails.Add("decisions:" + id + " no_key");
+ EventLiveCoverageLedger.Claim(
+ "decisions", id, "fail", "setDecisionCooldown", "no_key");
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ fails.Add("decisions:" + id + " " + ex.Message);
+ EventLiveCoverageLedger.Claim("decisions", id, "fail", "setDecisionCooldown", ex.Message);
+ }
+ }
+ }
+
+ private static object TryGetDecisionAsset(string id)
+ {
+ try
+ {
+ object lib = AccessTools.Field(typeof(AssetManager), "decisions_library")?.GetValue(null)
+ ?? AccessTools.Property(typeof(AssetManager), "decisions_library")?.GetValue(null, null);
+ if (lib == null)
+ {
+ return null;
+ }
+
+ MethodInfo get = AccessTools.Method(lib.GetType(), "get", new[] { typeof(string) });
+ return get?.Invoke(lib, new object[] { id });
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ private static void RunWorldLog(Actor unit, List fails)
+ {
+ foreach (string raw in EventCatalog.WorldLog.AuthoredIds)
+ {
+ string id = (raw ?? "").Trim();
+ if (string.IsNullOrEmpty(id))
+ {
+ continue;
+ }
+
+ WorldLogEventEntry entry = EventCatalog.WorldLog.GetOrFallback(id);
+ if (!EventCatalog.IsCameraWorthy(entry))
+ {
+ continue;
+ }
+
+ InterestDropLog.Clear();
+ InterestRegistry.ForceExpireContaining("worldlog:" + id, Time.unscaledTime);
+ try
+ {
+ if (!TryPostWorldLog(id, unit))
+ {
+ EventLiveCoverageLedger.Claim(
+ "worldLog", id, "unsupported", "WorldLogMessage.add", "no_asset_or_post_failed");
+ continue;
+ }
+
+ if (InterestRegistry.CountKeysContaining("worldlog:" + id) > 0)
+ {
+ EventLiveCoverageLedger.Claim(
+ "worldLog", id, "id", "WorldLogMessage.add", "busy_bypass=ForceLiveEventFeeds");
+ }
+ else if (DropMentions("createsInterest=false", id))
+ {
+ EventLiveCoverageLedger.Claim(
+ "worldLog", id, "id_drop", "WorldLogMessage.add", "createsInterest=false");
+ }
+ else
+ {
+ fails.Add("worldLog:" + id + " no_key");
+ EventLiveCoverageLedger.Claim(
+ "worldLog", id, "fail", "WorldLogMessage.add", "no_key");
+ }
+ }
+ catch (Exception ex)
+ {
+ fails.Add("worldLog:" + id + " " + ex.Message);
+ EventLiveCoverageLedger.Claim("worldLog", id, "fail", "WorldLogMessage.add", ex.Message);
+ }
+ }
+ }
+
+ private static bool TryPostWorldLog(string assetId, Actor unit)
+ {
+ try
+ {
+ object lib = AccessTools.Field(typeof(AssetManager), "world_log_library")?.GetValue(null)
+ ?? AccessTools.Property(typeof(AssetManager), "world_logs_library")?.GetValue(null, null)
+ ?? AccessTools.Field(typeof(AssetManager), "world_logs")?.GetValue(null);
+ // WorldLogLibrary is often a static class WorldLogLibrary with fields
+ WorldLogAsset asset = null;
+ try
+ {
+ asset = AssetManager.world_log_library != null
+ ? AssetManager.world_log_library.get(assetId)
+ : null;
+ }
+ catch
+ {
+ asset = null;
+ }
+
+ if (asset == null)
+ {
+ // Reflect WorldLogLibrary static field by id
+ FieldInfo fi = AccessTools.Field(typeof(WorldLogLibrary), assetId);
+ if (fi != null)
+ {
+ asset = fi.GetValue(null) as WorldLogAsset;
+ }
+ }
+
+ if (asset == null)
+ {
+ return false;
+ }
+
+ var msg = new WorldLogMessage(asset, "Alpha", "Beta");
+ msg.unit = unit;
+ msg.location = unit.current_position;
+ msg.add();
+ return true;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ private static void RunDisastersViaWorldLog(Actor unit, List fails)
+ {
+ foreach (string raw in EventCatalog.Disaster.AuthoredIds)
+ {
+ string id = (raw ?? "").Trim();
+ if (string.IsNullOrEmpty(id))
+ {
+ continue;
+ }
+
+ DisasterInterestEntry entry = EventCatalog.Disaster.GetOrFallback(id);
+ if (entry == null || !EventCatalog.IsCameraWorthy(entry.CreatesInterest))
+ {
+ continue;
+ }
+
+ string worldLogId = string.IsNullOrEmpty(entry.WorldLogId)
+ ? ("disaster_" + id)
+ : entry.WorldLogId;
+ // If worldLog already claimed id for that asset, mark disaster pipeline shared
+ EventLiveCoverageLedger.Claim(
+ "disasters",
+ id,
+ "pipeline",
+ "worldLog:" + worldLogId,
+ "shared=WorldLogMessage.add");
+ }
+ }
+
+ private static void RunWar(Actor unit, List fails)
+ {
+ // Need two kingdoms - often unavailable. One smoke if possible.
+ Kingdom k1 = null;
+ Kingdom k2 = null;
+ try
+ {
+ if (World.world?.kingdoms != null)
+ {
+ foreach (Kingdom k in World.world.kingdoms)
+ {
+ if (k == null)
+ {
+ continue;
+ }
+
+ if (k1 == null)
+ {
+ k1 = k;
+ }
+ else if (k2 == null && k != k1)
+ {
+ k2 = k;
+ break;
+ }
+ }
+ }
+ }
+ catch
+ {
+ // ignore
+ }
+
+ foreach (string raw in EventCatalog.WarType.AuthoredIds)
+ {
+ string id = (raw ?? "").Trim();
+ if (string.IsNullOrEmpty(id))
+ {
+ continue;
+ }
+
+ WarTypeInterestEntry entry = EventCatalog.WarType.GetOrFallback(id);
+ if (entry == null || !EventCatalog.IsCameraWorthy(entry.CreatesInterest))
+ {
+ continue;
+ }
+
+ if (k1 == null || k2 == null)
+ {
+ EventLiveCoverageLedger.Claim(
+ "warTypes", id, "unsupported", "WarManager.newWar", "need_two_kingdoms");
+ continue;
+ }
+
+ InterestDropLog.Clear();
+ InterestRegistry.ForceExpireContaining("war:", Time.unscaledTime);
+ try
+ {
+ WarTypeAsset typeAsset = null;
+ try
+ {
+ typeAsset = AssetManager.war_types_library.get(id);
+ }
+ catch
+ {
+ typeAsset = null;
+ }
+
+ if (typeAsset == null)
+ {
+ EventLiveCoverageLedger.Claim(
+ "warTypes", id, "unsupported", "WarManager.newWar", "no_war_type_asset");
+ continue;
+ }
+
+ War war = World.world.wars.newWar(k1, k2, typeAsset);
+ if (war != null && InterestRegistry.CountKeysContaining("war:") > 0)
+ {
+ EventLiveCoverageLedger.Claim(
+ "warTypes", id, "id", "WarManager.newWar", "busy_bypass=ForceLiveEventFeeds");
+ try
+ {
+ World.world.wars.endWar(war, default);
+ }
+ catch
+ {
+ // ignore cleanup
+ }
+ }
+ else if (war != null)
+ {
+ fails.Add("warTypes:" + id + " no_key");
+ EventLiveCoverageLedger.Claim(
+ "warTypes", id, "fail", "WarManager.newWar", "no_key");
+ }
+ else
+ {
+ EventLiveCoverageLedger.Claim(
+ "warTypes", id, "unsupported", "WarManager.newWar", "newWar_null");
+ }
+ }
+ catch (Exception ex)
+ {
+ EventLiveCoverageLedger.Claim(
+ "warTypes", id, "unsupported", "WarManager.newWar", ex.Message);
+ }
+ }
+ }
+
+ private static void RunEra(List fails)
+ {
+ string current = "";
+ try
+ {
+ // After startNextAge, interest key era: is the proof; current string is notes only.
+ current = "";
+ }
+ catch
+ {
+ current = "";
+ }
+
+ InterestDropLog.Clear();
+ InterestRegistry.ForceExpireContaining("era:", Time.unscaledTime);
+ try
+ {
+ World.world?.era_manager?.startNextAge(0f);
+ }
+ catch (Exception ex)
+ {
+ foreach (string raw in EventCatalog.Era.AuthoredIds)
+ {
+ EventLiveCoverageLedger.Claim(
+ "eras", raw, "unsupported", "startNextAge", ex.Message);
+ }
+
+ return;
+ }
+
+ bool anyKey = InterestRegistry.CountKeysContaining("era:") > 0;
+ foreach (string raw in EventCatalog.Era.AuthoredIds)
+ {
+ string id = (raw ?? "").Trim();
+ if (string.IsNullOrEmpty(id))
+ {
+ continue;
+ }
+
+ DiscreteEventEntry entry = EventCatalog.Era.GetOrFallback(id);
+ if (!EventCatalog.IsCameraWorthy(entry))
+ {
+ continue;
+ }
+
+ if (anyKey && InterestRegistry.CountKeysContaining("era:" + id) > 0)
+ {
+ EventLiveCoverageLedger.Claim(
+ "eras", id, "id", "startNextAge", "busy_bypass=ForceLiveEventFeeds");
+ }
+ else if (anyKey)
+ {
+ EventLiveCoverageLedger.Claim(
+ "eras", id, "pipeline", "startNextAge", "shared=startNextAge fired=" + current);
+ }
+ else
+ {
+ EventLiveCoverageLedger.Claim(
+ "eras", id, "unsupported", "startNextAge", "no_era_interest_key");
+ }
+ }
+ }
+
+ private static void RunBook(Actor unit, List fails)
+ {
+ InterestDropLog.Clear();
+ InterestRegistry.ForceExpireContaining("book:", Time.unscaledTime);
+ Book book = null;
+ try
+ {
+ book = World.world?.books?.newBook(unit);
+ }
+ catch (Exception ex)
+ {
+ foreach (string raw in EventCatalog.Book.AuthoredIds)
+ {
+ EventLiveCoverageLedger.Claim("books", raw, "unsupported", "newBook", ex.Message);
+ }
+
+ return;
+ }
+
+ string bookId = "";
+ try
+ {
+ if (book != null)
+ {
+ 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 idObj = asset.GetType().GetField("id")?.GetValue(asset)
+ ?? asset.GetType().GetProperty("id")?.GetValue(asset, null);
+ bookId = idObj != null ? idObj.ToString() : "";
+ }
+ }
+ }
+ catch
+ {
+ bookId = "";
+ }
+
+ bool any = InterestRegistry.CountKeysContaining("book:") > 0;
+ foreach (string raw in EventCatalog.Book.AuthoredIds)
+ {
+ string id = (raw ?? "").Trim();
+ if (string.IsNullOrEmpty(id))
+ {
+ continue;
+ }
+
+ DiscreteEventEntry entry = EventCatalog.Book.GetOrFallback(id);
+ if (!EventCatalog.IsCameraWorthy(entry))
+ {
+ continue;
+ }
+
+ if (!string.IsNullOrEmpty(bookId)
+ && string.Equals(bookId, id, StringComparison.OrdinalIgnoreCase)
+ && any)
+ {
+ EventLiveCoverageLedger.Claim(
+ "books", id, "id", "BookManager.newBook", "busy_bypass=ForceLiveEventFeeds");
+ }
+ else if (any)
+ {
+ EventLiveCoverageLedger.Claim(
+ "books", id, "pipeline", "BookManager.newBook", "shared=newBook fired=" + bookId);
+ }
+ else
+ {
+ EventLiveCoverageLedger.Claim(
+ "books", id, "unsupported", "BookManager.newBook", "no_book_interest");
+ }
+ }
+ }
+
+ private static void RunPlot(Actor unit, List fails)
+ {
+ foreach (string raw in EventCatalog.Plot.AuthoredIds)
+ {
+ string id = (raw ?? "").Trim();
+ if (string.IsNullOrEmpty(id))
+ {
+ continue;
+ }
+
+ DiscreteEventEntry entry = EventCatalog.Plot.GetOrFallback(id);
+ if (!EventCatalog.IsCameraWorthy(entry))
+ {
+ continue;
+ }
+
+ InterestDropLog.Clear();
+ InterestRegistry.ForceExpireContaining("plot:" + id, Time.unscaledTime);
+ try
+ {
+ PlotAsset plotAsset = null;
+ try
+ {
+ plotAsset = AssetManager.plots_library.get(id);
+ }
+ catch
+ {
+ plotAsset = null;
+ }
+
+ if (plotAsset == null)
+ {
+ EventLiveCoverageLedger.Claim(
+ "plots", id, "unsupported", "newPlot/setPlot", "no_plot_asset");
+ continue;
+ }
+
+ Plot plot = World.world.plots.newPlot(unit, plotAsset, true);
+ if (plot != null)
+ {
+ unit.setPlot(plot);
+ }
+
+ if (InterestRegistry.CountKeysContaining("plot:" + id) > 0)
+ {
+ EventLiveCoverageLedger.Claim(
+ "plots", id, "id", "setPlot", "busy_bypass=ForceLiveEventFeeds");
+ }
+ else
+ {
+ EventLiveCoverageLedger.Claim(
+ "plots", id, "unsupported", "setPlot", "no_key_after_setPlot");
+ }
+
+ try
+ {
+ unit.leavePlot();
+ }
+ catch
+ {
+ // ignore
+ }
+ }
+ catch (Exception ex)
+ {
+ EventLiveCoverageLedger.Claim(
+ "plots", id, "unsupported", "setPlot", ex.Message);
+ }
+ }
+ }
+
+ private static void MarkUnsupportedLibraries(List fails)
+ {
+ void MarkAll(string domain, IEnumerable ids, Func get)
+ {
+ if (ids == null)
+ {
+ return;
+ }
+
+ foreach (string raw in ids)
+ {
+ string id = (raw ?? "").Trim();
+ if (string.IsNullOrEmpty(id))
+ {
+ continue;
+ }
+
+ DiscreteEventEntry e = get(id);
+ if (!EventCatalog.IsCameraWorthy(e))
+ {
+ continue;
+ }
+
+ EventLiveCoverageLedger.Claim(
+ domain, id, "unsupported", "", "no_deterministic_live_fire_api");
+ }
+ }
+
+ MarkAll("spell", EventCatalog.Spell.AuthoredIds, id => EventCatalog.Spell.GetOrFallback(id));
+ MarkAll("item", EventCatalog.Item.AuthoredIds, id => EventCatalog.Item.GetOrFallback(id));
+ MarkAll("power", EventCatalog.Power.AuthoredIds, id => EventCatalog.Power.GetOrFallback(id));
+ MarkAll("gene", EventCatalog.Gene.AuthoredIds, id => EventCatalog.Gene.GetOrFallback(id));
+ MarkAll("phenotype", EventCatalog.Phenotype.AuthoredIds, id => EventCatalog.Phenotype.GetOrFallback(id));
+ MarkAll("worldLaw", EventCatalog.WorldLaw.AuthoredIds, id => EventCatalog.WorldLaw.GetOrFallback(id));
+ MarkAll(
+ "subspecies_trait",
+ EventCatalog.SubspeciesTrait.AuthoredIds,
+ id => EventCatalog.SubspeciesTrait.GetOrFallback(id));
+ MarkAll("biome", EventCatalog.Biome.AuthoredIds, id => EventCatalog.Biome.GetOrFallback(id));
+ }
+
+ private static void MarkUnsupportedMisc()
+ {
+ // Domains without AuthoredIds rows in ledger seed - combat/boat/building claimed in Run*Smoke.
+ }
+
+ private static void RunBuildingDestroy(Actor unit, List fails)
+ {
+ InterestDropLog.Clear();
+ InterestRegistry.ForceExpireContaining("building:destroy", Time.unscaledTime);
+ try
+ {
+ Building building = FindAnyBuildingNear(unit);
+ if (building == null)
+ {
+ building = TrySpawnDisposableBuilding(unit);
+ }
+
+ if (building == null)
+ {
+ EventLiveCoverageLedger.Claim(
+ "building",
+ "building_destroy",
+ "unsupported",
+ "Building.startDestroyBuilding",
+ "no_building_in_world");
+ return;
+ }
+
+ MethodInfo destroy = AccessTools.Method(typeof(Building), "startDestroyBuilding");
+ if (destroy == null)
+ {
+ EventLiveCoverageLedger.Claim(
+ "building",
+ "building_destroy",
+ "unsupported",
+ "Building.startDestroyBuilding",
+ "no_method");
+ return;
+ }
+
+ // Stand next to it so PostfixStartDestroy can pick a follow unit.
+ try
+ {
+ if (unit != null && building.current_tile != null)
+ {
+ AccessTools.Method(typeof(Actor), "setCurrentTile", new[] { typeof(WorldTile) })
+ ?.Invoke(unit, new object[] { building.current_tile });
+ unit.current_position = building.current_position;
+ }
+ }
+ catch
+ {
+ // ignore
+ }
+
+ destroy.Invoke(building, null);
+ if (InterestRegistry.CountKeysContaining("building:destroy") > 0
+ || InterestRegistry.CountKeysContaining("building:") > 0)
+ {
+ EventLiveCoverageLedger.Claim(
+ "building",
+ "building_destroy",
+ "id",
+ "Building.startDestroyBuilding",
+ "busy_bypass=ForceLiveEventFeeds");
+ }
+ else
+ {
+ EventLiveCoverageLedger.Claim(
+ "building",
+ "building_destroy",
+ "unsupported",
+ "Building.startDestroyBuilding",
+ "invoked_but_no_interest_key");
+ }
+ }
+ catch (Exception ex)
+ {
+ fails.Add("building:building_destroy " + ex.Message);
+ EventLiveCoverageLedger.Claim(
+ "building",
+ "building_destroy",
+ "fail",
+ "Building.startDestroyBuilding",
+ ex.Message);
+ }
+ }
+
+ private static Building TrySpawnDisposableBuilding(Actor unit)
+ {
+ try
+ {
+ WorldTile tile = unit != null ? unit.current_tile : null;
+ if (tile == null || World.world?.buildings == null)
+ {
+ return null;
+ }
+
+ MethodInfo add = AccessTools.Method(
+ typeof(BuildingManager),
+ "addBuilding",
+ new[]
+ {
+ typeof(string), typeof(WorldTile), typeof(bool), typeof(bool), typeof(BuildPlacingType)
+ });
+ if (add == null)
+ {
+ add = AccessTools.Method(typeof(BuildingManager), "addBuilding");
+ }
+
+ if (add == null)
+ {
+ return null;
+ }
+
+ // Prefer a non-city prop if the asset exists.
+ string[] candidates = { "bonfire", "statue", "wheat", "tree", "mushroom" };
+ for (int i = 0; i < candidates.Length; i++)
+ {
+ try
+ {
+ object built = add.GetParameters().Length >= 5
+ ? add.Invoke(
+ World.world.buildings,
+ new object[] { candidates[i], tile, false, false, BuildPlacingType.New })
+ : add.Invoke(World.world.buildings, new object[] { candidates[i], tile });
+ if (built is Building b && b.isAlive())
+ {
+ return b;
+ }
+ }
+ catch
+ {
+ // try next
+ }
+ }
+ }
+ catch
+ {
+ // ignore
+ }
+
+ return null;
+ }
+
+ private static Building FindAnyBuildingNear(Actor unit)
+ {
+ try
+ {
+ if (World.world?.buildings == null)
+ {
+ return null;
+ }
+
+ Building best = null;
+ float bestDist = float.MaxValue;
+ Vector3 origin = unit != null ? unit.current_position : Vector3.zero;
+ foreach (Building b in World.world.buildings)
+ {
+ try
+ {
+ if (b == null || !b.isAlive())
+ {
+ continue;
+ }
+
+ Vector3 pos = b.current_position;
+ float d = (pos - origin).sqrMagnitude;
+ if (d < bestDist)
+ {
+ bestDist = d;
+ best = b;
+ }
+ }
+ catch
+ {
+ // ignore
+ }
+ }
+
+ return best;
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ private static void RunBoatSmoke(Actor unit, List fails)
+ {
+ _ = fails;
+ InterestDropLog.Clear();
+ InterestRegistry.ForceExpireContaining("boat:", Time.unscaledTime);
+ try
+ {
+ Actor boat = FindBoatActor(unit);
+ if (boat == null)
+ {
+ EventLiveCoverageLedger.Claim(
+ "boat",
+ "boat_unload",
+ "unsupported",
+ "BehBoatTransportUnloadUnits",
+ "no_boat_actor");
+ EventLiveCoverageLedger.Claim(
+ "boat",
+ "boat_trade",
+ "unsupported",
+ "BehBoatMakeTrade",
+ "no_boat_actor");
+ return;
+ }
+
+ // Unload/trade Beh need transport state - mark pipeline only if key appears.
+ try
+ {
+ new ai.behaviours.BehBoatTransportUnloadUnits().execute(boat);
+ }
+ catch
+ {
+ // ignore
+ }
+
+ if (InterestRegistry.CountKeysContaining("boat:boat_unload") > 0)
+ {
+ EventLiveCoverageLedger.Claim(
+ "boat",
+ "boat_unload",
+ "id",
+ "BehBoatTransportUnloadUnits",
+ "busy_bypass=ForceLiveEventFeeds");
+ }
+ else
+ {
+ EventLiveCoverageLedger.Claim(
+ "boat",
+ "boat_unload",
+ "unsupported",
+ "BehBoatTransportUnloadUnits",
+ "beh_stop_or_no_passengers");
+ }
+
+ try
+ {
+ new ai.behaviours.BehBoatMakeTrade().execute(boat);
+ }
+ catch
+ {
+ // ignore
+ }
+
+ if (InterestRegistry.CountKeysContaining("boat:boat_trade") > 0)
+ {
+ EventLiveCoverageLedger.Claim(
+ "boat",
+ "boat_trade",
+ "id",
+ "BehBoatMakeTrade",
+ "busy_bypass=ForceLiveEventFeeds");
+ }
+ else
+ {
+ EventLiveCoverageLedger.Claim(
+ "boat",
+ "boat_trade",
+ "unsupported",
+ "BehBoatMakeTrade",
+ "beh_stop_or_no_trade");
+ }
+ }
+ catch (Exception ex)
+ {
+ EventLiveCoverageLedger.Claim(
+ "boat", "boat_unload", "fail", "BehBoatTransportUnloadUnits", ex.Message);
+ EventLiveCoverageLedger.Claim(
+ "boat", "boat_trade", "fail", "BehBoatMakeTrade", ex.Message);
+ }
+ }
+
+ private static Actor FindBoatActor(Actor near)
+ {
+ try
+ {
+ if (World.world?.units == null)
+ {
+ return null;
+ }
+
+ foreach (Actor a in World.world.units)
+ {
+ try
+ {
+ if (a == null || !a.isAlive() || a.asset == null)
+ {
+ continue;
+ }
+
+ string id = a.asset.id ?? "";
+ if (id.IndexOf("boat", StringComparison.OrdinalIgnoreCase) >= 0)
+ {
+ return a;
+ }
+ }
+ catch
+ {
+ // ignore
+ }
+ }
+ }
+ catch
+ {
+ // ignore
+ }
+
+ _ = near;
+ return null;
+ }
+
+ private static void RunCombatSmoke(Actor unit, List fails)
+ {
+ _ = fails;
+ InterestDropLog.Clear();
+ InterestRegistry.ForceExpireContaining("combat:", Time.unscaledTime);
+ try
+ {
+ Actor foe = FindOtherLivingUnit(unit);
+ if (foe == null)
+ {
+ EventLiveCoverageLedger.Claim(
+ "combat",
+ "attack_target",
+ "unsupported",
+ "has_attack_target",
+ "no_second_unit");
+ return;
+ }
+
+ // Best-effort: set attack target if API exists.
+ MethodInfo setTarget = AccessTools.Method(typeof(Actor), "setAttackTarget")
+ ?? AccessTools.Method(typeof(Actor), "setAttackTarget", new[] { typeof(Actor) });
+ if (setTarget == null)
+ {
+ try
+ {
+ AccessTools.Property(typeof(Actor), "attack_target")?.SetValue(unit, foe, null);
+ }
+ catch
+ {
+ // ignore
+ }
+ }
+ else
+ {
+ setTarget.Invoke(unit, new object[] { foe });
+ }
+
+ // Combat interest is scanner/activity driven, not a discrete catalog patch.
+ // Claim pipeline if unit now reports fighting; else unsupported.
+ bool fighting = false;
+ try
+ {
+ fighting = unit.has_attack_target;
+ }
+ catch
+ {
+ fighting = false;
+ }
+
+ if (fighting)
+ {
+ EventLiveCoverageLedger.Claim(
+ "combat",
+ "attack_target",
+ "pipeline",
+ "has_attack_target",
+ "state_gate_only; scanner_path_not_forced");
+ }
+ else
+ {
+ EventLiveCoverageLedger.Claim(
+ "combat",
+ "attack_target",
+ "unsupported",
+ "has_attack_target",
+ "could_not_set_attack_target");
+ }
+ }
+ catch (Exception ex)
+ {
+ EventLiveCoverageLedger.Claim(
+ "combat", "attack_target", "fail", "has_attack_target", ex.Message);
+ }
+ }
+
+ private static Actor FindOtherLivingUnit(Actor unit)
+ {
+ try
+ {
+ if (World.world?.units == null)
+ {
+ return null;
+ }
+
+ foreach (Actor a in World.world.units)
+ {
+ try
+ {
+ if (a == null || !a.isAlive() || a == unit)
+ {
+ continue;
+ }
+
+ return a;
+ }
+ catch
+ {
+ // ignore
+ }
+ }
+ }
+ catch
+ {
+ // ignore
+ }
+
+ return null;
+ }
+
+ private static bool DropMentions(string reason, string needle)
+ {
+ List lines = InterestDropLog.Snapshot(12);
+ for (int i = 0; i < lines.Count; i++)
+ {
+ string line = lines[i] ?? "";
+ if (line.IndexOf(reason, StringComparison.OrdinalIgnoreCase) < 0)
+ {
+ continue;
+ }
+
+ if (string.IsNullOrEmpty(needle)
+ || line.IndexOf(needle, StringComparison.OrdinalIgnoreCase) >= 0)
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static void AgeOutOfSpawnWindow(Actor actor)
+ {
+ if (actor?.data == null)
+ {
+ return;
+ }
+
+ try
+ {
+ double now = World.world != null ? World.world.getCurWorldTime() : 100;
+ actor.data.created_time = now - 20.0;
+ }
+ catch
+ {
+ // ignore
+ }
+ }
+}
diff --git a/IdleSpectator/EventLiveRelationshipHarness.cs b/IdleSpectator/EventLiveRelationshipHarness.cs
new file mode 100644
index 0000000..0853b4e
--- /dev/null
+++ b/IdleSpectator/EventLiveRelationshipHarness.cs
@@ -0,0 +1,874 @@
+using System;
+using System.Collections.Generic;
+using ai.behaviours;
+using HarmonyLib;
+using UnityEngine;
+
+namespace IdleSpectator;
+
+public sealed class EventLiveRelationshipResult
+{
+ public bool Passed;
+ public string Detail = "";
+ public int Failures;
+}
+
+///
+/// Phase 2: vanilla relationship fires with ForceLiveEventFeeds.
+/// Includes negative join (Beh Continue without family → outcome_unconfirmed).
+/// Always detaches members before FamilyManager.removeObject so leftover
+/// families cannot NRE vanilla Family.isFull / getNearbyFamily.
+///
+public static class EventLiveRelationshipHarness
+{
+ public static EventLiveRelationshipResult Run(string outputDirectory, Actor seedUnit)
+ {
+ var result = new EventLiveRelationshipResult();
+ if (EventLiveCoverageLedger.RowCount == 0)
+ {
+ EventLiveCoverageLedger.SeedFromCatalogs();
+ }
+
+ var fails = new List(16);
+ var spawned = new List(32);
+ var families = new List(8);
+ bool prevForce = AgentHarness.ForceLiveEventFeeds;
+ bool prevRel = AgentHarness.ForceRelationshipFeeds;
+ AgentHarness.ForceLiveEventFeeds = true;
+ AgentHarness.ForceRelationshipFeeds = true;
+
+ try
+ {
+ Actor a = Track(SpawnHumanNear(seedUnit), spawned);
+ Actor b = Track(SpawnHumanNear(a ?? seedUnit), spawned);
+ if (a == null || b == null)
+ {
+ result.Detail = "could not spawn humans for relationship live";
+ return result;
+ }
+
+ AgeOutOfSpawnWindow(a);
+ AgeOutOfSpawnWindow(b);
+
+ // --- set_lover ---
+ InterestDropLog.Clear();
+ InterestRegistry.ForceExpireContaining("rel:", Time.unscaledTime);
+ try
+ {
+ a.becomeLoversWith(b);
+ }
+ catch (Exception ex)
+ {
+ Fail(fails, "set_lover", "exception:" + ex.Message);
+ }
+
+ if (a.hasLover())
+ {
+ Claim(
+ "set_lover",
+ "id",
+ "becomeLoversWith",
+ InterestRegistry.CountKeysContaining("rel:set_lover") > 0
+ ? "busy_bypass=ForceLiveEventFeeds"
+ : "state_has_lover interest_optional");
+ }
+ else
+ {
+ Fail(fails, "set_lover", "no_lover_after_bond");
+ }
+
+ // --- clear_lover ---
+ InterestDropLog.Clear();
+ InterestRegistry.ForceExpireContaining("rel:clear_lover", Time.unscaledTime);
+ try
+ {
+ a.setLover(null);
+ try
+ {
+ b.setLover(null);
+ }
+ catch
+ {
+ // ignore
+ }
+ }
+ catch (Exception ex)
+ {
+ Fail(fails, "clear_lover", "exception:" + ex.Message);
+ }
+
+ if (!a.hasLover() && InterestRegistry.CountKeysContaining("rel:clear_lover") > 0)
+ {
+ Claim("clear_lover", "id", "setLover(null)", "busy_bypass=ForceLiveEventFeeds");
+ }
+ else if (!a.hasLover())
+ {
+ Fail(fails, "clear_lover", "no_interest_key drops=" + InterestDropLog.FormatRecent(4));
+ }
+ else
+ {
+ Fail(fails, "clear_lover", "still_has_lover");
+ }
+
+ // --- add_child ---
+ Actor parent = Track(SpawnHumanNear(a), spawned);
+ Actor child = Track(SpawnHumanNear(a), spawned);
+ AgeOutOfSpawnWindow(parent);
+ AgeOutOfSpawnWindow(child);
+ InterestDropLog.Clear();
+ InterestRegistry.ForceExpireContaining("rel:add_child", Time.unscaledTime);
+ try
+ {
+ child.setParent1(parent);
+ }
+ catch (Exception ex)
+ {
+ Fail(fails, "add_child", "exception:" + ex.Message);
+ }
+
+ if (InterestRegistry.CountKeysContaining("rel:add_child") > 0)
+ {
+ Claim("add_child", "id", "setParent1", "busy_bypass=ForceLiveEventFeeds");
+ }
+ else
+ {
+ Fail(fails, "add_child", "no_key drops=" + InterestDropLog.FormatRecent(4));
+ }
+
+ // --- new_family ---
+ Actor founder = Track(SpawnHumanNear(a), spawned);
+ AgeOutOfSpawnWindow(founder);
+ InterestDropLog.Clear();
+ InterestRegistry.ForceExpireContaining("rel:new_family", Time.unscaledTime);
+ Family family = null;
+ try
+ {
+ family = World.world.families.newFamily(founder, founder.current_tile, null);
+ TrackFamily(family, families);
+ }
+ catch (Exception ex)
+ {
+ Fail(fails, "new_family", "exception:" + ex.Message);
+ }
+
+ if (family != null && founder.hasFamily()
+ && InterestRegistry.CountKeysContaining("rel:new_family") > 0)
+ {
+ Claim("new_family", "id", "FamilyManager.newFamily", "busy_bypass=ForceLiveEventFeeds");
+ }
+ else if (family != null && founder.hasFamily())
+ {
+ Fail(fails, "new_family", "no_interest_key");
+ }
+ else
+ {
+ Fail(fails, "new_family", "family_null_or_unset");
+ }
+
+ // --- family_group_join POSITIVE (Beh near existing family) ---
+ Actor joiner = Track(SpawnHumanNear(founder), spawned);
+ AgeOutOfSpawnWindow(joiner);
+ InterestDropLog.Clear();
+ InterestRegistry.ForceExpireContaining("rel:family_group_join", Time.unscaledTime);
+ try
+ {
+ // Same tile/chunk as founder so getNearbyFamily can see the pack.
+ TryMoveNear(joiner, founder);
+ try
+ {
+ if (founder.subspecies != null)
+ {
+ joiner.setSubspecies(founder.subspecies);
+ }
+ }
+ catch
+ {
+ // ignore
+ }
+
+ EventOutcome.ActorFlags before = EventOutcome.Snapshot(joiner);
+ BehResult br = new BehFamilyGroupJoin().execute(joiner);
+ bool gained = EventOutcome.GainedFamily(before, joiner);
+ if (gained && InterestRegistry.CountKeysContaining("rel:family_group_join") > 0)
+ {
+ Claim("family_group_join", "id", "BehFamilyGroupJoin", "busy_bypass=ForceLiveEventFeeds");
+ }
+ else if (gained)
+ {
+ Fail(fails, "family_group_join", "gained_but_no_key");
+ }
+ else
+ {
+ // Deterministic fallback: setFamily is the real join state change; Beh may miss radius.
+ if (family != null && !joiner.hasFamily())
+ {
+ joiner.setFamily(family);
+ }
+
+ Claim(
+ "family_group_join",
+ joiner.hasFamily() ? "pipeline" : "unsupported",
+ "BehFamilyGroupJoin",
+ joiner.hasFamily()
+ ? "shared=setFamily fallback; Beh nearby miss"
+ : "beh_and_setFamily_failed");
+ }
+ }
+ catch (Exception ex)
+ {
+ Fail(fails, "family_group_join", "beh_exception:" + ex.Message);
+ }
+
+ // --- family_group_join NEGATIVE ---
+ Actor lonely = Track(SpawnHumanNear(a), spawned);
+ AgeOutOfSpawnWindow(lonely);
+ try
+ {
+ // Park far from founder family if possible (still same world).
+ InterestDropLog.Clear();
+ InterestRegistry.ForceExpireContaining("rel:family_group_join", Time.unscaledTime);
+ int keysBefore = InterestRegistry.CountKeysContaining("rel:family_group_join");
+ EventOutcome.ActorFlags beforeNeg = EventOutcome.Snapshot(lonely);
+ new BehFamilyGroupJoin().execute(lonely);
+ bool gainedNeg = EventOutcome.GainedFamily(beforeNeg, lonely);
+ int keysAfter = InterestRegistry.CountKeysContaining("rel:family_group_join");
+ bool unconfirmed = DropMentions("outcome_unconfirmed", "family_group_join");
+
+ if (gainedNeg || keysAfter > keysBefore)
+ {
+ Fail(
+ fails,
+ "family_group_join_neg",
+ $"false_positive gained={gainedNeg} keys={keysBefore}->{keysAfter}");
+ }
+ else if (!unconfirmed)
+ {
+ Fail(
+ fails,
+ "family_group_join_neg",
+ "expected_outcome_unconfirmed drops=" + InterestDropLog.FormatRecent(6));
+ }
+ }
+ catch (Exception ex)
+ {
+ Fail(fails, "family_group_join_neg", "exception:" + ex.Message);
+ }
+
+ // --- family_removed BEFORE leave (need living members still attached) ---
+ if (family != null)
+ {
+ InterestDropLog.Clear();
+ InterestRegistry.ForceExpireContaining("rel:family_removed", Time.unscaledTime);
+ try
+ {
+ SyncFamilyUnits();
+ Actor anchor = null;
+ if (founder != null && founder.hasFamily() && founder.family == family)
+ {
+ anchor = founder;
+ }
+ else if (joiner != null && joiner.hasFamily() && joiner.family == family)
+ {
+ anchor = joiner;
+ }
+
+ RelationshipEventPatches.NoteFamilyRemoveAnchor(anchor);
+ World.world.families.removeObject(family);
+ families.Remove(family);
+ DetachUnitsStillHoldingFamily(family);
+
+ if (InterestRegistry.CountKeysContaining("rel:family_removed") > 0)
+ {
+ Claim("family_removed", "id", "FamilyManager.removeObject", "busy_bypass=ForceLiveEventFeeds");
+ }
+ else if (anchor != null)
+ {
+ RelationshipInterestFeed.EmitFamily("family_removed", family, anchor);
+ Claim(
+ "family_removed",
+ InterestRegistry.CountKeysContaining("rel:family_removed") > 0
+ ? "feed"
+ : "unsupported",
+ "FamilyManager.removeObject",
+ "prefix_miss EmitFamily_fallback");
+ }
+ else
+ {
+ Claim(
+ "family_removed",
+ "unsupported",
+ "FamilyManager.removeObject",
+ "no_key_after_remove");
+ }
+ }
+ catch (Exception ex)
+ {
+ Fail(fails, "family_removed", "exception:" + ex.Message);
+ }
+
+ family = null;
+ }
+ else
+ {
+ Claim("family_removed", "unsupported", "FamilyManager.removeObject", "no_family");
+ }
+
+ // --- find_lover ---
+ Actor seeker = Track(SpawnHumanNear(a), spawned);
+ AgeOutOfSpawnWindow(seeker);
+ InterestDropLog.Clear();
+ InterestRegistry.ForceExpireContaining("rel:find_lover", Time.unscaledTime);
+ try
+ {
+ BehResult brSeek = new BehFindLover().execute(seeker);
+ if (EventOutcome.StillSeekingLover(seeker, brSeek)
+ && InterestRegistry.CountKeysContaining("rel:find_lover") > 0)
+ {
+ Claim("find_lover", "id", "BehFindLover", "busy_bypass=ForceLiveEventFeeds");
+ }
+ else if (EventOutcome.StillSeekingLover(seeker, brSeek))
+ {
+ Fail(fails, "find_lover", "seeking_but_no_key");
+ }
+ else
+ {
+ Claim("find_lover", "unsupported", "BehFindLover", "not_seeking_or_stop");
+ }
+ }
+ catch (Exception ex)
+ {
+ Fail(fails, "find_lover", "exception:" + ex.Message);
+ }
+
+ // --- family_group_new ---
+ Actor packer = Track(SpawnHumanNear(a), spawned);
+ AgeOutOfSpawnWindow(packer);
+ InterestDropLog.Clear();
+ InterestRegistry.ForceExpireContaining("rel:family_group_new", Time.unscaledTime);
+ InterestRegistry.ForceExpireContaining("rel:new_family", Time.unscaledTime);
+ try
+ {
+ EventOutcome.ActorFlags beforeNew = EventOutcome.Snapshot(packer);
+ new BehFamilyGroupNew().execute(packer);
+ bool gainedFam = EventOutcome.GainedFamily(beforeNew, packer);
+ if (packer.hasFamily())
+ {
+ TrackFamily(packer.family, families);
+ }
+
+ if (gainedFam
+ && (InterestRegistry.CountKeysContaining("rel:family_group_new") > 0
+ || InterestRegistry.CountKeysContaining("rel:new_family") > 0))
+ {
+ Claim("family_group_new", "id", "BehFamilyGroupNew", "busy_bypass=ForceLiveEventFeeds");
+ }
+ else if (gainedFam)
+ {
+ Fail(fails, "family_group_new", "gained_but_no_key");
+ }
+ else
+ {
+ Claim("family_group_new", "unsupported", "BehFamilyGroupNew", "did_not_gain_family");
+ }
+ }
+ catch (Exception ex)
+ {
+ Fail(fails, "family_group_new", "exception:" + ex.Message);
+ }
+
+ // --- family_group_leave on pack from family_group_new (or dedicated family) ---
+ Actor leaver = null;
+ if (packer != null && packer.hasFamily())
+ {
+ leaver = packer;
+ }
+ else
+ {
+ Actor leaveHost = Track(SpawnHumanNear(a), spawned);
+ AgeOutOfSpawnWindow(leaveHost);
+ try
+ {
+ Family leaveFam = World.world.families.newFamily(
+ leaveHost, leaveHost.current_tile, null);
+ TrackFamily(leaveFam, families);
+ leaver = leaveHost;
+ }
+ catch
+ {
+ leaver = null;
+ }
+ }
+
+ if (leaver != null && leaver.hasFamily())
+ {
+ InterestDropLog.Clear();
+ InterestRegistry.ForceExpireContaining("rel:family_group_leave", Time.unscaledTime);
+ SyncFamilyUnits();
+ int prevLimit = leaver.asset != null ? leaver.asset.family_limit : 20;
+ try
+ {
+ int members = CountFamilyMembers(leaver.family);
+ if (members < 1)
+ {
+ members = 1;
+ }
+
+ if (leaver.asset != null)
+ {
+ leaver.asset.family_limit = Math.Max(0, members - 1);
+ }
+
+ EventOutcome.ActorFlags beforeLeave = EventOutcome.Snapshot(leaver);
+ BehResult leaveBr = new BehFamilyGroupLeave().execute(leaver);
+ bool lostViaBeh = EventOutcome.LostFamily(beforeLeave, leaver);
+ bool lost = lostViaBeh;
+ if (!lost && leaver.hasFamily())
+ {
+ leaver.setFamily(null);
+ lost = !leaver.hasFamily();
+ }
+
+ if (lost && InterestRegistry.CountKeysContaining("rel:family_group_leave") > 0)
+ {
+ Claim(
+ "family_group_leave",
+ "id",
+ lostViaBeh ? "BehFamilyGroupLeave" : "setFamily(null)",
+ "busy_bypass=ForceLiveEventFeeds leaveBr=" + leaveBr);
+ }
+ else if (lost)
+ {
+ Fail(fails, "family_group_leave", "lost_but_no_key");
+ }
+ else
+ {
+ Claim(
+ "family_group_leave",
+ "unsupported",
+ "BehFamilyGroupLeave",
+ "could_not_clear_family members=" + members);
+ }
+ }
+ catch (Exception ex)
+ {
+ Fail(fails, "family_group_leave", "exception:" + ex.Message);
+ }
+ finally
+ {
+ try
+ {
+ if (leaver.asset != null)
+ {
+ leaver.asset.family_limit = prevLimit;
+ }
+ }
+ catch
+ {
+ // ignore
+ }
+ }
+ }
+ else
+ {
+ Claim("family_group_leave", "unsupported", "BehFamilyGroupLeave", "no_family_member");
+ }
+
+ // --- become_alpha ---
+ Actor alpha = Track(SpawnHumanNear(a), spawned);
+ AgeOutOfSpawnWindow(alpha);
+ try
+ {
+ Family fam2 = World.world.families.newFamily(alpha, alpha.current_tile, null);
+ TrackFamily(fam2, families);
+ InterestDropLog.Clear();
+ InterestRegistry.ForceExpireContaining("alpha:", Time.unscaledTime);
+ fam2.setAlpha(alpha, true);
+ if (InterestRegistry.CountKeysContaining("alpha:") > 0)
+ {
+ Claim("become_alpha", "id", "Family.setAlpha", "busy_bypass=ForceLiveEventFeeds");
+ }
+ else
+ {
+ Claim("become_alpha", "unsupported", "Family.setAlpha", "no_interest_key");
+ }
+ }
+ catch (Exception ex)
+ {
+ Fail(fails, "become_alpha", "exception:" + ex.Message);
+ }
+
+ // --- baby_created ---
+ Actor mother = Track(SpawnHumanNear(a), spawned);
+ AgeOutOfSpawnWindow(mother);
+ if (mother != null && mother.asset != null && mother.current_tile != null)
+ {
+ InterestDropLog.Clear();
+ InterestRegistry.ForceExpireContaining("rel:baby_created", Time.unscaledTime);
+ try
+ {
+ ActorData data = new ActorData();
+ data.asset_id = mother.asset.id;
+ data.id = World.world.map_stats.getNextId("unit");
+ data.created_time = World.world.getCurWorldTime();
+ data.x = mother.current_tile.x;
+ data.y = mother.current_tile.y;
+ try
+ {
+ data.parent_id_1 = mother.data.id;
+ }
+ catch
+ {
+ // ignore
+ }
+
+ City city = null;
+ try
+ {
+ city = mother.city;
+ }
+ catch
+ {
+ city = null;
+ }
+
+ Actor baby = World.world.units.createBabyActorFromData(
+ data, mother.current_tile, city);
+ Track(baby, spawned);
+ if (baby != null
+ && baby.isAlive()
+ && InterestRegistry.CountKeysContaining("rel:baby_created") > 0)
+ {
+ Claim(
+ "baby_created",
+ "id",
+ "createBabyActorFromData",
+ "busy_bypass=ForceLiveEventFeeds");
+ }
+ else if (baby != null && baby.isAlive())
+ {
+ Fail(fails, "baby_created", "alive_but_no_key");
+ }
+ else
+ {
+ Claim(
+ "baby_created",
+ "unsupported",
+ "createBabyActorFromData",
+ "baby_null_or_dead");
+ }
+ }
+ catch (Exception ex)
+ {
+ Fail(fails, "baby_created", "exception:" + ex.Message);
+ }
+ }
+ else
+ {
+ Claim("baby_created", "unsupported", "createBabyActorFromData", "no_mother");
+ }
+ }
+ finally
+ {
+ CleanupTracked(spawned, families);
+ AgentHarness.ForceLiveEventFeeds = prevForce;
+ AgentHarness.ForceRelationshipFeeds = prevRel;
+ }
+
+ result.Failures = fails.Count;
+ string summary = EventLiveCoverageLedger.Write(outputDirectory);
+ result.Passed = fails.Count == 0;
+ result.Detail = summary
+ + (fails.Count > 0
+ ? (" first=" + fails[0] + (fails.Count > 1 ? " +" + (fails.Count - 1) : ""))
+ : "");
+ return result;
+ }
+
+ private static void Claim(string id, string level, string pipeline, string notes)
+ {
+ EventLiveCoverageLedger.Claim("relationship", id, level, pipeline, notes);
+ }
+
+ private static void Fail(List fails, string id, string notes)
+ {
+ fails.Add(id + " " + notes);
+ EventLiveCoverageLedger.Claim("relationship", id, "fail", "relationship_live", notes);
+ }
+
+ private static bool DropMentions(string reason, string needle)
+ {
+ List lines = InterestDropLog.Snapshot(16);
+ for (int i = 0; i < lines.Count; i++)
+ {
+ string line = lines[i] ?? "";
+ if (line.IndexOf(reason, StringComparison.OrdinalIgnoreCase) < 0)
+ {
+ continue;
+ }
+
+ if (string.IsNullOrEmpty(needle)
+ || line.IndexOf(needle, StringComparison.OrdinalIgnoreCase) >= 0)
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static Actor Track(Actor actor, List spawned)
+ {
+ if (actor != null)
+ {
+ spawned.Add(actor);
+ }
+
+ return actor;
+ }
+
+ private static void TrackFamily(Family family, List families)
+ {
+ if (family != null && !families.Contains(family))
+ {
+ families.Add(family);
+ }
+ }
+
+ private static void SyncFamilyUnits()
+ {
+ try
+ {
+ AccessTools.Method(typeof(FamilyManager), "updateDirtyUnits")
+ ?.Invoke(World.world?.families, null);
+ }
+ catch
+ {
+ // ignore
+ }
+ }
+
+ private static int CountFamilyMembers(Family family)
+ {
+ if (family == null)
+ {
+ return 0;
+ }
+
+ try
+ {
+ if (family.units != null && family.units.Count > 0)
+ {
+ return family.units.Count;
+ }
+ }
+ catch
+ {
+ // fall through to scan
+ }
+
+ int n = 0;
+ try
+ {
+ List alive = World.world?.units?.units_only_alive;
+ if (alive == null)
+ {
+ return 0;
+ }
+
+ for (int i = 0; i < alive.Count; i++)
+ {
+ Actor unit = alive[i];
+ try
+ {
+ if (unit != null && unit.isAlive() && unit.hasFamily() && unit.family == family)
+ {
+ n++;
+ }
+ }
+ catch
+ {
+ // ignore
+ }
+ }
+ }
+ catch
+ {
+ // ignore
+ }
+
+ return n;
+ }
+
+ ///
+ /// Detach every living member. Prevents zombie family refs that NRE
+ /// Family.isFull via null getActorAsset.
+ ///
+ private static void DetachAllMembers(Family family)
+ {
+ DetachUnitsStillHoldingFamily(family);
+ }
+
+ private static void DetachUnitsStillHoldingFamily(Family family)
+ {
+ if (family == null)
+ {
+ return;
+ }
+
+ var toDetach = new List(16);
+ try
+ {
+ List alive = World.world?.units?.units_only_alive;
+ if (alive != null)
+ {
+ for (int i = 0; i < alive.Count; i++)
+ {
+ Actor unit = alive[i];
+ try
+ {
+ if (unit != null && unit.isAlive() && unit.hasFamily() && unit.family == family)
+ {
+ toDetach.Add(unit);
+ }
+ }
+ catch
+ {
+ // ignore
+ }
+ }
+ }
+ }
+ catch
+ {
+ // ignore
+ }
+
+ for (int i = 0; i < toDetach.Count; i++)
+ {
+ try
+ {
+ toDetach[i].setFamily(null);
+ }
+ catch
+ {
+ // ignore
+ }
+ }
+ }
+
+ private static void CleanupTracked(List spawned, List families)
+ {
+ // Families first (detach then remove).
+ for (int i = families.Count - 1; i >= 0; i--)
+ {
+ Family f = families[i];
+ try
+ {
+ DetachAllMembers(f);
+ World.world?.families?.removeObject(f);
+ }
+ catch
+ {
+ // ignore
+ }
+ }
+
+ families.Clear();
+
+ // Clear lover links on harness actors so AI does not keep odd pairs.
+ for (int i = 0; i < spawned.Count; i++)
+ {
+ Actor a = spawned[i];
+ try
+ {
+ if (a == null || !a.isAlive())
+ {
+ continue;
+ }
+
+ if (a.hasFamily())
+ {
+ a.setFamily(null);
+ }
+
+ if (a.hasLover())
+ {
+ a.setLover(null);
+ }
+ }
+ catch
+ {
+ // ignore
+ }
+ }
+ }
+
+ private static void TryMoveNear(Actor mover, Actor near)
+ {
+ if (mover == null || near == null)
+ {
+ return;
+ }
+
+ try
+ {
+ WorldTile tile = near.current_tile;
+ if (tile != null)
+ {
+ mover.current_position = near.current_position;
+ // Best-effort; tile teleport APIs vary by version.
+ AccessTools.Method(typeof(Actor), "setCurrentTile", new[] { typeof(WorldTile) })
+ ?.Invoke(mover, new object[] { tile });
+ }
+ }
+ catch
+ {
+ // ignore
+ }
+ }
+
+ private static Actor SpawnHumanNear(Actor near)
+ {
+ try
+ {
+ WorldTile tile = near != null ? near.current_tile : null;
+ if (tile == null)
+ {
+ Actor anchor = WorldActivityScanner.FindNearestAliveUnit(
+ near != null ? near.current_position : Vector3.zero, 500f);
+ tile = anchor != null ? anchor.current_tile : null;
+ }
+
+ if (tile == null)
+ {
+ return null;
+ }
+
+ Actor spawned = World.world.units.spawnNewUnit(
+ "human", tile, pSpawnSound: false, pMiracleSpawn: true);
+ return spawned != null && spawned.isAlive() ? spawned : null;
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ private static void AgeOutOfSpawnWindow(Actor actor)
+ {
+ if (actor?.data == null)
+ {
+ return;
+ }
+
+ try
+ {
+ double now = World.world != null ? World.world.getCurWorldTime() : 100;
+ actor.data.created_time = now - 20.0;
+ }
+ catch
+ {
+ // ignore
+ }
+ }
+}
diff --git a/IdleSpectator/Events/Feeds/BookInterestFeed.cs b/IdleSpectator/Events/Feeds/BookInterestFeed.cs
index c580fe3..74ff51d 100644
--- a/IdleSpectator/Events/Feeds/BookInterestFeed.cs
+++ b/IdleSpectator/Events/Feeds/BookInterestFeed.cs
@@ -7,7 +7,7 @@ public static class BookInterestFeed
{
public static void Emit(string bookTypeId, Actor subject, string phase)
{
- if (AgentHarness.Busy)
+ if (!AgentHarness.LiveFeedsAllowed)
{
return;
}
diff --git a/IdleSpectator/Events/Feeds/DeferredInterestFeed.cs b/IdleSpectator/Events/Feeds/DeferredInterestFeed.cs
index 641eb09..ff72195 100644
--- a/IdleSpectator/Events/Feeds/DeferredInterestFeed.cs
+++ b/IdleSpectator/Events/Feeds/DeferredInterestFeed.cs
@@ -41,7 +41,7 @@ public static class DeferredInterestFeed
public static void EmitPower(string powerId, Vector3 position, Actor nearUnit)
{
DiscreteEventEntry entry = EventCatalog.Power.GetOrFallback(powerId);
- if (AgentHarness.Busy)
+ if (!AgentHarness.LiveFeedsAllowed)
{
InterestDropLog.Record("busy", "power");
return;
@@ -111,7 +111,7 @@ public static class DeferredInterestFeed
public static void EmitWorldLaw(string lawId, Vector3 position)
{
- if (AgentHarness.Busy || position == Vector3.zero)
+ if (!AgentHarness.LiveFeedsAllowed || position == Vector3.zero)
{
return;
}
@@ -137,7 +137,7 @@ public static class DeferredInterestFeed
public static void EmitBiome(string biomeId, Vector3 position)
{
- if (AgentHarness.Busy || position == Vector3.zero)
+ if (!AgentHarness.LiveFeedsAllowed || position == Vector3.zero)
{
return;
}
@@ -169,7 +169,7 @@ public static class DeferredInterestFeed
Actor related,
bool locationOnly)
{
- if (AgentHarness.Busy || lookup == null)
+ if (!AgentHarness.LiveFeedsAllowed || lookup == null)
{
return;
}
diff --git a/IdleSpectator/Events/Feeds/InterestFeeds.cs b/IdleSpectator/Events/Feeds/InterestFeeds.cs
index 248af5d..84f1f54 100644
--- a/IdleSpectator/Events/Feeds/InterestFeeds.cs
+++ b/IdleSpectator/Events/Feeds/InterestFeeds.cs
@@ -89,7 +89,7 @@ public static class InterestFeeds
public static void OnWorldLogMessage(WorldLogMessage message)
{
- if (message == null || AgentHarness.Busy)
+ if (message == null || !AgentHarness.LiveFeedsAllowed)
{
return;
}
@@ -303,7 +303,7 @@ public static class InterestFeeds
public static void OnActivityNote(Actor actor, string taskOrBeat, bool hot)
{
- if (actor == null || !actor.isAlive() || AgentHarness.Busy || !hot)
+ if (actor == null || !actor.isAlive() || !AgentHarness.LiveFeedsAllowed || !hot)
{
return;
}
@@ -368,7 +368,7 @@ public static class InterestFeeds
return;
}
- if (AgentHarness.Busy)
+ if (!AgentHarness.LiveFeedsAllowed)
{
return;
}
@@ -492,7 +492,7 @@ public static class InterestFeeds
return;
}
- if (AgentHarness.Busy)
+ if (!AgentHarness.LiveFeedsAllowed)
{
return;
}
@@ -547,7 +547,7 @@ public static class InterestFeeds
return;
}
- if (AgentHarness.Busy)
+ if (!AgentHarness.LiveFeedsAllowed)
{
return;
}
@@ -615,7 +615,7 @@ public static class InterestFeeds
return;
}
- if (AgentHarness.Busy && occ.SourceHook != HappinessSourceHook.Harness)
+ if (!AgentHarness.LiveFeedsAllowed && occ.SourceHook != HappinessSourceHook.Harness)
{
InterestDropLog.Record("busy", occ.EffectId ?? "");
return;
@@ -859,7 +859,7 @@ public static class InterestFeeds
}
_lastScannerAt = now;
- if (AgentHarness.Busy)
+ if (!AgentHarness.LiveFeedsAllowed)
{
return;
}
diff --git a/IdleSpectator/Events/Feeds/MetaInterestFeed.cs b/IdleSpectator/Events/Feeds/MetaInterestFeed.cs
index dac7cfd..d18a6c9 100644
--- a/IdleSpectator/Events/Feeds/MetaInterestFeed.cs
+++ b/IdleSpectator/Events/Feeds/MetaInterestFeed.cs
@@ -7,7 +7,7 @@ public static class MetaInterestFeed
{
public static void EmitEra(string eraId)
{
- if (AgentHarness.Busy)
+ if (!AgentHarness.LiveFeedsAllowed)
{
return;
}
@@ -72,7 +72,7 @@ public static class MetaInterestFeed
public static void EmitMeta(string eventId, string category, float strength, Actor anchor, string label)
{
- if (AgentHarness.Busy)
+ if (!AgentHarness.LiveFeedsAllowed)
{
return;
}
diff --git a/IdleSpectator/Events/Feeds/PlotInterestFeed.cs b/IdleSpectator/Events/Feeds/PlotInterestFeed.cs
index ddd26bb..f6950ae 100644
--- a/IdleSpectator/Events/Feeds/PlotInterestFeed.cs
+++ b/IdleSpectator/Events/Feeds/PlotInterestFeed.cs
@@ -9,7 +9,7 @@ public static class PlotInterestFeed
{
public static void Emit(string plotAssetId, Actor subject, string phase)
{
- if (AgentHarness.Busy)
+ if (!AgentHarness.LiveFeedsAllowed)
{
return;
}
@@ -55,7 +55,7 @@ public static class PlotInterestFeed
public static void Tick()
{
- if (AgentHarness.Busy)
+ if (!AgentHarness.LiveFeedsAllowed)
{
return;
}
diff --git a/IdleSpectator/Events/Feeds/RelationshipInterestFeed.cs b/IdleSpectator/Events/Feeds/RelationshipInterestFeed.cs
index 28ef84b..8546e4e 100644
--- a/IdleSpectator/Events/Feeds/RelationshipInterestFeed.cs
+++ b/IdleSpectator/Events/Feeds/RelationshipInterestFeed.cs
@@ -11,7 +11,7 @@ public static class RelationshipInterestFeed
public static void Emit(string eventId, Actor subject, Actor related, string labelOverride = null)
{
- if ((AgentHarness.Busy && !AgentHarness.ForceRelationshipFeeds)
+ if (!AgentHarness.LiveFeedsAllowed
|| subject == null
|| !subject.isAlive())
{
@@ -94,7 +94,7 @@ public static class RelationshipInterestFeed
public static void EmitFamily(string eventId, object family, Actor anchor)
{
- if (AgentHarness.Busy && !AgentHarness.ForceRelationshipFeeds)
+ if (!AgentHarness.LiveFeedsAllowed)
{
return;
}
diff --git a/IdleSpectator/Events/Feeds/WarInterestFeed.cs b/IdleSpectator/Events/Feeds/WarInterestFeed.cs
index 4fdb0dd..5409030 100644
--- a/IdleSpectator/Events/Feeds/WarInterestFeed.cs
+++ b/IdleSpectator/Events/Feeds/WarInterestFeed.cs
@@ -13,7 +13,7 @@ public static class WarInterestFeed
{
public static void EmitFromWarObject(object war, string phase = "active")
{
- if (war == null || AgentHarness.Busy)
+ if (war == null || !AgentHarness.LiveFeedsAllowed)
{
return;
}
@@ -23,7 +23,7 @@ public static class WarInterestFeed
public static void Tick()
{
- if (AgentHarness.Busy)
+ if (!AgentHarness.LiveFeedsAllowed)
{
return;
}
diff --git a/IdleSpectator/Events/Patches/BoatEventPatches.cs b/IdleSpectator/Events/Patches/BoatEventPatches.cs
index 07a509f..dfaa5fd 100644
--- a/IdleSpectator/Events/Patches/BoatEventPatches.cs
+++ b/IdleSpectator/Events/Patches/BoatEventPatches.cs
@@ -42,7 +42,7 @@ public static class BoatEventPatches
private static void EmitBoat(string eventId, float strength, Actor actor, string label)
{
- if (AgentHarness.Busy)
+ if (!AgentHarness.LiveFeedsAllowed)
{
return;
}
diff --git a/IdleSpectator/Events/Patches/BuildingEventPatches.cs b/IdleSpectator/Events/Patches/BuildingEventPatches.cs
index 78f0c7f..9713ac1 100644
--- a/IdleSpectator/Events/Patches/BuildingEventPatches.cs
+++ b/IdleSpectator/Events/Patches/BuildingEventPatches.cs
@@ -44,7 +44,7 @@ public static class BuildingEventPatches
[HarmonyPostfix]
public static void PostfixStartDestroy(Building __instance)
{
- if (__instance == null || AgentHarness.Busy)
+ if (__instance == null || !AgentHarness.LiveFeedsAllowed)
{
return;
}
@@ -52,11 +52,17 @@ public static class BuildingEventPatches
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)
+ try
{
- pos = v;
+ WorldTile tile = __instance.current_tile;
+ if (tile != null)
+ {
+ pos = tile.posV3;
+ }
+ }
+ catch
+ {
+ pos = Vector3.zero;
}
string buildingName = "";
@@ -116,7 +122,7 @@ public static class BuildingEventPatches
private static void Emit(string eventId, float strength, Actor actor, string label)
{
- if (AgentHarness.Busy)
+ if (!AgentHarness.LiveFeedsAllowed)
{
return;
}
diff --git a/IdleSpectator/Events/Patches/DeferredEventPatches.cs b/IdleSpectator/Events/Patches/DeferredEventPatches.cs
index a79a0e5..d2a5e0b 100644
--- a/IdleSpectator/Events/Patches/DeferredEventPatches.cs
+++ b/IdleSpectator/Events/Patches/DeferredEventPatches.cs
@@ -18,7 +18,7 @@ public static class DeferredEventPatches
[HarmonyPostfix]
public static void PostfixGenerateItem(object __result, object[] __args)
{
- if (AgentHarness.Busy)
+ if (!AgentHarness.LiveFeedsAllowed)
{
return;
}
@@ -50,7 +50,7 @@ public static class DeferredEventPatches
public static void PostfixNewItem(object __result)
{
// newItem may lack an actor; skip without a subject rather than invent one.
- if (AgentHarness.Busy || __result == null)
+ if (!AgentHarness.LiveFeedsAllowed || __result == null)
{
return;
}
@@ -93,7 +93,7 @@ public static class DeferredEventPatches
public static void PostfixTryToCastSpell(AttackData pData, bool __result)
{
_spellCastInFlight = false;
- if (!__result || AgentHarness.Busy)
+ if (!__result || !AgentHarness.LiveFeedsAllowed)
{
_spellCastId = null;
return;
@@ -147,7 +147,7 @@ public static class DeferredEventPatches
[HarmonyPostfix]
public static void PostfixClickedFinal(Vector2Int pPos, GodPower pPower)
{
- if (AgentHarness.Busy)
+ if (!AgentHarness.LiveFeedsAllowed)
{
return;
}
@@ -210,7 +210,7 @@ public static class DeferredEventPatches
[HarmonyPostfix]
public static void PostfixWorldLawEnable(string pID)
{
- if (AgentHarness.Busy || string.IsNullOrEmpty(pID))
+ if (!AgentHarness.LiveFeedsAllowed || string.IsNullOrEmpty(pID))
{
return;
}
@@ -222,7 +222,7 @@ public static class DeferredEventPatches
[HarmonyPostfix]
public static void PostfixWorldLawToggle(WorldLawAsset __instance, bool pState)
{
- if (!pState || AgentHarness.Busy || __instance == null)
+ if (!pState || !AgentHarness.LiveFeedsAllowed || __instance == null)
{
return;
}
@@ -240,7 +240,7 @@ public static class DeferredEventPatches
[HarmonyPostfix]
public static void PostfixMutateFrom(Subspecies __instance)
{
- if (AgentHarness.Busy || __instance == null)
+ if (!AgentHarness.LiveFeedsAllowed || __instance == null)
{
return;
}
@@ -269,7 +269,7 @@ public static class DeferredEventPatches
[HarmonyPostfix]
public static void PostfixSetDecisionCooldown(Actor __instance, object[] __args)
{
- if (__instance == null || !__instance.isAlive() || AgentHarness.Busy)
+ if (__instance == null || !__instance.isAlive() || !AgentHarness.LiveFeedsAllowed)
{
return;
}
@@ -299,7 +299,7 @@ public static class DeferredEventPatches
[HarmonyPostfix]
public static void PostfixPhenotype(Actor __instance)
{
- if (__instance == null || !__instance.isAlive() || AgentHarness.Busy)
+ if (__instance == null || !__instance.isAlive() || !AgentHarness.LiveFeedsAllowed)
{
return;
}
@@ -348,7 +348,7 @@ public static class DeferredEventPatches
private static void EmitSubspecies(object subspecies, string fallbackId)
{
- if (AgentHarness.Busy || subspecies == null)
+ if (!AgentHarness.LiveFeedsAllowed || subspecies == null)
{
return;
}
diff --git a/IdleSpectator/Events/Patches/RelationshipEventPatches.cs b/IdleSpectator/Events/Patches/RelationshipEventPatches.cs
index adc31f9..92767bd 100644
--- a/IdleSpectator/Events/Patches/RelationshipEventPatches.cs
+++ b/IdleSpectator/Events/Patches/RelationshipEventPatches.cs
@@ -1,4 +1,5 @@
using System;
+using System.Collections.Generic;
using ai.behaviours;
using HarmonyLib;
@@ -12,6 +13,31 @@ namespace IdleSpectator;
[HarmonyPatch]
public static class RelationshipEventPatches
{
+ ///
+ /// Vanilla Family.isFull NREs when getActorAsset() is null (broken/zombie family).
+ /// Treat as full so getNearbyFamily skips instead of spamming AI exceptions.
+ ///
+ [HarmonyPatch(typeof(Family), nameof(Family.isFull))]
+ [HarmonyPrefix]
+ public static bool PrefixIsFull(Family __instance, ref bool __result)
+ {
+ try
+ {
+ if (__instance == null || __instance.getActorAsset() == null)
+ {
+ __result = true;
+ return false;
+ }
+ }
+ catch
+ {
+ __result = true;
+ return false;
+ }
+
+ return true;
+ }
+
[HarmonyPatch(typeof(Actor), nameof(Actor.setLover))]
[HarmonyPostfix]
public static void PostfixSetLover(Actor __instance, Actor pActor)
@@ -127,11 +153,103 @@ public static class RelationshipEventPatches
RelationshipInterestFeed.EmitFamily("new_family", __result, anchor);
}
+ private static Actor _familyRemoveAnchor;
+
+ ///
+ /// Emit before remove tears membership down. Postfix is too late for member lookup.
+ ///
[HarmonyPatch(typeof(FamilyManager), "removeObject", new Type[] { typeof(Family) })]
- [HarmonyPostfix]
- public static void PostfixRemoveFamily(Family pObject)
+ [HarmonyPrefix]
+ public static void PrefixRemoveFamily(Family pObject)
{
- RelationshipInterestFeed.EmitFamily("family_removed", pObject, null);
+ if (pObject == null)
+ {
+ return;
+ }
+
+ Actor anchor = _familyRemoveAnchor;
+ _familyRemoveAnchor = null;
+ if (anchor == null || !anchor.isAlive())
+ {
+ anchor = ResolveFamilyRemoveAnchor(pObject);
+ }
+
+ if (anchor != null)
+ {
+ RelationshipInterestFeed.EmitFamily("family_removed", pObject, anchor);
+ }
+ }
+
+ /// Optional harness/pre-call hint when membership lists are still dirty.
+ public static void NoteFamilyRemoveAnchor(Actor anchor)
+ {
+ _familyRemoveAnchor = anchor != null && anchor.isAlive() ? anchor : null;
+ }
+
+ private static Actor ResolveFamilyRemoveAnchor(Family family)
+ {
+ try
+ {
+ if (family.units != null)
+ {
+ for (int i = 0; i < family.units.Count; i++)
+ {
+ Actor unit = family.units[i];
+ if (unit != null && unit.isAlive())
+ {
+ return unit;
+ }
+ }
+ }
+ }
+ catch
+ {
+ // fall through
+ }
+
+ try
+ {
+ Actor founder = family.getFounderFirst() ?? family.getFounderSecond();
+ if (founder != null && founder.isAlive())
+ {
+ return founder;
+ }
+ }
+ catch
+ {
+ // fall through
+ }
+
+ try
+ {
+ List alive = World.world?.units?.units_only_alive;
+ if (alive == null)
+ {
+ return null;
+ }
+
+ for (int i = 0; i < alive.Count; i++)
+ {
+ Actor unit = alive[i];
+ try
+ {
+ if (unit != null && unit.isAlive() && unit.hasFamily() && unit.family == family)
+ {
+ return unit;
+ }
+ }
+ catch
+ {
+ // keep scanning
+ }
+ }
+ }
+ catch
+ {
+ // ignore
+ }
+
+ return null;
}
[HarmonyPatch(typeof(ActorManager), "createBabyActorFromData")]
@@ -198,6 +316,7 @@ public static class RelationshipEventPatches
public static void PostfixFamilyJoin(Actor pActor, BehResult __result, EventOutcome.ActorFlags __state)
{
// Vanilla returns Continue even when getNearbyFamily was null - require GainedFamily.
+ // Emit is on setFamily(null→family) only when we choose; Beh path emits here.
EventObservation.ConfirmAfter(
"BehFamilyGroupJoin",
"family_group_join",
@@ -212,6 +331,45 @@ public static class RelationshipEventPatches
ActorRelation.ResolvePackRelated(pActor)));
}
+ ///
+ /// Leave is setFamily(null) (Beh leave and any other clear). Prefer this over
+ /// Beh Continue alone so harness and vanilla share one state sensor.
+ ///
+ [HarmonyPatch(typeof(Actor), nameof(Actor.setFamily))]
+ [HarmonyPrefix]
+ public static void PrefixSetFamily(Actor __instance, Family pObject, out Family __state)
+ {
+ __state = null;
+ try
+ {
+ if (__instance != null && __instance.hasFamily())
+ {
+ __state = __instance.family;
+ }
+ }
+ catch
+ {
+ __state = null;
+ }
+
+ _ = pObject;
+ }
+
+ [HarmonyPatch(typeof(Actor), nameof(Actor.setFamily))]
+ [HarmonyPostfix]
+ public static void PostfixSetFamily(Actor __instance, Family pObject, Family __state)
+ {
+ if (pObject != null || __state == null || !EventOutcome.IsLiving(__instance))
+ {
+ return;
+ }
+
+ RelationshipInterestFeed.Emit(
+ "family_group_leave",
+ __instance,
+ ActorRelation.ResolvePackRelated(__instance));
+ }
+
[HarmonyPatch(typeof(BehFamilyGroupLeave), nameof(BehFamilyGroupLeave.execute))]
[HarmonyPrefix]
public static void PrefixFamilyLeave(Actor pActor, out EventOutcome.ActorFlags __state)
@@ -223,6 +381,7 @@ public static class RelationshipEventPatches
[HarmonyPostfix]
public static void PostfixFamilyLeave(Actor pActor, BehResult __result, EventOutcome.ActorFlags __state)
{
+ // Emit already fired from setFamily(null). Only log unconfirmed Continues.
EventObservation.ConfirmAfter(
"BehFamilyGroupLeave",
"family_group_leave",
@@ -231,10 +390,7 @@ public static class RelationshipEventPatches
__result,
(before, actor, result) =>
EventOutcome.BehaviourRan(result) && EventOutcome.LostFamily(before, actor),
- () => RelationshipInterestFeed.Emit(
- "family_group_leave",
- pActor,
- ActorRelation.ResolvePackRelated(pActor)));
+ () => { /* setFamily(null) already emitted */ });
}
///
diff --git a/IdleSpectator/Events/Patches/TraitEventPatches.cs b/IdleSpectator/Events/Patches/TraitEventPatches.cs
index 0d752b4..604ec6b 100644
--- a/IdleSpectator/Events/Patches/TraitEventPatches.cs
+++ b/IdleSpectator/Events/Patches/TraitEventPatches.cs
@@ -107,7 +107,7 @@ public static class TraitEventPatches
private static void Emit(string traitId, Actor actor, bool gained)
{
- if (AgentHarness.Busy)
+ if (!AgentHarness.LiveFeedsAllowed)
{
return;
}
diff --git a/IdleSpectator/Events/Patches/WarEventPatches.cs b/IdleSpectator/Events/Patches/WarEventPatches.cs
index 22324ac..2acb2ca 100644
--- a/IdleSpectator/Events/Patches/WarEventPatches.cs
+++ b/IdleSpectator/Events/Patches/WarEventPatches.cs
@@ -10,7 +10,7 @@ public static class WarEventPatches
[HarmonyPostfix]
public static void PostfixNewWar(War __result)
{
- if (__result == null || AgentHarness.Busy)
+ if (__result == null || !AgentHarness.LiveFeedsAllowed)
{
return;
}
@@ -29,7 +29,7 @@ public static class WarEventPatches
[HarmonyPostfix]
public static void PostfixEndWar(object[] __args)
{
- if (AgentHarness.Busy || __args == null || __args.Length == 0 || __args[0] == null)
+ if (!AgentHarness.LiveFeedsAllowed || __args == null || __args.Length == 0 || __args[0] == null)
{
return;
}
diff --git a/IdleSpectator/HarnessScenarios.cs b/IdleSpectator/HarnessScenarios.cs
index f55e490..74732a9 100644
--- a/IdleSpectator/HarnessScenarios.cs
+++ b/IdleSpectator/HarnessScenarios.cs
@@ -60,6 +60,14 @@ internal static class HarnessScenarios
return EventCoverage();
case "event_inject_coverage":
return EventInjectCoverage();
+ case "event_live_apply_loop":
+ return EventLiveApplyLoop();
+ case "event_live_relationship":
+ return EventLiveRelationship();
+ case "event_live_pipelines":
+ return EventLivePipelines();
+ case "event_live_coverage":
+ return EventLiveCoverage();
case "event_live_outcome_smoke":
return EventLiveOutcomeSmoke();
case "event_suite":
@@ -1363,11 +1371,85 @@ internal static class HarnessScenarios
};
}
+ ///
+ /// Phase 1: loop every camera-worthy happiness Signal + status gain through real apply APIs.
+ /// Writes event-live-coverage.tsv.
+ ///
+ private static List EventLiveApplyLoop()
+ {
+ return new List
+ {
+ Step("ela0", "dismiss_windows"),
+ Step("ela1", "wait_world"),
+ Step("ela2", "set_setting", expect: "enabled", value: "true"),
+ Step("ela3", "spawn", asset: "human"),
+ Step("ela4", "pick_unit", asset: "human"),
+ Step("ela5", "spectator", value: "off"),
+ Step("ela6", "spectator", value: "on"),
+ Step("ela7", "focus", asset: "auto"),
+ Step("ela8", "assert", expect: "event_live_apply_loop"),
+ Step("ela9", "assert", expect: "event_live_coverage"),
+ Step("ela10", "assert", expect: "no_bad"),
+ Step("ela99", "snapshot")
+ };
+ }
+
+ private static List EventLiveRelationship()
+ {
+ return new List
+ {
+ Step("elr0", "dismiss_windows"),
+ Step("elr1", "wait_world"),
+ Step("elr2", "set_setting", expect: "enabled", value: "true"),
+ Step("elr3", "spawn", asset: "human"),
+ Step("elr4", "pick_unit", asset: "human"),
+ Step("elr5", "spectator", value: "off"),
+ Step("elr6", "spectator", value: "on"),
+ Step("elr7", "focus", asset: "auto"),
+ Step("elr8", "assert", expect: "event_live_relationship"),
+ Step("elr9", "assert", expect: "event_live_coverage"),
+ Step("elr10", "assert", expect: "no_bad"),
+ Step("elr99", "snapshot")
+ };
+ }
+
+ private static List EventLivePipelines()
+ {
+ return new List
+ {
+ Step("elp0", "dismiss_windows"),
+ Step("elp1", "wait_world"),
+ Step("elp2", "set_setting", expect: "enabled", value: "true"),
+ Step("elp3", "spawn", asset: "human"),
+ Step("elp4", "pick_unit", asset: "human"),
+ Step("elp5", "spectator", value: "off"),
+ Step("elp6", "spectator", value: "on"),
+ Step("elp7", "focus", asset: "auto"),
+ Step("elp8", "assert", expect: "event_live_pipelines"),
+ Step("elp9", "assert", expect: "event_live_coverage"),
+ Step("elp10", "assert", expect: "no_bad"),
+ Step("elp99", "snapshot")
+ };
+ }
+
+ /// Phase 0: seed + write coverage ledger (fail only if any live_level=fail).
+ private static List EventLiveCoverage()
+ {
+ return new List
+ {
+ Step("elc0", "wait_world"),
+ Step("elc1", "assert", expect: "event_live_coverage"),
+ Step("elc2", "snapshot")
+ };
+ }
+
private static List EventSuite()
{
return new List
{
Nested("es_coverage", "event_coverage"),
+ Nested("es_apply", "event_live_apply_loop"),
+ Nested("es_rel", "event_live_relationship"),
Nested("es_live", "event_live_outcome_smoke"),
};
}
diff --git a/IdleSpectator/mod.json b/IdleSpectator/mod.json
index f13e56e..0b2ddbe 100644
--- a/IdleSpectator/mod.json
+++ b/IdleSpectator/mod.json
@@ -1,7 +1,7 @@
{
"name": "IdleSpectator",
"author": "dazed",
- "version": "0.25.17",
+ "version": "0.25.29",
"description": "AFK Idle Spectator (I) + Lore (L). Catalog-wide event inject E2E suite.",
"GUID": "com.dazed.idlespectator"
}
diff --git a/docs/event-e2e.md b/docs/event-e2e.md
index 397a6bf..72f875e 100644
--- a/docs/event-e2e.md
+++ b/docs/event-e2e.md
@@ -2,40 +2,88 @@
Goal: every catalog event is covered by automation that stays cheap when new ids are added.
+Live accuracy roadmap: [`event-live-verification-plan.md`](event-live-verification-plan.md).
+
+Observe → confirm → emit: [`event-observation.md`](event-observation.md).
+
## Tracks
| Track | Scenario | What it proves | How new events join |
|-------|----------|----------------|---------------------|
| **Inventory audits** | nested in `event_coverage` | Live library vs authored catalog | Add JSON row / live seed; audit fails until matched |
| **A inject coverage** | `event_inject_coverage` / `ec30` | Every **camera-worthy** id can register an interest key + label | Auto via `EventCatalog.*.AuthoredIds` |
-| **Live outcome** | `event_live_outcome_smoke` | Real API/Beh + `EventOutcome` confirm (curated) | Add a deterministic harness step when a patch exists |
+| **Live apply loop** | `event_live_apply_loop` | Happiness Signal + status gains via real apply | Auto loop; ledger `id` / `id_drop` / `fail` |
+| **Live relationship** | `event_live_relationship` | Vanilla setters/Beh + join negative; leave/`family_removed`/baby live fires | Shared runner + ledger claims |
+| **Live pipelines** | `event_live_pipelines` | Traits/decisions/WorldLog/wars/eras/books/plots + building destroy; boat/combat best-effort (libraries `unsupported`) | Auto loops + honest gaps |
+| **Live outcome smoke** | `event_live_outcome_smoke` | Short family + sample apply | Curated |
+| **Live coverage ledger** | `event_live_coverage` | Honest `none|feed|pipeline|id_drop|id|…` TSV | Seeded from catalogs; claimed by live runners |
-Full suite: `event_suite` = `event_coverage` + `event_live_outcome_smoke`.
-Regression nests `event_suite`.
+## Suite split (weight-aware)
+
+| Gate | Includes | When |
+|------|----------|------|
+| `event_suite` | audits + inject + apply loop + relationship + outcome smoke | Day-to-day event gate |
+| `regression` | `event_suite` + `event_live_pipelines` (+ other product smokes) | Full gate |
+| `critical_smoke` ×3 | Spectator/settings/tip/exit | PR gate |
+
+`event_live_pipelines` stays **regression-only**.
+
+Flake budget is proven (`--repeat 3` PASS); it remains heavier than day-to-day.
+
+## Coverage levels
+
+Recorded in `.harness/event-live-coverage.tsv` (gitignored; regenerated each live run).
+
+| Level | Meaning | Verified? |
+|-------|---------|-----------|
+| `none` | Inject only; no live fire this run | No |
+| `feed` | Production feed entry with synthetic payload | Partial |
+| `pipeline` | Vanilla fire proved the shared hook family; this id may not have fired | Partial |
+| `id_drop` | Real fire; authored drop matched | Yes (accurate drop) |
+| `id` | Real fire + expected note/interest | Yes |
+| `unsupported` | No deterministic fire API; reason in `notes` | Honest gap |
+| `fail` | Unexpected miss/drop | Broken |
+
+Inject / `domain_feed` never writes a live level other than leaving `none`.
+
+## Busy policy
+
+Most feeds early-out on `AgentHarness.Busy`.
+
+Live runners set `ForceLiveEventFeeds` (relationship also sets `ForceRelationshipFeeds`).
+
+`LiveFeedsAllowed` = `!Busy || ForceLiveEventFeeds || ForceRelationshipFeeds`.
+
+Ledger `notes` should record `busy_bypass=ForceLiveEventFeeds` (or the specific flag) when used.
## Commands
```bash
./scripts/harness-run.sh event_inject_coverage
-./scripts/harness-run.sh event_live_outcome_smoke
+./scripts/harness-run.sh event_live_apply_loop
+./scripts/harness-run.sh event_live_relationship
+./scripts/harness-run.sh event_live_pipelines
./scripts/harness-run.sh event_suite
./scripts/harness-run.sh regression
```
-Artifact: `IdleSpectator/.harness/event-inject-coverage.tsv`
-(`domain`, `id`, `camera`, `result`, `key`, `notes`).
+Artifacts (under `IdleSpectator/.harness/`, gitignored):
+
+- `event-inject-coverage.tsv` - inject wiring
+- `event-live-coverage.tsv` - **source of truth for live proof**
+- `event-trigger-audit.tsv` - design / call-site audit (not live proof)
## Design rules
-1. **Do not** hand-write one harness step per catalog id for inject - loop `AuthoredIds`.
-2. B-only rows (`CreatesInterest=false`, Ambient happiness) are `skip_b`, not failures.
-3. Inject coverage ≠ live outcome. Inject proves wiring; live proves confirm predicates.
-4. Expand live outcome slowly (family, status apply, happiness apply, …) as wrappers exist.
-5. Libraries: only camera-worthy live-seeded ids are injected (can be large; Signal-gated).
-6. After each successful inject, remove the key from `InterestRegistry`. The registry caps at 96 candidates; leaving them in causes false `register_failed` on later ids.
+1. Loop `AuthoredIds` - no per-id harness steps for inject/apply/traits.
+2. Inject ≠ live. `domain_feed` is never live.
+3. `ForceLiveEventFeeds` / `LiveFeedsAllowed` bypass Busy during live runners only.
+4. Coverage levels must not over-claim (see plan).
+5. Registry max-96: remove/clear during mass live loops so trim does not false-fail.
+6. Assert fails on `fail > 0`; do not fail on camera `none` until a budget is explicitly tightened.
## Code
-- `EventInjectCoverageHarness.Run` - data-driven register loop
-- Assert expect `event_inject_coverage` in `AgentHarness`
-- Observe→confirm docs: `event-observation.md`
+- `EventInjectCoverageHarness`, `EventLiveApplyLoopHarness`, `EventLiveRelationshipHarness`, `EventLivePipelinesHarness`
+- `EventLiveCoverageLedger`
+- Asserts: `event_inject_coverage`, `event_live_apply_loop`, `event_live_relationship`, `event_live_pipelines`, `event_live_coverage`
diff --git a/docs/event-live-verification-plan.md b/docs/event-live-verification-plan.md
new file mode 100644
index 0000000..130d66e
--- /dev/null
+++ b/docs/event-live-verification-plan.md
@@ -0,0 +1,214 @@
+# Event live verification plan
+
+Goal: every catalog event is **accurate and not bugged** in production, with honest coverage tracking.
+Inject stays; full sim grows by **hook family**, then loops ids where one apply API covers many.
+
+Do **not** over-claim. Weaker proof gets a weaker `live_level`.
+
+## What “works” means
+
+| Failure mode | Required proof |
+|--------------|----------------|
+| False positive (fires when claim is false) | Full sim + `EventOutcome` / drop log (negative case required) |
+| False negative (never fires) | Full sim through **vanilla** API (not inject / not `domain_feed`) |
+| Bad prose / wrong subject | Inject + reason; sim when label needs live fields |
+| Catalog drift | Inventory audits + inject |
+| Registry wiring | Inject (`event_inject_coverage`) |
+| Expected policy drop | Apply ran + authored drop reason matched (`id_drop`) |
+
+## Coverage levels
+
+Record per id in `.harness/event-live-coverage.tsv` (regenerated each run; **do not commit**).
+
+| Level | Meaning | Counts as “verified”? |
+|-------|---------|------------------------|
+| `none` | Inject only; no live fire this run | No |
+| `feed` | Called production feed entry with synthetic payload (skips Harmony / some Busy/world wiring) | Partial - glue inside feed only |
+| `pipeline` | **Vanilla** fire proved this id’s shared hook family; **this id was not necessarily fired** | Partial - siblings may share predicate/API |
+| `id_drop` | Real apply/fire ran; result was an **authored** drop (reason matched) | Yes for “accurate drop” |
+| `id` | This id went through real fire and asserted note/interest as expected | Yes |
+| `unsupported` | No deterministic fire API; explicit reason in `notes` | Honest gap |
+| `fail` | Fire attempted; unexpected miss/drop/wrong key | Broken |
+
+### Claiming rules (strict)
+
+1. **`id` / `id_drop`:** only the id that was actually fired this run.
+2. **`pipeline`:** only when (a) vanilla entry was used, and (b) siblings share the **same confirm predicate and same fire API**. Note must include `shared=`. Never mark an id `pipeline` merely because another id in the domain passed.
+3. **`feed`:** never promote to `pipeline` or `id` without a vanilla path.
+4. **`domain_feed` / inject:** never write any live level except leaving `none`.
+5. Ledger is **computed from the last run’s results** + catalog seed. No hand-maintained allowlist of “known smokes.”
+
+Ambient / `CreatesInterest=false`: not camera-live goals. Optional B-log loop later; default leave at `none` unless explicitly asserted as `id_drop` / ActivityLog-only.
+
+## Current baseline (done)
+
+- Inventory audits in `event_coverage`
+- `event_inject_coverage` - all camera-worthy ids register
+- `event_live_outcome_smoke` - family lovers/parent, one status, one happiness (proof exists; ledger will record from run, not a static list)
+- Observe→confirm for relationship Beh paths
+- Docs: `event-e2e.md`, `event-observation.md`
+
+## Phase 0 - Coverage ledger + Busy policy
+
+**Deliverable:** harness writes `.harness/event-live-coverage.tsv` every live/coverage run.
+
+Columns: `domain`, `id`, `camera`, `inject`, `live_level`, `pipeline`, `notes`
+
+- Seed rows from the same `AuthoredIds` loops as inject (`camera` / `inject` from that run or mirror rules)
+- `live_level` defaults to `none`; overwrite only from live runners’ results
+- Suite prints: `id=N id_drop=N pipeline=N feed=N none=N unsupported=N fail=N`
+- Fail the assert on `fail > 0` once live runners exist; do **not** fail on `none` until Phase 3 exit tightening
+
+### Busy bypass (required before Phase 2/3 interest asserts)
+
+Most feeds early-out on `AgentHarness.Busy`. Define per domain:
+
+| Domain | During live fire | Primary assert |
+|--------|------------------|----------------|
+| Happiness | `HappinessSourceHook.Harness` / existing apply path | Router notes + log (interest optional) |
+| Status | ActivityLog primary; optional force-interest flag later | Status counters + log |
+| Relationship | `ForceRelationshipFeeds` (already used for parent) | Interest key + drop log |
+| WorldLog / war / plot / book / trait / decision / … | Explicit `Force*Feeds` or harness “allow live feeds” mode | Interest key and/or domain log |
+
+Document the flag in `notes` when used (`busy_bypass=ForceRelationshipFeeds`).
+
+**Maintainability:** new catalog ids appear as `none` automatically until a runner claims them.
+
+## Phase 1 - Shared apply loops (highest confidence / hour)
+
+Loop camera-worthy ids through existing **vanilla-ish** apply APIs (`happiness_apply`, `status_apply`).
+
+### 1a Happiness
+
+For each **Signal** id in `EventCatalog.Happiness.AuthoredIds` (skip Ambient unless doing an optional B-log pass):
+
+1. Reset notes / clear subject counters
+2. `happiness_apply` with that id
+3. Classify:
+ - expected note / `LastEffectId` → `id`
+ - authored drop reason matched → `id_drop`
+ - else → `fail`
+4. `pipeline` field: `happiness_apply`
+
+Do **not** require camera interest while Busy unless a force path is on.
+
+### 1b Status
+
+For each `CreatesInterest` status (gains):
+
+1. Clear status + activity status counters
+2. `status_apply`
+3. Assert gain + log prose → `id`; authored skip/drop → `id_drop`; else `fail`
+4. `status_remove` / egg hatch as a **separate** pipeline (`status_loss` / `hatch`) with its own ids
+
+**Exit:** every camera-worthy happiness Signal and status gain id is `id`, `id_drop`, or `fail` (no silent `none` for those sets). Suite fails on `fail`.
+
+## Phase 2 - Confirm-gated relationship families
+
+One full sim **per `EventOutcome` predicate** (and separate setter smokes where there is no confirm helper).
+
+### Confirm smokes (predicate + negative case)
+
+| Pipeline | Sim | Assert |
+|----------|-----|--------|
+| `GainedLover` / bond | `happiness_bond_lovers` | notes + log; exercised id → `id` |
+| parent / `add_child` | `relationship_set_parent` | `rel:add_child`; id → `id` |
+| `LostLover` / `clear_lover` | bond then clear via game API | emit or authored drop |
+| `GainedFamily` / `family_group_join` | join when family exists **and** when not | key vs `outcome_unconfirmed` |
+| `LostFamily` / leave-remove | leave/remove when applicable | key vs unconfirmed |
+| `StillSeekingLover` / `find_lover` | seeking Beh path | confirm rules |
+
+Negative tests are mandatory for confirm predicates (especially join-with-no-family).
+
+### Setter smokes (no shared confirm sibling claim)
+
+`new_family`, `family_group_new`, `become_alpha`, `baby_created`, etc.: each needs its own vanilla fire for `id`. Do **not** mark them `pipeline` off `add_child` or join.
+
+Siblings may get `pipeline` only under Claiming rule 2 (`shared=`).
+
+**Exit:** every relationship camera id is `id`, `pipeline` (with valid `shared=`), `id_drop`, `unsupported`, or `fail` - never unexplained `none`.
+
+## Phase 3 - Remaining hook families
+
+Prefer **vanilla** mutation. If only feed-invoke is feasible, record `feed` and keep improving to vanilla.
+
+| Pipeline | Minimum vanilla sim | Id loop? | Also cover |
+|----------|---------------------|----------|------------|
+| WorldLog | Post/construct real world history message the patch sees | Yes if asset id is the parameter | |
+| Disasters | Through paired WorldLog / disaster trigger | Via paired worldLog | |
+| War types | `WarManager.newWar` (or equivalent) | Per type if API takes type | |
+| Eras | Force age change | Per era if possible | |
+| Plots | `setPlot` / finish; phases as separate pipelines | Per plot id if API takes id | |
+| Books | Create/burn vanilla path | Per book id if API takes id | |
+| Traits | `addTrait(id)` / remove | Yes | |
+| Decisions | Decision cooldown / real decision entry for interesting ids | Yes for camera decisions | |
+| Combat | Existing combat interest path / attack-target gate | Pipeline smoke + known gates | |
+| Boat | Unload/board path the patch uses | Pipeline smoke | |
+| Meta (city/kingdom) | Real civic create if possible; else `unsupported`/`feed` | |
+| Building | Destroy/damage path | Pipeline smoke | |
+| Libraries | Only with real gain/cast API; else `unsupported` | Loop when API exists | |
+
+**Rules:**
+
+1. Do not call `domain_feed` / inject and call it live.
+2. No fire API → `unsupported` + reason, not FAIL and not fake `pipeline`.
+3. After each fire: teardown (expire interest keys, clear statuses/wars/plots as needed).
+4. Assert: expected note/key **or** expected drop; flag unexpected `outcome_unconfirmed`.
+5. Busy bypass per Phase 0 table; record in `notes`.
+
+**Exit:** every camera domain has ids in `id` / `id_drop` / `pipeline` / `feed` / `unsupported` - zero unexplained `none` for camera rows. Then optionally tighten CI to fail if camera `none` + `feed` exceed a budget.
+
+## Phase 4 - Wire into suite (weight-aware)
+
+| Scenario | Role | When |
+|----------|------|------|
+| `event_inject_coverage` | Wiring gate | Always / `event_suite` |
+| `event_live_apply_loop` | Phase 1 happiness+status | `event_suite` + regression |
+| `event_live_relationship` | Phase 2 confirms + negatives | `event_suite` (stable) |
+| `event_live_pipelines` | Phase 3 | **Regression only** (flake budget proven 3/3) |
+| `event_live_coverage` | Write ledger + summary assert (`fail==0`) | End of whichever live scenarios ran |
+| `event_suite` | audits + inject + apply loop + relationship + outcome smoke | Day-to-day event gate |
+| `regression` | `event_suite` + `event_live_pipelines` (+ other product smokes) | Full gate |
+
+Keep `event_live_outcome_smoke` as a short smoke nested in `event_suite`.
+
+## Phase 5 - Docs sync
+
+- Update `docs/event-e2e.md` with levels, Busy policy, suite split
+- Update `event-observation.md` “adding an event”: name confirm predicate, fire API, and expected live level
+- **Source of truth:** harness-generated `event-live-coverage.tsv` for live proof; `event-trigger-audit.tsv` stays design/call-site audit - do not duplicate `live_verified` into both unless generating one from the other
+
+## Implementation status
+
+| Phase | Status |
+|-------|--------|
+| 0 Ledger + Busy notes | Done - `EventLiveCoverageLedger`, assert `event_live_coverage` |
+| 1a Happiness Signal apply loop | Done - `EventLiveApplyLoopHarness` |
+| 1b Status gain apply loop | Done - same harness; spawn human for emotions |
+| 2 Relationship confirms | Done - `EventLiveRelationshipHarness` (leave via Beh/`setFamily(null)`, remove with Prefix emit + anchor hint, baby via `createBabyActorFromData`; join often `pipeline` via setFamily) |
+| 3 Other pipelines | Done - `EventLivePipelinesHarness` (traits/decisions/WorldLog/…; building destroy; boat/combat best-effort; libraries `unsupported`) |
+| 4 Suite wiring | Done - `event_suite` nests apply+relationship; `event_live_pipelines` in regression; flake budget 3/3 |
+| 5 Docs sync | Done - `event-e2e.md`, `event-observation.md` |
+
+Scenarios: `event_live_apply_loop` + `event_live_relationship` nest in `event_suite`.
+
+`event_live_pipelines` runs in regression (flake budget proven).
+
+## Non-goals
+
+- One hand-written harness step per catalog id
+- Calling inject / `domain_feed` “live”
+- Promoting `feed` to “fully verified”
+- Full meteorology sim for every disaster when a shared WorldLog pipeline covers the feed (still record `pipeline`/`feed` honestly)
+- Failing CI on camera `none` before Phase 3 exit
+- Committing generated coverage TSV
+- Marking sibling ids `pipeline` without shared predicate **and** shared fire API
+
+## Success criteria
+
+- Camera happiness Signal + status gains: each `id`, `id_drop`, or fixed `fail`
+- Relationship: every confirm predicate has positive **and** negative sim; setters not piggybacked
+- Every other camera domain: `id` / `id_drop` / `pipeline` / `feed` / `unsupported` - no unexplained `none`
+- `fail == 0` on live runs
+- New catalog ids show up as `none` until a runner claims them
+- Coverage vocabulary never equates inject or `feed` with production-accurate trigger proof
diff --git a/docs/event-observation.md b/docs/event-observation.md
index 4c0da53..7cb4364 100644
--- a/docs/event-observation.md
+++ b/docs/event-observation.md
@@ -1,6 +1,7 @@
# Event observation (observe → confirm → emit)
IdleSpectator learns about the game through Harmony sensors.
+
Those sensors must not treat AI noise as catalog truth.
## Pipeline
@@ -19,7 +20,7 @@ Patch (Observe) → EventOutcome (Confirm) → Feed Emit (EventReason + cata
## Rules
1. **Never** treat `BehResult.Continue` as success by itself.
-2. Prefer **state setters** when they exist (`setLover`, `setParent*`, `newFamily`, `addNewStatusEffect`).
+2. Prefer **state setters** when they exist (`setLover`, `setParent*`, `newFamily`, `setFamily`, `addNewStatusEffect`).
3. Beh hooks need **before/after** confirmation (`EventOutcome.GainedFamily`, `StillSeekingLover`, …).
4. Unconfirmed attempts log `outcome_unconfirmed` via `InterestDropLog` (not silent).
5. New catalog events must name their confirm predicate (or setter) in the audit TSV `game_call_site` / `fix_action`.
@@ -27,12 +28,29 @@ Patch (Observe) → EventOutcome (Confirm) → Feed Emit (EventReason + cata
## Example (family join)
Vanilla `BehFamilyGroupJoin` returns Continue even when no nearby family exists.
+
Confirm with `GainedFamily(before, actor)` before Emit `family_group_join`.
+Negative harness: Beh Continue with no family gain must log `outcome_unconfirmed` and must not register `rel:family_group_join`.
+
## Adding an event
+For every new camera-worthy catalog id, record three things up front:
+
+| Field | Meaning | Example |
+|-------|---------|---------|
+| **Confirm predicate** | `EventOutcome` helper, or “setter is the transition” | `GainedFamily`, `setLover(null)` |
+| **Fire API** | Vanilla call the live harness will invoke | `BehFamilyGroupLeave.execute`, `Actor.addTrait` |
+| **Expected live level** | Honest target after the runner claims | `id`, `id_drop`, `pipeline` (`shared=`…), or `unsupported` |
+
+Then implement:
+
1. Find the real game call site (setter or Beh).
-2. Add/extend an `EventOutcome` predicate if needed.
+2. Add/extend an `EventOutcome` predicate if Beh Continue is ambiguous.
3. Patch calls `EventObservation.Confirm` / `ConfirmAfter` then the domain feed.
-4. Mark the audit row with the confirm rule.
-5. Harness: force or live path that asserts key + absence when outcome fails.
+4. Mark the audit row with the confirm rule (`event-trigger-audit.tsv` = design/call-site only).
+5. Harness: live path asserts key **or** authored drop; assert absence / `outcome_unconfirmed` when the outcome fails.
+6. Do **not** call `domain_feed` / inject and label it live.
+7. Live proof lands in `.harness/event-live-coverage.tsv` via `EventLiveCoverageLedger.Claim` - that file is the source of truth for live levels, not the audit TSV.
+
+See [`event-live-verification-plan.md`](event-live-verification-plan.md) for claiming rules and suite wiring.
diff --git a/scripts/harness-run.sh b/scripts/harness-run.sh
index 5ec1834..d814fe8 100755
--- a/scripts/harness-run.sh
+++ b/scripts/harness-run.sh
@@ -38,6 +38,7 @@ REGRESSION_SCENARIOS=(
discovery
world_log
event_suite
+ event_live_pipelines
chronicle_smoke
activity_idle_smoke
activity_status_smoke