diff --git a/IdleSpectator/ActivityInterestTable.cs b/IdleSpectator/ActivityInterestTable.cs
new file mode 100644
index 0000000..d0c1a0e
--- /dev/null
+++ b/IdleSpectator/ActivityInterestTable.cs
@@ -0,0 +1,276 @@
+using UnityEngine;
+
+namespace IdleSpectator;
+
+/// How interesting a unit's current AI task/job is for idle scoring.
+public enum ActivityBand
+{
+ Cold = 0,
+ Warm = 1,
+ Hot = 2
+}
+
+///
+/// Maps WorldBox task / citizen-job ids (and combat flags) to Hot/Warm/Cold interest.
+/// Unknown ids default to Warm if they look like work, else Cold.
+///
+public static class ActivityInterestTable
+{
+ public const float WarmScoreFloor = 18f;
+ public const float HotScoreFloor = 40f;
+
+ /// Activity score 0–100 used by idle scoring.
+ public static float ScoreActivity(Actor actor)
+ {
+ if (actor == null || !actor.isAlive())
+ {
+ return 0f;
+ }
+
+ ActivityBand band = Classify(actor);
+ float score = band switch
+ {
+ ActivityBand.Hot => 55f,
+ ActivityBand.Warm => 28f,
+ _ => 4f
+ };
+
+ if (actor.has_attack_target)
+ {
+ score = Mathf.Max(score, 70f);
+ }
+
+ try
+ {
+ if (actor.hasTask() && actor.ai?.task != null && actor.ai.task.in_combat)
+ {
+ score = Mathf.Max(score, 72f);
+ }
+
+ if (actor.hasTask() && actor.ai?.task != null && actor.ai.task.is_fireman)
+ {
+ score = Mathf.Max(score, 60f);
+ }
+ }
+ catch
+ {
+ // ignore
+ }
+
+ return Mathf.Clamp(score, 0f, 100f);
+ }
+
+ public static ActivityBand Classify(Actor actor)
+ {
+ if (actor == null || !actor.isAlive())
+ {
+ return ActivityBand.Cold;
+ }
+
+ if (actor.has_attack_target)
+ {
+ return ActivityBand.Hot;
+ }
+
+ string taskId = SafeTaskId(actor);
+ string jobId = SafeJobId(actor);
+
+ ActivityBand fromTask = ClassifyId(taskId);
+ ActivityBand fromJob = ClassifyId(jobId);
+ ActivityBand best = fromTask > fromJob ? fromTask : fromJob;
+
+ try
+ {
+ if (actor.hasTask() && actor.ai?.task != null)
+ {
+ if (actor.ai.task.in_combat || actor.ai.task.is_fireman)
+ {
+ return ActivityBand.Hot;
+ }
+ }
+ }
+ catch
+ {
+ // ignore
+ }
+
+ return best;
+ }
+
+ public static ActivityBand ClassifyId(string id)
+ {
+ if (string.IsNullOrEmpty(id))
+ {
+ return ActivityBand.Cold;
+ }
+
+ string s = id.ToLowerInvariant();
+
+ if (ContainsAny(s,
+ "fight", "attack", "hunt", "fireman", "fire", "war", "kill", "danger",
+ "unload", "throw_resource", "capture", "siege"))
+ {
+ return ActivityBand.Hot;
+ }
+
+ if (ContainsAny(s,
+ "farm", "harvest", "plant", "fertiliz", "mine", "gather", "woodcut", "chop",
+ "build", "construct", "road", "smith", "blacksmith", "pollinat", "bee",
+ "honey", "herb", "bush", "social", "lover", "breed", "fish", "trade",
+ "clean", "fertiliz", "bucket", "hoe"))
+ {
+ return ActivityBand.Warm;
+ }
+
+ if (ContainsAny(s,
+ "eat", "sleep", "wait", "idle", "random_move", "random_swim", "make_decision",
+ "run_away", "swim_to", "print_", "godfinger", "end_job"))
+ {
+ return ActivityBand.Cold;
+ }
+
+ // Unknown active task: mild warm so we do not ignore novel work.
+ if (s.Length > 0)
+ {
+ return ActivityBand.Warm;
+ }
+
+ return ActivityBand.Cold;
+ }
+
+ public static string SafeTaskId(Actor actor)
+ {
+ try
+ {
+ if (actor != null && actor.hasTask() && actor.ai?.task != null)
+ {
+ return actor.ai.task.id ?? "";
+ }
+ }
+ catch
+ {
+ // ignore
+ }
+
+ return "";
+ }
+
+ public static string SafeJobId(Actor actor)
+ {
+ try
+ {
+ if (actor?.citizen_job != null)
+ {
+ return actor.citizen_job.id ?? "";
+ }
+ }
+ catch
+ {
+ // ignore
+ }
+
+ return "";
+ }
+
+ public static string DumpFocusProbe(Actor actor)
+ {
+ if (actor == null)
+ {
+ return "actor=null";
+ }
+
+ string name = "?";
+ try
+ {
+ name = actor.getName() ?? "?";
+ }
+ catch
+ {
+ // ignore
+ }
+
+ string taskId = SafeTaskId(actor);
+ string jobId = SafeJobId(actor);
+ string taskLoc = "";
+ try
+ {
+ if (actor.hasTask() && actor.ai?.task != null)
+ {
+ taskLoc = actor.ai.task.getLocalizedText() ?? "";
+ }
+ }
+ catch
+ {
+ // ignore
+ }
+
+ string actorTarget = TargetLabel(actor);
+ ActivityBand band = Classify(actor);
+ return
+ $"name={name} task='{taskId}' job='{jobId}' loc='{taskLoc}' target='{actorTarget}' "
+ + $"band={band} activity={ScoreActivity(actor):0.0} attack={actor.has_attack_target}";
+ }
+
+ public static string TargetLabel(Actor actor)
+ {
+ if (actor == null)
+ {
+ return "";
+ }
+
+ try
+ {
+ if (actor.beh_actor_target != null && actor.beh_actor_target.isAlive())
+ {
+ if (actor.beh_actor_target.isActor())
+ {
+ Actor other = actor.beh_actor_target.a;
+ if (other != null)
+ {
+ string n = other.getName();
+ return string.IsNullOrEmpty(n) ? "foe" : n;
+ }
+ }
+
+ return "target";
+ }
+
+ if (actor.beh_building_target != null && actor.beh_building_target.isAlive())
+ {
+ Building b = actor.beh_building_target;
+ string type = b.asset != null ? b.asset.type : "";
+ string id = b.asset != null ? b.asset.id : "";
+ if (!string.IsNullOrEmpty(type))
+ {
+ return type.Replace("type_", "");
+ }
+
+ return string.IsNullOrEmpty(id) ? "building" : id;
+ }
+
+ if (actor.beh_tile_target != null)
+ {
+ return "tile";
+ }
+ }
+ catch
+ {
+ // ignore
+ }
+
+ return "";
+ }
+
+ private static bool ContainsAny(string hay, params string[] needles)
+ {
+ for (int i = 0; i < needles.Length; i++)
+ {
+ if (hay.IndexOf(needles[i], System.StringComparison.Ordinal) >= 0)
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/IdleSpectator/ActivityLog.cs b/IdleSpectator/ActivityLog.cs
new file mode 100644
index 0000000..b74d950
--- /dev/null
+++ b/IdleSpectator/ActivityLog.cs
@@ -0,0 +1,295 @@
+using System.Collections.Generic;
+using UnityEngine;
+
+namespace IdleSpectator;
+
+public enum ActivityKind
+{
+ TaskStart,
+ ActionBeat
+}
+
+public sealed class ActivityEntry
+{
+ public float CreatedAt;
+ public long SubjectId;
+ public ActivityKind Kind;
+ public string TaskId = "";
+ public string JobId = "";
+ public string TargetLabel = "";
+ /// Stable fact line for asserts.
+ public string Line = "";
+ /// Varied display prose for HUD.
+ public string DisplayLine = "";
+}
+
+///
+/// Ephemeral per-character activity ring (tasks / action beats). Not Chronicle History.
+///
+public static class ActivityLog
+{
+ public const int MaxLinesPerSubject = 32;
+ public const int MaxSubjects = 4000;
+ private const float ActionBeatCooldown = 2.5f;
+
+ 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 LineIndex = new Dictionary();
+
+ public static int SubjectCount => BySubject.Count;
+
+ public static void ClearSession()
+ {
+ BySubject.Clear();
+ LastTaskId.Clear();
+ LastBeatAt.Clear();
+ LineIndex.Clear();
+ }
+
+ public static int CountFor(long subjectId)
+ {
+ if (subjectId == 0)
+ {
+ return 0;
+ }
+
+ return BySubject.TryGetValue(subjectId, out List list) ? list.Count : 0;
+ }
+
+ public static IReadOnlyList LatestForSubject(long subjectId, int max)
+ {
+ if (subjectId == 0 || max <= 0)
+ {
+ return System.Array.Empty();
+ }
+
+ if (!BySubject.TryGetValue(subjectId, out List list) || list.Count == 0)
+ {
+ return System.Array.Empty();
+ }
+
+ int take = Mathf.Min(max, list.Count);
+ var result = new ActivityEntry[take];
+ // Newest first (matches Chronicle.LatestForSubject peek order).
+ for (int i = 0; i < take; i++)
+ {
+ result[i] = list[list.Count - 1 - i];
+ }
+
+ return result;
+ }
+
+ public static void NoteTask(Actor actor, string taskId)
+ {
+ if (actor == null || !actor.isAlive() || string.IsNullOrEmpty(taskId))
+ {
+ return;
+ }
+
+ long id;
+ try
+ {
+ id = actor.getID();
+ }
+ catch
+ {
+ return;
+ }
+
+ if (id == 0)
+ {
+ return;
+ }
+
+ if (LastTaskId.TryGetValue(id, out string prev) && prev == taskId)
+ {
+ return;
+ }
+
+ LastTaskId[id] = taskId;
+ string jobId = ActivityInterestTable.SafeJobId(actor);
+ string target = ActivityInterestTable.TargetLabel(actor);
+ string loc = "";
+ try
+ {
+ if (actor.hasTask() && actor.ai?.task != null)
+ {
+ loc = actor.ai.task.getLocalizedText() ?? "";
+ }
+ }
+ catch
+ {
+ // ignore
+ }
+
+ string raw = string.IsNullOrEmpty(loc) ? ("Task: " + taskId) : loc;
+ if (!string.IsNullOrEmpty(target))
+ {
+ raw = raw + " → " + target;
+ }
+
+ int idx = NextIndex(id);
+ string display = ActivityProse.Format(
+ ActivityKind.TaskStart, taskId, raw, target, id, idx);
+ Append(id, new ActivityEntry
+ {
+ CreatedAt = Time.unscaledTime,
+ SubjectId = id,
+ Kind = ActivityKind.TaskStart,
+ TaskId = taskId,
+ JobId = jobId,
+ TargetLabel = target,
+ Line = raw,
+ DisplayLine = display
+ });
+ }
+
+ public static void NoteActionBeat(Actor actor, string actionKey, string rawFact, string target)
+ {
+ if (actor == null || !actor.isAlive() || string.IsNullOrEmpty(actionKey))
+ {
+ return;
+ }
+
+ long id;
+ try
+ {
+ id = actor.getID();
+ }
+ catch
+ {
+ return;
+ }
+
+ if (id == 0)
+ {
+ return;
+ }
+
+ float now = Time.unscaledTime;
+ if (LastBeatAt.TryGetValue(id, out float last) && now - last < ActionBeatCooldown)
+ {
+ return;
+ }
+
+ LastBeatAt[id] = now;
+ string taskId = ActivityInterestTable.SafeTaskId(actor);
+ string jobId = ActivityInterestTable.SafeJobId(actor);
+ string fact = string.IsNullOrEmpty(rawFact) ? actionKey : rawFact;
+ int idx = NextIndex(id);
+ string display = ActivityProse.Format(
+ ActivityKind.ActionBeat, actionKey, fact, target ?? "", id, idx);
+ Append(id, new ActivityEntry
+ {
+ CreatedAt = now,
+ SubjectId = id,
+ Kind = ActivityKind.ActionBeat,
+ TaskId = string.IsNullOrEmpty(taskId) ? actionKey : taskId,
+ JobId = jobId,
+ TargetLabel = target ?? "",
+ Line = fact,
+ DisplayLine = display
+ });
+ }
+
+ /// Harness: inject activity without a live AI transition.
+ public static bool ForceNote(long subjectId, string taskOrAction, string rawLine, bool asBeat = false)
+ {
+ if (subjectId == 0 || string.IsNullOrEmpty(taskOrAction))
+ {
+ return false;
+ }
+
+ int idx = NextIndex(subjectId);
+ string fact = string.IsNullOrEmpty(rawLine) ? taskOrAction : rawLine;
+ ActivityKind kind = asBeat ? ActivityKind.ActionBeat : ActivityKind.TaskStart;
+ string display = ActivityProse.Format(kind, taskOrAction, fact, "", subjectId, idx);
+ Append(subjectId, new ActivityEntry
+ {
+ CreatedAt = Time.unscaledTime,
+ SubjectId = subjectId,
+ Kind = kind,
+ TaskId = taskOrAction,
+ Line = fact,
+ DisplayLine = display
+ });
+ return true;
+ }
+
+ private static int NextIndex(long id)
+ {
+ LineIndex.TryGetValue(id, out int idx);
+ idx++;
+ LineIndex[id] = idx;
+ return idx;
+ }
+
+ private static void Append(long id, ActivityEntry entry)
+ {
+ if (!BySubject.TryGetValue(id, out List list))
+ {
+ list = new List();
+ BySubject[id] = list;
+ PruneSubjectsIfNeeded();
+ }
+
+ list.Add(entry);
+ while (list.Count > MaxLinesPerSubject)
+ {
+ list.RemoveAt(0);
+ }
+ }
+
+ private static void PruneSubjectsIfNeeded()
+ {
+ if (BySubject.Count <= MaxSubjects)
+ {
+ return;
+ }
+
+ long focusId = 0;
+ try
+ {
+ if (MoveCamera.hasFocusUnit() && MoveCamera._focus_unit != null)
+ {
+ focusId = MoveCamera._focus_unit.getID();
+ }
+ }
+ catch
+ {
+ focusId = 0;
+ }
+
+ var ranked = new List<(long id, float lastAt)>(BySubject.Count);
+ foreach (KeyValuePair> kv in BySubject)
+ {
+ if (kv.Key == focusId)
+ {
+ continue;
+ }
+
+ float last = 0f;
+ List list = kv.Value;
+ if (list != null && list.Count > 0 && list[list.Count - 1] != null)
+ {
+ last = list[list.Count - 1].CreatedAt;
+ }
+
+ ranked.Add((kv.Key, last));
+ }
+
+ ranked.Sort((a, b) => a.lastAt.CompareTo(b.lastAt));
+ int need = BySubject.Count - MaxSubjects;
+ for (int i = 0; i < ranked.Count && need > 0; i++)
+ {
+ long id = ranked[i].id;
+ BySubject.Remove(id);
+ LastTaskId.Remove(id);
+ LastBeatAt.Remove(id);
+ LineIndex.Remove(id);
+ need--;
+ }
+ }
+}
diff --git a/IdleSpectator/ActivityProse.cs b/IdleSpectator/ActivityProse.cs
new file mode 100644
index 0000000..0eda38a
--- /dev/null
+++ b/IdleSpectator/ActivityProse.cs
@@ -0,0 +1,266 @@
+using System.Collections.Generic;
+
+namespace IdleSpectator;
+
+///
+/// Fact-preserving multi-variant activity lines (anti-stale dossier feed).
+///
+public static class ActivityProse
+{
+ private static readonly Dictionary Variants = new Dictionary
+ {
+ ["fighting"] = new[]
+ {
+ "Locked in combat",
+ "Trading blows",
+ "In the fray",
+ "Clash of steel"
+ },
+ ["fight"] = new[]
+ {
+ "Locked in combat",
+ "Trading blows",
+ "In the fray"
+ },
+ ["attack"] = new[]
+ {
+ "Strikes at {target}",
+ "Presses the attack on {target}",
+ "Lunges toward {target}"
+ },
+ ["hunt"] = new[]
+ {
+ "Hunts {target}",
+ "Stalks {target}",
+ "On the trail of {target}"
+ },
+ ["eating"] = new[]
+ {
+ "Taking a meal",
+ "Eating",
+ "Breaking hunger"
+ },
+ ["eat"] = new[]
+ {
+ "Taking a meal",
+ "Eating",
+ "Breaking hunger"
+ },
+ ["sleep"] = new[]
+ {
+ "Sleeping",
+ "Resting",
+ "Deep in slumber"
+ },
+ ["random_move"] = new[]
+ {
+ "Wandering",
+ "Roaming aimlessly",
+ "Drifting along"
+ },
+ ["pollinate"] = new[]
+ {
+ "Dusts the blossom",
+ "Works the flower",
+ "Leaves pollen behind",
+ "Humming over petals"
+ },
+ ["farm"] = new[]
+ {
+ "Tends the fields",
+ "Works the soil",
+ "Farming the plot"
+ },
+ ["harvest"] = new[]
+ {
+ "Harvests the crop",
+ "Gathers the yield",
+ "Cuts the ripe fields"
+ },
+ ["mine"] = new[]
+ {
+ "Delves the mine",
+ "Chipping ore",
+ "Deep in the dig"
+ },
+ ["build"] = new[]
+ {
+ "Building",
+ "Raising walls",
+ "At the worksite"
+ },
+ ["gather"] = new[]
+ {
+ "Gathering stores",
+ "Foraging supplies",
+ "Collecting goods"
+ },
+ ["woodcut"] = new[]
+ {
+ "Felling timber",
+ "Cutting wood",
+ "Axe to the trees"
+ },
+ ["fire"] = new[]
+ {
+ "Fighting the blaze",
+ "Dousing flames",
+ "Racing the fire"
+ },
+ ["fireman"] = new[]
+ {
+ "Fighting the blaze",
+ "Dousing flames",
+ "Racing the fire"
+ },
+ ["social"] = new[]
+ {
+ "Socializing",
+ "In conversation",
+ "Among companions"
+ },
+ ["lover"] = new[]
+ {
+ "Seeking a lover",
+ "Drawn to {target}",
+ "Heart on the path"
+ },
+ ["unload"] = new[]
+ {
+ "Unloads goods in town",
+ "Delivers the haul",
+ "Empties the pack at home"
+ },
+ ["store"] = new[]
+ {
+ "Stores supplies",
+ "Stocking the village",
+ "Puts goods away"
+ },
+ ["fish"] = new[]
+ {
+ "Fishing",
+ "Casting for fish",
+ "Working the waters"
+ },
+ ["trade"] = new[]
+ {
+ "Trading",
+ "Bartering goods",
+ "On a trade run"
+ },
+ ["run_away"] = new[]
+ {
+ "Fleeing",
+ "Running for safety",
+ "In retreat"
+ },
+ ["make_decision"] = new[]
+ {
+ "Considering next steps",
+ "Weighing a choice",
+ "Paused in thought"
+ },
+ ["wait"] = new[]
+ {
+ "Waiting",
+ "Holding still",
+ "Biding time"
+ },
+ ["BehPollinate"] = new[]
+ {
+ "Dusts the blossom",
+ "Works the flower",
+ "Leaves pollen behind"
+ },
+ ["BehUnloadResources"] = new[]
+ {
+ "Unloads goods in town",
+ "Delivers the haul",
+ "Empties the pack at home"
+ },
+ ["BehAttackActorHuntingTarget"] = new[]
+ {
+ "Hunts {target}",
+ "Strikes prey",
+ "Closes on the quarry"
+ },
+ ["BehCityActorRemoveFire"] = new[]
+ {
+ "Dousing flames",
+ "Fighting the blaze",
+ "Beats back the fire"
+ }
+ };
+
+ public static string Format(
+ ActivityKind kind,
+ string key,
+ string rawFact,
+ string target,
+ long subjectId,
+ int lineIndex)
+ {
+ if (string.IsNullOrEmpty(rawFact) && string.IsNullOrEmpty(key))
+ {
+ return "";
+ }
+
+ string template = PickTemplate(key, subjectId, lineIndex);
+ if (string.IsNullOrEmpty(template))
+ {
+ return rawFact ?? key ?? "";
+ }
+
+ string t = string.IsNullOrEmpty(target) ? "their mark" : target;
+ return template.Replace("{target}", t);
+ }
+
+ private static string PickTemplate(string key, long subjectId, int lineIndex)
+ {
+ if (string.IsNullOrEmpty(key))
+ {
+ return null;
+ }
+
+ string[] bag = null;
+ if (Variants.TryGetValue(key, out bag) && bag != null && bag.Length > 0)
+ {
+ return bag[PickIndex(subjectId, key, lineIndex, bag.Length)];
+ }
+
+ string lower = key.ToLowerInvariant();
+ foreach (KeyValuePair kv in Variants)
+ {
+ if (lower.IndexOf(kv.Key, System.StringComparison.Ordinal) >= 0
+ && kv.Value != null
+ && kv.Value.Length > 0)
+ {
+ return kv.Value[PickIndex(subjectId, kv.Key, lineIndex, kv.Value.Length)];
+ }
+ }
+
+ return null;
+ }
+
+ private static int PickIndex(long subjectId, string key, int lineIndex, int count)
+ {
+ if (count <= 1)
+ {
+ return 0;
+ }
+
+ unchecked
+ {
+ int h = (int)(subjectId * 397) ^ (key?.GetHashCode() ?? 0) ^ (lineIndex * 31);
+ // Extra jitter so back-to-back repeats of the same task shift.
+ h ^= (int)(UnityEngine.Time.unscaledTime * 10f);
+ if (h < 0)
+ {
+ h = -h;
+ }
+
+ return h % count;
+ }
+ }
+}
diff --git a/IdleSpectator/ActorActivityPatches.cs b/IdleSpectator/ActorActivityPatches.cs
new file mode 100644
index 0000000..adea71a
--- /dev/null
+++ b/IdleSpectator/ActorActivityPatches.cs
@@ -0,0 +1,97 @@
+using ai.behaviours;
+using HarmonyLib;
+
+namespace IdleSpectator;
+
+///
+/// Feeds from task changes and curated behaviour beats.
+///
+[HarmonyPatch]
+public static class ActorActivityPatches
+{
+ [HarmonyPatch(typeof(Actor), nameof(Actor.setTask))]
+ [HarmonyPostfix]
+ public static void PostfixSetTask(Actor __instance, string pTaskId)
+ {
+ if (__instance == null || string.IsNullOrEmpty(pTaskId))
+ {
+ return;
+ }
+
+ ActivityLog.NoteTask(__instance, pTaskId);
+ }
+
+ [HarmonyPatch(typeof(BehPollinate), nameof(BehPollinate.execute))]
+ [HarmonyPostfix]
+ public static void PostfixPollinate(Actor pActor, BehResult __result)
+ {
+ if (pActor == null || __result == BehResult.Stop)
+ {
+ return;
+ }
+
+ string target = ActivityInterestTable.TargetLabel(pActor);
+ if (string.IsNullOrEmpty(target))
+ {
+ target = "flower";
+ }
+
+ ActivityLog.NoteActionBeat(pActor, "BehPollinate", "Pollinates " + target, target);
+ }
+
+ [HarmonyPatch(typeof(BehUnloadResources), nameof(BehUnloadResources.execute))]
+ [HarmonyPrefix]
+ public static void PrefixUnload(Actor pActor, out bool __state)
+ {
+ __state = false;
+ try
+ {
+ __state = pActor != null && pActor.isCarryingResources();
+ }
+ catch
+ {
+ __state = false;
+ }
+ }
+
+ [HarmonyPatch(typeof(BehUnloadResources), nameof(BehUnloadResources.execute))]
+ [HarmonyPostfix]
+ public static void PostfixUnload(Actor pActor, bool __state)
+ {
+ if (pActor == null || !__state)
+ {
+ return;
+ }
+
+ ActivityLog.NoteActionBeat(pActor, "BehUnloadResources", "Unloads resources", "town");
+ }
+
+ [HarmonyPatch(typeof(BehAttackActorHuntingTarget), nameof(BehAttackActorHuntingTarget.execute))]
+ [HarmonyPostfix]
+ public static void PostfixHuntAttack(Actor pActor, BehResult __result)
+ {
+ if (pActor == null)
+ {
+ return;
+ }
+
+ string target = ActivityInterestTable.TargetLabel(pActor);
+ ActivityLog.NoteActionBeat(
+ pActor,
+ "BehAttackActorHuntingTarget",
+ string.IsNullOrEmpty(target) ? "Hunts prey" : "Hunts " + target,
+ target);
+ }
+
+ [HarmonyPatch(typeof(BehCityActorRemoveFire), nameof(BehCityActorRemoveFire.execute))]
+ [HarmonyPostfix]
+ public static void PostfixRemoveFire(Actor pActor, BehResult __result)
+ {
+ if (pActor == null)
+ {
+ return;
+ }
+
+ ActivityLog.NoteActionBeat(pActor, "BehCityActorRemoveFire", "Fights fire", "blaze");
+ }
+}
diff --git a/IdleSpectator/AgentHarness.cs b/IdleSpectator/AgentHarness.cs
index 8a47769..cc020e8 100644
--- a/IdleSpectator/AgentHarness.cs
+++ b/IdleSpectator/AgentHarness.cs
@@ -352,6 +352,84 @@ public static class AgentHarness
break;
}
+ case "activity_probe":
+ {
+ Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
+ string probe = ActivityInterestTable.DumpFocusProbe(focus);
+ long id = 0;
+ try
+ {
+ if (focus != null)
+ {
+ id = focus.getID();
+ }
+ }
+ catch
+ {
+ id = 0;
+ }
+
+ _cmdOk++;
+ Emit(cmd, ok: true, detail: $"{probe} activity_count={ActivityLog.CountFor(id)}");
+ break;
+ }
+
+ case "activity_force":
+ {
+ long id = WatchCaption.CurrentUnitId;
+ if (id == 0 && MoveCamera.hasFocusUnit() && MoveCamera._focus_unit != null)
+ {
+ try
+ {
+ id = MoveCamera._focus_unit.getID();
+ }
+ catch
+ {
+ id = 0;
+ }
+ }
+
+ string key = !string.IsNullOrEmpty(cmd.asset)
+ ? cmd.asset
+ : (!string.IsNullOrEmpty(cmd.value) ? cmd.value : "farm");
+ string line = !string.IsNullOrEmpty(cmd.label) ? cmd.label : key;
+ bool asBeat = (cmd.tier ?? "").IndexOf("beat", System.StringComparison.OrdinalIgnoreCase) >= 0;
+ int n = Math.Max(1, cmd.count);
+ int okN = 0;
+ for (int i = 0; i < n; i++)
+ {
+ string one = n > 1 ? $"{line} #{i + 1}" : line;
+ if (ActivityLog.ForceNote(id, key, one, asBeat))
+ {
+ okN++;
+ }
+ }
+
+ WatchCaption.ForceRefreshHistory();
+ bool ok = okN == n && id != 0;
+ if (ok)
+ {
+ _cmdOk++;
+ }
+ else
+ {
+ _cmdFail++;
+ }
+
+ Emit(cmd, ok, detail:
+ $"forced={okN}/{n} id={id} count={ActivityLog.CountFor(id)} shown={WatchCaption.LastActivityShown}");
+ break;
+ }
+
+ case "activity_clear":
+ {
+ ActivityLog.ClearSession();
+ WatchCaption.ForceRefreshHistory();
+ _cmdOk++;
+ Emit(cmd, ok: true, detail: "activity_cleared");
+ break;
+ }
+
case "chronicle_world_force":
{
string label = !string.IsNullOrEmpty(cmd.label)
@@ -1680,10 +1758,59 @@ public static class AgentHarness
case "dossier_history_contains":
{
string needle = string.IsNullOrEmpty(cmd.value) ? cmd.label : cmd.value;
- string hist = WatchCaption.LastHistoryPreview ?? "";
+ string hist = WatchCaption.LastHistoryJoined ?? "";
+ if (string.IsNullOrEmpty(hist))
+ {
+ hist = WatchCaption.LastHistoryPreview ?? "";
+ }
+
pass = !string.IsNullOrEmpty(needle)
&& hist.IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0;
- detail = $"hist='{hist}' needle='{needle}'";
+ detail = $"hist='{hist}' needle='{needle}' act={WatchCaption.LastActivityShown} life={WatchCaption.LastLifeShown}";
+ break;
+ }
+ case "activity_count":
+ {
+ long id = WatchCaption.CurrentUnitId;
+ int want = ParseCountExpect(cmd, defaultValue: 1);
+ int have = ActivityLog.CountFor(id);
+ string cmp = (cmd.label ?? "min").Trim().ToLowerInvariant();
+ pass = cmp == "exact" || cmp == "==" ? have == want : have >= want;
+ detail = $"activity_count={have} want={want} id={id} shown={WatchCaption.LastActivityShown}";
+ break;
+ }
+ case "activity_prose_variety":
+ {
+ long id = WatchCaption.CurrentUnitId;
+ IReadOnlyList entries = ActivityLog.LatestForSubject(id, 12);
+ var distinct = new HashSet(StringComparer.Ordinal);
+ for (int i = 0; i < entries.Count; i++)
+ {
+ if (entries[i] != null && !string.IsNullOrEmpty(entries[i].DisplayLine))
+ {
+ distinct.Add(entries[i].DisplayLine);
+ }
+ }
+
+ int want = ParseCountExpect(cmd, defaultValue: 2);
+ pass = distinct.Count >= want;
+ detail = $"distinct={distinct.Count} want={want} total={entries.Count}";
+ break;
+ }
+ case "activity_first_scoring":
+ {
+ pass = WorldActivityScanner.HarnessActivityFirstScoringOk(out string scoreDetail);
+ detail = scoreDetail;
+ break;
+ }
+ case "activity_band_cold_focus":
+ {
+ Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
+ ActivityBand band = ActivityInterestTable.Classify(focus);
+ bool cold = WorldActivityScanner.IsFocusActivityCold(focus);
+ string want = (cmd.value ?? "cold").Trim().ToLowerInvariant();
+ pass = want == "cold" ? cold : !cold;
+ detail = $"band={band} cold={cold} want={want} probe={ActivityInterestTable.DumpFocusProbe(focus)}";
break;
}
case "chronicle_world_latest_contains":
@@ -1799,7 +1926,12 @@ public static class AgentHarness
case "dossier_history_shown":
{
int want = ParseCountExpect(cmd, defaultValue: 1);
- int have = WatchCaption.LastHistoryShown;
+ string scope = (cmd.tier ?? "").Trim().ToLowerInvariant();
+ int have = scope == "life"
+ ? WatchCaption.LastLifeShown
+ : scope == "activity"
+ ? WatchCaption.LastActivityShown
+ : WatchCaption.LastHistoryShown;
string cmp = (cmd.label ?? "min").Trim().ToLowerInvariant();
if (cmp == "exact" || cmp == "==")
{
@@ -1811,7 +1943,7 @@ public static class AgentHarness
}
detail =
- $"shown={have} want={want} scrollable={WatchCaption.LastHistoryScrollable} histFill={WatchCaption.LastHistoryFillsBody}";
+ $"shown={have} want={want} scope={scope} total={WatchCaption.LastHistoryShown} act={WatchCaption.LastActivityShown} life={WatchCaption.LastLifeShown} scrollable={WatchCaption.LastHistoryScrollable} histFill={WatchCaption.LastHistoryFillsBody}";
break;
}
case "dossier_history_scrollable":
@@ -1912,16 +2044,17 @@ public static class AgentHarness
{
long dossierId = WatchCaption.CurrentUnitId;
long loreId = ChronicleHud.DetailUnitId;
- int dossierShown = WatchCaption.LastHistoryShown;
+ int lifeShown = WatchCaption.LastLifeShown;
int loreData = Chronicle.HistoryCountFor(loreId);
int loreUi = ChronicleHud.LastDetailHistoryRows;
bool idsMatch = dossierId != 0 && dossierId == loreId;
- bool countsAgree = dossierShown <= 0
+ // Life lines must agree with Lore; activity-only peek does not imply chronicle rows.
+ bool countsAgree = lifeShown <= 0
? loreData == 0 && loreUi == 0
- : loreData >= dossierShown && loreUi >= Mathf.Min(dossierShown, 24);
+ : loreData >= lifeShown && loreUi >= Mathf.Min(lifeShown, 24);
pass = Chronicle.HudVisible && idsMatch && countsAgree;
detail =
- $"dossierId={dossierId} loreId={loreId} dossierShown={dossierShown} loreData={loreData} loreUi={loreUi} preview={WatchCaption.LastHistoryPreview}";
+ $"dossierId={dossierId} loreId={loreId} lifeShown={lifeShown} act={WatchCaption.LastActivityShown} loreData={loreData} loreUi={loreUi} preview={WatchCaption.LastHistoryPreview}";
break;
}
case "dossier_pinned":
diff --git a/IdleSpectator/Chronicle.cs b/IdleSpectator/Chronicle.cs
index 556a542..3b9ee6a 100644
--- a/IdleSpectator/Chronicle.cs
+++ b/IdleSpectator/Chronicle.cs
@@ -176,6 +176,7 @@ public static class Chronicle
if (!ReferenceEquals(world, _boundWorld))
{
ClearSession();
+ ActivityLog.ClearSession();
_boundWorld = world;
LogService.LogInfo("[IdleSpectator] Chronicle cleared for new world session");
}
@@ -620,6 +621,7 @@ public static class Chronicle
_trackedFocusId = 0;
_currentAgeId = "";
_currentAgeName = "";
+ ActivityLog.ClearSession();
BumpRevision();
// Keep _boundWorld so re-enable on same map does not loop-clear.
}
diff --git a/IdleSpectator/HarnessScenarios.cs b/IdleSpectator/HarnessScenarios.cs
index 09feb58..658f5a1 100644
--- a/IdleSpectator/HarnessScenarios.cs
+++ b/IdleSpectator/HarnessScenarios.cs
@@ -35,6 +35,9 @@ internal static class HarnessScenarios
case "chronicle":
case "chronicle_smoke":
return ChronicleSmoke();
+ case "activity":
+ case "activity_idle_smoke":
+ return ActivityIdleSmoke();
case "chronicle_subject_bench":
case "subject_bench":
return ChronicleSubjectBench();
@@ -85,6 +88,7 @@ internal static class HarnessScenarios
Nested("reg_discovery", "discovery"),
Nested("reg_worldlog", "world_log"),
Nested("reg_chronicle", "chronicle_smoke"),
+ Nested("reg_activity", "activity_idle_smoke"),
Step("reg_end", "fast_timing", value: "false"),
Step("reg_snap", "snapshot"),
};
@@ -415,6 +419,49 @@ internal static class HarnessScenarios
};
}
+ private static List ActivityIdleSmoke()
+ {
+ return new List
+ {
+ Step("act0", "dismiss_windows"),
+ Step("act1", "wait_world"),
+ Step("act2", "set_setting", expect: "enabled", value: "true"),
+ Step("act3", "set_setting", expect: "show_dossier_caption", value: "true"),
+ Step("act4", "set_setting", expect: "chronicle_enabled", value: "true"),
+ Step("act5", "chronicle_clear"),
+ Step("act6", "activity_clear"),
+ Step("act7", "pick_unit", asset: "auto"),
+ Step("act8", "spectator", value: "off"),
+ Step("act9", "spectator", value: "on"),
+ Step("act10", "focus", asset: "auto"),
+ Step("act11", "wait", wait: 0.35f),
+ Step("act12", "activity_probe"),
+ Step("act13", "assert", expect: "activity_first_scoring"),
+ Step("act14", "activity_force", asset: "eating", label: "Taking a meal", count: 1),
+ Step("act15", "activity_force", asset: "farm", label: "Tends the fields", count: 1),
+ Step("act16", "activity_force", asset: "pollinate", label: "Dusts the blossom", count: 1),
+ Step("act17", "activity_force", asset: "BehPollinate", label: "Works the flower", count: 4, tier: "beat"),
+ Step("act17b", "activity_force", asset: "zzz_harness_act", label: "Harness pollen trail", count: 1),
+ 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"),
+ Step("act21", "assert", expect: "dossier_history_shown", value: "1", label: "min", tier: "activity"),
+ Step("act22", "assert", expect: "dossier_history_contains", value: "Harness pollen"),
+ Step("act23", "assert", expect: "caption_layout_ok"),
+ Step("act24", "screenshot", value: "hud-activity_idle_smoke.png"),
+ Step("act25", "wait", wait: 0.55f),
+ // Life still peeks when chronicle history exists beside activity.
+ Step("act26", "chronicle_force", label: "Killed a serpent"),
+ Step("act27", "wait", wait: 0.2f),
+ Step("act28", "assert", expect: "dossier_history_contains", value: "serpent"),
+ Step("act29", "assert", expect: "dossier_history_shown", value: "1", label: "min", tier: "life"),
+ Step("act30", "assert", expect: "caption_layout_ok"),
+ Step("act31", "assert", expect: "health"),
+ Step("act32", "assert", expect: "no_bad"),
+ Step("act99", "snapshot"),
+ };
+ }
+
private static List ChronicleSmoke()
{
return new List
@@ -560,7 +607,7 @@ internal static class HarnessScenarios
Step("ch18p6", "wait", wait: 0.25f),
Step("ch18p7", "assert", expect: "idle", value: "false"),
Step("ch18p8", "assert", expect: "lore_detail", value: "true"),
- Step("ch18p9", "assert", expect: "dossier_history_shown", value: "0", label: "exact"),
+ Step("ch18p9", "assert", expect: "dossier_history_shown", value: "0", label: "exact", tier: "life"),
Step("ch18p10", "assert", expect: "lore_history_shown", value: "0", label: "exact"),
Step("ch18p11", "assert", expect: "dossier_lore_history_match"),
Step("ch18p12", "screenshot", value: "hud-lore-empty-history.png"),
diff --git a/IdleSpectator/InterestDirector.cs b/IdleSpectator/InterestDirector.cs
index 518ff93..83e74b8 100644
--- a/IdleSpectator/InterestDirector.cs
+++ b/IdleSpectator/InterestDirector.cs
@@ -17,6 +17,8 @@ public static class InterestDirector
public const float AmbientRotateSeconds = 20f;
public const float ActionPollSeconds = 0.75f;
public const float InputExitGraceSeconds = 2.5f;
+ /// Leave a Cold (idle) focus sooner so interesting work elsewhere can win.
+ public const float ColdFocusEscapeSeconds = 6f;
// Production defaults (copied when leaving harness fast timing).
private static float _minDwell = MinDwellSeconds;
@@ -28,6 +30,7 @@ public static class InterestDirector
private static float _ambientRotate = AmbientRotateSeconds;
private static float _actionPoll = ActionPollSeconds;
private static float _inputExitGrace = InputExitGraceSeconds;
+ private static float _coldFocusEscape = ColdFocusEscapeSeconds;
private static InterestEvent _current;
private static float _currentStartedAt = -999f;
@@ -63,6 +66,7 @@ public static class InterestDirector
_ambientRotate = 2.5f;
_actionPoll = 0.35f;
_inputExitGrace = 0.6f;
+ _coldFocusEscape = 1.2f;
}
else
{
@@ -75,6 +79,7 @@ public static class InterestDirector
_ambientRotate = AmbientRotateSeconds;
_actionPoll = ActionPollSeconds;
_inputExitGrace = InputExitGraceSeconds;
+ _coldFocusEscape = ColdFocusEscapeSeconds;
}
}
@@ -203,16 +208,66 @@ public static class InterestDirector
bool quiet = now - _lastInterestingAt >= _quietAmbientAfter;
bool dwellDone = _current == null || onCurrent >= DwellFor(_current);
bool ambientDue = now - _lastAmbientAt >= _ambientRotate;
+ bool coldEscape = FocusWentCold(onCurrent) && sinceSwitch >= _switchCooldown;
if (_current == null && sinceSwitch >= 0.5f)
{
TryAmbient(force: true);
}
+ else if (coldEscape)
+ {
+ TryAmbient(force: true);
+ }
else if (quiet && dwellDone && ambientDue && sinceSwitch >= _switchCooldown)
{
TryAmbient(force: false);
}
}
+ private static bool FocusWentCold(float onCurrent)
+ {
+ if (_current == null || onCurrent < _coldFocusEscape)
+ {
+ return false;
+ }
+
+ // WorldLog Story/Epic should not be yanked solely for a cold follow unit.
+ if (_current.Tier >= InterestTier.Story
+ && !string.IsNullOrEmpty(_current.AssetId)
+ && _current.AssetId != "scored_unit"
+ && _current.AssetId != "live_battle")
+ {
+ // Ambient-scored kings used AssetId = species id - still allow cold escape for those.
+ // Only protect true world-log assets (war_*, kingdom_*, etc.).
+ if (_current.AssetId.IndexOf("war", System.StringComparison.OrdinalIgnoreCase) >= 0
+ || _current.AssetId.IndexOf("kingdom", System.StringComparison.OrdinalIgnoreCase) >= 0
+ || _current.AssetId.IndexOf("city", System.StringComparison.OrdinalIgnoreCase) >= 0
+ || _current.AssetId.IndexOf("disaster", System.StringComparison.OrdinalIgnoreCase) >= 0)
+ {
+ return false;
+ }
+ }
+
+ Actor unit = null;
+ try
+ {
+ if (MoveCamera.hasFocusUnit())
+ {
+ unit = MoveCamera._focus_unit;
+ }
+ }
+ catch
+ {
+ unit = null;
+ }
+
+ if (unit == null || !unit.isAlive())
+ {
+ return true;
+ }
+
+ return WorldActivityScanner.IsFocusActivityCold(unit);
+ }
+
///
/// True when a drained New species tip would be allowed to take the camera.
///
diff --git a/IdleSpectator/WatchCaption.cs b/IdleSpectator/WatchCaption.cs
index 26bd973..a37741c 100644
--- a/IdleSpectator/WatchCaption.cs
+++ b/IdleSpectator/WatchCaption.cs
@@ -107,9 +107,18 @@ public static class WatchCaption
/// Harness: newest mini-history line currently shown (if any).
public static string LastHistoryPreview { get; private set; } = "";
+ /// Harness: all visible peek lines joined (activity + life).
+ public static string LastHistoryJoined { get; private set; } = "";
+
/// Harness: how many history lines are currently visible on the dossier.
public static int LastHistoryShown { get; private set; }
+ /// Harness: activity feed lines currently visible.
+ public static int LastActivityShown { get; private set; }
+
+ /// Harness: Chronicle Life lines currently visible in the dossier column.
+ public static int LastLifeShown { get; private set; }
+
/// Current dossier subject id (0 if none).
public static long CurrentUnitId => _current != null ? _current.UnitId : 0;
@@ -324,7 +333,10 @@ public static class WatchCaption
LastDetail = "";
LastCaptionText = "";
LastHistoryPreview = "";
+ LastHistoryJoined = "";
LastHistoryShown = 0;
+ LastActivityShown = 0;
+ LastLifeShown = 0;
LastHistoryFillsBody = false;
_pinnedWhilePaused = false;
LastTraitsPreview = "";
@@ -396,6 +408,7 @@ public static class WatchCaption
LastDetail = "";
LastCaptionText = LastHeadline;
LastHistoryPreview = "";
+ LastHistoryJoined = "";
_current = null;
_boundActor = null;
EnsureBuilt();
@@ -637,6 +650,18 @@ public static class WatchCaption
Front(_historyBtn);
}
+ /// Harness: rebuild the dossier peek column after activity injects.
+ public static void ForceRefreshHistory()
+ {
+ _lastHistoryCount = -1;
+ if (!_visible || _current == null)
+ {
+ return;
+ }
+
+ RefreshHistoryIfChanged();
+ }
+
private static void RefreshHistoryIfChanged()
{
if (!_visible || _current == null)
@@ -645,7 +670,7 @@ public static class WatchCaption
}
long id = _current.UnitId;
- int count = Chronicle.HistoryCountFor(id);
+ int count = ActivityLog.CountFor(id) + Chronicle.HistoryCountFor(id);
if (id == _lastHistorySubjectId && count == _lastHistoryCount)
{
return;
@@ -840,7 +865,10 @@ public static class WatchCaption
else
{
LastHistoryPreview = "";
+ LastHistoryJoined = "";
LastHistoryShown = 0;
+ LastActivityShown = 0;
+ LastLifeShown = 0;
LastHistoryFillsBody = false;
if (_historyCol != null)
{
@@ -861,13 +889,28 @@ public static class WatchCaption
private static int FillHistory(long unitId)
{
- IReadOnlyList entries =
- Chronicle.LatestForSubject(unitId, HistoryPeekMax);
+ // Activity first (prominent), then Chronicle Life fills remaining peek slots.
+ // Always reserve one Life slot when the subject has chronicle history so Life
+ // 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 activity =
+ ActivityLog.LatestForSubject(unitId, actCap);
+ int lifeNeed = Mathf.Max(0, HistoryPeekMax - (activity?.Count ?? 0));
+
+ IReadOnlyList life =
+ lifeNeed > 0
+ ? Chronicle.LatestForSubject(unitId, lifeNeed)
+ : System.Array.Empty();
+
_lastHistorySubjectId = unitId;
- _lastHistoryCount = Chronicle.HistoryCountFor(unitId);
+ _lastHistoryCount = ActivityLog.CountFor(unitId) + chronicleCount;
LastHistoryPreview = "";
+ LastHistoryJoined = "";
LastHistoryShown = 0;
+ LastActivityShown = 0;
+ LastLifeShown = 0;
_historyColW = HistoryColMinW;
for (int i = 0; i < _historySlotHeights.Length; i++)
{
@@ -879,39 +922,113 @@ public static class WatchCaption
return 0;
}
- if (entries == null || entries.Count == 0)
+ int total = (activity?.Count ?? 0) + (life?.Count ?? 0);
+ if (total == 0)
{
- for (int i = 0; i < _historySlots.Length; i++)
+ // Soft fallback: show current task as a single activity line when the ring is empty.
+ string taskFallback = "";
+ try
{
- HistorySlot slot = _historySlots[i];
- if (slot?.Root != null)
+ Actor live = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
+ if (live != null && live.isAlive() && live.getID() == unitId && live.hasTask()
+ && live.ai?.task != null)
{
- slot.Root.SetActive(false);
+ taskFallback = live.ai.task.getLocalizedText() ?? "";
}
-
- if (slot?.Label != null)
- {
- slot.Label.text = "";
- }
-
- _historySlotHeights[i] = 0f;
+ }
+ catch
+ {
+ taskFallback = "";
}
- _historyCol.SetActive(false);
- LastHistoryShown = 0;
- LastHistoryFillsBody = false;
- LastHistoryPreview = "";
- return 0;
+ if (string.IsNullOrEmpty(taskFallback))
+ {
+ for (int i = 0; i < _historySlots.Length; i++)
+ {
+ HistorySlot slot = _historySlots[i];
+ if (slot?.Root != null)
+ {
+ slot.Root.SetActive(false);
+ }
+
+ if (slot?.Label != null)
+ {
+ slot.Label.text = "";
+ }
+
+ _historySlotHeights[i] = 0f;
+ }
+
+ _historyCol.SetActive(false);
+ LastHistoryShown = 0;
+ LastActivityShown = 0;
+ LastLifeShown = 0;
+ LastHistoryFillsBody = false;
+ LastHistoryPreview = "";
+ LastHistoryJoined = "";
+ return 0;
+ }
+
+ activity = new[]
+ {
+ new ActivityEntry
+ {
+ DisplayLine = taskFallback,
+ Line = taskFallback,
+ Kind = ActivityKind.TaskStart
+ }
+ };
+ total = 1;
}
_historyCol.SetActive(true);
- // Content-based minimum width; Relayout stretches to fill the panel body.
+ var lines = new List<(string text, bool isActivity, ChronicleKind? kind)>(HistoryPeekMax);
+ if (activity != null)
+ {
+ for (int i = 0; i < activity.Count && lines.Count < HistoryPeekMax; i++)
+ {
+ ActivityEntry e = activity[i];
+ if (e == null)
+ {
+ continue;
+ }
+
+ string t = !string.IsNullOrEmpty(e.DisplayLine) ? e.DisplayLine : e.Line;
+ if (string.IsNullOrEmpty(t))
+ {
+ continue;
+ }
+
+ lines.Add((t, true, null));
+ }
+ }
+
+ if (life != null)
+ {
+ for (int i = 0; i < life.Count && lines.Count < HistoryPeekMax; i++)
+ {
+ ChronicleEntry e = life[i];
+ if (e == null)
+ {
+ continue;
+ }
+
+ string t = e.DisplayLineRich ?? e.DisplayLine ?? "";
+ if (string.IsNullOrEmpty(t))
+ {
+ continue;
+ }
+
+ lines.Add((t, false, e.Kind));
+ }
+ }
+
float widestLabel = 0f;
- for (int i = 0; i < _historySlots.Length; i++)
+ for (int i = 0; i < lines.Count && i < _historySlots.Length; i++)
{
HistorySlot slot = _historySlots[i];
- if (slot?.Label == null || i >= entries.Count || entries[i] == null)
+ if (slot?.Label == null)
{
continue;
}
@@ -922,7 +1039,7 @@ public static class WatchCaption
label.horizontalOverflow = HorizontalWrapMode.Overflow;
label.verticalOverflow = VerticalWrapMode.Overflow;
label.alignment = TextAnchor.UpperLeft;
- label.text = entries[i].DisplayLineRich ?? "";
+ label.text = lines[i].text;
Canvas.ForceUpdateCanvases();
try
{
@@ -930,7 +1047,7 @@ public static class WatchCaption
}
catch
{
- widestLabel = Mathf.Max(widestLabel, (entries[i].DisplayLine ?? "").Length * 6.2f);
+ widestLabel = Mathf.Max(widestLabel, lines[i].text.Length * 6.2f);
}
}
@@ -938,63 +1055,96 @@ public static class WatchCaption
widestLabel + HistoryIcon + 4f,
HistoryColMinW,
HistoryColMaxW);
- ApplyHistorySlotContents(entries, _historyColW);
+ ApplyCombinedSlotContents(lines, _historyColW);
LastHistoryShown = CountActiveHistorySlots();
return LastHistoryShown;
}
- private static void ApplyHistorySlotContents(IReadOnlyList entries, float colW)
+ private static void ApplyCombinedSlotContents(
+ List<(string text, bool isActivity, ChronicleKind? kind)> lines,
+ float colW)
{
float labelW = Mathf.Max(24f, colW - HistoryIcon - 2f);
LastHistoryPreview = "";
+ LastHistoryJoined = "";
+ LastActivityShown = 0;
+ LastLifeShown = 0;
+ var joined = new List(HistoryPeekMax);
+
for (int i = 0; i < _historySlots.Length; i++)
{
HistorySlot slot = _historySlots[i];
- if (slot == null || slot.Root == null)
+ if (slot?.Root == null)
{
continue;
}
- if (entries != null && i < entries.Count && entries[i] != null)
- {
- ChronicleEntry e = entries[i];
- slot.Root.SetActive(true);
- HudIcons.Apply(slot.Icon, HudIcons.ForChronicleKind(e.Kind));
- if (slot.Label != null)
- {
- slot.Label.horizontalOverflow = HorizontalWrapMode.Wrap;
- slot.Label.verticalOverflow = VerticalWrapMode.Overflow;
- slot.Label.text = e.DisplayLineRich ?? "";
- RectTransform lrt = slot.Label.GetComponent();
- lrt.SetSizeWithCurrentAnchors(RectTransform.Axis.Horizontal, labelW);
- Canvas.ForceUpdateCanvases();
- float needed = HistoryLineMinH;
- try
- {
- needed = Mathf.Max(HistoryLineMinH, slot.Label.preferredHeight + 4f);
- }
- catch
- {
- int chars = (e.DisplayLine ?? "").Length;
- int perLine = Mathf.Max(12, (int)(labelW / 6.2f));
- int lines = Mathf.Max(1, (chars + perLine - 1) / perLine);
- needed = HistoryLineMinH * lines;
- }
-
- _historySlotHeights[i] = Mathf.Clamp(needed, HistoryLineMinH, HistoryLineMaxH);
- }
-
- if (string.IsNullOrEmpty(LastHistoryPreview))
- {
- LastHistoryPreview = e.DisplayLine ?? "";
- }
- }
- else
+ if (i >= lines.Count)
{
slot.Root.SetActive(false);
_historySlotHeights[i] = 0f;
+ continue;
+ }
+
+ var row = lines[i];
+ slot.Root.SetActive(true);
+ Sprite icon = row.isActivity
+ ? (HudIcons.FromUiIcon("iconClock")
+ ?? HudIcons.FromUiIcon("iconTask")
+ ?? HudIcons.ForChronicleKind(ChronicleKind.Other))
+ : HudIcons.ForChronicleKind(row.kind ?? ChronicleKind.Other);
+ HudIcons.Apply(slot.Icon, icon);
+
+ if (slot.Label != null)
+ {
+ slot.Label.supportRichText = true;
+ slot.Label.horizontalOverflow = HorizontalWrapMode.Wrap;
+ slot.Label.verticalOverflow = VerticalWrapMode.Overflow;
+ slot.Label.color = row.isActivity
+ ? new Color(0.88f, 0.9f, 0.78f, 1f)
+ : HistoryTextColor;
+ slot.Label.text = row.text ?? "";
+ RectTransform lrt = slot.Label.GetComponent();
+ lrt.SetSizeWithCurrentAnchors(RectTransform.Axis.Horizontal, labelW);
+ Canvas.ForceUpdateCanvases();
+ float needed = HistoryLineMinH;
+ try
+ {
+ needed = Mathf.Max(HistoryLineMinH, slot.Label.preferredHeight + 4f);
+ }
+ catch
+ {
+ int chars = (row.text ?? "").Length;
+ int perLine = Mathf.Max(12, (int)(labelW / 6.2f));
+ int wrapLines = Mathf.Max(1, (chars + perLine - 1) / perLine);
+ needed = HistoryLineMinH * wrapLines;
+ }
+
+ _historySlotHeights[i] = Mathf.Clamp(needed, HistoryLineMinH, HistoryLineMaxH);
+ }
+ else
+ {
+ _historySlotHeights[i] = HistoryLineMinH;
+ }
+
+ if (row.isActivity)
+ {
+ LastActivityShown++;
+ }
+ else
+ {
+ LastLifeShown++;
+ }
+
+ string plain = (row.text ?? "").Replace("\n", " ");
+ joined.Add(plain);
+ if (string.IsNullOrEmpty(LastHistoryPreview))
+ {
+ LastHistoryPreview = plain;
}
}
+
+ LastHistoryJoined = string.Join(" | ", joined);
}
private static void RemeasureHistoryForWidth(float colW)
diff --git a/IdleSpectator/WorldActivityScanner.cs b/IdleSpectator/WorldActivityScanner.cs
index 14042a2..d551f73 100644
--- a/IdleSpectator/WorldActivityScanner.cs
+++ b/IdleSpectator/WorldActivityScanner.cs
@@ -183,18 +183,27 @@ public static class WorldActivityScanner
}
InterestTier tier = InterestTier.Ambient;
- if (best.isFavorite() || best.isKing() || best.isCityLeader())
- {
- tier = InterestTier.Story;
- }
- else if (best.has_attack_target || (best.data != null && best.data.kills >= 10))
+ ActivityBand band = ActivityInterestTable.Classify(best);
+ bool important = best.isFavorite() || best.isKing() || best.isCityLeader();
+
+ if (band == ActivityBand.Hot || best.has_attack_target
+ || (best.data != null && best.data.kills >= 10 && band >= ActivityBand.Warm))
{
tier = InterestTier.Action;
}
+ else if (band >= ActivityBand.Warm && important)
+ {
+ // Important + interesting (not idle king/favorite).
+ tier = InterestTier.Story;
+ }
else if (IsSpectacle(best) || IsSingletonSpecies(best, speciesCounts))
{
tier = InterestTier.Curiosity;
}
+ else
+ {
+ tier = InterestTier.Ambient;
+ }
return new InterestEvent
{
@@ -231,6 +240,40 @@ public static class WorldActivityScanner
return speciesCounts;
}
+ public static bool HarnessActivityFirstScoringOk(out string detail)
+ {
+ // Mirror ScoreActor importance gating with synthetic bands.
+ float Cold(float importance) =>
+ ComposeScore(4f, ActivityBand.Cold, importance);
+ float Warm(float importance) =>
+ ComposeScore(28f, ActivityBand.Warm, importance);
+ float Hot(float importance) =>
+ ComposeScore(55f, ActivityBand.Hot, importance);
+
+ float coldKingFav = Cold(40f); // favorite + king
+ float hotPeasant = Hot(0f);
+ float warmFav = Warm(22f);
+ float coldPeasant = Cold(0f);
+
+ bool ok = hotPeasant > coldKingFav
+ && warmFav > coldKingFav
+ && hotPeasant > warmFav
+ && coldKingFav > coldPeasant;
+ detail =
+ $"coldKingFav={coldKingFav:0.0} hotPeasant={hotPeasant:0.0} warmFav={warmFav:0.0} coldPeasant={coldPeasant:0.0}";
+ return ok;
+ }
+
+ private static float ComposeScore(float activity, ActivityBand band, float importance)
+ {
+ if (band >= ActivityBand.Warm || activity >= ActivityInterestTable.WarmScoreFloor)
+ {
+ return activity + importance * 0.85f;
+ }
+
+ return activity + importance * 0.08f;
+ }
+
public static float ScoreActorPublic(Actor actor, Dictionary speciesCounts)
{
return ScoreActor(actor, speciesCounts);
@@ -253,48 +296,54 @@ public static class WorldActivityScanner
private static float ScoreActor(Actor actor, Dictionary speciesCounts)
{
- float score = 0f;
+ if (actor == null)
+ {
+ return 0f;
+ }
+
+ float activity = ActivityInterestTable.ScoreActivity(actor);
+ ActivityBand band = ActivityInterestTable.Classify(actor);
+
+ float importance = 0f;
if (actor.isFavorite())
{
- score += 50f;
+ importance += 22f;
}
if (actor.isKing())
{
- score += 40f;
+ importance += 18f;
}
else if (actor.isCityLeader())
{
- score += 35f;
+ importance += 14f;
}
else if (actor.is_profession_warrior)
{
- score += 12f;
+ importance += 6f;
}
- if (actor.has_attack_target)
+ if (IsSpectacle(actor))
{
- score += 30f;
+ importance += 8f;
}
- int kills = actor.data != null ? actor.data.kills : 0;
- score += Mathf.Min(25, kills);
-
string speciesId = actor.asset != null ? actor.asset.id : "unknown";
- if (speciesCounts.TryGetValue(speciesId, out int pop) && pop == 1)
+ if (speciesCounts != null
+ && speciesCounts.TryGetValue(speciesId, out int pop)
+ && pop == 1)
{
- score += 15f;
+ importance += 10f;
}
if (actor.subspecies != null)
{
- // Nascent lineage: few members in subspecies asset unit set if available.
try
{
int subCount = CountSubspeciesMembers(actor.subspecies);
if (subCount > 0 && subCount <= 3)
{
- score += 10f;
+ importance += 6f;
}
}
catch
@@ -303,17 +352,37 @@ public static class WorldActivityScanner
}
}
- if (IsSpectacle(actor))
+ importance += Mathf.Min(8f, actor.renown / 120f);
+
+ // Importance only amplifies when the unit is actually doing something.
+ float score;
+ if (band >= ActivityBand.Warm || activity >= ActivityInterestTable.WarmScoreFloor)
{
- score += 8f;
+ score = activity + importance * 0.85f;
+ }
+ else
+ {
+ // Cold: tiny identity bump so favorites/kings do not monopolize Ambient.
+ score = activity + importance * 0.08f;
+ }
+
+ int kills = actor.data != null ? actor.data.kills : 0;
+ if (band >= ActivityBand.Warm)
+ {
+ score += Mathf.Min(12f, kills * 0.35f);
}
- score += Mathf.Min(10f, actor.renown / 100f);
- // Tiny jitter so equal sheep do not stick forever.
score += (actor.getID() % 7) * 0.01f;
return score;
}
+ /// True when the focused unit's current activity is Cold (idle scoring).
+ public static bool IsFocusActivityCold(Actor actor)
+ {
+ return ActivityInterestTable.Classify(actor) == ActivityBand.Cold
+ && (actor == null || !actor.has_attack_target);
+ }
+
private static int CountSubspeciesMembers(Subspecies subspecies)
{
int count = 0;
diff --git a/IdleSpectator/mod.json b/IdleSpectator/mod.json
index a44b2ff..c83ffc2 100644
--- a/IdleSpectator/mod.json
+++ b/IdleSpectator/mod.json
@@ -1,7 +1,7 @@
{
"name": "IdleSpectator",
"author": "dazed",
- "version": "0.12.24",
- "description": "AFK Idle Spectator (I) + Lore (L). Fallen favorites filter.",
+ "version": "0.12.25",
+ "description": "AFK Idle Spectator (I) + Lore (L). Activity feed + activity-first idle scoring.",
"GUID": "com.dazed.idlespectator"
}
diff --git a/scripts/harness-run.sh b/scripts/harness-run.sh
index a61a3dc..2250a39 100755
--- a/scripts/harness-run.sh
+++ b/scripts/harness-run.sh
@@ -34,6 +34,7 @@ REGRESSION_SCENARIOS=(
discovery
world_log
chronicle_smoke
+ activity_idle_smoke
)
scenario=""