Compare commits

...

7 commits

46 changed files with 7389 additions and 243 deletions

View file

@ -317,6 +317,14 @@ public static class ActivityAssetCatalog
public static List<string> EnumerateLiveBiomeIds() => EnumerateLibraryIds("biome_library");
public static List<string> EnumerateLiveCultureTraitIds() => EnumerateLibraryIds("culture_traits");
public static List<string> EnumerateLiveReligionTraitIds() => EnumerateLibraryIds("religion_traits");
public static List<string> EnumerateLiveClanTraitIds() => EnumerateLibraryIds("clan_traits");
public static List<string> EnumerateLiveLanguageTraitIds() => EnumerateLibraryIds("language_traits");
private static List<string> EnumerateLibraryIds(string assetManagerField)
{
var ids = new List<string>();

View file

@ -262,9 +262,21 @@ public static class ActivityLog
float stamped = Time.unscaledTime - secondsAgo;
int n = 0;
// Keep the newest row "fresh" so soft-window peeks can still show it while
// older rows fall out of the caption ring (activity_idle_smoke act17*).
int last = -1;
for (int i = list.Count - 1; i >= 0; i--)
{
if (list[i] != null)
{
last = i;
break;
}
}
for (int i = 0; i < list.Count; i++)
{
if (list[i] == null)
if (list[i] == null || i == last)
{
continue;
}

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.
@ -181,6 +191,23 @@ public static class AgentHarness
break;
}
case "force_live_feeds":
{
bool on = ParseBool(cmd.value, defaultValue: true);
ForceLiveEventFeeds = on;
_cmdOk++;
Emit(cmd, ok: true, detail: $"force_live_feeds={on}");
break;
}
case "drop_clear":
{
InterestDropLog.Clear();
_cmdOk++;
Emit(cmd, ok: true, detail: "drops_cleared");
break;
}
case "age_current":
{
float seconds = cmd.wait > 0f ? cmd.wait : ParseFloat(cmd.value, 2f);
@ -1961,10 +1988,31 @@ public static class AgentHarness
DoInterestInject(cmd);
break;
case "presentability_probe":
DoPresentabilityProbe(cmd);
break;
case "interest_force_session":
DoInterestForceSession(cmd);
break;
case "interest_combat_session":
DoInterestCombatSession(cmd);
break;
case "combat_maintain_focus":
{
InterestDirector.HarnessMaintainCombatFocus();
string tip = CameraDirector.LastWatchLabel ?? "";
string focus = FocusLabel();
_cmdOk++;
Emit(
cmd,
ok: true,
detail: $"key={InterestDirector.CurrentKey} tip='{tip}' focus={focus} completion={InterestDirector.CurrentCandidate?.Completion}");
break;
}
case "interest_variety_clear":
InterestVariety.Clear();
_cmdOk++;
@ -2120,6 +2168,26 @@ public static class AgentHarness
break;
}
case "camera_clear_focus":
{
// Simulate vanilla clearing follow mid-idle (death / spectator desync).
CameraDirector.ClearFollow();
bool cleared = !MoveCamera.hasFocusUnit()
|| MoveCamera._focus_unit == null
|| !MoveCamera._focus_unit.isAlive();
if (cleared)
{
_cmdOk++;
}
else
{
_cmdFail++;
}
Emit(cmd, ok: cleared, detail: $"cleared={cleared} idle={SpectatorMode.Active}");
break;
}
case "interest_expire_pending":
{
string needle = cmd.value ?? "";
@ -2227,6 +2295,7 @@ public static class AgentHarness
bool ok = published != null;
if (ok)
{
InterestFeeds.HarnessEnsureCursorBefore(published.Sequence);
_cmdOk++;
}
else
@ -2723,6 +2792,86 @@ public static class AgentHarness
Emit(cmd, ok: true, detail: $"fed id={assetId} key={key} evt={entry.EventStrength:0.#}");
}
/// <summary>
/// Proves unpresentable Labels are rejected at Register (selection = presentation).
/// </summary>
private static void DoPresentabilityProbe(HarnessCommand cmd)
{
if (!WorldReady())
{
_cmdFail++;
Emit(cmd, ok: false, detail: "world_not_ready");
return;
}
Actor unit = ResolveUnit(string.IsNullOrEmpty(cmd.asset) || cmd.asset == "auto" ? null : cmd.asset)
?? WorldActivityScanner.FindNearestAliveUnit(CameraPos(), 200f);
if (unit == null)
{
_cmdFail++;
Emit(cmd, ok: false, detail: "no unit for presentability_probe");
return;
}
InterestDropLog.Clear();
string name = EventFeedUtil.SafeName(unit);
string mode = (cmd.value ?? "fall").Trim().ToLowerInvariant();
string label;
if (mode == "good" || mode == "presentable")
{
label = string.IsNullOrEmpty(cmd.label)
? EventReason.Fight(unit, null)
: cmd.label;
if (!label.StartsWith(name, StringComparison.OrdinalIgnoreCase) && !string.IsNullOrEmpty(name))
{
label = EventPresentability.EnsureSubjectLedLabel(unit, label);
}
}
else
{
// Article-led fall shape that dossier blanks via identity_filter.
label = string.IsNullOrEmpty(cmd.label)
? ("A Maple Tree falls near " + (string.IsNullOrEmpty(name) ? "someone" : name))
: cmd.label;
}
string key = "probe:presentability:" + EventFeedUtil.SafeId(unit) + ":" + Time.unscaledTime.ToString("0.###");
InterestCandidate registered = EventFeedUtil.Register(
key,
"probe",
"Spectacle",
label,
"presentability_probe",
88f,
unit.current_position,
unit);
bool expectReject = mode != "good" && mode != "presentable";
bool ok;
string detail;
if (expectReject)
{
ok = registered == null && InterestDropLog.RecentContains("unpresentable");
detail = $"reject label='{label}' registered={registered != null} drops='{InterestDropLog.FormatRecent(4)}'";
}
else
{
ok = registered != null && EventPresentability.WouldShow(registered);
detail = $"accept label='{label}' registered={registered != null} key={(registered != null ? registered.Key : "")}";
}
if (ok)
{
_cmdOk++;
}
else
{
_cmdFail++;
}
Emit(cmd, ok: ok, detail: detail);
}
/// <summary>
/// Injects a Signal from a domain catalog while the harness is Busy
/// (domain feeds themselves early-out on Busy, so register here).
@ -3343,6 +3492,100 @@ public static class AgentHarness
Emit(cmd, ok, detail: $"key={InterestDirector.CurrentKey} score={InterestDirector.CurrentScoreLabel} active={InterestDirector.CurrentIsActive}");
}
/// <summary>
/// Force a CombatActive session with follow + related so handoff/tip rewrite can be asserted.
/// asset = follow, value = related asset (optional), label overrides Fight prose.
/// </summary>
private static void DoInterestCombatSession(HarnessCommand cmd)
{
if (!WorldReady())
{
_cmdFail++;
Emit(cmd, ok: false, detail: "world_not_ready");
return;
}
Actor follow = ResolveUnit(string.IsNullOrEmpty(cmd.asset) || cmd.asset == "auto" ? null : cmd.asset);
if (follow == null)
{
follow = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
}
if (follow == null)
{
follow = WorldActivityScanner.FindNearestAliveUnit(CameraPos(), 200f);
}
Actor related = null;
if (!string.IsNullOrEmpty(cmd.value) && cmd.value != "auto")
{
related = ResolveUnit(cmd.value);
}
if (related == null || related == follow)
{
foreach (Actor actor in WorldActivityScanner.EnumerateAliveUnitsPublic())
{
if (actor != null && actor != follow && actor.isAlive())
{
related = actor;
break;
}
}
}
if (follow == null || !follow.isAlive())
{
_cmdFail++;
Emit(cmd, ok: false, detail: "no follow unit for interest_combat_session");
return;
}
string label = string.IsNullOrEmpty(cmd.label)
? EventReason.Fight(follow, related)
: cmd.label.Replace("{a}", SafeName(follow)).Replace("{b}", SafeName(related));
string keySuffix = string.IsNullOrEmpty(cmd.expect) ? "combat_focus" : cmd.expect;
InterestCandidate c = InterestFeeds.RegisterHarness(
follow,
label,
keySuffix,
lead: InterestLeadKind.EventLed,
completion: InterestCompletionKind.CombatActive,
forceActive: true,
eventStrength: 120f,
characterSignificance: 20f,
maxWatch: 60f,
ttlSeconds: 60f,
related: related,
resumable: true);
if (c != null)
{
c.Category = "Combat";
c.Verb = "fighting";
if (related != null)
{
c.ParticipantCount = 2;
}
InterestScoring.ScoreCheap(c);
}
bool ok = InterestDirector.HarnessForceSession(c);
if (ok)
{
_cmdOk++;
}
else
{
_cmdFail++;
}
Emit(
cmd,
ok,
detail: $"key={InterestDirector.CurrentKey} follow={SafeName(follow)} related={SafeName(related)} tip='{CameraDirector.LastWatchLabel}'");
}
private static void DoSetSetting(HarnessCommand cmd)
{
string key = cmd.expect;
@ -3449,6 +3692,41 @@ public static class AgentHarness
$"tipAsset={tipAsset} unitAsset={unitAsset} tip={tip}";
break;
}
case "tip_matches_focus":
{
Actor unit = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
string tip = CameraDirector.LastWatchLabel ?? "";
string name = SafeName(unit);
if (string.IsNullOrEmpty(tip))
{
// Cleared fight tip during combat cold is OK.
pass = true;
detail = "tip_empty focus=" + name;
}
else if (string.IsNullOrEmpty(name))
{
pass = false;
detail = "no_focus tip='" + tip + "'";
}
else
{
pass = tip.StartsWith(name, StringComparison.OrdinalIgnoreCase)
|| tip.IndexOf(name, StringComparison.OrdinalIgnoreCase) == 0;
// Also accept Battle-style where name leads.
detail = $"tip='{tip}' focus='{name}' pass={pass}";
}
break;
}
case "tip_not_contains":
{
string needle = string.IsNullOrEmpty(cmd.value) ? cmd.label : cmd.value;
string tip = CameraDirector.LastWatchLabel ?? "";
pass = string.IsNullOrEmpty(needle)
|| tip.IndexOf(needle, StringComparison.OrdinalIgnoreCase) < 0;
detail = $"tip='{tip}' not_contains='{needle}'";
break;
}
case "unit_asset":
{
string wantAsset = string.IsNullOrEmpty(cmd.asset) ? cmd.value : cmd.asset;
@ -3469,6 +3747,30 @@ public static class AgentHarness
detail = $"bad={StateProbe.BadEventCount}";
break;
}
case "drop_contains":
{
string needle = string.IsNullOrEmpty(cmd.value) ? cmd.label : cmd.value;
pass = !string.IsNullOrEmpty(needle) && InterestDropLog.RecentContains(needle);
detail = $"needle='{needle}' drops='{InterestDropLog.FormatRecent(8)}'";
break;
}
case "no_placeholder_tip":
{
string formatted = CameraDirector.LastFormattedWatchTip ?? "";
pass = formatted.IndexOf("something interesting", StringComparison.OrdinalIgnoreCase) < 0;
detail = $"formattedTip='{formatted}'";
break;
}
case "has_focus":
{
bool want = ParseBool(cmd.value, defaultValue: true);
bool has = MoveCamera.hasFocusUnit()
&& MoveCamera._focus_unit != null
&& MoveCamera._focus_unit.isAlive();
pass = has == want;
detail = $"hasFocus={has} want={want}";
break;
}
case "tip_contains":
{
string needle = string.IsNullOrEmpty(cmd.value) ? cmd.label : cmd.value;
@ -4691,6 +4993,64 @@ public static class AgentHarness
detail = audit.Detail;
break;
}
case "event_inject_coverage":
{
Actor focus = ResolveUnit(null)
?? (MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null)
?? WorldActivityScanner.FindNearestAliveUnit(CameraPos(), 200f);
EventInjectCoverageResult coverage =
EventInjectCoverageHarness.Run(HarnessDir(), focus);
pass = coverage.Passed;
detail = coverage.Detail;
break;
}
case "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());
@ -5376,15 +5736,37 @@ public static class AgentHarness
{
string needle = string.IsNullOrEmpty(cmd.value) ? cmd.label : cmd.value;
ChronicleEntry latest = Chronicle.LatestWorld;
string line = latest != null ? latest.Line : "";
string lore = latest != null ? latest.HudLine : "";
string display = latest != null ? latest.DisplayLine : "";
pass = latest != null
&& latest.Kind != ChronicleKind.AgeChapter
&& !string.IsNullOrEmpty(needle)
&& (line.IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0
|| lore.IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0);
detail = $"world_latest='{display}' needle='{needle}' age='{Chronicle.CurrentAgeName}'";
pass = false;
if (!string.IsNullOrEmpty(needle))
{
IReadOnlyList<ChronicleEntry> snap = Chronicle.SnapshotMemory();
for (int i = 0; i < snap.Count; i++)
{
ChronicleEntry e = snap[i];
if (e == null || e.Kind == ChronicleKind.AgeChapter)
{
continue;
}
if (e.Kind != ChronicleKind.World)
{
continue;
}
string line = e.Line ?? "";
string lore = e.HudLine ?? "";
if (line.IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0
|| lore.IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0)
{
pass = true;
display = e.DisplayLine ?? display;
break;
}
}
}
detail = $"world_hit='{display}' needle='{needle}' age='{Chronicle.CurrentAgeName}' latest='{(latest != null ? latest.DisplayLine : "")}'";
break;
}
case "world_memory_age":

View file

@ -7,6 +7,7 @@ public static class CameraDirector
{
public static string LastWatchLabel { get; private set; } = "";
public static string LastWatchAssetId { get; private set; } = "";
public static string LastFormattedWatchTip { get; private set; } = "";
public static void FocusUnit(Actor actor, bool updateCaption = true)
{
@ -33,6 +34,7 @@ public static class CameraDirector
}
string tip = FormatWatchTip(interest);
LastFormattedWatchTip = tip;
// LastWatchLabel is tip/harness telemetry only - never dossier reason truth.
LastWatchLabel = interest.Label ?? "";
LastWatchAssetId = interest.AssetId ?? "";
@ -81,10 +83,12 @@ public static class CameraDirector
public static string FormatWatchTip(InterestEvent interest)
{
string reason = string.IsNullOrEmpty(interest.Label)
? "something interesting"
: interest.Label;
return $"Watching: {reason}";
if (interest == null || string.IsNullOrEmpty(interest.Label))
{
return "Watching";
}
return "Watching: " + interest.Label;
}
public static void ClearFollow()

View file

@ -209,6 +209,50 @@ public static class DomainEventHarness
EventCatalog.Biome.AuthoredIds);
AppendLibraryDomain(tsv, "biomes", ActivityAssetCatalog.EnumerateLiveBiomeIds(), biomes, EventCatalog.Biome.GetOrFallback);
CatalogCoverageDiff cultureTraits = CatalogCoverageAudit.Diff(
"culture_traits",
ActivityAssetCatalog.EnumerateLiveCultureTraitIds(),
EventCatalog.CultureTrait.AuthoredIds);
AppendLibraryDomain(
tsv,
"culture_traits",
ActivityAssetCatalog.EnumerateLiveCultureTraitIds(),
cultureTraits,
EventCatalog.CultureTrait.GetOrFallback);
CatalogCoverageDiff religionTraits = CatalogCoverageAudit.Diff(
"religion_traits",
ActivityAssetCatalog.EnumerateLiveReligionTraitIds(),
EventCatalog.ReligionTrait.AuthoredIds);
AppendLibraryDomain(
tsv,
"religion_traits",
ActivityAssetCatalog.EnumerateLiveReligionTraitIds(),
religionTraits,
EventCatalog.ReligionTrait.GetOrFallback);
CatalogCoverageDiff clanTraits = CatalogCoverageAudit.Diff(
"clan_traits",
ActivityAssetCatalog.EnumerateLiveClanTraitIds(),
EventCatalog.ClanTrait.AuthoredIds);
AppendLibraryDomain(
tsv,
"clan_traits",
ActivityAssetCatalog.EnumerateLiveClanTraitIds(),
clanTraits,
EventCatalog.ClanTrait.GetOrFallback);
CatalogCoverageDiff languageTraits = CatalogCoverageAudit.Diff(
"language_traits",
ActivityAssetCatalog.EnumerateLiveLanguageTraitIds(),
EventCatalog.LanguageTrait.AuthoredIds);
AppendLibraryDomain(
tsv,
"language_traits",
ActivityAssetCatalog.EnumerateLiveLanguageTraitIds(),
languageTraits,
EventCatalog.LanguageTrait.GetOrFallback);
int relMissing = 0;
int relTotal = 0;
foreach (string id in EventCatalog.Relationship.AuthoredIds)

View file

