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 ?? ""; } const string color = "#A8B4C8"; 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(); _boundWorld = world; LogService.LogInfo("[IdleSpectator] Chronicle cleared for new world session"); } 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(); } } /// 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 = ""; 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 - character history only. public static void NoteKill(Actor killer, Actor victim) { if (!ModSettings.ChronicleEnabled || killer == null || victim == null) { return; } AppendHistory( ChronicleKind.Kill, killer, victim, $"Killed {SafeName(victim)} ({SpeciesOf(victim)})"); } /// Victim POV - cause of death on character history. public static void NoteDeath(Actor victim, AttackType attackType, Actor killer) { if (!ModSettings.ChronicleEnabled || 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); AppendHistory(ChronicleKind.Death, victim, killer, line, attackType); } public static void NoteLovers(Actor a, Actor b) { if (!ModSettings.ChronicleEnabled || a == null || b == null) { return; } string key = PairKey(a.getID(), b.getID()); if (!LoverPairKeys.Add(key)) { return; } CapPairKeys(LoverPairKeys); AppendHistory(ChronicleKind.Lover, a, b, $"Became lovers with {SafeName(b)}"); AppendHistory(ChronicleKind.Lover, b, a, $"Became lovers with {SafeName(a)}"); } public static void NoteBestFriends(Actor a, Actor b) { if (!ModSettings.ChronicleEnabled || a == null || b == null) { return; } string key = PairKey(a.getID(), b.getID()); if (!FriendPairKeys.Add(key)) { return; } CapPairKeys(FriendPairKeys); AppendHistory(ChronicleKind.Friend, a, b, $"Befriended {SafeName(b)}"); AppendHistory(ChronicleKind.Friend, b, a, $"Befriended {SafeName(a)}"); } 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; } 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); string baseCause; switch (manner) { case DeathManner.Slain: baseCause = hasKiller ? "Killed by foe" : "Killed in combat"; break; case DeathManner.OldAge: baseCause = "Died of old age"; break; case DeathManner.Starvation: baseCause = "Starved to death"; break; case DeathManner.Drowned: baseCause = "Drowned"; break; case DeathManner.Burned: baseCause = "Burned to death"; break; case DeathManner.Plague: baseCause = "Died of plague"; break; case DeathManner.Divine: baseCause = "Struck down by divine power"; break; case DeathManner.Accident: baseCause = "Died in an explosion"; break; default: baseCause = "Died"; break; } 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; } 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"; } } /// 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; /// 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: living killer if possible, else last death / history position. /// Dossier always shows the fallen unit's last living state (not the killer). /// public static bool FocusFallenSubject(long unitId) { LastFallenFocusDetail = "none"; LastFallenFocusPosition = Vector3.zero; if (unitId == 0) { return false; } UnitDossier fallen = ResolveFallenDossier(unitId); if (fallen != null) { WatchCaption.PinWhilePaused(); WatchCaption.SetFromDossier(fallen); } ChronicleEntry death = LatestDeathEntry(unitId); if (death != null && death.OtherId != 0) { Actor killer = FindUnitById(death.OtherId); if (killer != null && killer.isAlive()) { CameraDirector.FocusUnit(killer, updateCaption: false); LastFallenFocusDetail = "killer:" + death.OtherId; LogService.LogInfo( $"[IdleSpectator][LORE] fallen focus killer id={death.OtherId} victim={unitId}"); return true; } } if (TryResolveFallenPlace(unitId, death, out Vector3 pos)) { // Drop any prior follow target or the camera eases back away from the death site. CameraDirector.ClearFollow(); CameraDirector.PanTo(pos); LastFallenFocusDetail = "place"; LastFallenFocusPosition = pos; LogService.LogInfo($"[IdleSpectator][LORE] fallen focus place victim={unitId} pos={pos}"); return true; } return fallen != null; } 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)})" : ""; switch (attackType) { case AttackType.Age: return "Died of old age"; case AttackType.Starvation: return "Starved to death"; case AttackType.Fire: return IsOnLava(victim) ? "Burned in lava" : "Burned to death"; case AttackType.Drowning: case AttackType.Water: return "Drowned"; case AttackType.Poison: return "Died of poison"; case AttackType.Plague: return "Died of plague"; case AttackType.Infection: return "Died of infection"; case AttackType.Tumor: return "Died of a tumor"; case AttackType.Acid: return "Dissolved in acid"; case AttackType.AshFever: return "Died of ash fever"; case AttackType.Gravity: return "Fell to their death"; case AttackType.Explosion: return "Died in an explosion"; case AttackType.Divine: return "Struck down by divine power"; case AttackType.Eaten: return string.IsNullOrEmpty(killerLabel) ? "Was eaten" : $"Eaten by {killerLabel}"; case AttackType.Weapon: case AttackType.Other: case AttackType.Smile: if (!string.IsNullOrEmpty(killerLabel)) { return $"Killed by {killerLabel}"; } return attackType == AttackType.Weapon ? "Killed in combat" : "Died"; case AttackType.Metamorphosis: return "Transformed away"; case AttackType.None: return "Died"; default: return string.IsNullOrEmpty(killerLabel) ? $"Died ({attackType})" : $"Killed by {killerLabel}"; } } private static bool IsOnLava(Actor victim) { 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); } }