diff --git a/.cursor/skills/idle-spectator-e2e/reference.md b/.cursor/skills/idle-spectator-e2e/reference.md index e3520ce..258d7f8 100644 --- a/.cursor/skills/idle-spectator-e2e/reference.md +++ b/.cursor/skills/idle-spectator-e2e/reference.md @@ -47,6 +47,8 @@ Use harness `fast_timing` + `director_run` / `age_current` for scheduling tests. | Director tests take forever | production dwell/rotate | `fast_timing` (not game 5x) | | Game never opens / `Cannot run as root user` | Shell sandboxed; Steam rejects root-like env | re-run launch/`harness-run.sh` with Shell `required_permissions: ["all"]` | | Old mod still loaded after edit | NML caches until restart | kill `.../common/worldbox/worldbox`, bump `mod.json`, relaunch with `all` perms | +| Harness says ready but mod broken | Stale `Harmony patches applied` / missed `error CS` | runner fails boot on IdleSpectator `error CS`; ready requires Harmony in current (truncated) Player.log | +| Harness stuck on `o...` forever | Launch marker written into Player.log then wiped by Unity | do not put boot markers in Player.log; wait for process + Harmony | ## Useful asserts diff --git a/IdleSpectator/AgentHarness.cs b/IdleSpectator/AgentHarness.cs index 699a4bb..9f0c32d 100644 --- a/IdleSpectator/AgentHarness.cs +++ b/IdleSpectator/AgentHarness.cs @@ -317,8 +317,19 @@ public static class AgentHarness case "chronicle_force": case "chronicle_note": { - bool ok = Chronicle.ForceNoteCurrentFocus( - string.IsNullOrEmpty(cmd.label) ? "Killed" : cmd.label); + int n = Math.Max(1, cmd.count); + string baseLabel = string.IsNullOrEmpty(cmd.label) ? "Killed" : cmd.label; + int okN = 0; + for (int i = 0; i < n; i++) + { + string label = n > 1 ? $"{baseLabel} #{i + 1}" : baseLabel; + if (Chronicle.ForceNoteCurrentFocus(label)) + { + okN++; + } + } + + bool ok = okN == n; if (ok) { _cmdOk++; @@ -329,7 +340,7 @@ public static class AgentHarness } Emit(cmd, ok, detail: - $"history={Chronicle.HistoryCountFor(Chronicle.CurrentHistorySubjectId())} caption={WatchCaption.LastHeadline}"); + $"forced={okN}/{n} history={Chronicle.HistoryCountFor(Chronicle.CurrentHistorySubjectId())} caption={WatchCaption.LastHeadline}"); break; } @@ -379,21 +390,12 @@ public static class AgentHarness } case "chronicle_tab": + case "world_memory_open": { - string tab = (cmd.value ?? "").Trim().ToLowerInvariant(); - if (tab == "world") - { - ChronicleHud.SetTab(ChronicleTab.World); - } - else - { - ChronicleHud.SetTab(ChronicleTab.History); - tab = "history"; - } - + // Tabs removed - World Memory is a single panel. Chronicle.ShowHud(); - bool ok = ChronicleHud.ActiveTab.ToString().Equals(tab, System.StringComparison.OrdinalIgnoreCase); - if (ok) + bool open = Chronicle.HudVisible; + if (open) { _cmdOk++; } @@ -402,7 +404,7 @@ public static class AgentHarness _cmdFail++; } - Emit(cmd, ok, detail: $"tab={ChronicleHud.ActiveTab}"); + Emit(cmd, open, detail: $"hud={open} memory={Chronicle.MemoryCount} age={Chronicle.CurrentAgeName}"); break; } @@ -419,7 +421,71 @@ public static class AgentHarness _cmdFail++; } - Emit(cmd, open, detail: $"hud={open} tab={ChronicleHud.ActiveTab}"); + Emit(cmd, open, detail: $"hud={open} memory={Chronicle.MemoryCount} age={Chronicle.CurrentAgeName}"); + break; + } + + case "dossier_favorite": + { + Actor target = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null; + string v = (cmd.value ?? "").Trim().ToLowerInvariant(); + if (v == "true" || v == "1" || v == "on" || v == "false" || v == "0" || v == "off") + { + bool want = v == "true" || v == "1" || v == "on"; + bool ok = false; + try + { + if (target != null && target.isAlive()) + { + if (target.isFavorite() != want) + { + target.switchFavorite(); + } + + ok = target.isFavorite() == want; + WatchCaption.SetFromActor(target); + } + } + catch + { + ok = false; + } + + if (ok) + { + _cmdOk++; + } + else + { + _cmdFail++; + } + + Emit(cmd, ok, detail: $"favorite={want} set"); + break; + } + + WatchCaption.ToggleFavorite(); + bool fav = false; + try + { + fav = target != null && target.isFavorite(); + } + catch + { + // ignore + } + + _cmdOk++; + Emit(cmd, ok: true, detail: $"favorite={fav}"); + break; + } + + case "dossier_expand": + { + // Expand control removed: history is always full-list + scroll when tall. + _cmdOk++; + Emit(cmd, ok: true, + detail: $"expand_removed shown={WatchCaption.LastHistoryShown} scrollable={WatchCaption.LastHistoryScrollable}"); break; } @@ -1140,12 +1206,14 @@ public static class AgentHarness } case "caption_layout_ok": { + float maxH = 220f; pass = WatchCaption.LastLayoutOk && WatchCaption.LastPanelSize.y > 20f - && WatchCaption.LastPanelSize.y < 220f - && WatchCaption.LastPanelSize.x <= 440f; + && WatchCaption.LastPanelSize.y < maxH + && WatchCaption.LastPanelSize.x <= 440f + && (WatchCaption.LastHistoryShown <= 0 || WatchCaption.LastHistoryFillsBody); detail = - $"layoutOk={WatchCaption.LastLayoutOk} size={WatchCaption.LastPanelSize} caption='{(WatchCaption.LastCaptionText ?? "").Replace("\n", " | ")}' hist='{(WatchCaption.LastHistoryPreview ?? "").Replace("\n", " ")}'"; + $"layoutOk={WatchCaption.LastLayoutOk} size={WatchCaption.LastPanelSize} histFill={WatchCaption.LastHistoryFillsBody} scrollable={WatchCaption.LastHistoryScrollable} caption='{(WatchCaption.LastCaptionText ?? "").Replace("\n", " | ")}' hist='{(WatchCaption.LastHistoryPreview ?? "").Replace("\n", " ")}'"; break; } case "dossier_traits_ok": @@ -1183,22 +1251,118 @@ public static class AgentHarness break; } case "chronicle_world_latest_contains": + case "world_memory_contains": { string needle = string.IsNullOrEmpty(cmd.value) ? cmd.label : cmd.value; ChronicleEntry latest = Chronicle.LatestWorld; string line = latest != null ? latest.Line : ""; + string lore = latest != null ? latest.HudLine : ""; + string display = latest != null ? latest.DisplayLine : ""; pass = latest != null + && latest.Kind != ChronicleKind.AgeChapter && !string.IsNullOrEmpty(needle) - && line.IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0; - detail = $"world_latest='{line}' needle='{needle}'"; + && (line.IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0 + || lore.IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0); + detail = $"world_latest='{display}' needle='{needle}' age='{Chronicle.CurrentAgeName}'"; + break; + } + case "world_memory_age": + { + pass = !string.IsNullOrEmpty(Chronicle.CurrentAgeId) + || Chronicle.MemoryCount > 0; + detail = $"ageId='{Chronicle.CurrentAgeId}' ageName='{Chronicle.CurrentAgeName}' memory={Chronicle.MemoryCount}"; + break; + } + case "dossier_favorite": + case "is_favorite": + { + Actor unit = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null; + bool fav = false; + try + { + fav = unit != null && unit.isFavorite(); + } + catch + { + // ignore + } + + string wantRaw = (cmd.value ?? "true").Trim().ToLowerInvariant(); + bool want = wantRaw != "false" && wantRaw != "0" && wantRaw != "off"; + pass = fav == want; + detail = $"favorite={fav} want={want}"; + break; + } + case "dossier_history_shown": + { + int want = ParseCountExpect(cmd, defaultValue: 1); + int have = WatchCaption.LastHistoryShown; + string cmp = (cmd.label ?? "min").Trim().ToLowerInvariant(); + if (cmp == "exact" || cmp == "==") + { + pass = have == want; + } + else + { + pass = have >= want; + } + + detail = + $"shown={have} want={want} scrollable={WatchCaption.LastHistoryScrollable} histFill={WatchCaption.LastHistoryFillsBody}"; + break; + } + case "dossier_history_scrollable": + { + pass = WatchCaption.LastHistoryScrollable && WatchCaption.LastHistoryShown > 3; + detail = + $"scrollable={WatchCaption.LastHistoryScrollable} shown={WatchCaption.LastHistoryShown}"; + break; + } + case "dossier_history_fills_body": + { + pass = WatchCaption.LastHistoryShown > 0 && WatchCaption.LastHistoryFillsBody; + detail = + $"histFill={WatchCaption.LastHistoryFillsBody} shown={WatchCaption.LastHistoryShown} size={WatchCaption.LastPanelSize}"; + break; + } + case "hud_no_overlap": + { + bool hasDossier = WatchCaption.TryGetScreenPixelRect(out Rect dossier); + bool hasMemory = ChronicleHud.TryGetScreenPixelRect(out Rect memory); + bool overlap = false; + if (hasDossier && hasMemory && ChronicleHud.Visible) + { + overlap = dossier.Overlaps(memory); + } + + pass = hasDossier && (!ChronicleHud.Visible || (hasMemory && !overlap)); + detail = + $"dossier={(hasDossier ? dossier.ToString() : "-")} memory={(hasMemory ? memory.ToString() : "-")} visible={ChronicleHud.Visible} overlap={overlap}"; + break; + } + case "world_memory_compact": + { + bool hasMemory = ChronicleHud.TryGetScreenPixelRect(out Rect memory); + // Same canvas scale as dossier (no localScale damp). Reject tiny or near-fullscreen. + float minW = Screen.width * 0.22f; + float maxW = Screen.width * 0.55f; + float minH = Screen.height * 0.16f; + float maxH = Screen.height * 0.50f; + pass = ChronicleHud.Visible + && hasMemory + && memory.width >= minW + && memory.height >= minH + && memory.width <= maxW + && memory.height <= maxH; + detail = + $"memory={(hasMemory ? memory.ToString() : "-")} screen={Screen.width}x{Screen.height} band=({minW:0}-{maxW:0} x {minH:0}-{maxH:0})"; break; } case "chronicle_tab": { - string want = (cmd.value ?? "history").Trim().ToLowerInvariant(); - string have = ChronicleHud.ActiveTab.ToString().ToLowerInvariant(); - pass = have == want; - detail = $"tab={have} want={want}"; + // Deprecated: panel is always World Memory when open. + pass = Chronicle.HudVisible; + detail = $"hud={Chronicle.HudVisible} memory={Chronicle.MemoryCount} (tabs removed)"; break; } default: diff --git a/IdleSpectator/Chronicle.cs b/IdleSpectator/Chronicle.cs index 1cb2342..9522034 100644 --- a/IdleSpectator/Chronicle.cs +++ b/IdleSpectator/Chronicle.cs @@ -11,13 +11,9 @@ public enum ChronicleKind Lover, Friend, World, - Other -} - -public enum ChronicleTab -{ - History, - World + Other, + /// Age chapter header row in World Memory (not a clickable landmark). + AgeChapter } public sealed class ChronicleEntry @@ -32,28 +28,39 @@ public sealed class ChronicleEntry public long OtherId; public string Name = ""; public string SpeciesId = ""; + /// Raw fact line (asserts / debug). public string Line = ""; + /// Chronicle-voice line for HUD; falls back to . + public string LoreLine = ""; public Vector3 Position; + public string AssetId = ""; + public string AgeId = ""; + public string AgeName = ""; + /// True when this row is a curated character legend in World Memory. + public bool IsLegend; /// Back-compat for harness / jump helpers. public long UnitId => SubjectId; - /// Plain date-prefixed line for logs / asserts. + public string HudLine => !string.IsNullOrEmpty(LoreLine) ? LoreLine : (Line ?? ""); + + /// Plain date-prefixed line for logs / asserts (uses lore when present). public string DisplayLine { get { + string body = HudLine; if (string.IsNullOrEmpty(DateLabel)) { - return Line ?? ""; + return body ?? ""; } - if (string.IsNullOrEmpty(Line)) + if (string.IsNullOrEmpty(body)) { return "[" + DateLabel + "]"; } - return "[" + DateLabel + "] " + Line; + return "[" + DateLabel + "] " + body; } } @@ -62,41 +69,48 @@ public sealed class ChronicleEntry { get { + string body = HudLine; if (string.IsNullOrEmpty(DateLabel)) { - return Line ?? ""; + return body ?? ""; } const string color = "#A8B4C8"; string stamped = "[" + DateLabel + "]"; - if (string.IsNullOrEmpty(Line)) + if (string.IsNullOrEmpty(body)) { return stamped; } - return stamped + " " + Line; + return stamped + " " + body; } } } /// -/// Session chronicles: per-character History + separate World feed (WorldLog Story/Epic). +/// Session chronicles: per-character History + World Memory landmarks (age chapters). /// public static class Chronicle { public const KeyCode ToggleKey = KeyCode.F9; - private const int MaxHistoryPerUnit = 40; - private const int MaxWorldEntries = 100; + public const int MaxHistoryPerUnit = 40; + private const int MaxLandmarks = 80; + private const int MaxLandmarksPerAge = 8; private static readonly Dictionary> Histories = new Dictionary>(); - private static readonly List WorldEntries = new List(); + private static readonly List MemoryLandmarks = new List(); private static readonly HashSet LoverPairKeys = new HashSet(); private static readonly HashSet FriendPairKeys = new HashSet(); private static readonly HashSet DeathLogged = new HashSet(); + private static readonly HashSet LandmarkDedupe = new HashSet(); + private static readonly Dictionary LandmarksPerAge = new Dictionary(); + private static bool _hudReady; private static long _lastHistorySubjectId; private static object _boundWorld; + private static string _currentAgeId = ""; + private static string _currentAgeName = ""; public static void OnSpectatorEnabled() { @@ -107,17 +121,22 @@ public static class Chronicle _boundWorld = world; LogService.LogInfo("[IdleSpectator] Chronicle cleared for new world session"); } + + RefreshAgeChapter(force: true); } - public static int Count => HistoryCountFor(CurrentHistorySubjectId()) + WorldCount; + public static int Count => HistoryCountFor(CurrentHistorySubjectId()) + MemoryCount; - public static int WorldCount + /// World Memory landmark count (replaces the old World feed). + public static int WorldCount => MemoryCount; + + public static int MemoryCount { get { - lock (WorldEntries) + lock (MemoryLandmarks) { - return WorldEntries.Count; + return MemoryLandmarks.Count; } } } @@ -126,6 +145,10 @@ public static class Chronicle public static long LastHistorySubjectId => _lastHistorySubjectId; + public static string CurrentAgeId => _currentAgeId; + + public static string CurrentAgeName => _currentAgeName; + public static int HistoryCountFor(long subjectId) { if (subjectId == 0) @@ -186,11 +209,17 @@ public static class Chronicle return newest; } + /// World Memory landmarks oldest→newest (HUD reverses for display). public static IReadOnlyList SnapshotWorld() { - lock (WorldEntries) + return SnapshotMemory(); + } + + public static IReadOnlyList SnapshotMemory() + { + lock (MemoryLandmarks) { - return WorldEntries.ToArray(); + return MemoryLandmarks.ToArray(); } } @@ -208,9 +237,9 @@ public static class Chronicle { get { - lock (WorldEntries) + lock (MemoryLandmarks) { - return WorldEntries.Count == 0 ? null : WorldEntries[WorldEntries.Count - 1]; + return MemoryLandmarks.Count == 0 ? null : MemoryLandmarks[MemoryLandmarks.Count - 1]; } } } @@ -282,12 +311,12 @@ public static class Chronicle if (ChronicleHud.IsReady) { _hudReady = true; - LogService.LogInfo("[IdleSpectator] Chronicle HUD ready (F9, History|World)"); + LogService.LogInfo("[IdleSpectator] World Memory HUD ready (F9)"); } } catch (System.Exception ex) { - LogService.LogInfo("[IdleSpectator] Chronicle HUD init failed: " + ex.Message); + LogService.LogInfo("[IdleSpectator] World Memory HUD init failed: " + ex.Message); } } @@ -298,15 +327,19 @@ public static class Chronicle Histories.Clear(); } - lock (WorldEntries) + lock (MemoryLandmarks) { - WorldEntries.Clear(); + MemoryLandmarks.Clear(); } LoverPairKeys.Clear(); FriendPairKeys.Clear(); DeathLogged.Clear(); + LandmarkDedupe.Clear(); + LandmarksPerAge.Clear(); _lastHistorySubjectId = 0; + _currentAgeId = ""; + _currentAgeName = ""; // Keep _boundWorld so re-enable on same map does not loop-clear. } @@ -348,12 +381,19 @@ public static class Chronicle public static bool JumpToLatest() { + // Prefer a Memory landmark when it can resolve a unit/position; else focus history. + ChronicleEntry memory = LatestWorld; + if (memory != null && memory.Kind != ChronicleKind.AgeChapter && JumpTo(memory)) + { + return true; + } + return JumpTo(Latest); } public static bool JumpTo(ChronicleEntry entry) { - if (entry == null) + if (entry == null || entry.Kind == ChronicleKind.AgeChapter) { return false; } @@ -393,7 +433,8 @@ public static class Chronicle return; } - // Keep last history subject sticky when focus dies / drops briefly. + RefreshAgeChapter(force: false); + if (MoveCamera.hasFocusUnit() && MoveCamera._focus_unit != null) { try @@ -413,7 +454,7 @@ public static class Chronicle ChronicleHud.UpdateLive(); } - /// Killer POV - character history only. + /// Killer POV - character history only (may promote to Memory if notable). public static void NoteKill(Actor killer, Actor victim) { if (!ModSettings.ChronicleEnabled || killer == null || victim == null) @@ -519,14 +560,16 @@ public static class Chronicle // ignore } - string line = label; - AppendWorld(unit, line, pos); - - // Mirror onto that unit's History when WorldLog points at someone. - if (unit != null) - { - AppendHistory(ChronicleKind.World, unit, null, line); - } + string assetId = message.asset_id ?? ""; + AppendLandmark( + ChronicleKind.World, + unit, + null, + label, + LoreProse.Rewrite(ChronicleKind.World, label, assetId), + pos, + assetId, + isLegend: false); } /// Harness: inject a life-event style line on the focused unit's History. @@ -539,8 +582,6 @@ public static class Chronicle Actor unit = MoveCamera._focus_unit; string kindPrefix = string.IsNullOrEmpty(prefix) ? "Killed" : prefix; - // Short tags keep the old "Killed: Name (species) - life event" shape. - // Long strings are treated as the full event line (dossier wrap tests). string line; if (kindPrefix.Length <= 24 && kindPrefix.IndexOf(' ') < 0) { @@ -556,7 +597,7 @@ public static class Chronicle return true; } - /// Harness: inject a World-feed line (does not touch character History). + /// Harness: inject a World Memory landmark (does not touch character History). public static bool ForceWorldNote(string label) { if (string.IsNullOrEmpty(label)) @@ -564,7 +605,15 @@ public static class Chronicle return false; } - AppendWorld(null, label, Vector3.zero); + AppendLandmark( + ChronicleKind.World, + null, + null, + label, + LoreProse.Rewrite(ChronicleKind.World, label), + Vector3.zero, + "harness", + isLegend: false); return true; } @@ -672,7 +721,9 @@ public static class Chronicle return; } + RefreshAgeChapter(force: false); StampWorldDate(out double worldTime, out string dateLabel); + string lore = LoreProse.Rewrite(kind, line); ChronicleEntry entry = new ChronicleEntry { CreatedAt = Time.unscaledTime, @@ -684,7 +735,10 @@ public static class Chronicle Name = SafeName(subject), SpeciesId = SpeciesOf(subject), Line = line, - Position = subject.current_position + LoreLine = lore, + Position = subject.current_position, + AgeId = _currentAgeId, + AgeName = _currentAgeName }; lock (Histories) @@ -703,40 +757,239 @@ public static class Chronicle } LogService.LogInfo($"[IdleSpectator][CHRONICLE][History:{subjectId}] {entry.DisplayLine}"); + + if (ShouldPromoteLegend(kind, subject, other)) + { + AppendLandmark( + kind, + subject, + other, + line, + lore, + subject.current_position, + "legend:" + kind, + isLegend: true); + } } - private static void AppendWorld(Actor unit, string line, Vector3 fallbackPosition) + private static bool ShouldPromoteLegend(ChronicleKind kind, Actor subject, Actor other) { - if (string.IsNullOrEmpty(line)) + if (kind == ChronicleKind.Other || kind == ChronicleKind.AgeChapter) + { + return false; + } + + return IsNotable(subject) || IsNotable(other); + } + + private static bool IsNotable(Actor actor) + { + if (actor == null) + { + return false; + } + + try + { + return actor.isFavorite() || actor.isKing() || actor.isCityLeader(); + } + catch + { + return false; + } + } + + private static void AppendLandmark( + ChronicleKind kind, + Actor subject, + Actor other, + string rawLine, + string loreLine, + Vector3 fallbackPosition, + string assetId, + bool isLegend) + { + if (string.IsNullOrEmpty(rawLine) && string.IsNullOrEmpty(loreLine)) { return; } + RefreshAgeChapter(force: false); + string ageId = string.IsNullOrEmpty(_currentAgeId) ? "unknown" : _currentAgeId; + + string dedupeKey = ageId + "|" + assetId + "|" + rawLine; + if (!LandmarkDedupe.Add(dedupeKey)) + { + return; + } + + lock (MemoryLandmarks) + { + if (!LandmarksPerAge.TryGetValue(ageId, out int ageCount)) + { + ageCount = 0; + } + + if (ageCount >= MaxLandmarksPerAge) + { + return; + } + + StampWorldDate(out double worldTime, out string dateLabel); + ChronicleEntry entry = new ChronicleEntry + { + CreatedAt = Time.unscaledTime, + WorldTime = worldTime, + DateLabel = dateLabel, + Kind = kind, + SubjectId = subject != null ? subject.getID() : 0, + OtherId = other != null ? other.getID() : 0, + Name = subject != null ? SafeName(subject) : "", + SpeciesId = subject != null ? SpeciesOf(subject) : "", + Line = rawLine ?? "", + LoreLine = string.IsNullOrEmpty(loreLine) ? (rawLine ?? "") : loreLine, + Position = subject != null ? subject.current_position : fallbackPosition, + AssetId = assetId ?? "", + AgeId = ageId, + AgeName = _currentAgeName, + IsLegend = isLegend + }; + + MemoryLandmarks.Add(entry); + LandmarksPerAge[ageId] = ageCount + 1; + while (MemoryLandmarks.Count > MaxLandmarks) + { + ChronicleEntry removed = MemoryLandmarks[0]; + MemoryLandmarks.RemoveAt(0); + if (!string.IsNullOrEmpty(removed.AgeId) + && LandmarksPerAge.TryGetValue(removed.AgeId, out int c) + && c > 0) + { + LandmarksPerAge[removed.AgeId] = c - 1; + } + } + + LogService.LogInfo("[IdleSpectator][MEMORY] " + entry.DisplayLine); + } + } + + private static void RefreshAgeChapter(bool force) + { + string ageId; + string ageName; + if (!TryReadCurrentAge(out ageId, out ageName)) + { + return; + } + + if (!force && ageId == _currentAgeId) + { + return; + } + + bool changed = ageId != _currentAgeId; + _currentAgeId = ageId; + _currentAgeName = ageName; + + if (!changed && !force) + { + return; + } + + if (!changed) + { + return; + } + + // New age chapter: reset per-age cap tracking for the new id (keep old counts). + if (!LandmarksPerAge.ContainsKey(ageId)) + { + LandmarksPerAge[ageId] = 0; + } + StampWorldDate(out double worldTime, out string dateLabel); - ChronicleEntry entry = new ChronicleEntry + ChronicleEntry chapter = new ChronicleEntry { CreatedAt = Time.unscaledTime, WorldTime = worldTime, DateLabel = dateLabel, - Kind = ChronicleKind.World, - SubjectId = unit != null ? unit.getID() : 0, - OtherId = 0, - Name = unit != null ? SafeName(unit) : "", - SpeciesId = unit != null ? SpeciesOf(unit) : "", - Line = line, - Position = unit != null ? unit.current_position : fallbackPosition + Kind = ChronicleKind.AgeChapter, + Line = ageName, + LoreLine = ageName, + AgeId = ageId, + AgeName = ageName, + AssetId = "age:" + ageId }; - lock (WorldEntries) + lock (MemoryLandmarks) { - WorldEntries.Add(entry); - while (WorldEntries.Count > MaxWorldEntries) + // Avoid duplicate chapter headers for the same age. + for (int i = MemoryLandmarks.Count - 1; i >= 0; i--) { - WorldEntries.RemoveAt(0); + ChronicleEntry e = MemoryLandmarks[i]; + if (e != null && e.Kind == ChronicleKind.AgeChapter && e.AgeId == ageId) + { + return; + } + } + + MemoryLandmarks.Add(chapter); + while (MemoryLandmarks.Count > MaxLandmarks) + { + MemoryLandmarks.RemoveAt(0); } } - LogService.LogInfo("[IdleSpectator][CHRONICLE][World] " + entry.DisplayLine); + LogService.LogInfo("[IdleSpectator][MEMORY] Age chapter: " + ageName); + } + + private static bool TryReadCurrentAge(out string ageId, out string ageName) + { + ageId = ""; + ageName = ""; + try + { + if (World.world == null || World.world.era_manager == null) + { + return false; + } + + WorldAgeAsset age = World.world.era_manager.getCurrentAge(); + if (age == null) + { + return false; + } + + ageId = age.id ?? "unknown"; + ageName = ageId; + try + { + string locale = age.getLocaleID(); + if (!string.IsNullOrEmpty(locale)) + { + string translated = LocalizedTextManager.getText(locale); + if (!string.IsNullOrEmpty(translated)) + { + ageName = translated; + } + } + } + catch + { + // keep id + } + + if (string.IsNullOrEmpty(ageName)) + { + ageName = ageId; + } + + return true; + } + catch + { + return false; + } } /// @@ -761,7 +1014,6 @@ public static class Chronicle return; } - // raw: [day, month, year] - UI uses month + year only string monthName = Date.formatMonth(raw[1]); if (string.IsNullOrEmpty(monthName)) { diff --git a/IdleSpectator/ChronicleHud.cs b/IdleSpectator/ChronicleHud.cs index 32f3aa4..6b79758 100644 --- a/IdleSpectator/ChronicleHud.cs +++ b/IdleSpectator/ChronicleHud.cs @@ -7,41 +7,43 @@ using UnityEngine.UI; namespace IdleSpectator; /// -/// Compact bottom-right chronicle card with History | World tabs and vanilla icons. +/// Compact bottom-right World Memory card: age chapters + lore landmarks (F9). +/// Sized in canvas units (same scaler as the dossier) to stay clear of top-right. /// public static class ChronicleHud { - private const float PanelWidth = 380f; - private const float PanelHeight = 280f; - private const int MaxVisibleRows = 24; - private const float RowHeightMin = 28f; - private const float KindIconSize = 14f; - private const float TitleIconSize = 16f; + // 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; + 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 static GameObject _root; + private static RectTransform _rootRt; private static RectTransform _content; private static Text _titleText; private static Image _titleIcon; - private static Text _historyTabLabel; - private static Text _worldTabLabel; - private static Image _historyTabBg; - private static Image _worldTabBg; private static readonly List Rows = new List(); private static bool _visible; private static int _lastCount = -1; - private static long _lastSubjectId = -1; - private static ChronicleTab _tab = ChronicleTab.History; + private static string _lastAgeId = ""; public static bool Visible => _visible && _root != null && _root.activeSelf; public static bool IsReady => _root != null; - public static ChronicleTab ActiveTab => _tab; - public static void EnsureBuilt() { + DestroyOrphanHuds(); + if (_root != null) { + ApplyRootPlacement(); return; } @@ -56,69 +58,73 @@ public static class ChronicleHud return; } - _root = new GameObject("IdleSpectatorChronicleHud", typeof(RectTransform), typeof(Image), typeof(CanvasGroup)); + _root = new GameObject("IdleSpectatorWorldMemoryHud", typeof(RectTransform), typeof(Image), typeof(CanvasGroup)); _root.transform.SetParent(canvas.transform, false); - RectTransform rt = _root.GetComponent(); - rt.anchorMin = new Vector2(1f, 0f); - rt.anchorMax = new Vector2(1f, 0f); - rt.pivot = new Vector2(1f, 0f); - rt.sizeDelta = new Vector2(PanelWidth, PanelHeight); - rt.anchoredPosition = new Vector2(-12f, 56f); + _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.82f)); + HudCanvas.StylePanel(bg, _root.transform, new Color(0.07f, 0.08f, 0.1f, 0.88f)); CanvasGroup group = _root.GetComponent(); group.blocksRaycasts = true; group.interactable = true; - BuildTabButton(_root.transform, "HistoryTab", "History", "iconBooks", -6f, 0f, - out _historyTabBg, out _historyTabLabel, () => SetTab(ChronicleTab.History)); - BuildTabButton(_root.transform, "WorldTab", "World", "iconWar", -6f, 88f, - out _worldTabBg, out _worldTabLabel, () => SetTab(ChronicleTab.World)); - - Text f9 = HudCanvas.MakeText(_root.transform, "F9", "F9", 9); + 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(28f, 16f); - f9Rt.anchoredPosition = new Vector2(-8f, -8f); + 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; _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(16f, -34f); + titleIconRt.anchoredPosition = new Vector2(14f, -14f); + HudIcons.Apply(_titleIcon, HudIcons.FromUiIcon("iconBooks") ?? HudIcons.FromUiIcon("iconWar")); - _titleText = HudCanvas.MakeText(_root.transform, "Title", "", 9); + _titleText = HudCanvas.MakeText(_root.transform, "Title", "World Memory", 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(28f, -44f); - titleRt.offsetMax = new Vector2(-10f, -28f); + titleRt.offsetMin = new Vector2(24f, -24f); + titleRt.offsetMax = new Vector2(-28f, -4f); _titleText.alignment = TextAnchor.MiddleLeft; _titleText.color = new Color(0.78f, 0.8f, 0.84f, 1f); + _titleText.fontStyle = FontStyle.Bold; + _titleText.raycastTarget = false; + _titleText.horizontalOverflow = HorizontalWrapMode.Overflow; - GameObject scrollGo = new GameObject("Scroll", typeof(RectTransform), typeof(Image), typeof(ScrollRect), typeof(Mask)); + GameObject scrollGo = new GameObject( + "Scroll", + typeof(RectTransform), + typeof(Image), + typeof(ScrollRect), + typeof(RectMask2D)); scrollGo.transform.SetParent(_root.transform, false); RectTransform scrollRt = scrollGo.GetComponent(); scrollRt.anchorMin = new Vector2(0f, 0f); scrollRt.anchorMax = new Vector2(1f, 1f); - scrollRt.offsetMin = new Vector2(6f, 6f); - scrollRt.offsetMax = new Vector2(-6f, -48f); + scrollRt.offsetMin = new Vector2(5f, 5f); + scrollRt.offsetMax = new Vector2(-5f, -26f); Image scrollBg = scrollGo.GetComponent(); scrollBg.color = new Color(0f, 0f, 0f, 0.08f); - Mask mask = scrollGo.GetComponent(); - mask.showMaskGraphic = false; + scrollBg.raycastTarget = true; - GameObject contentGo = new GameObject("Content", typeof(RectTransform), typeof(VerticalLayoutGroup), typeof(ContentSizeFitter)); + GameObject contentGo = new GameObject( + "Content", + typeof(RectTransform), + typeof(VerticalLayoutGroup), + typeof(ContentSizeFitter)); contentGo.transform.SetParent(scrollGo.transform, false); _content = contentGo.GetComponent(); _content.anchorMin = new Vector2(0f, 1f); @@ -133,7 +139,7 @@ public static class ChronicleHud layout.childControlWidth = true; layout.childForceExpandHeight = false; layout.childForceExpandWidth = true; - layout.spacing = 3f; + layout.spacing = 2f; layout.padding = new RectOffset(0, 0, 0, 0); ContentSizeFitter fitter = contentGo.GetComponent(); @@ -147,17 +153,111 @@ public static class ChronicleHud scroll.movementType = ScrollRect.MovementType.Clamped; scroll.viewport = scrollRt; - RefreshTabChrome(); _root.SetActive(false); _visible = false; - LogService.LogInfo("[IdleSpectator] Chronicle HUD ready (F9, History|World + icons)"); + LogService.LogInfo("[IdleSpectator] World Memory HUD ready (F9, compact)"); } - public static void SetTab(ChronicleTab tab) + private static void DestroyOrphanHuds() { - _tab = tab; - RefreshTabChrome(); - Rebuild(force: true); + // Hot-reload / rename can leave old full-screen-looking cards in the canvas. + try + { + Canvas canvas = HudCanvas.Resolve(); + if (canvas == null) + { + return; + } + + for (int i = canvas.transform.childCount - 1; i >= 0; i--) + { + Transform child = canvas.transform.GetChild(i); + if (child == null) + { + continue; + } + + string n = child.name; + if (n != "IdleSpectatorChronicleHud" && n != "IdleSpectatorWorldMemoryHud") + { + continue; + } + + if (_root != null && child.gameObject == _root) + { + continue; + } + + Object.Destroy(child.gameObject); + } + } + catch + { + // ignore + } + } + + private static void ApplyRootPlacement() + { + if (_root == null) + { + return; + } + + if (_rootRt == null) + { + _rootRt = _root.GetComponent(); + } + + if (_rootRt == null) + { + return; + } + + // Bottom-right, same canvas units as the dossier so PlayerConfig UI size scales both. + _rootRt.anchorMin = new Vector2(1f, 0f); + _rootRt.anchorMax = new Vector2(1f, 0f); + _rootRt.pivot = new Vector2(1f, 0f); + _rootRt.sizeDelta = new Vector2(PanelWidth, PanelHeight); + _rootRt.anchoredPosition = new Vector2(-ScreenInset, BottomClearance); + _rootRt.localScale = Vector3.one; + } + + public static bool IsPointerOverPanel() + { + if (_rootRt == null || _root == null || !_root.activeInHierarchy) + { + return false; + } + + return RectTransformUtility.RectangleContainsScreenPoint(_rootRt, Input.mousePosition, null); + } + + public static bool TryGetScreenPixelRect(out Rect pixelRect) + { + pixelRect = default; + if (_rootRt == null || _root == null || !_root.activeInHierarchy) + { + return false; + } + + Vector3[] corners = new Vector3[4]; + _rootRt.GetWorldCorners(corners); + float xMin = float.MaxValue; + float yMin = float.MaxValue; + float xMax = float.MinValue; + float yMax = float.MinValue; + for (int i = 0; i < 4; i++) + { + Vector2 sp = RectTransformUtility.WorldToScreenPoint(null, corners[i]); + xMin = Mathf.Min(xMin, sp.x); + yMin = Mathf.Min(yMin, sp.y); + xMax = Mathf.Max(xMax, sp.x); + yMax = Mathf.Max(yMax, sp.y); + } + + pixelRect = Rect.MinMaxRect(xMin, yMin, xMax, yMax); + return pixelRect.width > 8f && pixelRect.height > 8f; } public static void SetVisible(bool visible) @@ -168,6 +268,7 @@ public static class ChronicleHud return; } + ApplyRootPlacement(); _visible = visible; _root.SetActive(visible); if (visible) @@ -188,12 +289,11 @@ public static class ChronicleHud return; } - long subjectId = Chronicle.CurrentHistorySubjectId(); - int count = _tab == ChronicleTab.History - ? Chronicle.HistoryCountFor(subjectId) - : Chronicle.WorldCount; + ApplyRootPlacement(); - if (count != _lastCount || subjectId != _lastSubjectId) + int count = Chronicle.MemoryCount; + string ageId = Chronicle.CurrentAgeId; + if (count != _lastCount || ageId != _lastAgeId) { Rebuild(force: true); } @@ -207,69 +307,40 @@ public static class ChronicleHud return; } + ApplyRootPlacement(); ClearRows(); - RefreshTabChrome(); - long subjectId = Chronicle.CurrentHistorySubjectId(); - _lastSubjectId = subjectId; + IReadOnlyList entries = Chronicle.SnapshotMemory(); + _lastCount = entries.Count; + _lastAgeId = Chronicle.CurrentAgeId; - IReadOnlyList entries; - if (_tab == ChronicleTab.World) + string ageTitle = Chronicle.CurrentAgeName; + if (_titleText != null) { - entries = Chronicle.SnapshotWorld(); - _lastCount = entries.Count; - if (_titleText != null) - { - _titleText.text = "World events"; - } - - HudIcons.Apply(_titleIcon, HudIcons.FromUiIcon("iconWar") ?? HudIcons.FromUiIcon("iconBooks")); - - if (entries.Count == 0) - { - AddEmpty("No world events yet"); - return; - } - } - else - { - entries = Chronicle.SnapshotForFocus(); - _lastCount = entries.Count; - string title = Chronicle.CurrentHistoryTitle(); - if (_titleText != null) - { - _titleText.text = string.IsNullOrEmpty(title) - ? "Focus a creature to see history" - : title; - } - - Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null; - if (focus != null) - { - HudIcons.Apply(_titleIcon, HudIcons.FromActor(focus)); - } - else - { - HudIcons.Apply(_titleIcon, HudIcons.FromUiIcon("iconBooks")); - } - - if (subjectId == 0) - { - AddEmpty("Focus a creature to see history"); - return; - } - - if (entries.Count == 0) - { - AddEmpty("No history yet"); - return; - } + _titleText.text = string.IsNullOrEmpty(ageTitle) + ? "World Memory" + : "World Memory · " + ageTitle; } + if (entries.Count == 0) + { + AddEmpty("No landmarks yet"); + return; + } + + List display = BuildDisplayOrder(entries); int shown = 0; - for (int i = entries.Count - 1; i >= 0; i--) + for (int i = 0; i < display.Count; i++) { - GameObject row = BuildClickableRow(entries[i], shown); + ChronicleEntry entry = display[i]; + if (entry == null) + { + continue; + } + + GameObject row = entry.Kind == ChronicleKind.AgeChapter + ? BuildChapterRow(entry) + : BuildClickableRow(entry, shown); row.transform.SetParent(_content, false); Rows.Add(row); shown++; @@ -280,6 +351,89 @@ public static class ChronicleHud } } + private static List BuildDisplayOrder(IReadOnlyList chronological) + { + var ageOrder = new List(); + var chapters = new Dictionary(); + var byAge = new Dictionary>(); + + for (int i = 0; i < chronological.Count; i++) + { + ChronicleEntry e = chronological[i]; + if (e == null) + { + continue; + } + + string ageId = string.IsNullOrEmpty(e.AgeId) ? "unknown" : e.AgeId; + if (e.Kind == ChronicleKind.AgeChapter) + { + chapters[ageId] = e; + if (!ageOrder.Contains(ageId)) + { + ageOrder.Add(ageId); + } + + continue; + } + + if (!byAge.TryGetValue(ageId, out List list)) + { + list = new List(); + byAge[ageId] = list; + if (!ageOrder.Contains(ageId)) + { + ageOrder.Add(ageId); + } + } + + list.Add(e); + } + + var result = new List(); + for (int a = ageOrder.Count - 1; a >= 0; a--) + { + string ageId = ageOrder[a]; + bool newestAge = a == ageOrder.Count - 1; + + if (chapters.TryGetValue(ageId, out ChronicleEntry chapter)) + { + result.Add(chapter); + } + else if (byAge.ContainsKey(ageId)) + { + string name = byAge[ageId].Count > 0 ? byAge[ageId][0].AgeName : ageId; + if (string.IsNullOrEmpty(name)) + { + name = ageId; + } + + result.Add(new ChronicleEntry + { + Kind = ChronicleKind.AgeChapter, + AgeId = ageId, + AgeName = name, + Line = name, + LoreLine = name + }); + } + + if (!byAge.TryGetValue(ageId, out List landmarks) || landmarks.Count == 0) + { + continue; + } + + int cap = newestAge ? MaxVisibleRows : 2; + int take = Mathf.Min(cap, landmarks.Count); + for (int i = landmarks.Count - 1; i >= landmarks.Count - take; i--) + { + result.Add(landmarks[i]); + } + } + + return result; + } + private static void AddEmpty(string text) { GameObject empty = BuildStaticRow(text); @@ -287,80 +441,6 @@ public static class ChronicleHud Rows.Add(empty); } - private static void BuildTabButton( - Transform parent, - string name, - string label, - string iconName, - float yFromTop, - float x, - out Image bg, - out Text text, - UnityAction onClick) - { - GameObject go = new GameObject(name, typeof(RectTransform), typeof(Image), typeof(Button)); - go.transform.SetParent(parent, false); - RectTransform rt = go.GetComponent(); - rt.anchorMin = new Vector2(0f, 1f); - rt.anchorMax = new Vector2(0f, 1f); - rt.pivot = new Vector2(0f, 1f); - rt.sizeDelta = new Vector2(82f, 18f); - rt.anchoredPosition = new Vector2(8f + x, yFromTop); - - bg = go.GetComponent(); - bg.color = new Color(0.15f, 0.16f, 0.2f, 0.9f); - - Image icon = HudCanvas.MakeIcon(go.transform, "Icon", 12f); - RectTransform iconRt = icon.GetComponent(); - iconRt.anchorMin = new Vector2(0f, 0.5f); - iconRt.anchorMax = new Vector2(0f, 0.5f); - iconRt.pivot = new Vector2(0.5f, 0.5f); - iconRt.anchoredPosition = new Vector2(10f, 0f); - HudIcons.Apply(icon, HudIcons.FromUiIcon(iconName)); - - text = HudCanvas.MakeText(go.transform, "Label", label, 9); - RectTransform textRt = text.GetComponent(); - textRt.anchorMin = Vector2.zero; - textRt.anchorMax = Vector2.one; - textRt.offsetMin = new Vector2(20f, 0f); - textRt.offsetMax = new Vector2(-2f, 0f); - text.alignment = TextAnchor.MiddleLeft; - text.raycastTarget = false; - - Button button = go.GetComponent