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" }