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"
}