diff --git a/.cursor/skills/idle-spectator-e2e/SKILL.md b/.cursor/skills/idle-spectator-e2e/SKILL.md index ebe1029..fd726b5 100644 --- a/.cursor/skills/idle-spectator-e2e/SKILL.md +++ b/.cursor/skills/idle-spectator-e2e/SKILL.md @@ -86,7 +86,7 @@ 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) | +| `chronicle_smoke` | History inject, death-cause samples, World feed isolation, History\|World tabs, HUD jump | | `smoke` | Short enable/focus health check | | `tip_match` | Tip asset must match focused unit | | `regression` | Shell suite of all gate scenarios above | diff --git a/IdleSpectator/ActorChroniclePatches.cs b/IdleSpectator/ActorChroniclePatches.cs index 05322be..d153192 100644 --- a/IdleSpectator/ActorChroniclePatches.cs +++ b/IdleSpectator/ActorChroniclePatches.cs @@ -3,7 +3,7 @@ using HarmonyLib; namespace IdleSpectator; /// -/// Feeds Chronicle from real creature life events (kills, lovers, best friends). +/// Feeds character History from kills, deaths, lovers, and best friends. /// [HarmonyPatch] public static class ActorChroniclePatches @@ -15,6 +15,42 @@ public static class ActorChroniclePatches Chronicle.NoteKill(__instance, pDeadUnit); } + [HarmonyPatch(typeof(Actor), "die")] + [HarmonyPrefix] + public static void PrefixDie(Actor __instance, out Actor __state) + { + __state = null; + try + { + if (__instance == null) + { + return; + } + + BaseSimObject attacker = __instance.attackedBy; + if (attacker != null && !attacker.isRekt() && attacker.isActor() && attacker != __instance) + { + __state = attacker.a; + } + } + catch + { + __state = null; + } + } + + [HarmonyPatch(typeof(Actor), "die")] + [HarmonyPostfix] + public static void PostfixDie(Actor __instance, AttackType pType, bool pCountDeath, Actor __state) + { + if (!pCountDeath || __instance == null) + { + return; + } + + Chronicle.NoteDeath(__instance, pType, __state); + } + [HarmonyPatch(typeof(Actor), nameof(Actor.becomeLoversWith))] [HarmonyPostfix] public static void PostfixLovers(Actor __instance, Actor pTarget) diff --git a/IdleSpectator/AgentHarness.cs b/IdleSpectator/AgentHarness.cs index 8cf41f2..a0b29ea 100644 --- a/IdleSpectator/AgentHarness.cs +++ b/IdleSpectator/AgentHarness.cs @@ -316,7 +316,7 @@ public static class AgentHarness case "chronicle_note": { bool ok = Chronicle.ForceNoteCurrentFocus( - string.IsNullOrEmpty(cmd.label) ? "Slew" : cmd.label); + string.IsNullOrEmpty(cmd.label) ? "Killed" : cmd.label); if (ok) { _cmdOk++; @@ -326,7 +326,81 @@ public static class AgentHarness _cmdFail++; } - Emit(cmd, ok, detail: $"chronicle_count={Chronicle.Count} caption={WatchCaption.LastHeadline}"); + Emit(cmd, ok, detail: + $"history={Chronicle.HistoryCountFor(Chronicle.CurrentHistorySubjectId())} caption={WatchCaption.LastHeadline}"); + break; + } + + case "chronicle_clear": + { + Chronicle.ClearSession(); + _cmdOk++; + Emit(cmd, ok: true, detail: "chronicle_cleared"); + break; + } + + case "chronicle_world_force": + { + string label = !string.IsNullOrEmpty(cmd.label) + ? cmd.label + : (!string.IsNullOrEmpty(cmd.value) ? cmd.value : "War: harness vs test"); + bool ok = Chronicle.ForceWorldNote(label); + if (ok) + { + _cmdOk++; + } + else + { + _cmdFail++; + } + + Emit(cmd, ok, detail: $"world_count={Chronicle.WorldCount} label={label}"); + break; + } + + case "chronicle_death_sample": + { + string typeName = !string.IsNullOrEmpty(cmd.value) ? cmd.value : "Age"; + bool ok = Chronicle.ForceDeathSample(typeName); + if (ok) + { + _cmdOk++; + } + else + { + _cmdFail++; + } + + ChronicleEntry latest = Chronicle.Latest; + Emit(cmd, ok, detail: $"type={typeName} latest={(latest != null ? latest.Line : "")}"); + break; + } + + case "chronicle_tab": + { + string tab = (cmd.value ?? "").Trim().ToLowerInvariant(); + if (tab == "world") + { + ChronicleHud.SetTab(ChronicleTab.World); + } + else + { + ChronicleHud.SetTab(ChronicleTab.History); + tab = "history"; + } + + Chronicle.ShowHud(); + bool ok = ChronicleHud.ActiveTab.ToString().Equals(tab, System.StringComparison.OrdinalIgnoreCase); + if (ok) + { + _cmdOk++; + } + else + { + _cmdFail++; + } + + Emit(cmd, ok, detail: $"tab={ChronicleHud.ActiveTab}"); break; } @@ -343,7 +417,7 @@ public static class AgentHarness _cmdFail++; } - Emit(cmd, open, detail: $"hud={open}"); + Emit(cmd, open, detail: $"hud={open} tab={ChronicleHud.ActiveTab}"); break; } @@ -989,17 +1063,23 @@ public static class AgentHarness case "chronicle_count": { int want = ParseCountExpect(cmd, defaultValue: 1); - int have = Chronicle.Count; + int have = Chronicle.HistoryCountFor(Chronicle.CurrentHistorySubjectId()); string cmp = (cmd.label ?? "").Trim().ToLowerInvariant(); if (cmp == "min" || cmp == ">=" || string.IsNullOrEmpty(cmp)) { pass = have >= want; - detail = $"count={have} min={want}"; + detail = $"history_count={have} min={want}"; + } + else if (cmp == "world") + { + have = Chronicle.WorldCount; + pass = have >= want; + detail = $"world_count={have} min={want}"; } else { pass = have == want; - detail = $"count={have} want={want}"; + detail = $"history_count={have} want={want}"; } break; @@ -1020,6 +1100,25 @@ public static class AgentHarness detail = $"latest='{line}' needle='{needle}'"; break; } + case "chronicle_world_latest_contains": + { + string needle = string.IsNullOrEmpty(cmd.value) ? cmd.label : cmd.value; + ChronicleEntry latest = Chronicle.LatestWorld; + string line = latest != null ? latest.Line : ""; + pass = latest != null + && !string.IsNullOrEmpty(needle) + && line.IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0; + detail = $"world_latest='{line}' needle='{needle}'"; + 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}"; + break; + } default: pass = false; detail = "unknown expect: " + expect; diff --git a/IdleSpectator/Chronicle.cs b/IdleSpectator/Chronicle.cs index 957dd67..ebc81a2 100644 --- a/IdleSpectator/Chronicle.cs +++ b/IdleSpectator/Chronicle.cs @@ -6,6 +6,7 @@ namespace IdleSpectator; public enum ChronicleKind { + Death, Kill, Lover, Friend, @@ -13,62 +14,176 @@ public enum ChronicleKind Other } +public enum ChronicleTab +{ + History, + World +} + public sealed class ChronicleEntry { public float CreatedAt; public ChronicleKind Kind; - public long UnitId; + public long SubjectId; + public long OtherId; public string Name = ""; public string SpeciesId = ""; public string Line = ""; public Vector3 Position; + + /// Back-compat for harness / jump helpers. + public long UnitId => SubjectId; } /// -/// Session ring buffer of notable life/world events (kills, lovers, WorldLog), not spectator watch notes. +/// Session chronicles: per-character History + separate World feed (WorldLog Story/Epic). /// public static class Chronicle { public const KeyCode ToggleKey = KeyCode.F9; - private const int MaxEntries = 300; + private const int MaxHistoryPerUnit = 40; + private const int MaxWorldEntries = 100; - private static readonly List Entries = new List(); + private static readonly Dictionary> Histories = + new Dictionary>(); + private static readonly List WorldEntries = 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 bool _hudReady; + private static long _lastHistorySubjectId; - public static int Count + public static int Count => HistoryCountFor(CurrentHistorySubjectId()) + WorldCount; + + public static int WorldCount { get { - lock (Entries) + lock (WorldEntries) { - return Entries.Count; + return WorldEntries.Count; } } } public static bool HudVisible => ChronicleHud.Visible; - public static IReadOnlyList Snapshot() + public static long LastHistorySubjectId => _lastHistorySubjectId; + + public static int HistoryCountFor(long subjectId) { - lock (Entries) + if (subjectId == 0) { - return Entries.ToArray(); + return 0; + } + + lock (Histories) + { + return Histories.TryGetValue(subjectId, out List list) ? list.Count : 0; } } + public static IReadOnlyList SnapshotForFocus() + { + return SnapshotForSubject(CurrentHistorySubjectId()); + } + + public static IReadOnlyList SnapshotForSubject(long subjectId) + { + if (subjectId == 0) + { + return System.Array.Empty(); + } + + lock (Histories) + { + if (!Histories.TryGetValue(subjectId, out List list)) + { + return System.Array.Empty(); + } + + return list.ToArray(); + } + } + + public static IReadOnlyList SnapshotWorld() + { + lock (WorldEntries) + { + return WorldEntries.ToArray(); + } + } + + /// Latest history entry for the current/last focus (harness + jump). public static ChronicleEntry Latest { get { - lock (Entries) + IReadOnlyList list = SnapshotForFocus(); + return list.Count == 0 ? null : list[list.Count - 1]; + } + } + + public static ChronicleEntry LatestWorld + { + get + { + lock (WorldEntries) { - return Entries.Count == 0 ? null : Entries[Entries.Count - 1]; + return WorldEntries.Count == 0 ? null : WorldEntries[WorldEntries.Count - 1]; } } } + public static long CurrentHistorySubjectId() + { + if (MoveCamera.hasFocusUnit() && MoveCamera._focus_unit != null) + { + try + { + long id = MoveCamera._focus_unit.getID(); + if (id != 0) + { + _lastHistorySubjectId = id; + return id; + } + } + catch + { + // fall through + } + } + + return _lastHistorySubjectId; + } + + public static string CurrentHistoryTitle() + { + long id = CurrentHistorySubjectId(); + if (id == 0) + { + return ""; + } + + Actor unit = FindUnitById(id); + if (unit != null) + { + return $"{SafeName(unit)} ({SpeciesOf(unit)})"; + } + + IReadOnlyList list = SnapshotForSubject(id); + if (list.Count > 0) + { + ChronicleEntry e = list[list.Count - 1]; + if (!string.IsNullOrEmpty(e.Name)) + { + return string.IsNullOrEmpty(e.SpeciesId) ? e.Name : $"{e.Name} ({e.SpeciesId})"; + } + } + + return "Unit #" + id; + } + public static void EnsureWindow() { if (!ModSettings.ChronicleEnabled) @@ -87,7 +202,7 @@ public static class Chronicle if (ChronicleHud.IsReady) { _hudReady = true; - LogService.LogInfo("[IdleSpectator] Chronicle HUD ready (F9, life-event side panel)"); + LogService.LogInfo("[IdleSpectator] Chronicle HUD ready (F9, History|World)"); } } catch (System.Exception ex) @@ -98,13 +213,20 @@ public static class Chronicle public static void ClearSession() { - lock (Entries) + lock (Histories) { - Entries.Clear(); + Histories.Clear(); + } + + lock (WorldEntries) + { + WorldEntries.Clear(); } LoverPairKeys.Clear(); FriendPairKeys.Clear(); + DeathLogged.Clear(); + _lastHistorySubjectId = 0; } public static void PollInput() @@ -155,7 +277,7 @@ public static class Chronicle return false; } - Actor unit = FindUnitById(entry.UnitId); + Actor unit = FindUnitById(entry.SubjectId); if (unit != null && unit.isAlive()) { CameraDirector.FocusUnit(unit); @@ -163,6 +285,17 @@ public static class Chronicle return true; } + if (entry.OtherId != 0) + { + Actor other = FindUnitById(entry.OtherId); + if (other != null && other.isAlive()) + { + CameraDirector.FocusUnit(other); + WatchCaption.SetFromActor(other); + return true; + } + } + if (entry.Position != Vector3.zero) { CameraDirector.PanTo(entry.Position); @@ -179,9 +312,27 @@ public static class Chronicle return; } + // Keep last history subject sticky when focus dies / drops briefly. + if (MoveCamera.hasFocusUnit() && MoveCamera._focus_unit != null) + { + try + { + long id = MoveCamera._focus_unit.getID(); + if (id != 0) + { + _lastHistorySubjectId = id; + } + } + catch + { + // ignore + } + } + ChronicleHud.UpdateLive(); } + /// Killer POV - character history only. public static void NoteKill(Actor killer, Actor victim) { if (!ModSettings.ChronicleEnabled || killer == null || victim == null) @@ -189,14 +340,38 @@ public static class Chronicle return; } - string killerName = SafeName(killer); - string victimName = SafeName(victim); - string killerSpecies = SpeciesOf(killer); - string victimSpecies = SpeciesOf(victim); - Append( + AppendHistory( ChronicleKind.Kill, killer, - $"Slew: {killerName} ({killerSpecies}) felled {victimName} ({victimSpecies})"); + victim, + $"Killed {SafeName(victim)} ({SpeciesOf(victim)})"); + } + + /// Victim POV - cause of death on character history. + public static void NoteDeath(Actor victim, AttackType attackType, Actor killer) + { + if (!ModSettings.ChronicleEnabled || victim == null) + { + return; + } + + long id = 0; + try + { + id = victim.getID(); + } + catch + { + return; + } + + if (id == 0 || !DeathLogged.Add(id)) + { + return; + } + + string line = FormatDeathCause(victim, attackType, killer); + AppendHistory(ChronicleKind.Death, victim, killer, line); } public static void NoteLovers(Actor a, Actor b) @@ -212,10 +387,8 @@ public static class Chronicle return; } - Append( - ChronicleKind.Lover, - a, - $"Lovers: {SafeName(a)} and {SafeName(b)}"); + AppendHistory(ChronicleKind.Lover, a, b, $"Became lovers with {SafeName(b)}"); + AppendHistory(ChronicleKind.Lover, b, a, $"Became lovers with {SafeName(a)}"); } public static void NoteBestFriends(Actor a, Actor b) @@ -231,10 +404,8 @@ public static class Chronicle return; } - Append( - ChronicleKind.Friend, - a, - $"Friends: {SafeName(a)} befriended {SafeName(b)}"); + AppendHistory(ChronicleKind.Friend, a, b, $"Befriended {SafeName(b)}"); + AppendHistory(ChronicleKind.Friend, b, a, $"Befriended {SafeName(a)}"); } public static void NoteWorldLog(WorldLogMessage message, string label) @@ -267,15 +438,18 @@ public static class Chronicle // ignore } - Append( - ChronicleKind.World, - unit, - $"World: {label}", - fallbackPosition: pos); + 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); + } } - /// Harness: inject a life-event style line for the current focus. - public static bool ForceNoteCurrentFocus(string prefix = "Slew") + /// Harness: inject a life-event style line on the focused unit's History. + public static bool ForceNoteCurrentFocus(string prefix = "Killed") { if (!MoveCamera.hasFocusUnit() || MoveCamera._focus_unit == null) { @@ -284,15 +458,162 @@ public static class Chronicle Actor unit = MoveCamera._focus_unit; UnitDossier dossier = UnitDossier.FromActor(unit); - string kindPrefix = string.IsNullOrEmpty(prefix) ? "Slew" : prefix; - Append( + string kindPrefix = string.IsNullOrEmpty(prefix) ? "Killed" : prefix; + AppendHistory( ChronicleKind.Other, unit, + null, $"{kindPrefix}: {dossier.Name} ({dossier.SpeciesId}) - life event"); return true; } - private static void Append(ChronicleKind kind, Actor unit, string line, Vector3 fallbackPosition = default) + /// Harness: inject a World-feed line (does not touch character History). + public static bool ForceWorldNote(string label) + { + if (string.IsNullOrEmpty(label)) + { + return false; + } + + AppendWorld(null, label, Vector3.zero); + return true; + } + + /// Harness: append a formatted death-cause line for the focused unit. + public static bool ForceDeathSample(string attackTypeName, Actor killer = null) + { + if (!MoveCamera.hasFocusUnit() || MoveCamera._focus_unit == null) + { + return false; + } + + if (!System.Enum.TryParse(attackTypeName, ignoreCase: true, out AttackType attackType)) + { + return false; + } + + Actor victim = MoveCamera._focus_unit; + string line = FormatDeathCause(victim, attackType, killer); + AppendHistory(ChronicleKind.Death, victim, killer, line); + return true; + } + + public static string FormatDeathCause(Actor victim, AttackType attackType, Actor killer) + { + string killerLabel = killer != null + ? $"{SafeName(killer)} ({SpeciesOf(killer)})" + : ""; + + switch (attackType) + { + case AttackType.Age: + return "Died of old age"; + case AttackType.Starvation: + return "Starved to death"; + case AttackType.Fire: + return IsOnLava(victim) ? "Burned in lava" : "Burned to death"; + case AttackType.Drowning: + case AttackType.Water: + return "Drowned"; + case AttackType.Poison: + return "Died of poison"; + case AttackType.Plague: + return "Died of plague"; + case AttackType.Infection: + return "Died of infection"; + case AttackType.Tumor: + return "Died of a tumor"; + case AttackType.Acid: + return "Dissolved in acid"; + case AttackType.AshFever: + return "Died of ash fever"; + case AttackType.Gravity: + return "Fell to their death"; + case AttackType.Explosion: + return "Died in an explosion"; + case AttackType.Divine: + return "Struck down by divine power"; + case AttackType.Eaten: + return string.IsNullOrEmpty(killerLabel) + ? "Was eaten" + : $"Eaten by {killerLabel}"; + case AttackType.Weapon: + case AttackType.Other: + case AttackType.Smile: + if (!string.IsNullOrEmpty(killerLabel)) + { + return $"Killed by {killerLabel}"; + } + + return attackType == AttackType.Weapon ? "Killed in combat" : "Died"; + case AttackType.Metamorphosis: + return "Transformed away"; + case AttackType.None: + return "Died"; + default: + return string.IsNullOrEmpty(killerLabel) + ? $"Died ({attackType})" + : $"Killed by {killerLabel}"; + } + } + + private static bool IsOnLava(Actor victim) + { + try + { + WorldTile tile = victim.current_tile; + return tile != null && tile.Type != null && tile.Type.lava; + } + catch + { + return false; + } + } + + private static void AppendHistory(ChronicleKind kind, Actor subject, Actor other, string line) + { + if (subject == null || string.IsNullOrEmpty(line)) + { + return; + } + + long subjectId = subject.getID(); + if (subjectId == 0) + { + return; + } + + ChronicleEntry entry = new ChronicleEntry + { + CreatedAt = Time.unscaledTime, + Kind = kind, + SubjectId = subjectId, + OtherId = other != null ? other.getID() : 0, + Name = SafeName(subject), + SpeciesId = SpeciesOf(subject), + Line = line, + Position = subject.current_position + }; + + lock (Histories) + { + if (!Histories.TryGetValue(subjectId, out List list)) + { + list = new List(); + Histories[subjectId] = list; + } + + list.Add(entry); + while (list.Count > MaxHistoryPerUnit) + { + list.RemoveAt(0); + } + } + + LogService.LogInfo($"[IdleSpectator][CHRONICLE][History:{subjectId}] {line}"); + } + + private static void AppendWorld(Actor unit, string line, Vector3 fallbackPosition) { if (string.IsNullOrEmpty(line)) { @@ -302,24 +623,25 @@ public static class Chronicle ChronicleEntry entry = new ChronicleEntry { CreatedAt = Time.unscaledTime, - Kind = kind, - UnitId = unit != null ? unit.getID() : 0, + 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 }; - lock (Entries) + lock (WorldEntries) { - Entries.Add(entry); - while (Entries.Count > MaxEntries) + WorldEntries.Add(entry); + while (WorldEntries.Count > MaxWorldEntries) { - Entries.RemoveAt(0); + WorldEntries.RemoveAt(0); } } - LogService.LogInfo("[IdleSpectator][CHRONICLE] " + line); + LogService.LogInfo("[IdleSpectator][CHRONICLE][World] " + line); } private static string PairKey(long a, long b) diff --git a/IdleSpectator/ChronicleHud.cs b/IdleSpectator/ChronicleHud.cs index aab1b1e..8a711c8 100644 --- a/IdleSpectator/ChronicleHud.cs +++ b/IdleSpectator/ChronicleHud.cs @@ -1,5 +1,4 @@ using System.Collections.Generic; -using NeoModLoader.General; using NeoModLoader.services; using UnityEngine; using UnityEngine.Events; @@ -8,25 +7,34 @@ 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). +/// Compact bottom-right chronicle card with History | World tabs. /// public static class ChronicleHud { - private const float PanelWidth = 300f; - private const int MaxVisibleRows = 14; + private const float PanelWidth = 248f; + private const float PanelHeight = 178f; + private const int MaxVisibleRows = 5; + private const float RowHeight = 20f; private static GameObject _root; private static RectTransform _content; - private static Text _header; + private static Text _titleText; + 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; public static bool Visible => _visible && _root != null && _root.activeSelf; public static bool IsReady => _root != null; + public static ChronicleTab ActiveTab => _tab; + public static void EnsureBuilt() { if (_root != null) @@ -39,7 +47,7 @@ public static class ChronicleHud return; } - Canvas canvas = ResolveCanvas(); + Canvas canvas = HudCanvas.Resolve(); if (canvas == null) { return; @@ -49,50 +57,54 @@ public static class ChronicleHud _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); + 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, 12f); Image bg = _root.GetComponent(); - bg.color = new Color(0.05f, 0.06f, 0.08f, 0.82f); + bg.color = new Color(0.05f, 0.06f, 0.08f, 0.65f); 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); + BuildTabButton(_root.transform, "HistoryTab", "History", -6f, 0f, out _historyTabBg, out _historyTabLabel, + () => SetTab(ChronicleTab.History)); + BuildTabButton(_root.transform, "WorldTab", "World", -6f, 72f, out _worldTabBg, out _worldTabLabel, + () => SetTab(ChronicleTab.World)); - 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); + Text f9 = HudCanvas.MakeText(_root.transform, "F9", "F9", 9); + 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); + f9.alignment = TextAnchor.MiddleRight; + f9.color = new Color(0.55f, 0.58f, 0.62f, 1f); + + _titleText = HudCanvas.MakeText(_root.transform, "Title", "", 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.sizeDelta = new Vector2(-14f, 16f); + titleRt.anchoredPosition = new Vector2(0f, -26f); + _titleText.alignment = TextAnchor.MiddleLeft; + _titleText.color = new Color(0.78f, 0.8f, 0.84f, 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); + scrollRt.offsetMin = new Vector2(6f, 6f); + scrollRt.offsetMax = new Vector2(-6f, -44f); Image scrollBg = scrollGo.GetComponent(); - scrollBg.color = new Color(0f, 0f, 0f, 0.08f); + scrollBg.color = new Color(0f, 0f, 0f, 0.05f); Mask mask = scrollGo.GetComponent(); mask.showMaskGraphic = false; @@ -111,7 +123,7 @@ public static class ChronicleHud layout.childControlWidth = true; layout.childForceExpandHeight = false; layout.childForceExpandWidth = true; - layout.spacing = 2f; + layout.spacing = 1f; layout.padding = new RectOffset(0, 0, 0, 0); ContentSizeFitter fitter = contentGo.GetComponent(); @@ -125,9 +137,17 @@ 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, non-modal side panel)"); + LogService.LogInfo("[IdleSpectator] Chronicle HUD ready (F9, History|World)"); + } + + public static void SetTab(ChronicleTab tab) + { + _tab = tab; + RefreshTabChrome(); + Rebuild(force: true); } public static void SetVisible(bool visible) @@ -158,7 +178,12 @@ public static class ChronicleHud return; } - if (Chronicle.Count != _lastCount) + long subjectId = Chronicle.CurrentHistorySubjectId(); + int count = _tab == ChronicleTab.History + ? Chronicle.HistoryCountFor(subjectId) + : Chronicle.WorldCount; + + if (count != _lastCount || subjectId != _lastSubjectId) { Rebuild(force: true); } @@ -173,15 +198,50 @@ public static class ChronicleHud } ClearRows(); - IReadOnlyList entries = Chronicle.Snapshot(); - _lastCount = entries.Count; + RefreshTabChrome(); - if (entries.Count == 0) + long subjectId = Chronicle.CurrentHistorySubjectId(); + _lastSubjectId = subjectId; + + IReadOnlyList entries; + if (_tab == ChronicleTab.World) { - GameObject empty = BuildStaticRow("Waiting for life events..."); - empty.transform.SetParent(_content, false); - Rows.Add(empty); - return; + entries = Chronicle.SnapshotWorld(); + _lastCount = entries.Count; + if (_titleText != null) + { + _titleText.text = "World events"; + } + + 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; + } + + if (subjectId == 0) + { + AddEmpty("Focus a creature to see history"); + return; + } + + if (entries.Count == 0) + { + AddEmpty("No history yet"); + return; + } } int shown = 0; @@ -198,6 +258,78 @@ public static class ChronicleHud } } + private static void AddEmpty(string text) + { + GameObject empty = BuildStaticRow(text); + empty.transform.SetParent(_content, false); + Rows.Add(empty); + } + + private static void BuildTabButton( + Transform parent, + string name, + string label, + 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(68f, 16f); + rt.anchoredPosition = new Vector2(8f + x, yFromTop); + + bg = go.GetComponent(); + bg.color = new Color(0.15f, 0.16f, 0.2f, 0.9f); + + text = HudCanvas.MakeText(go.transform, "Label", label, 9); + RectTransform textRt = text.GetComponent(); + textRt.anchorMin = Vector2.zero; + textRt.anchorMax = Vector2.one; + textRt.offsetMin = Vector2.zero; + textRt.offsetMax = Vector2.zero; + text.alignment = TextAnchor.MiddleCenter; + text.raycastTarget = false; + + Button button = go.GetComponent