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