Compare commits

...

2 commits

13 changed files with 845 additions and 71 deletions

View file

@ -208,6 +208,74 @@ public static class ActivityLog
return result;
}
/// <summary>
/// Newest-first peek filtered by <see cref="ActivityRelevance"/> against live actor state.
/// Full ring is unchanged (Lore Activity / <see cref="LatestForSubject"/>).
/// </summary>
public static IReadOnlyList<ActivityEntry> LatestRelevantForSubject(
Actor actor,
long subjectId,
int max,
int scanMax = 0)
{
if (subjectId == 0 || max <= 0)
{
return System.Array.Empty<ActivityEntry>();
}
if (!BySubject.TryGetValue(subjectId, out List<ActivityEntry> list) || list.Count == 0)
{
return System.Array.Empty<ActivityEntry>();
}
int scan = scanMax > 0
? Mathf.Min(scanMax, list.Count)
: Mathf.Min(ActivityRelevance.PeekScanMax, list.Count);
float now = Time.unscaledTime;
var picked = new List<ActivityEntry>(max);
for (int i = 0; i < scan && picked.Count < max; i++)
{
ActivityEntry entry = list[list.Count - 1 - i];
if (ActivityRelevance.IsPeekRelevant(actor, entry, now))
{
picked.Add(entry);
}
}
// Never leave the peek empty when the ring has lines - show newest as last resort.
if (picked.Count == 0 && list.Count > 0)
{
picked.Add(list[list.Count - 1]);
}
return picked;
}
/// <summary>Harness: age all lines for a subject so soft-window relevance expires.</summary>
public static int HarnessAgeSubject(long subjectId, float secondsAgo)
{
if (subjectId == 0 || secondsAgo <= 0f
|| !BySubject.TryGetValue(subjectId, out List<ActivityEntry> list))
{
return 0;
}
float stamped = Time.unscaledTime - secondsAgo;
int n = 0;
for (int i = 0; i < list.Count; i++)
{
if (list[i] == null)
{
continue;
}
list[i].CreatedAt = stamped;
n++;
}
return n;
}
public static void NoteTask(Actor actor, string taskId)
{
if (actor == null || !actor.isAlive() || string.IsNullOrEmpty(taskId))

View file

@ -0,0 +1,309 @@
using System;
namespace IdleSpectator;
/// <summary>
/// Live-relevance policy for Activity History <b>peek</b> (dossier).
/// The ring stays append-only; Lore Activity still shows the full journal.
/// Peek only keeps lines that still match what the unit is doing (or just did).
/// </summary>
public static class ActivityRelevance
{
/// <summary>
/// Lines newer than this always pass (beat just happened; AI may not have settled).
/// </summary>
public const float SoftWindowSeconds = 8f;
/// <summary>How many newest ring lines to scan when filling a peek of size N.</summary>
public const int PeekScanMax = 24;
public enum Family
{
Ambient,
Combat,
Haul,
Social,
Romance,
Work,
Care,
Travel,
Milestone,
Status,
Happiness
}
public static Family FamilyForEntry(ActivityEntry entry)
{
if (entry == null)
{
return Family.Ambient;
}
switch (entry.Kind)
{
case ActivityKind.StatusGain:
case ActivityKind.StatusLoss:
return Family.Status;
case ActivityKind.Happiness:
return Family.Happiness;
default:
break;
}
string key = (entry.TaskId ?? "").Trim();
if (key.StartsWith("milestone_", StringComparison.OrdinalIgnoreCase))
{
return Family.Milestone;
}
return FamilyForVerb(ActivityVerbMap.Resolve(key));
}
public static Family FamilyForVerb(string verb)
{
if (string.IsNullOrEmpty(verb))
{
return Family.Ambient;
}
switch (verb)
{
case "hunt":
case "fight":
case "flee":
case "fire":
case "ignite":
case "extinguish":
case "warrior":
case "loot":
case "steal":
return Family.Combat;
case "haul":
return Family.Haul;
case "social":
case "group":
return Family.Social;
case "lover":
return Family.Romance;
case "farm":
case "work":
case "pollinate":
case "fish":
case "trade":
case "build":
case "read":
case "heal":
return Family.Work;
case "eat":
case "sleep":
case "play":
case "relieve":
case "laugh":
case "sing":
case "cry":
case "swear":
case "wait":
case "recharge":
case "dream":
return Family.Care;
case "move":
case "settle":
case "boat":
return Family.Travel;
default:
return Family.Ambient;
}
}
/// <summary>
/// Whether this ring line should appear in the dossier History peek for <paramref name="actor"/>.
/// </summary>
public static bool IsPeekRelevant(Actor actor, ActivityEntry entry, float now)
{
if (entry == null)
{
return false;
}
if (now - entry.CreatedAt <= SoftWindowSeconds)
{
return true;
}
Family family = FamilyForEntry(entry);
if (family == Family.Milestone)
{
// Life beats stay peek-worthy; Chronicle also covers them.
return true;
}
if (actor == null || !actor.isAlive())
{
return false;
}
if (family == Family.Status)
{
return StatusStillMatches(actor, entry);
}
if (family == Family.Happiness)
{
// Discrete mood beats age out of peek after the soft window.
return false;
}
string entryVerb = ActivityVerbMap.Resolve(entry.TaskId ?? "");
string liveTaskId = ActivityInterestTable.SafeTaskId(actor);
string liveVerb = ActivityVerbMap.Resolve(liveTaskId);
if (!string.IsNullOrEmpty(entryVerb)
&& !string.IsNullOrEmpty(liveVerb)
&& (string.Equals(entryVerb, liveVerb, StringComparison.OrdinalIgnoreCase)
|| FamilyForVerb(entryVerb) == FamilyForVerb(liveVerb)))
{
return true;
}
return FamilyLivePredicate(actor, family);
}
/// <summary>
/// Fingerprint of live AI bits that can change peek without adding ring lines.
/// </summary>
public static int LiveFingerprint(Actor actor, float now)
{
int h = 17;
h = h * 31 + (int)(now / SoftWindowSeconds);
if (actor == null)
{
return h;
}
try
{
h = h * 31 + (actor.has_attack_target ? 1 : 0);
h = h * 31 + (actor.isCarryingResources() ? 1 : 0);
string task = ActivityInterestTable.SafeTaskId(actor) ?? "";
h = h * 31 + (task?.GetHashCode() ?? 0);
if (actor.hasTask() && actor.ai?.task != null)
{
h = h * 31 + (actor.ai.task.in_combat ? 1 : 0);
h = h * 31 + (actor.ai.task.is_fireman ? 1 : 0);
}
}
catch
{
// ignore
}
return h;
}
private static bool FamilyLivePredicate(Actor actor, Family family)
{
switch (family)
{
case Family.Combat:
return CombatLive(actor);
case Family.Haul:
return HaulLive(actor);
case Family.Social:
case Family.Romance:
case Family.Work:
case Family.Care:
case Family.Travel:
case Family.Ambient:
default:
return false;
}
}
private static bool CombatLive(Actor actor)
{
try
{
if (actor.has_attack_target)
{
return true;
}
if (actor.hasTask() && actor.ai?.task != null
&& (actor.ai.task.in_combat || actor.ai.task.is_fireman))
{
return true;
}
}
catch
{
// ignore
}
return false;
}
private static bool HaulLive(Actor actor)
{
try
{
return actor.isCarryingResources();
}
catch
{
return false;
}
}
private static bool StatusStillMatches(Actor actor, ActivityEntry entry)
{
string statusId = StatusIdFromEntry(entry);
if (string.IsNullOrEmpty(statusId))
{
return false;
}
try
{
bool has = actor.hasStatus(statusId);
if (entry.Kind == ActivityKind.StatusGain)
{
return has;
}
if (entry.Kind == ActivityKind.StatusLoss)
{
return !has;
}
}
catch
{
// ignore
}
return false;
}
private static string StatusIdFromEntry(ActivityEntry entry)
{
string key = (entry?.TaskId ?? "").Trim();
const string gain = "status_gain:";
const string loss = "status_loss:";
if (key.StartsWith(gain, StringComparison.OrdinalIgnoreCase))
{
return key.Substring(gain.Length).Trim();
}
if (key.StartsWith(loss, StringComparison.OrdinalIgnoreCase))
{
return key.Substring(loss.Length).Trim();
}
// NoteStatusChange may store bare status id or ActivityStatusProse.TaskKey.
if (key.IndexOf(':') < 0 && !string.IsNullOrEmpty(key))
{
return key;
}
return "";
}
}

View file

@ -540,6 +540,40 @@ public static class AgentHarness
break;
}
case "activity_age":
{
long id = WatchCaption.CurrentUnitId;
if (id == 0 && MoveCamera.hasFocusUnit() && MoveCamera._focus_unit != null)
{
try
{
id = MoveCamera._focus_unit.getID();
}
catch
{
id = 0;
}
}
float seconds = 12f;
if (!string.IsNullOrEmpty(cmd.value)
&& float.TryParse(
cmd.value,
System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture,
out float parsed)
&& parsed > 0f)
{
seconds = parsed;
}
int n = ActivityLog.HarnessAgeSubject(id, seconds);
WatchCaption.ForceRefreshHistory();
_cmdOk++;
Emit(cmd, ok: true, detail: $"aged={n} subject={id} seconds={seconds}");
break;
}
case "activity_sample_reset":
{
ActivityLog.ResetSampleCounters();

View file

@ -0,0 +1,67 @@
using System;
using ai.behaviours;
namespace IdleSpectator;
/// <summary>
/// Observe → confirm → emit bridge. Patches stay thin sensors; feeds only run after
/// <see cref="EventOutcome"/> confirms the catalog claim matches game state.
/// </summary>
public static class EventObservation
{
/// <summary>
/// If <paramref name="confirmed"/>, run <paramref name="emit"/>; otherwise record a drop.
/// </summary>
public static bool Confirm(
string source,
string eventId,
bool confirmed,
Action emit)
{
string id = string.IsNullOrEmpty(eventId) ? "unknown" : eventId.Trim();
string src = string.IsNullOrEmpty(source) ? "observe" : source.Trim();
if (!confirmed)
{
InterestDropLog.Record("outcome_unconfirmed", src + ":" + id);
return false;
}
if (emit == null)
{
return false;
}
emit();
return true;
}
/// <summary>
/// Prefix/Postfix helper: snapshot before a behaviour, confirm with a predicate after.
/// </summary>
public static bool ConfirmAfter(
string source,
string eventId,
EventOutcome.ActorFlags before,
Actor actor,
BehResult result,
Func<EventOutcome.ActorFlags, Actor, BehResult, bool> outcome,
Action emit)
{
if (outcome == null)
{
return false;
}
bool ok = false;
try
{
ok = outcome(before, actor, result);
}
catch
{
ok = false;
}
return Confirm(source, eventId, ok, emit);
}
}

View file

@ -0,0 +1,156 @@
using System;
using ai.behaviours;
namespace IdleSpectator;
/// <summary>
/// Shared outcome checks for Layer A/B event confirmation.
/// Patches observe; this layer answers "did the semantic event actually happen?"
/// before any feed Emit. Do not treat <see cref="BehResult.Continue"/> as success.
/// </summary>
public static class EventOutcome
{
/// <summary>Cheap before-snapshot for Prefix → Postfix confirmation.</summary>
public struct ActorFlags
{
public bool Valid;
public bool Alive;
public bool HasFamily;
public bool HasLover;
}
public static ActorFlags Snapshot(Actor actor)
{
var flags = new ActorFlags();
if (actor == null)
{
return flags;
}
flags.Valid = true;
try
{
flags.Alive = actor.isAlive();
if (!flags.Alive)
{
return flags;
}
flags.HasFamily = actor.hasFamily();
flags.HasLover = actor.hasLover();
}
catch
{
flags.Valid = false;
}
return flags;
}
public static bool IsLiving(Actor actor)
{
try
{
return actor != null && actor.isAlive();
}
catch
{
return false;
}
}
/// <summary>Behaviour node finished without Stop. Never implies semantic success.</summary>
public static bool BehaviourRan(BehResult result)
{
return result != BehResult.Stop;
}
public static bool GainedFamily(ActorFlags before, Actor actor)
{
if (!before.Valid || !before.Alive || !IsLiving(actor))
{
return false;
}
try
{
return !before.HasFamily && actor.hasFamily();
}
catch
{
return false;
}
}
public static bool LostFamily(ActorFlags before, Actor actor)
{
if (!before.Valid || !before.Alive || !IsLiving(actor))
{
return false;
}
try
{
return before.HasFamily && !actor.hasFamily();
}
catch
{
return false;
}
}
public static bool GainedLover(ActorFlags before, Actor actor)
{
if (!before.Valid || !before.Alive || !IsLiving(actor))
{
return false;
}
try
{
return !before.HasLover && actor.hasLover();
}
catch
{
return false;
}
}
public static bool LostLover(ActorFlags before, Actor actor)
{
if (!before.Valid || !before.Alive || !IsLiving(actor))
{
return false;
}
try
{
return before.HasLover && !actor.hasLover();
}
catch
{
return false;
}
}
/// <summary>
/// Still seeking: behaviour continued and the unit did not acquire a lover
/// (success path is <c>set_lover</c> / love status).
/// </summary>
public static bool StillSeekingLover(Actor actor, BehResult result)
{
if (!BehaviourRan(result) || !IsLiving(actor))
{
return false;
}
try
{
return !actor.hasLover();
}
catch
{
return false;
}
}
}

View file

@ -4,7 +4,11 @@ using HarmonyLib;
namespace IdleSpectator;
/// <summary>Relationship lifecycle patches from mutation discovery.</summary>
/// <summary>
/// Relationship lifecycle sensors. Thin Harmony only - outcomes confirmed via
/// <see cref="EventOutcome"/> / <see cref="EventObservation"/> before Emit.
/// Prefer state setters (<c>setLover</c>, <c>setParent*</c>, <c>newFamily</c>) over Beh Continue.
/// </summary>
[HarmonyPatch]
public static class RelationshipEventPatches
{
@ -12,11 +16,12 @@ public static class RelationshipEventPatches
[HarmonyPostfix]
public static void PostfixSetLover(Actor __instance, Actor pActor)
{
if (__instance == null || !__instance.isAlive())
if (!EventOutcome.IsLiving(__instance))
{
return;
}
// setLover is already the confirmed state transition.
if (pActor == null)
{
RelationshipInterestFeed.Emit("clear_lover", __instance, null);
@ -105,24 +110,20 @@ public static class RelationshipEventPatches
private static void EmitAddChild(Actor parent, Actor child, bool increaseChildren)
{
if (!increaseChildren || parent == null || child == null)
{
return;
}
if (!parent.isAlive() || !child.isAlive())
{
return;
}
RelationshipInterestFeed.Emit("add_child", parent, child);
EventObservation.Confirm(
"setParent",
"add_child",
increaseChildren
&& EventOutcome.IsLiving(parent)
&& EventOutcome.IsLiving(child),
() => RelationshipInterestFeed.Emit("add_child", parent, child));
}
[HarmonyPatch(typeof(FamilyManager), nameof(FamilyManager.newFamily))]
[HarmonyPostfix]
public static void PostfixNewFamily(Family __result, Actor pActor)
{
Actor anchor = pActor != null && pActor.isAlive() ? pActor : null;
Actor anchor = EventOutcome.IsLiving(pActor) ? pActor : null;
RelationshipInterestFeed.EmitFamily("new_family", __result, anchor);
}
@ -137,70 +138,103 @@ public static class RelationshipEventPatches
[HarmonyPostfix]
public static void PostfixCreateBaby(Actor __result)
{
if (__result == null || !__result.isAlive())
{
return;
}
RelationshipInterestFeed.Emit("baby_created", __result, null);
EventObservation.Confirm(
"createBaby",
"baby_created",
EventOutcome.IsLiving(__result),
() => RelationshipInterestFeed.Emit("baby_created", __result, null));
}
/// <summary>
/// Seeking only: BehFindLover returns Continue both when still searching and after
/// <see cref="Actor.becomeLoversWith"/>. Success is covered by set_lover / love status.
/// Seeking only. Beh Continue is not success - confirm still !hasLover.
/// Success is covered by set_lover / love status.
/// </summary>
[HarmonyPatch(typeof(BehFindLover), nameof(BehFindLover.execute))]
[HarmonyPostfix]
public static void PostfixFindLover(Actor pActor, BehResult __result)
{
if (pActor == null || !pActor.isAlive() || __result == BehResult.Stop)
{
return;
}
EventObservation.Confirm(
"BehFindLover",
"find_lover",
EventOutcome.StillSeekingLover(pActor, __result),
() => RelationshipInterestFeed.Emit("find_lover", pActor, null));
}
if (pActor.hasLover())
{
return;
}
RelationshipInterestFeed.Emit("find_lover", pActor, null);
[HarmonyPatch(typeof(BehFamilyGroupNew), nameof(BehFamilyGroupNew.execute))]
[HarmonyPrefix]
public static void PrefixFamilyNew(Actor pActor, out EventOutcome.ActorFlags __state)
{
__state = EventOutcome.Snapshot(pActor);
}
[HarmonyPatch(typeof(BehFamilyGroupNew), nameof(BehFamilyGroupNew.execute))]
[HarmonyPostfix]
public static void PostfixFamilyNew(Actor pActor, BehResult __result)
public static void PostfixFamilyNew(Actor pActor, BehResult __result, EventOutcome.ActorFlags __state)
{
if (pActor == null || !pActor.isAlive() || __result == BehResult.Stop)
{
return;
}
// Shares founding key with new_family when both fire.
EventObservation.ConfirmAfter(
"BehFamilyGroupNew",
"family_group_new",
__state,
pActor,
__result,
(before, actor, result) =>
EventOutcome.BehaviourRan(result) && EventOutcome.GainedFamily(before, actor),
() => RelationshipInterestFeed.Emit(
"family_group_new",
pActor,
ActorRelation.ResolvePackRelated(pActor)));
}
// BehFamilyGroupNew always calls FamilyManager.newFamily first - share that founding key.
RelationshipInterestFeed.Emit("family_group_new", pActor, ActorRelation.ResolvePackRelated(pActor));
[HarmonyPatch(typeof(BehFamilyGroupJoin), nameof(BehFamilyGroupJoin.execute))]
[HarmonyPrefix]
public static void PrefixFamilyJoin(Actor pActor, out EventOutcome.ActorFlags __state)
{
__state = EventOutcome.Snapshot(pActor);
}
[HarmonyPatch(typeof(BehFamilyGroupJoin), nameof(BehFamilyGroupJoin.execute))]
[HarmonyPostfix]
public static void PostfixFamilyJoin(Actor pActor, BehResult __result)
public static void PostfixFamilyJoin(Actor pActor, BehResult __result, EventOutcome.ActorFlags __state)
{
if (pActor == null || !pActor.isAlive() || __result == BehResult.Stop)
{
return;
}
// Vanilla returns Continue even when getNearbyFamily was null - require GainedFamily.
EventObservation.ConfirmAfter(
"BehFamilyGroupJoin",
"family_group_join",
__state,
pActor,
__result,
(before, actor, result) =>
EventOutcome.BehaviourRan(result) && EventOutcome.GainedFamily(before, actor),
() => RelationshipInterestFeed.Emit(
"family_group_join",
pActor,
ActorRelation.ResolvePackRelated(pActor)));
}
RelationshipInterestFeed.Emit("family_group_join", pActor, ActorRelation.ResolvePackRelated(pActor));
[HarmonyPatch(typeof(BehFamilyGroupLeave), nameof(BehFamilyGroupLeave.execute))]
[HarmonyPrefix]
public static void PrefixFamilyLeave(Actor pActor, out EventOutcome.ActorFlags __state)
{
__state = EventOutcome.Snapshot(pActor);
}
[HarmonyPatch(typeof(BehFamilyGroupLeave), nameof(BehFamilyGroupLeave.execute))]
[HarmonyPostfix]
public static void PostfixFamilyLeave(Actor pActor, BehResult __result)
public static void PostfixFamilyLeave(Actor pActor, BehResult __result, EventOutcome.ActorFlags __state)
{
if (pActor == null || !pActor.isAlive() || __result == BehResult.Stop)
{
return;
}
RelationshipInterestFeed.Emit("family_group_leave", pActor, ActorRelation.ResolvePackRelated(pActor));
EventObservation.ConfirmAfter(
"BehFamilyGroupLeave",
"family_group_leave",
__state,
pActor,
__result,
(before, actor, result) =>
EventOutcome.BehaviourRan(result) && EventOutcome.LostFamily(before, actor),
() => RelationshipInterestFeed.Emit(
"family_group_leave",
pActor,
ActorRelation.ResolvePackRelated(pActor)));
}
/// <summary>
@ -211,11 +245,10 @@ public static class RelationshipEventPatches
[HarmonyPostfix]
public static void PostfixSetAlpha(Actor pActor, bool pNew)
{
if (!pNew || pActor == null || !pActor.isAlive())
{
return;
}
InterestFeeds.EmitAlpha(pActor, "family_set_alpha");
EventObservation.Confirm(
"Family.setAlpha",
"become_alpha",
pNew && EventOutcome.IsLiving(pActor),
() => InterestFeeds.EmitAlpha(pActor, "family_set_alpha"));
}
}

View file

@ -1393,6 +1393,22 @@ internal static class HarnessScenarios
Step("act16r", "dossier_clear_job"),
Step("act17", "activity_force", asset: "BehPollinate", label: "Works the flower", count: 3, tier: "beat"),
Step("act17b", "activity_force", asset: "zzz_harness_act", label: "Harness pollen trail", count: 1),
// Live-relevance peek: after soft window, aged hunt drops; newest unload remains.
// Isolated ring so older move-matching lines cannot crowd the peek.
Step("act17c", "activity_clear"),
Step("act17d", "activity_force", asset: "BehAttackActorHuntingTarget",
label: "Hunts prey", count: 1, tier: "beat", expect: "PreyThing"),
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"),
Step("act17h", "assert", expect: "dossier_history_contains", value: "wheat"),
Step("act17i", "assert", expect: "activity_log_contains", value: "Hunts"),
// 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),
Step("act17l", "activity_force", asset: "BehSocializeTalk", label: "Talks", count: 1, tier: "beat",
expect: "Barkley"),
Step("act18", "wait", wait: 0.25f),
Step("act19", "assert", expect: "activity_count", value: "4", label: "min"),
Step("act20", "assert", expect: "activity_prose_variety", value: "2"),

View file

@ -77,6 +77,7 @@ public static class WatchCaption
private static float _historyColW = HistoryColMinW;
private static int _lastHistoryCount = -1;
private static long _lastHistorySubjectId;
private static int _lastLiveHistFingerprint = int.MinValue;
private const float LifeSepH = 3f;
private static Button _favoriteBtn;
@ -896,6 +897,7 @@ public static class WatchCaption
public static void ForceRefreshHistory()
{
_lastHistoryCount = -1;
_lastLiveHistFingerprint = int.MinValue;
if (!_visible || _current == null)
{
return;
@ -935,7 +937,11 @@ public static class WatchCaption
long id = _current.UnitId;
int count = ActivityLog.CountFor(id) + Chronicle.HistoryCountFor(id);
if (id == _lastHistorySubjectId && count == _lastHistoryCount)
Actor live = ResolveBoundLiveActor();
int fingerprint = ActivityRelevance.LiveFingerprint(live, Time.unscaledTime);
if (id == _lastHistorySubjectId
&& count == _lastHistoryCount
&& fingerprint == _lastLiveHistFingerprint)
{
return;
}
@ -1146,8 +1152,30 @@ public static class WatchCaption
// does not vanish under a busy activity ring (and chronicle smoke stays valid).
int chronicleCount = Chronicle.HistoryCountFor(unitId);
int actCap = chronicleCount > 0 ? HistoryPeekMax - 1 : HistoryPeekMax;
IReadOnlyList<ActivityEntry> activity =
ActivityLog.LatestForSubject(unitId, actCap);
Actor live = null;
try
{
live = ResolveBoundLiveActor();
if (live != null && live.isAlive() && live.getID() != unitId)
{
live = null;
}
if (live == null && MoveCamera.hasFocusUnit() && MoveCamera._focus_unit != null
&& MoveCamera._focus_unit.isAlive()
&& MoveCamera._focus_unit.getID() == unitId)
{
live = MoveCamera._focus_unit;
}
}
catch
{
live = null;
}
IReadOnlyList<ActivityEntry> activity = live != null
? ActivityLog.LatestRelevantForSubject(live, unitId, actCap)
: ActivityLog.LatestForSubject(unitId, actCap);
int lifeNeed = Mathf.Max(0, HistoryPeekMax - (activity?.Count ?? 0));
IReadOnlyList<ChronicleEntry> life =
@ -1157,6 +1185,7 @@ public static class WatchCaption
_lastHistorySubjectId = unitId;
_lastHistoryCount = ActivityLog.CountFor(unitId) + chronicleCount;
_lastLiveHistFingerprint = ActivityRelevance.LiveFingerprint(live, Time.unscaledTime);
LastHistoryPreview = "";
LastHistoryJoined = "";
@ -1181,11 +1210,11 @@ public static class WatchCaption
string taskFallback = "";
try
{
Actor live = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
if (live != null && live.isAlive() && live.getID() == unitId && live.hasTask()
&& live.ai?.task != null)
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
if (focus != null && focus.isAlive() && focus.getID() == unitId && focus.hasTask()
&& focus.ai?.task != null)
{
taskFallback = live.ai.task.getLocalizedText() ?? "";
taskFallback = focus.ai.task.getLocalizedText() ?? "";
}
}
catch

View file

@ -1,7 +1,7 @@
{
"name": "IdleSpectator",
"author": "dazed",
"version": "0.25.11",
"description": "AFK Idle Spectator (I) + Lore (L). Event trigger audit Phases 4-8 + job reason row.",
"version": "0.25.14",
"description": "AFK Idle Spectator (I) + Lore (L). EventOutcome observe→confirm→emit.",
"GUID": "com.dazed.idlespectator"
}

38
docs/event-observation.md Normal file
View file

@ -0,0 +1,38 @@
# Event observation (observe → confirm → emit)
IdleSpectator learns about the game through Harmony sensors.
Those sensors must not treat AI noise as catalog truth.
## Pipeline
```text
Patch (Observe) → EventOutcome (Confirm) → Feed Emit (EventReason + catalog)
```
| Layer | Responsibility | Lives in |
|-------|----------------|----------|
| Observe | Thin Harmony Prefix/Postfix; snapshot before when needed | `Events/Patches/*` |
| Confirm | Did the semantic claim happen? | `Events/EventOutcome.cs` |
| Bridge | Drop unconfirmed with `InterestDropLog`; else run emit | `Events/EventObservation.cs` |
| Emit | Catalog dials, keys, `EventReason` labels | `Events/Feeds/*` |
## Rules
1. **Never** treat `BehResult.Continue` as success by itself.
2. Prefer **state setters** when they exist (`setLover`, `setParent*`, `newFamily`, `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`.
## Example (family join)
Vanilla `BehFamilyGroupJoin` returns Continue even when no nearby family exists.
Confirm with `GainedFamily(before, actor)` before Emit `family_group_join`.
## Adding an event
1. Find the real game call site (setter or Beh).
2. Add/extend an `EventOutcome` predicate if needed.
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.

View file

@ -117,3 +117,16 @@ Phases 47 worksheet complete (all audit rows `done`).
Worksheet: `docs/event-trigger-audit.tsv` (all domains done).
## Family pack join false positive (2026-07-16)
`BehFamilyGroupJoin` returns `Continue` even when `getNearbyFamily` is null (no `setFamily`).
IdleSpectator used to Emit on any Continue → orange “joins a family pack” with no Family meta.
Gate: Emit only when `hasFamily()` after execute.
## Observe → confirm → emit (2026-07-16)
Shared pipeline: `EventOutcome` predicates + `EventObservation.Confirm*` before feeds.
Relationship Beh hooks (find_lover, family group new/join/leave) migrated first.
Rule: never treat `BehResult.Continue` as semantic success.
Docs: `docs/event-observation.md`.

View file

@ -16,6 +16,16 @@ Character fill runs only when the pending **EventLed** queue is empty, and uses
Orange dossier reason = the owning A events `Label` while the scene is active (`InterestDirector.TryGetOwnedReasonCandidate`).
Quiet grace / inactive dwell clears the reason even if focus has not switched yet.
## Activity History peek freshness
Layer B ring stays append-only (Lore Activity = full journal).
Dossier History peek uses `ActivityRelevance` / `ActivityLog.LatestRelevantForSubject`:
- Soft window (~8s): newest beats always show.
- After that: same verb/family as live task, or family live predicates (combat ↔ attack target / in_combat, haul ↔ carrying, status ↔ still has/lost status).
- Milestones always peek-worthy.
- Peek refreshes when live AI fingerprint changes (task, attack target, carrying), not only when the ring grows.
Drops (ambient, createsInterest=false, identity filter, below margin, …) go to `InterestDropLog`.
## EventReason
@ -72,6 +82,7 @@ Active EventLed A scenes should not silently blank a valid Label.
## See also
- [`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
- [`scoring-model.md`](scoring-model.md) - score weights

View file

@ -212,11 +212,11 @@ relationship clear_lover 60 True setLover(null)+MapBox dead-lover cleanup hook_r
relationship add_child 74 True Actor.setParent1/setParent2 hook_retarget done
relationship new_family 72 True FamilyManager.newFamily accurate done
relationship family_removed 58 True FamilyManager.removeObject accurate Confirmed patch + EventReason prose done
relationship find_lover 52 True BehFindLover Continue && !hasLover gate done
relationship find_lover 52 True BehFindLover + EventOutcome.StillSeekingLover gate EventObservation; success via set_lover done
relationship become_alpha 74 True Family.setAlpha accurate done
relationship family_group_new 58 True BehFamilyGroupNew; shares new_family key merge done
relationship family_group_join 36 True BehFamilyGroupJoin accurate Confirmed patch + EventReason prose done
relationship family_group_leave 36 True BehFamilyGroupLeave accurate Confirmed patch + EventReason prose done
relationship family_group_new 58 True BehFamilyGroupNew + EventOutcome.GainedFamily accurate EventObservation; shares new_family key done
relationship family_group_join 36 True BehFamilyGroupJoin + EventOutcome.GainedFamily gate EventObservation; Continue≠join done
relationship family_group_leave 36 True BehFamilyGroupLeave + EventOutcome.LostFamily accurate EventObservation done
relationship baby_created 72 True ActorManager.createBabyActorFromData accurate done
traits zombie 72 True Actor.addTrait/removeTrait → EventReason.Trait prose_fix Catalog label aligned to gains/loses; spawn-window gate done
traits wise 38 True Actor.addTrait/removeTrait → EventReason.Trait prose_fix Catalog label aligned to gains/loses; spawn-window gate done

1 domain id strength camera game_call_site verdict fix_action done
212 relationship add_child 74 True Actor.setParent1/setParent2 hook_retarget done
213 relationship new_family 72 True FamilyManager.newFamily accurate done
214 relationship family_removed 58 True FamilyManager.removeObject accurate Confirmed patch + EventReason prose done
215 relationship find_lover 52 True BehFindLover Continue && !hasLover BehFindLover + EventOutcome.StillSeekingLover gate EventObservation; success via set_lover done
216 relationship become_alpha 74 True Family.setAlpha accurate done
217 relationship family_group_new 58 True BehFamilyGroupNew; shares new_family key BehFamilyGroupNew + EventOutcome.GainedFamily merge accurate EventObservation; shares new_family key done
218 relationship family_group_join 36 True BehFamilyGroupJoin BehFamilyGroupJoin + EventOutcome.GainedFamily accurate gate Confirmed patch + EventReason prose EventObservation; Continue≠join done
219 relationship family_group_leave 36 True BehFamilyGroupLeave BehFamilyGroupLeave + EventOutcome.LostFamily accurate Confirmed patch + EventReason prose EventObservation done
220 relationship baby_created 72 True ActorManager.createBabyActorFromData accurate done
221 traits zombie 72 True Actor.addTrait/removeTrait → EventReason.Trait prose_fix Catalog label aligned to gains/loses; spawn-window gate done
222 traits wise 38 True Actor.addTrait/removeTrait → EventReason.Trait prose_fix Catalog label aligned to gains/loses; spawn-window gate done