@ -290,6 +290,7 @@ public static class EventCatalogConfig
InterestCategory = ExtractJsonString(obj, "category") ?? "",
CanonicalMilestoneKey = ExtractJsonString(obj, "canonicalMilestoneKey") ?? "",
StatusOverlapId = ExtractJsonString(obj, "statusOverlapId") ?? "",
StatusOverlapKind = ParseStatusOverlapKind(obj, ExtractJsonString(obj, "statusOverlapId")),
VariantsWithRelated = ExtractStringArray(obj, "variantsWithRelated"),
VariantsWithoutRelated = ExtractStringArray(obj, "variantsWithoutRelated")
};
@ -468,6 +469,51 @@ public static class EventCatalogConfig
return Enum.TryParse(raw, true, out TEnum value) ? value : fallback;
}
private static HappinessStatusOverlapKind ParseStatusOverlapKind(string json, string statusOverlapId)
{
if (string.IsNullOrEmpty(statusOverlapId))
{
return HappinessStatusOverlapKind.None;
}
HappinessStatusOverlapKind parsed = ParseEnum(
json, "statusOverlapKind", HappinessStatusOverlapKind.None);
if (parsed != HappinessStatusOverlapKind.None)
{
return parsed;
}
// Legacy rows without statusOverlapKind: offset end-pings vs onset holds.
string id = ExtractJsonString(json, "id") ?? "";
if (IsLegacyOffsetOverlap(id))
{
return HappinessStatusOverlapKind.Offset;
}
return HappinessStatusOverlapKind.Hold;
}
private static bool IsLegacyOffsetOverlap(string effectId)
{
if (string.IsNullOrEmpty(effectId))
{
return false;
}
switch (effectId.Trim().ToLowerInvariant())
{
case "just_laughed":
case "just_sang":
case "just_swore":
case "just_cried":
case "just_had_tantrum":
case "just_inspired":
return true;
default:
return false;
}
}
private static string[] ExtractStringArray(string json, string key)
{
if (!TryExtractArray(json, key, out string arr) || string.IsNullOrEmpty(arr))

View file

@ -0,0 +1,537 @@
using System;
using System.Collections.Generic;
using System.Text;
using UnityEngine;
namespace IdleSpectator;
public sealed class EventInjectCoverageResult
{
public bool Passed;
public string Detail = "";
public int Attempted;
public int Registered;
public int SkippedB;
public int Failed;
}
/// <summary>
/// Data-driven Layer A inject coverage: every camera-worthy catalog id must register
/// an interest key. New JSON/live-seeded ids join automatically via AuthoredIds.
/// Does not prove live Beh/API outcomes - see event_live_outcome_smoke for that track.
///
/// Each successful inject is removed from <see cref="InterestRegistry"/> immediately so
/// <see cref="InterestRegistry.MaxCandidates"/> trim cannot false-fail later ids.
/// </summary>
public static class EventInjectCoverageHarness
{
public static EventInjectCoverageResult Run(string outputDirectory, Actor unit)
{
var result = new EventInjectCoverageResult();
if (unit == null || !unit.isAlive())
{
result.Detail = "no living unit for inject coverage";
return result;
}
InterestRegistry.Clear();
var tsv = new StringBuilder();
tsv.AppendLine("domain\tid\tcamera\tresult\tkey\tnotes");
var failures = new List<string>(32);
void Row(string domain, string id, bool camera, string outcome, string key, string notes)
{
tsv.Append(domain).Append('\t')
.Append(id).Append('\t')
.Append(camera ? "true" : "false").Append('\t')
.Append(outcome).Append('\t')
.Append(key ?? "").Append('\t')
.Append(notes ?? "").Append('\n');
}
void Fail(string domain, string id, string notes)
{
result.Failed++;
failures.Add(domain + ":" + id + " " + notes);
Row(domain, id, true, "FAIL", "", notes);
}
void SkipB(string domain, string id, string notes)
{
result.SkippedB++;
Row(domain, id, false, "skip_b", "", notes);
}
void Ok(string domain, string id, string key)
{
result.Registered++;
Row(domain, id, true, "ok", key, "");
}
bool Accept(InterestCandidate c, string needle, out string key, out string notes)
{
key = "";
notes = "";
if (c == null || string.IsNullOrEmpty(c.Key))
{
notes = "register_returned_null";
return false;
}
key = c.Key;
if (!string.IsNullOrEmpty(needle)
&& c.Key.IndexOf(needle, StringComparison.OrdinalIgnoreCase) < 0)
{
notes = "key_mismatch want=" + needle + " got=" + c.Key;
return false;
}
// Drop immediately so MaxCandidates trim cannot wipe later injects.
InterestRegistry.Remove(c.Key);
return true;
}
// Happiness Signal
foreach (string id in EventCatalog.Happiness.AuthoredIds)
{
result.Attempted++;
HappinessCatalogEntry entry = EventCatalog.Happiness.GetOrFallback(id);
if (!EventCatalog.IsCameraWorthy(entry))
{
SkipB("happiness", id, entry.Presentation.ToString());
continue;
}
string label = EventReason.Apply("{a} · " + id, unit, id: id);
InterestCandidate c = InterestFeeds.RegisterHappinessSignal(unit, id, label);
string needle = "happiness:" + id;
if (!Accept(c, needle, out string key, out string notes))
{
Fail("happiness", id, notes);
continue;
}
Ok("happiness", id, key);
}
// Status CreatesInterest (gains)
foreach (string id in EventCatalog.Status.AuthoredIds)
{
result.Attempted++;
StatusInterestEntry entry = EventCatalog.Status.GetOrFallback(id);
if (!EventCatalog.IsCameraWorthy(entry))
{
SkipB("status", id, "CreatesInterest=false");
continue;
}
string phrase = ActivityStatusProse.Phrase(id, gained: true, out _);
string key = "status:" + id + ":" + EventFeedUtil.SafeId(unit);
InterestCandidate c = EventFeedUtil.Register(
key,
"status_inject",
string.IsNullOrEmpty(entry.Category) ? "StatusTransformation" : entry.Category,
EventReason.Status(unit, phrase),
id,
entry.EventStrength,
unit.current_position,
unit);
if (!Accept(c, "status:" + id, out string accepted, out string notes))
{
Fail("status", id, notes);
continue;
}
Ok("status", id, accepted);
}
// WorldLog
foreach (string id in EventCatalog.WorldLog.AuthoredIds)
{
result.Attempted++;
WorldLogEventEntry entry = EventCatalog.WorldLog.GetOrFallback(id);
if (!EventCatalog.IsCameraWorthy(entry))
{
SkipB("worldLog", id, "CreatesInterest=false");
continue;
}
string key = "worldlog:" + id + ":inject";
string label = entry.MakeLabel("Alpha", "Beta");
InterestCandidate c = EventFeedUtil.Register(
key,
"worldlog_inject",
string.IsNullOrEmpty(entry.Category) ? "WorldLog" : entry.Category,
label,
id,
entry.EventStrength,
unit.current_position,
unit,
locationOnly: false);
if (!Accept(c, "worldlog:" + id, out string accepted, out string notes))
{
Fail("worldLog", id, notes);
continue;
}
Ok("worldLog", id, accepted);
}
// Disasters → paired WorldLog id (policy overlay; camera via worldLog)
foreach (string id in EventCatalog.Disaster.AuthoredIds)
{
result.Attempted++;
DisasterInterestEntry entry = EventCatalog.Disaster.GetOrFallback(id);
if (entry == null || !EventCatalog.IsCameraWorthy(entry.CreatesInterest))
{
SkipB("disasters", id, "camera=false");
continue;
}
string worldLogId = string.IsNullOrEmpty(entry.WorldLogId)
? ("disaster_" + id)
: entry.WorldLogId;
WorldLogEventEntry wl = EventCatalog.WorldLog.GetOrFallback(worldLogId);
if (!EventCatalog.IsCameraWorthy(wl))
{
Fail("disasters", id, "paired_worldLog_not_camera:" + worldLogId);
continue;
}
string key = "worldlog:" + worldLogId + ":disaster_inject";
InterestCandidate c = EventFeedUtil.Register(
key,
"disaster_inject",
"Disaster",
wl.MakeLabel("Alpha", "Beta"),
worldLogId,
entry.EventStrength,
unit.current_position,
unit);
if (!Accept(c, "worldlog:" + worldLogId, out string accepted, out string notes))
{
Fail("disasters", id, notes + " worldLog=" + worldLogId);
continue;
}
Ok("disasters", id, accepted);
}
// War types
foreach (string id in EventCatalog.WarType.AuthoredIds)
{
result.Attempted++;
WarTypeInterestEntry entry = EventCatalog.WarType.GetOrFallback(id);
if (entry == null || !EventCatalog.IsCameraWorthy(entry.CreatesInterest))
{
SkipB("warTypes", id, "camera=false");
continue;
}
string key = "war:inject:" + id;
string label = EventReason.Apply(entry.LabelTemplate, unit, id: id);
if (string.IsNullOrEmpty(label))
{
label = "War (" + id + ")";
}
InterestCandidate c = EventFeedUtil.Register(
key,
"war_inject",
string.IsNullOrEmpty(entry.Category) ? "War" : entry.Category,
label,
id,
entry.EventStrength,
unit.current_position,
unit);
if (!Accept(c, key, out string accepted, out string notes))
{
Fail("warTypes", id, notes);
continue;
}
Ok("warTypes", id, accepted);
}
InjectDiscreteDomain(
"relationship",
EventCatalog.Relationship.AuthoredIds,
id => EventCatalog.Relationship.GetOrFallback(id),
"rel",
unit,
result,
Fail,
SkipB,
Ok,
Accept);
InjectDiscreteDomain(
"plots",
EventCatalog.Plot.AuthoredIds,
id => EventCatalog.Plot.GetOrFallback(id),
"plot",
unit,
result,
Fail,
SkipB,
Ok,
Accept,
phaseAware: true);
InjectDiscreteDomain(
"books",
EventCatalog.Book.AuthoredIds,
id => EventCatalog.Book.GetOrFallback(id),
"book",
unit,
result,
Fail,
SkipB,
Ok,
Accept);
InjectDiscreteDomain(
"eras",
EventCatalog.Era.AuthoredIds,
id => EventCatalog.Era.GetOrFallback(id),
"era",
unit,
result,
Fail,
SkipB,
Ok,
Accept,
locationOnlyOk: true);
InjectDiscreteDomain(
"traits",
EventCatalog.Trait.AuthoredIds,
id => EventCatalog.Trait.GetOrFallback(id),
"trait",
unit,
result,
Fail,
SkipB,
Ok,
Accept,
useTraitLabel: true);
// Decisions: only camera-worthy Signal decisions (live-seeded + JSON)
foreach (string id in EventCatalog.Decision.AuthoredIds)
{
result.Attempted++;
if (!EventCatalog.Decision.IsCameraWorthy(id))
{
SkipB("decisions", id, "not_interesting_decision");
continue;
}
DiscreteEventEntry entry = EventCatalog.Decision.GetOrFallback(id);
string key = "decision:" + id + ":" + EventFeedUtil.SafeId(unit);
string label = EventReason.Decision(unit, id);
InterestCandidate c = EventFeedUtil.Register(
key,
"decision_inject",
entry.Category,
label,
id,
entry.EventStrength,
unit.current_position,
unit);
if (!Accept(c, "decision:" + id, out string accepted, out string notes))
{
Fail("decisions", id, notes);
continue;
}
Ok("decisions", id, accepted);
}
// Libraries - camera-worthy only (Signal / ambientCreatesInterest policy)
InjectLibrary("spell", EventCatalog.Spell.AuthoredIds, id => EventCatalog.Spell.GetOrFallback(id), unit, result, Fail, SkipB, Ok, Accept);
InjectLibrary("item", EventCatalog.Item.AuthoredIds, id => EventCatalog.Item.GetOrFallback(id), unit, result, Fail, SkipB, Ok, Accept);
InjectLibrary("power", EventCatalog.Power.AuthoredIds, id => EventCatalog.Power.GetOrFallback(id), unit, result, Fail, SkipB, Ok, Accept);
InjectLibrary("gene", EventCatalog.Gene.AuthoredIds, id => EventCatalog.Gene.GetOrFallback(id), unit, result, Fail, SkipB, Ok, Accept);
InjectLibrary("phenotype", EventCatalog.Phenotype.AuthoredIds, id => EventCatalog.Phenotype.GetOrFallback(id), unit, result, Fail, SkipB, Ok, Accept);
InjectLibrary("worldLaw", EventCatalog.WorldLaw.AuthoredIds, id => EventCatalog.WorldLaw.GetOrFallback(id), unit, result, Fail, SkipB, Ok, Accept);
InjectLibrary("subspecies_trait", EventCatalog.SubspeciesTrait.AuthoredIds, id => EventCatalog.SubspeciesTrait.GetOrFallback(id), unit, result, Fail, SkipB, Ok, Accept);
InjectLibrary("biome", EventCatalog.Biome.AuthoredIds, id => EventCatalog.Biome.GetOrFallback(id), unit, result, Fail, SkipB, Ok, Accept);
InjectLibrary("culture_trait", EventCatalog.CultureTrait.AuthoredIds, id => EventCatalog.CultureTrait.GetOrFallback(id), unit, result, Fail, SkipB, Ok, Accept);
InjectLibrary("religion_trait", EventCatalog.ReligionTrait.AuthoredIds, id => EventCatalog.ReligionTrait.GetOrFallback(id), unit, result, Fail, SkipB, Ok, Accept);
InjectLibrary("clan_trait", EventCatalog.ClanTrait.AuthoredIds, id => EventCatalog.ClanTrait.GetOrFallback(id), unit, result, Fail, SkipB, Ok, Accept);
InjectLibrary("language_trait", EventCatalog.LanguageTrait.AuthoredIds, id => EventCatalog.LanguageTrait.GetOrFallback(id), unit, result, Fail, SkipB, Ok, Accept);
CatalogCoverageAudit.WriteReport(outputDirectory, "event-inject-coverage.tsv", tsv.ToString());
result.Passed = result.Failed == 0 && result.Registered > 0;
result.Detail =
$"attempted={result.Attempted} registered={result.Registered} skip_b={result.SkippedB} "
+ $"failed={result.Failed}"
+ (failures.Count > 0
? (" first=" + failures[0] + (failures.Count > 1 ? " +" + (failures.Count - 1) : ""))
: "");
return result;
}
private delegate DiscreteEventEntry DiscreteGetter(string id);
private delegate bool AcceptFn(InterestCandidate c, string needle, out string key, out string notes);
private static void InjectDiscreteDomain(
string domain,
IEnumerable<string> ids,
DiscreteGetter get,
string keyPrefix,
Actor unit,
EventInjectCoverageResult result,
Action<string, string, string> fail,
Action<string, string, string> skipB,
Action<string, string, string> ok,
AcceptFn accept,
bool phaseAware = false,
bool locationOnlyOk = false,
bool useTraitLabel = false)
{
if (ids == null)
{
return;
}
foreach (string raw in ids)
{
result.Attempted++;
string id = (raw ?? "").Trim();
if (string.IsNullOrEmpty(id))
{
continue;
}
DiscreteEventEntry entry = get(id);
if (!EventCatalog.IsCameraWorthy(entry))
{
skipB(domain, id, "CreatesInterest=false");
continue;
}
string key = keyPrefix + ":" + id
+ (phaseAware ? ":new:" : ":")
+ (locationOnlyOk ? "inject" : EventFeedUtil.SafeId(unit).ToString());
if (domain == "eras")
{
key = "era:" + id;
}
else if (domain == "traits")
{
key = "trait:" + id + ":gains:" + EventFeedUtil.SafeId(unit);
}
else if (domain == "relationship")
{
key = "rel:" + id + ":" + EventFeedUtil.SafeId(unit);
}
else if (domain == "plots")
{
key = "plot:" + id + ":new:" + EventFeedUtil.SafeId(unit);
}
else if (domain == "books")
{
key = "book:" + id + ":new:" + EventFeedUtil.SafeId(unit);
}
string label;
if (useTraitLabel)
{
label = EventReason.Trait(unit, id, gained: true);
}
else if (phaseAware && domain == "plots")
{
label = EventReason.Plot(unit, entry.LabelTemplate, "new", entry.Id);
}
else if (domain == "eras")
{
label = entry.MakeLabel("", "");
}
else
{
label = entry.MakeLabel(unit);
}
InterestCandidate c = EventFeedUtil.Register(
key,
domain + "_inject",
entry.Category,
label,
id,
entry.EventStrength,
unit.current_position,
follow: unit,
locationOnly: false);
string needle = keyPrefix == "rel" ? "rel:" + id : key.Split(':')[0] + ":" + id;
if (domain == "eras")
{
needle = "era:" + id;
}
if (!accept(c, needle, out string accepted, out string notes))
{
fail(domain, id, notes);
continue;
}
ok(domain, id, accepted);
}
}
private static void InjectLibrary(
string domain,
IEnumerable<string> ids,
DiscreteGetter get,
Actor unit,
EventInjectCoverageResult result,
Action<string, string, string> fail,
Action<string, string, string> skipB,
Action<string, string, string> ok,
AcceptFn accept)
{
if (ids == null)
{
return;
}
foreach (string raw in ids)
{
result.Attempted++;
string id = (raw ?? "").Trim();
if (string.IsNullOrEmpty(id))
{
continue;
}
DiscreteEventEntry entry = get(id);
if (!EventCatalog.IsCameraWorthy(entry))
{
skipB(domain, id, "CreatesInterest=false");
continue;
}
string key = domain + ":" + id + ":" + EventFeedUtil.SafeId(unit);
string label = EventReason.Library(unit, domain, id);
InterestCandidate c = EventFeedUtil.Register(
key,
domain + "_inject",
entry.Category,
label,
id,
entry.EventStrength,
unit.current_position,
unit);
if (!accept(c, domain + ":" + id, out string accepted, out string notes))
{
fail(domain, id, notes);
continue;
}
ok(domain, id, accepted);
}
}
}

View file

@ -0,0 +1,322 @@
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);
}
// --- Status loss / hatch pipelines (ActivityLog primary; egg → hatch interest) ---
RunStatusLossAndHatch(unit, subjectId, fails, result);
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 void RunStatusLossAndHatch(
Actor unit,
long subjectId,
List<string> fails,
EventLiveApplyLoopResult result)
{
bool prev = AgentHarness.ForceLiveEventFeeds;
AgentHarness.ForceLiveEventFeeds = true;
try
{
// Sample camera status: gain then finish → ActivityLog loss.
string sampleId = null;
foreach (string raw in EventCatalog.Status.AuthoredIds)
{
string id = (raw ?? "").Trim();
if (string.IsNullOrEmpty(id)
|| string.Equals(id, "egg", StringComparison.OrdinalIgnoreCase))
{
continue;
}
StatusInterestEntry entry = EventCatalog.Status.GetOrFallback(id);
if (!EventCatalog.IsCameraWorthy(entry))
{
continue;
}
sampleId = id;
break;
}
if (!string.IsNullOrEmpty(sampleId))
{
StatusGameApi.TryFinishAll(unit);
StatusGameApi.TryAddStatus(unit, sampleId, overrideTimer: 8f);
ActivityLog.ResetStatusCountersForSubject(subjectId);
InterestDropLog.Clear();
StatusGameApi.TryFinishStatus(unit, sampleId);
bool lost = ActivityLog.StatusLossesSinceClear > 0
&& string.Equals(
ActivityLog.LastStatusLossId,
sampleId,
StringComparison.OrdinalIgnoreCase);
if (lost)
{
EventLiveCoverageLedger.Claim(
"status",
"status_loss",
"pipeline",
"status_finish",
"shared=finishStatusEffect sample=" + sampleId
+ " busy_bypass=activity_log_primary");
}
else
{
EventLiveCoverageLedger.Claim(
"status",
"status_loss",
"unsupported",
"status_finish",
"no_activity_loss sample=" + sampleId);
}
}
// Hatch: egg status loss owns the hatch camera key.
StatusGameApi.TryFinishAll(unit);
InterestRegistry.ForceExpireContaining("hatch:", UnityEngine.Time.unscaledTime);
ActivityLog.ResetStatusCountersForSubject(subjectId);
InterestDropLog.Clear();
bool eggOn = StatusGameApi.TryAddStatus(unit, "egg", overrideTimer: 2f);
StatusGameApi.TryFinishStatus(unit, "egg");
bool hatchKey = InterestRegistry.CountKeysContaining("hatch:") > 0;
bool eggLoss = ActivityLog.StatusLossesSinceClear > 0
&& string.Equals(
ActivityLog.LastStatusLossId,
"egg",
StringComparison.OrdinalIgnoreCase);
if (hatchKey)
{
EventLiveCoverageLedger.Claim(
"status",
"egg",
"id",
"status_egg_loss",
"hatch_key busy_bypass=ForceLiveEventFeeds");
}
else if (eggOn && eggLoss)
{
EventLiveCoverageLedger.Claim(
"status",
"egg",
"pipeline",
"status_finish",
"shared=finishStatusEffect activity_loss_only");
}
else if (!eggOn)
{
EventLiveCoverageLedger.Claim(
"status",
"egg",
"id_drop",
"status_apply",
"egg_apply_false");
}
else
{
result.Failures++;
fails.Add("status:egg hatch_miss");
EventLiveCoverageLedger.Claim(
"status",
"egg",
"fail",
"status_egg_loss",
"no_hatch_key");
}
}
finally
{
AgentHarness.ForceLiveEventFeeds = prev;
}
}
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,240 @@
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));
SeedDiscrete("culture_trait", EventCatalog.CultureTrait.AuthoredIds, id => EventCatalog.CultureTrait.GetOrFallback(id));
SeedDiscrete("religion_trait", EventCatalog.ReligionTrait.AuthoredIds, id => EventCatalog.ReligionTrait.GetOrFallback(id));
SeedDiscrete("clan_trait", EventCatalog.ClanTrait.AuthoredIds, id => EventCatalog.ClanTrait.GetOrFallback(id));
SeedDiscrete("language_trait", EventCatalog.LanguageTrait.AuthoredIds, id => EventCatalog.LanguageTrait.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,998 @@
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
{
TryMoveNear(joiner, founder);
try
{
if (founder.subspecies != null)
{
joiner.setSubspecies(founder.subspecies);
}
}
catch
{
// ignore
}
SyncFamilyUnits();
Family nearby = null;
try
{
nearby = World.world.families.getNearbyFamily(joiner.asset, joiner.current_tile);
}
catch
{
nearby = null;
}
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 nearby=" + (nearby != null));
}
else if (gained)
{
Fail(fails, "family_group_join", "gained_but_no_key");
}
else
{
if (family != null && !joiner.hasFamily())
{
joiner.setFamily(family);
}
Claim(
"family_group_join",
joiner.hasFamily() ? "pipeline" : "unsupported",
"BehFamilyGroupJoin",
joiner.hasFamily()
? "shared=setFamily fallback; getNearbyFamily=" + (nearby != null)
: "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 (must stay !hasLover: isolate so Beh does not instantly bond) ---
Actor seeker = Track(SpawnHumanNear(a), spawned);
AgeOutOfSpawnWindow(seeker);
InterestDropLog.Clear();
InterestRegistry.ForceExpireContaining("rel:find_lover", Time.unscaledTime);
try
{
TryIsolateFromPeers(seeker);
try
{
if (seeker.hasLover())
{
seeker.setLover(null);
}
}
catch
{
// ignore
}
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 isolated");
}
else if (EventOutcome.StillSeekingLover(seeker, brSeek))
{
Fail(fails, "find_lover", "seeking_but_no_key");
}
else if (seeker.hasLover())
{
Claim(
"find_lover",
"unsupported",
"BehFindLover",
"bonded_instead_of_seeking");
}
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;
AccessTools.Method(typeof(Actor), "setCurrentTile", new[] { typeof(WorldTile) })
?.Invoke(mover, new object[] { tile });
}
}
catch
{
// ignore
}
}
/// <summary>
/// Park the actor on a distant tile so BehFindLover cannot instantly bond with harness peers.
/// </summary>
private static void TryIsolateFromPeers(Actor actor)
{
if (actor == null || actor.current_tile == null || World.world?.map_chunk_manager == null)
{
return;
}
try
{
WorldTile best = null;
float bestScore = -1f;
Vector3 origin = actor.current_position;
// Sample a coarse grid of tiles for emptiness + distance.
int w = MapBox.width;
int h = MapBox.height;
int step = Math.Max(8, Math.Min(w, h) / 16);
for (int x = 2; x < w - 2; x += step)
{
for (int y = 2; y < h - 2; y += step)
{
WorldTile tile = null;
try
{
tile = World.world.GetTile(x, y);
}
catch
{
tile = null;
}
if (tile == null)
{
continue;
}
float dist = (tile.posV3 - origin).sqrMagnitude;
if (dist < 80f * 80f)
{
continue;
}
int nearby = 0;
try
{
foreach (Actor u in Finder.getUnitsFromChunk(tile, 2))
{
if (u != null && u.isAlive() && u != actor)
{
nearby++;
if (nearby > 0)
{
break;
}
}
}
}
catch
{
nearby = 99;
}
if (nearby > 0)
{
continue;
}
float score = dist;
if (score > bestScore)
{
bestScore = score;
best = tile;
}
}
}
if (best != null)
{
actor.current_position = best.posV3;
AccessTools.Method(typeof(Actor), "setCurrentTile", new[] { typeof(WorldTile) })
?.Invoke(actor, new object[] { best });
}
}
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

@ -518,4 +518,239 @@ public static partial class EventCatalog
return LiveLibraryInterest.Lookup(Entries, id, d.Category, d.Label, 26f);
}
}
private static bool IsDramaticMetaTrait(string id)
{
if (string.IsNullOrEmpty(id))
{
return false;
}
string s = id.ToLowerInvariant();
return s.Contains("war")
|| s.Contains("blood")
|| s.Contains("death")
|| s.Contains("fire")
|| s.Contains("magic")
|| s.Contains("divine")
|| s.Contains("demon")
|| s.Contains("hate")
|| s.Contains("love")
|| s.Contains("xenophob")
|| s.Contains("expans")
|| s.Contains("conquest")
|| s.Contains("slave")
|| s.Contains("zealot")
|| s.Contains("fanatic")
|| s.Contains("schism")
|| s.Contains("royal");
}
public static class CultureTrait
{
private static readonly Dictionary<string, DiscreteEventEntry> Entries =
new Dictionary<string, DiscreteEventEntry>(StringComparer.OrdinalIgnoreCase);
private static int _appliedGeneration = -1;
public static void InvalidateOverlays()
{
Entries.Clear();
_appliedGeneration = -1;
}
private static void Ensure()
{
if (_appliedGeneration == EventCatalogConfig.Generation && Entries.Count > 0)
{
return;
}
Entries.Clear();
_appliedGeneration = EventCatalogConfig.Generation;
LibraryCatalogDefaults d = LibOr("cultureTrait", 34f, 74f, "Culture", "{a} culture gains {id}", true);
LiveLibraryInterest.EnsureSeeded(
Entries,
ActivityAssetCatalog.EnumerateLiveCultureTraitIds,
d.Category,
d.Label,
d.AmbientStrength,
signalIds: null,
d.SignalStrength,
signalPredicate: IsDramaticMetaTrait,
ambientCreatesInterest: d.AmbientCreatesInterest);
}
public static IEnumerable<string> AuthoredIds
{
get
{
Ensure();
return Entries.Keys;
}
}
public static DiscreteEventEntry GetOrFallback(string id)
{
Ensure();
LibraryCatalogDefaults d = LibOr("cultureTrait", 34f, 74f, "Culture", "{a} culture gains {id}", true);
return LiveLibraryInterest.Lookup(Entries, id, d.Category, d.Label, 34f);
}
}
public static class ReligionTrait
{
private static readonly Dictionary<string, DiscreteEventEntry> Entries =
new Dictionary<string, DiscreteEventEntry>(StringComparer.OrdinalIgnoreCase);
private static int _appliedGeneration = -1;
public static void InvalidateOverlays()
{
Entries.Clear();
_appliedGeneration = -1;
}
private static void Ensure()
{
if (_appliedGeneration == EventCatalogConfig.Generation && Entries.Count > 0)
{
return;
}
Entries.Clear();
_appliedGeneration = EventCatalogConfig.Generation;
LibraryCatalogDefaults d = LibOr("religionTrait", 34f, 74f, "Religion", "{a} faith gains {id}", true);
LiveLibraryInterest.EnsureSeeded(
Entries,
ActivityAssetCatalog.EnumerateLiveReligionTraitIds,
d.Category,
d.Label,
d.AmbientStrength,
signalIds: null,
d.SignalStrength,
signalPredicate: IsDramaticMetaTrait,
ambientCreatesInterest: d.AmbientCreatesInterest);
}
public static IEnumerable<string> AuthoredIds
{
get
{
Ensure();
return Entries.Keys;
}
}
public static DiscreteEventEntry GetOrFallback(string id)
{
Ensure();
LibraryCatalogDefaults d = LibOr("religionTrait", 34f, 74f, "Religion", "{a} faith gains {id}", true);
return LiveLibraryInterest.Lookup(Entries, id, d.Category, d.Label, 34f);
}
}
public static class ClanTrait
{
private static readonly Dictionary<string, DiscreteEventEntry> Entries =
new Dictionary<string, DiscreteEventEntry>(StringComparer.OrdinalIgnoreCase);
private static int _appliedGeneration = -1;
public static void InvalidateOverlays()
{
Entries.Clear();
_appliedGeneration = -1;
}
private static void Ensure()
{
if (_appliedGeneration == EventCatalogConfig.Generation && Entries.Count > 0)
{
return;
}
Entries.Clear();
_appliedGeneration = EventCatalogConfig.Generation;
LibraryCatalogDefaults d = LibOr("clanTrait", 32f, 72f, "Clan", "{a} clan gains {id}", true);
LiveLibraryInterest.EnsureSeeded(
Entries,
ActivityAssetCatalog.EnumerateLiveClanTraitIds,
d.Category,
d.Label,
d.AmbientStrength,
signalIds: null,
d.SignalStrength,
signalPredicate: IsDramaticMetaTrait,
ambientCreatesInterest: d.AmbientCreatesInterest);
}
public static IEnumerable<string> AuthoredIds
{
get
{
Ensure();
return Entries.Keys;
}
}
public static DiscreteEventEntry GetOrFallback(string id)
{
Ensure();
LibraryCatalogDefaults d = LibOr("clanTrait", 32f, 72f, "Clan", "{a} clan gains {id}", true);
return LiveLibraryInterest.Lookup(Entries, id, d.Category, d.Label, 32f);
}
}
public static class LanguageTrait
{
private static readonly Dictionary<string, DiscreteEventEntry> Entries =
new Dictionary<string, DiscreteEventEntry>(StringComparer.OrdinalIgnoreCase);
private static int _appliedGeneration = -1;
public static void InvalidateOverlays()
{
Entries.Clear();
_appliedGeneration = -1;
}
private static void Ensure()
{
if (_appliedGeneration == EventCatalogConfig.Generation && Entries.Count > 0)
{
return;
}
Entries.Clear();
_appliedGeneration = EventCatalogConfig.Generation;
LibraryCatalogDefaults d = LibOr("languageTrait", 30f, 68f, "Language", "{a} tongue gains {id}", true);
LiveLibraryInterest.EnsureSeeded(
Entries,
ActivityAssetCatalog.EnumerateLiveLanguageTraitIds,
d.Category,
d.Label,
d.AmbientStrength,
signalIds: null,
d.SignalStrength,
signalPredicate: IsDramaticMetaTrait,
ambientCreatesInterest: d.AmbientCreatesInterest);
}
public static IEnumerable<string> AuthoredIds
{
get
{
Ensure();
return Entries.Keys;
}
}
public static DiscreteEventEntry GetOrFallback(string id)
{
Ensure();
LibraryCatalogDefaults d = LibOr("languageTrait", 30f, 68f, "Language", "{a} tongue gains {id}", true);
return LiveLibraryInterest.Lookup(Entries, id, d.Category, d.Label, 30f);
}
}
}

