From 8ab8a9880a485dc6d7951a5cc0fa9ce04d1dbffd Mon Sep 17 00:00:00 2001 From: DazedAnon Date: Wed, 15 Jul 2026 13:29:20 -0500 Subject: [PATCH] Enhance ActivityLog with verb suppression logic to prevent duplicate task entries within a cooldown period. Introduce methods for managing logged verbs and their timestamps, and update related functionality in AgentHarness and WatchCaption for improved task synchronization. Add new harness scenarios for testing verb deduplication and task refresh. Increment version in mod.json to reflect these changes. --- IdleSpectator/ActivityLog.cs | 123 +++++++++++++++++++++++++++++- IdleSpectator/AgentHarness.cs | 41 ++++++++++ IdleSpectator/HarnessScenarios.cs | 2 + IdleSpectator/UnitDossier.cs | 10 ++- IdleSpectator/WatchCaption.cs | 111 ++++++++++++++++++++++----- IdleSpectator/mod.json | 2 +- 6 files changed, 264 insertions(+), 25 deletions(-) diff --git a/IdleSpectator/ActivityLog.cs b/IdleSpectator/ActivityLog.cs index b894288..9e92441 100644 --- a/IdleSpectator/ActivityLog.cs +++ b/IdleSpectator/ActivityLog.cs @@ -35,12 +35,20 @@ public static class ActivityLog public const int MaxLinesPerSubject = 32; public const int MaxSubjects = 4000; private const float ActionBeatCooldown = 2.5f; + /// + /// Suppress repeat Task/Beat lines that resolve to the same verb (e.g. play/child_* churn). + /// Status and milestones are exempt. + /// + private const float SameVerbCooldown = 10f; private static readonly Dictionary> BySubject = new Dictionary>(); private static readonly Dictionary LastTaskId = new Dictionary(); private static readonly Dictionary LastBeatAt = new Dictionary(); + private static readonly Dictionary LastLoggedVerb = new Dictionary(); + private static readonly Dictionary LastLoggedVerbAt = new Dictionary(); private static readonly Dictionary LineIndex = new Dictionary(); + private static int _verbSuppressedSinceClear; private static int _notesSinceClear; private static int _tasksSinceClear; @@ -65,12 +73,15 @@ public static class ActivityLog public static int StatusRefreshesSinceClear => _statusRefreshesSinceClear; public static string LastStatusGainId => _lastStatusGainId ?? ""; public static string LastStatusLossId => _lastStatusLossId ?? ""; + public static int VerbSuppressedSinceClear => _verbSuppressedSinceClear; public static void ClearSession() { BySubject.Clear(); LastTaskId.Clear(); LastBeatAt.Clear(); + LastLoggedVerb.Clear(); + LastLoggedVerbAt.Clear(); LineIndex.Clear(); ResetSampleCounters(); } @@ -84,6 +95,7 @@ public static class ActivityLog _statusGainsSinceClear = 0; _statusLossesSinceClear = 0; _statusRefreshesSinceClear = 0; + _verbSuppressedSinceClear = 0; _lastStatusGainId = ""; _lastStatusLossId = ""; _clearedAt = Time.unscaledTime; @@ -150,7 +162,7 @@ public static class ActivityLog return $"elapsed={elapsed:0.0}s notes={_notesSinceClear} tasks={_tasksSinceClear} beats={_beatsSinceClear} " + $"status_gains={_statusGainsSinceClear} status_losses={_statusLossesSinceClear} " - + $"status_refreshes={_statusRefreshesSinceClear} " + + $"status_refreshes={_statusRefreshesSinceClear} verb_suppressed={_verbSuppressedSinceClear} " + $"per_sec={perSec:0.00} per_min={perMin:0.0} per_subject_per_min={perSubjectPerMin:0.00} " + $"subjects={BySubject.Count} ring_lines={lines} top=[{top}]"; } @@ -215,7 +227,13 @@ public static class ActivityLog return; } + if (ShouldSuppressSameVerb(id, taskId)) + { + return; + } + LastTaskId[id] = taskId; + RememberLoggedVerb(id, taskId); ActivityContext ctx = ActivityInterestTable.BuildContext(actor, taskId); string jobId = ctx.JobId; string actorName = ctx.ActorName; @@ -323,7 +341,13 @@ public static class ActivityLog return; } + if (ShouldSuppressSameVerb(id, actionKey)) + { + return; + } + LastBeatAt[id] = now; + RememberLoggedVerb(id, actionKey); ActivityContext ctx = ActivityInterestTable.BuildContext(actor, actionKey); string jobId = ctx.JobId; @@ -811,6 +835,101 @@ public static class ActivityLog return idx; } + private static string ResolveVerbKey(string key) + { + if (string.IsNullOrEmpty(key)) + { + return ""; + } + + string verb = ActivityVerbMap.Resolve(key); + return string.IsNullOrEmpty(verb) ? key.Trim().ToLowerInvariant() : verb; + } + + private static bool ShouldSuppressSameVerb(long subjectId, string key) + { + string verb = ResolveVerbKey(key); + if (string.IsNullOrEmpty(verb)) + { + return false; + } + + float now = Time.unscaledTime; + if (LastLoggedVerb.TryGetValue(subjectId, out string prev) + && prev == verb + && LastLoggedVerbAt.TryGetValue(subjectId, out float at) + && now - at < SameVerbCooldown) + { + _verbSuppressedSinceClear++; + return true; + } + + return false; + } + + private static void RememberLoggedVerb(long subjectId, string key) + { + string verb = ResolveVerbKey(key); + if (string.IsNullOrEmpty(verb)) + { + return; + } + + LastLoggedVerb[subjectId] = verb; + LastLoggedVerbAt[subjectId] = Time.unscaledTime; + } + + /// + /// Harness: attempt several same-verb task keys on a live actor; returns how many lines were added. + /// + public static int HarnessSpamSameVerb(Actor actor, string[] taskKeys) + { + if (actor == null || taskKeys == null || taskKeys.Length == 0) + { + return 0; + } + + long id; + try + { + if (!actor.isAlive()) + { + return 0; + } + + id = actor.getID(); + } + catch + { + return 0; + } + + if (id == 0) + { + return 0; + } + + int before = CountFor(id); + // Fresh window: allow the first key through regardless of prior task/verb state. + LastTaskId.Remove(id); + LastLoggedVerb.Remove(id); + LastLoggedVerbAt.Remove(id); + for (int i = 0; i < taskKeys.Length; i++) + { + string key = taskKeys[i]; + if (string.IsNullOrEmpty(key)) + { + continue; + } + + // Drop exact-id gate so each distinct play-like key can attempt a line. + LastTaskId.Remove(id); + NoteTask(actor, key); + } + + return Mathf.Max(0, CountFor(id) - before); + } + private static void Append(long id, ActivityEntry entry) { if (!BySubject.TryGetValue(id, out List list)) @@ -873,6 +992,8 @@ public static class ActivityLog BySubject.Remove(id); LastTaskId.Remove(id); LastBeatAt.Remove(id); + LastLoggedVerb.Remove(id); + LastLoggedVerbAt.Remove(id); LineIndex.Remove(id); need--; } diff --git a/IdleSpectator/AgentHarness.cs b/IdleSpectator/AgentHarness.cs index 8724993..a678391 100644 --- a/IdleSpectator/AgentHarness.cs +++ b/IdleSpectator/AgentHarness.cs @@ -2004,6 +2004,20 @@ public static class AgentHarness detail = $"needle='{needle}' caption='{caption.Replace("\n", " | ")}' dossier={(dossier != null)}"; break; } + case "dossier_task_refresh": + { + // Stale focus snapshot must recover: blank chip, then sync from live AI. + Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null; + string live = UnitDossier.ReadLiveTask(focus); + string after = WatchCaption.HarnessBlankAndRefreshTask(); + string chip = WatchCaption.ShownTaskChipText; + bool chipMatches = string.IsNullOrEmpty(live) + ? string.IsNullOrEmpty(chip) + : !string.IsNullOrEmpty(chip); + pass = after == live && chipMatches; + detail = $"live='{live}' dossier='{after}' chip='{chip}'"; + break; + } case "chronicle_count": { int want = ParseCountExpect(cmd, defaultValue: 1); @@ -2251,6 +2265,33 @@ public static class AgentHarness detail = $"absent={!hit} needle='{needle}' sample='{sample}' count={entries.Count}"; break; } + case "activity_verb_dedupe": + { + // Distinct play-like task ids should collapse to one Activity line within the cooldown. + Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null; + if (focus == null || !focus.isAlive()) + { + pass = false; + detail = "no_live_focus"; + break; + } + + ActivityLog.ResetSampleCounters(); + string[] keys = + { + "task_unit_play", + "child_play", + "fun_move", + "child_run_around", + "frolic" + }; + int added = ActivityLog.HarnessSpamSameVerb(focus, keys); + int suppressed = ActivityLog.VerbSuppressedSinceClear; + pass = added == 1 && suppressed >= keys.Length - 1; + detail = + $"added={added} want_added=1 suppressed={suppressed} want_suppressed>={keys.Length - 1} keys={keys.Length}"; + break; + } case "activity_prose_sentence": { // Ensure mood/task labels become sentences, not "{name} laughing". diff --git a/IdleSpectator/HarnessScenarios.cs b/IdleSpectator/HarnessScenarios.cs index d2a3392..1d0a726 100644 --- a/IdleSpectator/HarnessScenarios.cs +++ b/IdleSpectator/HarnessScenarios.cs @@ -613,6 +613,8 @@ internal static class HarnessScenarios Step("act13e", "assert", expect: "activity_prose_library"), Step("act13f", "assert", expect: "activity_taxonomy_audit"), Step("act13g", "assert", expect: "activity_status_audit"), + Step("act13h", "assert", expect: "activity_verb_dedupe"), + Step("act13i", "assert", expect: "dossier_task_refresh"), Step("act14", "activity_force", asset: "laughing", label: "Laughing", count: 1, value: "angle"), Step("act14b", "assert", expect: "activity_log_contains", value: "celestial"), Step("act14c", "activity_force", asset: "eating", label: "Taking a meal", count: 1), diff --git a/IdleSpectator/UnitDossier.cs b/IdleSpectator/UnitDossier.cs index 9cbb508..3947946 100644 --- a/IdleSpectator/UnitDossier.cs +++ b/IdleSpectator/UnitDossier.cs @@ -671,11 +671,17 @@ public sealed class UnitDossier return Humanize(trait.id); } + /// Live AI task label for the nametag chip (localized, else humanized id). + public static string ReadLiveTask(Actor actor) + { + return SafeTask(actor); + } + private static string SafeTask(Actor actor) { try { - if (!actor.hasTask() || actor.ai == null || actor.ai.task == null) + if (actor == null || !actor.hasTask() || actor.ai == null || actor.ai.task == null) { return ""; } @@ -692,7 +698,7 @@ public sealed class UnitDossier { try { - if (actor.hasTask() && actor.ai?.task != null) + if (actor != null && actor.hasTask() && actor.ai?.task != null) { return Humanize(actor.ai.task.id); } diff --git a/IdleSpectator/WatchCaption.cs b/IdleSpectator/WatchCaption.cs index 5d25898..4bd7397 100644 --- a/IdleSpectator/WatchCaption.cs +++ b/IdleSpectator/WatchCaption.cs @@ -502,6 +502,7 @@ public static class WatchCaption } RefreshLivePortrait(); + RefreshLiveTask(); RefreshHistoryIfChanged(); return; } @@ -551,6 +552,7 @@ public static class WatchCaption } RefreshLivePortrait(); + RefreshLiveTask(); RefreshHistoryIfChanged(); } @@ -609,6 +611,51 @@ public static class WatchCaption return; } + Actor actor = ResolveBoundLiveActor(); + if (actor == null) + { + return; + } + + DossierAvatar.Show(actor); + BringHeaderFront(); + } + + /// + /// Keep the nametag task chip in sync with live AI (focus snapshot can miss brief no-task gaps). + /// + private static void RefreshLiveTask() + { + if (!_visible || _current == null) + { + return; + } + + Actor actor = ResolveBoundLiveActor(); + if (actor == null) + { + return; + } + + string live = UnitDossier.ReadLiveTask(actor); + string prev = _current.TaskText ?? ""; + if (live == prev) + { + return; + } + + _current.TaskText = live; + ApplyTaskChip(live); + + bool hasBody = _current.UnitId != 0; + bool hasTask = !string.IsNullOrEmpty(live); + bool hasReason = _reasonText != null && _reasonText.gameObject.activeSelf; + Relayout(hasBody, CountActiveTraitSlots(), hasTask, hasReason, CountActiveHistorySlots()); + BringHeaderFront(); + } + + private static Actor ResolveBoundLiveActor() + { Actor actor = _boundActor; if (actor == null || !actor.isAlive()) { @@ -620,18 +667,56 @@ public static class WatchCaption if (actor == null || !actor.isAlive()) { - return; + return null; } if (_current != null && actor.getID() != _current.UnitId) { - return; + return null; } - DossierAvatar.Show(actor); - BringHeaderFront(); + return actor; } + private static void ApplyTaskChip(string taskText) + { + bool hasTask = !string.IsNullOrEmpty(taskText); + if (_taskIcon != null) + { + HudIcons.Apply(_taskIcon, hasTask ? HudIcons.Task() : null); + _taskIcon.gameObject.SetActive(hasTask); + } + + if (_taskText != null) + { + string task = hasTask ? taskText : ""; + if (task.Length > 14) + { + task = task.Substring(0, 13) + "..."; + } + + _taskText.text = task; + _taskText.gameObject.SetActive(hasTask); + } + } + + /// Harness: blank the nametag task, then sync from live AI. + public static string HarnessBlankAndRefreshTask() + { + if (_current != null) + { + _current.TaskText = ""; + } + + ApplyTaskChip(""); + RefreshLiveTask(); + return _current != null ? (_current.TaskText ?? "") : ""; + } + + /// Harness: text currently shown on the nametag task chip. + public static string ShownTaskChipText => + _taskText != null && _taskText.gameObject.activeSelf ? (_taskText.text ?? "") : ""; + private static void BringHeaderFront() { // Draw nametag above vanilla avatar chrome (which can paint outside its host). @@ -811,23 +896,7 @@ public static class WatchCaption } bool hasTask = dossier != null && !string.IsNullOrEmpty(dossier.TaskText); - if (_taskIcon != null) - { - HudIcons.Apply(_taskIcon, hasTask ? HudIcons.Task() : null); - _taskIcon.gameObject.SetActive(hasTask); - } - - if (_taskText != null) - { - string task = hasTask ? dossier.TaskText : ""; - if (task.Length > 14) - { - task = task.Substring(0, 13) + "..."; - } - - _taskText.text = task; - _taskText.gameObject.SetActive(hasTask); - } + ApplyTaskChip(hasTask ? dossier.TaskText : ""); int traitCount = 0; LastTraitsPreview = ""; diff --git a/IdleSpectator/mod.json b/IdleSpectator/mod.json index aba7969..b274dd8 100644 --- a/IdleSpectator/mod.json +++ b/IdleSpectator/mod.json @@ -1,7 +1,7 @@ { "name": "IdleSpectator", "author": "dazed", - "version": "0.12.98", + "version": "0.12.100", "description": "AFK Idle Spectator (I) + Lore (L). Detailed living activity prose by default.", "GUID": "com.dazed.idlespectator" }