From b40f3f86218a87edc1d4e7f634ddf10bcd2c3fae Mon Sep 17 00:00:00 2001 From: DazedAnon Date: Tue, 14 Jul 2026 21:19:13 -0500 Subject: [PATCH] Implement Lore panel functionality, replacing Chronicle with a new tabbed interface for World Memory and Character History. Enhanced HUD interactions, added search and favorites features, and updated key bindings. Adjusted test scenarios to validate new Lore features and behaviors. --- IdleSpectator/AgentHarness.cs | 293 +++++- IdleSpectator/CameraDirector.cs | 1 + IdleSpectator/CameraZoomPatches.cs | 34 + IdleSpectator/Chronicle.cs | 452 ++++++++- IdleSpectator/ChronicleCharacterSummary.cs | 46 + IdleSpectator/ChronicleHud.cs | 1061 +++++++++++++++++++- IdleSpectator/HarnessScenarios.cs | 91 +- IdleSpectator/InterestDirector.cs | 22 + IdleSpectator/Locales/en.json | 4 +- IdleSpectator/Main.cs | 2 +- IdleSpectator/SpectatorMode.cs | 18 +- IdleSpectator/WatchCaption.cs | 293 +++--- IdleSpectator/mod.json | 4 +- 13 files changed, 2082 insertions(+), 239 deletions(-) create mode 100644 IdleSpectator/CameraZoomPatches.cs create mode 100644 IdleSpectator/ChronicleCharacterSummary.cs diff --git a/IdleSpectator/AgentHarness.cs b/IdleSpectator/AgentHarness.cs index 9f0c32d..6364e9e 100644 --- a/IdleSpectator/AgentHarness.cs +++ b/IdleSpectator/AgentHarness.cs @@ -390,10 +390,20 @@ public static class AgentHarness } case "chronicle_tab": + case "lore_tab": case "world_memory_open": { - // Tabs removed - World Memory is a single panel. Chronicle.ShowHud(); + string tabRaw = (cmd.value ?? cmd.label ?? "world").Trim().ToLowerInvariant(); + if (tabRaw == "characters" || tabRaw == "character" || tabRaw == "chars" || tabRaw == "history") + { + ChronicleHud.SetTab(ChronicleHud.LoreTab.Characters); + } + else + { + ChronicleHud.SetTab(ChronicleHud.LoreTab.World); + } + bool open = Chronicle.HudVisible; if (open) { @@ -404,13 +414,138 @@ public static class AgentHarness _cmdFail++; } - Emit(cmd, open, detail: $"hud={open} memory={Chronicle.MemoryCount} age={Chronicle.CurrentAgeName}"); + Emit(cmd, open, + detail: $"hud={open} tab={ChronicleHud.ActiveTab} memory={Chronicle.MemoryCount} recent={Chronicle.RecentFocusCount}"); + break; + } + + case "lore_search": + { + Chronicle.ShowHud(); + ChronicleHud.SetTab(ChronicleHud.LoreTab.Characters); + ChronicleHud.SetSearch(cmd.value ?? cmd.label ?? ""); + _cmdOk++; + Emit(cmd, ok: true, detail: $"search='{ChronicleHud.SearchText}'"); + break; + } + + case "lore_favorites": + { + Chronicle.ShowHud(); + ChronicleHud.SetTab(ChronicleHud.LoreTab.Characters); + string v = (cmd.value ?? "true").Trim().ToLowerInvariant(); + bool on = v != "false" && v != "0" && v != "off"; + ChronicleHud.SetFavoritesOnly(on); + _cmdOk++; + Emit(cmd, ok: true, detail: $"favoritesOnly={ChronicleHud.FavoritesOnly}"); + break; + } + + case "lore_pick": + { + Chronicle.ShowHud(); + ChronicleHud.SetTab(ChronicleHud.LoreTab.Characters); + string needle = cmd.value ?? cmd.label ?? ""; + if (needle == "auto") + { + needle = ""; + } + + bool ok = ChronicleHud.PickFirstCharacter(needle); + if (ok) + { + _cmdOk++; + } + else + { + _cmdFail++; + } + + Emit(cmd, ok, + detail: $"picked={ok} focus={MoveCamera.hasFocusUnit()} dossier={WatchCaption.LastHeadline} recent={Chronicle.RecentFocusCount}"); + break; + } + + case "lore_open_focus": + case "chronicle_open_focus": + { + ChronicleHud.OpenSyncedFromFocus(); + bool open = Chronicle.HudVisible; + if (open) + { + _cmdOk++; + } + else + { + _cmdFail++; + } + + Emit(cmd, open, + detail: + $"lore={Chronicle.HudVisible} detail={ChronicleHud.DetailUnitId} dossierId={WatchCaption.CurrentUnitId} idle={SpectatorMode.Active} follow={ChronicleHud.FollowFocus}"); + break; + } + + case "lore_pick_fallen": + { + Chronicle.ShowHud(); + ChronicleHud.SetTab(ChronicleHud.LoreTab.Characters); + string needle = cmd.value ?? cmd.label ?? ""; + if (needle == "auto") + { + needle = ""; + } + + bool ok = ChronicleHud.PickFirstFallen(needle); + if (ok) + { + _cmdOk++; + } + else + { + _cmdFail++; + } + + Emit(cmd, ok, + detail: + $"picked={ok} detail={ChronicleHud.DetailUnitId} follow={ChronicleHud.FollowFocus} hist={Chronicle.HistoryCountFor(ChronicleHud.DetailUnitId)}"); + break; + } + + case "chronicle_orphan": + { + int n = Math.Max(1, cmd.count); + string name = !string.IsNullOrEmpty(cmd.label) ? cmd.label : "Orphan"; + string species = !string.IsNullOrEmpty(cmd.asset) ? cmd.asset : "creature"; + string prefix = !string.IsNullOrEmpty(cmd.value) ? cmd.value : "Fallen"; + long id = Chronicle.ForceOrphanHistory(name, species, n, prefix); + bool ok = id != 0 && Chronicle.HistoryCountFor(id) >= n; + if (ok) + { + _cmdOk++; + } + else + { + _cmdFail++; + } + + Emit(cmd, ok, detail: $"orphanId={id} hist={Chronicle.HistoryCountFor(id)} want={n}"); + break; + } + + case "lore_close": + case "chronicle_close": + { + Chronicle.HideHud(); + _cmdOk++; + Emit(cmd, ok: true, detail: $"lore={Chronicle.HudVisible}"); break; } case "chronicle_open": { Chronicle.ShowHud(); + ChronicleHud.SetTab(ChronicleHud.LoreTab.World); bool open = Chronicle.HudVisible; if (open) { @@ -481,11 +616,29 @@ public static class AgentHarness } case "dossier_expand": + case "dossier_history_open": { - // Expand control removed: history is always full-list + scroll when tall. + WatchCaption.OpenFullHistoryInLore(); + bool open = Chronicle.HudVisible && ChronicleHud.DetailUnitId != 0; + if (open) + { + _cmdOk++; + } + else + { + _cmdFail++; + } + + Emit(cmd, open, + detail: $"lore={Chronicle.HudVisible} detail={ChronicleHud.DetailUnitId} idle={SpectatorMode.Active} shown={WatchCaption.LastHistoryShown}"); + break; + } + + case "lore_back": + { + ChronicleHud.ClearUnitDetail(); _cmdOk++; - Emit(cmd, ok: true, - detail: $"expand_removed shown={WatchCaption.LastHistoryShown} scrollable={WatchCaption.LastHistoryScrollable}"); + Emit(cmd, ok: true, detail: $"detail={ChronicleHud.DetailUnitId} tab={ChronicleHud.ActiveTab}"); break; } @@ -1211,9 +1364,10 @@ public static class AgentHarness && WatchCaption.LastPanelSize.y > 20f && WatchCaption.LastPanelSize.y < maxH && WatchCaption.LastPanelSize.x <= 440f - && (WatchCaption.LastHistoryShown <= 0 || WatchCaption.LastHistoryFillsBody); + && (WatchCaption.LastHistoryShown <= 0 || WatchCaption.LastHistoryFillsBody) + && WatchCaption.LastHistoryShown <= 3; detail = - $"layoutOk={WatchCaption.LastLayoutOk} size={WatchCaption.LastPanelSize} histFill={WatchCaption.LastHistoryFillsBody} scrollable={WatchCaption.LastHistoryScrollable} caption='{(WatchCaption.LastCaptionText ?? "").Replace("\n", " | ")}' hist='{(WatchCaption.LastHistoryPreview ?? "").Replace("\n", " ")}'"; + $"layoutOk={WatchCaption.LastLayoutOk} size={WatchCaption.LastPanelSize} histFill={WatchCaption.LastHistoryFillsBody} shown={WatchCaption.LastHistoryShown} caption='{(WatchCaption.LastCaptionText ?? "").Replace("\n", " | ")}' hist='{(WatchCaption.LastHistoryPreview ?? "").Replace("\n", " ")}'"; break; } case "dossier_traits_ok": @@ -1313,11 +1467,114 @@ public static class AgentHarness } case "dossier_history_scrollable": { - pass = WatchCaption.LastHistoryScrollable && WatchCaption.LastHistoryShown > 3; + // Dossier history is no longer scrollable; full history lives in Lore. + pass = !WatchCaption.LastHistoryScrollable && WatchCaption.LastHistoryShown <= 3; detail = $"scrollable={WatchCaption.LastHistoryScrollable} shown={WatchCaption.LastHistoryShown}"; break; } + case "lore_detail": + { + bool want = ParseBool(cmd.value, defaultValue: true); + bool have = ChronicleHud.DetailUnitId != 0; + pass = Chronicle.HudVisible && have == want; + detail = + $"detailId={ChronicleHud.DetailUnitId} want={want} tab={ChronicleHud.ActiveTab} follow={ChronicleHud.FollowFocus}"; + break; + } + case "lore_follow": + { + bool want = ParseBool(cmd.value, defaultValue: true); + bool have = ChronicleHud.FollowFocus; + pass = Chronicle.HudVisible && ChronicleHud.DetailUnitId != 0 && have == want; + detail = + $"follow={have} want={want} detailId={ChronicleHud.DetailUnitId}"; + break; + } + case "lore_follows_focus": + { + long focusId = WatchCaption.CurrentUnitId; + if (focusId == 0) + { + focusId = Chronicle.CurrentHistorySubjectId(); + } + + long loreId = ChronicleHud.DetailUnitId; + pass = Chronicle.HudVisible + && ChronicleHud.FollowFocus + && SpectatorMode.Active + && focusId != 0 + && loreId == focusId; + detail = + $"follow={ChronicleHud.FollowFocus} idle={SpectatorMode.Active} focusId={focusId} loreId={loreId}"; + break; + } + case "lore_history_shown": + { + int want = ParseCountExpect(cmd, defaultValue: 1); + long detailId = ChronicleHud.DetailUnitId; + int haveData = Chronicle.HistoryCountFor(detailId); + int haveUi = ChronicleHud.LastDetailHistoryRows; + string cmp = (cmd.label ?? "min").Trim().ToLowerInvariant(); + bool dataOk = cmp == "exact" || cmp == "==" ? haveData == want : haveData >= want; + bool uiOk = cmp == "exact" || cmp == "==" ? haveUi == want : haveUi >= want; + pass = Chronicle.HudVisible && detailId != 0 && dataOk && uiOk; + detail = + $"loreHist={haveData} uiRows={haveUi} want={want} detailId={detailId} dossierId={WatchCaption.CurrentUnitId}"; + break; + } + case "dossier_lore_history_match": + { + long dossierId = WatchCaption.CurrentUnitId; + long loreId = ChronicleHud.DetailUnitId; + int dossierShown = WatchCaption.LastHistoryShown; + int loreData = Chronicle.HistoryCountFor(loreId); + int loreUi = ChronicleHud.LastDetailHistoryRows; + bool idsMatch = dossierId != 0 && dossierId == loreId; + bool countsAgree = dossierShown <= 0 + ? loreData == 0 && loreUi == 0 + : loreData >= dossierShown && loreUi >= Mathf.Min(dossierShown, 24); + pass = Chronicle.HudVisible && idsMatch && countsAgree; + detail = + $"dossierId={dossierId} loreId={loreId} dossierShown={dossierShown} loreData={loreData} loreUi={loreUi} preview={WatchCaption.LastHistoryPreview}"; + break; + } + case "dossier_pinned": + { + bool want = ParseBool(cmd.value, defaultValue: true); + bool have = WatchCaption.PinnedWhilePaused && WatchCaption.Visible; + pass = have == want; + detail = + $"pinned={WatchCaption.PinnedWhilePaused} visible={WatchCaption.Visible} idle={SpectatorMode.Active} want={want}"; + break; + } + case "lore_zoom_blocked": + { + MoveCamera cam = MoveCamera.instance; + if (cam == null) + { + pass = false; + detail = "no camera"; + break; + } + + float before = cam.getTargetZoom(); + CameraZoomPatches.ForceAbsorbForHarness = true; + try + { + MoveCamera.zoomInWheel(null); + MoveCamera.zoomOutWheel(null); + } + finally + { + CameraZoomPatches.ForceAbsorbForHarness = false; + } + + float after = cam.getTargetZoom(); + pass = Mathf.Abs(after - before) < 0.001f; + detail = $"zoomBefore={before} zoomAfter={after} absorb={ChronicleHud.ShouldAbsorbScrollZoom()}"; + break; + } case "dossier_history_fills_body": { pass = WatchCaption.LastHistoryShown > 0 && WatchCaption.LastHistoryFillsBody; @@ -1359,10 +1616,24 @@ public static class AgentHarness break; } case "chronicle_tab": + case "lore_tab": { - // Deprecated: panel is always World Memory when open. - pass = Chronicle.HudVisible; - detail = $"hud={Chronicle.HudVisible} memory={Chronicle.MemoryCount} (tabs removed)"; + string wantRaw = (cmd.value ?? cmd.label ?? "world").Trim().ToLowerInvariant(); + ChronicleHud.LoreTab want = wantRaw == "characters" || wantRaw == "character" + || wantRaw == "chars" || wantRaw == "history" + ? ChronicleHud.LoreTab.Characters + : ChronicleHud.LoreTab.World; + pass = Chronicle.HudVisible && ChronicleHud.ActiveTab == want; + detail = + $"hud={Chronicle.HudVisible} tab={ChronicleHud.ActiveTab} want={want} recent={Chronicle.RecentFocusCount}"; + break; + } + case "lore_recent": + { + int want = ParseCountExpect(cmd, defaultValue: 1); + int have = Chronicle.RecentFocusCount; + pass = have >= want; + detail = $"recent={have} min={want}"; break; } default: diff --git a/IdleSpectator/CameraDirector.cs b/IdleSpectator/CameraDirector.cs index 20bc467..27bea76 100644 --- a/IdleSpectator/CameraDirector.cs +++ b/IdleSpectator/CameraDirector.cs @@ -110,6 +110,7 @@ public static class CameraDirector bool firstFocus = !MoveCamera.hasFocusUnit(); MoveCamera.setFocusUnit(actor); + Chronicle.NoteFocus(actor); MoveCamera cam = MoveCamera.instance; if (cam == null) diff --git a/IdleSpectator/CameraZoomPatches.cs b/IdleSpectator/CameraZoomPatches.cs new file mode 100644 index 0000000..93e4c31 --- /dev/null +++ b/IdleSpectator/CameraZoomPatches.cs @@ -0,0 +1,34 @@ +using HarmonyLib; + +namespace IdleSpectator; + +/// +/// WorldBox zooms on mouse wheel even over UI while a unit is focused +/// (!isOverUI || MoveCamera.inSpectatorMode()). Absorb wheel zoom when +/// the cursor is over Lore or the dossier so ScrollRect history can scroll. +/// +[HarmonyPatch] +internal static class CameraZoomPatches +{ + /// Harness: force the zoom block without needing a real mouse position. + public static bool ForceAbsorbForHarness; + + private static bool Absorb() + { + return ForceAbsorbForHarness || ChronicleHud.ShouldAbsorbScrollZoom(); + } + + [HarmonyPatch(typeof(MoveCamera), nameof(MoveCamera.zoomInWheel))] + [HarmonyPrefix] + private static bool PrefixZoomInWheel() + { + return !Absorb(); + } + + [HarmonyPatch(typeof(MoveCamera), nameof(MoveCamera.zoomOutWheel))] + [HarmonyPrefix] + private static bool PrefixZoomOutWheel() + { + return !Absorb(); + } +} diff --git a/IdleSpectator/Chronicle.cs b/IdleSpectator/Chronicle.cs index 9522034..55b2318 100644 --- a/IdleSpectator/Chronicle.cs +++ b/IdleSpectator/Chronicle.cs @@ -92,7 +92,7 @@ public sealed class ChronicleEntry /// public static class Chronicle { - public const KeyCode ToggleKey = KeyCode.F9; + public const KeyCode ToggleKey = KeyCode.L; public const int MaxHistoryPerUnit = 40; private const int MaxLandmarks = 80; private const int MaxLandmarksPerAge = 8; @@ -105,9 +105,13 @@ public static class Chronicle private static readonly HashSet DeathLogged = new HashSet(); 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; private static bool _hudReady; private static long _lastHistorySubjectId; + private static long _trackedFocusId; private static object _boundWorld; private static string _currentAgeId = ""; private static string _currentAgeName = ""; @@ -145,6 +149,17 @@ public static class Chronicle 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; @@ -311,7 +326,7 @@ public static class Chronicle if (ChronicleHud.IsReady) { _hudReady = true; - LogService.LogInfo("[IdleSpectator] World Memory HUD ready (F9)"); + LogService.LogInfo("[IdleSpectator] World Memory HUD ready (L)"); } } catch (System.Exception ex) @@ -337,7 +352,9 @@ public static class Chronicle DeathLogged.Clear(); LandmarkDedupe.Clear(); LandmarksPerAge.Clear(); + RecentFocusIds.Clear(); _lastHistorySubjectId = 0; + _trackedFocusId = 0; _currentAgeId = ""; _currentAgeName = ""; // Keep _boundWorld so re-enable on same map does not loop-clear. @@ -351,6 +368,11 @@ public static class Chronicle } EnsureWindow(); + if (ChronicleHud.IsTypingSearch) + { + return; + } + if (Input.GetKeyDown(ToggleKey)) { ToggleWindow(); @@ -371,6 +393,11 @@ public static class Chronicle public static void ShowHud() { EnsureWindow(); + if (ChronicleHud.DetailUnitId == 0) + { + ChronicleHud.SetTab(ChronicleHud.LoreTab.Characters); + } + ChronicleHud.SetVisible(true); } @@ -401,9 +428,7 @@ public static class Chronicle Actor unit = FindUnitById(entry.SubjectId); if (unit != null && unit.isAlive()) { - CameraDirector.FocusUnit(unit); - WatchCaption.SetFromActor(unit); - return true; + return FocusSubject(unit); } if (entry.OtherId != 0) @@ -411,9 +436,7 @@ public static class Chronicle Actor other = FindUnitById(entry.OtherId); if (other != null && other.isAlive()) { - CameraDirector.FocusUnit(other); - WatchCaption.SetFromActor(other); - return true; + return FocusSubject(other); } } @@ -426,6 +449,356 @@ public static class Chronicle 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); + } + + 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; + } + + /// + /// Character History rows: recent focuses first, then other history subjects / favorites. + /// + public static List ListCharacters( + string search = "", + bool favoritesOnly = false, + int max = 40) + { + var byId = new Dictionary(); + string needle = (search ?? "").Trim().ToLowerInvariant(); + + void Upsert(Actor actor, bool recent, int recentIndex) + { + if (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; + } + + 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 + }; + 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); + } + + 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); + } + + // 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) + { + Upsert(live, recent: false, recentIndex: -1); + continue; + } + + if (favoritesOnly) + { + 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; + } + } + + byId[id] = new ChronicleCharacterSummary + { + UnitId = id, + Name = name, + SpeciesId = species, + HistoryCount = snap.Count, + IsFavorite = false, + IsAlive = false, + IsRecent = false + }; + } + + var list = new List(byId.Values); + list.Sort((a, b) => + { + if (a.IsAlive != b.IsAlive) + { + return a.IsAlive ? -1 : 1; + } + + if (a.IsRecent != b.IsRecent) + { + return a.IsRecent ? -1 : 1; + } + + if (a.IsRecent && b.IsRecent) + { + return a.RecentIndex.CompareTo(b.RecentIndex); + } + + if (a.IsFavorite != b.IsFavorite) + { + return a.IsFavorite ? -1 : 1; + } + + return string.Compare(a.Title, b.Title, System.StringComparison.OrdinalIgnoreCase); + }); + + if (max > 0 && list.Count > max) + { + list.RemoveRange(max, list.Count - max); + } + + return list; + } + public static void Update() { if (!ModSettings.ChronicleEnabled || !Config.game_loaded || World.world == null) @@ -440,9 +813,10 @@ public static class Chronicle try { long id = MoveCamera._focus_unit.getID(); - if (id != 0) + if (id != 0 && id != _trackedFocusId) { - _lastHistorySubjectId = id; + _trackedFocusId = id; + NoteFocusId(id); } } catch @@ -597,6 +971,64 @@ public static class Chronicle 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") + { + 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; + 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 ? $"{prefix} #{i + 1}" : prefix; + list.Add(new ChronicleEntry + { + CreatedAt = Time.unscaledTime, + WorldTime = worldTime, + DateLabel = dateLabel, + Kind = ChronicleKind.Death, + SubjectId = id, + Name = who, + SpeciesId = sp, + Line = line, + LoreLine = line, + AgeId = _currentAgeId, + AgeName = _currentAgeName + }); + } + + while (list.Count > MaxHistoryPerUnit) + { + list.RemoveAt(0); + } + } + + NoteFocusId(id); + LogService.LogInfo($"[IdleSpectator][CHRONICLE] orphan history id={id} count={count} name={who}"); + return id; + } + /// Harness: inject a World Memory landmark (does not touch character History). public static bool ForceWorldNote(string label) { diff --git a/IdleSpectator/ChronicleCharacterSummary.cs b/IdleSpectator/ChronicleCharacterSummary.cs new file mode 100644 index 0000000..cb22512 --- /dev/null +++ b/IdleSpectator/ChronicleCharacterSummary.cs @@ -0,0 +1,46 @@ +namespace IdleSpectator; + +/// One row in the Lore panel Character History tab. +public sealed class ChronicleCharacterSummary +{ + public long UnitId; + public string Name = ""; + public string SpeciesId = ""; + public int HistoryCount; + public bool IsFavorite; + public bool IsAlive; + public bool IsRecent; + public int RecentIndex = -1; + + public string Title + { + get + { + if (string.IsNullOrEmpty(Name)) + { + return SpeciesId ?? "creature"; + } + + if (string.IsNullOrEmpty(SpeciesId)) + { + return Name; + } + + return Name + " (" + SpeciesId + ")"; + } + } + + public string Detail + { + get + { + string hist = HistoryCount <= 0 ? "no history" : HistoryCount + " events"; + if (IsRecent) + { + return "recent · " + hist; + } + + return hist; + } + } +} diff --git a/IdleSpectator/ChronicleHud.cs b/IdleSpectator/ChronicleHud.cs index 6b79758..e88ee43 100644 --- a/IdleSpectator/ChronicleHud.cs +++ b/IdleSpectator/ChronicleHud.cs @@ -2,45 +2,121 @@ using System.Collections.Generic; using NeoModLoader.services; using UnityEngine; using UnityEngine.Events; +using UnityEngine.EventSystems; using UnityEngine.UI; namespace IdleSpectator; /// -/// Compact bottom-right World Memory card: age chapters + lore landmarks (F9). +/// Bottom-right Lore panel (L): World Memory + Character History tabs. /// Sized in canvas units (same scaler as the dossier) to stay clear of top-right. /// public static class ChronicleHud { - // Reference units on CanvasMain (~285x420). Keep modest so high-res scale is not a wall. - private const float PanelWidth = 220f; - private const float PanelHeight = 124f; + public enum LoreTab + { + World = 0, + Characters = 1 + } + + private const float PanelWidth = 268f; + private const float PanelHeight = 188f; private const float ScreenInset = 12f; private const float BottomClearance = 64f; - private const int MaxVisibleRows = 20; - private const float RowHeightMin = 24f; - private const float ChapterHeight = 18f; - private const float KindIconSize = 12f; - private const float TitleIconSize = 14f; + private const int MaxVisibleRows = 24; + private const float RowHeightMin = 20f; + private const float ChapterHeight = 15f; + private const float KindIconSize = 11f; + private const float TitleIconSize = 13f; + private const float TitleBandH = 22f; + private const float TabH = 15f; + private const float CharToolsH = 17f; + private const float ScrollSensitivity = 55f; + private const string ChromeMarker = "LoreV4"; private static GameObject _root; private static RectTransform _rootRt; private static RectTransform _content; + private static RectTransform _scrollRt; + private static ScrollRect _scroll; private static Text _titleText; + private static Text _hotkeyText; private static Image _titleIcon; private static readonly List Rows = new List(); private static bool _visible; private static int _lastCount = -1; private static string _lastAgeId = ""; + private static LoreTab _tab = LoreTab.World; + + private static GameObject _tabsBar; + private static GameObject _worldTabBtn; + private static GameObject _charsTabBtn; + private static GameObject _charTools; + private static InputField _searchField; + private static Button _favFilterBtn; + private static Image _favFilterIcon; + private static bool _favoritesOnly; + private static string _searchText = ""; + private static int _lastCharFingerprint = int.MinValue; + private static long _detailUnitId; + private static bool _followFocus; + private static GameObject _backBtn; + private static Text _backBtnLabel; + + /// Rows actually built in the last Characters detail rebuild (harness). + public static int LastDetailHistoryRows { get; private set; } public static bool Visible => _visible && _root != null && _root.activeSelf; public static bool IsReady => _root != null; + public static LoreTab ActiveTab => _tab; + + public static bool FavoritesOnly => _favoritesOnly; + + public static string SearchText => _searchText ?? ""; + + public static long DetailUnitId => _detailUnitId; + + /// True when Lore detail was opened via L/books and should track idle focus. + public static bool FollowFocus => _followFocus; + + public static bool IsTypingSearch => + _detailUnitId == 0 + && _searchField != null + && _searchField.isFocused + && _root != null + && _root.activeInHierarchy; + public static void EnsureBuilt() { DestroyOrphanHuds(); + if (_root != null + && (_root.transform.Find("Tabs") == null || _root.transform.Find(ChromeMarker) == null)) + { + try + { + Object.Destroy(_root); + } + catch + { + // ignore + } + + _root = null; + _rootRt = null; + _content = null; + _scrollRt = null; + _scroll = null; + _tabsBar = null; + _searchField = null; + _backBtn = null; + _backBtnLabel = null; + _detailUnitId = 0; + _followFocus = false; + } + if (_root != null) { ApplyRootPlacement(); @@ -60,50 +136,212 @@ public static class ChronicleHud _root = new GameObject("IdleSpectatorWorldMemoryHud", typeof(RectTransform), typeof(Image), typeof(CanvasGroup)); _root.transform.SetParent(canvas.transform, false); + new GameObject(ChromeMarker, typeof(RectTransform)).transform.SetParent(_root.transform, false); _rootRt = _root.GetComponent(); ApplyRootPlacement(); Image bg = _root.GetComponent(); bg.raycastTarget = true; - HudCanvas.StylePanel(bg, _root.transform, new Color(0.07f, 0.08f, 0.1f, 0.88f)); + HudCanvas.StylePanel(bg, _root.transform, new Color(0.07f, 0.08f, 0.1f, 0.92f)); CanvasGroup group = _root.GetComponent(); group.blocksRaycasts = true; group.interactable = true; - Text f9 = HudCanvas.MakeText(_root.transform, "F9", "F9", 8); - RectTransform f9Rt = f9.GetComponent(); - f9Rt.anchorMin = new Vector2(1f, 1f); - f9Rt.anchorMax = new Vector2(1f, 1f); - f9Rt.pivot = new Vector2(1f, 1f); - f9Rt.sizeDelta = new Vector2(24f, 14f); - f9Rt.anchoredPosition = new Vector2(-6f, -4f); - f9.alignment = TextAnchor.MiddleRight; - f9.color = new Color(0.55f, 0.58f, 0.62f, 1f); - f9.raycastTarget = false; + _hotkeyText = HudCanvas.MakeText(_root.transform, "Hotkey", "L", 8); + RectTransform hotRt = _hotkeyText.GetComponent(); + hotRt.anchorMin = new Vector2(1f, 1f); + hotRt.anchorMax = new Vector2(1f, 1f); + hotRt.pivot = new Vector2(1f, 1f); + hotRt.sizeDelta = new Vector2(16f, 12f); + hotRt.anchoredPosition = new Vector2(-6f, -5f); + _hotkeyText.alignment = TextAnchor.MiddleRight; + _hotkeyText.color = new Color(0.5f, 0.53f, 0.58f, 0.95f); + _hotkeyText.raycastTarget = false; _titleIcon = HudCanvas.MakeIcon(_root.transform, "TitleIcon", TitleIconSize); RectTransform titleIconRt = _titleIcon.GetComponent(); titleIconRt.anchorMin = new Vector2(0f, 1f); titleIconRt.anchorMax = new Vector2(0f, 1f); titleIconRt.pivot = new Vector2(0.5f, 0.5f); - titleIconRt.anchoredPosition = new Vector2(14f, -14f); + titleIconRt.anchoredPosition = new Vector2(13f, -TitleBandH * 0.5f - 1f); HudIcons.Apply(_titleIcon, HudIcons.FromUiIcon("iconBooks") ?? HudIcons.FromUiIcon("iconWar")); - _titleText = HudCanvas.MakeText(_root.transform, "Title", "World Memory", 9); + _titleText = HudCanvas.MakeText(_root.transform, "Title", "Lore", 9); RectTransform titleRt = _titleText.GetComponent(); titleRt.anchorMin = new Vector2(0f, 1f); titleRt.anchorMax = new Vector2(1f, 1f); titleRt.pivot = new Vector2(0.5f, 1f); - titleRt.offsetMin = new Vector2(24f, -24f); - titleRt.offsetMax = new Vector2(-28f, -4f); + titleRt.offsetMin = new Vector2(24f, -TitleBandH); + titleRt.offsetMax = new Vector2(-22f, -2f); _titleText.alignment = TextAnchor.MiddleLeft; - _titleText.color = new Color(0.78f, 0.8f, 0.84f, 1f); + _titleText.color = new Color(0.9f, 0.91f, 0.93f, 1f); _titleText.fontStyle = FontStyle.Bold; _titleText.raycastTarget = false; _titleText.horizontalOverflow = HorizontalWrapMode.Overflow; + BuildTabs(); + BuildCharTools(); + BuildScroll(); + + _root.SetActive(false); + _visible = false; + ApplyTabChrome(); + LogService.LogInfo("[IdleSpectator] Lore HUD ready (L, World + Characters)"); + } + + private static void BuildTabs() + { + _tabsBar = new GameObject("Tabs", typeof(RectTransform)); + _tabsBar.transform.SetParent(_root.transform, false); + RectTransform tabsRt = _tabsBar.GetComponent(); + tabsRt.anchorMin = new Vector2(0f, 1f); + tabsRt.anchorMax = new Vector2(1f, 1f); + tabsRt.pivot = new Vector2(0.5f, 1f); + tabsRt.anchoredPosition = new Vector2(0f, -TitleBandH); + tabsRt.sizeDelta = new Vector2(-12f, TabH); + + _worldTabBtn = BuildTabButton(_tabsBar.transform, "WorldTab", "World", () => SetTab(LoreTab.World)); + _charsTabBtn = BuildTabButton(_tabsBar.transform, "CharsTab", "Characters", () => SetTab(LoreTab.Characters)); + + PlaceTab(_worldTabBtn, 0f); + PlaceTab(_charsTabBtn, 1f); + } + + private static GameObject BuildTabButton(Transform parent, string name, string label, UnityAction onClick) + { + GameObject go = new GameObject(name, typeof(RectTransform), typeof(Image), typeof(Button)); + go.transform.SetParent(parent, false); + Image bg = go.GetComponent(); + bg.color = new Color(0.1f, 0.11f, 0.13f, 0.35f); + bg.raycastTarget = true; + Button button = go.GetComponent