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