Coverage for what we have

This commit is contained in:
DazedAnon 2026-07-16 17:32:00 -05:00
parent 048b215814
commit 1359d86591
24 changed files with 3139 additions and 67 deletions

View file

@ -63,6 +63,16 @@ public static class AgentHarness
/// </summary>
public static bool ForceRelationshipFeeds;
/// <summary>
/// When true, event feeds/patches still Emit while <see cref="Busy"/>
/// for live verification (traits, WorldLog, wars, plots, books, meta, …).
/// </summary>
public static bool ForceLiveEventFeeds;
/// <summary>True when production feeds may register during harness Busy.</summary>
public static bool LiveFeedsAllowed =>
!Busy || ForceLiveEventFeeds || ForceRelationshipFeeds;
/// <summary>
/// When true, InterestDirector / SpeciesDiscovery stay frozen during harness commands.
/// <c>director_run</c> 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());

View file

@ -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;
}
/// <summary>
/// Phase 1 live apply loops: every camera-worthy happiness Signal and status gain
/// through real <c>changeHappiness</c> / <c>addStatusEffect</c>. Claims into
/// <see cref="EventLiveCoverageLedger"/> (<c>id</c> / <c>id_drop</c> / <c>fail</c>).
/// Primary asserts are ActivityLog / router notes (Busy may skip interest).
/// </summary>
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<string>(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<string> 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;
}
}

View file

@ -0,0 +1,236 @@
using System;
using System.Collections.Generic;
using System.Text;
namespace IdleSpectator;
/// <summary>
/// Honest live-verification ledger (Phase 0). Regenerated each run; not committed.
/// Levels: none | feed | pipeline | id_drop | id | unsupported | fail
/// </summary>
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<string, EventLiveCoverageRow> Rows =
new Dictionary<string, EventLiveCoverageRow>(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<string> ids, Func<string, DiscreteEventEntry> 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 = ""
};
}
/// <summary>Overwrite live proof for one id. Does not invent catalog rows.</summary>
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<string>(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;
}
}

File diff suppressed because it is too large Load diff

View file

@ -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;
}
/// <summary>
/// Phase 2: vanilla relationship fires with ForceLiveEventFeeds.
/// Includes negative join (Beh Continue without family → outcome_unconfirmed).
/// Always detaches members before <c>FamilyManager.removeObject</c> so leftover
/// families cannot NRE vanilla <c>Family.isFull</c> / <c>getNearbyFamily</c>.
/// </summary>
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<string>(16);
var spawned = new List<Actor>(32);
var families = new List<Family>(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<string> 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<string> 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<Actor> spawned)
{
if (actor != null)
{
spawned.Add(actor);
}
return actor;
}
private static void TrackFamily(Family family, List<Family> 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<Actor> 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;
}
/// <summary>
/// Detach every living member. Prevents zombie family refs that NRE
/// <c>Family.isFull</c> via null <c>getActorAsset</c>.
/// </summary>
private static void DetachAllMembers(Family family)
{
DetachUnitsStillHoldingFamily(family);
}
private static void DetachUnitsStillHoldingFamily(Family family)
{
if (family == null)
{
return;
}
var toDetach = new List<Actor>(16);
try
{
List<Actor> 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<Actor> spawned, List<Family> 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
}
}
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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
{
/// <summary>
/// Vanilla <c>Family.isFull</c> NREs when <c>getActorAsset()</c> is null (broken/zombie family).
/// Treat as full so <c>getNearbyFamily</c> skips instead of spamming AI exceptions.
/// </summary>
[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;
/// <summary>
/// Emit before remove tears membership down. Postfix is too late for member lookup.
/// </summary>
[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);
}
}
/// <summary>Optional harness/pre-call hint when membership lists are still dirty.</summary>
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<Actor> 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)));
}
/// <summary>
/// Leave is <c>setFamily(null)</c> (Beh leave and any other clear). Prefer this over
/// Beh Continue alone so harness and vanilla share one state sensor.
/// </summary>
[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 */ });
}
/// <summary>

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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
};
}
/// <summary>
/// Phase 1: loop every camera-worthy happiness Signal + status gain through real apply APIs.
/// Writes <c>event-live-coverage.tsv</c>.
/// </summary>
private static List<HarnessCommand> EventLiveApplyLoop()
{
return new List<HarnessCommand>
{
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<HarnessCommand> EventLiveRelationship()
{
return new List<HarnessCommand>
{
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<HarnessCommand> EventLivePipelines()
{
return new List<HarnessCommand>
{
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")
};
}
/// <summary>Phase 0: seed + write coverage ledger (fail only if any live_level=fail).</summary>
private static List<HarnessCommand> EventLiveCoverage()
{
return new List<HarnessCommand>
{
Step("elc0", "wait_world"),
Step("elc1", "assert", expect: "event_live_coverage"),
Step("elc2", "snapshot")
};
}
private static List<HarnessCommand> EventSuite()
{
return new List<HarnessCommand>
{
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"),
};
}

View file

@ -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"
}

View file

@ -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`

View file

@ -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 ids 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=<pipeline_id>`. 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 runs 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=<predicate>`).
**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

View file

@ -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.

View file

@ -38,6 +38,7 @@ REGRESSION_SCENARIOS=(
discovery
world_log
event_suite
event_live_pipelines
chronicle_smoke
activity_idle_smoke
activity_status_smoke