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