View file

@ -47,6 +47,10 @@ public static partial class EventCatalog
WorldLaw.InvalidateOverlays();
SubspeciesTrait.InvalidateOverlays();
Biome.InvalidateOverlays();
CultureTrait.InvalidateOverlays();
ReligionTrait.InvalidateOverlays();
ClanTrait.InvalidateOverlays();
LanguageTrait.InvalidateOverlays();
}
/// <summary>

View file

@ -1,3 +1,4 @@
using System;
using UnityEngine;
namespace IdleSpectator;
@ -99,6 +100,12 @@ public static class EventFeedUtil
MaxWatch = maxWatch,
Completion = completion
};
if (!EventPresentability.WouldShow(candidate))
{
InterestDropLog.Record("unpresentable", candidate.Label ?? key);
return null;
}
InterestScoring.ScoreCheap(candidate);
return InterestRegistry.Upsert(candidate);
}
@ -141,10 +148,21 @@ public static class EventFeedUtil
candidate.RelatedId = SafeId(candidate.RelatedUnit);
}
// Harness injects synthetic tip labels for director tests; dossier still blanks them.
// Production feeds (Source != harness) must be presentable to enter the registry.
if (!IsHarnessSource(candidate.Source) && !EventPresentability.WouldShow(candidate))
{
InterestDropLog.Record("unpresentable", candidate.Label ?? candidate.Key);
return null;
}
InterestScoring.ScoreCheap(candidate);
return InterestRegistry.Upsert(candidate);
}
private static bool IsHarnessSource(string source) =>
string.Equals(source, "harness", StringComparison.OrdinalIgnoreCase);
public static long SafeId(Actor actor)
{
if (actor == null)

View file

@ -0,0 +1,61 @@
namespace IdleSpectator;
/// <summary>
/// Single source of truth: would the orange dossier show this EventLed Label?
/// Selection must equal presentation - unpresentable beats must not win Watching.
/// </summary>
public static class EventPresentability
{
/// <summary>
/// True when the candidate may own Layer A camera.
/// Empty Label is only allowed for CharacterLed fill (task chip, no orange reason).
/// </summary>
public static bool WouldShow(InterestCandidate scene)
{
if (scene == null)
{
return false;
}
if (string.IsNullOrEmpty(scene.Label))
{
return scene.LeadKind == InterestLeadKind.CharacterLed;
}
return !string.IsNullOrEmpty(UnitDossier.EvaluateSceneLabel(scene, recordDrops: false));
}
/// <summary>
/// Shown orange reason, or empty when blanked by identity / ownership rules.
/// </summary>
public static string ShownReason(InterestCandidate scene, bool recordDrops = true)
{
return UnitDossier.EvaluateSceneLabel(scene, recordDrops);
}
/// <summary>
/// Harness synthetic tips ("HoldAction") must be subject-led EventReason shape
/// so the presentability gate matches production dossier rules.
/// </summary>
public static string EnsureSubjectLedLabel(Actor subject, string label)
{
if (string.IsNullOrEmpty(label))
{
return label ?? "";
}
string name = EventFeedUtil.SafeName(subject);
if (string.IsNullOrEmpty(name))
{
return label;
}
if (label.StartsWith(name, System.StringComparison.OrdinalIgnoreCase))
{
return label;
}
// "Name is in scene: HoldAction" → EventSentence + subject-led first token.
return name + " is in scene: " + label;
}
}

View file

@ -176,8 +176,8 @@ public static class EventReason
if (!string.IsNullOrEmpty(n))
{
return string.IsNullOrEmpty(id)
? "A building falls near " + n
: "A " + id + " falls near " + n;
? n + " is near a falling building"
: n + " is near a falling " + id;
}
}
@ -439,6 +439,14 @@ public static class EventReason
return an + " channels " + id;
case "subspecies_trait":
return an + " gains " + id;
case "culture_trait":
return an + "'s culture gains " + id;
case "religion_trait":
return an + "'s faith gains " + id;
case "clan_trait":
return an + "'s clan gains " + id;
case "language_trait":
return an + "'s language gains " + id;
default:
return an + " · " + id;
}

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;
}
@ -135,9 +135,53 @@ public static class DeferredInterestFeed
locationOnly: true);
}
public static void EmitCultureTrait(string traitId, Actor anchor)
{
EmitFromCatalog(
"culture_trait",
traitId,
anchor,
EventCatalog.CultureTrait.GetOrFallback,
related: null,
locationOnly: false);
}
public static void EmitReligionTrait(string traitId, Actor anchor)
{
EmitFromCatalog(
"religion_trait",
traitId,
anchor,
EventCatalog.ReligionTrait.GetOrFallback,
related: null,
locationOnly: false);
}
public static void EmitClanTrait(string traitId, Actor anchor)
{
EmitFromCatalog(
"clan_trait",
traitId,
anchor,
EventCatalog.ClanTrait.GetOrFallback,
related: null,
locationOnly: false);
}
public static void EmitLanguageTrait(string traitId, Actor anchor)
{
EmitFromCatalog(
"language_trait",
traitId,
anchor,
EventCatalog.LanguageTrait.GetOrFallback,
related: null,
locationOnly: false);
}
public static void EmitBiome(string biomeId, Vector3 position)
{
if (AgentHarness.Busy || position == Vector3.zero)
if (!AgentHarness.LiveFeedsAllowed || position == Vector3.zero)
{
return;
}
@ -169,7 +213,7 @@ public static class DeferredInterestFeed
Actor related,
bool locationOnly)
{
if (AgentHarness.Busy || lookup == null)
if (!AgentHarness.LiveFeedsAllowed || lookup == null)
{
return;
}

View file

@ -27,6 +27,30 @@ public static class InterestFeeds
InterestDropLog.Clear();
}
/// <summary>Keep drain cursor coherent when the happiness router sequence resets.</summary>
public static void OnHappinessRouterCleared()
{
_happinessCursor = HappinessEventRouter.CurrentSequence;
HappinessDrain.Clear();
}
/// <summary>
/// Harness: if the router was cleared without syncing the feed cursor, rewind so
/// <see cref="HarnessDrainOnce"/> can see the new sequence.
/// </summary>
public static void HarnessEnsureCursorBefore(ulong sequence)
{
if (sequence == 0)
{
return;
}
if (_happinessCursor >= sequence)
{
_happinessCursor = sequence - 1;
}
}
public static ulong HappinessCursor => _happinessCursor;
public static int CivicBoostCount
@ -89,7 +113,7 @@ public static class InterestFeeds
public static void OnWorldLogMessage(WorldLogMessage message)
{
if (message == null || AgentHarness.Busy)
if (message == null || !AgentHarness.LiveFeedsAllowed)
{
return;
}
@ -303,7 +327,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;
}
@ -314,7 +338,6 @@ public static class InterestFeeds
return;
}
long id = SafeId(actor);
Actor foe = null;
try
{
@ -329,7 +352,9 @@ public static class InterestFeeds
}
string verb = taskOrBeat ?? "fighting";
string key = EventCatalog.Combat.Key(id);
WorldActivityScanner.PreferCombatFollow(actor, foe, out Actor follow, out Actor related);
long followId = SafeId(follow ?? actor);
string key = EventCatalog.Combat.Key(followId);
var candidate = new InterestCandidate
{
Key = key,
@ -338,17 +363,17 @@ public static class InterestFeeds
Source = "activity",
EventStrength = EventCatalog.Combat.ActivityStrength,
VisualConfidence = 0.8f,
Position = actor.current_position,
FollowUnit = actor,
RelatedUnit = foe,
SubjectId = id,
RelatedId = foe != null ? SafeId(foe) : 0,
Label = EventReason.Fight(actor, foe),
AssetId = actor.asset != null ? actor.asset.id : "",
SpeciesId = actor.asset != null ? actor.asset.id : "",
Position = follow != null ? follow.current_position : actor.current_position,
FollowUnit = follow ?? actor,
RelatedUnit = related,
SubjectId = followId,
RelatedId = related != null ? SafeId(related) : 0,
Label = EventReason.Fight(follow ?? actor, related),
AssetId = (follow ?? actor).asset != null ? (follow ?? actor).asset.id : "",
SpeciesId = (follow ?? actor).asset != null ? (follow ?? actor).asset.id : "",
Verb = verb,
CityKey = actor.city != null ? (actor.city.name ?? "") : "",
KingdomKey = actor.kingdom != null ? (actor.kingdom.name ?? "") : "",
CityKey = (follow ?? actor).city != null ? ((follow ?? actor).city.name ?? "") : "",
KingdomKey = (follow ?? actor).kingdom != null ? ((follow ?? actor).kingdom.name ?? "") : "",
CreatedAt = Time.unscaledTime,
LastSeenAt = Time.unscaledTime,
ExpiresAt = Time.unscaledTime + EventCatalog.Combat.ActivityTtl,
@ -356,7 +381,15 @@ public static class InterestFeeds
MaxWatch = EventCatalog.Combat.MaxWatch,
Completion = InterestCompletionKind.CombatActive
};
candidate.ParticipantIds.Add(id);
candidate.ParticipantIds.Add(followId);
if (related != null)
{
long rid = SafeId(related);
if (rid != 0)
{
candidate.ParticipantIds.Add(rid);
}
}
InterestScoring.ScoreCheap(candidate);
EventFeedUtil.RegisterCandidate(candidate);
}
@ -368,7 +401,7 @@ public static class InterestFeeds
return;
}
if (AgentHarness.Busy)
if (!AgentHarness.LiveFeedsAllowed)
{
return;
}
@ -492,7 +525,7 @@ public static class InterestFeeds
return;
}
if (AgentHarness.Busy)
if (!AgentHarness.LiveFeedsAllowed)
{
return;
}
@ -547,7 +580,7 @@ public static class InterestFeeds
return;
}
if (AgentHarness.Busy)
if (!AgentHarness.LiveFeedsAllowed)
{
return;
}
@ -615,7 +648,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;
@ -728,6 +761,27 @@ public static class InterestFeeds
label = occ.SubjectName + " · " + occ.EffectId;
}
InterestCompletionKind completion = grief
? InterestCompletionKind.HappinessGrief
: InterestCompletionKind.FixedDwell;
string statusId = grief ? "crying" : "";
if (!grief
&& !hatchMoment
&& !alphaMoment
&& !TryBindStatusOverlapCamera(
occ,
subject,
ref key,
ref minWatch,
ref maxWatch,
ref strength,
ref completion,
ref statusId))
{
return;
}
var candidate = new InterestCandidate
{
Key = key,
@ -747,16 +801,14 @@ public static class InterestFeeds
AssetId = occ.SubjectSpecies,
SpeciesId = occ.SubjectSpecies,
HappinessEffectId = occ.EffectId,
StatusId = grief ? "crying" : "",
StatusId = statusId,
CorrelationKey = occ.CorrelationKey,
CreatedAt = Time.unscaledTime,
LastSeenAt = Time.unscaledTime,
ExpiresAt = Time.unscaledTime + ttl,
MinWatch = minWatch,
MaxWatch = maxWatch,
Completion = grief
? InterestCompletionKind.HappinessGrief
: InterestCompletionKind.FixedDwell
Completion = completion
};
candidate.ParticipantIds.Add(occ.SubjectId);
if (occ.RelatedId != 0)
@ -778,6 +830,117 @@ public static class InterestFeeds
}
}
/// <summary>
/// Camera-worthy status-overlap happiness must not FixedDwell-claim a status the unit lacks.
/// Hold (onset): StatusPhase while live, else drop. Offset (end-ping): FixedDwell without status.
/// </summary>
private static bool TryBindStatusOverlapCamera(
HappinessOccurrence occ,
Actor subject,
ref string key,
ref float minWatch,
ref float maxWatch,
ref float strength,
ref InterestCompletionKind completion,
ref string statusId)
{
if (occ == null || string.IsNullOrEmpty(occ.StatusOverlapId))
{
return true;
}
HappinessStatusOverlapKind kind = ResolveStatusOverlapKind(occ);
if (kind == HappinessStatusOverlapKind.None)
{
return true;
}
string overlapId = occ.StatusOverlapId.Trim();
StatusInterestEntry statusEntry = EventCatalog.Status.GetOrFallback(overlapId);
if (!EventCatalog.IsCameraWorthy(statusEntry))
{
return true;
}
bool hasStatus = StatusGameApi.HasStatus(subject, overlapId)
|| InterestCompletionHasStatus(subject, overlapId);
if (hasStatus)
{
key = "status:" + overlapId + ":" + occ.SubjectId;
statusId = overlapId;
completion = InterestCompletionKind.StatusPhase;
minWatch = Mathf.Max(minWatch, 2f);
maxWatch = Mathf.Max(maxWatch, InterestScoringConfig.W.maxWatchStatus);
strength = Mathf.Max(strength, statusEntry.EventStrength);
return true;
}
if (kind == HappinessStatusOverlapKind.Offset)
{
// End-ping after the status cleared - short FixedDwell, keep happiness key.
maxWatch = Mathf.Min(maxWatch, 12f);
return true;
}
InterestDropLog.Record("status_overlap_absent", occ.EffectId ?? overlapId);
return false;
}
private static HappinessStatusOverlapKind ResolveStatusOverlapKind(HappinessOccurrence occ)
{
if (occ.StatusOverlapKind != HappinessStatusOverlapKind.None)
{
return occ.StatusOverlapKind;
}
if (string.IsNullOrEmpty(occ.StatusOverlapId))
{
return HappinessStatusOverlapKind.None;
}
string id = (occ.EffectId ?? "").Trim().ToLowerInvariant();
switch (id)
{
case "just_laughed":
case "just_sang":
case "just_swore":
case "just_cried":
case "just_had_tantrum":
case "just_inspired":
return HappinessStatusOverlapKind.Offset;
default:
break;
}
string clause = (occ.PlainClause ?? "").Trim();
if (clause.StartsWith("finishes", StringComparison.OrdinalIgnoreCase)
|| clause.StartsWith("comes down", StringComparison.OrdinalIgnoreCase)
|| clause.StartsWith("calms after", StringComparison.OrdinalIgnoreCase)
|| clause.StartsWith("lets inspiration", StringComparison.OrdinalIgnoreCase))
{
return HappinessStatusOverlapKind.Offset;
}
return HappinessStatusOverlapKind.Hold;
}
private static bool InterestCompletionHasStatus(Actor actor, string statusId)
{
if (actor == null || string.IsNullOrEmpty(statusId))
{
return false;
}
try
{
return actor.hasStatus(statusId);
}
catch
{
return false;
}
}
/// <summary>Direct registry insert for harness happiness signals (skips drain filters).</summary>
public static InterestCandidate RegisterHappinessSignal(
Actor subject,
@ -859,7 +1022,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

@ -84,6 +84,26 @@ public static class InterestDropLog
return list;
}
public static bool RecentContains(string needle, int max = 24)
{
if (string.IsNullOrEmpty(needle))
{
return false;
}
List<string> lines = Snapshot(max);
for (int i = 0; i < lines.Count; i++)
{
if (!string.IsNullOrEmpty(lines[i])
&& lines[i].IndexOf(needle, StringComparison.OrdinalIgnoreCase) >= 0)
{
return true;
}
}
return false;
}
public static string FormatRecent(int max = 8)
{
List<string> lines = Snapshot(max);

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

@ -1,6 +1,5 @@
using ai.behaviours;
using HarmonyLib;
using UnityEngine;
namespace IdleSpectator;
@ -12,7 +11,7 @@ public static class BuildingEventPatches
[HarmonyPostfix]
public static void PostfixDamageBuilding(Actor pActor, BehResult __result)
{
// Routine siege chips are Layer B noise. Camera waits for consume/destroy.
// Routine siege chips are Layer B noise. Camera waits for consume.
_ = pActor;
_ = __result;
}
@ -44,79 +43,15 @@ public static class BuildingEventPatches
[HarmonyPostfix]
public static void PostfixStartDestroy(Building __instance)
{
if (__instance == null || AgentHarness.Busy)
{
return;
}
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)
{
pos = v;
}
string buildingName = "";
try
{
if (__instance.asset != null)
{
buildingName = __instance.asset.id ?? "";
}
}
catch
{
buildingName = "";
}
Actor near = pos != Vector3.zero
? EventFeedUtil.NearestUnit(pos, 48f)
: null;
string label = EventReason.BuildingFalls(buildingName, near);
if (near == null)
{
if (pos == Vector3.zero)
{
return;
}
string locKey = "building:destroy:" + buildingName + ":loc";
EventFeedUtil.Register(
locKey,
"building",
"Spectacle",
label,
string.IsNullOrEmpty(buildingName) ? "building_destroy" : buildingName,
88f,
pos,
follow: null,
locationOnly: true);
return;
}
string key = "building:destroy:" + buildingName + ":" + EventFeedUtil.SafeId(near);
EventFeedUtil.Register(
key,
"building",
"Spectacle",
label,
string.IsNullOrEmpty(buildingName) ? "building_destroy" : buildingName,
88f,
near.current_position,
near);
}
catch
{
// ignore API mismatches
}
// Ambient object/building falls are Layer B only.
// Nearest-unit attach + "A Tree falls near X" was winning Watching then blanking via identity_filter.
// Intentional spectacle remains building_consume (actor-led eat).
_ = __instance;
}
private static void Emit(string eventId, float strength, Actor actor, string label)
{
if (AgentHarness.Busy)
if (!AgentHarness.LiveFeedsAllowed)
{
return;
}

View file

@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using HarmonyLib;
using UnityEngine;
@ -18,7 +19,7 @@ public static class DeferredEventPatches
[HarmonyPostfix]
public static void PostfixGenerateItem(object __result, object[] __args)
{
if (AgentHarness.Busy)
if (!AgentHarness.LiveFeedsAllowed)
{
return;
}
@ -50,7 +51,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 +94,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 +148,7 @@ public static class DeferredEventPatches
[HarmonyPostfix]
public static void PostfixClickedFinal(Vector2Int pPos, GodPower pPower)
{
if (AgentHarness.Busy)
if (!AgentHarness.LiveFeedsAllowed)
{
return;
}
@ -210,7 +211,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 +223,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 +241,7 @@ public static class DeferredEventPatches
[HarmonyPostfix]
public static void PostfixMutateFrom(Subspecies __instance)
{
if (AgentHarness.Busy || __instance == null)
if (!AgentHarness.LiveFeedsAllowed || __instance == null)
{
return;
}
@ -265,11 +266,111 @@ public static class DeferredEventPatches
DeferredInterestFeed.EmitGene(geneId, anchor);
}
[HarmonyPatch(typeof(Chromosome), nameof(Chromosome.addGene))]
[HarmonyPostfix]
public static void PostfixAddGene(Chromosome __instance, GeneAsset pGeneAsset)
{
if (!AgentHarness.LiveFeedsAllowed || pGeneAsset == null)
{
return;
}
string geneId = ReadAssetId(pGeneAsset);
if (string.IsNullOrEmpty(geneId)
|| geneId.Equals("empty", StringComparison.OrdinalIgnoreCase)
|| geneId.IndexOf("void", StringComparison.OrdinalIgnoreCase) >= 0)
{
return;
}
Actor anchor = FindUnitNearChromosome(__instance);
if (anchor == null || !anchor.isAlive())
{
return;
}
DeferredInterestFeed.EmitGene(geneId, anchor);
}
[HarmonyPatch(typeof(Subspecies), nameof(Subspecies.addTrait), new Type[] { typeof(SubspeciesTrait), typeof(bool) })]
[HarmonyPostfix]
public static void PostfixSubspeciesAddTrait(Subspecies __instance, SubspeciesTrait pTrait, bool __result)
{
if (!__result || !AgentHarness.LiveFeedsAllowed || __instance == null || pTrait == null)
{
return;
}
Actor anchor = FindUnitForSubspecies(__instance);
if (anchor == null || !anchor.isAlive())
{
return;
}
string traitId = ReadAssetId(pTrait) ?? pTrait.id;
if (string.IsNullOrEmpty(traitId))
{
return;
}
DeferredInterestFeed.EmitSubspeciesTrait(traitId, anchor);
}
[HarmonyPatch(typeof(MetaObjectWithTraits<CultureData, CultureTrait>), "addTrait", new Type[] { typeof(string), typeof(bool) })]
[HarmonyPostfix]
public static void PostfixCultureAddTrait(Culture __instance, string __0, bool __result)
{
EmitMetaTraitGain(__result, __instance, __0, "culture_trait", DeferredInterestFeed.EmitCultureTrait);
}
[HarmonyPatch(typeof(MetaObjectWithTraits<ReligionData, ReligionTrait>), "addTrait", new Type[] { typeof(string), typeof(bool) })]
[HarmonyPostfix]
public static void PostfixReligionAddTrait(Religion __instance, string __0, bool __result)
{
EmitMetaTraitGain(__result, __instance, __0, "religion_trait", DeferredInterestFeed.EmitReligionTrait);
}
[HarmonyPatch(typeof(MetaObjectWithTraits<ClanData, ClanTrait>), "addTrait", new Type[] { typeof(string), typeof(bool) })]
[HarmonyPostfix]
public static void PostfixClanAddTrait(Clan __instance, string __0, bool __result)
{
EmitMetaTraitGain(__result, __instance, __0, "clan_trait", DeferredInterestFeed.EmitClanTrait);
}
[HarmonyPatch(typeof(MetaObjectWithTraits<LanguageData, LanguageTrait>), "addTrait", new Type[] { typeof(string), typeof(bool) })]
[HarmonyPostfix]
public static void PostfixLanguageAddTrait(Language __instance, string __0, bool __result)
{
EmitMetaTraitGain(__result, __instance, __0, "language_trait", DeferredInterestFeed.EmitLanguageTrait);
}
private static void EmitMetaTraitGain(
bool added,
object meta,
string traitId,
string domain,
Action<string, Actor> emit)
{
if (!added || !AgentHarness.LiveFeedsAllowed || string.IsNullOrEmpty(traitId) || emit == null)
{
return;
}
Actor anchor = FindUnitForMeta(meta);
if (anchor == null || !anchor.isAlive())
{
return;
}
emit(traitId, anchor);
_ = domain;
}
[HarmonyPatch(typeof(Actor), nameof(Actor.setDecisionCooldown))]
[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,28 +400,12 @@ 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;
}
string id = "";
try
{
object data = __instance.data;
object ph = data?.GetType().GetField("phenotype")?.GetValue(data)
?? data?.GetType().GetProperty("phenotype")?.GetValue(data, null);
if (ph != null)
{
id = ph.ToString() ?? "";
}
}
catch
{
id = "";
}
// Placeholder / empty ids are not camera events (catalog ambient is CreatesInterest=false).
string id = ResolvePhenotypeId(__instance);
if (string.IsNullOrEmpty(id)
|| string.Equals(id, "phenotype", StringComparison.OrdinalIgnoreCase))
{
@ -348,7 +433,7 @@ public static class DeferredEventPatches
private static void EmitSubspecies(object subspecies, string fallbackId)
{
if (AgentHarness.Busy || subspecies == null)
if (!AgentHarness.LiveFeedsAllowed || subspecies == null)
{
return;
}
@ -585,6 +670,129 @@ public static class DeferredEventPatches
return Vector3.zero;
}
private static string ResolvePhenotypeId(Actor actor)
{
try
{
object data = actor.data;
if (data == null)
{
return null;
}
object ph = data.GetType().GetField("phenotype")?.GetValue(data)
?? data.GetType().GetProperty("phenotype")?.GetValue(data, null);
if (ph != null)
{
string raw = ph.ToString();
if (!string.IsNullOrEmpty(raw)
&& !int.TryParse(raw, out _)
&& !string.Equals(raw, "0", StringComparison.Ordinal))
{
return raw;
}
}
int index = 0;
object idxObj = data.GetType().GetField("phenotype_index")?.GetValue(data)
?? data.GetType().GetProperty("phenotype_index")?.GetValue(data, null);
if (idxObj is int i)
{
index = i;
}
else if (idxObj != null)
{
int.TryParse(idxObj.ToString(), out index);
}
if (index <= 0 || AssetManager.phenotype_library?.list == null)
{
return null;
}
foreach (PhenotypeAsset asset in AssetManager.phenotype_library.list)
{
if (asset != null && asset.phenotype_index == index)
{
return asset.id;
}
}
}
catch
{
// ignore
}
return null;
}
private static Actor FindUnitNearChromosome(Chromosome chromosome)
{
if (chromosome == null || World.world?.units == null)
{
return null;
}
try
{
foreach (Actor unit in World.world.units)
{
if (unit == null || !unit.isAlive() || unit.subspecies?.nucleus?.chromosomes == null)
{
continue;
}
List<Chromosome> list = unit.subspecies.nucleus.chromosomes;
for (int i = 0; i < list.Count; i++)
{
if (ReferenceEquals(list[i], chromosome))
{
return unit;
}
}
}
}
catch
{
// ignore
}
return MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
}
private static Actor FindUnitForMeta(object meta)
{
if (meta == null || World.world?.units == null)
{
return null;
}
try
{
foreach (Actor unit in World.world.units)
{
if (unit == null || !unit.isAlive())
{
continue;
}
if (ReferenceEquals(unit.culture, meta)
|| ReferenceEquals(unit.religion, meta)
|| ReferenceEquals(unit.clan, meta)
|| ReferenceEquals(unit.language, meta))
{
return unit;
}
}
}
catch
{
// ignore
}
return MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
}
private static string ReadAssetId(object obj)
{
if (obj == null)
@ -612,9 +820,32 @@ public static class DeferredEventPatches
}
}
object data = obj.GetType().GetField("data")?.GetValue(obj)
?? obj.GetType().GetProperty("data")?.GetValue(obj, null);
if (data != null)
{
object assetId = data.GetType().GetField("asset_id")?.GetValue(data)
?? data.GetType().GetProperty("asset_id")?.GetValue(data, null);
if (assetId != null && !string.IsNullOrEmpty(assetId.ToString()))
{
return assetId.ToString();
}
}
object direct = obj.GetType().GetField("id")?.GetValue(obj)
?? obj.GetType().GetProperty("id")?.GetValue(obj, null);
return direct?.ToString();
// Item/instance numeric ids are not catalog asset ids.
if (direct is string ds && !string.IsNullOrEmpty(ds))
{
return ds;
}
if (direct != null && !(direct is long) && !(direct is int) && !(direct is ulong))
{
return direct.ToString();
}
return null;
}
catch
{

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

@ -70,6 +70,7 @@ public static class HappinessEventRouter
_lastEffectId = "";
_counterSubjectId = 0;
_nextSequence = 1;
InterestFeeds.OnHappinessRouterCleared();
}
/// <summary>Push listener for spectator interest intake (in addition to <see cref="DrainSince"/>).</summary>
@ -539,7 +540,8 @@ public static class HappinessEventRouter
Context = entry.Context,
InterestCategory = entry.InterestCategory,
CanonicalMilestoneKey = entry.CanonicalMilestoneKey,
StatusOverlapId = entry.StatusOverlapId
StatusOverlapId = entry.StatusOverlapId,
StatusOverlapKind = entry.StatusOverlapKind
};
}

