Coverage for what we have
This commit is contained in:
parent
048b215814
commit
1359d86591
24 changed files with 3139 additions and 67 deletions
|
|
@ -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());
|
||||
|
|
|
|||
197
IdleSpectator/EventLiveApplyLoopHarness.cs
Normal file
197
IdleSpectator/EventLiveApplyLoopHarness.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
236
IdleSpectator/EventLiveCoverageLedger.cs
Normal file
236
IdleSpectator/EventLiveCoverageLedger.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
1183
IdleSpectator/EventLivePipelinesHarness.cs
Normal file
1183
IdleSpectator/EventLivePipelinesHarness.cs
Normal file
File diff suppressed because it is too large
Load diff
874
IdleSpectator/EventLiveRelationshipHarness.cs
Normal file
874
IdleSpectator/EventLiveRelationshipHarness.cs
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
214
docs/event-live-verification-plan.md
Normal file
214
docs/event-live-verification-plan.md
Normal 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 id’s shared hook family; **this id was not necessarily fired** | Partial - siblings may share predicate/API |
|
||||
| `id_drop` | Real apply/fire ran; result was an **authored** drop (reason matched) | Yes for “accurate drop” |
|
||||
| `id` | This id went through real fire and asserted note/interest as expected | Yes |
|
||||
| `unsupported` | No deterministic fire API; explicit reason in `notes` | Honest gap |
|
||||
| `fail` | Fire attempted; unexpected miss/drop/wrong key | Broken |
|
||||
|
||||
### Claiming rules (strict)
|
||||
|
||||
1. **`id` / `id_drop`:** only the id that was actually fired this run.
|
||||
2. **`pipeline`:** only when (a) vanilla entry was used, and (b) siblings share the **same confirm predicate and same fire API**. Note must include `shared=<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 run’s results** + catalog seed. No hand-maintained allowlist of “known smokes.”
|
||||
|
||||
Ambient / `CreatesInterest=false`: not camera-live goals. Optional B-log loop later; default leave at `none` unless explicitly asserted as `id_drop` / ActivityLog-only.
|
||||
|
||||
## Current baseline (done)
|
||||
|
||||
- Inventory audits in `event_coverage`
|
||||
- `event_inject_coverage` - all camera-worthy ids register
|
||||
- `event_live_outcome_smoke` - family lovers/parent, one status, one happiness (proof exists; ledger will record from run, not a static list)
|
||||
- Observe→confirm for relationship Beh paths
|
||||
- Docs: `event-e2e.md`, `event-observation.md`
|
||||
|
||||
## Phase 0 - Coverage ledger + Busy policy
|
||||
|
||||
**Deliverable:** harness writes `.harness/event-live-coverage.tsv` every live/coverage run.
|
||||
|
||||
Columns: `domain`, `id`, `camera`, `inject`, `live_level`, `pipeline`, `notes`
|
||||
|
||||
- Seed rows from the same `AuthoredIds` loops as inject (`camera` / `inject` from that run or mirror rules)
|
||||
- `live_level` defaults to `none`; overwrite only from live runners’ results
|
||||
- Suite prints: `id=N id_drop=N pipeline=N feed=N none=N unsupported=N fail=N`
|
||||
- Fail the assert on `fail > 0` once live runners exist; do **not** fail on `none` until Phase 3 exit tightening
|
||||
|
||||
### Busy bypass (required before Phase 2/3 interest asserts)
|
||||
|
||||
Most feeds early-out on `AgentHarness.Busy`. Define per domain:
|
||||
|
||||
| Domain | During live fire | Primary assert |
|
||||
|--------|------------------|----------------|
|
||||
| Happiness | `HappinessSourceHook.Harness` / existing apply path | Router notes + log (interest optional) |
|
||||
| Status | ActivityLog primary; optional force-interest flag later | Status counters + log |
|
||||
| Relationship | `ForceRelationshipFeeds` (already used for parent) | Interest key + drop log |
|
||||
| WorldLog / war / plot / book / trait / decision / … | Explicit `Force*Feeds` or harness “allow live feeds” mode | Interest key and/or domain log |
|
||||
|
||||
Document the flag in `notes` when used (`busy_bypass=ForceRelationshipFeeds`).
|
||||
|
||||
**Maintainability:** new catalog ids appear as `none` automatically until a runner claims them.
|
||||
|
||||
## Phase 1 - Shared apply loops (highest confidence / hour)
|
||||
|
||||
Loop camera-worthy ids through existing **vanilla-ish** apply APIs (`happiness_apply`, `status_apply`).
|
||||
|
||||
### 1a Happiness
|
||||
|
||||
For each **Signal** id in `EventCatalog.Happiness.AuthoredIds` (skip Ambient unless doing an optional B-log pass):
|
||||
|
||||
1. Reset notes / clear subject counters
|
||||
2. `happiness_apply` with that id
|
||||
3. Classify:
|
||||
- expected note / `LastEffectId` → `id`
|
||||
- authored drop reason matched → `id_drop`
|
||||
- else → `fail`
|
||||
4. `pipeline` field: `happiness_apply`
|
||||
|
||||
Do **not** require camera interest while Busy unless a force path is on.
|
||||
|
||||
### 1b Status
|
||||
|
||||
For each `CreatesInterest` status (gains):
|
||||
|
||||
1. Clear status + activity status counters
|
||||
2. `status_apply`
|
||||
3. Assert gain + log prose → `id`; authored skip/drop → `id_drop`; else `fail`
|
||||
4. `status_remove` / egg hatch as a **separate** pipeline (`status_loss` / `hatch`) with its own ids
|
||||
|
||||
**Exit:** every camera-worthy happiness Signal and status gain id is `id`, `id_drop`, or `fail` (no silent `none` for those sets). Suite fails on `fail`.
|
||||
|
||||
## Phase 2 - Confirm-gated relationship families
|
||||
|
||||
One full sim **per `EventOutcome` predicate** (and separate setter smokes where there is no confirm helper).
|
||||
|
||||
### Confirm smokes (predicate + negative case)
|
||||
|
||||
| Pipeline | Sim | Assert |
|
||||
|----------|-----|--------|
|
||||
| `GainedLover` / bond | `happiness_bond_lovers` | notes + log; exercised id → `id` |
|
||||
| parent / `add_child` | `relationship_set_parent` | `rel:add_child`; id → `id` |
|
||||
| `LostLover` / `clear_lover` | bond then clear via game API | emit or authored drop |
|
||||
| `GainedFamily` / `family_group_join` | join when family exists **and** when not | key vs `outcome_unconfirmed` |
|
||||
| `LostFamily` / leave-remove | leave/remove when applicable | key vs unconfirmed |
|
||||
| `StillSeekingLover` / `find_lover` | seeking Beh path | confirm rules |
|
||||
|
||||
Negative tests are mandatory for confirm predicates (especially join-with-no-family).
|
||||
|
||||
### Setter smokes (no shared confirm sibling claim)
|
||||
|
||||
`new_family`, `family_group_new`, `become_alpha`, `baby_created`, etc.: each needs its own vanilla fire for `id`. Do **not** mark them `pipeline` off `add_child` or join.
|
||||
|
||||
Siblings may get `pipeline` only under Claiming rule 2 (`shared=<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
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ REGRESSION_SCENARIOS=(
|
|||
discovery
|
||||
world_log
|
||||
event_suite
|
||||
event_live_pipelines
|
||||
chronicle_smoke
|
||||
activity_idle_smoke
|
||||
activity_status_smoke
|
||||
|
|
|
|||
Loading…
Reference in a new issue