diff --git a/.cursor/skills/idle-spectator-e2e/SKILL.md b/.cursor/skills/idle-spectator-e2e/SKILL.md index 829e538..2720d26 100644 --- a/.cursor/skills/idle-spectator-e2e/SKILL.md +++ b/.cursor/skills/idle-spectator-e2e/SKILL.md @@ -112,6 +112,7 @@ A loaded world is still required. | `discovery` | Present dedupe + force drain + tip match | | `world_log` | Story/Epic interest interrupt via collector | | `chronicle_smoke` | History inject, death-cause samples, World feed isolation, History\|World tabs, HUD jump | +| `chronicle_subject_bench` | Timed Fallen list rebuilds at rising subject caps; recommends a budgeted max | | `smoke` | Short enable/focus health check | | `tip_match` | Tip asset must match focused unit | | `regression` | Shell suite of all gate scenarios above | diff --git a/IdleSpectator/AgentHarness.cs b/IdleSpectator/AgentHarness.cs index 8621d94..3daf8ad 100644 --- a/IdleSpectator/AgentHarness.cs +++ b/IdleSpectator/AgentHarness.cs @@ -427,6 +427,19 @@ public static class AgentHarness break; } + case "lore_refresh": + case "chronicle_refresh": + { + ChronicleHud.RefreshCastList(); + _cmdOk++; + Emit( + cmd, + ok: true, + detail: + $"stale={ChronicleHud.CastListStale} top='{ChronicleHud.LastListTopTitle}' tab={ChronicleHud.ActiveTab}"); + break; + } + case "lore_search": { Chronicle.ShowHud(); @@ -581,6 +594,166 @@ public static class AgentHarness break; } + case "chronicle_orphan_flood": + { + // Stress subject-cap pruning (does not need living units). + int n = Math.Max(1, cmd.count); + int made = 0; + for (int i = 0; i < n; i++) + { + long id = Chronicle.ForceOrphanHistory( + "Flood" + i, + "human", + 1, + "Flood", + AttackType.Age); + if (id != 0) + { + made++; + } + } + + bool ok = made == n + && Chronicle.HistorySubjectCount <= Chronicle.MaxHistorySubjects; + if (ok) + { + _cmdOk++; + } + else + { + _cmdFail++; + } + + Emit( + cmd, + ok, + detail: + $"made={made}/{n} subjects={Chronicle.HistorySubjectCount} max={Chronicle.MaxHistorySubjects} dossiers={Chronicle.FinalDossierCount}"); + break; + } + + case "chronicle_set_subject_cap": + { + int cap = Math.Max(40, cmd.count); + if (!string.IsNullOrEmpty(cmd.value) + && int.TryParse(cmd.value.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out int parsed)) + { + cap = parsed; + } + + Chronicle.SetMaxHistorySubjects(cap); + bool ok = Chronicle.MaxHistorySubjects == Mathf.Clamp(cap, 40, Chronicle.AbsoluteMaxHistorySubjects) + && Chronicle.HistorySubjectCount <= Chronicle.MaxHistorySubjects; + if (ok) + { + _cmdOk++; + } + else + { + _cmdFail++; + } + + Emit( + cmd, + ok, + detail: + $"cap={Chronicle.MaxHistorySubjects} subjects={Chronicle.HistorySubjectCount}"); + break; + } + + case "chronicle_subject_bench": + { + // value: comma-separated sizes, e.g. "320,640,1280,2560" + // count: events per subject (default 3). expect: max avg fallen ms budget (default 2). + string sizesRaw = string.IsNullOrEmpty(cmd.value) + ? "320,640,1280,2560,4096,8192" + : cmd.value; + int eventsPer = Math.Max(1, cmd.count); + if (eventsPer == 1 && string.IsNullOrEmpty(cmd.label)) + { + eventsPer = 3; + } + + float budgetMs = 2f; + if (!string.IsNullOrEmpty(cmd.expect) + && float.TryParse(cmd.expect, NumberStyles.Float, CultureInfo.InvariantCulture, out float parsedBudget)) + { + budgetMs = parsedBudget; + } + + string[] parts = sizesRaw.Split(','); + var reports = new List(); + int best = 0; + float bestAvg = -1f; + bool any = false; + for (int i = 0; i < parts.Length; i++) + { + string p = parts[i].Trim(); + if (!int.TryParse(p, NumberStyles.Integer, CultureInfo.InvariantCulture, out int size) + || size < 40) + { + continue; + } + + string report = Chronicle.BenchSubjectLoad(size, eventsPer, iters: 25); + reports.Add(report); + any = true; + + float avg = float.MaxValue; + const string needle = "fallenAvgMs="; + int idx = report.IndexOf(needle, StringComparison.Ordinal); + if (idx >= 0) + { + int start = idx + needle.Length; + int end = start; + while (end < report.Length + && (char.IsDigit(report[end]) || report[end] == '.')) + { + end++; + } + + float.TryParse( + report.Substring(start, end - start), + NumberStyles.Float, + CultureInfo.InvariantCulture, + out avg); + } + + // Highest size that stays within the ms budget. + if (avg <= budgetMs && size > best) + { + best = size; + bestAvg = avg; + } + } + + if (best <= 0) + { + best = Chronicle.DefaultMaxHistorySubjects; + bestAvg = budgetMs; + } + + Chronicle.SetMaxHistorySubjects(best); + Chronicle.ClearSession(); + bool ok = any && reports.Count > 0; + if (ok) + { + _cmdOk++; + } + else + { + _cmdFail++; + } + + string joined = string.Join(" || ", reports); + Emit( + cmd, + ok, + detail: + $"budgetMs={budgetMs:0.00} recommend={best} recommendAvgMs={bestAvg:0.00} capNow={Chronicle.MaxHistorySubjects} | {joined}"); + break; + } + case "lore_close": case "chronicle_close": { @@ -1545,6 +1718,23 @@ public static class AgentHarness detail = $"badKinds={bad} worldRows={worldRows} memory={snap.Count}"; break; } + case "chronicle_subjects_capped": + { + int subjects = Chronicle.HistorySubjectCount; + int dossiers = Chronicle.FinalDossierCount; + pass = subjects <= Chronicle.MaxHistorySubjects + && dossiers <= Chronicle.MaxHistorySubjects; + detail = + $"subjects={subjects} dossiers={dossiers} max={Chronicle.MaxHistorySubjects} revision={Chronicle.HistoryRevision}"; + break; + } + case "chronicle_memory_capped": + { + int memory = Chronicle.MemoryCount; + pass = memory <= Chronicle.MaxLandmarks; + detail = $"memory={memory} max={Chronicle.MaxLandmarks}"; + break; + } case "dossier_favorite": case "is_favorite": { @@ -1911,6 +2101,23 @@ public static class AgentHarness detail = $"top='{have}' want='{want}' tab={ChronicleHud.ActiveTab}"; break; } + case "lore_list_stale": + { + bool want = ParseBool(cmd.value, defaultValue: true); + if (string.IsNullOrEmpty(cmd.value) && !string.IsNullOrEmpty(cmd.expect) + && cmd.expect != "lore_list_stale") + { + want = ParseBool(cmd.expect, defaultValue: true); + } + + bool have = ChronicleHud.CastListStale; + bool castTab = ChronicleHud.ActiveTab == ChronicleHud.LoreTab.Living + || ChronicleHud.ActiveTab == ChronicleHud.LoreTab.Fallen; + pass = Chronicle.HudVisible && castTab && ChronicleHud.DetailUnitId == 0 && have == want; + detail = + $"stale={have} want={want} tab={ChronicleHud.ActiveTab} revision={Chronicle.HistoryRevision}"; + break; + } case "lore_recent": { int want = ParseCountExpect(cmd, defaultValue: 1); diff --git a/IdleSpectator/Chronicle.cs b/IdleSpectator/Chronicle.cs index bebf254..0072c70 100644 --- a/IdleSpectator/Chronicle.cs +++ b/IdleSpectator/Chronicle.cs @@ -134,8 +134,16 @@ public static class Chronicle { public const KeyCode ToggleKey = KeyCode.L; public const int MaxHistoryPerUnit = 40; - private const int MaxLandmarks = 80; + /// Default soft cap on keyed character histories (Living + Fallen archive subjects). + public const int DefaultMaxHistorySubjects = 100000; + /// Hard safety ceiling for . + public const int AbsoluteMaxHistorySubjects = 200000; + /// Soft cap on keyed character histories. Tunable via harness. + public static int MaxHistorySubjects { get; private set; } = DefaultMaxHistorySubjects; + public const int MaxLandmarks = 80; private const int MaxLandmarksPerAge = 8; + private const int MaxPairKeys = 1500; + private const int MaxLandmarkDedupe = 400; private static readonly Dictionary> Histories = new Dictionary>(); @@ -151,6 +159,9 @@ public static class Chronicle public const int MaxRecentFocus = 24; + /// Monotonic counter bumped on history/memory writes (cheap HUD dirty check). + public static int HistoryRevision { get; private set; } + private static bool _hudReady; private static long _lastHistorySubjectId; private static long _trackedFocusId; @@ -219,6 +230,137 @@ public static class Chronicle } } + /// How many distinct characters currently have History rows stored. + public static int HistorySubjectCount + { + get + { + lock (Histories) + { + return Histories.Count; + } + } + } + + public static int FinalDossierCount => FinalDossiers.Count; + + /// Harness / tuning: change subject cap and prune immediately. + public static void SetMaxHistorySubjects(int max) + { + MaxHistorySubjects = Mathf.Clamp(max, 40, AbsoluteMaxHistorySubjects); + lock (Histories) + { + PruneHistorySubjectsUnlocked(); + } + + PruneFinalDossiers(); + BumpRevision(); + } + + /// + /// Harness: fill to and time Fallen/Living list rebuilds. + /// Returns a single-line report of avg/max ms per size. + /// + public static string BenchSubjectLoad(int subjects, int eventsPerSubject = 3, int iters = 25) + { + subjects = Mathf.Clamp(subjects, 40, AbsoluteMaxHistorySubjects); + eventsPerSubject = Mathf.Clamp(eventsPerSubject, 1, MaxHistoryPerUnit); + iters = Mathf.Clamp(iters, 5, 200); + + int previousCap = MaxHistorySubjects; + ClearSession(); + SetMaxHistorySubjects(subjects); + FillBenchSubjectsUnlocked(subjects, eventsPerSubject); + + // Warmup + ListCharacters(max: 40, scope: CharacterListScope.Fallen); + ListCharacters(max: 40, scope: CharacterListScope.Living); + + int have = HistorySubjectCount; + + double fallenSum = 0; + double fallenMax = 0; + double livingSum = 0; + double livingMax = 0; + for (int i = 0; i < iters; i++) + { + float t0 = Time.realtimeSinceStartup; + ListCharacters(max: 40, scope: CharacterListScope.Fallen); + float fallenMs = (Time.realtimeSinceStartup - t0) * 1000f; + fallenSum += fallenMs; + if (fallenMs > fallenMax) + { + fallenMax = fallenMs; + } + + t0 = Time.realtimeSinceStartup; + ListCharacters(max: 40, scope: CharacterListScope.Living); + float livingMs = (Time.realtimeSinceStartup - t0) * 1000f; + livingSum += livingMs; + if (livingMs > livingMax) + { + livingMax = livingMs; + } + } + + float pruneMs; + { + float t0 = Time.realtimeSinceStartup; + SetMaxHistorySubjects(Mathf.Max(40, subjects / 2)); + pruneMs = (Time.realtimeSinceStartup - t0) * 1000f; + } + + string report = + $"n={subjects} events={eventsPerSubject} have={have} " + + $"fallenAvgMs={fallenSum / iters:0.00} fallenMaxMs={fallenMax:0.00} " + + $"livingAvgMs={livingSum / iters:0.00} livingMaxMs={livingMax:0.00} " + + $"pruneMs={pruneMs:0.00}"; + + LogService.LogInfo("[IdleSpectator][BENCH] " + report); + SetMaxHistorySubjects(previousCap); + return report; + } + + /// Fast synthetic fill for benches (skips lore rewrite / age refresh). + private static void FillBenchSubjectsUnlocked(int subjects, int eventsPerSubject) + { + float now = Time.unscaledTime; + lock (Histories) + { + for (int i = 0; i < subjects; i++) + { + long id = _orphanSeq--; + var list = new List(eventsPerSubject); + for (int e = 0; e < eventsPerSubject; e++) + { + list.Add(new ChronicleEntry + { + CreatedAt = now + (i * 0.0001f) + (e * 0.00001f), + WorldTime = i, + DateLabel = "Bench", + Kind = ChronicleKind.Death, + SubjectId = id, + Name = "Bench" + i, + SpeciesId = "human", + Line = "Bench death", + LoreLine = "Bench death", + DeathManner = DeathManner.OldAge, + AttackTypeName = "Age" + }); + } + + Histories[id] = list; + FinalDossiers[id] = UnitDossier.FromFallenArchive( + id, "Bench" + i, "human", DeathManner.OldAge); + } + + PruneHistorySubjectsUnlocked(); + } + + PruneFinalDossiers(); + BumpRevision(); + } + public static IReadOnlyList SnapshotForFocus() { return SnapshotForSubject(CurrentHistorySubjectId()); @@ -400,9 +542,18 @@ public static class Chronicle _trackedFocusId = 0; _currentAgeId = ""; _currentAgeName = ""; + BumpRevision(); // Keep _boundWorld so re-enable on same map does not loop-clear. } + private static void BumpRevision() + { + unchecked + { + HistoryRevision++; + } + } + public static void PollInput() { if (!Config.game_loaded || !ModSettings.ChronicleEnabled) @@ -1015,6 +1166,8 @@ public static class Chronicle return; } + CapPairKeys(LoverPairKeys); + AppendHistory(ChronicleKind.Lover, a, b, $"Became lovers with {SafeName(b)}"); AppendHistory(ChronicleKind.Lover, b, a, $"Became lovers with {SafeName(a)}"); } @@ -1032,6 +1185,8 @@ public static class Chronicle return; } + CapPairKeys(FriendPairKeys); + AppendHistory(ChronicleKind.Friend, a, b, $"Befriended {SafeName(b)}"); AppendHistory(ChronicleKind.Friend, b, a, $"Befriended {SafeName(a)}"); } @@ -1201,13 +1356,14 @@ public static class Chronicle { list.RemoveAt(0); } + + PruneHistorySubjectsUnlocked(); } - NoteFocusId(id); - LogService.LogInfo( - $"[IdleSpectator][CHRONICLE] orphan history id={id} count={count} name={who} manner={manner} killer={killerId}"); + BumpRevision(); FinalDossiers[id] = UnitDossier.FromFallenArchive(id, who, sp, manner); + PruneFinalDossiers(); return id; } @@ -1459,6 +1615,7 @@ public static class Chronicle } FinalDossiers[id] = dossier; + PruneFinalDossiers(); } /// Last living dossier for a fallen subject, or a minimal archive rebuild. @@ -1720,9 +1877,171 @@ public static class Chronicle { list.RemoveAt(0); } + + PruneHistorySubjectsUnlocked(); } - LogService.LogInfo($"[IdleSpectator][CHRONICLE][History:{subjectId}] {entry.DisplayLine}"); + _lastHistorySubjectId = subjectId; + BumpRevision(); + } + + /// + /// Drop oldest dormant subjects when over . + /// Caller must hold the Histories lock. + /// + private static void PruneHistorySubjectsUnlocked() + { + if (Histories.Count <= MaxHistorySubjects) + { + return; + } + + var keep = new HashSet(); + if (_trackedFocusId != 0) + { + keep.Add(_trackedFocusId); + } + + if (_lastHistorySubjectId != 0) + { + keep.Add(_lastHistorySubjectId); + } + + lock (RecentFocusIds) + { + for (int i = 0; i < RecentFocusIds.Count; i++) + { + keep.Add(RecentFocusIds[i]); + } + } + + var ranked = new List<(long id, float lastAt)>(Histories.Count); + foreach (KeyValuePair> kv in Histories) + { + if (keep.Contains(kv.Key)) + { + continue; + } + + float lastAt = 0f; + List list = kv.Value; + if (list != null && list.Count > 0) + { + ChronicleEntry last = list[list.Count - 1]; + if (last != null) + { + lastAt = last.CreatedAt; + } + } + + ranked.Add((kv.Key, lastAt)); + } + + ranked.Sort((a, b) => a.lastAt.CompareTo(b.lastAt)); + + int need = Histories.Count - MaxHistorySubjects; + for (int i = 0; i < ranked.Count && need > 0; i++) + { + long id = ranked[i].id; + Histories.Remove(id); + DeathLogged.Remove(id); + FinalDossiers.Remove(id); + need--; + } + } + + private static void PruneFinalDossiers() + { + if (FinalDossiers.Count <= MaxHistorySubjects) + { + return; + } + + HashSet histIds; + lock (Histories) + { + histIds = new HashSet(Histories.Keys); + } + + var orphan = new List(); + foreach (long id in FinalDossiers.Keys) + { + if (!histIds.Contains(id)) + { + orphan.Add(id); + } + } + + for (int i = 0; i < orphan.Count; i++) + { + FinalDossiers.Remove(orphan[i]); + } + + if (FinalDossiers.Count <= MaxHistorySubjects) + { + return; + } + + // Still over: drop extras not recently focused. + var protect = new HashSet(); + if (_trackedFocusId != 0) + { + protect.Add(_trackedFocusId); + } + + if (_lastHistorySubjectId != 0) + { + protect.Add(_lastHistorySubjectId); + } + + lock (RecentFocusIds) + { + for (int i = 0; i < RecentFocusIds.Count; i++) + { + protect.Add(RecentFocusIds[i]); + } + } + + var drop = new List(); + foreach (long id in FinalDossiers.Keys) + { + if (protect.Contains(id)) + { + continue; + } + + drop.Add(id); + if (FinalDossiers.Count - drop.Count <= MaxHistorySubjects) + { + break; + } + } + + for (int i = 0; i < drop.Count; i++) + { + FinalDossiers.Remove(drop[i]); + } + } + + private static void CapPairKeys(HashSet keys) + { + if (keys == null || keys.Count <= MaxPairKeys) + { + return; + } + + // Dedupes are best-effort; clearing under pressure avoids unbounded growth. + keys.Clear(); + } + + private static void CapLandmarkDedupe() + { + if (LandmarkDedupe.Count <= MaxLandmarkDedupe) + { + return; + } + + LandmarkDedupe.Clear(); } private static void AppendLandmark( @@ -1749,6 +2068,8 @@ public static class Chronicle return; } + CapLandmarkDedupe(); + lock (MemoryLandmarks) { if (!LandmarksPerAge.TryGetValue(ageId, out int ageCount)) @@ -1758,6 +2079,7 @@ public static class Chronicle if (ageCount >= MaxLandmarksPerAge) { + LandmarkDedupe.Remove(dedupeKey); return; } @@ -1788,16 +2110,22 @@ public static class Chronicle { ChronicleEntry removed = MemoryLandmarks[0]; MemoryLandmarks.RemoveAt(0); - if (!string.IsNullOrEmpty(removed.AgeId) - && LandmarksPerAge.TryGetValue(removed.AgeId, out int c) - && c > 0) + if (removed != null) { - LandmarksPerAge[removed.AgeId] = c - 1; + if (!string.IsNullOrEmpty(removed.AgeId) + && LandmarksPerAge.TryGetValue(removed.AgeId, out int c) + && c > 0) + { + LandmarksPerAge[removed.AgeId] = c - 1; + } + + string oldKey = (removed.AgeId ?? "") + "|" + (removed.AssetId ?? "") + "|" + (removed.Line ?? ""); + LandmarkDedupe.Remove(oldKey); } } - - LogService.LogInfo("[IdleSpectator][MEMORY] " + entry.DisplayLine); } + + BumpRevision(); } private static void RefreshAgeChapter(bool force) @@ -1867,7 +2195,7 @@ public static class Chronicle } } - LogService.LogInfo("[IdleSpectator][MEMORY] Age chapter: " + ageName); + BumpRevision(); } private static bool TryReadCurrentAge(out string ageId, out string ageName) diff --git a/IdleSpectator/ChronicleHud.cs b/IdleSpectator/ChronicleHud.cs index 77d51de..5e4c651 100644 --- a/IdleSpectator/ChronicleHud.cs +++ b/IdleSpectator/ChronicleHud.cs @@ -57,9 +57,13 @@ public static class ChronicleHud private static InputField _searchField; private static Button _favFilterBtn; private static Image _favFilterIcon; + private static Button _refreshBtn; + private static Image _refreshBg; + private static Text _refreshLabel; private static bool _favoritesOnly; private static string _searchText = ""; private static int _lastCharFingerprint = int.MinValue; + private static int _builtAtRevision = int.MinValue; private static long _detailUnitId; private static bool _followFocus; private static GameObject _backBtn; @@ -71,6 +75,10 @@ public static class ChronicleHud /// Title of the first row in the last Living/Fallen list rebuild (harness). public static string LastListTopTitle { get; private set; } = ""; + /// True when Living/Fallen list is older than new chronicle events (needs Refresh). + public static bool CastListStale => + Visible && IsCastTab && _detailUnitId == 0 && Chronicle.HistoryRevision != _builtAtRevision; + public static bool Visible => _visible && _root != null && _root.activeSelf; public static bool IsReady => _root != null; @@ -280,7 +288,7 @@ public static class ChronicleHud searchRt.anchorMin = new Vector2(0f, 0f); searchRt.anchorMax = new Vector2(1f, 1f); searchRt.offsetMin = Vector2.zero; - searchRt.offsetMax = new Vector2(-20f, 0f); + searchRt.offsetMax = new Vector2(-40f, 0f); Image searchBg = searchGo.GetComponent(); searchBg.color = new Color(0.05f, 0.06f, 0.08f, 0.95f); searchBg.raycastTarget = true; @@ -306,6 +314,16 @@ public static class ChronicleHud _searchField.customCaretColor = true; _searchField.onValueChanged.AddListener(OnSearchChanged); + _refreshBtn = BuildTextTool(_charTools.transform, "Refresh", "↻", RefreshCastList); + _refreshBg = _refreshBtn.GetComponent(); + _refreshLabel = _refreshBtn.transform.Find("Label")?.GetComponent(); + RectTransform refreshRt = _refreshBtn.GetComponent(); + refreshRt.anchorMin = new Vector2(1f, 0f); + refreshRt.anchorMax = new Vector2(1f, 1f); + refreshRt.pivot = new Vector2(1f, 0.5f); + refreshRt.sizeDelta = new Vector2(18f, 0f); + refreshRt.anchoredPosition = new Vector2(-19f, 0f); + _favFilterBtn = BuildIconTool(_charTools.transform, "FavFilter", HudIcons.Favorite(), ToggleFavoritesOnly); _favFilterIcon = _favFilterBtn.transform.Find("Icon")?.GetComponent(); RectTransform favRt = _favFilterBtn.GetComponent(); @@ -326,6 +344,25 @@ public static class ChronicleHud _charTools.SetActive(false); RefreshFavFilterVisual(); + RefreshStaleVisual(); + } + + private static Button BuildTextTool(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.12f, 0.13f, 0.16f, 0.95f); + bg.raycastTarget = true; + Button button = go.GetComponent