View file

@ -31,6 +31,17 @@ public enum HappinessOverlapPolicy
Continuation
}
/// <summary>
/// How a happiness <see cref="HappinessCatalogEntry.StatusOverlapId"/> relates to camera hold.
/// Hold = onset claim (require live status); Offset = end-ping FixedDwell when status is gone.
/// </summary>
public enum HappinessStatusOverlapKind
{
None,
Hold,
Offset
}
public enum HappinessRelationRole
{
None,
@ -69,6 +80,7 @@ public sealed class HappinessCatalogEntry
public float EventStrength = 40f;
public string CanonicalMilestoneKey = "";
public string StatusOverlapId = "";
public HappinessStatusOverlapKind StatusOverlapKind = HappinessStatusOverlapKind.None;
public string[] VariantsWithRelated = Array.Empty<string>();
public string[] VariantsWithoutRelated = Array.Empty<string>();
public bool IsFallback;
@ -102,6 +114,7 @@ public sealed class HappinessOccurrence
public string InterestCategory = "";
public string CanonicalMilestoneKey = "";
public string StatusOverlapId = "";
public HappinessStatusOverlapKind StatusOverlapKind = HappinessStatusOverlapKind.None;
public int SampledCount = 1;
public int TotalAffectedCount = 1;
public string CivEventKey = "";

View file

@ -44,6 +44,15 @@ internal static class HarnessScenarios
case "watch_reason":
case "watch_reason_clarity":
return WatchReasonClarity();
case "camera_presentability":
case "presentability":
return CameraPresentability();
case "status_overlap_camera":
case "status_overlap":
return StatusOverlapCamera();
case "combat_focus":
case "combat_tip_align":
return CombatFocusAlign();
case "director_action_priority":
case "action_priority":
return DirectorActionPriority();
@ -58,6 +67,20 @@ internal static class HarnessScenarios
return DisasterWarAudit();
case "event_coverage":
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":
return EventSuite();
case "domain_event_audit":
return DomainEventAudit();
case "event_tracking":
@ -164,7 +187,8 @@ internal static class HarnessScenarios
Nested("reg_action_priority", "director_action_priority"),
Nested("reg_discovery", "discovery"),
Nested("reg_worldlog", "world_log"),
Nested("reg_event_coverage", "event_coverage"),
Nested("reg_presentability", "camera_presentability"),
Nested("reg_event_coverage", "event_suite"),
Nested("reg_chronicle", "chronicle_smoke"),
Nested("reg_activity", "activity_idle_smoke"),
Nested("reg_activity_status", "activity_status_smoke"),
@ -333,9 +357,9 @@ internal static class HarnessScenarios
Step("st18", "assert", expect: "activity_status_gain_count", value: "1", label: "exact"),
Step("st19", "assert", expect: "activity_status_refresh_count", value: "1", label: "min"),
// Second distinct gain
// Second distinct gain (min: world may emit an extra status mid-suite)
Step("st20", "status_apply", value: "sleeping"),
Step("st21", "assert", expect: "activity_status_gain_count", value: "2", label: "exact"),
Step("st21", "assert", expect: "activity_status_gain_count", value: "2", label: "min"),
Step("st22", "assert", expect: "activity_log_contains", value: "asleep"),
// Explicit removal
@ -486,6 +510,7 @@ internal static class HarnessScenarios
Step("c34", "assert", expect: "tip_contains", value: "auto"),
Step("c35", "assert", expect: "power_bar", value: "false"),
Step("c36", "assert", expect: "no_bad"),
Step("c36b", "assert", expect: "no_placeholder_tip"),
Step("c40", "watch", asset: "auto", label: "Retarget A: {asset}", tier: "Curiosity"),
Step("c41", "wait", wait: 0.25f),
@ -938,6 +963,189 @@ internal static class HarnessScenarios
};
}
/// <summary>
/// Selection = presentation: unpresentable Labels never register A;
/// presentable EventReason does; focus survives max_cap; no placeholder tip.
/// </summary>
private static List<HarnessCommand> CameraPresentability()
{
return new List<HarnessCommand>
{
Step("cp0", "dismiss_windows"),
Step("cp1", "wait_world"),
Step("cp2", "set_setting", expect: "enabled", value: "true"),
Step("cp3", "fast_timing", value: "true"),
Step("cp4", "pick_unit", asset: "auto"),
Step("cp5", "spectator", value: "off"),
Step("cp6", "spectator", value: "on"),
Step("cp7", "focus", asset: "auto"),
Step("cp8", "reset_counters"),
// Article-led fall shape must not enter the registry.
Step("cp10", "presentability_probe", value: "fall"),
Step("cp11", "assert", expect: "drop_contains", value: "unpresentable"),
Step("cp12", "assert", expect: "interest_no_key", value: "probe:presentability"),
// Subject-led fight Label may register.
Step("cp20", "presentability_probe", value: "good"),
Step("cp21", "assert", expect: "interest_has_key", value: "probe:presentability"),
// Clear probe pending so force-session owns the tip (regression worlds are noisy).
Step("cp22", "interest_expire_pending", value: ""),
Step("cp23", "interest_end_session"),
// Presentable tip takes Watching; never placeholder.
Step("cp30", "interest_force_session", asset: "auto", label: "{a} is fighting", tier: "Action", expect: "cp_fight"),
Step("cp30b", "assert", expect: "tip_contains", value: "is fighting"),
Step("cp31", "director_run", wait: 0.6f),
Step("cp32", "assert", expect: "tip_contains", value: "is fighting"),
Step("cp33", "assert", expect: "no_placeholder_tip"),
Step("cp34", "assert", expect: "has_focus", value: "true"),
// Mid-scene camera clear must restore focus within one director tick (Phase D).
Step("cp35", "reset_counters"),
Step("cp36", "camera_clear_focus"),
Step("cp37", "director_run", wait: 0.25f),
Step("cp38", "assert", expect: "has_focus", value: "true"),
// Intentional clear trips one focus-dropped BAD; after repair there must be no gap spam.
Step("cp38b", "reset_counters"),
Step("cp39", "director_run", wait: 0.4f),
Step("cp39b", "assert", expect: "no_bad"),
// Max-cap end must keep a living focus unit.
Step("cp40", "age_current", wait: 9f),
Step("cp41", "director_run", wait: 1.2f),
Step("cp42", "assert", expect: "has_focus", value: "true"),
Step("cp43", "reset_counters"),
Step("cp43b", "director_run", wait: 0.4f),
Step("cp43c", "assert", expect: "no_bad"),
Step("cp44", "assert", expect: "no_placeholder_tip"),
// Clear again with no current scene after max_cap.
Step("cp50", "interest_end_session"),
Step("cp51", "camera_clear_focus"),
Step("cp52", "director_run", wait: 0.25f),
Step("cp53", "assert", expect: "has_focus", value: "true"),
Step("cp53b", "reset_counters"),
Step("cp54", "director_run", wait: 0.4f),
Step("cp54b", "assert", expect: "no_bad"),
Step("cp90", "fast_timing", value: "false"),
Step("cp99", "snapshot"),
Nested("cp_status_overlap", "status_overlap_camera"),
Nested("cp_combat_focus", "combat_focus"),
};
}
/// <summary>
/// Combat tip subject must match focus; death handoff rewrites tip; cold combat clears fight tip.
/// </summary>
private static List<HarnessCommand> CombatFocusAlign()
{
return new List<HarnessCommand>
{
Step("cf0", "dismiss_windows"),
Step("cf1", "wait_world"),
Step("cf2", "set_setting", expect: "enabled", value: "true"),
Step("cf3", "fast_timing", value: "true"),
Step("cf4", "spawn", asset: "human", count: 1),
Step("cf5", "spawn", asset: "wolf", count: 1),
Step("cf6", "spectator", value: "off"),
Step("cf7", "spectator", value: "on"),
Step("cf8", "pick_unit", asset: "human"),
Step("cf9", "focus", asset: "human"),
Step("cf10", "interest_end_session"),
// Combat scene: human fighting wolf. Clear follow → related (wolf) must own tip+focus.
Step("cf20", "interest_combat_session", asset: "human", value: "wolf", expect: "cf_fight"),
Step("cf21", "assert", expect: "tip_contains", value: "fighting"),
Step("cf22", "interest_clear_follow", asset: "wolf"),
Step("cf23", "combat_maintain_focus"),
Step("cf24", "assert", expect: "has_focus", value: "true"),
Step("cf25", "assert", expect: "tip_matches_focus"),
Step("cf26", "assert", expect: "unit_asset", asset: "wolf"),
// Cold combat must not keep "is fighting".
Step("cf30", "interest_combat_session", asset: "human", value: "wolf", expect: "cf_cold"),
Step("cf31", "assert", expect: "tip_contains", value: "fighting"),
Step("cf32", "interest_release_force"),
Step("cf33", "interest_mark_inactive", value: "1"),
Step("cf34", "combat_maintain_focus"),
Step("cf35", "assert", expect: "tip_not_contains", value: "fighting"),
Step("cf90", "fast_timing", value: "false"),
Step("cf99", "snapshot"),
};
}
/// <summary>
/// Status-overlap happiness must not FixedDwell-claim a camera-worthy status the unit lacks.
/// Hold while status is live; offset end-pings still register briefly without it.
/// </summary>
private static List<HarnessCommand> StatusOverlapCamera()
{
return new List<HarnessCommand>
{
Step("so0", "dismiss_windows"),
Step("so1", "wait_world"),
Step("so2", "set_setting", expect: "enabled", value: "true"),
Step("so3", "fast_timing", value: "true"),
Step("so4", "spawn", asset: "human", count: 1),
Step("so5", "pick_unit", asset: "human"),
Step("so6", "spectator", value: "off"),
Step("so7", "spectator", value: "on"),
Step("so8", "focus", asset: "auto"),
Step("so9", "interest_end_session"),
Step("so9b", "interest_expire_pending", value: ""),
Step("so9c", "force_live_feeds", value: "true"),
// Orphan onset: just_cursed without cursed status must not own the camera.
Step("so10", "happiness_reset"),
Step("so11", "drop_clear"),
Step("so12", "happiness_remember_only", value: "just_cursed"),
Step("so13", "interest_feeds_tick"),
Step("so14", "assert", expect: "drop_contains", value: "status_overlap_absent"),
Step("so15", "assert", expect: "interest_no_key", value: "just_cursed"),
Step("so16", "assert", expect: "interest_no_key", value: "status:cursed"),
// Same class: possessed onset without status.
Step("so20", "drop_clear"),
Step("so21", "happiness_remember_only", value: "just_possessed"),
Step("so22", "interest_feeds_tick"),
Step("so23", "assert", expect: "drop_contains", value: "status_overlap_absent"),
Step("so24", "assert", expect: "interest_no_key", value: "just_possessed"),
// Offset end-ping still registers without the status.
Step("so30", "drop_clear"),
Step("so31", "happiness_remember_only", value: "just_inspired"),
Step("so32", "interest_feeds_tick"),
Step("so33", "assert", expect: "interest_has_key", value: "just_inspired"),
Step("so34", "interest_expire_pending", value: "just_inspired"),
// Live cursed status owns StatusPhase; happiness Continuation merges.
// Short timer: cursed may not clear via finishStatusEffect (re-applies / sticky).
Step("so40", "interest_end_session"),
Step("so41", "interest_expire_pending", value: ""),
Step("so42", "drop_clear"),
Step("so43", "status_apply", value: "cursed", label: "0.4"),
Step("so44", "assert", expect: "interest_has_key", value: "status:cursed"),
Step("so45", "happiness_remember_only", value: "just_cursed"),
Step("so46", "interest_feeds_tick"),
Step("so47", "assert", expect: "interest_has_key", value: "status:cursed"),
Step("so48", "assert", expect: "interest_no_key", value: "happiness:just_cursed"),
Step("so49", "wait", wait: 1.1f),
Step("so50", "assert", expect: "activity_status_active", value: "cursed", label: "false"),
Step("so50b", "age_current", wait: 3f),
Step("so50c", "director_run", wait: 1.2f),
Step("so51", "interest_end_session"),
Step("so52", "interest_expire_pending", value: "status:cursed"),
Step("so53", "assert", expect: "interest_no_key", value: "status:cursed"),
Step("so90", "force_live_feeds", value: "false"),
Step("so91", "fast_timing", value: "false"),
Step("so99", "snapshot"),
};
}
/// <summary>
/// Watch-reason row must explain why the camera is here (event), not who (title/name).
/// </summary>
@ -970,6 +1178,7 @@ internal static class HarnessScenarios
Step("wr21", "assert", expect: "dossier_not_contains", value: "Action ·"),
Step("wr21b", "assert", expect: "dossier_not_contains", value: "Live ·"),
Step("wr22", "assert", expect: "dossier_not_contains", value: "King Isemward"),
Step("wr22b", "assert", expect: "no_placeholder_tip"),
Step("wr23", "screenshot", value: "hud-watch-reason-identity.png"),
Step("wr24", "wait", wait: 0.55f),
@ -1303,11 +1512,8 @@ internal static class HarnessScenarios
Step("ec13", "assert", expect: "activity_happiness_audit"),
Step("ec14", "assert", expect: "domain_event_audit"),
// Previously silent-dropped WorldLog disaster id must enter the registry
Step("ec20", "world_log_feed", asset: "disaster_tornado"),
Step("ec21", "assert", expect: "interest_has_key", value: "worldlog:disaster_tornado"),
Step("ec22", "world_log_feed", asset: "disaster_alien_invasion"),
Step("ec23", "assert", expect: "interest_has_key", value: "worldlog:disaster_alien_invasion"),
// Every camera-worthy catalog id must A-register (data-driven).
Step("ec30", "assert", expect: "event_inject_coverage"),
Step("ec90", "assert", expect: "health"),
Step("ec91", "assert", expect: "no_bad"),
@ -1315,6 +1521,134 @@ internal static class HarnessScenarios
};
}
private static List<HarnessCommand> EventInjectCoverage()
{
return new List<HarnessCommand>
{
Step("eic0", "dismiss_windows"),
Step("eic1", "wait_world"),
Step("eic2", "set_setting", expect: "enabled", value: "true"),
Step("eic3", "pick_unit", asset: "auto"),
Step("eic4", "spectator", value: "off"),
Step("eic5", "spectator", value: "on"),
Step("eic6", "focus", asset: "auto"),
Step("eic7", "assert", expect: "event_inject_coverage"),
Step("eic8", "assert", expect: "no_bad"),
Step("eic9", "snapshot")
};
}
/// <summary>
/// Curated live API/Beh outcomes (not full catalog). Expands when patches gain
/// deterministic harness wrappers. Inject coverage owns "every id registers."
/// </summary>
private static List<HarnessCommand> EventLiveOutcomeSmoke()
{
return new List<HarnessCommand>
{
Nested("elo_family", "family_trigger_smoke"),
Step("elo0", "dismiss_windows"),
Step("elo1", "wait_world"),
Step("elo2", "pick_unit", asset: "human"),
Step("elo3", "spectator", value: "off"),
Step("elo4", "spectator", value: "on"),
Step("elo5", "focus", asset: "auto"),
Step("elo10", "activity_status_reset"),
Step("elo11", "status_apply", value: "confused"),
Step("elo12", "assert", expect: "activity_status_gain_count", value: "1", label: "min"),
Step("elo13", "assert", expect: "activity_log_contains", value: "confused"),
Step("elo19", "happiness_reset"),
Step("elo20", "happiness_apply", value: "just_became_adult"),
Step("elo21", "assert", expect: "activity_happiness_count", value: "1", label: "min"),
Step("elo22", "assert", expect: "activity_log_contains", value: "adult"),
Step("elo90", "assert", expect: "no_bad"),
Step("elo99", "snapshot")
};
}
/// <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"),
};
}
private static List<HarnessCommand> MutationDiscovery()
{
return new List<HarnessCommand>
@ -1401,9 +1735,10 @@ internal static class HarnessScenarios
Step("act17e", "activity_force", asset: "BehUnloadResources", label: "wheat@Riverhold",
count: 1, tier: "beat", value: "human"),
Step("act17f", "activity_age", value: "12"),
Step("act17g", "assert", expect: "dossier_history_not_contains", value: "Hunts"),
// Needle PreyThing (not "Hunts") - species voice may omit "hunt(s)" entirely.
Step("act17g", "assert", expect: "dossier_history_not_contains", value: "PreyThing"),
Step("act17h", "assert", expect: "dossier_history_contains", value: "wheat"),
Step("act17i", "assert", expect: "activity_log_contains", value: "Hunts"),
Step("act17i", "assert", expect: "activity_log_contains", value: "PreyThing"),
// Rebuild a busy ring for later variety / count asserts.
Step("act17j", "activity_force", asset: "BehPollinate", label: "Works the flower", count: 2, tier: "beat"),
Step("act17k", "activity_force", asset: "zzz_harness_act", label: "Harness pollen trail", count: 1),

View file

@ -44,6 +44,8 @@ public static class InterestDirector
private static float _lastFeedsAt = -999f;
private static float _enabledAt = -999f;
private static float _inactiveSince = -999f;
private static float _lastCombatFocusAt = -999f;
private const float CombatFocusThrottleSeconds = 0.4f;
private static readonly List<InterestCandidate> PendingScratch = new List<InterestCandidate>(96);
public static string CurrentTierName => CurrentScoreLabel;
@ -357,6 +359,7 @@ public static class InterestDirector
float sinceSwitch = now - _lastSwitchAt;
UpdateSessionLiveness(now, onCurrent);
MaintainCombatFocus(now, force: false);
InterestCandidate next = SelectNext(now);
if (next != null && CanSwitchTo(next, onCurrent, sinceSwitch))
@ -374,6 +377,8 @@ public static class InterestDirector
// Pending events own the camera - never Character-fill over them.
if (InterestRegistry.HasPendingEvent())
{
// Still repair a missing follow so death mid-scene does not flash the power bar.
MaintainCameraFocus(now);
return;
}
@ -388,6 +393,8 @@ public static class InterestDirector
{
TryCharacterFill(force: false);
}
MaintainCameraFocus(now);
}
private static void UpdateSessionLiveness(float now, float onCurrent)
@ -413,6 +420,14 @@ public static class InterestDirector
return;
}
// No living subject left: do not sit in quiet_grace with an empty camera.
if (!_current.HasFollowUnit
&& (_current.RelatedUnit == null || !_current.RelatedUnit.isAlive()))
{
EndCurrent(now, reason: "follow_lost");
return;
}
// Ambient: end promptly when the vignette goes cold - do not hold for minCameraDwell.
if (IsAmbientShot(_current))
{
@ -454,8 +469,26 @@ public static class InterestDirector
return;
}
if (_current.Completion == InterestCompletionKind.CombatActive)
{
MaintainCombatFocus(Time.unscaledTime, force: true);
if (!_current.HasFollowUnit
&& (_current.RelatedUnit == null || !_current.RelatedUnit.isAlive()))
{
EndCurrent(Time.unscaledTime, reason: "follow_lost");
}
return;
}
if (_current.HasFollowUnit)
{
// Living subject still owned - reattach if vanilla cleared focus mid-scene.
if (!HasLivingCameraFocus() || MoveCamera._focus_unit != _current.FollowUnit)
{
CameraDirector.Watch(_current.ToInterestEvent());
}
return;
}
@ -472,6 +505,158 @@ public static class InterestDirector
EndCurrent(Time.unscaledTime, reason: "follow_lost");
}
/// <summary>
/// Combat scenes: keep camera on the highest-scored living participant and rewrite the tip
/// so the subject always matches focus. Clears fight Labels once combat goes cold.
/// </summary>
private static void MaintainCombatFocus(float now, bool force)
{
if (_current == null || _current.Completion != InterestCompletionKind.CombatActive)
{
return;
}
bool combatHot = InterestCompletion.IsActive(_current, _currentStartedAt, now);
if (!combatHot)
{
ClearStaleCombatLabel(now);
return;
}
if (!force && now - _lastCombatFocusAt < CombatFocusThrottleSeconds)
{
return;
}
_lastCombatFocusAt = now;
if (!WorldActivityScanner.TryPickBestCombatFocus(
_current, out Actor best, out Actor foe, out int fighters))
{
return;
}
bool mass = string.Equals(_current.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase)
|| fighters > 2
|| _current.ParticipantCount > 2;
string label = mass
? EventReason.Battle(best, fighters)
: EventReason.Fight(best, foe);
bool followChanged = _current.FollowUnit != best;
bool labelChanged = !string.Equals(_current.Label ?? "", label ?? "", StringComparison.Ordinal);
_current.FollowUnit = best;
_current.SubjectId = EventFeedUtil.SafeId(best);
_current.RelatedUnit = foe;
_current.RelatedId = foe != null ? EventFeedUtil.SafeId(foe) : 0;
_current.Label = label;
try
{
_current.Position = best.current_position;
}
catch
{
// keep prior position
}
if (followChanged
|| labelChanged
|| !HasLivingCameraFocus()
|| MoveCamera._focus_unit != best)
{
CameraDirector.Watch(_current.ToInterestEvent());
}
}
private static void ClearStaleCombatLabel(float now)
{
if (_current == null || string.IsNullOrEmpty(_current.Label))
{
return;
}
if (_current.Label.IndexOf("fighting", StringComparison.OrdinalIgnoreCase) < 0
&& _current.Label.IndexOf("fight of", StringComparison.OrdinalIgnoreCase) < 0)
{
return;
}
_current.Label = "";
_lastCombatFocusAt = now;
CameraDirector.Watch(_current.ToInterestEvent());
}
/// <summary>Harness: force one combat focus maintenance pass.</summary>
public static void HarnessMaintainCombatFocus()
{
MaintainCombatFocus(Time.unscaledTime, force: true);
}
/// <summary>
/// Keep a living focus unit whenever Idle Spectator is on.
/// Covers death mid-combat and quiet_grace windows where vanilla cleared follow.
/// </summary>
private static void MaintainCameraFocus(float now)
{
if (!SpectatorMode.Active)
{
return;
}
if (HasLivingCameraFocus())
{
return;
}
if (_current != null)
{
if (_current.Completion == InterestCompletionKind.CombatActive)
{
MaintainCombatFocus(now, force: true);
if (HasLivingCameraFocus())
{
return;
}
}
if (_current.HasFollowUnit)
{
CameraDirector.Watch(_current.ToInterestEvent());
if (HasLivingCameraFocus())
{
return;
}
}
if (_current.RelatedUnit != null && _current.RelatedUnit.isAlive())
{
// Non-combat ownership handoff; combat already tried picker above.
if (_current.Completion != InterestCompletionKind.CombatActive)
{
_current.FollowUnit = _current.RelatedUnit;
_current.SubjectId = EventFeedUtil.SafeId(_current.RelatedUnit);
CameraDirector.Watch(_current.ToInterestEvent());
if (HasLivingCameraFocus())
{
return;
}
}
}
// Orphan scene (no living follow/related) - end and refill.
EndCurrent(now, reason: "follow_lost");
return;
}
EnsureIdleFocus(now);
}
private static bool HasLivingCameraFocus()
{
return MoveCamera.hasFocusUnit()
&& MoveCamera._focus_unit != null
&& MoveCamera._focus_unit.isAlive();
}
private static void EndCurrent(float now, string reason)
{
if (_current != null)
@ -494,6 +679,37 @@ public static class InterestDirector
_interrupted = null;
_current = null;
_inactiveSince = -999f;
EnsureIdleFocus(now);
}
/// <summary>
/// After scene end, keep a living focus unit so idle never flashes the power bar.
/// </summary>
private static void EnsureIdleFocus(float now)
{
if (!SpectatorMode.Active)
{
return;
}
if (HasLivingCameraFocus())
{
return;
}
TryCharacterFill(force: true);
if (HasLivingCameraFocus())
{
return;
}
Actor any = EventFeedUtil.AnyAliveUnit();
if (any != null)
{
CameraDirector.FocusUnit(any);
}
_ = now;
}
private static InterestCandidate SelectNext(float now)
@ -515,10 +731,19 @@ public static class InterestDirector
continue;
}
// Drop stale / not-worth-watching shots from the pending set.
// Drop stale / not-worth-watching / unpresentable shots from the pending set.
// Harness synthetic labels may be unpresentable (dossier blanks); keep them for tip tests.
bool harness = string.Equals(c.Source, "harness", StringComparison.OrdinalIgnoreCase);
bool presentable = harness || EventPresentability.WouldShow(c);
if (!IsWorthWatchingNow(c, now, selectingDuringGrace: InQuietGrace)
|| IsStaleFreshLifeMoment(c, now))
|| IsStaleFreshLifeMoment(c, now)
|| !presentable)
{
if (!presentable)
{
InterestDropLog.Record("unpresentable", c.Label ?? c.Key);
}
InterestRegistry.Remove(c.Key);
PendingScratch.RemoveAt(i);
}
@ -1139,6 +1364,14 @@ public static class InterestDirector
return;
}
if (!EventPresentability.WouldShow(next)
&& !string.Equals(next.Source, "harness", StringComparison.OrdinalIgnoreCase))
{
InterestDropLog.Record("unpresentable", next.Label ?? next.Key);
InterestRegistry.Remove(next.Key);
return;
}
if (_current != null
&& _current.Resumable
&& next.TotalScore >= _current.TotalScore + InterestScoringConfig.W.cutInMargin)
@ -1188,15 +1421,15 @@ public static class InterestDirector
return;
}
// Harness batches inject their own candidates; never let a live Action/Story
// ambient fill steal the camera before trigger_interest / discovery steps.
if (AgentHarness.Busy && ambient.Score >= InterestScoringConfig.W.noticeScoreMin)
// Character fill never owns an orange reason - empty Label + CharacterLed.
// Force fill score so RegisterDirect does not publish as EventLed.
ambient.Label = "";
float fillCap = InterestScoringConfig.W.fillScoreMax > 0f
? InterestScoringConfig.W.fillScoreMax * 0.5f
: 20f;
if (ambient.Score > fillCap || AgentHarness.Busy)
{
ambient.Score = InterestScoringConfig.W.fillScoreMax * 0.5f;
if (string.IsNullOrEmpty(ambient.Label))
{
ambient.Label = "Ambient";
}
ambient.Score = fillCap;
}
InterestCandidate candidate = InterestFeeds.RegisterDirect(ambient);

