diff --git a/IdleSpectator/ActivityLog.cs b/IdleSpectator/ActivityLog.cs
index 8c5f371..41bad56 100644
--- a/IdleSpectator/ActivityLog.cs
+++ b/IdleSpectator/ActivityLog.cs
@@ -310,7 +310,7 @@ public static class ActivityLog
CreatedAt = now,
SubjectId = id,
Kind = ActivityKind.ActionBeat,
- TaskId = string.IsNullOrEmpty(taskId) ? actionKey : taskId,
+ TaskId = actionKey,
JobId = jobId,
TargetLabel = !string.IsNullOrEmpty(ctx.TargetName)
? ctx.TargetName
@@ -321,6 +321,127 @@ public static class ActivityLog
});
}
+ ///
+ /// Life milestone mirrored into Activity (no beat cooldown). Death may use a dead actor.
+ ///
+ public static void NoteMilestone(Actor actor, string actionKey, string rawLine, string target = "")
+ {
+ if (actor == null || string.IsNullOrEmpty(actionKey) || string.IsNullOrEmpty(rawLine))
+ {
+ return;
+ }
+
+ bool allowDead = string.Equals(actionKey, "milestone_death", System.StringComparison.Ordinal);
+ if (!allowDead)
+ {
+ try
+ {
+ if (!actor.isAlive())
+ {
+ return;
+ }
+ }
+ catch
+ {
+ return;
+ }
+ }
+
+ long id;
+ try
+ {
+ id = actor.getID();
+ }
+ catch
+ {
+ return;
+ }
+
+ if (id == 0)
+ {
+ return;
+ }
+
+ string actorName;
+ try
+ {
+ actorName = actor.getName();
+ if (string.IsNullOrEmpty(actorName))
+ {
+ actorName = actor.asset != null ? actor.asset.id : "creature";
+ }
+ }
+ catch
+ {
+ actorName = "creature";
+ }
+
+ string targetName = target ?? "";
+ string fact = rawLine;
+ string clause = LowerFirst(fact);
+ string display = string.IsNullOrEmpty(clause) ? actorName : actorName + " " + clause;
+ string displayRich;
+ if (string.IsNullOrEmpty(targetName))
+ {
+ displayRich = ActivityProse.ColorName(actorName) + (string.IsNullOrEmpty(clause) ? "" : " " + clause);
+ }
+ else
+ {
+ // Prefer a clean subject + verb + colored target when the fact is a short Life line.
+ string richTarget = ActivityProse.ColorName(targetName);
+ if (string.Equals(actionKey, "milestone_kill", System.StringComparison.Ordinal))
+ {
+ display = actorName + " killed " + targetName;
+ displayRich = ActivityProse.ColorName(actorName) + " killed " + richTarget;
+ }
+ else if (string.Equals(actionKey, "milestone_lover", System.StringComparison.Ordinal))
+ {
+ display = actorName + " became lovers with " + targetName;
+ displayRich = ActivityProse.ColorName(actorName) + " became lovers with " + richTarget;
+ }
+ else if (string.Equals(actionKey, "milestone_friend", System.StringComparison.Ordinal))
+ {
+ display = actorName + " befriended " + targetName;
+ displayRich = ActivityProse.ColorName(actorName) + " befriended " + richTarget;
+ }
+ else
+ {
+ displayRich = ActivityProse.ColorName(actorName)
+ + (string.IsNullOrEmpty(clause) ? "" : " " + clause);
+ }
+ }
+
+ float now = Time.unscaledTime;
+ RecordSample(actionKey, isBeat: true);
+ Append(id, new ActivityEntry
+ {
+ CreatedAt = now,
+ SubjectId = id,
+ Kind = ActivityKind.ActionBeat,
+ TaskId = actionKey,
+ JobId = "",
+ TargetLabel = targetName,
+ Line = fact,
+ DisplayLine = display,
+ DisplayLineRich = displayRich
+ });
+ }
+
+ private static string LowerFirst(string text)
+ {
+ if (string.IsNullOrEmpty(text))
+ {
+ return "";
+ }
+
+ if (text.Length == 1)
+ {
+ return text.ToLowerInvariant();
+ }
+
+ return char.ToLowerInvariant(text[0]) + text.Substring(1);
+ }
+
/// Harness: inject activity without a live AI transition.
public static bool ForceNote(
long subjectId,
diff --git a/IdleSpectator/AgentHarness.cs b/IdleSpectator/AgentHarness.cs
index 27f9c4d..5edea07 100644
--- a/IdleSpectator/AgentHarness.cs
+++ b/IdleSpectator/AgentHarness.cs
@@ -344,6 +344,28 @@ public static class AgentHarness
break;
}
+ case "chronicle_milestone":
+ {
+ string kind = !string.IsNullOrEmpty(cmd.value)
+ ? cmd.value
+ : (!string.IsNullOrEmpty(cmd.label) ? cmd.label : "kill");
+ string other = cmd.expect ?? "";
+ bool ok = Chronicle.ForceLifeMilestoneOnFocus(kind, other);
+ if (ok)
+ {
+ _cmdOk++;
+ }
+ else
+ {
+ _cmdFail++;
+ }
+
+ long id = Chronicle.CurrentHistorySubjectId();
+ Emit(cmd, ok, detail:
+ $"milestone={kind} history={Chronicle.HistoryCountFor(id)} activity={ActivityLog.CountFor(id)}");
+ break;
+ }
+
case "chronicle_clear":
{
Chronicle.ClearSession();
diff --git a/IdleSpectator/Chronicle.cs b/IdleSpectator/Chronicle.cs
index 3b9ee6a..a176a68 100644
--- a/IdleSpectator/Chronicle.cs
+++ b/IdleSpectator/Chronicle.cs
@@ -1217,25 +1217,27 @@ public static class Chronicle
ChronicleHud.UpdateLive();
}
- /// Killer POV - character history only.
+ /// Killer POV - Chronicle Life when enabled; always mirrors to Activity.
public static void NoteKill(Actor killer, Actor victim)
{
- if (!ModSettings.ChronicleEnabled || killer == null || victim == null)
+ if (killer == null || victim == null)
{
return;
}
- AppendHistory(
- ChronicleKind.Kill,
- killer,
- victim,
- $"Killed {SafeName(victim)} ({SpeciesOf(victim)})");
+ string victimName = SafeName(victim);
+ string line = $"Killed {victimName} ({SpeciesOf(victim)})";
+ ActivityLog.NoteMilestone(killer, "milestone_kill", line, victimName);
+ if (ModSettings.ChronicleEnabled)
+ {
+ AppendHistory(ChronicleKind.Kill, killer, victim, line);
+ }
}
- /// Victim POV - cause of death on character history.
+ /// Victim POV - Chronicle Life when enabled; always mirrors to Activity.
public static void NoteDeath(Actor victim, AttackType attackType, Actor killer)
{
- if (!ModSettings.ChronicleEnabled || victim == null)
+ if (victim == null)
{
return;
}
@@ -1256,12 +1258,17 @@ public static class Chronicle
}
string line = FormatDeathCause(victim, attackType, killer);
- AppendHistory(ChronicleKind.Death, victim, killer, line, attackType);
+ string killerName = killer != null ? SafeName(killer) : "";
+ ActivityLog.NoteMilestone(victim, "milestone_death", line, killerName);
+ if (ModSettings.ChronicleEnabled)
+ {
+ AppendHistory(ChronicleKind.Death, victim, killer, line, attackType);
+ }
}
public static void NoteLovers(Actor a, Actor b)
{
- if (!ModSettings.ChronicleEnabled || a == null || b == null)
+ if (a == null || b == null)
{
return;
}
@@ -1274,13 +1281,22 @@ public static class Chronicle
CapPairKeys(LoverPairKeys);
- AppendHistory(ChronicleKind.Lover, a, b, $"Became lovers with {SafeName(b)}");
- AppendHistory(ChronicleKind.Lover, b, a, $"Became lovers with {SafeName(a)}");
+ string nameA = SafeName(a);
+ string nameB = SafeName(b);
+ string lineA = $"Became lovers with {nameB}";
+ string lineB = $"Became lovers with {nameA}";
+ ActivityLog.NoteMilestone(a, "milestone_lover", lineA, nameB);
+ ActivityLog.NoteMilestone(b, "milestone_lover", lineB, nameA);
+ if (ModSettings.ChronicleEnabled)
+ {
+ AppendHistory(ChronicleKind.Lover, a, b, lineA);
+ AppendHistory(ChronicleKind.Lover, b, a, lineB);
+ }
}
public static void NoteBestFriends(Actor a, Actor b)
{
- if (!ModSettings.ChronicleEnabled || a == null || b == null)
+ if (a == null || b == null)
{
return;
}
@@ -1293,8 +1309,17 @@ public static class Chronicle
CapPairKeys(FriendPairKeys);
- AppendHistory(ChronicleKind.Friend, a, b, $"Befriended {SafeName(b)}");
- AppendHistory(ChronicleKind.Friend, b, a, $"Befriended {SafeName(a)}");
+ string nameA = SafeName(a);
+ string nameB = SafeName(b);
+ string lineA = $"Befriended {nameB}";
+ string lineB = $"Befriended {nameA}";
+ ActivityLog.NoteMilestone(a, "milestone_friend", lineA, nameB);
+ ActivityLog.NoteMilestone(b, "milestone_friend", lineB, nameA);
+ if (ModSettings.ChronicleEnabled)
+ {
+ AppendHistory(ChronicleKind.Friend, a, b, lineA);
+ AppendHistory(ChronicleKind.Friend, b, a, lineB);
+ }
}
public static void NoteWorldLog(WorldLogMessage message, string label)
@@ -1364,6 +1389,139 @@ public static class Chronicle
return true;
}
+ ///
+ /// Harness: fire a real Life milestone path (Activity mirror + Chronicle when enabled).
+ /// kind: kill | death | lover | friend
+ ///
+ public static bool ForceLifeMilestoneOnFocus(string kind, string otherName = "")
+ {
+ if (!MoveCamera.hasFocusUnit() || MoveCamera._focus_unit == null)
+ {
+ return false;
+ }
+
+ Actor focus = MoveCamera._focus_unit;
+ Actor other = FindOtherLivingUnit(focus);
+ string k = (kind ?? "").Trim().ToLowerInvariant();
+ string label = string.IsNullOrEmpty(otherName)
+ ? (other != null ? SafeName(other) : "Barkley")
+ : otherName;
+
+ switch (k)
+ {
+ case "kill":
+ if (other != null)
+ {
+ NoteKill(focus, other);
+ return true;
+ }
+
+ ActivityLog.NoteMilestone(
+ focus,
+ "milestone_kill",
+ $"Killed {label} (creature)",
+ label);
+ if (ModSettings.ChronicleEnabled)
+ {
+ AppendHistory(ChronicleKind.Kill, focus, null, $"Killed {label} (creature)");
+ }
+
+ return true;
+
+ case "death":
+ NoteDeath(focus, AttackType.Age, other);
+ return true;
+
+ case "lover":
+ if (other != null)
+ {
+ // Allow re-force across scenario steps by clearing this pair first.
+ LoverPairKeys.Remove(PairKey(focus.getID(), other.getID()));
+ NoteLovers(focus, other);
+ return true;
+ }
+
+ ActivityLog.NoteMilestone(
+ focus,
+ "milestone_lover",
+ $"Became lovers with {label}",
+ label);
+ if (ModSettings.ChronicleEnabled)
+ {
+ AppendHistory(ChronicleKind.Lover, focus, null, $"Became lovers with {label}");
+ }
+
+ return true;
+
+ case "friend":
+ if (other != null)
+ {
+ FriendPairKeys.Remove(PairKey(focus.getID(), other.getID()));
+ NoteBestFriends(focus, other);
+ return true;
+ }
+
+ ActivityLog.NoteMilestone(
+ focus,
+ "milestone_friend",
+ $"Befriended {label}",
+ label);
+ if (ModSettings.ChronicleEnabled)
+ {
+ AppendHistory(ChronicleKind.Friend, focus, null, $"Befriended {label}");
+ }
+
+ return true;
+
+ default:
+ return false;
+ }
+ }
+
+ private static Actor FindOtherLivingUnit(Actor exclude)
+ {
+ try
+ {
+ long excludeId = 0;
+ try
+ {
+ excludeId = exclude != null ? exclude.getID() : 0;
+ }
+ catch
+ {
+ excludeId = 0;
+ }
+
+ foreach (Actor unit in WorldActivityScanner.EnumerateAliveUnitsPublic())
+ {
+ if (unit == null)
+ {
+ continue;
+ }
+
+ try
+ {
+ if (unit.getID() == excludeId)
+ {
+ continue;
+ }
+
+ return unit;
+ }
+ catch
+ {
+ // skip
+ }
+ }
+ }
+ catch
+ {
+ // ignore
+ }
+
+ return null;
+ }
+
private static long _orphanSeq = -1000;
///
diff --git a/IdleSpectator/ChronicleHud.cs b/IdleSpectator/ChronicleHud.cs
index 1d69ae9..05834e8 100644
--- a/IdleSpectator/ChronicleHud.cs
+++ b/IdleSpectator/ChronicleHud.cs
@@ -1373,7 +1373,8 @@ public static class ChronicleHud
kindIcon.raycastTarget = false;
HudIcons.Apply(
kindIcon,
- HudIcons.FromUiIcon("iconClock")
+ HudIcons.ForActivityKey(entry.TaskId)
+ ?? HudIcons.FromUiIcon("iconClock")
?? HudIcons.FromUiIcon("iconTask")
?? HudIcons.ForChronicleKind(ChronicleKind.Other));
diff --git a/IdleSpectator/HarnessScenarios.cs b/IdleSpectator/HarnessScenarios.cs
index 7b4ed77..e108706 100644
--- a/IdleSpectator/HarnessScenarios.cs
+++ b/IdleSpectator/HarnessScenarios.cs
@@ -506,6 +506,13 @@ internal static class HarnessScenarios
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"),
+ // Life milestones mirror into Activity (kill + Became lovers) while Life stays on Chronicle.
+ Step("act20d", "chronicle_milestone", value: "kill"),
+ Step("act20e", "assert", expect: "activity_log_contains", value: "killed"),
+ Step("act20f", "assert", expect: "chronicle_latest_contains", value: "Killed"),
+ Step("act20g", "chronicle_milestone", value: "lover"),
+ Step("act20h", "assert", expect: "activity_log_contains", value: "Became lovers"),
+ Step("act20i", "assert", expect: "chronicle_latest_contains", value: "Became lovers"),
Step("act21", "assert", expect: "dossier_history_shown", value: "1", label: "min", tier: "activity"),
Step("act22", "assert", expect: "dossier_history_not_contains", value: "Harness pollen"),
Step("act23", "assert", expect: "caption_layout_ok"),
@@ -518,6 +525,9 @@ internal static class HarnessScenarios
Step("act29", "assert", expect: "dossier_history_shown", value: "1", label: "min", tier: "life"),
Step("act29b", "screenshot", value: "hud-activity-life-split.png"),
Step("act29c", "wait", wait: 0.45f),
+ // Mixed activity icons + kill/lover milestones in dossier peek.
+ Step("act29d", "screenshot", value: "hud-activity-icons-milestones.png"),
+ Step("act29e", "wait", wait: 0.45f),
// Books: Activity / Life tabs.
Step("act40", "lore_open_focus"),
Step("act41", "wait", wait: 0.25f),
@@ -568,6 +578,14 @@ internal static class HarnessScenarios
Step("ch15b", "assert", expect: "chronicle_latest_contains", value: "auto"),
Step("ch15c", "wait", wait: 0.2f),
Step("ch15d", "assert", expect: "dossier_history_contains", value: "Killed"),
+ // Real kill / lover milestones also land in Activity.
+ Step("ch15d2", "chronicle_milestone", value: "kill"),
+ Step("ch15d3", "assert", expect: "activity_log_contains", value: "killed"),
+ Step("ch15d4", "assert", expect: "chronicle_latest_contains", value: "Killed"),
+ Step("ch15d5", "chronicle_milestone", value: "lover"),
+ Step("ch15d6", "assert", expect: "activity_log_contains", value: "Became lovers"),
+ Step("ch15d7", "wait", wait: 0.2f),
+ Step("ch15d8", "assert", expect: "dossier_history_shown", value: "1", label: "min", tier: "activity"),
// Long event wraps in the narrow dossier history column (no wider panel).
Step("ch15e", "chronicle_force",
diff --git a/IdleSpectator/HudIcons.cs b/IdleSpectator/HudIcons.cs
index 283b296..69641d0 100644
--- a/IdleSpectator/HudIcons.cs
+++ b/IdleSpectator/HudIcons.cs
@@ -161,6 +161,116 @@ public static class HudIcons
}
}
+ ///
+ /// Activity row icon from a task id, Beh* key, or milestone_* key.
+ ///
+ public static Sprite ForActivityKey(string key)
+ {
+ if (string.IsNullOrEmpty(key))
+ {
+ return Task() ?? FromUiIcon("iconClock");
+ }
+
+ string k = key.ToLowerInvariant();
+ if (k == "milestone_kill")
+ {
+ return ForChronicleKind(ChronicleKind.Kill);
+ }
+
+ if (k == "milestone_death")
+ {
+ return ForChronicleKind(ChronicleKind.Death);
+ }
+
+ if (k == "milestone_lover")
+ {
+ return ForChronicleKind(ChronicleKind.Lover);
+ }
+
+ if (k == "milestone_friend")
+ {
+ return ForChronicleKind(ChronicleKind.Friend);
+ }
+
+ string verb = ActivityVerbMap.Resolve(key);
+ if (string.IsNullOrEmpty(verb))
+ {
+ verb = k;
+ }
+
+ switch (verb)
+ {
+ case "fight":
+ case "warrior":
+ return FromUiIcon("iconAttack") ?? FromUiIcon("iconKills") ?? Task();
+ case "hunt":
+ return FromUiIcon("iconKills") ?? FromUiIcon("iconAttack") ?? Task();
+ case "lover":
+ return FromUiIcon("iconArrowLover") ?? Task();
+ case "social":
+ case "cry":
+ case "swear":
+ return FromUiIcon("iconTalk") ?? FromUiIcon("iconSpeech") ?? FromUiIcon("iconSocial")
+ ?? FromUiIcon("iconArrowLover") ?? Task();
+ case "eat":
+ return FromUiIcon("iconFood") ?? FromUiIcon("iconHunger") ?? Task();
+ case "sleep":
+ case "dream":
+ return FromUiIcon("iconSleep") ?? FromUiIcon("iconRest") ?? FromUiIcon("iconClock") ?? Task();
+ case "farm":
+ return FromUiIcon("iconFarm") ?? FromUiIcon("iconCrops") ?? FromUiIcon("iconWheat") ?? Task();
+ case "work":
+ case "haul":
+ return FromUiIcon("iconShowTasks") ?? FromUiIcon("iconHammer") ?? FromUiIcon("iconWalker") ?? Task();
+ case "fire":
+ case "ignite":
+ case "extinguish":
+ return FromUiIcon("iconFire") ?? FromUiIcon("iconLava") ?? Task();
+ case "flee":
+ return FromUiIcon("iconFlee") ?? FromUiIcon("iconRun") ?? FromUiIcon("iconDanger")
+ ?? FromUiIcon("iconAttack") ?? Task();
+ case "play":
+ case "laugh":
+ case "sing":
+ return FromUiIcon("iconHappy") ?? FromUiIcon("iconFun") ?? FromUiIcon("iconMusic")
+ ?? FromUiIcon("iconFavoriteStar") ?? Task();
+ case "read":
+ return FromUiIcon("iconBooks") ?? FromUiIcon("iconBook") ?? Task();
+ case "heal":
+ case "recharge":
+ return FromUiIcon("iconHealth") ?? FromUiIcon("iconHeal") ?? FromUiIcon("iconHeart") ?? Task();
+ case "pollinate":
+ return FromUiIcon("iconFlower") ?? FromUiIcon("iconBee") ?? FromUiIcon("iconNature") ?? Task();
+ case "fish":
+ return FromUiIcon("iconFish") ?? FromUiIcon("iconFishing") ?? FromUiIcon("iconWater") ?? Task();
+ case "trade":
+ return FromUiIcon("iconTrade") ?? FromUiIcon("iconCoin") ?? FromUiIcon("iconGold") ?? Task();
+ case "settle":
+ case "group":
+ return FromUiIcon("iconCity") ?? FromUiIcon("iconHome") ?? FromUiIcon("iconKingdom") ?? Task();
+ case "relieve":
+ return FromUiIcon("iconPoop") ?? FromUiIcon("iconToilet") ?? Task();
+ case "confused":
+ case "strange_urge":
+ case "possessed":
+ return FromUiIcon("iconConfused") ?? FromUiIcon("iconMadness") ?? FromUiIcon("iconPossessed")
+ ?? FromUiIcon("iconQuestionMark") ?? Task();
+ case "steal":
+ case "loot":
+ return FromUiIcon("iconSteal") ?? FromUiIcon("iconLoot") ?? FromUiIcon("iconBag") ?? Task();
+ case "harvest_life":
+ case "gather_life":
+ return FromUiIcon("iconSoulHarvested") ?? FromUiIcon("iconSoul") ?? FromUiIcon("iconSkulls")
+ ?? FromUiIcon("iconDead") ?? Task();
+ case "move":
+ return FromUiIcon("iconWalker") ?? FromUiIcon("iconShowTasks") ?? Task();
+ case "wait":
+ return FromUiIcon("iconClock") ?? Task();
+ default:
+ return Task() ?? FromUiIcon("iconClock");
+ }
+ }
+
public static Sprite ForDeathManner(DeathManner manner)
{
switch (manner)
diff --git a/IdleSpectator/WatchCaption.cs b/IdleSpectator/WatchCaption.cs
index 6782d8e..d8b1b3e 100644
--- a/IdleSpectator/WatchCaption.cs
+++ b/IdleSpectator/WatchCaption.cs
@@ -988,7 +988,7 @@ public static class WatchCaption
_historyCol.SetActive(true);
- var lines = new List<(string rich, string plain, bool isActivity, ChronicleKind? kind)>(HistoryPeekMax);
+ var lines = new List<(string rich, string plain, bool isActivity, ChronicleKind? kind, string activityKey)>(HistoryPeekMax);
if (activity != null)
{
for (int i = 0; i < activity.Count && lines.Count < HistoryPeekMax; i++)
@@ -1006,7 +1006,7 @@ public static class WatchCaption
}
string rich = !string.IsNullOrEmpty(e.DisplayLineRich) ? e.DisplayLineRich : plain;
- lines.Add((rich, plain, true, null));
+ lines.Add((rich, plain, true, null, e.TaskId ?? ""));
}
}
@@ -1027,7 +1027,7 @@ public static class WatchCaption
continue;
}
- lines.Add((rich, plain, false, e.Kind));
+ lines.Add((rich, plain, false, e.Kind, ""));
}
}
@@ -1068,7 +1068,7 @@ public static class WatchCaption
}
private static void ApplyCombinedSlotContents(
- List<(string rich, string plain, bool isActivity, ChronicleKind? kind)> lines,
+ List<(string rich, string plain, bool isActivity, ChronicleKind? kind, string activityKey)> lines,
float colW)
{
float labelW = Mathf.Max(24f, colW - HistoryIcon - 2f);
@@ -1098,8 +1098,8 @@ public static class WatchCaption
_historySlotIsActivity[i] = row.isActivity;
slot.Root.SetActive(true);
Sprite icon = row.isActivity
- ? (HudIcons.FromUiIcon("iconClock")
- ?? HudIcons.FromUiIcon("iconTask")
+ ? (HudIcons.ForActivityKey(row.activityKey)
+ ?? HudIcons.FromUiIcon("iconClock")
?? HudIcons.ForChronicleKind(ChronicleKind.Other))
: HudIcons.ForChronicleKind(row.kind ?? ChronicleKind.Other);
HudIcons.Apply(slot.Icon, icon);
diff --git a/IdleSpectator/mod.json b/IdleSpectator/mod.json
index 08706d5..c29fe65 100644
--- a/IdleSpectator/mod.json
+++ b/IdleSpectator/mod.json
@@ -1,7 +1,7 @@
{
"name": "IdleSpectator",
"author": "dazed",
- "version": "0.12.86",
+ "version": "0.12.87",
"description": "AFK Idle Spectator (I) + Lore (L). Detailed living activity prose by default.",
"GUID": "com.dazed.idlespectator"
}