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