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:
parent
04731c2c06
commit
f953a66d77
7 changed files with 475 additions and 9 deletions
|
|
@ -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))
|
||||
|
|
|
|||
309
IdleSpectator/ActivityRelevance.cs
Normal file
309
IdleSpectator/ActivityRelevance.cs
Normal 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 "";
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,16 @@ Character fill runs only when the pending **EventLed** queue is empty, and uses
|
|||
Orange dossier reason = the owning A event’s `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
|
||||
|
|
|
|||
Loading…
Reference in a new issue