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.
This commit is contained in:
parent
d5803bbd59
commit
8ab8a9880a
6 changed files with 264 additions and 25 deletions
|
|
@ -35,12 +35,20 @@ public static class ActivityLog
|
|||
public const int MaxLinesPerSubject = 32;
|
||||
public const int MaxSubjects = 4000;
|
||||
private const float ActionBeatCooldown = 2.5f;
|
||||
/// <summary>
|
||||
/// Suppress repeat Task/Beat lines that resolve to the same verb (e.g. play/child_* churn).
|
||||
/// Status and milestones are exempt.
|
||||
/// </summary>
|
||||
private const float SameVerbCooldown = 10f;
|
||||
|
||||
private static readonly Dictionary<long, List<ActivityEntry>> BySubject =
|
||||
new Dictionary<long, List<ActivityEntry>>();
|
||||
private static readonly Dictionary<long, string> LastTaskId = new Dictionary<long, string>();
|
||||
private static readonly Dictionary<long, float> LastBeatAt = new Dictionary<long, float>();
|
||||
private static readonly Dictionary<long, string> LastLoggedVerb = new Dictionary<long, string>();
|
||||
private static readonly Dictionary<long, float> LastLoggedVerbAt = new Dictionary<long, float>();
|
||||
private static readonly Dictionary<long, int> LineIndex = new Dictionary<long, int>();
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Harness: attempt several same-verb task keys on a live actor; returns how many lines were added.
|
||||
/// </summary>
|
||||
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<ActivityEntry> 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--;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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".
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -671,11 +671,17 @@ public sealed class UnitDossier
|
|||
return Humanize(trait.id);
|
||||
}
|
||||
|
||||
/// <summary>Live AI task label for the nametag chip (localized, else humanized id).</summary>
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Keep the nametag task chip in sync with live AI (focus snapshot can miss brief no-task gaps).
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Harness: blank the nametag task, then sync from live AI.</summary>
|
||||
public static string HarnessBlankAndRefreshTask()
|
||||
{
|
||||
if (_current != null)
|
||||
{
|
||||
_current.TaskText = "";
|
||||
}
|
||||
|
||||
ApplyTaskChip("");
|
||||
RefreshLiveTask();
|
||||
return _current != null ? (_current.TaskText ?? "") : "";
|
||||
}
|
||||
|
||||
/// <summary>Harness: text currently shown on the nametag task chip.</summary>
|
||||
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 = "";
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue