Implement activity history enhancements in IdleSpectator by adding methods for live-relevance peeking and subject aging. Update WatchCaption to utilize the new functionality for improved activity tracking. Increment version in mod.json to 0.25.12 to reflect these changes.

This commit is contained in:
DazedAnon 2026-07-16 15:49:23 -05:00
parent 04731c2c06
commit f953a66d77
7 changed files with 475 additions and 9 deletions

View file

@ -208,6 +208,74 @@ public static class ActivityLog
return result;
}
/// <summary>
/// Newest-first peek filtered by <see cref="ActivityRelevance"/> against live actor state.
/// Full ring is unchanged (Lore Activity / <see cref="LatestForSubject"/>).
/// </summary>
public static IReadOnlyList<ActivityEntry> LatestRelevantForSubject(
Actor actor,
long subjectId,
int max,
int scanMax = 0)
{
if (subjectId == 0 || max <= 0)
{
return System.Array.Empty<ActivityEntry>();
}
if (!BySubject.TryGetValue(subjectId, out List<ActivityEntry> list) || list.Count == 0)
{
return System.Array.Empty<ActivityEntry>();
}
int scan = scanMax > 0
? Mathf.Min(scanMax, list.Count)
: Mathf.Min(ActivityRelevance.PeekScanMax, list.Count);
float now = Time.unscaledTime;
var picked = new List<ActivityEntry>(max);
for (int i = 0; i < scan && picked.Count < max; i++)
{
ActivityEntry entry = list[list.Count - 1 - i];
if (ActivityRelevance.IsPeekRelevant(actor, entry, now))
{
picked.Add(entry);
}
}
// Never leave the peek empty when the ring has lines - show newest as last resort.
if (picked.Count == 0 && list.Count > 0)
{
picked.Add(list[list.Count - 1]);
}
return picked;
}
/// <summary>Harness: age all lines for a subject so soft-window relevance expires.</summary>
public static int HarnessAgeSubject(long subjectId, float secondsAgo)
{
if (subjectId == 0 || secondsAgo <= 0f
|| !BySubject.TryGetValue(subjectId, out List<ActivityEntry> list))
{
return 0;
}
float stamped = Time.unscaledTime - secondsAgo;
int n = 0;
for (int i = 0; i < list.Count; i++)
{
if (list[i] == null)
{
continue;
}
list[i].CreatedAt = stamped;
n++;
}
return n;
}
public static void NoteTask(Actor actor, string taskId)
{
if (actor == null || !actor.isAlive() || string.IsNullOrEmpty(taskId))

View file

@ -0,0 +1,309 @@
using System;
namespace IdleSpectator;
/// <summary>
/// Live-relevance policy for Activity History <b>peek</b> (dossier).
/// The ring stays append-only; Lore Activity still shows the full journal.
/// Peek only keeps lines that still match what the unit is doing (or just did).
/// </summary>
public static class ActivityRelevance
{
/// <summary>
/// Lines newer than this always pass (beat just happened; AI may not have settled).
/// </summary>
public const float SoftWindowSeconds = 8f;
/// <summary>How many newest ring lines to scan when filling a peek of size N.</summary>
public const int PeekScanMax = 24;
public enum Family
{
Ambient,
Combat,
Haul,
Social,
Romance,
Work,
Care,
Travel,
Milestone,
Status,
Happiness
}
public static Family FamilyForEntry(ActivityEntry entry)
{
if (entry == null)
{
return Family.Ambient;
}
switch (entry.Kind)
{
case ActivityKind.StatusGain:
case ActivityKind.StatusLoss:
return Family.Status;
case ActivityKind.Happiness:
return Family.Happiness;
default:
break;
}
string key = (entry.TaskId ?? "").Trim();
if (key.StartsWith("milestone_", StringComparison.OrdinalIgnoreCase))
{
return Family.Milestone;
}
return FamilyForVerb(ActivityVerbMap.Resolve(key));
}
public static Family FamilyForVerb(string verb)
{
if (string.IsNullOrEmpty(verb))
{
return Family.Ambient;
}
switch (verb)
{
case "hunt":
case "fight":
case "flee":
case "fire":
case "ignite":
case "extinguish":
case "warrior":
case "loot":
case "steal":
return Family.Combat;
case "haul":
return Family.Haul;
case "social":
case "group":
return Family.Social;
case "lover":
return Family.Romance;
case "farm":
case "work":
case "pollinate":
case "fish":
case "trade":
case "build":
case "read":
case "heal":
return Family.Work;
case "eat":
case "sleep":
case "play":
case "relieve":
case "laugh":
case "sing":
case "cry":
case "swear":
case "wait":
case "recharge":
case "dream":
return Family.Care;
case "move":
case "settle":
case "boat":
return Family.Travel;
default:
return Family.Ambient;
}
}
/// <summary>
/// Whether this ring line should appear in the dossier History peek for <paramref name="actor"/>.
/// </summary>
public static bool IsPeekRelevant(Actor actor, ActivityEntry entry, float now)
{
if (entry == null)
{
return false;
}
if (now - entry.CreatedAt <= SoftWindowSeconds)
{
return true;
}
Family family = FamilyForEntry(entry);
if (family == Family.Milestone)
{
// Life beats stay peek-worthy; Chronicle also covers them.
return true;
}
if (actor == null || !actor.isAlive())
{
return false;
}
if (family == Family.Status)
{
return StatusStillMatches(actor, entry);
}
if (family == Family.Happiness)
{
// Discrete mood beats age out of peek after the soft window.
return false;
}
string entryVerb = ActivityVerbMap.Resolve(entry.TaskId ?? "");
string liveTaskId = ActivityInterestTable.SafeTaskId(actor);
string liveVerb = ActivityVerbMap.Resolve(liveTaskId);
if (!string.IsNullOrEmpty(entryVerb)
&& !string.IsNullOrEmpty(liveVerb)
&& (string.Equals(entryVerb, liveVerb, StringComparison.OrdinalIgnoreCase)
|| FamilyForVerb(entryVerb) == FamilyForVerb(liveVerb)))
{
return true;
}
return FamilyLivePredicate(actor, family);
}
/// <summary>
/// Fingerprint of live AI bits that can change peek without adding ring lines.
/// </summary>
public static int LiveFingerprint(Actor actor, float now)
{
int h = 17;
h = h * 31 + (int)(now / SoftWindowSeconds);
if (actor == null)
{
return h;
}
try
{
h = h * 31 + (actor.has_attack_target ? 1 : 0);
h = h * 31 + (actor.isCarryingResources() ? 1 : 0);
string task = ActivityInterestTable.SafeTaskId(actor) ?? "";
h = h * 31 + (task?.GetHashCode() ?? 0);
if (actor.hasTask() && actor.ai?.task != null)
{
h = h * 31 + (actor.ai.task.in_combat ? 1 : 0);
h = h * 31 + (actor.ai.task.is_fireman ? 1 : 0);
}
}
catch
{
// ignore
}
return h;
}
private static bool FamilyLivePredicate(Actor actor, Family family)
{
switch (family)
{
case Family.Combat:
return CombatLive(actor);
case Family.Haul:
return HaulLive(actor);
case Family.Social:
case Family.Romance:
case Family.Work:
case Family.Care:
case Family.Travel:
case Family.Ambient:
default:
return false;
}
}
private static bool CombatLive(Actor actor)
{
try
{
if (actor.has_attack_target)
{
return true;
}
if (actor.hasTask() && actor.ai?.task != null
&& (actor.ai.task.in_combat || actor.ai.task.is_fireman))
{
return true;
}
}
catch
{
// ignore
}
return false;
}
private static bool HaulLive(Actor actor)
{
try
{
return actor.isCarryingResources();
}
catch
{
return false;
}
}
private static bool StatusStillMatches(Actor actor, ActivityEntry entry)
{
string statusId = StatusIdFromEntry(entry);
if (string.IsNullOrEmpty(statusId))
{
return false;
}
try
{
bool has = actor.hasStatus(statusId);
if (entry.Kind == ActivityKind.StatusGain)
{
return has;
}
if (entry.Kind == ActivityKind.StatusLoss)
{
return !has;
}
}
catch
{
// ignore
}
return false;
}
private static string StatusIdFromEntry(ActivityEntry entry)
{
string key = (entry?.TaskId ?? "").Trim();
const string gain = "status_gain:";
const string loss = "status_loss:";
if (key.StartsWith(gain, StringComparison.OrdinalIgnoreCase))
{
return key.Substring(gain.Length).Trim();
}
if (key.StartsWith(loss, StringComparison.OrdinalIgnoreCase))
{
return key.Substring(loss.Length).Trim();
}
// NoteStatusChange may store bare status id or ActivityStatusProse.TaskKey.
if (key.IndexOf(':') < 0 && !string.IsNullOrEmpty(key))
{
return key;
}
return "";
}
}

View file

@ -540,6 +540,40 @@ public static class AgentHarness
break;
}
case "activity_age":
{
long id = WatchCaption.CurrentUnitId;
if (id == 0 && MoveCamera.hasFocusUnit() && MoveCamera._focus_unit != null)
{
try
{
id = MoveCamera._focus_unit.getID();
}
catch
{
id = 0;
}
}
float seconds = 12f;
if (!string.IsNullOrEmpty(cmd.value)
&& float.TryParse(
cmd.value,
System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture,
out float parsed)
&& parsed > 0f)
{
seconds = parsed;
}
int n = ActivityLog.HarnessAgeSubject(id, seconds);
WatchCaption.ForceRefreshHistory();
_cmdOk++;
Emit(cmd, ok: true, detail: $"aged={n} subject={id} seconds={seconds}");
break;
}
case "activity_sample_reset":
{
ActivityLog.ResetSampleCounters();

View file

@ -1393,6 +1393,22 @@ internal static class HarnessScenarios
Step("act16r", "dossier_clear_job"),
Step("act17", "activity_force", asset: "BehPollinate", label: "Works the flower", count: 3, tier: "beat"),
Step("act17b", "activity_force", asset: "zzz_harness_act", label: "Harness pollen trail", count: 1),
// Live-relevance peek: after soft window, aged hunt drops; newest unload remains.
// Isolated ring so older move-matching lines cannot crowd the peek.
Step("act17c", "activity_clear"),
Step("act17d", "activity_force", asset: "BehAttackActorHuntingTarget",
label: "Hunts prey", count: 1, tier: "beat", expect: "PreyThing"),
Step("act17e", "activity_force", asset: "BehUnloadResources", label: "wheat@Riverhold",
count: 1, tier: "beat", value: "human"),
Step("act17f", "activity_age", value: "12"),
Step("act17g", "assert", expect: "dossier_history_not_contains", value: "Hunts"),
Step("act17h", "assert", expect: "dossier_history_contains", value: "wheat"),
Step("act17i", "assert", expect: "activity_log_contains", value: "Hunts"),
// Rebuild a busy ring for later variety / count asserts.
Step("act17j", "activity_force", asset: "BehPollinate", label: "Works the flower", count: 2, tier: "beat"),
Step("act17k", "activity_force", asset: "zzz_harness_act", label: "Harness pollen trail", count: 1),
Step("act17l", "activity_force", asset: "BehSocializeTalk", label: "Talks", count: 1, tier: "beat",
expect: "Barkley"),
Step("act18", "wait", wait: 0.25f),
Step("act19", "assert", expect: "activity_count", value: "4", label: "min"),
Step("act20", "assert", expect: "activity_prose_variety", value: "2"),

View file

@ -77,6 +77,7 @@ public static class WatchCaption
private static float _historyColW = HistoryColMinW;
private static int _lastHistoryCount = -1;
private static long _lastHistorySubjectId;
private static int _lastLiveHistFingerprint = int.MinValue;
private const float LifeSepH = 3f;
private static Button _favoriteBtn;
@ -896,6 +897,7 @@ public static class WatchCaption
public static void ForceRefreshHistory()
{
_lastHistoryCount = -1;
_lastLiveHistFingerprint = int.MinValue;
if (!_visible || _current == null)
{
return;
@ -935,7 +937,11 @@ public static class WatchCaption
long id = _current.UnitId;
int count = ActivityLog.CountFor(id) + Chronicle.HistoryCountFor(id);
if (id == _lastHistorySubjectId && count == _lastHistoryCount)
Actor live = ResolveBoundLiveActor();
int fingerprint = ActivityRelevance.LiveFingerprint(live, Time.unscaledTime);
if (id == _lastHistorySubjectId
&& count == _lastHistoryCount
&& fingerprint == _lastLiveHistFingerprint)
{
return;
}
@ -1146,8 +1152,30 @@ public static class WatchCaption
// does not vanish under a busy activity ring (and chronicle smoke stays valid).
int chronicleCount = Chronicle.HistoryCountFor(unitId);
int actCap = chronicleCount > 0 ? HistoryPeekMax - 1 : HistoryPeekMax;
IReadOnlyList<ActivityEntry> activity =
ActivityLog.LatestForSubject(unitId, actCap);
Actor live = null;
try
{
live = ResolveBoundLiveActor();
if (live != null && live.isAlive() && live.getID() != unitId)
{
live = null;
}
if (live == null && MoveCamera.hasFocusUnit() && MoveCamera._focus_unit != null
&& MoveCamera._focus_unit.isAlive()
&& MoveCamera._focus_unit.getID() == unitId)
{
live = MoveCamera._focus_unit;
}
}
catch
{
live = null;
}
IReadOnlyList<ActivityEntry> activity = live != null
? ActivityLog.LatestRelevantForSubject(live, unitId, actCap)
: ActivityLog.LatestForSubject(unitId, actCap);
int lifeNeed = Mathf.Max(0, HistoryPeekMax - (activity?.Count ?? 0));
IReadOnlyList<ChronicleEntry> life =
@ -1157,6 +1185,7 @@ public static class WatchCaption
_lastHistorySubjectId = unitId;
_lastHistoryCount = ActivityLog.CountFor(unitId) + chronicleCount;
_lastLiveHistFingerprint = ActivityRelevance.LiveFingerprint(live, Time.unscaledTime);
LastHistoryPreview = "";
LastHistoryJoined = "";
@ -1181,11 +1210,11 @@ public static class WatchCaption
string taskFallback = "";
try
{
Actor live = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
if (live != null && live.isAlive() && live.getID() == unitId && live.hasTask()
&& live.ai?.task != null)
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
if (focus != null && focus.isAlive() && focus.getID() == unitId && focus.hasTask()
&& focus.ai?.task != null)
{
taskFallback = live.ai.task.getLocalizedText() ?? "";
taskFallback = focus.ai.task.getLocalizedText() ?? "";
}
}
catch

View file

@ -1,7 +1,7 @@
{
"name": "IdleSpectator",
"author": "dazed",
"version": "0.25.11",
"description": "AFK Idle Spectator (I) + Lore (L). Event trigger audit Phases 4-8 + job reason row.",
"version": "0.25.12",
"description": "AFK Idle Spectator (I) + Lore (L). Activity History peek live-relevance.",
"GUID": "com.dazed.idlespectator"
}

View file

@ -16,6 +16,16 @@ Character fill runs only when the pending **EventLed** queue is empty, and uses
Orange dossier reason = the owning A events `Label` while the scene is active (`InterestDirector.TryGetOwnedReasonCandidate`).
Quiet grace / inactive dwell clears the reason even if focus has not switched yet.
## Activity History peek freshness
Layer B ring stays append-only (Lore Activity = full journal).
Dossier History peek uses `ActivityRelevance` / `ActivityLog.LatestRelevantForSubject`:
- Soft window (~8s): newest beats always show.
- After that: same verb/family as live task, or family live predicates (combat ↔ attack target / in_combat, haul ↔ carrying, status ↔ still has/lost status).
- Milestones always peek-worthy.
- Peek refreshes when live AI fingerprint changes (task, attack target, carrying), not only when the ring grows.
Drops (ambient, createsInterest=false, identity filter, below margin, …) go to `InterestDropLog`.
## EventReason