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