From 1359d865918b6e19c756a4eb4e69d5e01ee230a1 Mon Sep 17 00:00:00 2001 From: DazedAnon Date: Thu, 16 Jul 2026 17:32:00 -0500 Subject: [PATCH] Coverage for what we have --- IdleSpectator/AgentHarness.cs | 57 + IdleSpectator/EventLiveApplyLoopHarness.cs | 197 +++ IdleSpectator/EventLiveCoverageLedger.cs | 236 ++++ IdleSpectator/EventLivePipelinesHarness.cs | 1183 +++++++++++++++++ IdleSpectator/EventLiveRelationshipHarness.cs | 874 ++++++++++++ .../Events/Feeds/BookInterestFeed.cs | 2 +- .../Events/Feeds/DeferredInterestFeed.cs | 8 +- IdleSpectator/Events/Feeds/InterestFeeds.cs | 14 +- .../Events/Feeds/MetaInterestFeed.cs | 4 +- .../Events/Feeds/PlotInterestFeed.cs | 4 +- .../Events/Feeds/RelationshipInterestFeed.cs | 4 +- IdleSpectator/Events/Feeds/WarInterestFeed.cs | 4 +- .../Events/Patches/BoatEventPatches.cs | 2 +- .../Events/Patches/BuildingEventPatches.cs | 18 +- .../Events/Patches/DeferredEventPatches.cs | 20 +- .../Patches/RelationshipEventPatches.cs | 170 ++- .../Events/Patches/TraitEventPatches.cs | 2 +- .../Events/Patches/WarEventPatches.cs | 4 +- IdleSpectator/HarnessScenarios.cs | 82 ++ IdleSpectator/mod.json | 2 +- docs/event-e2e.md | 78 +- docs/event-live-verification-plan.md | 214 +++ docs/event-observation.md | 26 +- scripts/harness-run.sh | 1 + 24 files changed, 3139 insertions(+), 67 deletions(-) create mode 100644 IdleSpectator/EventLiveApplyLoopHarness.cs create mode 100644 IdleSpectator/EventLiveCoverageLedger.cs create mode 100644 IdleSpectator/EventLivePipelinesHarness.cs create mode 100644 IdleSpectator/EventLiveRelationshipHarness.cs create mode 100644 docs/event-live-verification-plan.md 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