diff --git a/IdleSpectator/ActivityInterestTable.cs b/IdleSpectator/ActivityInterestTable.cs index d0c1a0e..911bfc4 100644 --- a/IdleSpectator/ActivityInterestTable.cs +++ b/IdleSpectator/ActivityInterestTable.cs @@ -213,13 +213,47 @@ public static class ActivityInterestTable public static string TargetLabel(Actor actor) { + ResolveTarget(actor, out string name, out _, out string place); + return !string.IsNullOrEmpty(name) ? name : place; + } + + /// + /// Prefer live actor targets (by name). Places/buildings stay as type labels. + /// + public static void ResolveTarget( + Actor actor, + out string targetName, + out bool targetIsActor, + out string placeLabel) + { + targetName = ""; + targetIsActor = false; + placeLabel = ""; if (actor == null) { - return ""; + return; } try { + if (actor.has_attack_target && actor.attack_target != null && actor.attack_target.isAlive()) + { + if (actor.attack_target.isActor()) + { + Actor foe = actor.attack_target.a; + if (foe != null) + { + string n = foe.getName(); + if (!string.IsNullOrEmpty(n)) + { + targetName = n; + targetIsActor = true; + return; + } + } + } + } + if (actor.beh_actor_target != null && actor.beh_actor_target.isAlive()) { if (actor.beh_actor_target.isActor()) @@ -228,11 +262,17 @@ public static class ActivityInterestTable if (other != null) { string n = other.getName(); - return string.IsNullOrEmpty(n) ? "foe" : n; + if (!string.IsNullOrEmpty(n)) + { + targetName = n; + targetIsActor = true; + return; + } } } - return "target"; + placeLabel = "their mark"; + return; } if (actor.beh_building_target != null && actor.beh_building_target.isAlive()) @@ -242,23 +282,41 @@ public static class ActivityInterestTable string id = b.asset != null ? b.asset.id : ""; if (!string.IsNullOrEmpty(type)) { - return type.Replace("type_", ""); + placeLabel = type.Replace("type_", ""); + return; } - return string.IsNullOrEmpty(id) ? "building" : id; + placeLabel = string.IsNullOrEmpty(id) ? "a building" : id; + return; } if (actor.beh_tile_target != null) { - return "tile"; + placeLabel = "the wilds"; } } catch { // ignore } + } - return ""; + public static string ActorName(Actor actor) + { + if (actor == null) + { + return ""; + } + + try + { + string n = actor.getName(); + return string.IsNullOrEmpty(n) ? "" : n; + } + catch + { + return ""; + } } private static bool ContainsAny(string hay, params string[] needles) diff --git a/IdleSpectator/ActivityLog.cs b/IdleSpectator/ActivityLog.cs index b74d950..d99ce45 100644 --- a/IdleSpectator/ActivityLog.cs +++ b/IdleSpectator/ActivityLog.cs @@ -19,8 +19,10 @@ public sealed class ActivityEntry public string TargetLabel = ""; /// Stable fact line for asserts. public string Line = ""; - /// Varied display prose for HUD. + /// Varied display prose for HUD (plain text). public string DisplayLine = ""; + /// Display prose with colored names (Unity rich text). + public string DisplayLineRich = ""; } /// @@ -38,7 +40,17 @@ public static class ActivityLog private static readonly Dictionary LastBeatAt = new Dictionary(); private static readonly Dictionary LineIndex = new Dictionary(); + private static int _notesSinceClear; + private static int _tasksSinceClear; + private static int _beatsSinceClear; + private static float _clearedAt = -1f; + private static readonly Dictionary TaskHitsSinceClear = + new Dictionary(); + public static int SubjectCount => BySubject.Count; + public static int NotesSinceClear => _notesSinceClear; + public static int TasksSinceClear => _tasksSinceClear; + public static int BeatsSinceClear => _beatsSinceClear; public static void ClearSession() { @@ -46,6 +58,52 @@ public static class ActivityLog LastTaskId.Clear(); LastBeatAt.Clear(); LineIndex.Clear(); + ResetSampleCounters(); + } + + /// Harness: reset rate counters without wiping the ring. + public static void ResetSampleCounters() + { + _notesSinceClear = 0; + _tasksSinceClear = 0; + _beatsSinceClear = 0; + _clearedAt = Time.unscaledTime; + TaskHitsSinceClear.Clear(); + } + + public static string FormatSampleStats() + { + float elapsed = _clearedAt < 0f ? 0f : Mathf.Max(0.01f, Time.unscaledTime - _clearedAt); + float perSec = _notesSinceClear / elapsed; + float perMin = perSec * 60f; + int lines = 0; + foreach (var kv in BySubject) + { + lines += kv.Value != null ? kv.Value.Count : 0; + } + + string top = ""; + if (TaskHitsSinceClear.Count > 0) + { + var ranked = new List>(TaskHitsSinceClear); + ranked.Sort((a, b) => b.Value.CompareTo(a.Value)); + int n = Mathf.Min(8, ranked.Count); + for (int i = 0; i < n; i++) + { + if (i > 0) + { + top += ", "; + } + + top += ranked[i].Key + "=" + ranked[i].Value; + } + } + + float perSubjectPerMin = BySubject.Count > 0 ? perMin / BySubject.Count : 0f; + return + $"elapsed={elapsed:0.0}s notes={_notesSinceClear} tasks={_tasksSinceClear} beats={_beatsSinceClear} " + + $"per_sec={perSec:0.00} per_min={perMin:0.0} per_subject_per_min={perSubjectPerMin:0.00} " + + $"subjects={BySubject.Count} ring_lines={lines} top=[{top}]"; } public static int CountFor(long subjectId) @@ -110,7 +168,9 @@ public static class ActivityLog LastTaskId[id] = taskId; string jobId = ActivityInterestTable.SafeJobId(actor); - string target = ActivityInterestTable.TargetLabel(actor); + ActivityInterestTable.ResolveTarget( + actor, out string targetName, out bool targetIsActor, out string place); + string actorName = ActivityInterestTable.ActorName(actor); string loc = ""; try { @@ -124,15 +184,27 @@ public static class ActivityLog // ignore } + string targetLabel = !string.IsNullOrEmpty(targetName) ? targetName : place; string raw = string.IsNullOrEmpty(loc) ? ("Task: " + taskId) : loc; - if (!string.IsNullOrEmpty(target)) + if (!string.IsNullOrEmpty(targetLabel)) { - raw = raw + " → " + target; + raw = raw + " → " + targetLabel; } int idx = NextIndex(id); - string display = ActivityProse.Format( - ActivityKind.TaskStart, taskId, raw, target, id, idx); + ActivityProse.Format( + ActivityKind.TaskStart, + taskId, + actorName, + targetName, + targetIsActor, + place, + raw, + id, + idx, + out string display, + out string displayRich); + RecordSample(taskId, isBeat: false); Append(id, new ActivityEntry { CreatedAt = Time.unscaledTime, @@ -140,12 +212,32 @@ public static class ActivityLog Kind = ActivityKind.TaskStart, TaskId = taskId, JobId = jobId, - TargetLabel = target, + TargetLabel = targetLabel, Line = raw, - DisplayLine = display + DisplayLine = display, + DisplayLineRich = displayRich }); } + /// + /// Capture safety net: if the live task id differs from the last logged id, note it. + /// + public static void EnsureCurrentTask(Actor actor) + { + if (actor == null || !actor.isAlive()) + { + return; + } + + string taskId = ActivityInterestTable.SafeTaskId(actor); + if (string.IsNullOrEmpty(taskId)) + { + return; + } + + NoteTask(actor, taskId); + } + public static void NoteActionBeat(Actor actor, string actionKey, string rawFact, string target) { if (actor == null || !actor.isAlive() || string.IsNullOrEmpty(actionKey)) @@ -177,10 +269,30 @@ public static class ActivityLog LastBeatAt[id] = now; string taskId = ActivityInterestTable.SafeTaskId(actor); string jobId = ActivityInterestTable.SafeJobId(actor); + string actorName = ActivityInterestTable.ActorName(actor); + ActivityInterestTable.ResolveTarget( + actor, out string resolvedName, out bool targetIsActor, out string place); + if (string.IsNullOrEmpty(resolvedName) && !string.IsNullOrEmpty(target)) + { + resolvedName = target; + targetIsActor = true; + } + string fact = string.IsNullOrEmpty(rawFact) ? actionKey : rawFact; int idx = NextIndex(id); - string display = ActivityProse.Format( - ActivityKind.ActionBeat, actionKey, fact, target ?? "", id, idx); + ActivityProse.Format( + ActivityKind.ActionBeat, + actionKey, + actorName, + resolvedName, + targetIsActor, + place, + fact, + id, + idx, + out string display, + out string displayRich); + RecordSample(actionKey, isBeat: true); Append(id, new ActivityEntry { CreatedAt = now, @@ -188,14 +300,21 @@ public static class ActivityLog Kind = ActivityKind.ActionBeat, TaskId = string.IsNullOrEmpty(taskId) ? actionKey : taskId, JobId = jobId, - TargetLabel = target ?? "", + TargetLabel = !string.IsNullOrEmpty(resolvedName) ? resolvedName : (place ?? ""), Line = fact, - DisplayLine = display + DisplayLine = display, + DisplayLineRich = displayRich }); } /// Harness: inject activity without a live AI transition. - public static bool ForceNote(long subjectId, string taskOrAction, string rawLine, bool asBeat = false) + public static bool ForceNote( + long subjectId, + string taskOrAction, + string rawLine, + bool asBeat = false, + string actorName = "", + string targetName = "") { if (subjectId == 0 || string.IsNullOrEmpty(taskOrAction)) { @@ -205,19 +324,54 @@ public static class ActivityLog 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); + ActivityProse.Format( + kind, + taskOrAction, + actorName, + targetName, + !string.IsNullOrEmpty(targetName), + "", + fact, + subjectId, + idx, + out string display, + out string displayRich); Append(subjectId, new ActivityEntry { CreatedAt = Time.unscaledTime, SubjectId = subjectId, Kind = kind, TaskId = taskOrAction, + TargetLabel = targetName ?? "", Line = fact, - DisplayLine = display + DisplayLine = display, + DisplayLineRich = displayRich }); return true; } + private static void RecordSample(string key, bool isBeat) + { + if (_clearedAt < 0f) + { + _clearedAt = Time.unscaledTime; + } + + _notesSinceClear++; + if (isBeat) + { + _beatsSinceClear++; + } + else + { + _tasksSinceClear++; + } + + string k = string.IsNullOrEmpty(key) ? "?" : key; + TaskHitsSinceClear.TryGetValue(k, out int n); + TaskHitsSinceClear[k] = n + 1; + } + private static int NextIndex(long id) { LineIndex.TryGetValue(id, out int idx); diff --git a/IdleSpectator/ActivityProse.cs b/IdleSpectator/ActivityProse.cs index 0eda38a..2ae3005 100644 --- a/IdleSpectator/ActivityProse.cs +++ b/IdleSpectator/ActivityProse.cs @@ -3,196 +3,347 @@ using System.Collections.Generic; namespace IdleSpectator; /// -/// Fact-preserving multi-variant activity lines (anti-stale dossier feed). +/// Fact-preserving activity sentences with colored actor/target names. /// public static class ActivityProse { + public const string NameColorHex = "#F0C14A"; + + /// + /// Templates use {actor} and optional {target}. Never invent outcomes. + /// private static readonly Dictionary Variants = new Dictionary { - ["fighting"] = new[] + ["attack"] = new[] { - "Locked in combat", - "Trading blows", - "In the fray", - "Clash of steel" + "{actor} strikes at {target}", + "{actor} presses the attack on {target}", + "{actor} lunges toward {target}" }, ["fight"] = new[] { - "Locked in combat", - "Trading blows", - "In the fray" + "{actor} fights {target}", + "{actor} clashes with {target}", + "{actor} trades blows with {target}" }, - ["attack"] = new[] + ["fighting"] = new[] { - "Strikes at {target}", - "Presses the attack on {target}", - "Lunges toward {target}" + "{actor} fights {target}", + "{actor} is locked in combat with {target}", + "{actor} clashes with {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" + "{actor} hunts {target}", + "{actor} stalks {target}", + "{actor} closes on {target}" }, ["BehAttackActorHuntingTarget"] = new[] { - "Hunts {target}", - "Strikes prey", - "Closes on the quarry" + "{actor} hunts {target}", + "{actor} strikes at {target}", + "{actor} closes on {target}" + }, + ["heal"] = new[] + { + "{actor} tends to {target}", + "{actor} heals {target}", + "{actor} binds {target}'s wounds" + }, + ["check_heal"] = new[] + { + "{actor} checks on wounds", + "{actor} looks over injuries", + "{actor} takes stock of pain" + }, + ["cure"] = new[] + { + "{actor} seeks a cure", + "{actor} works to recover", + "{actor} fights sickness" + }, + ["eat"] = new[] + { + "{actor} takes a meal", + "{actor} eats", + "{actor} breaks hunger" + }, + ["eating"] = new[] + { + "{actor} takes a meal", + "{actor} eats", + "{actor} breaks hunger" + }, + ["sleep"] = new[] + { + "{actor} sleeps", + "{actor} rests", + "{actor} settles in to sleep" + }, + ["decide_where_to_sleep"] = new[] + { + "{actor} looks for a place to sleep", + "{actor} decides where to rest", + "{actor} seeks shelter for the night" + }, + ["random_move"] = new[] + { + "{actor} wanders", + "{actor} roams nearby", + "{actor} drifts along" + }, + ["city_idle_walking"] = new[] + { + "{actor} walks the streets", + "{actor} strolls through town", + "{actor} paces the settlement" + }, + ["move"] = new[] + { + "{actor} is on the move", + "{actor} travels onward", + "{actor} heads out" + }, + ["pollinate"] = new[] + { + "{actor} dusts the blossom", + "{actor} works the flower", + "{actor} leaves pollen behind" + }, + ["BehPollinate"] = new[] + { + "{actor} dusts the blossom", + "{actor} works the flower", + "{actor} leaves pollen behind" + }, + ["farm"] = new[] + { + "{actor} tends the fields", + "{actor} works the soil", + "{actor} farms the plot" + }, + ["harvest"] = new[] + { + "{actor} harvests the crop", + "{actor} gathers the yield", + "{actor} cuts the ripe fields" + }, + ["forag"] = new[] + { + "{actor} forages", + "{actor} gathers wild food", + "{actor} searches for forage" + }, + ["gather"] = new[] + { + "{actor} gathers stores", + "{actor} collects goods", + "{actor} forages supplies" + }, + ["mine"] = new[] + { + "{actor} delves the mine", + "{actor} chips at ore", + "{actor} works the dig" + }, + ["build"] = new[] + { + "{actor} builds", + "{actor} raises walls", + "{actor} works the construction" + }, + ["woodcut"] = new[] + { + "{actor} fells timber", + "{actor} cuts wood", + "{actor} takes an axe to the trees" + }, + ["fire"] = new[] + { + "{actor} fights the blaze", + "{actor} douses flames", + "{actor} races the fire" }, ["BehCityActorRemoveFire"] = new[] { - "Dousing flames", - "Fighting the blaze", - "Beats back the fire" + "{actor} fights the blaze", + "{actor} douses flames", + "{actor} beats back the fire" + }, + ["social"] = new[] + { + "{actor} talks with {target}", + "{actor} socializes with {target}", + "{actor} converses with {target}" + }, + ["socialize"] = new[] + { + "{actor} talks with {target}", + "{actor} socializes with {target}", + "{actor} joins company near {target}" + }, + ["lover"] = new[] + { + "{actor} seeks out {target}", + "{actor} is drawn to {target}", + "{actor} pursues {target}'s affection" + }, + ["breed"] = new[] + { + "{actor} tries to start a family with {target}", + "{actor} courts {target}", + "{actor} seeks offspring with {target}" + }, + ["sexual_reproduction"] = new[] + { + "{actor} tries to start a family with {target}", + "{actor} courts {target}", + "{actor} seeks offspring with {target}" + }, + ["unload"] = new[] + { + "{actor} unloads goods in town", + "{actor} delivers the haul", + "{actor} empties the pack at home" + }, + ["BehUnloadResources"] = new[] + { + "{actor} unloads goods in town", + "{actor} delivers the haul", + "{actor} empties the pack at home" + }, + ["job"] = new[] + { + "{actor} seeks work", + "{actor} looks for a job", + "{actor} searches for employment" + }, + ["find_city_job"] = new[] + { + "{actor} seeks work in town", + "{actor} looks for a city job", + "{actor} searches for employment" + }, + ["find_house"] = new[] + { + "{actor} looks for a house", + "{actor} seeks a home", + "{actor} searches for shelter" + }, + ["claim"] = new[] + { + "{actor} claims land", + "{actor} stakes a claim", + "{actor} takes claim of ground" + }, + ["warrior"] = new[] + { + "{actor} follows the army", + "{actor} marches with the warband", + "{actor} keeps formation" + }, + ["army"] = new[] + { + "{actor} follows the army", + "{actor} marches with the warband", + "{actor} keeps formation" + }, + ["wait"] = new[] + { + "{actor} waits", + "{actor} holds still", + "{actor} bides time" + }, + ["make_decision"] = new[] + { + "{actor} considers next steps", + "{actor} weighs a choice", + "{actor} pauses in thought" + }, + ["reflection"] = new[] + { + "{actor} reflects", + "{actor} takes a quiet moment", + "{actor} thinks things over" + }, + ["run_away"] = new[] + { + "{actor} flees", + "{actor} runs for safety", + "{actor} retreats" + }, + ["trade"] = new[] + { + "{actor} trades", + "{actor} barters goods", + "{actor} is on a trade run" + }, + ["fish"] = new[] + { + "{actor} fishes", + "{actor} casts for fish", + "{actor} works the waters" + }, + ["family_group"] = new[] + { + "{actor} joins the herd", + "{actor} seeks the family group", + "{actor} gathers with kin" + }, + ["generate_loot"] = new[] + { + "{actor} gathers loot", + "{actor} claims spoils", + "{actor} picks through loot" } }; + public static string ColorName(string name) + { + if (string.IsNullOrEmpty(name)) + { + return ""; + } + + return "" + name + ""; + } + + public static void Format( + ActivityKind kind, + string key, + string actorName, + string targetName, + bool targetIsActor, + string placeOrFallback, + string rawFact, + long subjectId, + int lineIndex, + out string plain, + out string rich) + { + plain = ""; + rich = ""; + string actor = string.IsNullOrEmpty(actorName) ? "Someone" : actorName; + string targetPlain = ResolveTargetPlain(targetName, targetIsActor, placeOrFallback); + string template = PickTemplate(key, subjectId, lineIndex, !string.IsNullOrEmpty(targetPlain)); + + if (string.IsNullOrEmpty(template)) + { + plain = BuildFallbackPlain(actor, targetPlain, rawFact, key); + rich = BuildFallbackRich(actor, targetPlain, targetIsActor, rawFact, key); + return; + } + + plain = Apply(template, actor, targetPlain); + rich = Apply( + template, + ColorName(actor), + targetIsActor && !string.IsNullOrEmpty(targetName) + ? ColorName(targetName) + : targetPlain); + } + + /// Legacy helper used by older call sites / harness. public static string Format( ActivityKind kind, string key, @@ -201,22 +352,123 @@ public static class ActivityProse long subjectId, int lineIndex) { - if (string.IsNullOrEmpty(rawFact) && string.IsNullOrEmpty(key)) + Format( + kind, + key, + actorName: "", + targetName: target, + targetIsActor: !string.IsNullOrEmpty(target), + placeOrFallback: target, + rawFact: rawFact, + subjectId: subjectId, + lineIndex: lineIndex, + out string plain, + out _); + return plain; + } + + private static string ResolveTargetPlain(string targetName, bool targetIsActor, string placeOrFallback) + { + if (!string.IsNullOrEmpty(targetName)) + { + return targetName; + } + + if (!string.IsNullOrEmpty(placeOrFallback)) + { + return placeOrFallback; + } + + return ""; + } + + private static string Apply(string template, string actor, string target) + { + string t = template.Replace("{actor}", actor ?? ""); + if (string.IsNullOrEmpty(target)) + { + // Drop dangling " with {target}" style if target missing - templates should + // prefer no-target variants via PickTemplate, but scrub leftovers. + t = t.Replace(" with {target}", "") + .Replace(" on {target}", "") + .Replace(" toward {target}", "") + .Replace(" at {target}", "") + .Replace(" {target}", "") + .Replace("{target}", "someone"); + } + else + { + t = t.Replace("{target}", target); + } + + return t.Trim(); + } + + private static string BuildFallbackPlain(string actor, string target, string rawFact, string key) + { + string action = !string.IsNullOrEmpty(rawFact) ? rawFact : HumanizeKey(key); + if (string.IsNullOrEmpty(action)) + { + return actor; + } + + if (!string.IsNullOrEmpty(target) && action.IndexOf(target, System.StringComparison.OrdinalIgnoreCase) < 0) + { + return actor + " " + LowerFirst(action) + " → " + target; + } + + return actor + " " + LowerFirst(action); + } + + private static string BuildFallbackRich( + string actor, + string target, + bool targetIsActor, + string rawFact, + string key) + { + string action = !string.IsNullOrEmpty(rawFact) ? rawFact : HumanizeKey(key); + string a = ColorName(actor); + if (string.IsNullOrEmpty(action)) + { + return a; + } + + if (!string.IsNullOrEmpty(target) && action.IndexOf(target, System.StringComparison.OrdinalIgnoreCase) < 0) + { + string t = targetIsActor ? ColorName(target) : target; + return a + " " + LowerFirst(action) + " → " + t; + } + + return a + " " + LowerFirst(action); + } + + private static string LowerFirst(string s) + { + if (string.IsNullOrEmpty(s)) + { + return s; + } + + if (s.Length == 1) + { + return s.ToLowerInvariant(); + } + + return char.ToLowerInvariant(s[0]) + s.Substring(1); + } + + private static string HumanizeKey(string key) + { + if (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); + return key.Replace('_', ' ').Replace("Beh", "").Trim(); } - private static string PickTemplate(string key, long subjectId, int lineIndex) + private static string PickTemplate(string key, long subjectId, int lineIndex, bool hasTarget) { if (string.IsNullOrEmpty(key)) { @@ -226,21 +478,61 @@ public static class ActivityProse string[] bag = null; if (Variants.TryGetValue(key, out bag) && bag != null && bag.Length > 0) { - return bag[PickIndex(subjectId, key, lineIndex, bag.Length)]; + return PreferTargetAware(bag, hasTarget, subjectId, key, lineIndex); } string lower = key.ToLowerInvariant(); + string bestKey = null; + string[] bestBag = null; + int bestLen = -1; foreach (KeyValuePair kv in Variants) { - if (lower.IndexOf(kv.Key, System.StringComparison.Ordinal) >= 0 - && kv.Value != null - && kv.Value.Length > 0) + if (kv.Value == null || kv.Value.Length == 0) { - return kv.Value[PickIndex(subjectId, kv.Key, lineIndex, kv.Value.Length)]; + continue; + } + + if (lower.IndexOf(kv.Key, System.StringComparison.Ordinal) >= 0 && kv.Key.Length > bestLen) + { + bestLen = kv.Key.Length; + bestKey = kv.Key; + bestBag = kv.Value; } } - return null; + if (bestBag == null) + { + return null; + } + + return PreferTargetAware(bestBag, hasTarget, subjectId, bestKey, lineIndex); + } + + private static string PreferTargetAware( + string[] bag, + bool hasTarget, + long subjectId, + string key, + int lineIndex) + { + var suited = new List(bag.Length); + for (int i = 0; i < bag.Length; i++) + { + string line = bag[i]; + bool needsTarget = line.IndexOf("{target}", System.StringComparison.Ordinal) >= 0; + if (hasTarget || !needsTarget) + { + suited.Add(line); + } + } + + if (suited.Count == 0) + { + // Fall back to any template; Apply() will scrub missing targets. + suited.AddRange(bag); + } + + return suited[PickIndex(subjectId, key, lineIndex, suited.Count)]; } private static int PickIndex(long subjectId, string key, int lineIndex, int count) @@ -253,7 +545,6 @@ public static class ActivityProse 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) { diff --git a/IdleSpectator/AgentHarness.cs b/IdleSpectator/AgentHarness.cs index cc020e8..d6c0132 100644 --- a/IdleSpectator/AgentHarness.cs +++ b/IdleSpectator/AgentHarness.cs @@ -394,12 +394,26 @@ public static class AgentHarness : (!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; + string actorName = ""; + try + { + if (MoveCamera.hasFocusUnit() && MoveCamera._focus_unit != null) + { + actorName = MoveCamera._focus_unit.getName() ?? ""; + } + } + catch + { + actorName = ""; + } + + string targetName = cmd.expect ?? ""; 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)) + if (ActivityLog.ForceNote(id, key, one, asBeat, actorName, targetName)) { okN++; } @@ -430,6 +444,46 @@ public static class AgentHarness break; } + case "activity_sample_reset": + { + ActivityLog.ResetSampleCounters(); + _cmdOk++; + Emit(cmd, ok: true, detail: ActivityLog.FormatSampleStats()); + break; + } + + case "activity_stats": + { + string stats = ActivityLog.FormatSampleStats(); + _cmdOk++; + Emit(cmd, ok: true, detail: stats); + break; + } + + case "lore_detail_pane": + { + string raw = (cmd.value ?? cmd.label ?? "life").Trim().ToLowerInvariant(); + ChronicleHud.DetailPane pane = raw.StartsWith("act") + ? ChronicleHud.DetailPane.Activity + : ChronicleHud.DetailPane.Life; + ChronicleHud.SetDetailPane(pane); + bool ok = ChronicleHud.DetailUnitId != 0 + && ChronicleHud.ActiveDetailPane == pane; + if (ok) + { + _cmdOk++; + } + else + { + _cmdFail++; + } + + Emit(cmd, ok, detail: + $"pane={ChronicleHud.ActiveDetailPane} detailId={ChronicleHud.DetailUnitId} " + + $"actRows={ChronicleHud.LastDetailActivityRows} lifeRows={ChronicleHud.LastDetailHistoryRows}"); + break; + } + case "chronicle_world_force": { string label = !string.IsNullOrEmpty(cmd.label) @@ -1803,6 +1857,90 @@ public static class AgentHarness detail = scoreDetail; break; } + case "activity_names_colored": + { + long id = WatchCaption.CurrentUnitId; + IReadOnlyList entries = ActivityLog.LatestForSubject(id, 8); + bool any = false; + bool colored = false; + for (int i = 0; i < entries.Count; i++) + { + ActivityEntry e = entries[i]; + if (e == null || string.IsNullOrEmpty(e.DisplayLineRich)) + { + continue; + } + + any = true; + if (e.DisplayLineRich.IndexOf("= 0) + { + colored = true; + break; + } + } + + pass = any && colored; + detail = $"any={any} colored={colored} preview='{WatchCaption.LastHistoryPreview}'"; + break; + } + case "activity_log_contains": + { + long id = WatchCaption.CurrentUnitId; + string needle = string.IsNullOrEmpty(cmd.value) ? cmd.label : cmd.value; + IReadOnlyList entries = + ActivityLog.LatestForSubject(id, ActivityLog.MaxLinesPerSubject); + bool hit = false; + string sample = ""; + for (int i = 0; i < entries.Count; i++) + { + ActivityEntry e = entries[i]; + if (e == null) + { + continue; + } + + string line = e.DisplayLine ?? e.Line ?? ""; + if (string.IsNullOrEmpty(sample)) + { + sample = line; + } + + if (!string.IsNullOrEmpty(needle) + && line.IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0) + { + hit = true; + sample = line; + break; + } + } + + pass = hit; + detail = $"hit={hit} needle='{needle}' sample='{sample}' count={entries.Count}"; + break; + } + case "lore_detail_pane": + { + string want = (cmd.value ?? "life").Trim().ToLowerInvariant(); + ChronicleHud.DetailPane have = ChronicleHud.ActiveDetailPane; + bool match = want.StartsWith("act") + ? have == ChronicleHud.DetailPane.Activity + : have == ChronicleHud.DetailPane.Life; + pass = Chronicle.HudVisible && ChronicleHud.DetailUnitId != 0 && match; + detail = + $"pane={have} want={want} actRows={ChronicleHud.LastDetailActivityRows} lifeRows={ChronicleHud.LastDetailHistoryRows}"; + break; + } + case "lore_activity_shown": + { + int want = ParseCountExpect(cmd, defaultValue: 1); + int have = ChronicleHud.LastDetailActivityRows; + string cmp = (cmd.label ?? "min").Trim().ToLowerInvariant(); + pass = Chronicle.HudVisible + && ChronicleHud.ActiveDetailPane == ChronicleHud.DetailPane.Activity + && (cmp == "exact" || cmp == "==" ? have == want : have >= want); + detail = $"actRows={have} want={want} pane={ChronicleHud.ActiveDetailPane}"; + break; + } case "activity_band_cold_focus": { Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null; diff --git a/IdleSpectator/ChronicleHud.cs b/IdleSpectator/ChronicleHud.cs index 7783581..1d69ae9 100644 --- a/IdleSpectator/ChronicleHud.cs +++ b/IdleSpectator/ChronicleHud.cs @@ -20,6 +20,12 @@ public static class ChronicleHud Fallen = 2 } + public enum DetailPane + { + Life = 0, + Activity = 1 + } + private const float PanelWidth = 268f; private const float PanelHeight = 188f; private const float ScreenInset = 12f; @@ -33,7 +39,7 @@ public static class ChronicleHud private const float TabH = 15f; private const float CharToolsH = 17f; private const float ScrollSensitivity = 55f; - private const string ChromeMarker = "LoreV5"; + private const string ChromeMarker = "LoreV6"; private static GameObject _root; private static RectTransform _rootRt; @@ -68,10 +74,20 @@ public static class ChronicleHud private static bool _followFocus; private static GameObject _backBtn; private static Text _backBtnLabel; + private static GameObject _detailPaneBar; + private static GameObject _detailActivityBtn; + private static GameObject _detailLifeBtn; + private static DetailPane _detailPane = DetailPane.Life; /// Rows actually built in the last character detail rebuild (harness). public static int LastDetailHistoryRows { get; private set; } + /// Activity rows in the last Activity-pane rebuild (harness). + public static int LastDetailActivityRows { get; private set; } + + /// Active detail sub-pane when a unit history is open. + public static DetailPane ActiveDetailPane => _detailPane; + /// Title of the first row in the last Living/Fallen list rebuild (harness). public static string LastListTopTitle { get; private set; } = ""; @@ -128,8 +144,12 @@ public static class ChronicleHud _searchField = null; _backBtn = null; _backBtnLabel = null; + _detailPaneBar = null; + _detailActivityBtn = null; + _detailLifeBtn = null; _detailUnitId = 0; _followFocus = false; + _detailPane = DetailPane.Life; } if (_root != null) @@ -337,16 +357,46 @@ public static class ChronicleHud _backBtnLabel = _backBtn.transform.Find("Label")?.GetComponent(); RectTransform backRt = _backBtn.GetComponent(); backRt.anchorMin = new Vector2(0f, 0f); - backRt.anchorMax = new Vector2(0.42f, 1f); + backRt.anchorMax = new Vector2(0.36f, 1f); backRt.offsetMin = Vector2.zero; backRt.offsetMax = Vector2.zero; _backBtn.SetActive(false); + _detailPaneBar = new GameObject("DetailPanes", typeof(RectTransform)); + _detailPaneBar.transform.SetParent(_charTools.transform, false); + RectTransform paneRt = _detailPaneBar.GetComponent(); + paneRt.anchorMin = new Vector2(0.38f, 0f); + paneRt.anchorMax = new Vector2(1f, 1f); + paneRt.offsetMin = Vector2.zero; + paneRt.offsetMax = Vector2.zero; + _detailActivityBtn = BuildTabButton( + _detailPaneBar.transform, "ActivityPane", "Activity", () => SetDetailPane(DetailPane.Activity)); + _detailLifeBtn = BuildTabButton( + _detailPaneBar.transform, "LifePane", "Life", () => SetDetailPane(DetailPane.Life)); + PlaceDetailPane(_detailActivityBtn, 0f); + PlaceDetailPane(_detailLifeBtn, 1f); + _detailPaneBar.SetActive(false); + _charTools.SetActive(false); RefreshFavFilterVisual(); RefreshStaleVisual(); } + private static void PlaceDetailPane(GameObject tab, float index) + { + if (tab == null) + { + return; + } + + RectTransform rt = tab.GetComponent(); + float w = 0.5f; + rt.anchorMin = new Vector2(index * w, 0f); + rt.anchorMax = new Vector2(index * w + w, 1f); + rt.offsetMin = new Vector2(1f, 1f); + rt.offsetMax = new Vector2(-1f, -1f); + } + private static Button BuildTextTool(Transform parent, string name, string label, UnityAction onClick) { GameObject go = new GameObject(name, typeof(RectTransform), typeof(Image), typeof(Button)); @@ -671,6 +721,7 @@ public static class ChronicleHud EnsureBuilt(); _detailUnitId = unitId; _followFocus = followFocus; + _detailPane = DetailPane.Life; if (pauseIdle && SpectatorMode.Active) { @@ -705,6 +756,7 @@ public static class ChronicleHud { _detailUnitId = 0; _followFocus = false; + _detailPane = DetailPane.Life; _lastCharFingerprint = int.MinValue; ApplyTabChrome(); if (Visible && IsCastTab) @@ -713,6 +765,24 @@ public static class ChronicleHud } } + public static void SetDetailPane(DetailPane pane) + { + if (_detailUnitId == 0) + { + return; + } + + if (_detailPane == pane) + { + StyleDetailPanes(); + return; + } + + _detailPane = pane; + StyleDetailPanes(); + Rebuild(force: true); + } + public static void SetFavoritesOnly(bool on) { if (_favoritesOnly == on) @@ -871,6 +941,12 @@ public static class ChronicleHud } } + if (_detailPaneBar != null) + { + _detailPaneBar.SetActive(detail); + StyleDetailPanes(); + } + StyleTab(_worldTabBtn, _tab == LoreTab.World); StyleTab(_livingTabBtn, _tab == LoreTab.Living); StyleTab(_fallenTabBtn, _tab == LoreTab.Fallen); @@ -879,6 +955,12 @@ public static class ChronicleHud RefreshTitle(); } + private static void StyleDetailPanes() + { + StyleTab(_detailActivityBtn, _detailPane == DetailPane.Activity); + StyleTab(_detailLifeBtn, _detailPane == DetailPane.Life); + } + private static void RefreshTitle() { if (_titleText == null) @@ -1086,6 +1168,8 @@ public static class ChronicleHud if (_detailUnitId != 0) { hash = (hash * 31) + Chronicle.HistoryCountFor(_detailUnitId); + hash = (hash * 31) + ActivityLog.CountFor(_detailUnitId); + hash = (hash * 31) + (int)_detailPane; } return hash; @@ -1155,7 +1239,15 @@ public static class ChronicleHud LastListTopTitle = ""; if (_detailUnitId != 0) { - RebuildUnitHistoryDetail(_detailUnitId); + if (_detailPane == DetailPane.Activity) + { + RebuildActivityDetail(_detailUnitId); + } + else + { + RebuildUnitHistoryDetail(_detailUnitId); + } + return; } @@ -1211,6 +1303,103 @@ public static class ChronicleHud } } + private static void RebuildActivityDetail(long unitId) + { + IReadOnlyList entries = + ActivityLog.LatestForSubject(unitId, ActivityLog.MaxLinesPerSubject); + _lastCharFingerprint = CharacterFingerprint(); + _builtAtRevision = Chronicle.HistoryRevision; + RefreshStaleVisual(); + LastDetailHistoryRows = 0; + if (entries == null || entries.Count == 0) + { + LastDetailActivityRows = 0; + AddEmpty("No activity yet"); + return; + } + + AddSection(entries.Count + " actions"); + int shown = 0; + for (int i = 0; i < entries.Count; i++) + { + ActivityEntry entry = entries[i]; + if (entry == null) + { + continue; + } + + GameObject row = BuildActivityDetailRow(entry, shown); + row.transform.SetParent(_content, false); + Rows.Add(row); + shown++; + } + + LastDetailActivityRows = shown; + if (_scroll != null) + { + _scroll.verticalNormalizedPosition = 1f; + } + } + + private static GameObject BuildActivityDetailRow(ActivityEntry entry, int index) + { + GameObject go = new GameObject( + $"ActDetail_{index}", + typeof(RectTransform), + typeof(Image), + typeof(LayoutElement), + typeof(ContentSizeFitter)); + RectTransform rt = go.GetComponent(); + rt.sizeDelta = new Vector2(PanelWidth - 14f, RowHeightMin); + LayoutElement le = go.GetComponent(); + le.minHeight = RowHeightMin; + le.preferredHeight = -1f; + ContentSizeFitter fit = go.GetComponent(); + fit.verticalFit = ContentSizeFitter.FitMode.PreferredSize; + fit.horizontalFit = ContentSizeFitter.FitMode.Unconstrained; + + Image bg = go.GetComponent(); + bg.color = index % 2 == 0 + ? new Color(1f, 1f, 1f, 0.04f) + : new Color(1f, 1f, 1f, 0.015f); + bg.raycastTarget = false; + + Image kindIcon = HudCanvas.MakeIcon(go.transform, "Kind", KindIconSize); + RectTransform kindRt = kindIcon.GetComponent(); + kindRt.anchorMin = new Vector2(0f, 1f); + kindRt.anchorMax = new Vector2(0f, 1f); + kindRt.pivot = new Vector2(0.5f, 1f); + kindRt.anchoredPosition = new Vector2(9f, -5f); + kindIcon.raycastTarget = false; + HudIcons.Apply( + kindIcon, + HudIcons.FromUiIcon("iconClock") + ?? HudIcons.FromUiIcon("iconTask") + ?? HudIcons.ForChronicleKind(ChronicleKind.Other)); + + string text = !string.IsNullOrEmpty(entry.DisplayLineRich) + ? entry.DisplayLineRich + : (!string.IsNullOrEmpty(entry.DisplayLine) ? entry.DisplayLine : entry.Line); + Text label = HudCanvas.MakeText(go.transform, "Text", text ?? "", 8); + RectTransform labelRt = label.GetComponent(); + labelRt.anchorMin = new Vector2(0f, 0f); + labelRt.anchorMax = new Vector2(1f, 1f); + labelRt.offsetMin = new Vector2(18f, 2f); + labelRt.offsetMax = new Vector2(-4f, -2f); + label.alignment = TextAnchor.UpperLeft; + label.color = new Color(0.88f, 0.9f, 0.78f, 1f); + label.supportRichText = true; + label.resizeTextForBestFit = false; + label.horizontalOverflow = HorizontalWrapMode.Wrap; + label.verticalOverflow = VerticalWrapMode.Overflow; + label.raycastTarget = false; + + ContentSizeFitter textFit = label.gameObject.AddComponent(); + textFit.verticalFit = ContentSizeFitter.FitMode.PreferredSize; + textFit.horizontalFit = ContentSizeFitter.FitMode.Unconstrained; + return go; + } + private static void RebuildUnitHistoryDetail(long unitId) { IReadOnlyList entries = Chronicle.LatestForSubject(unitId, Chronicle.MaxHistoryPerUnit); diff --git a/IdleSpectator/HarnessScenarios.cs b/IdleSpectator/HarnessScenarios.cs index 658f5a1..de2d174 100644 --- a/IdleSpectator/HarnessScenarios.cs +++ b/IdleSpectator/HarnessScenarios.cs @@ -38,6 +38,9 @@ internal static class HarnessScenarios case "activity": case "activity_idle_smoke": return ActivityIdleSmoke(); + case "activity_rate": + case "activity_rate_sample": + return ActivityRateSample(); case "chronicle_subject_bench": case "subject_bench": return ChronicleSubjectBench(); @@ -419,6 +422,18 @@ internal static class HarnessScenarios }; } + private static List ActivityRateSample() + { + return new List + { + Step("ar0", "wait_world"), + Step("ar1", "activity_sample_reset"), + Step("ar2", "wait", wait: 20f), + Step("ar3", "activity_stats"), + Step("ar4", "snapshot"), + }; + } + private static List ActivityIdleSmoke() { return new List @@ -439,12 +454,14 @@ internal static class HarnessScenarios 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("act16", "activity_force", asset: "hunt", label: "Hunts", count: 1, expect: "Barkley"), + Step("act17", "activity_force", asset: "BehPollinate", label: "Works the flower", count: 3, tier: "beat"), Step("act17b", "activity_force", asset: "zzz_harness_act", label: "Harness pollen trail", count: 1), 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("act20b", "assert", expect: "activity_names_colored"), + Step("act20c", "assert", expect: "activity_log_contains", value: "Barkley"), 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"), @@ -455,9 +472,23 @@ internal static class HarnessScenarios 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("act29b", "screenshot", value: "hud-activity-life-split.png"), + Step("act29c", "wait", wait: 0.45f), + // Books: Activity / Life tabs. + Step("act40", "lore_open_focus"), + Step("act41", "wait", wait: 0.25f), + Step("act42", "assert", expect: "lore_detail_pane", value: "life"), + Step("act43", "lore_detail_pane", value: "activity"), + Step("act44", "wait", wait: 0.2f), + Step("act45", "assert", expect: "lore_detail_pane", value: "activity"), + Step("act46", "assert", expect: "lore_activity_shown", value: "4", label: "min"), + Step("act47", "screenshot", value: "hud-lore-activity-tab.png"), + Step("act48", "wait", wait: 0.45f), + Step("act49", "lore_detail_pane", value: "life"), + Step("act50", "assert", expect: "lore_history_shown", value: "1", label: "min"), + Step("act51", "assert", expect: "idle", value: "false"), + Step("act52", "assert", expect: "caption_layout_ok"), + Step("act53", "assert", expect: "no_bad"), Step("act99", "snapshot"), }; } diff --git a/IdleSpectator/WatchCaption.cs b/IdleSpectator/WatchCaption.cs index a37741c..ecef77f 100644 --- a/IdleSpectator/WatchCaption.cs +++ b/IdleSpectator/WatchCaption.cs @@ -64,11 +64,15 @@ public static class WatchCaption private static GameObject _traitsRow; private static GameObject _historyCol; + private static Image _activityBoxBg; + private static RectTransform _lifeSep; private static readonly HistorySlot[] _historySlots = new HistorySlot[HistoryPeekMax]; private static readonly float[] _historySlotHeights = new float[HistoryPeekMax]; + private static readonly bool[] _historySlotIsActivity = new bool[HistoryPeekMax]; private static float _historyColW = HistoryColMinW; private static int _lastHistoryCount = -1; private static long _lastHistorySubjectId; + private const float LifeSepH = 3f; private static Button _favoriteBtn; private static Image _favoriteIcon; @@ -773,6 +777,7 @@ public static class WatchCaption if (hasLive) { DossierAvatar.Show(actor); + ActivityLog.EnsureCurrentTask(actor); } else if (hasBody) { @@ -983,7 +988,7 @@ public static class WatchCaption _historyCol.SetActive(true); - var lines = new List<(string text, bool isActivity, ChronicleKind? kind)>(HistoryPeekMax); + var lines = new List<(string rich, string plain, bool isActivity, ChronicleKind? kind)>(HistoryPeekMax); if (activity != null) { for (int i = 0; i < activity.Count && lines.Count < HistoryPeekMax; i++) @@ -994,13 +999,14 @@ public static class WatchCaption continue; } - string t = !string.IsNullOrEmpty(e.DisplayLine) ? e.DisplayLine : e.Line; - if (string.IsNullOrEmpty(t)) + string plain = !string.IsNullOrEmpty(e.DisplayLine) ? e.DisplayLine : e.Line; + if (string.IsNullOrEmpty(plain)) { continue; } - lines.Add((t, true, null)); + string rich = !string.IsNullOrEmpty(e.DisplayLineRich) ? e.DisplayLineRich : plain; + lines.Add((rich, plain, true, null)); } } @@ -1014,13 +1020,14 @@ public static class WatchCaption continue; } - string t = e.DisplayLineRich ?? e.DisplayLine ?? ""; - if (string.IsNullOrEmpty(t)) + string plain = e.DisplayLine ?? e.HudLine ?? ""; + string rich = e.DisplayLineRich ?? plain; + if (string.IsNullOrEmpty(plain) && string.IsNullOrEmpty(rich)) { continue; } - lines.Add((t, false, e.Kind)); + lines.Add((rich, plain, false, e.Kind)); } } @@ -1039,7 +1046,7 @@ public static class WatchCaption label.horizontalOverflow = HorizontalWrapMode.Overflow; label.verticalOverflow = VerticalWrapMode.Overflow; label.alignment = TextAnchor.UpperLeft; - label.text = lines[i].text; + label.text = lines[i].plain; Canvas.ForceUpdateCanvases(); try { @@ -1047,7 +1054,7 @@ public static class WatchCaption } catch { - widestLabel = Mathf.Max(widestLabel, lines[i].text.Length * 6.2f); + widestLabel = Mathf.Max(widestLabel, lines[i].plain.Length * 6.2f); } } @@ -1061,7 +1068,7 @@ public static class WatchCaption } private static void ApplyCombinedSlotContents( - List<(string text, bool isActivity, ChronicleKind? kind)> lines, + List<(string rich, string plain, bool isActivity, ChronicleKind? kind)> lines, float colW) { float labelW = Mathf.Max(24f, colW - HistoryIcon - 2f); @@ -1083,10 +1090,12 @@ public static class WatchCaption { slot.Root.SetActive(false); _historySlotHeights[i] = 0f; + _historySlotIsActivity[i] = false; continue; } var row = lines[i]; + _historySlotIsActivity[i] = row.isActivity; slot.Root.SetActive(true); Sprite icon = row.isActivity ? (HudIcons.FromUiIcon("iconClock") @@ -1103,7 +1112,7 @@ public static class WatchCaption slot.Label.color = row.isActivity ? new Color(0.88f, 0.9f, 0.78f, 1f) : HistoryTextColor; - slot.Label.text = row.text ?? ""; + slot.Label.text = row.rich ?? row.plain ?? ""; RectTransform lrt = slot.Label.GetComponent(); lrt.SetSizeWithCurrentAnchors(RectTransform.Axis.Horizontal, labelW); Canvas.ForceUpdateCanvases(); @@ -1114,7 +1123,7 @@ public static class WatchCaption } catch { - int chars = (row.text ?? "").Length; + int chars = (row.plain ?? "").Length; int perLine = Mathf.Max(12, (int)(labelW / 6.2f)); int wrapLines = Mathf.Max(1, (chars + perLine - 1) / perLine); needed = HistoryLineMinH * wrapLines; @@ -1136,7 +1145,7 @@ public static class WatchCaption LastLifeShown++; } - string plain = (row.text ?? "").Replace("\n", " "); + string plain = (row.plain ?? "").Replace("\n", " "); joined.Add(plain); if (string.IsNullOrEmpty(LastHistoryPreview)) { @@ -1185,6 +1194,8 @@ public static class WatchCaption float total = 0f; int used = 0; + bool hasAct = false; + bool hasLife = false; for (int i = 0; i < _historySlotHeights.Length && used < historyCount; i++) { if (_historySlotHeights[i] <= 0.01f) @@ -1193,9 +1204,23 @@ public static class WatchCaption } total += _historySlotHeights[i]; + if (_historySlotIsActivity[i]) + { + hasAct = true; + } + else + { + hasLife = true; + } + used++; } + if (hasAct && hasLife) + { + total += LifeSepH; + } + return Mathf.Max(BodyH, total); } @@ -1521,6 +1546,16 @@ public static class WatchCaption if (historyCount <= 0) { _historyCol.SetActive(false); + if (_activityBoxBg != null) + { + _activityBoxBg.gameObject.SetActive(false); + } + + if (_lifeSep != null) + { + _lifeSep.gameObject.SetActive(false); + } + return; } @@ -1533,6 +1568,11 @@ public static class WatchCaption col.sizeDelta = new Vector2(_historyColW, bodyH); float top = 0f; + float activityTop = -1f; + float activityBottom = 0f; + bool sawLife = false; + bool placedSep = false; + for (int i = 0; i < _historySlots.Length; i++) { HistorySlot slot = _historySlots[i]; @@ -1541,7 +1581,41 @@ public static class WatchCaption continue; } + bool isAct = _historySlotIsActivity[i]; + if (!isAct && !placedSep && activityTop >= 0f) + { + // Thin rule between Activity box and Life. + if (_lifeSep != null) + { + _lifeSep.gameObject.SetActive(true); + _lifeSep.SetParent(_historyCol.transform, false); + _lifeSep.anchorMin = new Vector2(0f, 1f); + _lifeSep.anchorMax = new Vector2(1f, 1f); + _lifeSep.pivot = new Vector2(0.5f, 1f); + _lifeSep.offsetMin = new Vector2(2f, -(top + LifeSepH)); + _lifeSep.offsetMax = new Vector2(-2f, -top); + } + + top += LifeSepH; + placedSep = true; + } + + if (!isAct) + { + sawLife = true; + } + float lineH = _historySlotHeights[i] > 0.01f ? _historySlotHeights[i] : HistoryLineMinH; + if (isAct) + { + if (activityTop < 0f) + { + activityTop = top; + } + + activityBottom = top + lineH; + } + RectTransform rt = slot.Root.GetComponent(); rt.anchorMin = new Vector2(0f, 1f); rt.anchorMax = new Vector2(1f, 1f); @@ -1569,6 +1643,31 @@ public static class WatchCaption lrt.offsetMax = new Vector2(0f, -1f); } } + + if (_lifeSep != null && (!sawLife || activityTop < 0f)) + { + _lifeSep.gameObject.SetActive(false); + } + + if (_activityBoxBg != null) + { + if (activityTop >= 0f && activityBottom > activityTop) + { + _activityBoxBg.gameObject.SetActive(true); + RectTransform box = _activityBoxBg.rectTransform; + box.SetParent(_historyCol.transform, false); + box.SetAsFirstSibling(); + box.anchorMin = new Vector2(0f, 1f); + box.anchorMax = new Vector2(1f, 1f); + box.pivot = new Vector2(0.5f, 1f); + box.offsetMin = new Vector2(0f, -activityBottom); + box.offsetMax = new Vector2(0f, -activityTop); + } + else + { + _activityBoxBg.gameObject.SetActive(false); + } + } } private static float PlaceTraits(float yFromTop, int traitCount) @@ -1768,6 +1867,8 @@ public static class WatchCaption _taskText = null; _traitsRow = null; _historyCol = null; + _activityBoxBg = null; + _lifeSep = null; _favoriteBtn = null; _favoriteIcon = null; _historyBtn = null; @@ -1808,6 +1909,21 @@ public static class WatchCaption _historyCol = new GameObject("HistoryCol", typeof(RectTransform)); _historyCol.transform.SetParent(_root.transform, false); + GameObject boxGo = new GameObject("ActivityBox", typeof(RectTransform), typeof(Image)); + boxGo.transform.SetParent(_historyCol.transform, false); + _activityBoxBg = boxGo.GetComponent(); + _activityBoxBg.color = new Color(0.14f, 0.16f, 0.14f, 0.55f); + _activityBoxBg.raycastTarget = false; + boxGo.SetActive(false); + + GameObject sepGo = new GameObject("LifeSep", typeof(RectTransform), typeof(Image)); + sepGo.transform.SetParent(_historyCol.transform, false); + _lifeSep = sepGo.GetComponent(); + Image sepImg = sepGo.GetComponent(); + sepImg.color = new Color(0.55f, 0.58f, 0.62f, 0.55f); + sepImg.raycastTarget = false; + sepGo.SetActive(false); + for (int i = 0; i < _historySlots.Length; i++) { GameObject slotGo = new GameObject("Hist" + i, typeof(RectTransform)); @@ -1817,6 +1933,7 @@ public static class WatchCaption label.color = HistoryTextColor; label.alignment = TextAnchor.MiddleLeft; label.horizontalOverflow = HorizontalWrapMode.Overflow; + label.supportRichText = true; label.resizeTextMinSize = 6; label.resizeTextMaxSize = 8; _historySlots[i] = new HistorySlot { Root = slotGo, Icon = icon, Label = label }; diff --git a/IdleSpectator/mod.json b/IdleSpectator/mod.json index c83ffc2..3d8df80 100644 --- a/IdleSpectator/mod.json +++ b/IdleSpectator/mod.json @@ -1,7 +1,7 @@ { "name": "IdleSpectator", "author": "dazed", - "version": "0.12.25", - "description": "AFK Idle Spectator (I) + Lore (L). Activity feed + activity-first idle scoring.", + "version": "0.12.29", + "description": "AFK Idle Spectator (I) + Lore (L). Detailed activity prose, Activity/Life tabs.", "GUID": "com.dazed.idlespectator" }