From beb08265ec126bced3768b7788972bbf08f8a5ba Mon Sep 17 00:00:00 2001 From: DazedAnon Date: Tue, 14 Jul 2026 23:11:51 -0500 Subject: [PATCH] Its a start --- IdleSpectator/ActivityInterestTable.cs | 276 +++++++++++++++++++++++ IdleSpectator/ActivityLog.cs | 295 +++++++++++++++++++++++++ IdleSpectator/ActivityProse.cs | 266 ++++++++++++++++++++++ IdleSpectator/ActorActivityPatches.cs | 97 ++++++++ IdleSpectator/AgentHarness.cs | 149 ++++++++++++- IdleSpectator/Chronicle.cs | 2 + IdleSpectator/HarnessScenarios.cs | 49 +++- IdleSpectator/InterestDirector.cs | 55 +++++ IdleSpectator/WatchCaption.cs | 278 +++++++++++++++++------ IdleSpectator/WorldActivityScanner.cs | 115 ++++++++-- IdleSpectator/mod.json | 4 +- scripts/harness-run.sh | 1 + 12 files changed, 1489 insertions(+), 98 deletions(-) create mode 100644 IdleSpectator/ActivityInterestTable.cs create mode 100644 IdleSpectator/ActivityLog.cs create mode 100644 IdleSpectator/ActivityProse.cs create mode 100644 IdleSpectator/ActorActivityPatches.cs 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=""