View file

@ -292,17 +292,54 @@ public sealed class UnitDossier
}
}
return SceneLabelBeat(scene, d) ?? "";
return EvaluateSceneLabel(scene, d, recordDrops: true) ?? "";
}
private static string SceneLabelBeat(InterestCandidate scene, UnitDossier d)
/// <summary>
/// Shared with <see cref="EventPresentability"/> - dossier orange reason rules.
/// </summary>
public static string EvaluateSceneLabel(InterestCandidate scene, bool recordDrops = true)
{
if (scene == null || string.IsNullOrEmpty(scene.Label))
{
return "";
}
string beat = EventLabelBeat(scene.Label, d);
UnitDossier d = new UnitDossier();
try
{
if (scene.FollowUnit != null && scene.FollowUnit.isAlive())
{
d.UnitId = scene.FollowUnit.getID();
d.Name = SafeName(scene.FollowUnit);
d.SpeciesId = scene.FollowUnit.asset != null ? scene.FollowUnit.asset.id : "";
}
else if (!string.IsNullOrEmpty(scene.SpeciesId))
{
d.SpeciesId = scene.SpeciesId;
}
}
catch
{
// ignore
}
return EvaluateSceneLabel(scene, d, recordDrops);
}
private static string EvaluateSceneLabel(InterestCandidate scene, UnitDossier d, bool recordDrops)
{
return SceneLabelBeat(scene, d, recordDrops) ?? "";
}
private static string SceneLabelBeat(InterestCandidate scene, UnitDossier d, bool recordDrops = true)
{
if (scene == null || string.IsNullOrEmpty(scene.Label))
{
return "";
}
string beat = EventLabelBeat(scene.Label, d, recordDrops);
if (string.IsNullOrEmpty(beat))
{
return "";
@ -311,14 +348,22 @@ public sealed class UnitDossier
// Ownership: never let the reason name a living stranger (not subject/related).
if (ReasonNamesStranger(beat, scene))
{
InterestDropLog.Record("identity_filter", "stranger:" + beat);
if (recordDrops)
{
InterestDropLog.Record("identity_filter", "stranger:" + beat);
}
return "";
}
// Ownership: reject beats that lead with someone else's proper name.
if (LeadsWithForeignName(beat, scene))
{
InterestDropLog.Record("identity_filter", "foreign:" + beat);
if (recordDrops)
{
InterestDropLog.Record("identity_filter", "foreign:" + beat);
}
return "";
}
@ -352,8 +397,45 @@ public sealed class UnitDossier
return false;
}
// Leading token looks like a proper name that is not the owned subject/related.
return first.Length >= 3 && char.IsLetter(first[0]);
// Only reject when the leading token is another living unit's name.
// Do not treat "Age" / "War" / catalog nouns as foreign proper names.
if (World.world?.units == null || first.Length < 3)
{
return false;
}
try
{
foreach (Actor unit in World.world.units)
{
if (unit == null || !unit.isAlive())
{
continue;
}
if (unit == scene.FollowUnit || unit == scene.RelatedUnit)
{
continue;
}
string name = EventFeedUtil.SafeName(unit);
if (string.IsNullOrEmpty(name) || name.Length < 3)
{
continue;
}
if (first.Equals(FirstToken(name), System.StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
}
catch
{
// ignore
}
return false;
}
private static string FirstToken(string text)
@ -506,7 +588,7 @@ public sealed class UnitDossier
return false;
}
private static string EventLabelBeat(string label, UnitDossier d)
private static string EventLabelBeat(string label, UnitDossier d, bool recordDrops = true)
{
if (string.IsNullOrEmpty(label))
{
@ -529,7 +611,11 @@ public sealed class UnitDossier
{
if (IsWeakFlavorBeat(s, d))
{
InterestDropLog.Record("identity_filter", "weak:" + s);
if (recordDrops)
{
InterestDropLog.Record("identity_filter", "weak:" + s);
}
return "";
}
@ -538,7 +624,11 @@ public sealed class UnitDossier
if (IsIdentityWhy(s, d) || IsWeakFlavorBeat(s, d))
{
InterestDropLog.Record("identity_filter", s);
if (recordDrops)
{
InterestDropLog.Record("identity_filter", s);
}
return "";
}
@ -553,6 +643,11 @@ public sealed class UnitDossier
}
string t = text.Trim();
if (t.StartsWith("New species", System.StringComparison.OrdinalIgnoreCase))
{
return false;
}
if (t.StartsWith("Lone ", System.StringComparison.OrdinalIgnoreCase)
|| t.Equals("Lone of kind", System.StringComparison.OrdinalIgnoreCase)
|| t.Equals("lone of kind", System.StringComparison.OrdinalIgnoreCase))
@ -562,9 +657,12 @@ public sealed class UnitDossier
if (d != null
&& !string.IsNullOrEmpty(d.SpeciesId)
&& t.IndexOf(d.SpeciesId, System.StringComparison.OrdinalIgnoreCase) >= 0
&& ContainsWholeName(t, d.SpeciesId)
&& t.IndexOf("Fight", System.StringComparison.OrdinalIgnoreCase) < 0
&& t.IndexOf("War", System.StringComparison.OrdinalIgnoreCase) < 0)
&& t.IndexOf("War", System.StringComparison.OrdinalIgnoreCase) < 0
&& t.IndexOf("New species", System.StringComparison.OrdinalIgnoreCase) < 0
&& t.IndexOf(':') < 0
&& !LooksLikeEventSentence(t))
{
return true;
}
@ -585,19 +683,20 @@ public sealed class UnitDossier
}
string t = text.Trim();
if (t.StartsWith("King ", System.StringComparison.OrdinalIgnoreCase)
|| t.StartsWith("King of ", System.StringComparison.OrdinalIgnoreCase)
|| t.StartsWith("Leader ", System.StringComparison.OrdinalIgnoreCase)
// Pure title crumbs only - not WorldLog beats like "King left:" / "King killed:".
if (t.StartsWith("King of ", System.StringComparison.OrdinalIgnoreCase)
|| t.StartsWith("Leader of ", System.StringComparison.OrdinalIgnoreCase)
|| t.StartsWith("Favorite", System.StringComparison.OrdinalIgnoreCase))
|| t.Equals("King", System.StringComparison.OrdinalIgnoreCase)
|| t.Equals("Leader", System.StringComparison.OrdinalIgnoreCase)
|| t.Equals("Favorite", System.StringComparison.OrdinalIgnoreCase))
{
return true;
}
// Pure title leftovers after ShortenWatchLabel ("King", "Leader", "Favorite").
if (t.Equals("King", System.StringComparison.OrdinalIgnoreCase)
|| t.Equals("Leader", System.StringComparison.OrdinalIgnoreCase)
|| t.Equals("Favorite", System.StringComparison.OrdinalIgnoreCase))
// "Favorite …" title leftover without an event colon / fell beat.
if (t.StartsWith("Favorite", System.StringComparison.OrdinalIgnoreCase)
&& t.IndexOf(':') < 0
&& t.IndexOf("fell", System.StringComparison.OrdinalIgnoreCase) < 0)
{
return true;
}
@ -637,6 +736,7 @@ public sealed class UnitDossier
string t = text;
return t.IndexOf(" is ", System.StringComparison.OrdinalIgnoreCase) >= 0
|| t.IndexOf(" · ", System.StringComparison.OrdinalIgnoreCase) >= 0
|| t.IndexOf(" became ", System.StringComparison.OrdinalIgnoreCase) >= 0
|| t.IndexOf(" joins ", System.StringComparison.OrdinalIgnoreCase) >= 0
|| t.IndexOf(" leaves ", System.StringComparison.OrdinalIgnoreCase) >= 0
@ -648,6 +748,7 @@ public sealed class UnitDossier
|| t.IndexOf(" summons ", System.StringComparison.OrdinalIgnoreCase) >= 0
|| t.IndexOf(" begins ", System.StringComparison.OrdinalIgnoreCase) >= 0
|| t.IndexOf(" ends ", System.StringComparison.OrdinalIgnoreCase) >= 0
|| t.EndsWith(" ends", System.StringComparison.OrdinalIgnoreCase)
|| t.IndexOf(" burns ", System.StringComparison.OrdinalIgnoreCase) >= 0
|| t.IndexOf(" welcomes ", System.StringComparison.OrdinalIgnoreCase) >= 0
|| t.IndexOf(" parted ", System.StringComparison.OrdinalIgnoreCase) >= 0
@ -663,11 +764,18 @@ public sealed class UnitDossier
|| t.IndexOf(" mates ", System.StringComparison.OrdinalIgnoreCase) >= 0
|| t.IndexOf(" appears ", System.StringComparison.OrdinalIgnoreCase) >= 0
|| t.IndexOf(" finishes ", System.StringComparison.OrdinalIgnoreCase) >= 0
|| t.IndexOf(" bloodlust ", System.StringComparison.OrdinalIgnoreCase) >= 0;
|| t.IndexOf(" bloodlust ", System.StringComparison.OrdinalIgnoreCase) >= 0
|| t.IndexOf(" killed", System.StringComparison.OrdinalIgnoreCase) >= 0
|| t.IndexOf(" fell", System.StringComparison.OrdinalIgnoreCase) >= 0
|| t.IndexOf(" from ", System.StringComparison.OrdinalIgnoreCase) >= 0
|| t.StartsWith("Rebellion:", System.StringComparison.OrdinalIgnoreCase)
|| t.StartsWith("War:", System.StringComparison.OrdinalIgnoreCase)
|| t.IndexOf(':') >= 0;
}
/// <summary>
/// "Name verb …" EventReason form (covers hatches/mates/appears without an "is" helper).
/// Also accepts authored middot crumbs ("Name · phrase") and possessives ("Name's family ends").
/// Rejects identity crumbs like "Name (species, …)".
/// </summary>
private static bool StartsWithSubjectVerbPhrase(string text, string name)
@ -683,7 +791,32 @@ public sealed class UnitDossier
}
int i = name.Length;
if (i >= text.Length || text[i] != ' ')
if (i >= text.Length)
{
return false;
}
// "Name · library / happiness crumb"
if (text[i] == ' '
&& i + 1 < text.Length
&& (text[i + 1] == '·' || text[i + 1] == '•'))
{
return true;
}
// "Name's family ends"
if (text[i] == '\''
&& i + 2 < text.Length
&& (text[i + 1] == 's' || text[i + 1] == 'S')
&& text[i + 2] == ' ')
{
int afterPossessive = i + 3;
return afterPossessive < text.Length
&& char.IsLetter(text[afterPossessive])
&& text[afterPossessive] != '(';
}
if (text[i] != ' ')
{
return false;
}

