diff --git a/IdleSpectator/ActivityLog.cs b/IdleSpectator/ActivityLog.cs
index 9e92441..f617f9e 100644
--- a/IdleSpectator/ActivityLog.cs
+++ b/IdleSpectator/ActivityLog.cs
@@ -849,7 +849,7 @@ public static class ActivityLog
private static bool ShouldSuppressSameVerb(long subjectId, string key)
{
string verb = ResolveVerbKey(key);
- if (string.IsNullOrEmpty(verb))
+ if (string.IsNullOrEmpty(verb) || ActivityVerbMap.IsSameVerbCooldownExempt(verb))
{
return false;
}
diff --git a/IdleSpectator/ActivityVerbMap.cs b/IdleSpectator/ActivityVerbMap.cs
index f7d2e10..f45d941 100644
--- a/IdleSpectator/ActivityVerbMap.cs
+++ b/IdleSpectator/ActivityVerbMap.cs
@@ -128,6 +128,39 @@ public static class ActivityVerbMap
return !string.IsNullOrEmpty(Resolve(key));
}
+ ///
+ /// High-signal verbs skip the ActivityLog same-verb cooldown (combat, danger, romance, etc.).
+ /// Spammy verbs like play/move/work still dedupe.
+ ///
+ public static bool IsSameVerbCooldownExempt(string verb)
+ {
+ if (string.IsNullOrEmpty(verb))
+ {
+ return false;
+ }
+
+ switch (verb)
+ {
+ case "fight":
+ case "hunt":
+ case "flee":
+ case "lover":
+ case "steal":
+ case "loot":
+ case "heal":
+ case "ignite":
+ case "extinguish":
+ case "fire":
+ case "harvest_life":
+ case "possessed":
+ case "confused":
+ case "strange_urge":
+ return true;
+ default:
+ return false;
+ }
+ }
+
public static List EnumerateLiveTaskIds()
{
return ActivityAssetCatalog.EnumerateLiveTaskIds();
diff --git a/IdleSpectator/AgentHarness.cs b/IdleSpectator/AgentHarness.cs
index a678391..b0617d9 100644
--- a/IdleSpectator/AgentHarness.cs
+++ b/IdleSpectator/AgentHarness.cs
@@ -2006,15 +2006,20 @@ public static class AgentHarness
}
case "dossier_task_refresh":
{
- // Stale focus snapshot must recover: blank chip, then sync from live AI.
+ // Stale focus snapshot must recover: blank dossier TaskText, then sync from live AI.
+ // Empty live AI is ok - chip may keep a sticky prior label (never cleared on gaps).
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;
+ if (string.IsNullOrEmpty(live))
+ {
+ pass = string.IsNullOrEmpty(after);
+ detail = $"live_empty dossier='{after}' sticky_chip='{chip}'";
+ break;
+ }
+
+ pass = after == live && !string.IsNullOrEmpty(chip);
detail = $"live='{live}' dossier='{after}' chip='{chip}'";
break;
}
@@ -2292,6 +2297,32 @@ public static class AgentHarness
$"added={added} want_added=1 suppressed={suppressed} want_suppressed>={keys.Length - 1} keys={keys.Length}";
break;
}
+ case "activity_verb_exempt":
+ {
+ // Combat / high-signal verbs skip same-verb 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_attack",
+ "punch_a_building",
+ "tantrum",
+ "task_unit_attack"
+ };
+ int added = ActivityLog.HarnessSpamSameVerb(focus, keys);
+ int suppressed = ActivityLog.VerbSuppressedSinceClear;
+ pass = added == keys.Length && suppressed == 0;
+ detail =
+ $"added={added} want_added={keys.Length} suppressed={suppressed} want_suppressed=0";
+ 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 1d0a726..6aef175 100644
--- a/IdleSpectator/HarnessScenarios.cs
+++ b/IdleSpectator/HarnessScenarios.cs
@@ -614,6 +614,7 @@ internal static class HarnessScenarios
Step("act13f", "assert", expect: "activity_taxonomy_audit"),
Step("act13g", "assert", expect: "activity_status_audit"),
Step("act13h", "assert", expect: "activity_verb_dedupe"),
+ Step("act13h2", "assert", expect: "activity_verb_exempt"),
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"),
diff --git a/IdleSpectator/WatchCaption.cs b/IdleSpectator/WatchCaption.cs
index 4bd7397..2785c6a 100644
--- a/IdleSpectator/WatchCaption.cs
+++ b/IdleSpectator/WatchCaption.cs
@@ -16,6 +16,8 @@ public static class WatchCaption
private const float SexSize = 14f;
private const float LiveMax = 44f;
private const float ChipIcon = 12f;
+ /// Fixed nametag task label slot so text swaps do not autofit-tick the header.
+ private const float TaskLabelW = 72f;
private const float TraitIcon = 12f;
private const float HistoryIcon = 10f;
private const float HistoryColMinW = 118f;
@@ -623,6 +625,7 @@ public static class WatchCaption
///
/// Keep the nametag task chip in sync with live AI (focus snapshot can miss brief no-task gaps).
+ /// Never clears the chip on empty AI gaps - only replaces text when a new task is present.
///
private static void RefreshLiveTask()
{
@@ -638,19 +641,30 @@ public static class WatchCaption
}
string live = UnitDossier.ReadLiveTask(actor);
+ // Hold the last shown label through brief no-task gaps so autofit does not collapse.
+ if (string.IsNullOrEmpty(live))
+ {
+ return;
+ }
+
string prev = _current.TaskText ?? "";
if (live == prev)
{
return;
}
+ bool wasShowing = _taskText != null && _taskText.gameObject.activeSelf;
_current.TaskText = live;
- ApplyTaskChip(live);
+ ReplaceTaskChip(live);
+
+ // First appearance needs layout; later swaps only change text inside a fixed-width slot.
+ if (!wasShowing)
+ {
+ bool hasBody = _current.UnitId != 0;
+ bool hasReason = _reasonText != null && _reasonText.gameObject.activeSelf;
+ Relayout(hasBody, CountActiveTraitSlots(), hasTask: true, hasReason, CountActiveHistorySlots());
+ }
- bool hasBody = _current.UnitId != 0;
- bool hasTask = !string.IsNullOrEmpty(live);
- bool hasReason = _reasonText != null && _reasonText.gameObject.activeSelf;
- Relayout(hasBody, CountActiveTraitSlots(), hasTask, hasReason, CountActiveHistorySlots());
BringHeaderFront();
}
@@ -678,29 +692,61 @@ public static class WatchCaption
return actor;
}
- private static void ApplyTaskChip(string taskText)
+ private static string TruncateTaskLabel(string taskText)
{
- bool hasTask = !string.IsNullOrEmpty(taskText);
+ if (string.IsNullOrEmpty(taskText))
+ {
+ return "";
+ }
+
+ return taskText.Length > 14 ? taskText.Substring(0, 13) + "..." : taskText;
+ }
+
+ /// Show or replace the task chip without ever blanking mid-update.
+ private static void ReplaceTaskChip(string taskText)
+ {
+ if (string.IsNullOrEmpty(taskText))
+ {
+ return;
+ }
+
if (_taskIcon != null)
{
- HudIcons.Apply(_taskIcon, hasTask ? HudIcons.Task() : null);
- _taskIcon.gameObject.SetActive(hasTask);
+ HudIcons.Apply(_taskIcon, HudIcons.Task());
+ _taskIcon.gameObject.SetActive(true);
}
if (_taskText != null)
{
- string task = hasTask ? taskText : "";
- if (task.Length > 14)
- {
- task = task.Substring(0, 13) + "...";
- }
-
- _taskText.text = task;
- _taskText.gameObject.SetActive(hasTask);
+ _taskText.text = TruncateTaskLabel(taskText);
+ _taskText.gameObject.SetActive(true);
}
}
- /// Harness: blank the nametag task, then sync from live AI.
+ private static void ApplyTaskChip(string taskText)
+ {
+ if (!string.IsNullOrEmpty(taskText))
+ {
+ ReplaceTaskChip(taskText);
+ return;
+ }
+
+ if (_taskIcon != null)
+ {
+ HudIcons.Apply(_taskIcon, null);
+ _taskIcon.gameObject.SetActive(false);
+ }
+
+ if (_taskText != null)
+ {
+ _taskText.text = "";
+ _taskText.gameObject.SetActive(false);
+ }
+ }
+
+ ///
+ /// Harness: simulate a stale empty snapshot, then sync from live AI (replace, never stay blank if live has a task).
+ ///
public static string HarnessBlankAndRefreshTask()
{
if (_current != null)
@@ -708,7 +754,7 @@ public static class WatchCaption
_current.TaskText = "";
}
- ApplyTaskChip("");
+ // Do not hide the chip here - RefreshLiveTask must replace in place when live task exists.
RefreshLiveTask();
return _current != null ? (_current.TaskText ?? "") : "";
}
@@ -1465,9 +1511,8 @@ public static class WatchCaption
_taskText.gameObject.SetActive(true);
PlaceLeftChip(_taskIcon.rectTransform, x, yFromTop + 3f, ChipIcon, ChipIcon);
x += ChipIcon + 1f;
- float taskW = Mathf.Clamp(MeasureTextWidth(_taskText, 36f), 20f, 72f);
- PlaceLeftChip(_taskText.rectTransform, x, yFromTop, taskW, HeaderH);
- x += taskW + 4f;
+ PlaceLeftChip(_taskText.rectTransform, x, yFromTop, TaskLabelW, HeaderH);
+ x += TaskLabelW + 4f;
}
else
{
@@ -1521,7 +1566,7 @@ public static class WatchCaption
if (hasTask && _taskIcon != null && _taskText != null)
{
x += ChipIcon + 1f;
- x += Mathf.Clamp(MeasureTextWidth(_taskText, 36f), 20f, 72f) + 4f;
+ x += TaskLabelW + 4f;
}
if (_sexIcon != null && _sexIcon.gameObject.activeSelf)
diff --git a/IdleSpectator/mod.json b/IdleSpectator/mod.json
index b274dd8..9ad5bc8 100644
--- a/IdleSpectator/mod.json
+++ b/IdleSpectator/mod.json
@@ -1,7 +1,7 @@
{
"name": "IdleSpectator",
"author": "dazed",
- "version": "0.12.100",
+ "version": "0.12.103",
"description": "AFK Idle Spectator (I) + Lore (L). Detailed living activity prose by default.",
"GUID": "com.dazed.idlespectator"
}