using System.Collections.Generic; using NeoModLoader.services; using UnityEngine; namespace IdleSpectator; public enum ChronicleKind { Death, Kill, Lover, Friend, World, Other, /// Age chapter header row in World Memory (not a clickable landmark). AgeChapter } /// How a character died - used for Fallen list labels and icons. public enum DeathManner { None = 0, Slain, OldAge, Starvation, Drowned, Burned, Plague, Accident, Divine, Other } public sealed class ChronicleEntry { public float CreatedAt; /// WorldBox sim time when the event was recorded (). public double WorldTime; /// In-world month + year, e.g. January 1 (no brackets). public string DateLabel = ""; public ChronicleKind Kind; public long SubjectId; public long OtherId; public string Name = ""; public string SpeciesId = ""; /// Raw fact line (asserts / debug). public string Line = ""; /// Chronicle-voice line for HUD; falls back to . public string LoreLine = ""; public Vector3 Position; /// True when was recorded from a real world location. public bool HasWorldPosition; public string AssetId = ""; public string AgeId = ""; public string AgeName = ""; /// True when this row is styled as a highlighted World Memory landmark. public bool IsLegend; /// Vanilla name when is Death. public string AttackTypeName = ""; /// Classified death manner for Fallen UI. public DeathManner DeathManner; /// Back-compat for harness / jump helpers. public long UnitId => SubjectId; public string HudLine => !string.IsNullOrEmpty(LoreLine) ? LoreLine : (Line ?? ""); /// Resolved death manner (stored, or inferred from line / killer). public DeathManner ResolvedDeathManner { get { if (Kind != ChronicleKind.Death) { return DeathManner.None; } if (DeathManner != DeathManner.None) { return DeathManner; } return Chronicle.InferDeathManner(AttackTypeName, OtherId != 0, Line); } } /// Plain date-prefixed line for logs / asserts (uses lore when present). public string DisplayLine { get { string body = HudLine; if (string.IsNullOrEmpty(DateLabel)) { return body ?? ""; } if (string.IsNullOrEmpty(body)) { return "[" + DateLabel + "]"; } return "[" + DateLabel + "] " + body; } } /// HUD line with a muted colored date in brackets (Unity rich text). public string DisplayLineRich { get { string body = HudLine; if (string.IsNullOrEmpty(DateLabel)) { return body ?? ""; } string stamped = "[" + DateLabel + "]"; if (string.IsNullOrEmpty(body)) { return stamped; } return stamped + " " + body; } } } /// /// Session chronicles: per-character History + World Memory landmarks (age chapters). /// public static class Chronicle { public const KeyCode ToggleKey = KeyCode.L; public const int MaxHistoryPerUnit = 40; /// Default soft cap on keyed character histories (Living + Fallen archive subjects). public const int DefaultMaxHistorySubjects = 100000; /// Hard safety ceiling for . public const int AbsoluteMaxHistorySubjects = 200000; /// Soft cap on keyed character histories. Tunable via harness. public static int MaxHistorySubjects { get; private set; } = DefaultMaxHistorySubjects; public const int MaxLandmarks = 80; private const int MaxLandmarksPerAge = 8; private const int MaxPairKeys = 1500; private const int MaxLandmarkDedupe = 400; private static readonly Dictionary> Histories = new Dictionary>(); private static readonly List MemoryLandmarks = new List(); private static readonly HashSet LoverPairKeys = new HashSet(); private static readonly HashSet FriendPairKeys = new HashSet(); private static readonly HashSet DeathLogged = new HashSet(); private static readonly HashSet FavoriteIds = new HashSet(); private static readonly Dictionary FinalDossiers = new Dictionary(); private static readonly HashSet LandmarkDedupe = new HashSet(); private static readonly Dictionary LandmarksPerAge = new Dictionary(); private static readonly List RecentFocusIds = new List(); public const int MaxRecentFocus = 24; /// Monotonic counter bumped on history/memory writes (cheap HUD dirty check). public static int HistoryRevision { get; private set; } private static bool _hudReady; private static long _lastHistorySubjectId; private static long _trackedFocusId; private static object _boundWorld; private static string _currentAgeId = ""; private static string _currentAgeName = ""; public static void OnSpectatorEnabled() { object world = World.world; if (!ReferenceEquals(world, _boundWorld)) { ClearSession(); ActivityLog.ClearSession(); _boundWorld = world; LogService.LogInfo("[IdleSpectator] Chronicle cleared for new world session"); } // Roster + saga memory use a richer bind key than world reference alone. LifeSagaSession.EnsureBound(); RefreshAgeChapter(force: true); } public static int Count => HistoryCountFor(CurrentHistorySubjectId()) + MemoryCount; /// World Memory landmark count (replaces the old World feed). public static int WorldCount => MemoryCount; public static int MemoryCount { get { lock (MemoryLandmarks) { return MemoryLandmarks.Count; } } } public static bool HudVisible => ChronicleHud.Visible; public static long LastHistorySubjectId => _lastHistorySubjectId; public static int RecentFocusCount { get { lock (RecentFocusIds) { return RecentFocusIds.Count; } } } public static string CurrentAgeId => _currentAgeId; public static string CurrentAgeName => _currentAgeName; public static int HistoryCountFor(long subjectId) { if (subjectId == 0) { return 0; } lock (Histories) { return Histories.TryGetValue(subjectId, out List list) ? list.Count : 0; } } /// How many distinct characters currently have History rows stored. public static int HistorySubjectCount { get { lock (Histories) { return Histories.Count; } } } public static int FinalDossierCount => FinalDossiers.Count; public static bool IsFavoriteSubject(long unitId) { return unitId != 0 && FavoriteIds.Contains(unitId); } /// Track favorites across death so Fallen can filter like Living. public static void SetFavoriteSubject(long unitId, bool favorite) { if (unitId == 0) { return; } bool had = FavoriteIds.Contains(unitId); if (favorite == had) { if (FinalDossiers.TryGetValue(unitId, out UnitDossier existing) && existing != null) { existing.IsFavorite = favorite; } return; } if (favorite) { FavoriteIds.Add(unitId); } else { FavoriteIds.Remove(unitId); } if (FinalDossiers.TryGetValue(unitId, out UnitDossier dossier) && dossier != null) { dossier.IsFavorite = favorite; } BumpRevision(); } public static void SyncFavoriteFromActor(Actor actor) { if (actor == null) { return; } long id = 0; try { id = actor.getID(); } catch { return; } if (id == 0) { return; } bool fav = false; try { fav = actor.isAlive() && actor.isFavorite(); } catch { fav = false; } SetFavoriteSubject(id, fav); } /// Harness / tuning: change subject cap and prune immediately. public static void SetMaxHistorySubjects(int max) { MaxHistorySubjects = Mathf.Clamp(max, 40, AbsoluteMaxHistorySubjects); lock (Histories) { PruneHistorySubjectsUnlocked(); } PruneFinalDossiers(); BumpRevision(); } /// /// Harness: fill to and time Fallen/Living list rebuilds. /// Returns a single-line report of avg/max ms per size. /// public static string BenchSubjectLoad(int subjects, int eventsPerSubject = 3, int iters = 25) { subjects = Mathf.Clamp(subjects, 40, AbsoluteMaxHistorySubjects); eventsPerSubject = Mathf.Clamp(eventsPerSubject, 1, MaxHistoryPerUnit); iters = Mathf.Clamp(iters, 5, 200); int previousCap = MaxHistorySubjects; ClearSession(); SetMaxHistorySubjects(subjects); FillBenchSubjectsUnlocked(subjects, eventsPerSubject); // Warmup ListCharacters(max: 40, scope: CharacterListScope.Fallen); ListCharacters(max: 40, scope: CharacterListScope.Living); int have = HistorySubjectCount; double fallenSum = 0; double fallenMax = 0; double livingSum = 0; double livingMax = 0; for (int i = 0; i < iters; i++) { float t0 = Time.realtimeSinceStartup; ListCharacters(max: 40, scope: CharacterListScope.Fallen); float fallenMs = (Time.realtimeSinceStartup - t0) * 1000f; fallenSum += fallenMs; if (fallenMs > fallenMax) { fallenMax = fallenMs; } t0 = Time.realtimeSinceStartup; ListCharacters(max: 40, scope: CharacterListScope.Living); float livingMs = (Time.realtimeSinceStartup - t0) * 1000f; livingSum += livingMs; if (livingMs > livingMax) { livingMax = livingMs; } } float pruneMs; { float t0 = Time.realtimeSinceStartup; SetMaxHistorySubjects(Mathf.Max(40, subjects / 2)); pruneMs = (Time.realtimeSinceStartup - t0) * 1000f; } string report = $"n={subjects} events={eventsPerSubject} have={have} " + $"fallenAvgMs={fallenSum / iters:0.00} fallenMaxMs={fallenMax:0.00} " + $"livingAvgMs={livingSum / iters:0.00} livingMaxMs={livingMax:0.00} " + $"pruneMs={pruneMs:0.00}"; LogService.LogInfo("[IdleSpectator][BENCH] " + report); SetMaxHistorySubjects(previousCap); return report; } /// Fast synthetic fill for benches (skips lore rewrite / age refresh). private static void FillBenchSubjectsUnlocked(int subjects, int eventsPerSubject) { float now = Time.unscaledTime; lock (Histories) { for (int i = 0; i < subjects; i++) { long id = _orphanSeq--; var list = new List(eventsPerSubject); for (int e = 0; e < eventsPerSubject; e++) { list.Add(new ChronicleEntry { CreatedAt = now + (i * 0.0001f) + (e * 0.00001f), WorldTime = i, DateLabel = "Bench", Kind = ChronicleKind.Death, SubjectId = id, Name = "Bench" + i, SpeciesId = "human", Line = "Bench death", LoreLine = "Bench death", DeathManner = DeathManner.OldAge, AttackTypeName = "Age" }); } Histories[id] = list; FinalDossiers[id] = UnitDossier.FromFallenArchive( id, "Bench" + i, "human", DeathManner.OldAge); } PruneHistorySubjectsUnlocked(); } PruneFinalDossiers(); BumpRevision(); } public static IReadOnlyList SnapshotForFocus() { return SnapshotForSubject(CurrentHistorySubjectId()); } public static IReadOnlyList SnapshotForSubject(long subjectId) { if (subjectId == 0) { return System.Array.Empty(); } lock (Histories) { if (!Histories.TryGetValue(subjectId, out List list)) { return System.Array.Empty(); } return list.ToArray(); } } /// /// Count History rows for with CreatedAt inside the last /// (unscaled). Used by Character Ledger density. /// public static int RecentHistoryCount(long subjectId, float windowSeconds) { if (subjectId == 0 || windowSeconds <= 0f) { return 0; } IReadOnlyList snap = SnapshotForSubject(subjectId); if (snap.Count == 0) { return 0; } float cutoff = Time.unscaledTime - windowSeconds; int n = 0; for (int i = 0; i < snap.Count; i++) { ChronicleEntry e = snap[i]; if (e != null && e.CreatedAt >= cutoff) { n++; } } return n; } /// /// Collect distinct values from subject History. /// Newest rows first; stops at . /// public static void EnumerateRelatedIds(long subjectId, List into, int max = 8) { if (into == null) { return; } into.Clear(); if (subjectId == 0 || max <= 0) { return; } IReadOnlyList snap = SnapshotForSubject(subjectId); for (int i = snap.Count - 1; i >= 0 && into.Count < max; i--) { ChronicleEntry e = snap[i]; if (e == null || e.OtherId == 0 || e.OtherId == subjectId) { continue; } bool seen = false; for (int j = 0; j < into.Count; j++) { if (into[j] == e.OtherId) { seen = true; break; } } if (!seen) { into.Add(e.OtherId); } } } /// Newest-first slice for compact dossier HUD. public static IReadOnlyList LatestForSubject(long subjectId, int max) { if (max <= 0) { return System.Array.Empty(); } IReadOnlyList all = SnapshotForSubject(subjectId); if (all.Count == 0) { return all; } int take = Mathf.Min(max, all.Count); ChronicleEntry[] newest = new ChronicleEntry[take]; for (int i = 0; i < take; i++) { newest[i] = all[all.Count - 1 - i]; } return newest; } /// World Memory landmarks oldest→newest (HUD reverses for display). public static IReadOnlyList SnapshotWorld() { return SnapshotMemory(); } public static IReadOnlyList SnapshotMemory() { lock (MemoryLandmarks) { return MemoryLandmarks.ToArray(); } } /// Latest history entry for the current/last focus (harness + jump). public static ChronicleEntry Latest { get { IReadOnlyList list = SnapshotForFocus(); return list.Count == 0 ? null : list[list.Count - 1]; } } public static ChronicleEntry LatestWorld { get { lock (MemoryLandmarks) { return MemoryLandmarks.Count == 0 ? null : MemoryLandmarks[MemoryLandmarks.Count - 1]; } } } public static long CurrentHistorySubjectId() { if (MoveCamera.hasFocusUnit() && MoveCamera._focus_unit != null) { try { long id = MoveCamera._focus_unit.getID(); if (id != 0) { _lastHistorySubjectId = id; return id; } } catch { // fall through } } return _lastHistorySubjectId; } public static string CurrentHistoryTitle() { long id = CurrentHistorySubjectId(); if (id == 0) { return ""; } Actor unit = FindUnitById(id); if (unit != null) { return $"{SafeName(unit)} ({SpeciesOf(unit)})"; } IReadOnlyList list = SnapshotForSubject(id); if (list.Count > 0) { ChronicleEntry e = list[list.Count - 1]; if (!string.IsNullOrEmpty(e.Name)) { return string.IsNullOrEmpty(e.SpeciesId) ? e.Name : $"{e.Name} ({e.SpeciesId})"; } } return "Unit #" + id; } public static void EnsureWindow() { if (!ModSettings.ChronicleEnabled) { return; } if (_hudReady && ChronicleHud.IsReady) { return; } try { ChronicleHud.EnsureBuilt(); if (ChronicleHud.IsReady) { _hudReady = true; LogService.LogInfo("[IdleSpectator] World Memory HUD ready (L)"); } } catch (System.Exception ex) { LogService.LogInfo("[IdleSpectator] World Memory HUD init failed: " + ex.Message); } } public static void ClearSession() { lock (Histories) { Histories.Clear(); } lock (MemoryLandmarks) { MemoryLandmarks.Clear(); } LoverPairKeys.Clear(); FriendPairKeys.Clear(); DeathLogged.Clear(); FavoriteIds.Clear(); FinalDossiers.Clear(); LandmarkDedupe.Clear(); LandmarksPerAge.Clear(); RecentFocusIds.Clear(); _lastHistorySubjectId = 0; _trackedFocusId = 0; _currentAgeId = ""; _currentAgeName = ""; ActivityLog.ClearSession(); LifeSagaMemory.ClearSession(); BumpRevision(); // Keep _boundWorld so re-enable on same map does not loop-clear. } private static void BumpRevision() { unchecked { HistoryRevision++; } } public static void PollInput() { if (!Config.game_loaded || !ModSettings.ChronicleEnabled) { return; } EnsureWindow(); if (ChronicleHud.IsTypingSearch) { return; } if (Input.GetKeyDown(ToggleKey)) { ToggleWindow(); } } public static void ToggleWindow() { if (!ModSettings.ChronicleEnabled) { return; } EnsureWindow(); ChronicleHud.Toggle(); } public static void ShowHud() { EnsureWindow(); // Default into Living only when opening from World with no detail pinned. if (ChronicleHud.DetailUnitId == 0 && ChronicleHud.ActiveTab == ChronicleHud.LoreTab.World) { ChronicleHud.SetTab(ChronicleHud.LoreTab.Living); } ChronicleHud.SetVisible(true); } public static void HideHud() { ChronicleHud.SetVisible(false); } /// Harness: how the last JumpTo resolved (unit:id | place | none). public static string LastJumpDetail { get; private set; } = ""; /// Harness: world position used by the last place-style JumpTo. public static Vector3 LastJumpPosition { get; private set; } = Vector3.zero; public static bool JumpToLatest() { // Prefer a Memory landmark when it can resolve a unit/position; else focus history. ChronicleEntry memory = LatestWorld; if (memory != null && memory.Kind != ChronicleKind.AgeChapter && JumpTo(memory)) { return true; } return JumpTo(Latest); } public static bool JumpTo(ChronicleEntry entry) { LastJumpDetail = "none"; LastJumpPosition = Vector3.zero; if (entry == null || entry.Kind == ChronicleKind.AgeChapter) { return false; } // World Memory landmarks: always go to the recorded place when we have one, // even if the related unit is gone or has moved on. bool memoryPlaceFirst = entry.Kind == ChronicleKind.World || entry.IsLegend; if (memoryPlaceFirst && EntryHasWorldPosition(entry)) { CameraDirector.ClearFollow(); CameraDirector.PanTo(entry.Position); LastJumpDetail = "place"; LastJumpPosition = entry.Position; LogService.LogInfo( $"[IdleSpectator][MEMORY] jump place pos={entry.Position} line={entry.DisplayLine}"); return true; } Actor unit = FindUnitById(entry.SubjectId); if (unit != null && unit.isAlive()) { bool ok = FocusSubject(unit); if (ok) { LastJumpDetail = "unit:" + entry.SubjectId; } return ok; } if (entry.OtherId != 0) { Actor other = FindUnitById(entry.OtherId); if (other != null && other.isAlive()) { bool ok = FocusSubject(other); if (ok) { LastJumpDetail = "unit:" + entry.OtherId; } return ok; } } if (EntryHasWorldPosition(entry)) { CameraDirector.ClearFollow(); CameraDirector.PanTo(entry.Position); LastJumpDetail = "place"; LastJumpPosition = entry.Position; LogService.LogInfo( $"[IdleSpectator][MEMORY] jump place pos={entry.Position} line={entry.DisplayLine}"); return true; } return false; } /// Record a focus for the Character History "recent" list. public static void NoteFocus(Actor actor) { if (actor == null || !actor.isAlive()) { return; } long id; try { id = actor.getID(); } catch { return; } if (id == 0) { return; } NoteFocusId(id); SyncFavoriteFromActor(actor); } private static void NoteFocusId(long id) { if (id == 0) { return; } _lastHistorySubjectId = id; lock (RecentFocusIds) { RecentFocusIds.Remove(id); RecentFocusIds.Insert(0, id); while (RecentFocusIds.Count > MaxRecentFocus) { RecentFocusIds.RemoveAt(RecentFocusIds.Count - 1); } } } /// Focus a living unit and show its dossier (enables spectator if needed). public static string TitleForSubject(long subjectId) { if (subjectId == 0) { return ""; } Actor live = FindUnitById(subjectId); if (live != null) { return SafeName(live) + " (" + SpeciesOf(live) + ")"; } IReadOnlyList snap = SnapshotForSubject(subjectId); if (snap == null || snap.Count == 0) { return ""; } ChronicleEntry last = snap[snap.Count - 1]; if (last == null) { return ""; } string name = last.Name ?? ""; string species = last.SpeciesId ?? ""; if (string.IsNullOrEmpty(name)) { return species; } if (string.IsNullOrEmpty(species)) { return name; } return name + " (" + species + ")"; } /// Focus camera + dossier while idle is paused (does not re-enable spectator). public static bool FocusCameraForBrowse(long unitId) { Actor unit = FindUnitById(unitId); if (unit == null || !unit.isAlive()) { NoteFocusId(unitId); return false; } WatchCaption.PinWhilePaused(); CameraDirector.FocusUnit(unit); WatchCaption.SetFromActor(unit); NoteFocus(unit); return true; } public static bool FocusSubject(long unitId) { return FocusSubject(FindUnitById(unitId)); } public static bool FocusSubject(Actor unit) { if (unit == null || !unit.isAlive()) { return false; } if (!SpectatorMode.Active && ModSettings.Enabled) { SpectatorMode.SetActive(true); } CameraDirector.FocusUnit(unit); WatchCaption.SetFromActor(unit); NoteFocus(unit); return true; } /// Which cast the Characters browser should show. public enum CharacterListScope { Living, Fallen } /// /// Living: favorites, then recently watched, then by event count. /// Fallen: newest death / event first. /// public static List ListCharacters( string search = "", bool favoritesOnly = false, int max = 40, CharacterListScope scope = CharacterListScope.Living) { var byId = new Dictionary(); string needle = (search ?? "").Trim().ToLowerInvariant(); bool wantLiving = scope == CharacterListScope.Living; bool wantFallen = scope == CharacterListScope.Fallen; void Upsert(Actor actor, bool recent, int recentIndex) { if (!wantLiving || actor == null || !actor.isAlive()) { return; } long id; try { id = actor.getID(); } catch { return; } if (id == 0) { return; } bool fav = false; try { fav = actor.isFavorite(); } catch { fav = false; } if (favoritesOnly && !fav) { return; } if (fav) { FavoriteIds.Add(id); } else { FavoriteIds.Remove(id); } string name = SafeName(actor); string species = SpeciesOf(actor); if (!string.IsNullOrEmpty(needle)) { string hay = ((name ?? "") + " " + (species ?? "")).ToLowerInvariant(); if (hay.IndexOf(needle, System.StringComparison.Ordinal) < 0) { return; } } if (!byId.TryGetValue(id, out ChronicleCharacterSummary row)) { row = new ChronicleCharacterSummary { UnitId = id, Name = name, SpeciesId = species, HistoryCount = HistoryCountFor(id), IsFavorite = fav, IsAlive = true, SortTime = LatestActivityTime(id) }; byId[id] = row; } if (recent) { row.IsRecent = true; if (row.RecentIndex < 0 || recentIndex < row.RecentIndex) { row.RecentIndex = recentIndex; } } row.IsFavorite = fav; row.HistoryCount = HistoryCountFor(id); row.SortTime = LatestActivityTime(id); } if (wantLiving) { List recentCopy; lock (RecentFocusIds) { recentCopy = new List(RecentFocusIds); } for (int i = 0; i < recentCopy.Count; i++) { Upsert(FindUnitById(recentCopy[i]), recent: true, recentIndex: i); } foreach (Actor actor in WorldActivityScanner.EnumerateAliveUnitsPublic()) { if (actor == null || !actor.isAlive()) { continue; } bool fav = false; try { fav = actor.isFavorite(); } catch { fav = false; } long id = 0; try { id = actor.getID(); } catch { continue; } bool hasHist = HistoryCountFor(id) > 0; if (!fav && !hasHist && !byId.ContainsKey(id)) { continue; } Upsert(actor, recent: false, recentIndex: -1); } } if (wantFallen) { // History subjects that may still be named from entries even if not matched above. List histIds; lock (Histories) { histIds = new List(Histories.Keys); } for (int i = 0; i < histIds.Count; i++) { long id = histIds[i]; if (byId.ContainsKey(id)) { continue; } Actor live = FindUnitById(id); if (live != null && live.isAlive()) { continue; } if (favoritesOnly) { if (!IsFavoriteSubject(id) && !(FinalDossiers.TryGetValue(id, out UnitDossier favDossier) && favDossier != null && favDossier.IsFavorite)) { continue; } } IReadOnlyList snap = SnapshotForSubject(id); if (snap == null || snap.Count == 0) { continue; } ChronicleEntry last = snap[snap.Count - 1]; string name = last?.Name ?? ""; string species = last?.SpeciesId ?? ""; if (!string.IsNullOrEmpty(needle)) { string hay = (name + " " + species).ToLowerInvariant(); if (hay.IndexOf(needle, System.StringComparison.Ordinal) < 0) { continue; } } bool fallenFav = IsFavoriteSubject(id); if (!fallenFav && FinalDossiers.TryGetValue(id, out UnitDossier archived) && archived != null) { fallenFav = archived.IsFavorite; } byId[id] = new ChronicleCharacterSummary { UnitId = id, Name = name, SpeciesId = species, HistoryCount = snap.Count, IsFavorite = fallenFav, IsAlive = false, IsRecent = false, DeathManner = LatestDeathMannerFor(id), SortTime = LatestActivityTime(id) }; } } var list = new List(byId.Values); list.Sort((a, b) => { // Favorites first on both Living and Fallen. if (a.IsFavorite != b.IsFavorite) { return a.IsFavorite ? -1 : 1; } if (wantFallen || (!a.IsAlive && !b.IsAlive)) { // Fallen: most recent event first. if (a.SortTime != b.SortTime) { return b.SortTime.CompareTo(a.SortTime); } if (a.HistoryCount != b.HistoryCount) { return b.HistoryCount.CompareTo(a.HistoryCount); } return string.Compare(a.Title, b.Title, System.StringComparison.OrdinalIgnoreCase); } // Living: recently watched, event count, name. int ra = a.IsRecent && a.RecentIndex >= 0 ? a.RecentIndex : int.MaxValue; int rb = b.IsRecent && b.RecentIndex >= 0 ? b.RecentIndex : int.MaxValue; if (ra != rb) { return ra.CompareTo(rb); } if (a.HistoryCount != b.HistoryCount) { return b.HistoryCount.CompareTo(a.HistoryCount); } return string.Compare(a.Title, b.Title, System.StringComparison.OrdinalIgnoreCase); }); if (max > 0 && list.Count > max) { list.RemoveRange(max, list.Count - max); } return list; } private static float LatestActivityTime(long subjectId) { IReadOnlyList snap = SnapshotForSubject(subjectId); float best = 0f; for (int i = 0; i < snap.Count; i++) { ChronicleEntry e = snap[i]; if (e != null && e.CreatedAt > best) { best = e.CreatedAt; } } return best; } public static void Update() { if (!ModSettings.ChronicleEnabled || !Config.game_loaded || World.world == null) { return; } RefreshAgeChapter(force: false); if (MoveCamera.hasFocusUnit() && MoveCamera._focus_unit != null) { try { long id = MoveCamera._focus_unit.getID(); if (id != 0 && id != _trackedFocusId) { _trackedFocusId = id; NoteFocusId(id); } } catch { // ignore } } ChronicleHud.UpdateLive(); } /// Killer POV - Chronicle Life when enabled; always mirrors to Activity. public static void NoteKill(Actor killer, Actor victim) { if (killer == null || victim == null) { return; } string victimName = SafeName(victim); string line = $"Killed {victimName} ({SpeciesOf(victim)})"; long victimId = EventFeedUtil.SafeId(victim); ActivityLog.NoteMilestone(killer, "milestone_kill", line, victimName, victimId); try { HappinessEventRouter.NoteCanonicalMilestone(killer.getID(), "milestone_kill"); } catch { // ignore } if (ModSettings.ChronicleEnabled) { AppendHistory(ChronicleKind.Kill, killer, victim, line); } try { InterestFeeds.OnChronicleMilestone(killer, "milestone_kill", victim, line); } catch { // ignore } } /// Victim POV - Chronicle Life when enabled; always mirrors to Activity. public static void NoteDeath(Actor victim, AttackType attackType, Actor killer) { if (victim == null) { return; } long id = 0; try { id = victim.getID(); } catch { return; } if (id == 0 || !DeathLogged.Add(id)) { return; } string line = FormatDeathCause(victim, attackType, killer); string killerName = killer != null ? SafeName(killer) : ""; long killerId = EventFeedUtil.SafeId(killer); ActivityLog.NoteMilestone(victim, "milestone_death", line, killerName, killerId); if (ModSettings.ChronicleEnabled) { AppendHistory(ChronicleKind.Death, victim, killer, line, attackType); } } public static void NoteLovers(Actor a, Actor b) { if (a == null || b == null) { return; } string key = PairKey(a.getID(), b.getID()); if (!LoverPairKeys.Add(key)) { return; } CapPairKeys(LoverPairKeys); 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, EventFeedUtil.SafeId(b)); ActivityLog.NoteMilestone(b, "milestone_lover", lineB, nameA, EventFeedUtil.SafeId(a)); try { HappinessEventRouter.NoteCanonicalMilestone(a.getID(), "milestone_lover"); HappinessEventRouter.NoteCanonicalMilestone(b.getID(), "milestone_lover"); } catch { // ignore } if (ModSettings.ChronicleEnabled) { AppendHistory(ChronicleKind.Lover, a, b, lineA); AppendHistory(ChronicleKind.Lover, b, a, lineB); } try { InterestFeeds.OnChronicleMilestone(a, "milestone_lover", b, lineA); } catch { // ignore } } public static void NoteBestFriends(Actor a, Actor b) { if (a == null || b == null) { return; } string key = PairKey(a.getID(), b.getID()); if (!FriendPairKeys.Add(key)) { return; } CapPairKeys(FriendPairKeys); string nameA = SafeName(a); string nameB = SafeName(b); string lineA = $"Befriended {nameB}"; string lineB = $"Befriended {nameA}"; ActivityLog.NoteMilestone(a, "milestone_friend", lineA, nameB, EventFeedUtil.SafeId(b)); ActivityLog.NoteMilestone(b, "milestone_friend", lineB, nameA, EventFeedUtil.SafeId(a)); try { HappinessEventRouter.NoteCanonicalMilestone(a.getID(), "milestone_friend"); HappinessEventRouter.NoteCanonicalMilestone(b.getID(), "milestone_friend"); } catch { // ignore } if (ModSettings.ChronicleEnabled) { AppendHistory(ChronicleKind.Friend, a, b, lineA); AppendHistory(ChronicleKind.Friend, b, a, lineB); } try { InterestFeeds.OnChronicleMilestone(a, "milestone_friend", b, lineA); } catch { // ignore } } /// /// Durable happiness Life projection (birth, grief, roles, housing, book/plot, injury). /// Canonical lover/friend/kill milestones stay on their own paths. /// public static bool NoteHappinessLife(HappinessOccurrence occ, Actor subject, Actor related = null) { if (occ == null || subject == null || string.IsNullOrEmpty(occ.PlainClause)) { return false; } if (!ModSettings.ChronicleEnabled) { return false; } string line = char.ToUpperInvariant(occ.PlainClause[0]) + occ.PlainClause.Substring(1); AppendHistory(ChronicleKind.Other, subject, related, line); return true; } public static void NoteWorldLog(WorldLogMessage message, string label) { if (!ModSettings.ChronicleEnabled || message == null || string.IsNullOrEmpty(label)) { return; } Actor unit = null; try { if (message.hasFollowLocation()) { unit = message.unit; } } catch { // ignore } Vector3 pos = Vector3.zero; try { pos = message.getLocation(); } catch { // ignore } string assetId = message.asset_id ?? ""; AppendLandmark( ChronicleKind.World, unit, null, label, LoreProse.Rewrite(ChronicleKind.World, label, assetId), pos, assetId, isLegend: false); } /// Harness: inject a life-event style line on the focused unit's History. public static bool ForceNoteCurrentFocus(string prefix = "Killed") { if (!MoveCamera.hasFocusUnit() || MoveCamera._focus_unit == null) { return false; } Actor unit = MoveCamera._focus_unit; string kindPrefix = string.IsNullOrEmpty(prefix) ? "Killed" : prefix; string line; if (kindPrefix.Length <= 24 && kindPrefix.IndexOf(' ') < 0) { UnitDossier dossier = UnitDossier.FromActor(unit); line = $"{kindPrefix}: {dossier.Name} ({dossier.SpeciesId}) - life event"; } else { line = kindPrefix; } AppendHistory(ChronicleKind.Other, unit, null, line); 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; /// /// Harness: inject history for a subject id with no living unit (Fallen list). /// public static long ForceOrphanHistory( string name, string species, int count, string labelPrefix = "Fallen", AttackType attackType = AttackType.Age, long killerId = 0, Vector3 deathPos = default, bool favorite = false) { count = Mathf.Clamp(count, 1, MaxHistoryPerUnit); long id; lock (Histories) { id = _orphanSeq--; } string who = string.IsNullOrEmpty(name) ? "Nameless" : name; string sp = string.IsNullOrEmpty(species) ? "creature" : species; string prefix = string.IsNullOrEmpty(labelPrefix) ? "Fallen" : labelPrefix; bool hasKiller = killerId != 0; DeathManner manner = ClassifyDeathManner(attackType, hasKiller); Actor killerActor = hasKiller ? FindUnitById(killerId) : null; string baseCause = FormatDeathCause(null, attackType, killerActor); RefreshAgeChapter(force: false); StampWorldDate(out double worldTime, out string dateLabel); lock (Histories) { if (!Histories.TryGetValue(id, out List list)) { list = new List(); Histories[id] = list; } for (int i = 0; i < count; i++) { string line = count > 1 ? $"{baseCause} ({prefix} #{i + 1})" : baseCause; string lore = LoreProse.Rewrite(ChronicleKind.Death, line); list.Add(new ChronicleEntry { CreatedAt = Time.unscaledTime, WorldTime = worldTime, DateLabel = dateLabel, Kind = ChronicleKind.Death, SubjectId = id, OtherId = killerId, Name = who, SpeciesId = sp, Line = line, LoreLine = lore, Position = deathPos, HasWorldPosition = deathPos.sqrMagnitude > 0.0001f, AgeId = _currentAgeId, AgeName = _currentAgeName, AttackTypeName = attackType.ToString(), DeathManner = manner }); } while (list.Count > MaxHistoryPerUnit) { list.RemoveAt(0); } PruneHistorySubjectsUnlocked(); } BumpRevision(); UnitDossier archived = UnitDossier.FromFallenArchive(id, who, sp, manner); archived.IsFavorite = favorite; FinalDossiers[id] = archived; if (favorite) { FavoriteIds.Add(id); } PruneFinalDossiers(); return id; } /// Harness: inject a World Memory landmark (does not touch character History). public static bool ForceWorldNote(string label) { if (string.IsNullOrEmpty(label)) { return false; } Vector3 pos = Vector3.zero; if (MoveCamera.hasFocusUnit() && MoveCamera._focus_unit != null) { try { pos = MoveCamera._focus_unit.current_position; } catch { pos = Vector3.zero; } } if (pos.sqrMagnitude < 0.0001f && MoveCamera.instance != null) { pos = MoveCamera.instance.transform.position; pos.z = 0f; } if (pos.sqrMagnitude < 0.0001f) { pos = new Vector3(48f, 48f, 0f); } AppendLandmark( ChronicleKind.World, null, null, label, LoreProse.Rewrite(ChronicleKind.World, label), pos, "harness", isLegend: false); return true; } /// Harness: append a formatted death-cause line for the focused unit. public static bool ForceDeathSample(string attackTypeName, Actor killer = null) { if (!MoveCamera.hasFocusUnit() || MoveCamera._focus_unit == null) { return false; } if (!System.Enum.TryParse(attackTypeName, ignoreCase: true, out AttackType attackType)) { return false; } Actor victim = MoveCamera._focus_unit; string line = FormatDeathCause(victim, attackType, killer); AppendHistory(ChronicleKind.Death, victim, killer, line, attackType); return true; } public static DeathManner ClassifyDeathManner(AttackType attackType, bool hasKiller) { switch (attackType) { case AttackType.Age: return DeathManner.OldAge; case AttackType.Starvation: return DeathManner.Starvation; case AttackType.Drowning: case AttackType.Water: return DeathManner.Drowned; case AttackType.Fire: return DeathManner.Burned; case AttackType.Plague: case AttackType.Infection: case AttackType.Poison: case AttackType.Tumor: case AttackType.AshFever: return DeathManner.Plague; case AttackType.Divine: return DeathManner.Divine; case AttackType.Gravity: case AttackType.Explosion: case AttackType.Acid: case AttackType.Metamorphosis: return DeathManner.Accident; case AttackType.Eaten: case AttackType.Weapon: case AttackType.Smile: case AttackType.Other: return hasKiller ? DeathManner.Slain : DeathManner.Other; case AttackType.None: return hasKiller ? DeathManner.Slain : DeathManner.Other; default: return hasKiller ? DeathManner.Slain : DeathManner.Other; } } public static DeathManner InferDeathManner(string attackTypeName, bool hasKiller, string line) { if (!string.IsNullOrEmpty(attackTypeName) && System.Enum.TryParse(attackTypeName, ignoreCase: true, out AttackType parsed)) { return ClassifyDeathManner(parsed, hasKiller); } string raw = line ?? ""; if (raw.IndexOf("old age", System.StringComparison.OrdinalIgnoreCase) >= 0) { return DeathManner.OldAge; } if (raw.IndexOf("Starved", System.StringComparison.OrdinalIgnoreCase) >= 0) { return DeathManner.Starvation; } if (raw.IndexOf("Drown", System.StringComparison.OrdinalIgnoreCase) >= 0) { return DeathManner.Drowned; } if (raw.IndexOf("Burn", System.StringComparison.OrdinalIgnoreCase) >= 0 || raw.IndexOf("lava", System.StringComparison.OrdinalIgnoreCase) >= 0) { return DeathManner.Burned; } if (raw.IndexOf("plague", System.StringComparison.OrdinalIgnoreCase) >= 0 || raw.IndexOf("poison", System.StringComparison.OrdinalIgnoreCase) >= 0 || raw.IndexOf("infection", System.StringComparison.OrdinalIgnoreCase) >= 0 || raw.IndexOf("tumor", System.StringComparison.OrdinalIgnoreCase) >= 0 || raw.IndexOf("ash fever", System.StringComparison.OrdinalIgnoreCase) >= 0) { return DeathManner.Plague; } if (raw.IndexOf("divine", System.StringComparison.OrdinalIgnoreCase) >= 0) { return DeathManner.Divine; } if (raw.IndexOf("explosion", System.StringComparison.OrdinalIgnoreCase) >= 0 || raw.IndexOf("acid", System.StringComparison.OrdinalIgnoreCase) >= 0 || raw.IndexOf("Fell to", System.StringComparison.OrdinalIgnoreCase) >= 0) { return DeathManner.Accident; } if (hasKiller || raw.IndexOf("Killed by", System.StringComparison.OrdinalIgnoreCase) >= 0 || raw.IndexOf("Eaten by", System.StringComparison.OrdinalIgnoreCase) >= 0 || raw.IndexOf("Was eaten", System.StringComparison.OrdinalIgnoreCase) >= 0) { return DeathManner.Slain; } return DeathManner.Other; } /// Capitalized manner label for Lore / dossier (matches grave subtitle style). public static string DeathMannerLabel(DeathManner manner) { switch (manner) { case DeathManner.Slain: return "Slain"; case DeathManner.OldAge: return "Old Age"; case DeathManner.Starvation: return "Starved"; case DeathManner.Drowned: return "Drowned"; case DeathManner.Burned: return "Burned"; case DeathManner.Plague: return "Plague"; case DeathManner.Accident: return "Accident"; case DeathManner.Divine: return "Divine"; case DeathManner.Other: return "Dead"; default: return "Fallen"; } } /// /// Short capitalized grave / Fallen-list flavor, AttackType-specific when known. /// public static string FormatGraveDeathSubtitle(long unitId) { ChronicleEntry death = LatestDeathEntry(unitId); if (death == null) { return "Dead"; } string killer = ResolveKillerDisplayName(death); if (!string.IsNullOrEmpty(death.AttackTypeName) && System.Enum.TryParse(death.AttackTypeName, ignoreCase: true, out AttackType attackType)) { return FormatGraveFlavor(attackType, killer, death.Line); } return FormatGraveSubtitleFromAttackType(death.AttackTypeName, killer); } /// Punchy Title-Case manner for a known . public static string FormatGraveFlavor(AttackType attackType, string killer, string deathLine = "") { string k = killer ?? ""; bool hasKiller = !string.IsNullOrEmpty(k); string line = deathLine ?? ""; bool lava = line.IndexOf("lava", System.StringComparison.OrdinalIgnoreCase) >= 0; switch (attackType) { case AttackType.Age: return "Faded With Age"; case AttackType.Starvation: return "Wasted From Hunger"; case AttackType.Fire: return lava ? "Swallowed By Lava" : "Burned To Ash"; case AttackType.Drowning: case AttackType.Water: return "Lost To The Deep"; case AttackType.Poison: return "Poison Took Them"; case AttackType.Plague: return "Claimed By Plague"; case AttackType.Infection: return "Rotted From Within"; case AttackType.Tumor: return "Consumed By A Tumor"; case AttackType.Acid: return "Dissolved In Acid"; case AttackType.AshFever: return "Taken By Ash Fever"; case AttackType.Gravity: return "Fell From The Heights"; case AttackType.Explosion: return "Blown Apart"; case AttackType.Divine: return "Smote By The Gods"; case AttackType.Eaten: return hasKiller ? "Devoured by " + k : "Devoured"; case AttackType.Weapon: return hasKiller ? "Slain by " + k : "Fell In Battle"; case AttackType.Smile: return hasKiller ? "Slain by " + k : "Died Smiling"; case AttackType.Other: return hasKiller ? "Slain by " + k : "Met A Strange End"; case AttackType.Metamorphosis: return "Transformed Away"; case AttackType.None: return hasKiller ? "Slain by " + k : "Met Their End"; default: return hasKiller ? "Slain by " + k : "Dead"; } } private static string ResolveKillerDisplayName(ChronicleEntry death) { if (death == null || death.OtherId == 0) { return ""; } Actor killer = FindUnitById(death.OtherId); if (killer != null) { return SafeName(killer); } return TryExtractKillerFromLine(death.Line); } private static string TryExtractKillerFromLine(string line) { if (string.IsNullOrEmpty(line)) { return ""; } string[] prefixes = { "Was cut down by ", "Was torn apart and eaten by ", "Was slain by ", "Died with a smile at ", "Killed by ", "Eaten by ", "Slain by ", "Devoured by " }; for (int i = 0; i < prefixes.Length; i++) { string prefix = prefixes[i]; int at = line.IndexOf(prefix, System.StringComparison.OrdinalIgnoreCase); if (at < 0) { continue; } string rest = line.Substring(at + prefix.Length).Trim(); if (prefix.IndexOf("smile", System.StringComparison.OrdinalIgnoreCase) >= 0) { int hand = rest.IndexOf("'s hand", System.StringComparison.OrdinalIgnoreCase); if (hand > 0) { rest = rest.Substring(0, hand).Trim(); } } int paren = rest.IndexOf(" (", System.StringComparison.Ordinal); if (paren > 0) { rest = rest.Substring(0, paren).Trim(); } return rest; } return ""; } private static string FormatGraveSubtitleFromAttackType(string attackTypeName, string killer) { string t = (attackTypeName ?? "").Trim(); if (!string.IsNullOrEmpty(t) && System.Enum.TryParse(t, ignoreCase: true, out AttackType attackType)) { return FormatGraveFlavor(attackType, killer); } return string.IsNullOrEmpty(killer) ? "Dead" : "Slain by " + killer; } /// Newest death event for a subject, if any. public static ChronicleEntry LatestDeathEntry(long subjectId) { IReadOnlyList snap = SnapshotForSubject(subjectId); for (int i = snap.Count - 1; i >= 0; i--) { ChronicleEntry e = snap[i]; if (e != null && e.Kind == ChronicleKind.Death) { return e; } } return null; } public static DeathManner LatestDeathMannerFor(long subjectId) { ChronicleEntry death = LatestDeathEntry(subjectId); return death != null ? death.ResolvedDeathManner : DeathManner.None; } /// Harness: how the last fallen focus resolved (killer:id | place | none). public static string LastFallenFocusDetail { get; private set; } = ""; /// Harness: world position used for the last place-style fallen focus. public static Vector3 LastFallenFocusPosition { get; private set; } = Vector3.zero; /// /// Pause banner after fallen Lore open - must say when camera is on the killer. /// public static string FallenPauseBanner() { string detail = LastFallenFocusDetail ?? ""; if (detail.StartsWith("killer:", System.StringComparison.Ordinal)) { string killerLabel = ""; if (detail.Length > 7 && long.TryParse(detail.Substring(7), out long killerId) && killerId != 0) { Actor killer = FindUnitById(killerId); if (killer != null) { killerLabel = SafeName(killer); } } if (!string.IsNullOrEmpty(killerLabel)) { return "Paused (viewing fallen - camera on killer " + killerLabel + ")"; } return "Paused (viewing fallen - camera on killer)"; } return "Paused (viewing fallen)"; } /// Snapshot living dossier just before die() - for Fallen archive UI. public static void CaptureFinalDossier(Actor victim) { if (victim == null || !victim.isAlive()) { return; } long id = 0; try { id = victim.getID(); } catch { return; } if (id == 0) { return; } UnitDossier dossier = UnitDossier.FromActor(victim); if (dossier == null || dossier.UnitId == 0) { return; } FinalDossiers[id] = dossier; if (dossier.IsFavorite) { FavoriteIds.Add(id); } PruneFinalDossiers(); } /// Last living dossier for a fallen subject, or a minimal archive rebuild. public static UnitDossier ResolveFallenDossier(long unitId) { if (unitId == 0) { return null; } if (FinalDossiers.TryGetValue(unitId, out UnitDossier cached) && cached != null) { return cached; } ChronicleEntry death = LatestDeathEntry(unitId); ChronicleEntry any = death; if (any == null) { IReadOnlyList snap = SnapshotForSubject(unitId); if (snap != null && snap.Count > 0) { any = snap[snap.Count - 1]; } } if (any == null) { return null; } DeathManner manner = death != null ? death.ResolvedDeathManner : DeathManner.None; UnitDossier rebuilt = UnitDossier.FromFallenArchive(unitId, any.Name, any.SpeciesId, manner); FinalDossiers[unitId] = rebuilt; return rebuilt; } /// /// Camera for a dead subject. /// When is true (default), pan to death place / skull (no unit follow) /// and bind the dossier to the fallen unit. Killer follow is only via . /// public static bool FocusFallenSubject(long unitId, bool preferPlace = true) { LastFallenFocusDetail = "none"; LastFallenFocusPosition = Vector3.zero; if (unitId == 0) { return false; } ChronicleEntry death = LatestDeathEntry(unitId); if (!preferPlace && death != null && death.OtherId != 0) { Actor killer = FindUnitById(death.OtherId); if (killer != null && killer.isAlive()) { return FocusFallenKiller(unitId); } } UnitDossier fallen = ResolveFallenDossier(unitId); if (fallen != null) { WatchCaption.PinWhilePaused(); WatchCaption.SetFromDossier(fallen); } // Place / skull path: never keep following a living unit (e.g. prior killer focus). CameraDirector.ClearFollow(); if (TryResolveFallenPlace(unitId, death, out Vector3 pos)) { CameraDirector.PanTo(pos); LastFallenFocusDetail = "place"; LastFallenFocusPosition = pos; LogService.LogInfo($"[IdleSpectator][LORE] fallen focus place victim={unitId} pos={pos}"); return true; } LastFallenFocusDetail = "place"; return fallen != null; } /// Living killer for a fallen subject, or null. public static Actor FindLivingKillerFor(long unitId) { if (unitId == 0) { return null; } ChronicleEntry death = LatestDeathEntry(unitId); if (death == null || death.OtherId == 0) { return null; } Actor killer = FindUnitById(death.OtherId); if (killer == null || !killer.isAlive()) { return null; } return killer; } /// /// Camera follow + dossier on the living killer of a fallen Lore subject. /// Lore detail stays on the fallen archive; only the idle dossier retargets. /// public static bool FocusFallenKiller(long unitId) { Actor killer = FindLivingKillerFor(unitId); if (killer == null) { return false; } CameraDirector.FocusUnit(killer, updateCaption: false); WatchCaption.PinWhilePaused(); WatchCaption.SetFromActor(killer); LastFallenFocusDetail = "killer:" + killer.getID(); LastFallenFocusPosition = Vector3.zero; LogService.LogInfo( $"[IdleSpectator][LORE] focus killer id={killer.getID()} victim={unitId}"); return true; } private static bool EntryHasWorldPosition(ChronicleEntry e) { if (e == null) { return false; } if (e.HasWorldPosition) { return true; } // Legacy session entries before HasWorldPosition existed. return e.Position.sqrMagnitude > 0.0001f; } private static bool TryResolveFallenPlace(long unitId, ChronicleEntry death, out Vector3 pos) { pos = Vector3.zero; if (EntryHasWorldPosition(death)) { pos = death.Position; return true; } IReadOnlyList snap = SnapshotForSubject(unitId); for (int i = snap.Count - 1; i >= 0; i--) { if (EntryHasWorldPosition(snap[i])) { pos = snap[i].Position; return true; } } return false; } public static string FormatDeathCause(Actor victim, AttackType attackType, Actor killer) { string killerLabel = killer != null ? $"{SafeName(killer)} ({SpeciesOf(killer)})" : ""; bool hasKiller = !string.IsNullOrEmpty(killerLabel); switch (attackType) { case AttackType.Age: return "Faded gently into old age"; case AttackType.Starvation: return "Wasted away from hunger"; case AttackType.Fire: return IsOnLava(victim) ? "Was swallowed by lava" : "Burned until only ash remained"; case AttackType.Drowning: case AttackType.Water: return "Sank beneath the water and never rose"; case AttackType.Poison: return "Succumbed as poison flooded their veins"; case AttackType.Plague: return "Was claimed by a spreading plague"; case AttackType.Infection: return "Rotted from within as infection took hold"; case AttackType.Tumor: return "Was consumed by a growing tumor"; case AttackType.Acid: return "Dissolved screaming in acid"; case AttackType.AshFever: return "Burned out from ash fever"; case AttackType.Gravity: return "Fell from a height and broke upon the ground"; case AttackType.Explosion: return "Was torn apart in an explosion"; case AttackType.Divine: return "Was smote by divine power"; case AttackType.Eaten: return hasKiller ? $"Was torn apart and eaten by {killerLabel}" : "Was torn apart and eaten"; case AttackType.Weapon: return hasKiller ? $"Was cut down by {killerLabel}" : "Fell in bitter combat"; case AttackType.Smile: return hasKiller ? $"Died with a smile at {killerLabel}'s hand" : "Died with a strange smile"; case AttackType.Other: return hasKiller ? $"Was slain by {killerLabel}" : "Met a strange and sudden end"; case AttackType.Metamorphosis: return "Transformed away and left no body behind"; case AttackType.None: return hasKiller ? $"Was slain by {killerLabel}" : "Met their end"; default: return hasKiller ? $"Was slain by {killerLabel}" : $"Died ({attackType})"; } } private static bool IsOnLava(Actor victim) { if (victim == null) { return false; } try { WorldTile tile = victim.current_tile; return tile != null && tile.Type != null && tile.Type.lava; } catch { return false; } } private static void AppendHistory( ChronicleKind kind, Actor subject, Actor other, string line, AttackType? attackType = null) { if (subject == null || string.IsNullOrEmpty(line)) { return; } long subjectId = subject.getID(); if (subjectId == 0) { return; } RefreshAgeChapter(force: false); StampWorldDate(out double worldTime, out string dateLabel); string lore = LoreProse.Rewrite(kind, line); ChronicleEntry entry = new ChronicleEntry { CreatedAt = Time.unscaledTime, WorldTime = worldTime, DateLabel = dateLabel, Kind = kind, SubjectId = subjectId, OtherId = other != null ? other.getID() : 0, Name = SafeName(subject), SpeciesId = SpeciesOf(subject), Line = line, LoreLine = lore, Position = subject.current_position, HasWorldPosition = true, AgeId = _currentAgeId, AgeName = _currentAgeName }; if (kind == ChronicleKind.Death) { bool hasKiller = other != null; if (attackType.HasValue) { entry.AttackTypeName = attackType.Value.ToString(); entry.DeathManner = ClassifyDeathManner(attackType.Value, hasKiller); } else { entry.DeathManner = InferDeathManner("", hasKiller, line); } } lock (Histories) { if (!Histories.TryGetValue(subjectId, out List list)) { list = new List(); Histories[subjectId] = list; } list.Add(entry); while (list.Count > MaxHistoryPerUnit) { list.RemoveAt(0); } PruneHistorySubjectsUnlocked(); } _lastHistorySubjectId = subjectId; BumpRevision(); } /// /// Drop oldest dormant subjects when over . /// Caller must hold the Histories lock. /// private static void PruneHistorySubjectsUnlocked() { if (Histories.Count <= MaxHistorySubjects) { return; } var keep = new HashSet(); if (_trackedFocusId != 0) { keep.Add(_trackedFocusId); } if (_lastHistorySubjectId != 0) { keep.Add(_lastHistorySubjectId); } lock (RecentFocusIds) { for (int i = 0; i < RecentFocusIds.Count; i++) { keep.Add(RecentFocusIds[i]); } } var ranked = new List<(long id, float lastAt)>(Histories.Count); foreach (KeyValuePair> kv in Histories) { if (keep.Contains(kv.Key)) { continue; } float lastAt = 0f; List list = kv.Value; if (list != null && list.Count > 0) { ChronicleEntry last = list[list.Count - 1]; if (last != null) { lastAt = last.CreatedAt; } } ranked.Add((kv.Key, lastAt)); } ranked.Sort((a, b) => a.lastAt.CompareTo(b.lastAt)); int need = Histories.Count - MaxHistorySubjects; for (int i = 0; i < ranked.Count && need > 0; i++) { long id = ranked[i].id; Histories.Remove(id); DeathLogged.Remove(id); FinalDossiers.Remove(id); FavoriteIds.Remove(id); need--; } } private static void PruneFinalDossiers() { if (FinalDossiers.Count <= MaxHistorySubjects) { return; } HashSet histIds; lock (Histories) { histIds = new HashSet(Histories.Keys); } var orphan = new List(); foreach (long id in FinalDossiers.Keys) { if (!histIds.Contains(id)) { orphan.Add(id); } } for (int i = 0; i < orphan.Count; i++) { FinalDossiers.Remove(orphan[i]); } if (FinalDossiers.Count <= MaxHistorySubjects) { return; } // Still over: drop extras not recently focused. var protect = new HashSet(); if (_trackedFocusId != 0) { protect.Add(_trackedFocusId); } if (_lastHistorySubjectId != 0) { protect.Add(_lastHistorySubjectId); } lock (RecentFocusIds) { for (int i = 0; i < RecentFocusIds.Count; i++) { protect.Add(RecentFocusIds[i]); } } var drop = new List(); foreach (long id in FinalDossiers.Keys) { if (protect.Contains(id)) { continue; } drop.Add(id); if (FinalDossiers.Count - drop.Count <= MaxHistorySubjects) { break; } } for (int i = 0; i < drop.Count; i++) { FinalDossiers.Remove(drop[i]); } } private static void CapPairKeys(HashSet keys) { if (keys == null || keys.Count <= MaxPairKeys) { return; } // Dedupes are best-effort; clearing under pressure avoids unbounded growth. keys.Clear(); } private static void CapLandmarkDedupe() { if (LandmarkDedupe.Count <= MaxLandmarkDedupe) { return; } LandmarkDedupe.Clear(); } private static void AppendLandmark( ChronicleKind kind, Actor subject, Actor other, string rawLine, string loreLine, Vector3 fallbackPosition, string assetId, bool isLegend) { if (string.IsNullOrEmpty(rawLine) && string.IsNullOrEmpty(loreLine)) { return; } RefreshAgeChapter(force: false); string ageId = string.IsNullOrEmpty(_currentAgeId) ? "unknown" : _currentAgeId; string dedupeKey = ageId + "|" + assetId + "|" + rawLine; if (!LandmarkDedupe.Add(dedupeKey)) { return; } CapLandmarkDedupe(); lock (MemoryLandmarks) { if (!LandmarksPerAge.TryGetValue(ageId, out int ageCount)) { ageCount = 0; } if (ageCount >= MaxLandmarksPerAge) { LandmarkDedupe.Remove(dedupeKey); return; } StampWorldDate(out double worldTime, out string dateLabel); ChronicleEntry entry = new ChronicleEntry { CreatedAt = Time.unscaledTime, WorldTime = worldTime, DateLabel = dateLabel, Kind = kind, SubjectId = subject != null ? subject.getID() : 0, OtherId = other != null ? other.getID() : 0, Name = subject != null ? SafeName(subject) : "", SpeciesId = subject != null ? SpeciesOf(subject) : "", Line = rawLine ?? "", LoreLine = string.IsNullOrEmpty(loreLine) ? (rawLine ?? "") : loreLine, Position = subject != null ? subject.current_position : fallbackPosition, HasWorldPosition = subject != null || fallbackPosition.sqrMagnitude > 0.0001f, AssetId = assetId ?? "", AgeId = ageId, AgeName = _currentAgeName, IsLegend = isLegend }; MemoryLandmarks.Add(entry); LandmarksPerAge[ageId] = ageCount + 1; while (MemoryLandmarks.Count > MaxLandmarks) { ChronicleEntry removed = MemoryLandmarks[0]; MemoryLandmarks.RemoveAt(0); if (removed != null) { if (!string.IsNullOrEmpty(removed.AgeId) && LandmarksPerAge.TryGetValue(removed.AgeId, out int c) && c > 0) { LandmarksPerAge[removed.AgeId] = c - 1; } string oldKey = (removed.AgeId ?? "") + "|" + (removed.AssetId ?? "") + "|" + (removed.Line ?? ""); LandmarkDedupe.Remove(oldKey); } } } BumpRevision(); } private static void RefreshAgeChapter(bool force) { string ageId; string ageName; if (!TryReadCurrentAge(out ageId, out ageName)) { return; } if (!force && ageId == _currentAgeId) { return; } bool changed = ageId != _currentAgeId; _currentAgeId = ageId; _currentAgeName = ageName; if (!changed && !force) { return; } if (!changed) { return; } // New age chapter: reset per-age cap tracking for the new id (keep old counts). if (!LandmarksPerAge.ContainsKey(ageId)) { LandmarksPerAge[ageId] = 0; } StampWorldDate(out double worldTime, out string dateLabel); ChronicleEntry chapter = new ChronicleEntry { CreatedAt = Time.unscaledTime, WorldTime = worldTime, DateLabel = dateLabel, Kind = ChronicleKind.AgeChapter, Line = ageName, LoreLine = ageName, AgeId = ageId, AgeName = ageName, AssetId = "age:" + ageId }; lock (MemoryLandmarks) { // Avoid duplicate chapter headers for the same age. for (int i = MemoryLandmarks.Count - 1; i >= 0; i--) { ChronicleEntry e = MemoryLandmarks[i]; if (e != null && e.Kind == ChronicleKind.AgeChapter && e.AgeId == ageId) { return; } } MemoryLandmarks.Add(chapter); while (MemoryLandmarks.Count > MaxLandmarks) { MemoryLandmarks.RemoveAt(0); } } BumpRevision(); } private static bool TryReadCurrentAge(out string ageId, out string ageName) { ageId = ""; ageName = ""; try { if (World.world == null || World.world.era_manager == null) { return false; } WorldAgeAsset age = World.world.era_manager.getCurrentAge(); if (age == null) { return false; } ageId = age.id ?? "unknown"; ageName = ageId; try { string locale = age.getLocaleID(); if (!string.IsNullOrEmpty(locale)) { string translated = LocalizedTextManager.getText(locale); if (!string.IsNullOrEmpty(translated)) { ageName = translated; } } } catch { // keep id } if (string.IsNullOrEmpty(ageName)) { ageName = ageId; } return true; } catch { return false; } } /// /// Capture current WorldBox calendar as month + year (e.g. January 1). /// private static void StampWorldDate(out double worldTime, out string dateLabel) { worldTime = 0; dateLabel = ""; try { if (World.world == null) { return; } worldTime = World.world.getCurWorldTime(); int[] raw = Date.getRawDate(worldTime); if (raw == null || raw.Length < 3) { dateLabel = "y" + Date.getCurrentYear() + " m" + Date.getCurrentMonth(); return; } string monthName = Date.formatMonth(raw[1]); if (string.IsNullOrEmpty(monthName)) { monthName = "m" + raw[1]; } dateLabel = monthName + " " + raw[2]; } catch { dateLabel = ""; } } private static string PairKey(long a, long b) { long lo = a < b ? a : b; long hi = a < b ? b : a; return lo + ":" + hi; } private static Actor FindUnitById(long id) { if (id == 0) { return null; } foreach (Actor actor in WorldActivityScanner.EnumerateAliveUnitsPublic()) { if (actor != null && actor.getID() == id && actor.isAlive()) { return actor; } } return null; } private static string SpeciesOf(Actor actor) { return actor?.asset != null ? actor.asset.id : "creature"; } private static string SafeName(Actor actor) { try { string name = actor.getName(); if (!string.IsNullOrEmpty(name)) { return name; } } catch { // ignore } return SpeciesOf(actor); } }