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