diff --git a/IdleSpectator/ActivityLog.cs b/IdleSpectator/ActivityLog.cs
index 9289c97..8b4f3a2 100644
--- a/IdleSpectator/ActivityLog.cs
+++ b/IdleSpectator/ActivityLog.cs
@@ -208,6 +208,74 @@ public static class ActivityLog
return result;
}
+ ///
+ /// Newest-first peek filtered by against live actor state.
+ /// Full ring is unchanged (Lore Activity / ).
+ ///
+ public static IReadOnlyList LatestRelevantForSubject(
+ Actor actor,
+ long subjectId,
+ int max,
+ int scanMax = 0)
+ {
+ if (subjectId == 0 || max <= 0)
+ {
+ return System.Array.Empty();
+ }
+
+ if (!BySubject.TryGetValue(subjectId, out List list) || list.Count == 0)
+ {
+ return System.Array.Empty();
+ }
+
+ int scan = scanMax > 0
+ ? Mathf.Min(scanMax, list.Count)
+ : Mathf.Min(ActivityRelevance.PeekScanMax, list.Count);
+ float now = Time.unscaledTime;
+ var picked = new List(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;
+ }
+
+ /// Harness: age all lines for a subject so soft-window relevance expires.
+ public static int HarnessAgeSubject(long subjectId, float secondsAgo)
+ {
+ if (subjectId == 0 || secondsAgo <= 0f
+ || !BySubject.TryGetValue(subjectId, out List 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))
diff --git a/IdleSpectator/ActivityRelevance.cs b/IdleSpectator/ActivityRelevance.cs
new file mode 100644
index 0000000..84428bc
--- /dev/null
+++ b/IdleSpectator/ActivityRelevance.cs
@@ -0,0 +1,309 @@
+using System;
+
+namespace IdleSpectator;
+
+///
+/// Live-relevance policy for Activity History peek (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).
+///
+public static class ActivityRelevance
+{
+ ///
+ /// Lines newer than this always pass (beat just happened; AI may not have settled).
+ ///
+ public const float SoftWindowSeconds = 8f;
+
+ /// How many newest ring lines to scan when filling a peek of size N.
+ 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;
+ }
+ }
+
+ ///
+ /// Whether this ring line should appear in the dossier History peek for .
+ ///
+ 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);
+ }
+
+ ///
+ /// Fingerprint of live AI bits that can change peek without adding ring lines.
+ ///
+ 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 "";
+ }
+}
diff --git a/IdleSpectator/AgentHarness.cs b/IdleSpectator/AgentHarness.cs
index 917c9ef..a45f6e3 100644
--- a/IdleSpectator/AgentHarness.cs
+++ b/IdleSpectator/AgentHarness.cs
@@ -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();
diff --git a/IdleSpectator/HarnessScenarios.cs b/IdleSpectator/HarnessScenarios.cs
index e2a5ddb..298ef19 100644
--- a/IdleSpectator/HarnessScenarios.cs
+++ b/IdleSpectator/HarnessScenarios.cs
@@ -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"),
diff --git a/IdleSpectator/WatchCaption.cs b/IdleSpectator/WatchCaption.cs
index 67e1c07..f23ae6e 100644
--- a/IdleSpectator/WatchCaption.cs
+++ b/IdleSpectator/WatchCaption.cs
@@ -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 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 activity = live != null
+ ? ActivityLog.LatestRelevantForSubject(live, unitId, actCap)
+ : ActivityLog.LatestForSubject(unitId, actCap);
int lifeNeed = Mathf.Max(0, HistoryPeekMax - (activity?.Count ?? 0));
IReadOnlyList 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
diff --git a/IdleSpectator/mod.json b/IdleSpectator/mod.json
index fe97a85..4e33f40 100644
--- a/IdleSpectator/mod.json
+++ b/IdleSpectator/mod.json
@@ -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.12",
+ "description": "AFK Idle Spectator (I) + Lore (L). Activity History peek live-relevance.",
"GUID": "com.dazed.idlespectator"
}
diff --git a/docs/event-reason.md b/docs/event-reason.md
index 3c18688..abe437f 100644
--- a/docs/event-reason.md
+++ b/docs/event-reason.md
@@ -16,6 +16,16 @@ Character fill runs only when the pending **EventLed** queue is empty, and uses
Orange dossier reason = the owning A event’s `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