diff --git a/.cursor/skills/idle-spectator-e2e/SKILL.md b/.cursor/skills/idle-spectator-e2e/SKILL.md
index 9e1a984..ebe1029 100644
--- a/.cursor/skills/idle-spectator-e2e/SKILL.md
+++ b/.cursor/skills/idle-spectator-e2e/SKILL.md
@@ -4,7 +4,7 @@ description: >-
Run IdleSpectator WorldBox E2E tests via the in-mod agent harness
(scripts/harness-run.sh, critical_smoke, regression suite, .harness commands).
Use when adding or fixing IdleSpectator features, playtesting the mod,
- writing harness scenarios, or verifying spectator/focus/tip/settings behavior.
+ writing harness scenarios, or verifying spectator/focus/tip/settings/chronicle behavior.
---
# IdleSpectator E2E harness
@@ -13,9 +13,20 @@ Always validate IdleSpectator changes with the in-game harness.
Do not rely on OS mouse/keyboard automation (Wayland-flaky).
Do not call `Application.Quit()` (can hang Steam on Linux).
+## WorldBox wiki (mechanics source of truth)
+
+When deciding what is “interesting” for Spectator or Chronicle, **read the official wiki** before inventing behavior:
+
+- Hub: https://the-official-worldbox-wiki.fandom.com/wiki/The_Official_Worldbox_Wiki
+- Useful pages: [World History](https://the-official-worldbox-wiki.fandom.com/wiki/World_History), [Units](https://the-official-worldbox-wiki.fandom.com/wiki/Units) (lovers/best friends), [Unit Stats](https://the-official-worldbox-wiki.fandom.com/wiki/Unit_Stats), [Kings and Leaders](https://the-official-worldbox-wiki.fandom.com/wiki/Kings_and_Leaders), [Creature Traits](https://the-official-worldbox-wiki.fandom.com/wiki/Creature_Traits), [War](https://the-official-worldbox-wiki.fandom.com/wiki/War), [Disasters](https://the-official-worldbox-wiki.fandom.com/wiki/Disasters), [Ages](https://the-official-worldbox-wiki.fandom.com/wiki/Ages)
+
+Chronicle = **notable life/world events** (kills, lovers, crowning, wars), not “what Idle Spectator is staring at.”
+Watch caption = live AFK context for the current focus.
+Vanilla `Tooltip` / `TooltipLibrary` (`tooltips/tooltip_actor`, etc.) may be reusable for polish later.
+
## Mandatory workflow
-After any feature or bugfix in `IdleSpectator/`:
+After any feature or bug fix in `IdleSpectator/`:
1. Bump `IdleSpectator/mod.json` version (NML recompiles on game load).
2. Extend or add a scenario in `IdleSpectator/HarnessScenarios.cs` when the change is new behavior.
@@ -23,7 +34,7 @@ After any feature or bugfix in `IdleSpectator/`:
```bash
./scripts/harness-run.sh --repeat 3 critical_smoke
```
-4. After larger changes (director, discovery, settings, input, tips), also run:
+4. After larger changes (director, discovery, settings, input, tips, chronicle), also run:
```bash
./scripts/harness-run.sh regression
```
@@ -32,19 +43,16 @@ After any feature or bugfix in `IdleSpectator/`:
7. Report: scenario name(s), PASS/FAIL counts, and any new asserts.
For tiny doc-only edits, harness is optional.
-For anything that touches camera, spectator, tips, settings, discovery, or input exit, harness is required.
+For anything that touches camera, spectator, tips, settings, discovery, chronicle, or input exit, harness is required.
## Runner
```bash
-# Default: critical_smoke (starts WorldBox if needed)
./scripts/harness-run.sh
-
./scripts/harness-run.sh critical_smoke
./scripts/harness-run.sh --repeat 3 critical_smoke
-./scripts/harness-run.sh regression # full suite
-./scripts/harness-run.sh tip_match
-./scripts/harness-run.sh smoke
+./scripts/harness-run.sh regression
+./scripts/harness-run.sh chronicle_smoke
./scripts/harness-run.sh --no-launch critical_smoke
./scripts/harness-run.sh --cmd '{"id":"1","action":"snapshot"}'
```
@@ -63,16 +71,8 @@ Paths:
- Inbox/results: `IdleSpectator/.harness/` (gitignored)
- Log: `~/.config/unity3d/mkarpenko/WorldBox/Player.log`
-The runner:
-
-- Starts Steam/WorldBox when not running
-- Touches `.harness/reset` + rewinds offset so commands are re-read
-- Enables `.harness/auto-dismiss` (closes Welcome Back)
-- Waits for `last-result.json` status `PASS` / `FAIL`
-- `regression` runs each named scenario sequentially (stop on first fail)
-
+The runner starts Steam/WorldBox when needed, touches `.harness/reset` + rewinds offset, enables auto-dismiss, waits for `last-result.json`.
A loaded world is still required.
-`wait_world` + auto-dismiss handle Welcome Back and empty maps.
## Built-in scenarios
@@ -86,20 +86,19 @@ A loaded world is still required.
| `director_tiers` | Curiosity accept, Action interrupt, curiosity blocked by Action |
| `discovery` | Present dedupe + force drain + tip match |
| `world_log` | Story/Epic interest interrupt via collector |
+| `chronicle_smoke` | Life-event chronicle inject + HUD open/jump (not watch-notes) |
| `smoke` | Short enable/focus health check |
| `tip_match` | Tip asset must match focused unit |
| `regression` | Shell suite of all gate scenarios above |
-Out of gate (too flaky / environmental): real battles, map-gen discovery, OS hotkey `I`.
-
-Prefer `pick_unit` (living map unit) over miracle `spawn` in critical paths.
-Spawned crabs/sheep often die on bad tiles and flake tests.
+Out of gate: real battles, map-gen discovery, OS hotkey `I`.
+Prefer `pick_unit` over miracle `spawn` in critical paths.
## Timing note
Director and discovery use `Time.unscaledTime`.
Game speed 5x does **not** shorten harness waits.
-Use `fast_timing` in scenarios (and/or `director_run`) instead.
+Use `fast_timing` / `director_run` instead.
## Adding coverage for a new fix
@@ -107,42 +106,33 @@ Use `fast_timing` in scenarios (and/or `director_run`) instead.
2. Register new scenarios in `HarnessScenarios.Build` and in `REGRESSION_SCENARIOS` inside `scripts/harness-run.sh`.
3. Use `asset=auto` / `label="New species: {asset}"` after `pick_unit`.
4. Keep InterestDirector frozen while `AgentHarness.FreezeDirector` (Busy unless `director_run`).
-5. `wait_world` must stay front-of-queue on retry (never enqueue behind later steps).
+5. `wait_world` must stay front-of-queue on retry.
## Command cheat sheet
-JSONL lines in `.harness/commands.jsonl` (also used by scenarios):
-
- `wait_world`, `dismiss_windows`, `wait`, `director_run`, `fast_timing`
- `age_current`, `expire_grace`, `remember_focus`
- `spectator` (`value=on|off`, optional `expect=blocked`)
- `pick_unit`, `spawn`, `focus`, `watch`, `watch_ghost`, `trigger_interest`
- `simulate_input` (`expect=grace` to assert grace hold)
-- `set_setting` (`expect=enabled|show_watch_reasons`, `value=true|false`)
+- `set_setting` (`expect=enabled|show_watch_reasons|show_dossier_caption|chronicle_enabled`)
- `discovery_reset`, `discovery_note`, `discovery_mark`, `discovery_drain`
-- `assert` expects: `idle`, `health`, `power_bar`, `no_bad`, `tip_matches_unit`, `unit_asset`, `tip_contains`, `enabled`, `show_watch_reasons`, `current_tier`, `would_accept_curiosity`, `focus_same`, `presented`, `pending_discovery`, `pending_interest`, `in_grace`
-- `snapshot`, `reset_counters` (StateProbe only; `value=all` also clears harness counters), `scenario`
-
-Machine output:
-
-- `[IdleSpectator][HARNESS]` / `[ASSERT]` / `[SNAP]` in Player.log
-- `IdleSpectator/.harness/last-result.json`
-- `IdleSpectator/.harness/results.jsonl`
+- `chronicle_force`, `chronicle_open`, `chronicle_jump`
+- `assert` expects: `idle`, `health`, `power_bar`, `no_bad`, `tip_matches_unit`, `unit_asset`, `tip_contains`, `enabled`, `show_watch_reasons`, `current_tier`, `would_accept_curiosity`, `focus_same`, `presented`, `pending_discovery`, `pending_interest`, `in_grace`, `dossier_contains`, `caption_contains`, `chronicle_count`, `chronicle_latest_contains`
+- `snapshot`, `reset_counters`, `scenario`
## Process safety
-- Kill only the real game binary (`.../common/worldbox/worldbox`), never `pkill -f` patterns that match the agent shell.
-- Prefer `./scripts/harness-run.sh` for launch; avoid casual `Application.Quit`.
-- After code changes, restart/relaunch so NML recompiles (version bump helps confirm load).
+- Kill only the real game binary (`.../common/worldbox/worldbox`).
+- Prefer `./scripts/harness-run.sh`; avoid `Application.Quit`.
+- After code changes, restart/relaunch so NML recompiles.
## Definition of done
-A change is done only when:
-
- Relevant harness scenario(s) PASS
-- For core spectator/focus/tip/settings work: `critical_smoke` passes **3 times** (`--repeat 3`)
-- For larger director/discovery/input/settings work: `regression` PASS
-- Failures were fixed by hardening the mod or scenario, not by lowering asserts
+- Core work: `critical_smoke` passes **3 times**
+- Larger work: `regression` PASS
+- Do not lower asserts to greenwash flakes
## More detail
diff --git a/.cursor/skills/idle-spectator-e2e/reference.md b/.cursor/skills/idle-spectator-e2e/reference.md
index 83df766..ee38d27 100644
--- a/.cursor/skills/idle-spectator-e2e/reference.md
+++ b/.cursor/skills/idle-spectator-e2e/reference.md
@@ -1,5 +1,13 @@
# IdleSpectator harness reference
+## WorldBox wiki
+
+Use https://the-official-worldbox-wiki.fandom.com/wiki/The_Official_Worldbox_Wiki when designing Spectator priorities or Chronicle events (Units lovers/friends, World History, kings, traits, war, disasters).
+
+Chronicle records **life/world events**, not spectator watch notes.
+Watch caption covers live focus context.
+Vanilla tooltips (`Tooltip`, `TooltipLibrary`, `tooltips/tooltip_actor`) are candidates for future UI polish.
+
## Architecture
```text
diff --git a/IdleSpectator/ActorChroniclePatches.cs b/IdleSpectator/ActorChroniclePatches.cs
new file mode 100644
index 0000000..05322be
--- /dev/null
+++ b/IdleSpectator/ActorChroniclePatches.cs
@@ -0,0 +1,36 @@
+using HarmonyLib;
+
+namespace IdleSpectator;
+
+///
+/// Feeds Chronicle from real creature life events (kills, lovers, best friends).
+///
+[HarmonyPatch]
+public static class ActorChroniclePatches
+{
+ [HarmonyPatch(typeof(Actor), "newKillAction")]
+ [HarmonyPostfix]
+ public static void PostfixNewKill(Actor __instance, Actor pDeadUnit)
+ {
+ Chronicle.NoteKill(__instance, pDeadUnit);
+ }
+
+ [HarmonyPatch(typeof(Actor), nameof(Actor.becomeLoversWith))]
+ [HarmonyPostfix]
+ public static void PostfixLovers(Actor __instance, Actor pTarget)
+ {
+ Chronicle.NoteLovers(__instance, pTarget);
+ }
+
+ [HarmonyPatch(typeof(Actor), nameof(Actor.setBestFriend))]
+ [HarmonyPostfix]
+ public static void PostfixBestFriend(Actor __instance, Actor pActor, bool pNew)
+ {
+ if (!pNew)
+ {
+ return;
+ }
+
+ Chronicle.NoteBestFriends(__instance, pActor);
+ }
+}
diff --git a/IdleSpectator/AgentHarness.cs b/IdleSpectator/AgentHarness.cs
index 0c032b7..8cf41f2 100644
--- a/IdleSpectator/AgentHarness.cs
+++ b/IdleSpectator/AgentHarness.cs
@@ -312,6 +312,57 @@ public static class AgentHarness
DoWatchGhost(cmd);
break;
+ case "chronicle_force":
+ case "chronicle_note":
+ {
+ bool ok = Chronicle.ForceNoteCurrentFocus(
+ string.IsNullOrEmpty(cmd.label) ? "Slew" : cmd.label);
+ if (ok)
+ {
+ _cmdOk++;
+ }
+ else
+ {
+ _cmdFail++;
+ }
+
+ Emit(cmd, ok, detail: $"chronicle_count={Chronicle.Count} caption={WatchCaption.LastHeadline}");
+ break;
+ }
+
+ case "chronicle_open":
+ {
+ Chronicle.ShowHud();
+ bool open = Chronicle.HudVisible;
+ if (open)
+ {
+ _cmdOk++;
+ }
+ else
+ {
+ _cmdFail++;
+ }
+
+ Emit(cmd, open, detail: $"hud={open}");
+ break;
+ }
+
+ case "chronicle_jump":
+ {
+ bool ok = Chronicle.JumpToLatest();
+ if (ok)
+ {
+ _cmdOk++;
+ }
+ else
+ {
+ _cmdFail++;
+ }
+
+ Emit(cmd, ok, detail: $"jumped={ok} focus={MoveCamera.hasFocusUnit()}");
+ break;
+ }
+
case "spawn":
DoSpawn(cmd);
break;
@@ -917,6 +968,58 @@ public static class AgentHarness
detail = $"in_grace={have} want={want}";
break;
}
+ case "dossier_contains":
+ case "caption_contains":
+ {
+ string needle = string.IsNullOrEmpty(cmd.value) ? cmd.label : cmd.value;
+ if (needle == "auto" || string.IsNullOrEmpty(needle))
+ {
+ needle = _lastSpawnedAssetId;
+ }
+
+ string caption = WatchCaption.LastCaptionText ?? "";
+ UnitDossier dossier = WatchCaption.Current;
+ bool dossierHit = dossier != null && dossier.ContainsIgnoreCase(needle);
+ bool captionHit = !string.IsNullOrEmpty(needle)
+ && caption.IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0;
+ pass = dossierHit || captionHit;
+ detail = $"needle='{needle}' caption='{caption.Replace("\n", " | ")}' dossier={(dossier != null)}";
+ break;
+ }
+ case "chronicle_count":
+ {
+ int want = ParseCountExpect(cmd, defaultValue: 1);
+ int have = Chronicle.Count;
+ string cmp = (cmd.label ?? "").Trim().ToLowerInvariant();
+ if (cmp == "min" || cmp == ">=" || string.IsNullOrEmpty(cmp))
+ {
+ pass = have >= want;
+ detail = $"count={have} min={want}";
+ }
+ else
+ {
+ pass = have == want;
+ detail = $"count={have} want={want}";
+ }
+
+ break;
+ }
+ case "chronicle_latest_contains":
+ {
+ string needle = string.IsNullOrEmpty(cmd.value) ? cmd.label : cmd.value;
+ if (needle == "auto")
+ {
+ needle = _lastSpawnedAssetId;
+ }
+
+ ChronicleEntry latest = Chronicle.Latest;
+ string line = latest != null ? latest.Line : "";
+ pass = latest != null
+ && !string.IsNullOrEmpty(needle)
+ && line.IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0;
+ detail = $"latest='{line}' needle='{needle}'";
+ break;
+ }
default:
pass = false;
detail = "unknown expect: " + expect;
@@ -942,7 +1045,7 @@ public static class AgentHarness
private static void DoSnapshot(HarnessCommand cmd)
{
string snap =
- $"idle={SpectatorMode.Active} focus={MoveCamera.hasFocusUnit()} powerBar={CanvasMain.isBottomBarShowing()} unit={FocusLabel()} tip={CameraDirector.LastWatchLabel} tipAsset={CameraDirector.LastWatchAssetId} bad={StateProbe.BadEventCount} tier={InterestDirector.CurrentTierName} discoveryPending={SpeciesDiscovery.PendingCount}";
+ $"idle={SpectatorMode.Active} focus={MoveCamera.hasFocusUnit()} powerBar={CanvasMain.isBottomBarShowing()} unit={FocusLabel()} tip={CameraDirector.LastWatchLabel} tipAsset={CameraDirector.LastWatchAssetId} bad={StateProbe.BadEventCount} tier={InterestDirector.CurrentTierName} discoveryPending={SpeciesDiscovery.PendingCount} caption={WatchCaption.LastHeadline} chronicle={Chronicle.Count}";
_cmdOk++;
Emit(cmd, ok: true, detail: snap);
LogService.LogInfo("[IdleSpectator][SNAP] " + snap);
diff --git a/IdleSpectator/CameraDirector.cs b/IdleSpectator/CameraDirector.cs
index 51da7ef..cadb01b 100644
--- a/IdleSpectator/CameraDirector.cs
+++ b/IdleSpectator/CameraDirector.cs
@@ -13,6 +13,10 @@ public static class CameraDirector
if (actor != null && actor.isAlive())
{
RetargetFollow(actor);
+ if (SpectatorMode.Active)
+ {
+ WatchCaption.SetFromActor(actor);
+ }
}
}
@@ -31,12 +35,13 @@ public static class CameraDirector
string tip = FormatWatchTip(interest);
LastWatchLabel = interest.Label ?? "";
LastWatchAssetId = interest.AssetId ?? "";
- if (ModSettings.ShowWatchReasons)
+ if (ModSettings.ShowWatchReasons && !ModSettings.ShowDossierCaption)
{
WorldTip.showNowTop(tip, pTranslate: false);
}
LogService.LogInfo($"[IdleSpectator] {tip}");
+ WatchCaption.SetFromInterest(interest);
Actor follow = interest.HasFollowUnit
? interest.FollowUnit
@@ -96,6 +101,11 @@ public static class CameraDirector
MoveCamera.clearFocusUnitOnly();
}
+ public static void PanTo(Vector3 pos)
+ {
+ PanCamera(pos);
+ }
+
private static void RetargetFollow(Actor actor)
{
if (MoveCamera.isCameraFollowingUnit(actor))
diff --git a/IdleSpectator/Chronicle.cs b/IdleSpectator/Chronicle.cs
new file mode 100644
index 0000000..957dd67
--- /dev/null
+++ b/IdleSpectator/Chronicle.cs
@@ -0,0 +1,372 @@
+using System.Collections.Generic;
+using NeoModLoader.services;
+using UnityEngine;
+
+namespace IdleSpectator;
+
+public enum ChronicleKind
+{
+ Kill,
+ Lover,
+ Friend,
+ World,
+ Other
+}
+
+public sealed class ChronicleEntry
+{
+ public float CreatedAt;
+ public ChronicleKind Kind;
+ public long UnitId;
+ public string Name = "";
+ public string SpeciesId = "";
+ public string Line = "";
+ public Vector3 Position;
+}
+
+///
+/// Session ring buffer of notable life/world events (kills, lovers, WorldLog), not spectator watch notes.
+///
+public static class Chronicle
+{
+ public const KeyCode ToggleKey = KeyCode.F9;
+ private const int MaxEntries = 300;
+
+ private static readonly List Entries = new List();
+ private static readonly HashSet LoverPairKeys = new HashSet();
+ private static readonly HashSet FriendPairKeys = new HashSet();
+ private static bool _hudReady;
+
+ public static int Count
+ {
+ get
+ {
+ lock (Entries)
+ {
+ return Entries.Count;
+ }
+ }
+ }
+
+ public static bool HudVisible => ChronicleHud.Visible;
+
+ public static IReadOnlyList Snapshot()
+ {
+ lock (Entries)
+ {
+ return Entries.ToArray();
+ }
+ }
+
+ public static ChronicleEntry Latest
+ {
+ get
+ {
+ lock (Entries)
+ {
+ return Entries.Count == 0 ? null : Entries[Entries.Count - 1];
+ }
+ }
+ }
+
+ public static void EnsureWindow()
+ {
+ if (!ModSettings.ChronicleEnabled)
+ {
+ return;
+ }
+
+ if (_hudReady && ChronicleHud.IsReady)
+ {
+ return;
+ }
+
+ try
+ {
+ ChronicleHud.EnsureBuilt();
+ if (ChronicleHud.IsReady)
+ {
+ _hudReady = true;
+ LogService.LogInfo("[IdleSpectator] Chronicle HUD ready (F9, life-event side panel)");
+ }
+ }
+ catch (System.Exception ex)
+ {
+ LogService.LogInfo("[IdleSpectator] Chronicle HUD init failed: " + ex.Message);
+ }
+ }
+
+ public static void ClearSession()
+ {
+ lock (Entries)
+ {
+ Entries.Clear();
+ }
+
+ LoverPairKeys.Clear();
+ FriendPairKeys.Clear();
+ }
+
+ public static void PollInput()
+ {
+ if (!Config.game_loaded || !ModSettings.ChronicleEnabled)
+ {
+ return;
+ }
+
+ EnsureWindow();
+ if (Input.GetKeyDown(ToggleKey))
+ {
+ ToggleWindow();
+ }
+ }
+
+ public static void ToggleWindow()
+ {
+ if (!ModSettings.ChronicleEnabled)
+ {
+ return;
+ }
+
+ EnsureWindow();
+ ChronicleHud.Toggle();
+ }
+
+ public static void ShowHud()
+ {
+ EnsureWindow();
+ ChronicleHud.SetVisible(true);
+ }
+
+ public static void HideHud()
+ {
+ ChronicleHud.SetVisible(false);
+ }
+
+ public static bool JumpToLatest()
+ {
+ return JumpTo(Latest);
+ }
+
+ public static bool JumpTo(ChronicleEntry entry)
+ {
+ if (entry == null)
+ {
+ return false;
+ }
+
+ Actor unit = FindUnitById(entry.UnitId);
+ if (unit != null && unit.isAlive())
+ {
+ CameraDirector.FocusUnit(unit);
+ WatchCaption.SetFromActor(unit);
+ return true;
+ }
+
+ if (entry.Position != Vector3.zero)
+ {
+ CameraDirector.PanTo(entry.Position);
+ return true;
+ }
+
+ return false;
+ }
+
+ public static void Update()
+ {
+ if (!ModSettings.ChronicleEnabled || !Config.game_loaded || World.world == null)
+ {
+ return;
+ }
+
+ ChronicleHud.UpdateLive();
+ }
+
+ public static void NoteKill(Actor killer, Actor victim)
+ {
+ if (!ModSettings.ChronicleEnabled || killer == null || victim == null)
+ {
+ return;
+ }
+
+ string killerName = SafeName(killer);
+ string victimName = SafeName(victim);
+ string killerSpecies = SpeciesOf(killer);
+ string victimSpecies = SpeciesOf(victim);
+ Append(
+ ChronicleKind.Kill,
+ killer,
+ $"Slew: {killerName} ({killerSpecies}) felled {victimName} ({victimSpecies})");
+ }
+
+ public static void NoteLovers(Actor a, Actor b)
+ {
+ if (!ModSettings.ChronicleEnabled || a == null || b == null)
+ {
+ return;
+ }
+
+ string key = PairKey(a.getID(), b.getID());
+ if (!LoverPairKeys.Add(key))
+ {
+ return;
+ }
+
+ Append(
+ ChronicleKind.Lover,
+ a,
+ $"Lovers: {SafeName(a)} and {SafeName(b)}");
+ }
+
+ public static void NoteBestFriends(Actor a, Actor b)
+ {
+ if (!ModSettings.ChronicleEnabled || a == null || b == null)
+ {
+ return;
+ }
+
+ string key = PairKey(a.getID(), b.getID());
+ if (!FriendPairKeys.Add(key))
+ {
+ return;
+ }
+
+ Append(
+ ChronicleKind.Friend,
+ a,
+ $"Friends: {SafeName(a)} befriended {SafeName(b)}");
+ }
+
+ public static void NoteWorldLog(WorldLogMessage message, string label)
+ {
+ if (!ModSettings.ChronicleEnabled || message == null || string.IsNullOrEmpty(label))
+ {
+ return;
+ }
+
+ Actor unit = null;
+ try
+ {
+ if (message.hasFollowLocation())
+ {
+ unit = message.unit;
+ }
+ }
+ catch
+ {
+ // ignore
+ }
+
+ Vector3 pos = Vector3.zero;
+ try
+ {
+ pos = message.getLocation();
+ }
+ catch
+ {
+ // ignore
+ }
+
+ Append(
+ ChronicleKind.World,
+ unit,
+ $"World: {label}",
+ fallbackPosition: pos);
+ }
+
+ /// Harness: inject a life-event style line for the current focus.
+ public static bool ForceNoteCurrentFocus(string prefix = "Slew")
+ {
+ if (!MoveCamera.hasFocusUnit() || MoveCamera._focus_unit == null)
+ {
+ return false;
+ }
+
+ Actor unit = MoveCamera._focus_unit;
+ UnitDossier dossier = UnitDossier.FromActor(unit);
+ string kindPrefix = string.IsNullOrEmpty(prefix) ? "Slew" : prefix;
+ Append(
+ ChronicleKind.Other,
+ unit,
+ $"{kindPrefix}: {dossier.Name} ({dossier.SpeciesId}) - life event");
+ return true;
+ }
+
+ private static void Append(ChronicleKind kind, Actor unit, string line, Vector3 fallbackPosition = default)
+ {
+ if (string.IsNullOrEmpty(line))
+ {
+ return;
+ }
+
+ ChronicleEntry entry = new ChronicleEntry
+ {
+ CreatedAt = Time.unscaledTime,
+ Kind = kind,
+ UnitId = unit != null ? unit.getID() : 0,
+ Name = unit != null ? SafeName(unit) : "",
+ SpeciesId = unit != null ? SpeciesOf(unit) : "",
+ Line = line,
+ Position = unit != null ? unit.current_position : fallbackPosition
+ };
+
+ lock (Entries)
+ {
+ Entries.Add(entry);
+ while (Entries.Count > MaxEntries)
+ {
+ Entries.RemoveAt(0);
+ }
+ }
+
+ LogService.LogInfo("[IdleSpectator][CHRONICLE] " + line);
+ }
+
+ private static string PairKey(long a, long b)
+ {
+ long lo = a < b ? a : b;
+ long hi = a < b ? b : a;
+ return lo + ":" + hi;
+ }
+
+ private static Actor FindUnitById(long id)
+ {
+ if (id == 0)
+ {
+ return null;
+ }
+
+ foreach (Actor actor in WorldActivityScanner.EnumerateAliveUnitsPublic())
+ {
+ if (actor != null && actor.getID() == id && actor.isAlive())
+ {
+ return actor;
+ }
+ }
+
+ return null;
+ }
+
+ private static string SpeciesOf(Actor actor)
+ {
+ return actor?.asset != null ? actor.asset.id : "creature";
+ }
+
+ private static string SafeName(Actor actor)
+ {
+ try
+ {
+ string name = actor.getName();
+ if (!string.IsNullOrEmpty(name))
+ {
+ return name;
+ }
+ }
+ catch
+ {
+ // ignore
+ }
+
+ return SpeciesOf(actor);
+ }
+}
diff --git a/IdleSpectator/ChronicleHud.cs b/IdleSpectator/ChronicleHud.cs
new file mode 100644
index 0000000..aab1b1e
--- /dev/null
+++ b/IdleSpectator/ChronicleHud.cs
@@ -0,0 +1,341 @@
+using System.Collections.Generic;
+using NeoModLoader.General;
+using NeoModLoader.services;
+using UnityEngine;
+using UnityEngine.Events;
+using UnityEngine.UI;
+
+namespace IdleSpectator;
+
+///
+/// Non-modal right-side chronicle HUD. Works with focus/spectator - does not open a ScrollWindow.
+/// Styled as a compact event feed (inspired by vanilla tip/tooltip denseness, not a modal unit sheet).
+///
+public static class ChronicleHud
+{
+ private const float PanelWidth = 300f;
+ private const int MaxVisibleRows = 14;
+
+ private static GameObject _root;
+ private static RectTransform _content;
+ private static Text _header;
+ private static readonly List Rows = new List();
+ private static bool _visible;
+ private static int _lastCount = -1;
+
+ public static bool Visible => _visible && _root != null && _root.activeSelf;
+
+ public static bool IsReady => _root != null;
+
+ public static void EnsureBuilt()
+ {
+ if (_root != null)
+ {
+ return;
+ }
+
+ if (!Config.game_loaded && CanvasMain.instance == null)
+ {
+ return;
+ }
+
+ Canvas canvas = ResolveCanvas();
+ if (canvas == null)
+ {
+ return;
+ }
+
+ _root = new GameObject("IdleSpectatorChronicleHud", typeof(RectTransform), typeof(Image), typeof(CanvasGroup));
+ _root.transform.SetParent(canvas.transform, false);
+
+ RectTransform rt = _root.GetComponent();
+ rt.anchorMin = new Vector2(1f, 0.5f);
+ rt.anchorMax = new Vector2(1f, 0.5f);
+ rt.pivot = new Vector2(1f, 0.5f);
+ rt.sizeDelta = new Vector2(PanelWidth, 440f);
+ rt.anchoredPosition = new Vector2(-10f, 0f);
+
+ Image bg = _root.GetComponent();
+ bg.color = new Color(0.05f, 0.06f, 0.08f, 0.82f);
+ bg.raycastTarget = true;
+
+ CanvasGroup group = _root.GetComponent();
+ group.blocksRaycasts = true;
+ group.interactable = true;
+
+ _header = MakeText(_root.transform, "Header", "Chronicle · F9", 12);
+ RectTransform headerRt = _header.GetComponent();
+ headerRt.anchorMin = new Vector2(0f, 1f);
+ headerRt.anchorMax = new Vector2(1f, 1f);
+ headerRt.pivot = new Vector2(0.5f, 1f);
+ headerRt.sizeDelta = new Vector2(-16f, 24f);
+ headerRt.anchoredPosition = new Vector2(0f, -8f);
+ _header.fontStyle = FontStyle.Bold;
+ _header.alignment = TextAnchor.MiddleLeft;
+ _header.color = new Color(0.92f, 0.9f, 0.82f, 1f);
+
+ Text sub = MakeText(_root.transform, "Sub", "Life & world events · click to jump", 9);
+ RectTransform subRt = sub.GetComponent();
+ subRt.anchorMin = new Vector2(0f, 1f);
+ subRt.anchorMax = new Vector2(1f, 1f);
+ subRt.pivot = new Vector2(0.5f, 1f);
+ subRt.sizeDelta = new Vector2(-16f, 16f);
+ subRt.anchoredPosition = new Vector2(0f, -30f);
+ sub.alignment = TextAnchor.MiddleLeft;
+ sub.color = new Color(0.65f, 0.68f, 0.72f, 1f);
+
+ GameObject scrollGo = new GameObject("Scroll", typeof(RectTransform), typeof(Image), typeof(ScrollRect), typeof(Mask));
+ 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(8f, 10f);
+ scrollRt.offsetMax = new Vector2(-8f, -50f);
+ Image scrollBg = scrollGo.GetComponent();
+ scrollBg.color = new Color(0f, 0f, 0f, 0.08f);
+ Mask mask = scrollGo.GetComponent();
+ mask.showMaskGraphic = false;
+
+ 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);
+ _content.anchorMax = new Vector2(1f, 1f);
+ _content.pivot = new Vector2(0.5f, 1f);
+ _content.anchoredPosition = Vector2.zero;
+ _content.sizeDelta = new Vector2(0f, 0f);
+
+ VerticalLayoutGroup layout = contentGo.GetComponent();
+ layout.childAlignment = TextAnchor.UpperCenter;
+ layout.childControlHeight = false;
+ layout.childControlWidth = true;
+ layout.childForceExpandHeight = false;
+ layout.childForceExpandWidth = true;
+ layout.spacing = 2f;
+ layout.padding = new RectOffset(0, 0, 0, 0);
+
+ ContentSizeFitter fitter = contentGo.GetComponent();
+ fitter.verticalFit = ContentSizeFitter.FitMode.PreferredSize;
+ fitter.horizontalFit = ContentSizeFitter.FitMode.Unconstrained;
+
+ ScrollRect scroll = scrollGo.GetComponent();
+ scroll.content = _content;
+ scroll.horizontal = false;
+ scroll.vertical = true;
+ scroll.movementType = ScrollRect.MovementType.Clamped;
+ scroll.viewport = scrollRt;
+
+ _root.SetActive(false);
+ _visible = false;
+ LogService.LogInfo("[IdleSpectator] Chronicle HUD ready (F9, non-modal side panel)");
+ }
+
+ public static void SetVisible(bool visible)
+ {
+ EnsureBuilt();
+ if (_root == null)
+ {
+ return;
+ }
+
+ _visible = visible;
+ _root.SetActive(visible);
+ if (visible)
+ {
+ Rebuild(force: true);
+ }
+ }
+
+ public static void Toggle()
+ {
+ SetVisible(!Visible);
+ }
+
+ public static void UpdateLive()
+ {
+ if (!Visible)
+ {
+ return;
+ }
+
+ if (Chronicle.Count != _lastCount)
+ {
+ Rebuild(force: true);
+ }
+ }
+
+ public static void Rebuild(bool force = false)
+ {
+ EnsureBuilt();
+ if (_content == null || (!force && !Visible))
+ {
+ return;
+ }
+
+ ClearRows();
+ IReadOnlyList entries = Chronicle.Snapshot();
+ _lastCount = entries.Count;
+
+ if (entries.Count == 0)
+ {
+ GameObject empty = BuildStaticRow("Waiting for life events...");
+ empty.transform.SetParent(_content, false);
+ Rows.Add(empty);
+ return;
+ }
+
+ int shown = 0;
+ for (int i = entries.Count - 1; i >= 0; i--)
+ {
+ GameObject row = BuildClickableRow(entries[i], shown);
+ row.transform.SetParent(_content, false);
+ Rows.Add(row);
+ shown++;
+ if (shown >= MaxVisibleRows)
+ {
+ break;
+ }
+ }
+ }
+
+ private static void ClearRows()
+ {
+ foreach (GameObject row in Rows)
+ {
+ if (row != null)
+ {
+ Object.Destroy(row);
+ }
+ }
+
+ Rows.Clear();
+ }
+
+ private static GameObject BuildStaticRow(string text)
+ {
+ GameObject go = new GameObject("Empty", typeof(RectTransform), typeof(LayoutElement));
+ RectTransform rt = go.GetComponent();
+ rt.sizeDelta = new Vector2(PanelWidth - 24f, 26f);
+ LayoutElement le = go.GetComponent();
+ le.minHeight = 26f;
+ le.preferredHeight = 26f;
+
+ Text label = MakeText(go.transform, "Text", text, 9);
+ Stretch(label.GetComponent(), 4f);
+ label.alignment = TextAnchor.MiddleLeft;
+ label.color = new Color(0.7f, 0.72f, 0.75f, 0.95f);
+ return go;
+ }
+
+ private static GameObject BuildClickableRow(ChronicleEntry entry, int index)
+ {
+ GameObject go = new GameObject($"Row_{index}", typeof(RectTransform), typeof(Image), typeof(Button), typeof(LayoutElement));
+ RectTransform rt = go.GetComponent();
+ rt.sizeDelta = new Vector2(PanelWidth - 24f, 32f);
+ LayoutElement le = go.GetComponent();
+ le.minHeight = 32f;
+ le.preferredHeight = 32f;
+
+ Image bg = go.GetComponent();
+ bg.color = new Color(0.12f, 0.13f, 0.16f, 0.55f);
+
+ Text label = MakeText(go.transform, "Text", Truncate(entry.Line, 58), 9);
+ Stretch(label.GetComponent(), 6f);
+ label.alignment = TextAnchor.MiddleLeft;
+ label.color = ColorForKind(entry.Kind);
+
+ Button button = go.GetComponent