View file

@ -1,3 +1,4 @@
using System;
using System.Collections.Generic;
using NeoModLoader.services;
using UnityEngine;
@ -123,7 +124,8 @@ public static class WorldActivityScanner
CountFightCluster(actor.current_position, 10f, out int fighters, out int notables, out _);
fighters = Mathf.Max(1, fighters);
string label = EventReason.Fight(actor, foe);
PreferCombatFollow(actor, foe, out Actor follow, out Actor related);
string label = EventReason.Fight(follow, related);
var candidate = new InterestCandidate
{
Key = EventCatalog.Combat.Key(id),
@ -133,17 +135,17 @@ public static class WorldActivityScanner
EventStrength = Mathf.Max(actionScore, EventCatalog.Combat.ScannerStrengthFloor),
CharacterSignificance = charScore,
VisualConfidence = 0.7f,
Position = actor.current_position,
FollowUnit = actor,
RelatedUnit = foe,
SubjectId = id,
RelatedId = foe != null ? EventFeedUtil.SafeId(foe) : 0,
Position = follow != null ? follow.current_position : actor.current_position,
FollowUnit = follow,
RelatedUnit = related,
SubjectId = follow != null ? EventFeedUtil.SafeId(follow) : id,
RelatedId = related != null ? EventFeedUtil.SafeId(related) : 0,
Label = label,
Verb = "fighting",
AssetId = actor.asset != null ? actor.asset.id : "scored_unit",
SpeciesId = actor.asset != null ? actor.asset.id : "",
CityKey = actor.city != null ? (actor.city.name ?? "") : "",
KingdomKey = actor.kingdom != null ? (actor.kingdom.name ?? "") : "",
AssetId = follow?.asset != null ? follow.asset.id : (actor.asset != null ? actor.asset.id : "scored_unit"),
SpeciesId = follow?.asset != null ? follow.asset.id : (actor.asset != null ? actor.asset.id : ""),
CityKey = follow?.city != null ? (follow.city.name ?? "") : (actor.city != null ? (actor.city.name ?? "") : ""),
KingdomKey = follow?.kingdom != null ? (follow.kingdom.name ?? "") : (actor.kingdom != null ? (actor.kingdom.name ?? "") : ""),
ParticipantCount = fighters,
NotableParticipantCount = notables,
CreatedAt = Time.unscaledTime,
@ -318,6 +320,160 @@ public static class WorldActivityScanner
return best;
}
/// <summary>
/// Character weight for picking who to watch in a fight (notable / renown / role).
/// Matches <see cref="CountFightCluster"/> bestFollow ranking.
/// </summary>
public static float CombatFocusWeight(Actor actor)
{
if (actor == null || !actor.isAlive())
{
return -1f;
}
SplitActorScore(actor, null, out _, out float charScore);
float weight = charScore;
if (InterestScoring.IsExtremelyNotable(actor))
{
weight += 30f;
}
return weight;
}
/// <summary>
/// Prefer the higher-scored of two combatants as follow; the other becomes related.
/// </summary>
public static void PreferCombatFollow(Actor a, Actor b, out Actor follow, out Actor related)
{
follow = a;
related = b;
if (a == null || !a.isAlive())
{
follow = b != null && b.isAlive() ? b : null;
related = null;
return;
}
if (b == null || !b.isAlive())
{
related = null;
return;
}
if (CombatFocusWeight(b) > CombatFocusWeight(a))
{
follow = b;
related = a;
}
}
/// <summary>
/// Highest-scored living participant for an active combat scene.
/// Considers Follow, Related, and (for mass fights) the nearby fight cluster.
/// </summary>
public static bool TryPickBestCombatFocus(
InterestCandidate scene,
out Actor best,
out Actor foe,
out int fighters)
{
best = null;
foe = null;
fighters = 1;
if (scene == null)
{
return false;
}
fighters = Mathf.Max(1, scene.ParticipantCount);
float bestWeight = -1f;
Actor bestLocal = null;
void Consider(Actor unit)
{
if (unit == null || !unit.isAlive())
{
return;
}
float w = CombatFocusWeight(unit);
if (w > bestWeight)
{
bestWeight = w;
bestLocal = unit;
}
}
Consider(scene.FollowUnit);
Consider(scene.RelatedUnit);
bool mass = string.Equals(scene.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase)
|| scene.ParticipantCount > 2;
Vector3 pos = scene.Position;
if (bestLocal != null)
{
pos = bestLocal.current_position;
}
else if (scene.FollowUnit != null)
{
pos = scene.FollowUnit.current_position;
}
else if (scene.RelatedUnit != null)
{
pos = scene.RelatedUnit.current_position;
}
if (mass || bestLocal == null)
{
float radius = mass ? 14f : 10f;
CountFightCluster(pos, radius, out int clusterFighters, out _, out Actor clusterBest);
fighters = Mathf.Max(fighters, clusterFighters);
Consider(clusterBest);
}
best = bestLocal;
if (best == null)
{
return false;
}
try
{
if (best.has_attack_target
&& best.attack_target != null
&& best.attack_target.isAlive()
&& best.attack_target.isActor())
{
foe = best.attack_target.a;
if (foe != null && !foe.isAlive())
{
foe = null;
}
}
}
catch
{
foe = null;
}
if (foe == null)
{
Actor related = scene.RelatedUnit;
Actor follow = scene.FollowUnit;
if (related != null && related.isAlive() && related != best)
{
foe = related;
}
else if (follow != null && follow.isAlive() && follow != best)
{
foe = follow;
}
}
return true;
}
private static float ScoreFightCluster(int deaths, int fighters, int notables)
{
ScoringWeights w = InterestScoringConfig.W;

View file

@ -2,7 +2,7 @@
"modVersion": "0.25.1",
"title": "IdleSpectator event catalog",
"updated": "2026-07-16",
"note": "Authored IdleSpectator event catalog. Score ladder (EventStrength): 95-100 epic world (war/kingdom fall/major disaster); 88-94 major spectacle; 78-87 kingdom politics / peak grief; 70-77 life milestones (notice band); 55-69 notable beats; 40-54 warm social; 28-39 soft daily Signal; 25 B-only FX. decisions.action = infinitive after \"decides to\"; other domains use label/variants. scoring-model.json remains global formula (margins/multipliers).",
"note": "Authored IdleSpectator event catalog. Score ladder (EventStrength): 95-100 epic world (war/kingdom fall/major disaster); 88-94 major spectacle; 78-87 kingdom politics / peak grief; 70-77 life milestones (notice band); 55-69 notable beats; 40-54 warm social; 28-39 soft daily Signal; \u226425 B-only FX. decisions.action = infinitive after \"decides to\"; other domains use label/variants. scoring-model.json remains global formula (margins/multipliers).",
"decisions": [
{
"id": "find_lover",
@ -299,7 +299,8 @@
"eats and feels better",
"enjoys a meal"
],
"statusOverlapId": "just_ate"
"statusOverlapId": "just_ate",
"statusOverlapKind": "hold"
},
{
"id": "just_received_gift",
@ -390,7 +391,8 @@
"has a bad dream",
"is troubled by a bad dream"
],
"statusOverlapId": "had_bad_dream"
"statusOverlapId": "had_bad_dream",
"statusOverlapKind": "hold"
},
{
"id": "had_good_dream",
@ -409,7 +411,8 @@
"has a good dream",
"dreams pleasantly"
],
"statusOverlapId": "had_good_dream"
"statusOverlapId": "had_good_dream",
"statusOverlapKind": "hold"
},
{
"id": "had_nightmare",
@ -428,7 +431,8 @@
"has a nightmare",
"is haunted by a nightmare"
],
"statusOverlapId": "had_nightmare"
"statusOverlapId": "had_nightmare",
"statusOverlapKind": "hold"
},
{
"id": "slept_outside",
@ -665,7 +669,8 @@
"is smitten"
],
"canonicalMilestoneKey": "milestone_lover",
"statusOverlapId": "fell_in_love"
"statusOverlapId": "fell_in_love",
"statusOverlapKind": "hold"
},
{
"id": "just_had_child",
@ -756,7 +761,8 @@
"finishes laughing",
"had a good laugh"
],
"statusOverlapId": "laughing"
"statusOverlapId": "laughing",
"statusOverlapKind": "offset"
},
{
"id": "just_sang",
@ -775,7 +781,8 @@
"finishes singing",
"sings with feeling"
],
"statusOverlapId": "singing"
"statusOverlapId": "singing",
"statusOverlapKind": "offset"
},
{
"id": "just_swore",
@ -794,7 +801,8 @@
"finishes swearing",
"lets out a string of curses"
],
"statusOverlapId": "swearing"
"statusOverlapId": "swearing",
"statusOverlapKind": "offset"
},
{
"id": "just_cried",
@ -813,7 +821,8 @@
"finishes crying",
"cries it out"
],
"statusOverlapId": "crying"
"statusOverlapId": "crying",
"statusOverlapKind": "offset"
},
{
"id": "just_talked_gossip",
@ -850,7 +859,8 @@
"is startled",
"jumps in surprise"
],
"statusOverlapId": "surprised"
"statusOverlapId": "surprised",
"statusOverlapKind": "hold"
},
{
"id": "just_born",
@ -887,7 +897,8 @@
"is magnetized",
"feels a magnetic pull"
],
"statusOverlapId": "magnetized"
"statusOverlapId": "magnetized",
"statusOverlapKind": "hold"
},
{
"id": "just_forced_power",
@ -924,7 +935,8 @@
"is possessed",
"falls under possession"
],
"statusOverlapId": "possessed"
"statusOverlapId": "possessed",
"statusOverlapKind": "hold"
},
{
"id": "strange_urge",
@ -943,7 +955,8 @@
"feels a strange urge",
"is gripped by a strange urge"
],
"statusOverlapId": "strange_urge"
"statusOverlapId": "strange_urge",
"statusOverlapKind": "hold"
},
{
"id": "just_had_tantrum",
@ -962,7 +975,8 @@
"finishes a tantrum",
"calms after a tantrum"
],
"statusOverlapId": "tantrum"
"statusOverlapId": "tantrum",
"statusOverlapKind": "offset"
},
{
"id": "just_felt_the_divine",
@ -999,7 +1013,8 @@
"is enchanted",
"feels an enchantment take hold"
],
"statusOverlapId": "enchanted"
"statusOverlapId": "enchanted",
"statusOverlapKind": "hold"
},
{
"id": "just_inspired",
@ -1018,7 +1033,8 @@
"comes down from inspiration",
"lets inspiration settle into contentment"
],
"statusOverlapId": "inspired"
"statusOverlapId": "inspired",
"statusOverlapKind": "offset"
},
{
"id": "wrote_book",
@ -1182,7 +1198,8 @@
"is cursed",
"falls under a curse"
],
"statusOverlapId": "cursed"
"statusOverlapId": "cursed",
"statusOverlapKind": "hold"
},
{
"id": "starving",
@ -1201,7 +1218,8 @@
"is starving",
"goes hungry"
],
"statusOverlapId": "starving"
"statusOverlapId": "starving",
"statusOverlapKind": "hold"
},
{
"id": "conquered_city",
@ -3647,6 +3665,34 @@
"category": "Biome",
"label": "Biome shifts: {id}",
"ambientCreatesInterest": false
},
"cultureTrait": {
"ambientStrength": 34,
"signalStrength": 74,
"category": "Culture",
"label": "{a} culture gains {id}",
"ambientCreatesInterest": true
},
"religionTrait": {
"ambientStrength": 34,
"signalStrength": 74,
"category": "Religion",
"label": "{a} faith gains {id}",
"ambientCreatesInterest": true
},
"clanTrait": {
"ambientStrength": 32,
"signalStrength": 72,
"category": "Clan",
"label": "{a} clan gains {id}",
"ambientCreatesInterest": true
},
"languageTrait": {
"ambientStrength": 30,
"signalStrength": 68,
"category": "Language",
"label": "{a} tongue gains {id}",
"ambientCreatesInterest": true
}
}
}

