Enhance Chronicle and HUD functionality with new subject management features. Implemented subject cap adjustments, timed rebuilds for Fallen/Living lists, and added refresh capabilities to the HUD. Updated test scenarios to validate new behaviors and ensure proper subject handling.

This commit is contained in:
DazedAnon 2026-07-14 22:29:09 -05:00
parent 88cf59a6aa
commit 0b23a15618
6 changed files with 695 additions and 36 deletions

View file

@ -112,6 +112,7 @@ A loaded world is still required.
| `discovery` | Present dedupe + force drain + tip match | | `discovery` | Present dedupe + force drain + tip match |
| `world_log` | Story/Epic interest interrupt via collector | | `world_log` | Story/Epic interest interrupt via collector |
| `chronicle_smoke` | History inject, death-cause samples, World feed isolation, History\|World tabs, HUD jump | | `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 | | `smoke` | Short enable/focus health check |
| `tip_match` | Tip asset must match focused unit | | `tip_match` | Tip asset must match focused unit |
| `regression` | Shell suite of all gate scenarios above | | `regression` | Shell suite of all gate scenarios above |

View file

@ -427,6 +427,19 @@ public static class AgentHarness
break; 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": case "lore_search":
{ {
Chronicle.ShowHud(); Chronicle.ShowHud();
@ -581,6 +594,166 @@ public static class AgentHarness
break; 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<string>();
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 "lore_close":
case "chronicle_close": case "chronicle_close":
{ {
@ -1545,6 +1718,23 @@ public static class AgentHarness
detail = $"badKinds={bad} worldRows={worldRows} memory={snap.Count}"; detail = $"badKinds={bad} worldRows={worldRows} memory={snap.Count}";
break; 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 "dossier_favorite":
case "is_favorite": case "is_favorite":
{ {
@ -1911,6 +2101,23 @@ public static class AgentHarness
detail = $"top='{have}' want='{want}' tab={ChronicleHud.ActiveTab}"; detail = $"top='{have}' want='{want}' tab={ChronicleHud.ActiveTab}";
break; 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": case "lore_recent":
{ {
int want = ParseCountExpect(cmd, defaultValue: 1); int want = ParseCountExpect(cmd, defaultValue: 1);

View file

@ -134,8 +134,16 @@ public static class Chronicle
{ {
public const KeyCode ToggleKey = KeyCode.L; public const KeyCode ToggleKey = KeyCode.L;
public const int MaxHistoryPerUnit = 40; public const int MaxHistoryPerUnit = 40;
private const int MaxLandmarks = 80; /// <summary>Default soft cap on keyed character histories (Living + Fallen archive subjects).</summary>
public const int DefaultMaxHistorySubjects = 100000;
/// <summary>Hard safety ceiling for <see cref="SetMaxHistorySubjects"/>.</summary>
public const int AbsoluteMaxHistorySubjects = 200000;
/// <summary>Soft cap on keyed character histories. Tunable via harness.</summary>
public static int MaxHistorySubjects { get; private set; } = DefaultMaxHistorySubjects;
public const int MaxLandmarks = 80;
private const int MaxLandmarksPerAge = 8; private const int MaxLandmarksPerAge = 8;
private const int MaxPairKeys = 1500;
private const int MaxLandmarkDedupe = 400;
private static readonly Dictionary<long, List<ChronicleEntry>> Histories = private static readonly Dictionary<long, List<ChronicleEntry>> Histories =
new Dictionary<long, List<ChronicleEntry>>(); new Dictionary<long, List<ChronicleEntry>>();
@ -151,6 +159,9 @@ public static class Chronicle
public const int MaxRecentFocus = 24; public const int MaxRecentFocus = 24;
/// <summary>Monotonic counter bumped on history/memory writes (cheap HUD dirty check).</summary>
public static int HistoryRevision { get; private set; }
private static bool _hudReady; private static bool _hudReady;
private static long _lastHistorySubjectId; private static long _lastHistorySubjectId;
private static long _trackedFocusId; private static long _trackedFocusId;
@ -219,6 +230,137 @@ public static class Chronicle
} }
} }
/// <summary>How many distinct characters currently have History rows stored.</summary>
public static int HistorySubjectCount
{
get
{
lock (Histories)
{
return Histories.Count;
}
}
}
public static int FinalDossierCount => FinalDossiers.Count;
/// <summary>Harness / tuning: change subject cap and prune immediately.</summary>
public static void SetMaxHistorySubjects(int max)
{
MaxHistorySubjects = Mathf.Clamp(max, 40, AbsoluteMaxHistorySubjects);
lock (Histories)
{
PruneHistorySubjectsUnlocked();
}
PruneFinalDossiers();
BumpRevision();
}
/// <summary>
/// Harness: fill to <paramref name="subjects"/> and time Fallen/Living list rebuilds.
/// Returns a single-line report of avg/max ms per size.
/// </summary>
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;
}
/// <summary>Fast synthetic fill for benches (skips lore rewrite / age refresh).</summary>
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<ChronicleEntry>(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<ChronicleEntry> SnapshotForFocus() public static IReadOnlyList<ChronicleEntry> SnapshotForFocus()
{ {
return SnapshotForSubject(CurrentHistorySubjectId()); return SnapshotForSubject(CurrentHistorySubjectId());
@ -400,9 +542,18 @@ public static class Chronicle
_trackedFocusId = 0; _trackedFocusId = 0;
_currentAgeId = ""; _currentAgeId = "";
_currentAgeName = ""; _currentAgeName = "";
BumpRevision();
// Keep _boundWorld so re-enable on same map does not loop-clear. // Keep _boundWorld so re-enable on same map does not loop-clear.
} }
private static void BumpRevision()
{
unchecked
{
HistoryRevision++;
}
}
public static void PollInput() public static void PollInput()
{ {
if (!Config.game_loaded || !ModSettings.ChronicleEnabled) if (!Config.game_loaded || !ModSettings.ChronicleEnabled)
@ -1015,6 +1166,8 @@ public static class Chronicle
return; return;
} }
CapPairKeys(LoverPairKeys);
AppendHistory(ChronicleKind.Lover, a, b, $"Became lovers with {SafeName(b)}"); AppendHistory(ChronicleKind.Lover, a, b, $"Became lovers with {SafeName(b)}");
AppendHistory(ChronicleKind.Lover, b, a, $"Became lovers with {SafeName(a)}"); AppendHistory(ChronicleKind.Lover, b, a, $"Became lovers with {SafeName(a)}");
} }
@ -1032,6 +1185,8 @@ public static class Chronicle
return; return;
} }
CapPairKeys(FriendPairKeys);
AppendHistory(ChronicleKind.Friend, a, b, $"Befriended {SafeName(b)}"); AppendHistory(ChronicleKind.Friend, a, b, $"Befriended {SafeName(b)}");
AppendHistory(ChronicleKind.Friend, b, a, $"Befriended {SafeName(a)}"); AppendHistory(ChronicleKind.Friend, b, a, $"Befriended {SafeName(a)}");
} }
@ -1201,13 +1356,14 @@ public static class Chronicle
{ {
list.RemoveAt(0); list.RemoveAt(0);
} }
PruneHistorySubjectsUnlocked();
} }
NoteFocusId(id); BumpRevision();
LogService.LogInfo(
$"[IdleSpectator][CHRONICLE] orphan history id={id} count={count} name={who} manner={manner} killer={killerId}");
FinalDossiers[id] = UnitDossier.FromFallenArchive(id, who, sp, manner); FinalDossiers[id] = UnitDossier.FromFallenArchive(id, who, sp, manner);
PruneFinalDossiers();
return id; return id;
} }
@ -1459,6 +1615,7 @@ public static class Chronicle
} }
FinalDossiers[id] = dossier; FinalDossiers[id] = dossier;
PruneFinalDossiers();
} }
/// <summary>Last living dossier for a fallen subject, or a minimal archive rebuild.</summary> /// <summary>Last living dossier for a fallen subject, or a minimal archive rebuild.</summary>
@ -1720,9 +1877,171 @@ public static class Chronicle
{ {
list.RemoveAt(0); list.RemoveAt(0);
} }
PruneHistorySubjectsUnlocked();
} }
LogService.LogInfo($"[IdleSpectator][CHRONICLE][History:{subjectId}] {entry.DisplayLine}"); _lastHistorySubjectId = subjectId;
BumpRevision();
}
/// <summary>
/// Drop oldest dormant subjects when over <see cref="MaxHistorySubjects"/>.
/// Caller must hold the Histories lock.
/// </summary>
private static void PruneHistorySubjectsUnlocked()
{
if (Histories.Count <= MaxHistorySubjects)
{
return;
}
var keep = new HashSet<long>();
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<long, List<ChronicleEntry>> kv in Histories)
{
if (keep.Contains(kv.Key))
{
continue;
}
float lastAt = 0f;
List<ChronicleEntry> 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<long> histIds;
lock (Histories)
{
histIds = new HashSet<long>(Histories.Keys);
}
var orphan = new List<long>();
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<long>();
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<long>();
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<string> 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( private static void AppendLandmark(
@ -1749,6 +2068,8 @@ public static class Chronicle
return; return;
} }
CapLandmarkDedupe();
lock (MemoryLandmarks) lock (MemoryLandmarks)
{ {
if (!LandmarksPerAge.TryGetValue(ageId, out int ageCount)) if (!LandmarksPerAge.TryGetValue(ageId, out int ageCount))
@ -1758,6 +2079,7 @@ public static class Chronicle
if (ageCount >= MaxLandmarksPerAge) if (ageCount >= MaxLandmarksPerAge)
{ {
LandmarkDedupe.Remove(dedupeKey);
return; return;
} }
@ -1788,16 +2110,22 @@ public static class Chronicle
{ {
ChronicleEntry removed = MemoryLandmarks[0]; ChronicleEntry removed = MemoryLandmarks[0];
MemoryLandmarks.RemoveAt(0); MemoryLandmarks.RemoveAt(0);
if (!string.IsNullOrEmpty(removed.AgeId) if (removed != null)
&& LandmarksPerAge.TryGetValue(removed.AgeId, out int c)
&& c > 0)
{ {
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) 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) private static bool TryReadCurrentAge(out string ageId, out string ageName)

View file

@ -57,9 +57,13 @@ public static class ChronicleHud
private static InputField _searchField; private static InputField _searchField;
private static Button _favFilterBtn; private static Button _favFilterBtn;
private static Image _favFilterIcon; private static Image _favFilterIcon;
private static Button _refreshBtn;
private static Image _refreshBg;
private static Text _refreshLabel;
private static bool _favoritesOnly; private static bool _favoritesOnly;
private static string _searchText = ""; private static string _searchText = "";
private static int _lastCharFingerprint = int.MinValue; private static int _lastCharFingerprint = int.MinValue;
private static int _builtAtRevision = int.MinValue;
private static long _detailUnitId; private static long _detailUnitId;
private static bool _followFocus; private static bool _followFocus;
private static GameObject _backBtn; private static GameObject _backBtn;
@ -71,6 +75,10 @@ public static class ChronicleHud
/// <summary>Title of the first row in the last Living/Fallen list rebuild (harness).</summary> /// <summary>Title of the first row in the last Living/Fallen list rebuild (harness).</summary>
public static string LastListTopTitle { get; private set; } = ""; public static string LastListTopTitle { get; private set; } = "";
/// <summary>True when Living/Fallen list is older than new chronicle events (needs Refresh).</summary>
public static bool CastListStale =>
Visible && IsCastTab && _detailUnitId == 0 && Chronicle.HistoryRevision != _builtAtRevision;
public static bool Visible => _visible && _root != null && _root.activeSelf; public static bool Visible => _visible && _root != null && _root.activeSelf;
public static bool IsReady => _root != null; public static bool IsReady => _root != null;
@ -280,7 +288,7 @@ public static class ChronicleHud
searchRt.anchorMin = new Vector2(0f, 0f); searchRt.anchorMin = new Vector2(0f, 0f);
searchRt.anchorMax = new Vector2(1f, 1f); searchRt.anchorMax = new Vector2(1f, 1f);
searchRt.offsetMin = Vector2.zero; searchRt.offsetMin = Vector2.zero;
searchRt.offsetMax = new Vector2(-20f, 0f); searchRt.offsetMax = new Vector2(-40f, 0f);
Image searchBg = searchGo.GetComponent<Image>(); Image searchBg = searchGo.GetComponent<Image>();
searchBg.color = new Color(0.05f, 0.06f, 0.08f, 0.95f); searchBg.color = new Color(0.05f, 0.06f, 0.08f, 0.95f);
searchBg.raycastTarget = true; searchBg.raycastTarget = true;
@ -306,6 +314,16 @@ public static class ChronicleHud
_searchField.customCaretColor = true; _searchField.customCaretColor = true;
_searchField.onValueChanged.AddListener(OnSearchChanged); _searchField.onValueChanged.AddListener(OnSearchChanged);
_refreshBtn = BuildTextTool(_charTools.transform, "Refresh", "↻", RefreshCastList);
_refreshBg = _refreshBtn.GetComponent<Image>();
_refreshLabel = _refreshBtn.transform.Find("Label")?.GetComponent<Text>();
RectTransform refreshRt = _refreshBtn.GetComponent<RectTransform>();
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); _favFilterBtn = BuildIconTool(_charTools.transform, "FavFilter", HudIcons.Favorite(), ToggleFavoritesOnly);
_favFilterIcon = _favFilterBtn.transform.Find("Icon")?.GetComponent<Image>(); _favFilterIcon = _favFilterBtn.transform.Find("Icon")?.GetComponent<Image>();
RectTransform favRt = _favFilterBtn.GetComponent<RectTransform>(); RectTransform favRt = _favFilterBtn.GetComponent<RectTransform>();
@ -326,6 +344,25 @@ public static class ChronicleHud
_charTools.SetActive(false); _charTools.SetActive(false);
RefreshFavFilterVisual(); 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<Image>();
bg.color = new Color(0.12f, 0.13f, 0.16f, 0.95f);
bg.raycastTarget = true;
Button button = go.GetComponent<Button>();
button.targetGraphic = bg;
button.onClick.AddListener(onClick);
Text text = HudCanvas.MakeText(go.transform, "Label", label, 10);
StretchFull(text.GetComponent<RectTransform>(), 0f);
text.alignment = TextAnchor.MiddleCenter;
text.color = new Color(0.78f, 0.8f, 0.84f, 1f);
text.raycastTarget = false;
return button;
} }
private static Button BuildIconTool(Transform parent, string name, Sprite icon, UnityAction onClick) private static Button BuildIconTool(Transform parent, string name, Sprite icon, UnityAction onClick)
@ -815,6 +852,19 @@ public static class ChronicleHud
_favFilterBtn.gameObject.SetActive(_tab == LoreTab.Living && !detail); _favFilterBtn.gameObject.SetActive(_tab == LoreTab.Living && !detail);
} }
if (_refreshBtn != null)
{
_refreshBtn.gameObject.SetActive(chars && !detail);
// On Fallen (no fav), park refresh on the far right.
RectTransform refreshRt = _refreshBtn.GetComponent<RectTransform>();
if (refreshRt != null)
{
refreshRt.anchoredPosition = _tab == LoreTab.Living
? new Vector2(-19f, 0f)
: Vector2.zero;
}
}
if (_backBtn != null) if (_backBtn != null)
{ {
_backBtn.SetActive(detail); _backBtn.SetActive(detail);
@ -827,6 +877,7 @@ public static class ChronicleHud
StyleTab(_worldTabBtn, _tab == LoreTab.World); StyleTab(_worldTabBtn, _tab == LoreTab.World);
StyleTab(_livingTabBtn, _tab == LoreTab.Living); StyleTab(_livingTabBtn, _tab == LoreTab.Living);
StyleTab(_fallenTabBtn, _tab == LoreTab.Fallen); StyleTab(_fallenTabBtn, _tab == LoreTab.Fallen);
RefreshStaleVisual();
ApplyScrollInsets(); ApplyScrollInsets();
RefreshTitle(); RefreshTitle();
} }
@ -903,6 +954,37 @@ public static class ChronicleHud
: new Color(0.55f, 0.58f, 0.62f, 1f); : new Color(0.55f, 0.58f, 0.62f, 1f);
} }
private static void RefreshStaleVisual()
{
if (_refreshBg == null)
{
return;
}
bool stale = CastListStale;
_refreshBg.color = stale
? new Color(0.28f, 0.34f, 0.22f, 0.98f)
: new Color(0.12f, 0.13f, 0.16f, 0.95f);
if (_refreshLabel != null)
{
_refreshLabel.color = stale
? new Color(0.85f, 0.95f, 0.55f, 1f)
: new Color(0.78f, 0.8f, 0.84f, 1f);
}
}
/// <summary>Manual reload of Living/Fallen list (or open detail history).</summary>
public static void RefreshCastList()
{
if (!Visible || !IsCastTab)
{
return;
}
Rebuild(force: true);
LogService.LogInfo("[IdleSpectator][LORE] cast list refreshed");
}
public static void UpdateLive() public static void UpdateLive()
{ {
if (!Visible) if (!Visible)
@ -924,16 +1006,13 @@ public static class ChronicleHud
return; return;
} }
// Living/Fallen: snapshot until Refresh (or tab/search/fav). Follow mode still retargets.
if (TryRetargetFollowFocus()) if (TryRetargetFollowFocus())
{ {
return; return;
} }
int fp = CharacterFingerprint(); RefreshStaleVisual();
if (fp != _lastCharFingerprint)
{
Rebuild(force: true);
}
} }
/// <summary> /// <summary>
@ -1002,25 +1081,14 @@ public static class ChronicleHud
hash = (hash * 31) + (_favoritesOnly ? 1 : 0); hash = (hash * 31) + (_favoritesOnly ? 1 : 0);
hash = (hash * 31) + (_searchText ?? "").GetHashCode(); hash = (hash * 31) + (_searchText ?? "").GetHashCode();
hash = (hash * 31) + _detailUnitId.GetHashCode(); hash = (hash * 31) + _detailUnitId.GetHashCode();
hash = (hash * 31) + (int)_tab;
// Cheap dirty bits - never rescan all subjects every frame.
hash = (hash * 31) + Chronicle.HistoryRevision;
hash = (hash * 31) + Chronicle.HistorySubjectCount;
hash = (hash * 31) + Chronicle.MemoryCount;
if (_detailUnitId != 0) if (_detailUnitId != 0)
{ {
hash = (hash * 31) + Chronicle.HistoryCountFor(_detailUnitId); hash = (hash * 31) + Chronicle.HistoryCountFor(_detailUnitId);
return hash;
}
List<ChronicleCharacterSummary> list = Chronicle.ListCharacters(
_searchText,
_tab == LoreTab.Living && _favoritesOnly,
max: 12,
scope: _tab == LoreTab.Fallen
? Chronicle.CharacterListScope.Fallen
: Chronicle.CharacterListScope.Living);
hash = (hash * 31) + (int)_tab;
hash = (hash * 31) + list.Count;
for (int i = 0; i < list.Count && i < 8; i++)
{
hash = (hash * 31) + list[i].UnitId.GetHashCode();
hash = (hash * 31) + list[i].HistoryCount;
} }
return hash; return hash;
@ -1100,6 +1168,8 @@ public static class ChronicleHud
bool favOnly = scope == Chronicle.CharacterListScope.Living && _favoritesOnly; bool favOnly = scope == Chronicle.CharacterListScope.Living && _favoritesOnly;
List<ChronicleCharacterSummary> list = Chronicle.ListCharacters(_searchText, favOnly, scope: scope); List<ChronicleCharacterSummary> list = Chronicle.ListCharacters(_searchText, favOnly, scope: scope);
_lastCharFingerprint = CharacterFingerprint(); _lastCharFingerprint = CharacterFingerprint();
_builtAtRevision = Chronicle.HistoryRevision;
RefreshStaleVisual();
if (list.Count == 0) if (list.Count == 0)
{ {
@ -1150,6 +1220,8 @@ public static class ChronicleHud
{ {
IReadOnlyList<ChronicleEntry> entries = Chronicle.LatestForSubject(unitId, Chronicle.MaxHistoryPerUnit); IReadOnlyList<ChronicleEntry> entries = Chronicle.LatestForSubject(unitId, Chronicle.MaxHistoryPerUnit);
_lastCharFingerprint = CharacterFingerprint(); _lastCharFingerprint = CharacterFingerprint();
_builtAtRevision = Chronicle.HistoryRevision;
RefreshStaleVisual();
if (entries == null || entries.Count == 0) if (entries == null || entries.Count == 0)
{ {
LastDetailHistoryRows = 0; LastDetailHistoryRows = 0;

View file

@ -35,6 +35,9 @@ internal static class HarnessScenarios
case "chronicle": case "chronicle":
case "chronicle_smoke": case "chronicle_smoke":
return ChronicleSmoke(); return ChronicleSmoke();
case "chronicle_subject_bench":
case "subject_bench":
return ChronicleSubjectBench();
case "regression": case "regression":
case "full": case "full":
return Regression(); return Regression();
@ -43,6 +46,28 @@ internal static class HarnessScenarios
} }
} }
/// <summary>
/// Timed Fallen/Living list rebuilds at rising subject counts.
/// Picks the highest size under a 2ms Fallen avg budget (runtime cap only for that session).
/// </summary>
private static List<HarnessCommand> ChronicleSubjectBench()
{
return new List<HarnessCommand>
{
Step("sb0", "wait_world"),
Step("sb1", "dismiss_windows"),
Step("sb2", "chronicle_clear"),
Step(
"sb3",
"chronicle_subject_bench",
value: "320,640,1280,2560,4096,8192",
count: 3,
expect: "12"),
Step("sb4", "assert", expect: "chronicle_subjects_capped"),
Step("sb5", "snapshot")
};
}
/// <summary> /// <summary>
/// Full regression meta-suite (nested scenarios keep assert counters). /// Full regression meta-suite (nested scenarios keep assert counters).
/// Battle / map-gen paths stay out of gate. /// Battle / map-gen paths stay out of gate.
@ -442,6 +467,14 @@ internal static class HarnessScenarios
Step("ch16h", "assert", expect: "chronicle_latest_contains", value: "Burned"), Step("ch16h", "assert", expect: "chronicle_latest_contains", value: "Burned"),
// Character kills/deaths must stay on character history, not World Memory. // Character kills/deaths must stay on character history, not World Memory.
Step("ch16i", "assert", expect: "world_memory_world_only"), Step("ch16i", "assert", expect: "world_memory_world_only"),
// Soft-cap prune: temporarily lower the cap, flood past it, then restore.
Step("ch16j0", "chronicle_set_subject_cap", value: "500"),
Step("ch16j", "chronicle_orphan_flood", count: 580),
Step("ch16k", "assert", expect: "chronicle_subjects_capped"),
Step(
"ch16k2",
"chronicle_set_subject_cap",
value: Chronicle.DefaultMaxHistorySubjects.ToString()),
// World Memory landmarks (replaces History|World tabs) // World Memory landmarks (replaces History|World tabs)
Step("ch17a", "chronicle_world_force", label: "War: harness_alpha vs harness_beta"), Step("ch17a", "chronicle_world_force", label: "War: harness_alpha vs harness_beta"),
@ -450,6 +483,8 @@ internal static class HarnessScenarios
Step("ch17d", "assert", expect: "chronicle_latest_contains", value: "Burned"), Step("ch17d", "assert", expect: "chronicle_latest_contains", value: "Burned"),
Step("ch17e", "assert", expect: "world_memory_age"), Step("ch17e", "assert", expect: "world_memory_age"),
Step("ch17e2", "assert", expect: "world_memory_world_only"), Step("ch17e2", "assert", expect: "world_memory_world_only"),
Step("ch17e3", "assert", expect: "chronicle_memory_capped"),
Step("ch17e4", "assert", expect: "chronicle_subjects_capped"),
// Selecting a world event pans to its recorded place (even with no unit there). // Selecting a world event pans to its recorded place (even with no unit there).
Step("ch17j", "lore_jump_world"), Step("ch17j", "lore_jump_world"),
Step("ch17k", "wait", wait: 0.2f), Step("ch17k", "wait", wait: 0.2f),
@ -638,6 +673,22 @@ internal static class HarnessScenarios
Step("ch18ft3", "wait", wait: 0.2f), Step("ch18ft3", "wait", wait: 0.2f),
Step("ch18ft4", "assert", expect: "lore_tab", value: "fallen"), Step("ch18ft4", "assert", expect: "lore_tab", value: "fallen"),
Step("ch18ft5", "assert", expect: "fallen_list_top", value: "Slainbone"), Step("ch18ft5", "assert", expect: "fallen_list_top", value: "Slainbone"),
// Snapshot stays put until Refresh (new deaths do not auto-rebuild).
Step("ch18ft5b", "assert", expect: "lore_list_stale", value: "false"),
Step(
"ch18ft5c",
"chronicle_orphan",
label: "FreshFallen",
asset: "human",
value: "Fresh",
count: 1,
expect: "Age",
tier: "place"),
Step("ch18ft5d", "assert", expect: "lore_list_stale", value: "true"),
Step("ch18ft5e", "assert", expect: "fallen_list_top", value: "Slainbone"),
Step("ch18ft5f", "lore_refresh"),
Step("ch18ft5g", "assert", expect: "lore_list_stale", value: "false"),
Step("ch18ft5h", "assert", expect: "fallen_list_top", value: "FreshFallen"),
Step("ch18ft6", "screenshot", value: "hud-lore-fallen-tab.png"), Step("ch18ft6", "screenshot", value: "hud-lore-fallen-tab.png"),
Step("ch18ft7", "wait", wait: 0.55f), Step("ch18ft7", "wait", wait: 0.55f),
Step("ch18q12", "lore_tab", value: "world"), Step("ch18q12", "lore_tab", value: "world"),

View file

@ -1,7 +1,7 @@
{ {
"name": "IdleSpectator", "name": "IdleSpectator",
"author": "dazed", "author": "dazed",
"version": "0.12.17", "version": "0.12.22",
"description": "AFK Idle Spectator (I) + Lore (L). World tab is world events only.", "description": "AFK Idle Spectator (I) + Lore (L). Subject soft cap 100k.",
"GUID": "com.dazed.idlespectator" "GUID": "com.dazed.idlespectator"
} }