From 048b215814d6a1290ec846a950aa674b0b2d5659 Mon Sep 17 00:00:00 2001 From: DazedAnon Date: Thu, 16 Jul 2026 16:20:33 -0500 Subject: [PATCH] Shallow Live --- IdleSpectator/AgentHarness.cs | 11 + IdleSpectator/EventInjectCoverageHarness.cs | 533 ++++++++++++++++++++ IdleSpectator/HarnessScenarios.cs | 69 ++- IdleSpectator/mod.json | 4 +- docs/event-e2e.md | 41 ++ docs/event-reason.md | 1 + scripts/harness-run.sh | 2 +- 7 files changed, 652 insertions(+), 9 deletions(-) create mode 100644 IdleSpectator/EventInjectCoverageHarness.cs create mode 100644 docs/event-e2e.md diff --git a/IdleSpectator/AgentHarness.cs b/IdleSpectator/AgentHarness.cs index a45f6e3..803f3d9 100644 --- a/IdleSpectator/AgentHarness.cs +++ b/IdleSpectator/AgentHarness.cs @@ -4691,6 +4691,17 @@ public static class AgentHarness detail = audit.Detail; break; } + case "event_inject_coverage": + { + Actor focus = ResolveUnit(null) + ?? (MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null) + ?? WorldActivityScanner.FindNearestAliveUnit(CameraPos(), 200f); + EventInjectCoverageResult coverage = + EventInjectCoverageHarness.Run(HarnessDir(), focus); + pass = coverage.Passed; + detail = coverage.Detail; + break; + } case "mutation_discovery": { MutationDiscoveryResult discovery = MutationDiscoveryHarness.Run(HarnessDir()); diff --git a/IdleSpectator/EventInjectCoverageHarness.cs b/IdleSpectator/EventInjectCoverageHarness.cs new file mode 100644 index 0000000..9efc03d --- /dev/null +++ b/IdleSpectator/EventInjectCoverageHarness.cs @@ -0,0 +1,533 @@ +using System; +using System.Collections.Generic; +using System.Text; +using UnityEngine; + +namespace IdleSpectator; + +public sealed class EventInjectCoverageResult +{ + public bool Passed; + public string Detail = ""; + public int Attempted; + public int Registered; + public int SkippedB; + public int Failed; +} + +/// +/// Data-driven Layer A inject coverage: every camera-worthy catalog id must register +/// an interest key. New JSON/live-seeded ids join automatically via AuthoredIds. +/// Does not prove live Beh/API outcomes - see event_live_outcome_smoke for that track. +/// +/// Each successful inject is removed from immediately so +/// trim cannot false-fail later ids. +/// +public static class EventInjectCoverageHarness +{ + public static EventInjectCoverageResult Run(string outputDirectory, Actor unit) + { + var result = new EventInjectCoverageResult(); + if (unit == null || !unit.isAlive()) + { + result.Detail = "no living unit for inject coverage"; + return result; + } + + InterestRegistry.Clear(); + + var tsv = new StringBuilder(); + tsv.AppendLine("domain\tid\tcamera\tresult\tkey\tnotes"); + var failures = new List(32); + + void Row(string domain, string id, bool camera, string outcome, string key, string notes) + { + tsv.Append(domain).Append('\t') + .Append(id).Append('\t') + .Append(camera ? "true" : "false").Append('\t') + .Append(outcome).Append('\t') + .Append(key ?? "").Append('\t') + .Append(notes ?? "").Append('\n'); + } + + void Fail(string domain, string id, string notes) + { + result.Failed++; + failures.Add(domain + ":" + id + " " + notes); + Row(domain, id, true, "FAIL", "", notes); + } + + void SkipB(string domain, string id, string notes) + { + result.SkippedB++; + Row(domain, id, false, "skip_b", "", notes); + } + + void Ok(string domain, string id, string key) + { + result.Registered++; + Row(domain, id, true, "ok", key, ""); + } + + bool Accept(InterestCandidate c, string needle, out string key, out string notes) + { + key = ""; + notes = ""; + if (c == null || string.IsNullOrEmpty(c.Key)) + { + notes = "register_returned_null"; + return false; + } + + key = c.Key; + if (!string.IsNullOrEmpty(needle) + && c.Key.IndexOf(needle, StringComparison.OrdinalIgnoreCase) < 0) + { + notes = "key_mismatch want=" + needle + " got=" + c.Key; + return false; + } + + // Drop immediately so MaxCandidates trim cannot wipe later injects. + InterestRegistry.Remove(c.Key); + return true; + } + + // Happiness Signal + foreach (string id in EventCatalog.Happiness.AuthoredIds) + { + result.Attempted++; + HappinessCatalogEntry entry = EventCatalog.Happiness.GetOrFallback(id); + if (!EventCatalog.IsCameraWorthy(entry)) + { + SkipB("happiness", id, entry.Presentation.ToString()); + continue; + } + + string label = EventReason.Apply("{a} · " + id, unit, id: id); + InterestCandidate c = InterestFeeds.RegisterHappinessSignal(unit, id, label); + string needle = "happiness:" + id; + if (!Accept(c, needle, out string key, out string notes)) + { + Fail("happiness", id, notes); + continue; + } + + Ok("happiness", id, key); + } + + // Status CreatesInterest (gains) + foreach (string id in EventCatalog.Status.AuthoredIds) + { + result.Attempted++; + StatusInterestEntry entry = EventCatalog.Status.GetOrFallback(id); + if (!EventCatalog.IsCameraWorthy(entry)) + { + SkipB("status", id, "CreatesInterest=false"); + continue; + } + + string phrase = ActivityStatusProse.Phrase(id, gained: true, out _); + string key = "status:" + id + ":" + EventFeedUtil.SafeId(unit); + InterestCandidate c = EventFeedUtil.Register( + key, + "status_inject", + string.IsNullOrEmpty(entry.Category) ? "StatusTransformation" : entry.Category, + EventReason.Status(unit, phrase), + id, + entry.EventStrength, + unit.current_position, + unit); + if (!Accept(c, "status:" + id, out string accepted, out string notes)) + { + Fail("status", id, notes); + continue; + } + + Ok("status", id, accepted); + } + + // WorldLog + foreach (string id in EventCatalog.WorldLog.AuthoredIds) + { + result.Attempted++; + WorldLogEventEntry entry = EventCatalog.WorldLog.GetOrFallback(id); + if (!EventCatalog.IsCameraWorthy(entry)) + { + SkipB("worldLog", id, "CreatesInterest=false"); + continue; + } + + string key = "worldlog:" + id + ":inject"; + string label = entry.MakeLabel("Alpha", "Beta"); + InterestCandidate c = EventFeedUtil.Register( + key, + "worldlog_inject", + string.IsNullOrEmpty(entry.Category) ? "WorldLog" : entry.Category, + label, + id, + entry.EventStrength, + unit.current_position, + unit, + locationOnly: false); + if (!Accept(c, "worldlog:" + id, out string accepted, out string notes)) + { + Fail("worldLog", id, notes); + continue; + } + + Ok("worldLog", id, accepted); + } + + // Disasters → paired WorldLog id (policy overlay; camera via worldLog) + foreach (string id in EventCatalog.Disaster.AuthoredIds) + { + result.Attempted++; + DisasterInterestEntry entry = EventCatalog.Disaster.GetOrFallback(id); + if (entry == null || !EventCatalog.IsCameraWorthy(entry.CreatesInterest)) + { + SkipB("disasters", id, "camera=false"); + continue; + } + + string worldLogId = string.IsNullOrEmpty(entry.WorldLogId) + ? ("disaster_" + id) + : entry.WorldLogId; + WorldLogEventEntry wl = EventCatalog.WorldLog.GetOrFallback(worldLogId); + if (!EventCatalog.IsCameraWorthy(wl)) + { + Fail("disasters", id, "paired_worldLog_not_camera:" + worldLogId); + continue; + } + + string key = "worldlog:" + worldLogId + ":disaster_inject"; + InterestCandidate c = EventFeedUtil.Register( + key, + "disaster_inject", + "Disaster", + wl.MakeLabel("Alpha", "Beta"), + worldLogId, + entry.EventStrength, + unit.current_position, + unit); + if (!Accept(c, "worldlog:" + worldLogId, out string accepted, out string notes)) + { + Fail("disasters", id, notes + " worldLog=" + worldLogId); + continue; + } + + Ok("disasters", id, accepted); + } + + // War types + foreach (string id in EventCatalog.WarType.AuthoredIds) + { + result.Attempted++; + WarTypeInterestEntry entry = EventCatalog.WarType.GetOrFallback(id); + if (entry == null || !EventCatalog.IsCameraWorthy(entry.CreatesInterest)) + { + SkipB("warTypes", id, "camera=false"); + continue; + } + + string key = "war:inject:" + id; + string label = EventReason.Apply(entry.LabelTemplate, unit, id: id); + if (string.IsNullOrEmpty(label)) + { + label = "War (" + id + ")"; + } + + InterestCandidate c = EventFeedUtil.Register( + key, + "war_inject", + string.IsNullOrEmpty(entry.Category) ? "War" : entry.Category, + label, + id, + entry.EventStrength, + unit.current_position, + unit); + if (!Accept(c, key, out string accepted, out string notes)) + { + Fail("warTypes", id, notes); + continue; + } + + Ok("warTypes", id, accepted); + } + + InjectDiscreteDomain( + "relationship", + EventCatalog.Relationship.AuthoredIds, + id => EventCatalog.Relationship.GetOrFallback(id), + "rel", + unit, + result, + Fail, + SkipB, + Ok, + Accept); + + InjectDiscreteDomain( + "plots", + EventCatalog.Plot.AuthoredIds, + id => EventCatalog.Plot.GetOrFallback(id), + "plot", + unit, + result, + Fail, + SkipB, + Ok, + Accept, + phaseAware: true); + + InjectDiscreteDomain( + "books", + EventCatalog.Book.AuthoredIds, + id => EventCatalog.Book.GetOrFallback(id), + "book", + unit, + result, + Fail, + SkipB, + Ok, + Accept); + + InjectDiscreteDomain( + "eras", + EventCatalog.Era.AuthoredIds, + id => EventCatalog.Era.GetOrFallback(id), + "era", + unit, + result, + Fail, + SkipB, + Ok, + Accept, + locationOnlyOk: true); + + InjectDiscreteDomain( + "traits", + EventCatalog.Trait.AuthoredIds, + id => EventCatalog.Trait.GetOrFallback(id), + "trait", + unit, + result, + Fail, + SkipB, + Ok, + Accept, + useTraitLabel: true); + + // Decisions: only camera-worthy Signal decisions (live-seeded + JSON) + foreach (string id in EventCatalog.Decision.AuthoredIds) + { + result.Attempted++; + if (!EventCatalog.Decision.IsCameraWorthy(id)) + { + SkipB("decisions", id, "not_interesting_decision"); + continue; + } + + DiscreteEventEntry entry = EventCatalog.Decision.GetOrFallback(id); + string key = "decision:" + id + ":" + EventFeedUtil.SafeId(unit); + string label = EventReason.Decision(unit, id); + InterestCandidate c = EventFeedUtil.Register( + key, + "decision_inject", + entry.Category, + label, + id, + entry.EventStrength, + unit.current_position, + unit); + if (!Accept(c, "decision:" + id, out string accepted, out string notes)) + { + Fail("decisions", id, notes); + continue; + } + + Ok("decisions", id, accepted); + } + + // Libraries - camera-worthy only (Signal / ambientCreatesInterest policy) + InjectLibrary("spell", EventCatalog.Spell.AuthoredIds, id => EventCatalog.Spell.GetOrFallback(id), unit, result, Fail, SkipB, Ok, Accept); + InjectLibrary("item", EventCatalog.Item.AuthoredIds, id => EventCatalog.Item.GetOrFallback(id), unit, result, Fail, SkipB, Ok, Accept); + InjectLibrary("power", EventCatalog.Power.AuthoredIds, id => EventCatalog.Power.GetOrFallback(id), unit, result, Fail, SkipB, Ok, Accept); + InjectLibrary("gene", EventCatalog.Gene.AuthoredIds, id => EventCatalog.Gene.GetOrFallback(id), unit, result, Fail, SkipB, Ok, Accept); + InjectLibrary("phenotype", EventCatalog.Phenotype.AuthoredIds, id => EventCatalog.Phenotype.GetOrFallback(id), unit, result, Fail, SkipB, Ok, Accept); + InjectLibrary("worldLaw", EventCatalog.WorldLaw.AuthoredIds, id => EventCatalog.WorldLaw.GetOrFallback(id), unit, result, Fail, SkipB, Ok, Accept); + InjectLibrary("subspecies_trait", EventCatalog.SubspeciesTrait.AuthoredIds, id => EventCatalog.SubspeciesTrait.GetOrFallback(id), unit, result, Fail, SkipB, Ok, Accept); + InjectLibrary("biome", EventCatalog.Biome.AuthoredIds, id => EventCatalog.Biome.GetOrFallback(id), unit, result, Fail, SkipB, Ok, Accept); + + CatalogCoverageAudit.WriteReport(outputDirectory, "event-inject-coverage.tsv", tsv.ToString()); + + result.Passed = result.Failed == 0 && result.Registered > 0; + result.Detail = + $"attempted={result.Attempted} registered={result.Registered} skip_b={result.SkippedB} " + + $"failed={result.Failed}" + + (failures.Count > 0 + ? (" first=" + failures[0] + (failures.Count > 1 ? " +" + (failures.Count - 1) : "")) + : ""); + return result; + } + + private delegate DiscreteEventEntry DiscreteGetter(string id); + + private delegate bool AcceptFn(InterestCandidate c, string needle, out string key, out string notes); + + private static void InjectDiscreteDomain( + string domain, + IEnumerable ids, + DiscreteGetter get, + string keyPrefix, + Actor unit, + EventInjectCoverageResult result, + Action fail, + Action skipB, + Action ok, + AcceptFn accept, + bool phaseAware = false, + bool locationOnlyOk = false, + bool useTraitLabel = false) + { + if (ids == null) + { + return; + } + + foreach (string raw in ids) + { + result.Attempted++; + string id = (raw ?? "").Trim(); + if (string.IsNullOrEmpty(id)) + { + continue; + } + + DiscreteEventEntry entry = get(id); + if (!EventCatalog.IsCameraWorthy(entry)) + { + skipB(domain, id, "CreatesInterest=false"); + continue; + } + + string key = keyPrefix + ":" + id + + (phaseAware ? ":new:" : ":") + + (locationOnlyOk ? "inject" : EventFeedUtil.SafeId(unit).ToString()); + if (domain == "eras") + { + key = "era:" + id; + } + else if (domain == "traits") + { + key = "trait:" + id + ":gains:" + EventFeedUtil.SafeId(unit); + } + else if (domain == "relationship") + { + key = "rel:" + id + ":" + EventFeedUtil.SafeId(unit); + } + else if (domain == "plots") + { + key = "plot:" + id + ":new:" + EventFeedUtil.SafeId(unit); + } + else if (domain == "books") + { + key = "book:" + id + ":new:" + EventFeedUtil.SafeId(unit); + } + + string label; + if (useTraitLabel) + { + label = EventReason.Trait(unit, id, gained: true); + } + else if (phaseAware && domain == "plots") + { + label = EventReason.Plot(unit, entry.LabelTemplate, "new", entry.Id); + } + else if (domain == "eras") + { + label = entry.MakeLabel("", ""); + } + else + { + label = entry.MakeLabel(unit); + } + + InterestCandidate c = EventFeedUtil.Register( + key, + domain + "_inject", + entry.Category, + label, + id, + entry.EventStrength, + unit.current_position, + follow: unit, + locationOnly: false); + string needle = keyPrefix == "rel" ? "rel:" + id : key.Split(':')[0] + ":" + id; + if (domain == "eras") + { + needle = "era:" + id; + } + + if (!accept(c, needle, out string accepted, out string notes)) + { + fail(domain, id, notes); + continue; + } + + ok(domain, id, accepted); + } + } + + private static void InjectLibrary( + string domain, + IEnumerable ids, + DiscreteGetter get, + Actor unit, + EventInjectCoverageResult result, + Action fail, + Action skipB, + Action ok, + AcceptFn accept) + { + if (ids == null) + { + return; + } + + foreach (string raw in ids) + { + result.Attempted++; + string id = (raw ?? "").Trim(); + if (string.IsNullOrEmpty(id)) + { + continue; + } + + DiscreteEventEntry entry = get(id); + if (!EventCatalog.IsCameraWorthy(entry)) + { + skipB(domain, id, "CreatesInterest=false"); + continue; + } + + string key = domain + ":" + id + ":" + EventFeedUtil.SafeId(unit); + string label = EventReason.Library(unit, domain, id); + InterestCandidate c = EventFeedUtil.Register( + key, + domain + "_inject", + entry.Category, + label, + id, + entry.EventStrength, + unit.current_position, + unit); + if (!accept(c, domain + ":" + id, out string accepted, out string notes)) + { + fail(domain, id, notes); + continue; + } + + ok(domain, id, accepted); + } + } +} diff --git a/IdleSpectator/HarnessScenarios.cs b/IdleSpectator/HarnessScenarios.cs index 298ef19..f55e490 100644 --- a/IdleSpectator/HarnessScenarios.cs +++ b/IdleSpectator/HarnessScenarios.cs @@ -58,6 +58,12 @@ internal static class HarnessScenarios return DisasterWarAudit(); case "event_coverage": return EventCoverage(); + case "event_inject_coverage": + return EventInjectCoverage(); + case "event_live_outcome_smoke": + return EventLiveOutcomeSmoke(); + case "event_suite": + return EventSuite(); case "domain_event_audit": return DomainEventAudit(); case "event_tracking": @@ -164,7 +170,7 @@ internal static class HarnessScenarios Nested("reg_action_priority", "director_action_priority"), Nested("reg_discovery", "discovery"), Nested("reg_worldlog", "world_log"), - Nested("reg_event_coverage", "event_coverage"), + Nested("reg_event_coverage", "event_suite"), Nested("reg_chronicle", "chronicle_smoke"), Nested("reg_activity", "activity_idle_smoke"), Nested("reg_activity_status", "activity_status_smoke"), @@ -1303,11 +1309,8 @@ internal static class HarnessScenarios Step("ec13", "assert", expect: "activity_happiness_audit"), Step("ec14", "assert", expect: "domain_event_audit"), - // Previously silent-dropped WorldLog disaster id must enter the registry - Step("ec20", "world_log_feed", asset: "disaster_tornado"), - Step("ec21", "assert", expect: "interest_has_key", value: "worldlog:disaster_tornado"), - Step("ec22", "world_log_feed", asset: "disaster_alien_invasion"), - Step("ec23", "assert", expect: "interest_has_key", value: "worldlog:disaster_alien_invasion"), + // Every camera-worthy catalog id must A-register (data-driven). + Step("ec30", "assert", expect: "event_inject_coverage"), Step("ec90", "assert", expect: "health"), Step("ec91", "assert", expect: "no_bad"), @@ -1315,6 +1318,60 @@ internal static class HarnessScenarios }; } + private static List EventInjectCoverage() + { + return new List + { + Step("eic0", "dismiss_windows"), + Step("eic1", "wait_world"), + Step("eic2", "set_setting", expect: "enabled", value: "true"), + Step("eic3", "pick_unit", asset: "auto"), + Step("eic4", "spectator", value: "off"), + Step("eic5", "spectator", value: "on"), + Step("eic6", "focus", asset: "auto"), + Step("eic7", "assert", expect: "event_inject_coverage"), + Step("eic8", "assert", expect: "no_bad"), + Step("eic9", "snapshot") + }; + } + + /// + /// Curated live API/Beh outcomes (not full catalog). Expands when patches gain + /// deterministic harness wrappers. Inject coverage owns "every id registers." + /// + private static List EventLiveOutcomeSmoke() + { + return new List + { + Nested("elo_family", "family_trigger_smoke"), + Step("elo0", "dismiss_windows"), + Step("elo1", "wait_world"), + Step("elo2", "pick_unit", asset: "human"), + Step("elo3", "spectator", value: "off"), + Step("elo4", "spectator", value: "on"), + Step("elo5", "focus", asset: "auto"), + Step("elo10", "activity_status_reset"), + Step("elo11", "status_apply", value: "confused"), + Step("elo12", "assert", expect: "activity_status_gain_count", value: "1", label: "min"), + Step("elo13", "assert", expect: "activity_log_contains", value: "confused"), + Step("elo19", "happiness_reset"), + Step("elo20", "happiness_apply", value: "just_became_adult"), + Step("elo21", "assert", expect: "activity_happiness_count", value: "1", label: "min"), + Step("elo22", "assert", expect: "activity_log_contains", value: "adult"), + Step("elo90", "assert", expect: "no_bad"), + Step("elo99", "snapshot") + }; + } + + private static List EventSuite() + { + return new List + { + Nested("es_coverage", "event_coverage"), + Nested("es_live", "event_live_outcome_smoke"), + }; + } + private static List MutationDiscovery() { return new List diff --git a/IdleSpectator/mod.json b/IdleSpectator/mod.json index 6d95842..f13e56e 100644 --- a/IdleSpectator/mod.json +++ b/IdleSpectator/mod.json @@ -1,7 +1,7 @@ { "name": "IdleSpectator", "author": "dazed", - "version": "0.25.14", - "description": "AFK Idle Spectator (I) + Lore (L). EventOutcome observe→confirm→emit.", + "version": "0.25.17", + "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 new file mode 100644 index 0000000..397a6bf --- /dev/null +++ b/docs/event-e2e.md @@ -0,0 +1,41 @@ +# Event E2E suite (maintainable) + +Goal: every catalog event is covered by automation that stays cheap when new ids are added. + +## 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 | + +Full suite: `event_suite` = `event_coverage` + `event_live_outcome_smoke`. +Regression nests `event_suite`. + +## Commands + +```bash +./scripts/harness-run.sh event_inject_coverage +./scripts/harness-run.sh event_live_outcome_smoke +./scripts/harness-run.sh event_suite +./scripts/harness-run.sh regression +``` + +Artifact: `IdleSpectator/.harness/event-inject-coverage.tsv` +(`domain`, `id`, `camera`, `result`, `key`, `notes`). + +## 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. + +## Code + +- `EventInjectCoverageHarness.Run` - data-driven register loop +- Assert expect `event_inject_coverage` in `AgentHarness` +- Observe→confirm docs: `event-observation.md` diff --git a/docs/event-reason.md b/docs/event-reason.md index f44aba9..a2882df 100644 --- a/docs/event-reason.md +++ b/docs/event-reason.md @@ -82,6 +82,7 @@ Active EventLed A scenes should not silently blank a valid Label. ## See also +- [`event-e2e.md`](event-e2e.md) - catalog inject + live outcome suite - [`event-observation.md`](event-observation.md) - observe → confirm → emit (Beh Continue is not success) - [`event-audit.md`](event-audit.md) - sources, MaxWatch, A demotion set - [`event-prose-audit.md`](event-prose-audit.md) - prose vs game call sites diff --git a/scripts/harness-run.sh b/scripts/harness-run.sh index 0ebd665..5ec1834 100755 --- a/scripts/harness-run.sh +++ b/scripts/harness-run.sh @@ -37,7 +37,7 @@ REGRESSION_SCENARIOS=( director_action_priority discovery world_log - event_coverage + event_suite chronicle_smoke activity_idle_smoke activity_status_smoke