Implement life milestone tracking in ActivityLog and Chronicle. Introduced NoteMilestone method for logging significant events such as kills, deaths, and relationships, enhancing activity tracking. Updated Chronicle methods to utilize the new milestone logging, ensuring consistency between activity and chronicle records. Adjusted HUD icon handling for milestone activities and incremented version in mod.json to reflect these changes.

This commit is contained in:
DazedAnon 2026-07-15 11:07:52 -05:00
parent a91bff6110
commit 4b8deb012c
8 changed files with 455 additions and 25 deletions

View file

@ -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
});
}
/// <summary>
/// Life milestone mirrored into Activity (no beat cooldown). Death may use a dead actor.
/// </summary>
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);
}
/// <summary>Harness: inject activity without a live AI transition.</summary>
public static bool ForceNote(
long subjectId,

View file

@ -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();

View file

@ -1217,25 +1217,27 @@ public static class Chronicle
ChronicleHud.UpdateLive();
}
/// <summary>Killer POV - character history only.</summary>
/// <summary>Killer POV - Chronicle Life when enabled; always mirrors to Activity.</summary>
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);
}
}
/// <summary>Victim POV - cause of death on character history.</summary>
/// <summary>Victim POV - Chronicle Life when enabled; always mirrors to Activity.</summary>
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;
}
/// <summary>
/// Harness: fire a real Life milestone path (Activity mirror + Chronicle when enabled).
/// kind: kill | death | lover | friend
/// </summary>
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;
/// <summary>

View file

@ -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));

View file

@ -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",

View file

@ -161,6 +161,116 @@ public static class HudIcons
}
}
/// <summary>
/// Activity row icon from a task id, Beh* key, or milestone_* key.
/// </summary>
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)

View file

@ -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);

View file

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