View file

@ -1,7 +1,7 @@
{
"name": "IdleSpectator",
"author": "dazed",
"version": "0.25.14",
"description": "AFK Idle Spectator (I) + Lore (L). EventOutcome observe→confirm→emit.",
"version": "0.25.51",
"description": "AFK Idle Spectator (I) + Lore (L). Combat focus + tip alignment.",
"GUID": "com.dazed.idlespectator"
}

View file

@ -0,0 +1,232 @@
# Camera presentability plan
Goal: IdleSpectator never starts **Watching** on a beat the orange dossier would blank or refuse.
Ambient object-falls stop competing with real story (combat, birth, plot, kingdom) without chasing per-asset denylists.
This plan follows live Player.log audits after event-live verification (Phases 05 done).
Coverage of *firing* is largely solved.
Idle *quality* fails on ranking / ownership mismatch, not missing catalogs.
## Problem (from live idle)
| Symptom | Root |
|---------|------|
| ~60% watches are vegetation / object falls | Building destroy feed registers A at high strength |
| Tip → `identity_filter``max_cap` loop | Selection ≠ presentation: `"A Maple Tree falls near X"` fails dossier identity rules |
| Combat sticky via `below_margin` while falls cut in | Falls outrank or interrupt poorly; sticky fights do not lose focus cleanly |
| `no_focus_while_idle` BAD after `max_cap` / `follow_lost` / `quiet_grace` | Focus hold across scene end is broken |
| Tip `"Watching: something interesting"` | Empty Label placeholder in `CameraDirector.FormatWatchTip` |
## Principles (do not violate)
1. **Selection equals presentation.**
If `UnitDossier.EventLabelBeat` / scene identity rules would empty the reason for the follow subject, that candidate must not win Watching.
2. **Policy overlays classes, not asset ids.**
Demote "ambient building/object fall" as a feed family or WorldLog shape predicate.
Do not maintain Maple / Poop / Mineral id lists.
3. **Game remains source of truth for inventory.**
Authored dials set strength / CreatesInterest / watch windows.
They do not invent which buildings exist.
4. **Layer B stays honest.**
Falls may still hit Activity / Life when useful.
Demotion means "not camera A," not "erase from the world."
5. **One invariant over many patches.**
Prefer a shared presentability gate at register / rank time over scattered feed hacks.
## Architecture target
```
observe → confirm → emit Label
presentability gate (same rules as UnitDossier)
CreatesInterest / strength policy (class overlay)
InterestDirector rank → Watching
dossier shows the same Label (or scene ends)
```
Drop reason for the new gate: `unpresentable` (or reuse `identity_filter` with a clear prefix) in `InterestDropLog`.
## Phase A - Presentability gate (structural, do first)
**Invariant:** unpresentable beats never become the active EventLed scene.
### A1. Shared check
Extract a single helper used by both dossier and director, e.g. `EventPresentability.WouldShow(Actor subject, string label)` (or static on `UnitDossier`).
It must mirror:
- identity / weak flavor rejection
- `LeadsWithForeignName` / stranger scrub for the follow subject
- empty / placeholder Labels
Do **not** fork a second, looser copy of the rules.
### A2. Gate at publish or rank
Preferred order:
1. Soft reject in `EventFeedUtil.Register` when Label fails for `follow` (record drop, return null) - catches most unit-led noise early.
2. Hard reject in `InterestDirector` before adopting a candidate (defense in depth for location-only / race cases).
Location-only falls without a subject: either fail presentability by policy, or never register as A (Phase B).
### A3. Harness
Add focused asserts (new scenario or nest in existing director smoke):
| Case | Expect |
|------|--------|
| Register Label that dossier blanks for follow | no Watching; drop logged |
| Register subject-led presentable Label | Watching + orange reason match Label |
| Inject / live path that previously `identity_filter` after cut | no cut-in |
Exit: idle session no longer shows Watching → immediate `identity_filter` on the same tip as the dominant pattern.
Gate: `critical_smoke` + a dedicated presentability scenario ×3.
## Phase B - Demote ambient fall class (policy overlay)
**Class:** unit-adjacent or location-only **building/object destroy** ambient (trees, plants, minerals, mushrooms, poop, etc.).
Not in scope for demotion: intentional spectacle consumes (`building_consume` / eat when an actor is clearly the subject and Label is subject-led), war siege set-pieces if we later mark them Signal.
### B1. Predicate, not id list
In `BuildingEventPatches.PostfixStartDestroy` (and any WorldLog twin if it feeds the same shape):
- Default destroy → Layer B only (`CreatesInterest=false` / skip `EventFeedUtil.Register`, still Activity if desired).
- Or register A only when a **strong subject-led** story exists (actor is clearly damaging/consuming, not "nearest unit to a falling bush").
Nearest-unit attach for ambient falls is the bug pattern.
Prefer: no follow invent from proximity for A.
### B2. Catalog dials
If WorldLog building-destroy ids are camera today, set class default `camera: false` / low strength via discovery overlay rules - still not per maple id.
Update `docs/event-audit.md` Layer A noise notes when done.
### B3. Harness
| Case | Expect |
|------|--------|
| Destroy ambient vegetation near a unit | no A cut; optional B log |
| `BehConsumeTargetBuilding` with living eater | still A, subject-led Label |
| Building destroy pipeline in `event_live_pipelines` | `id_drop` or B-only as authored; not FAIL |
Exit: live idle fall watch share collapses; combat/birth/plot reclaim camera time.
## Phase C - Ban empty Watching tip
### C1. Product rule
Never show `Watching: something interesting`.
Options (pick one, keep simple):
- Refuse to start Watching without a non-empty presentable Label.
- Or tip omits reason / uses task chip only when Label empty (Character fill already uses empty Label intentionally - do not use FormatWatchTip placeholder there).
Remove or guard the fallback in `CameraDirector.FormatWatchTip`.
### C2. Harness
Assert tip never equals / contains the placeholder string during idle EventLed scenes.
## Phase D - Focus hold across scene end
Separate from fall demotion; required for idle quality.
### D1. Reproduce
`no_focus_while_idle` after `max_cap`, `follow_lost`, `quiet_grace`.
Reproduce closest to end-user idle (spectate, wait for scene churn) before patching.
### D2. Fix
When an EventLed scene ends and Character fill / next candidate should continue idle:
- Keep or immediately reacquire a living focus unit.
- Do not clear follow in a way that flashes the power bar or trips BAD.
- Every director tick: if Idle is on and camera has no living focus, reattach current follow/related or `EnsureIdleFocus` (do not wait for quiet_grace).
Document intended end-of-scene focus ownership in `event-audit.md` interrupt/hold section.
### D3. Harness
Assert: during SpectatorMode, after forced `max_cap` / quiet_grace end, `MoveCamera.hasFocusUnit()` remains true (or is restored within one director tick) unless world has no living units.
## Phase E - Optional Label rework (only if we want some falls as A)
Only after A+B.
Rewrite `EventReason.BuildingFalls` to subject-led form if product wants rare A falls:
- Bad: `A Maple Tree falls near Boh` (foreign-led / identity_filter)
- Better: `Boh is near a falling maple tree` (only if Phase B still allows that class as A)
Do not use Label rewrite as a substitute for the gate or class demotion.
## Phase F - Docs + idle quality gate
1. Cross-link from `event-reason.md` and `event-audit.md`.
2. Add a short "idle quality" checklist to `event-e2e.md`:
- no Watching → identity_filter churn on same tip
- no placeholder tip
- no `no_focus_while_idle` BAD spike in a quiet idle window
- fall class not dominating watches
3. Optional: lightweight Player.log auditor script or harness probe counting `identity_filter` after Watching tips in a timed idle soak.
## Implementation status
| Phase | Status |
|-------|--------|
| A Presentability gate | Done - `EventPresentability` + Register/SelectNext/SwitchTo; harness bypass for synthetic tips |
| B Fall class demotion | Done - `Building.startDestroyBuilding` is B-only; consume stays A |
| C Placeholder tip ban | Done - `FormatWatchTip` returns `Watching` when Label empty |
| D Focus hold | Done - `MaintainCameraFocus` every tick; orphan scenes end without quiet_grace gap |
| E Label rewrite | Done early - `BuildingFalls` subject-led (for residual / Activity prose) |
| F Docs + idle quality gate | Done - `camera_presentability` in regression; docs synced |
## Order of work
| Order | Phase | Why first |
|------:|-------|-----------|
| 1 | A Presentability gate | Fixes whole contradiction class forever |
| 2 | B Fall class demotion | Stops ambient spam without id lists |
| 3 | C Placeholder tip ban | Cheap, visible polish; blocks empty A |
| 4 | D Focus hold | Fixes BAD / churn; independent root cause |
| 5 | E Label rewrite | Optional; only if some falls stay A |
| 6 | F Docs / soak gate | Locks the bar |
Do not start with per-id JSON demotions.
Do not demote only and skip the gate.
## Non-goals
- Hand-authored denylist of building asset ids
- Treating inject coverage as idle-quality proof
- Making every WorldLog message camera-worthy
- Solving spell / biome / boat unload unsupported fires in this plan
- Beh-positive `family_group_join` (separate observation work)
## Success criteria
- Presentability helper is the single source of truth for "would orange show this?"
- Ambient object-falls are B-by-default (class predicate)
- Zero Watching cuts whose tip is immediately identity-filtered
- Zero `"something interesting"` tips in EventLed Watching
- Idle soak: `no_focus_while_idle` BAD count stays near zero across scene churn
- Regression still green (`critical_smoke` ×3; `regression` after AD)
## See also
- [event-reason.md](event-reason.md) - A/B layers, EventReason, dossier filters
- [event-audit.md](event-audit.md) - dials, interrupt / hold / grace
- [event-live-verification-plan.md](event-live-verification-plan.md) - fire accuracy (done)
- [event-e2e.md](event-e2e.md) - harness suite map
- [event-observation.md](event-observation.md) - observe → confirm → emit

