From 43cc098ac9193b89677a36d892e2ca37c0594f0a Mon Sep 17 00:00:00 2001 From: DazedAnon Date: Tue, 14 Jul 2026 15:55:22 -0500 Subject: [PATCH] Base chronicler --- .cursor/skills/idle-spectator-e2e/SKILL.md | 76 ++-- .../skills/idle-spectator-e2e/reference.md | 8 + IdleSpectator/ActorChroniclePatches.cs | 36 ++ IdleSpectator/AgentHarness.cs | 105 ++++- IdleSpectator/CameraDirector.cs | 12 +- IdleSpectator/Chronicle.cs | 372 ++++++++++++++++++ IdleSpectator/ChronicleHud.cs | 341 ++++++++++++++++ IdleSpectator/HarnessScenarios.cs | 38 ++ IdleSpectator/Locales/en.json | 7 +- IdleSpectator/Main.cs | 7 +- IdleSpectator/ModSettings.cs | 6 + IdleSpectator/SpectatorMode.cs | 7 +- IdleSpectator/UnitDossier.cs | 243 ++++++++++++ IdleSpectator/WatchCaption.cs | 111 ++++++ IdleSpectator/WorldActivityScanner.cs | 56 ++- IdleSpectator/WorldLogPatches.cs | 17 + IdleSpectator/default_config.json | 10 + IdleSpectator/mod.json | 4 +- README.md | 4 + scripts/harness-run.sh | 1 + 20 files changed, 1407 insertions(+), 54 deletions(-) create mode 100644 IdleSpectator/ActorChroniclePatches.cs create mode 100644 IdleSpectator/Chronicle.cs create mode 100644 IdleSpectator/ChronicleHud.cs create mode 100644 IdleSpectator/UnitDossier.cs create mode 100644 IdleSpectator/WatchCaption.cs 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