Compare commits
7 commits
44371365b3
...
c575c57190
| Author | SHA1 | Date | |
|---|---|---|---|
| c575c57190 | |||
| 4491423236 | |||
| 57d2e70476 | |||
| 3cac264bd7 | |||
| 148e807f23 | |||
| 1359d86591 | |||
| 048b215814 |
46 changed files with 7389 additions and 243 deletions
|
|
@ -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>();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
537
IdleSpectator/EventInjectCoverageHarness.cs
Normal file
537
IdleSpectator/EventInjectCoverageHarness.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
322
IdleSpectator/EventLiveApplyLoopHarness.cs
Normal file
322
IdleSpectator/EventLiveApplyLoopHarness.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
240
IdleSpectator/EventLiveCoverageLedger.cs
Normal file
240
IdleSpectator/EventLiveCoverageLedger.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
2173
IdleSpectator/EventLivePipelinesHarness.cs
Normal file
2173
IdleSpectator/EventLivePipelinesHarness.cs
Normal file
File diff suppressed because it is too large
Load diff
998
IdleSpectator/EventLiveRelationshipHarness.cs
Normal file
998
IdleSpectator/EventLiveRelationshipHarness.cs
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,6 +47,10 @@ public static partial class EventCatalog
|
|||
WorldLaw.InvalidateOverlays();
|
||||
SubspeciesTrait.InvalidateOverlays();
|
||||
Biome.InvalidateOverlays();
|
||||
CultureTrait.InvalidateOverlays();
|
||||
ReligionTrait.InvalidateOverlays();
|
||||
ClanTrait.InvalidateOverlays();
|
||||
LanguageTrait.InvalidateOverlays();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
61
IdleSpectator/Events/EventPresentability.cs
Normal file
61
IdleSpectator/Events/EventPresentability.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ public static class BookInterestFeed
|
|||
{
|
||||
public static void Emit(string bookTypeId, Actor subject, string phase)
|
||||
{
|
||||
if (AgentHarness.Busy)
|
||||
if (!AgentHarness.LiveFeedsAllowed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ public static class DeferredInterestFeed
|
|||
public static void EmitPower(string powerId, Vector3 position, Actor nearUnit)
|
||||
{
|
||||
DiscreteEventEntry entry = EventCatalog.Power.GetOrFallback(powerId);
|
||||
if (AgentHarness.Busy)
|
||||
if (!AgentHarness.LiveFeedsAllowed)
|
||||
{
|
||||
InterestDropLog.Record("busy", "power");
|
||||
return;
|
||||
|
|
@ -111,7 +111,7 @@ public static class DeferredInterestFeed
|
|||
|
||||
public static void EmitWorldLaw(string lawId, Vector3 position)
|
||||
{
|
||||
if (AgentHarness.Busy || position == Vector3.zero)
|
||||
if (!AgentHarness.LiveFeedsAllowed || position == Vector3.zero)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ public static class MetaInterestFeed
|
|||
{
|
||||
public static void EmitEra(string eraId)
|
||||
{
|
||||
if (AgentHarness.Busy)
|
||||
if (!AgentHarness.LiveFeedsAllowed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
|
@ -72,7 +72,7 @@ public static class MetaInterestFeed
|
|||
|
||||
public static void EmitMeta(string eventId, string category, float strength, Actor anchor, string label)
|
||||
{
|
||||
if (AgentHarness.Busy)
|
||||
if (!AgentHarness.LiveFeedsAllowed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ public static class PlotInterestFeed
|
|||
{
|
||||
public static void Emit(string plotAssetId, Actor subject, string phase)
|
||||
{
|
||||
if (AgentHarness.Busy)
|
||||
if (!AgentHarness.LiveFeedsAllowed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
|
@ -55,7 +55,7 @@ public static class PlotInterestFeed
|
|||
|
||||
public static void Tick()
|
||||
{
|
||||
if (AgentHarness.Busy)
|
||||
if (!AgentHarness.LiveFeedsAllowed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ public static class RelationshipInterestFeed
|
|||
|
||||
public static void Emit(string eventId, Actor subject, Actor related, string labelOverride = null)
|
||||
{
|
||||
if ((AgentHarness.Busy && !AgentHarness.ForceRelationshipFeeds)
|
||||
if (!AgentHarness.LiveFeedsAllowed
|
||||
|| subject == null
|
||||
|| !subject.isAlive())
|
||||
{
|
||||
|
|
@ -94,7 +94,7 @@ public static class RelationshipInterestFeed
|
|||
|
||||
public static void EmitFamily(string eventId, object family, Actor anchor)
|
||||
{
|
||||
if (AgentHarness.Busy && !AgentHarness.ForceRelationshipFeeds)
|
||||
if (!AgentHarness.LiveFeedsAllowed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ public static class WarInterestFeed
|
|||
{
|
||||
public static void EmitFromWarObject(object war, string phase = "active")
|
||||
{
|
||||
if (war == null || AgentHarness.Busy)
|
||||
if (war == null || !AgentHarness.LiveFeedsAllowed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
|
@ -23,7 +23,7 @@ public static class WarInterestFeed
|
|||
|
||||
public static void Tick()
|
||||
{
|
||||
if (AgentHarness.Busy)
|
||||
if (!AgentHarness.LiveFeedsAllowed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using ai.behaviours;
|
||||
using HarmonyLib;
|
||||
|
||||
|
|
@ -12,6 +13,31 @@ namespace IdleSpectator;
|
|||
[HarmonyPatch]
|
||||
public static class RelationshipEventPatches
|
||||
{
|
||||
/// <summary>
|
||||
/// Vanilla <c>Family.isFull</c> NREs when <c>getActorAsset()</c> is null (broken/zombie family).
|
||||
/// Treat as full so <c>getNearbyFamily</c> skips instead of spamming AI exceptions.
|
||||
/// </summary>
|
||||
[HarmonyPatch(typeof(Family), nameof(Family.isFull))]
|
||||
[HarmonyPrefix]
|
||||
public static bool PrefixIsFull(Family __instance, ref bool __result)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (__instance == null || __instance.getActorAsset() == null)
|
||||
{
|
||||
__result = true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
__result = true;
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(Actor), nameof(Actor.setLover))]
|
||||
[HarmonyPostfix]
|
||||
public static void PostfixSetLover(Actor __instance, Actor pActor)
|
||||
|
|
@ -127,11 +153,103 @@ public static class RelationshipEventPatches
|
|||
RelationshipInterestFeed.EmitFamily("new_family", __result, anchor);
|
||||
}
|
||||
|
||||
private static Actor _familyRemoveAnchor;
|
||||
|
||||
/// <summary>
|
||||
/// Emit before remove tears membership down. Postfix is too late for member lookup.
|
||||
/// </summary>
|
||||
[HarmonyPatch(typeof(FamilyManager), "removeObject", new Type[] { typeof(Family) })]
|
||||
[HarmonyPostfix]
|
||||
public static void PostfixRemoveFamily(Family pObject)
|
||||
[HarmonyPrefix]
|
||||
public static void PrefixRemoveFamily(Family pObject)
|
||||
{
|
||||
RelationshipInterestFeed.EmitFamily("family_removed", pObject, null);
|
||||
if (pObject == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Actor anchor = _familyRemoveAnchor;
|
||||
_familyRemoveAnchor = null;
|
||||
if (anchor == null || !anchor.isAlive())
|
||||
{
|
||||
anchor = ResolveFamilyRemoveAnchor(pObject);
|
||||
}
|
||||
|
||||
if (anchor != null)
|
||||
{
|
||||
RelationshipInterestFeed.EmitFamily("family_removed", pObject, anchor);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Optional harness/pre-call hint when membership lists are still dirty.</summary>
|
||||
public static void NoteFamilyRemoveAnchor(Actor anchor)
|
||||
{
|
||||
_familyRemoveAnchor = anchor != null && anchor.isAlive() ? anchor : null;
|
||||
}
|
||||
|
||||
private static Actor ResolveFamilyRemoveAnchor(Family family)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (family.units != null)
|
||||
{
|
||||
for (int i = 0; i < family.units.Count; i++)
|
||||
{
|
||||
Actor unit = family.units[i];
|
||||
if (unit != null && unit.isAlive())
|
||||
{
|
||||
return unit;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// fall through
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Actor founder = family.getFounderFirst() ?? family.getFounderSecond();
|
||||
if (founder != null && founder.isAlive())
|
||||
{
|
||||
return founder;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// fall through
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
List<Actor> alive = World.world?.units?.units_only_alive;
|
||||
if (alive == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
for (int i = 0; i < alive.Count; i++)
|
||||
{
|
||||
Actor unit = alive[i];
|
||||
try
|
||||
{
|
||||
if (unit != null && unit.isAlive() && unit.hasFamily() && unit.family == family)
|
||||
{
|
||||
return unit;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// keep scanning
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(ActorManager), "createBabyActorFromData")]
|
||||
|
|
@ -198,6 +316,7 @@ public static class RelationshipEventPatches
|
|||
public static void PostfixFamilyJoin(Actor pActor, BehResult __result, EventOutcome.ActorFlags __state)
|
||||
{
|
||||
// Vanilla returns Continue even when getNearbyFamily was null - require GainedFamily.
|
||||
// Emit is on setFamily(null→family) only when we choose; Beh path emits here.
|
||||
EventObservation.ConfirmAfter(
|
||||
"BehFamilyGroupJoin",
|
||||
"family_group_join",
|
||||
|
|
@ -212,6 +331,45 @@ public static class RelationshipEventPatches
|
|||
ActorRelation.ResolvePackRelated(pActor)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Leave is <c>setFamily(null)</c> (Beh leave and any other clear). Prefer this over
|
||||
/// Beh Continue alone so harness and vanilla share one state sensor.
|
||||
/// </summary>
|
||||
[HarmonyPatch(typeof(Actor), nameof(Actor.setFamily))]
|
||||
[HarmonyPrefix]
|
||||
public static void PrefixSetFamily(Actor __instance, Family pObject, out Family __state)
|
||||
{
|
||||
__state = null;
|
||||
try
|
||||
{
|
||||
if (__instance != null && __instance.hasFamily())
|
||||
{
|
||||
__state = __instance.family;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
__state = null;
|
||||
}
|
||||
|
||||
_ = pObject;
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(Actor), nameof(Actor.setFamily))]
|
||||
[HarmonyPostfix]
|
||||
public static void PostfixSetFamily(Actor __instance, Family pObject, Family __state)
|
||||
{
|
||||
if (pObject != null || __state == null || !EventOutcome.IsLiving(__instance))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
RelationshipInterestFeed.Emit(
|
||||
"family_group_leave",
|
||||
__instance,
|
||||
ActorRelation.ResolvePackRelated(__instance));
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(BehFamilyGroupLeave), nameof(BehFamilyGroupLeave.execute))]
|
||||
[HarmonyPrefix]
|
||||
public static void PrefixFamilyLeave(Actor pActor, out EventOutcome.ActorFlags __state)
|
||||
|
|
@ -223,6 +381,7 @@ public static class RelationshipEventPatches
|
|||
[HarmonyPostfix]
|
||||
public static void PostfixFamilyLeave(Actor pActor, BehResult __result, EventOutcome.ActorFlags __state)
|
||||
{
|
||||
// Emit already fired from setFamily(null). Only log unconfirmed Continues.
|
||||
EventObservation.ConfirmAfter(
|
||||
"BehFamilyGroupLeave",
|
||||
"family_group_leave",
|
||||
|
|
@ -231,10 +390,7 @@ public static class RelationshipEventPatches
|
|||
__result,
|
||||
(before, actor, result) =>
|
||||
EventOutcome.BehaviourRan(result) && EventOutcome.LostFamily(before, actor),
|
||||
() => RelationshipInterestFeed.Emit(
|
||||
"family_group_leave",
|
||||
pActor,
|
||||
ActorRelation.ResolvePackRelated(pActor)));
|
||||
() => { /* setFamily(null) already emitted */ });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -107,7 +107,7 @@ public static class TraitEventPatches
|
|||
|
||||
private static void Emit(string traitId, Actor actor, bool gained)
|
||||
{
|
||||
if (AgentHarness.Busy)
|
||||
if (!AgentHarness.LiveFeedsAllowed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ public static class WarEventPatches
|
|||
[HarmonyPostfix]
|
||||
public static void PostfixNewWar(War __result)
|
||||
{
|
||||
if (__result == null || AgentHarness.Busy)
|
||||
if (__result == null || !AgentHarness.LiveFeedsAllowed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
|
@ -29,7 +29,7 @@ public static class WarEventPatches
|
|||
[HarmonyPostfix]
|
||||
public static void PostfixEndWar(object[] __args)
|
||||
{
|
||||
if (AgentHarness.Busy || __args == null || __args.Length == 0 || __args[0] == null)
|
||||
if (!AgentHarness.LiveFeedsAllowed || __args == null || __args.Length == 0 || __args[0] == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = "";
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
232
docs/camera-presentability-plan.md
Normal file
232
docs/camera-presentability-plan.md
Normal 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 0–5 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 A–D)
|
||||
|
||||
## 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
|
||||
|
|
@ -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
100
docs/event-e2e.md
Normal 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`
|
||||
219
docs/event-live-verification-plan.md
Normal file
219
docs/event-live-verification-plan.md
Normal 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 id’s shared hook family; **this id was not necessarily fired** | Partial - siblings may share predicate/API |
|
||||
| `id_drop` | Real apply/fire ran; result was an **authored** drop (reason matched) | Yes for “accurate drop” |
|
||||
| `id` | This id went through real fire and asserted note/interest as expected | Yes |
|
||||
| `unsupported` | No deterministic fire API; explicit reason in `notes` | Honest gap |
|
||||
| `fail` | Fire attempted; unexpected miss/drop/wrong key | Broken |
|
||||
|
||||
### Claiming rules (strict)
|
||||
|
||||
1. **`id` / `id_drop`:** only the id that was actually fired this run.
|
||||
2. **`pipeline`:** only when (a) vanilla entry was used, and (b) siblings share the **same confirm predicate and same fire API**. Note must include `shared=<pipeline_id>`. Never mark an id `pipeline` merely because another id in the domain passed.
|
||||
3. **`feed`:** never promote to `pipeline` or `id` without a vanilla path.
|
||||
4. **`domain_feed` / inject:** never write any live level except leaving `none`.
|
||||
5. Ledger is **computed from the last run’s results** + catalog seed. No hand-maintained allowlist of “known smokes.”
|
||||
|
||||
Ambient / `CreatesInterest=false`: not camera-live goals. Optional B-log loop later; default leave at `none` unless explicitly asserted as `id_drop` / ActivityLog-only.
|
||||
|
||||
## Current baseline (done)
|
||||
|
||||
- Inventory audits in `event_coverage`
|
||||
- `event_inject_coverage` - all camera-worthy ids register
|
||||
- `event_live_outcome_smoke` - family lovers/parent, one status, one happiness (proof exists; ledger will record from run, not a static list)
|
||||
- Observe→confirm for relationship Beh paths
|
||||
- Docs: `event-e2e.md`, `event-observation.md`
|
||||
|
||||
## Phase 0 - Coverage ledger + Busy policy
|
||||
|
||||
**Deliverable:** harness writes `.harness/event-live-coverage.tsv` every live/coverage run.
|
||||
|
||||
Columns: `domain`, `id`, `camera`, `inject`, `live_level`, `pipeline`, `notes`
|
||||
|
||||
- Seed rows from the same `AuthoredIds` loops as inject (`camera` / `inject` from that run or mirror rules)
|
||||
- `live_level` defaults to `none`; overwrite only from live runners’ results
|
||||
- Suite prints: `id=N id_drop=N pipeline=N feed=N none=N unsupported=N fail=N`
|
||||
- Fail the assert on `fail > 0` once live runners exist; do **not** fail on `none` until Phase 3 exit tightening
|
||||
|
||||
### Busy bypass (required before Phase 2/3 interest asserts)
|
||||
|
||||
Most feeds early-out on `AgentHarness.Busy`. Define per domain:
|
||||
|
||||
| Domain | During live fire | Primary assert |
|
||||
|--------|------------------|----------------|
|
||||
| Happiness | `HappinessSourceHook.Harness` / existing apply path | Router notes + log (interest optional) |
|
||||
| Status | ActivityLog primary; optional force-interest flag later | Status counters + log |
|
||||
| Relationship | `ForceRelationshipFeeds` (already used for parent) | Interest key + drop log |
|
||||
| WorldLog / war / plot / book / trait / decision / … | Explicit `Force*Feeds` or harness “allow live feeds” mode | Interest key and/or domain log |
|
||||
|
||||
Document the flag in `notes` when used (`busy_bypass=ForceRelationshipFeeds`).
|
||||
|
||||
**Maintainability:** new catalog ids appear as `none` automatically until a runner claims them.
|
||||
|
||||
## Phase 1 - Shared apply loops (highest confidence / hour)
|
||||
|
||||
Loop camera-worthy ids through existing **vanilla-ish** apply APIs (`happiness_apply`, `status_apply`).
|
||||
|
||||
### 1a Happiness
|
||||
|
||||
For each **Signal** id in `EventCatalog.Happiness.AuthoredIds` (skip Ambient unless doing an optional B-log pass):
|
||||
|
||||
1. Reset notes / clear subject counters
|
||||
2. `happiness_apply` with that id
|
||||
3. Classify:
|
||||
- expected note / `LastEffectId` → `id`
|
||||
- authored drop reason matched → `id_drop`
|
||||
- else → `fail`
|
||||
4. `pipeline` field: `happiness_apply`
|
||||
|
||||
Do **not** require camera interest while Busy unless a force path is on.
|
||||
|
||||
### 1b Status
|
||||
|
||||
For each `CreatesInterest` status (gains):
|
||||
|
||||
1. Clear status + activity status counters
|
||||
2. `status_apply`
|
||||
3. Assert gain + log prose → `id`; authored skip/drop → `id_drop`; else `fail`
|
||||
4. `status_remove` / egg hatch as a **separate** pipeline (`status_loss` / `hatch`) with its own ids
|
||||
|
||||
**Exit:** every camera-worthy happiness Signal and status gain id is `id`, `id_drop`, or `fail` (no silent `none` for those sets). Suite fails on `fail`.
|
||||
|
||||
## Phase 2 - Confirm-gated relationship families
|
||||
|
||||
One full sim **per `EventOutcome` predicate** (and separate setter smokes where there is no confirm helper).
|
||||
|
||||
### Confirm smokes (predicate + negative case)
|
||||
|
||||
| Pipeline | Sim | Assert |
|
||||
|----------|-----|--------|
|
||||
| `GainedLover` / bond | `happiness_bond_lovers` | notes + log; exercised id → `id` |
|
||||
| parent / `add_child` | `relationship_set_parent` | `rel:add_child`; id → `id` |
|
||||
| `LostLover` / `clear_lover` | bond then clear via game API | emit or authored drop |
|
||||
| `GainedFamily` / `family_group_join` | join when family exists **and** when not | key vs `outcome_unconfirmed` |
|
||||
| `LostFamily` / leave-remove | leave/remove when applicable | key vs unconfirmed |
|
||||
| `StillSeekingLover` / `find_lover` | seeking Beh path | confirm rules |
|
||||
|
||||
Negative tests are mandatory for confirm predicates (especially join-with-no-family).
|
||||
|
||||
### Setter smokes (no shared confirm sibling claim)
|
||||
|
||||
`new_family`, `family_group_new`, `become_alpha`, `baby_created`, etc.: each needs its own vanilla fire for `id`. Do **not** mark them `pipeline` off `add_child` or join.
|
||||
|
||||
Siblings may get `pipeline` only under Claiming rule 2 (`shared=<predicate>`).
|
||||
|
||||
**Exit:** every relationship camera id is `id`, `pipeline` (with valid `shared=`), `id_drop`, `unsupported`, or `fail` - never unexplained `none`.
|
||||
|
||||
## Phase 3 - Remaining hook families
|
||||
|
||||
Prefer **vanilla** mutation. If only feed-invoke is feasible, record `feed` and keep improving to vanilla.
|
||||
|
||||
| Pipeline | Minimum vanilla sim | Id loop? | Also cover |
|
||||
|----------|---------------------|----------|------------|
|
||||
| WorldLog | Post/construct real world history message the patch sees | Yes if asset id is the parameter | |
|
||||
| Disasters | Through paired WorldLog / disaster trigger | Via paired worldLog | |
|
||||
| War types | `WarManager.newWar` (or equivalent) | Per type if API takes type | |
|
||||
| Eras | Force age change | Per era if possible | |
|
||||
| Plots | `setPlot` / finish; phases as separate pipelines | Per plot id if API takes id | |
|
||||
| Books | Create/burn vanilla path | Per book id if API takes id | |
|
||||
| Traits | `addTrait(id)` / remove | Yes | |
|
||||
| Decisions | Decision cooldown / real decision entry for interesting ids | Yes for camera decisions | |
|
||||
| Combat | Existing combat interest path / attack-target gate | Pipeline smoke + known gates | |
|
||||
| Boat | Unload/board path the patch uses | Pipeline smoke | |
|
||||
| Meta (city/kingdom) | Real civic create if possible; else `unsupported`/`feed` | |
|
||||
| Building | Destroy/damage path | Pipeline smoke | |
|
||||
| Libraries | Only with real gain/cast API; else `unsupported` | Loop when API exists | |
|
||||
|
||||
**Rules:**
|
||||
|
||||
1. Do not call `domain_feed` / inject and call it live.
|
||||
2. No fire API → `unsupported` + reason, not FAIL and not fake `pipeline`.
|
||||
3. After each fire: teardown (expire interest keys, clear statuses/wars/plots as needed).
|
||||
4. Assert: expected note/key **or** expected drop; flag unexpected `outcome_unconfirmed`.
|
||||
5. Busy bypass per Phase 0 table; record in `notes`.
|
||||
|
||||
**Exit:** every camera domain has ids in `id` / `id_drop` / `pipeline` / `feed` / `unsupported` - zero unexplained `none` for camera rows. Then optionally tighten CI to fail if camera `none` + `feed` exceed a budget.
|
||||
|
||||
## Phase 4 - Wire into suite (weight-aware)
|
||||
|
||||
| Scenario | Role | When |
|
||||
|----------|------|------|
|
||||
| `event_inject_coverage` | Wiring gate | Always / `event_suite` |
|
||||
| `event_live_apply_loop` | Phase 1 happiness+status | `event_suite` + regression |
|
||||
| `event_live_relationship` | Phase 2 confirms + negatives | `event_suite` (stable) |
|
||||
| `event_live_pipelines` | Phase 3 | **Regression only** (flake budget proven 3/3) |
|
||||
| `event_live_coverage` | Write ledger + summary assert (`fail==0`) | End of whichever live scenarios ran |
|
||||
| `event_suite` | audits + inject + apply loop + relationship + outcome smoke | Day-to-day event gate |
|
||||
| `regression` | `event_suite` + `event_live_pipelines` (+ other product smokes) | Full gate |
|
||||
|
||||
Keep `event_live_outcome_smoke` as a short smoke nested in `event_suite`.
|
||||
|
||||
## Phase 5 - Docs sync
|
||||
|
||||
- Update `docs/event-e2e.md` with levels, Busy policy, suite split
|
||||
- Update `event-observation.md` “adding an event”: name confirm predicate, fire API, and expected live level
|
||||
- **Source of truth:** harness-generated `event-live-coverage.tsv` for live proof; `event-trigger-audit.tsv` stays design/call-site audit - do not duplicate `live_verified` into both unless generating one from the other
|
||||
|
||||
## Implementation status
|
||||
|
||||
| Phase | Status |
|
||||
|-------|--------|
|
||||
| 0 Ledger + Busy notes | Done - `EventLiveCoverageLedger`, assert `event_live_coverage` |
|
||||
| 1a Happiness Signal apply loop | Done - `EventLiveApplyLoopHarness` |
|
||||
| 1b Status gain apply loop | Done - same harness; spawn human for emotions |
|
||||
| 2 Relationship confirms | Done - `EventLiveRelationshipHarness` (leave via Beh/`setFamily(null)`, remove with Prefix emit + anchor hint, baby via `createBabyActorFromData`; join often `pipeline` via setFamily) |
|
||||
| 3 Other pipelines | Done - `EventLivePipelinesHarness` (traits/decisions/WorldLog/…; building destroy; 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).
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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** |
|
||||
|
|
|
|||
|
|
@ -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=""
|
||||
|
|
|
|||
Loading…
Reference in a new issue