View file

@ -53,7 +53,9 @@ Happiness Aggregates stay civic mass (B unit camera).
Status B: brief FX/cooldowns + egg gain only.
Noise still skipped at patch (not dial): building_damage chips, boat_load.
Noise still skipped at patch (not dial): building_damage chips, boat_load, **ambient building/object destroy** (falls are Layer B; consume stays A).
Presentability: production `EventFeedUtil.Register` rejects Labels the dossier would blank (`unpresentable` drop). See [camera-presentability-plan.md](camera-presentability-plan.md).
## Full surface
@ -67,8 +69,18 @@ See [world-event-inventory.md](world-event-inventory.md) for live library counts
4. Soft peer rotates on EventLed only after `minCameraDwell` (15s), never during protected sticky hold without margin.
5. Quiet grace 6s: still-worth-watching filter, then Character fill with empty reason.
6. Drops recorded in `InterestDropLog`.
7. Focus continuity: while Idle Spectator is on, a missing living camera focus is repaired the same director tick (reattach scene follow/related, else ambient fill / any alive unit). Orphan scenes (no living follow/related) end as `follow_lost` instead of sitting in quiet_grace with an empty camera.
## Mutation gap triage
`mutation_discovery` refreshes `.harness/mutation_gaps.tsv`.
Open rows are unexplained Wire candidates only.
## Idle quality
Presentability gate, ambient fall demotion, focus hold, and tip placeholder bans are done
(see [camera-presentability-plan.md](camera-presentability-plan.md)).
**Polish next:** combat tip/focus alignment - camera follows the highest-scored living
participant and rewrites the Watching tip so the subject matches focus; clears "is fighting"
once combat goes cold.

100
docs/event-e2e.md Normal file
View file

@ -0,0 +1,100 @@
# Event E2E suite (maintainable)
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 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 + libraries (worldLaw/item/subspecies_trait/power/gene/phenotype/meta traits); spell/biome best-effort unsupported; boat/combat best-effort | 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 |
## 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.
## Idle quality (camera presentability)
See [`camera-presentability-plan.md`](camera-presentability-plan.md).
| Check | Scenario / assert |
|-------|-------------------|
| Unpresentable Label rejected at Register | `camera_presentability` / `presentability_probe` |
| No `Watching: something interesting` | `no_placeholder_tip` |
| Focus survives max_cap | `camera_presentability` / `has_focus` |
| Ambient building falls are B-only | live patch + pipelines `id_drop` |
## 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_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
```
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. 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`, `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,219 @@
# 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; library id loops for worldLaw/item/subspecies_trait/power/gene/phenotype/meta traits; spell/biome + boat/combat best-effort) |
| 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
## After this plan
Live *fire* proof is largely done.
Idle *watch quality* (selection = presentation, ambient fall class demotion, focus hold, tip placeholder) is a separate track: [camera-presentability-plan.md](camera-presentability-plan.md).

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

@ -79,9 +79,12 @@ See `scoring-model.json`.
`UnitDossier.EventLabelBeat` keeps EventReason / `Name verb …` sentences.
It rejects identity/weak crumbs and scrubs living stranger names (`ReasonNamesStranger` / `LeadsWithForeignName`).
Active EventLed A scenes should not silently blank a valid Label.
`EventPresentability.WouldShow` is the shared gate so unpresentable Labels never win Watching.
## See also
- [`camera-presentability-plan.md`](camera-presentability-plan.md) - selection = presentation; fall class demotion
- [`event-e2e.md`](event-e2e.md) - catalog inject + live outcome suite
- [`event-observation.md`](event-observation.md) - observe → confirm → emit (Beh Continue is not success)
- [`event-audit.md`](event-audit.md) - sources, MaxWatch, A demotion set
- [`event-prose-audit.md`](event-prose-audit.md) - prose vs game call sites

View file

@ -64,7 +64,7 @@ relationship (lover/child/family/baby/alpha), Family.setAlpha, SpeciesDiscovery.
| Gap | Why it matters | Status |
|-----|----------------|--------|
| Culture/religion/clan/language trait gain | Meta drama libraries exist | **Open** |
| Culture/religion/clan/language trait gain | Meta drama libraries exist | **Live** (MetaObjectWithTraits.addTrait in pipelines) |
| Building complete / wonder finish | Settlement spectacle | **Open** |
| Alliance/kingdom destroy managers | End beats if WorldLog thin | **Open** |
| Era/earthquake poll (ongoing) | Age change is wired; poll not | **Open** |

View file

@ -37,11 +37,14 @@ REGRESSION_SCENARIOS=(
director_action_priority
discovery
world_log
event_coverage
camera_presentability
event_suite
chronicle_smoke
activity_idle_smoke
activity_status_smoke
activity_happiness_smoke
# Heavier live fires last - wars/meta/eras can reshape the loaded world.
event_live_pipelines
)
scenario=""