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