diff --git a/.cursor/skills/idle-spectator-e2e/SKILL.md b/.cursor/skills/idle-spectator-e2e/SKILL.md index 5167bb5..829e538 100644 --- a/.cursor/skills/idle-spectator-e2e/SKILL.md +++ b/.cursor/skills/idle-spectator-e2e/SKILL.md @@ -143,7 +143,7 @@ Use `fast_timing` / `director_run` instead. - `set_setting` (`expect=enabled|show_watch_reasons|show_dossier_caption|chronicle_enabled`) - `discovery_reset`, `discovery_note`, `discovery_mark`, `discovery_drain` - `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`, `dossier_traits_ok`, `caption_layout_ok`, `dossier_history_contains`, `chronicle_count`, `chronicle_latest_contains` +- `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`, `dossier_traits_ok`, `caption_layout_ok`, `dossier_history_contains`, `chronicle_count`, `chronicle_latest_contains`, `chronicle_latest_dated`, `focus_arrows` - `snapshot`, `reset_counters`, `scenario` ## Process safety diff --git a/IdleSpectator/AgentHarness.cs b/IdleSpectator/AgentHarness.cs index f626f68..699a4bb 100644 --- a/IdleSpectator/AgentHarness.cs +++ b/IdleSpectator/AgentHarness.cs @@ -1121,18 +1121,29 @@ public static class AgentHarness ChronicleEntry latest = Chronicle.Latest; string line = latest != null ? latest.Line : ""; + string display = latest != null ? latest.DisplayLine : ""; pass = latest != null && !string.IsNullOrEmpty(needle) && line.IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0; - detail = $"latest='{line}' needle='{needle}'"; + detail = $"latest='{display}' needle='{needle}' date='{(latest != null ? latest.DateLabel : "")}'"; + break; + } + case "chronicle_latest_dated": + { + ChronicleEntry latest = Chronicle.Latest; + pass = latest != null + && !string.IsNullOrEmpty(latest.DateLabel) + && latest.WorldTime > 0; + detail = + $"date='{(latest != null ? latest.DateLabel : "")}' worldTime={(latest != null ? latest.WorldTime.ToString("0.###") : "0")} line='{(latest != null ? latest.Line : "")}'"; break; } case "caption_layout_ok": { pass = WatchCaption.LastLayoutOk && WatchCaption.LastPanelSize.y > 20f - && WatchCaption.LastPanelSize.y < 140f - && WatchCaption.LastPanelSize.x <= 300f; + && WatchCaption.LastPanelSize.y < 220f + && WatchCaption.LastPanelSize.x <= 440f; detail = $"layoutOk={WatchCaption.LastLayoutOk} size={WatchCaption.LastPanelSize} caption='{(WatchCaption.LastCaptionText ?? "").Replace("\n", " | ")}' hist='{(WatchCaption.LastHistoryPreview ?? "").Replace("\n", " ")}'"; break; diff --git a/IdleSpectator/CameraDirector.cs b/IdleSpectator/CameraDirector.cs index cadb01b..20bc467 100644 --- a/IdleSpectator/CameraDirector.cs +++ b/IdleSpectator/CameraDirector.cs @@ -35,11 +35,6 @@ public static class CameraDirector string tip = FormatWatchTip(interest); LastWatchLabel = interest.Label ?? ""; LastWatchAssetId = interest.AssetId ?? ""; - if (ModSettings.ShowWatchReasons && !ModSettings.ShowDossierCaption) - { - WorldTip.showNowTop(tip, pTranslate: false); - } - LogService.LogInfo($"[IdleSpectator] {tip}"); WatchCaption.SetFromInterest(interest); diff --git a/IdleSpectator/Chronicle.cs b/IdleSpectator/Chronicle.cs index b22785a..1cb2342 100644 --- a/IdleSpectator/Chronicle.cs +++ b/IdleSpectator/Chronicle.cs @@ -23,6 +23,10 @@ public enum ChronicleTab public sealed class ChronicleEntry { public float CreatedAt; + /// WorldBox sim time when the event was recorded (). + public double WorldTime; + /// In-world month + year, e.g. January 1 (no brackets). + public string DateLabel = ""; public ChronicleKind Kind; public long SubjectId; public long OtherId; @@ -33,6 +37,46 @@ public sealed class ChronicleEntry /// Back-compat for harness / jump helpers. public long UnitId => SubjectId; + + /// Plain date-prefixed line for logs / asserts. + public string DisplayLine + { + get + { + if (string.IsNullOrEmpty(DateLabel)) + { + return Line ?? ""; + } + + if (string.IsNullOrEmpty(Line)) + { + return "[" + DateLabel + "]"; + } + + return "[" + DateLabel + "] " + Line; + } + } + + /// HUD line with a muted colored date in brackets (Unity rich text). + public string DisplayLineRich + { + get + { + if (string.IsNullOrEmpty(DateLabel)) + { + return Line ?? ""; + } + + const string color = "#A8B4C8"; + string stamped = "[" + DateLabel + "]"; + if (string.IsNullOrEmpty(Line)) + { + return stamped; + } + + return stamped + " " + Line; + } + } } /// @@ -494,13 +538,21 @@ public static class Chronicle } Actor unit = MoveCamera._focus_unit; - UnitDossier dossier = UnitDossier.FromActor(unit); string kindPrefix = string.IsNullOrEmpty(prefix) ? "Killed" : prefix; - AppendHistory( - ChronicleKind.Other, - unit, - null, - $"{kindPrefix}: {dossier.Name} ({dossier.SpeciesId}) - life event"); + // 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) + { + UnitDossier dossier = UnitDossier.FromActor(unit); + line = $"{kindPrefix}: {dossier.Name} ({dossier.SpeciesId}) - life event"; + } + else + { + line = kindPrefix; + } + + AppendHistory(ChronicleKind.Other, unit, null, line); return true; } @@ -620,9 +672,12 @@ public static class Chronicle return; } + StampWorldDate(out double worldTime, out string dateLabel); ChronicleEntry entry = new ChronicleEntry { CreatedAt = Time.unscaledTime, + WorldTime = worldTime, + DateLabel = dateLabel, Kind = kind, SubjectId = subjectId, OtherId = other != null ? other.getID() : 0, @@ -647,7 +702,7 @@ public static class Chronicle } } - LogService.LogInfo($"[IdleSpectator][CHRONICLE][History:{subjectId}] {line}"); + LogService.LogInfo($"[IdleSpectator][CHRONICLE][History:{subjectId}] {entry.DisplayLine}"); } private static void AppendWorld(Actor unit, string line, Vector3 fallbackPosition) @@ -657,9 +712,12 @@ public static class Chronicle return; } + StampWorldDate(out double worldTime, out string dateLabel); ChronicleEntry entry = new ChronicleEntry { CreatedAt = Time.unscaledTime, + WorldTime = worldTime, + DateLabel = dateLabel, Kind = ChronicleKind.World, SubjectId = unit != null ? unit.getID() : 0, OtherId = 0, @@ -678,7 +736,44 @@ public static class Chronicle } } - LogService.LogInfo("[IdleSpectator][CHRONICLE][World] " + line); + LogService.LogInfo("[IdleSpectator][CHRONICLE][World] " + entry.DisplayLine); + } + + /// + /// Capture current WorldBox calendar as month + year (e.g. January 1). + /// + private static void StampWorldDate(out double worldTime, out string dateLabel) + { + worldTime = 0; + dateLabel = ""; + try + { + if (World.world == null) + { + return; + } + + worldTime = World.world.getCurWorldTime(); + int[] raw = Date.getRawDate(worldTime); + if (raw == null || raw.Length < 3) + { + dateLabel = "y" + Date.getCurrentYear() + " m" + Date.getCurrentMonth(); + return; + } + + // raw: [day, month, year] - UI uses month + year only + string monthName = Date.formatMonth(raw[1]); + if (string.IsNullOrEmpty(monthName)) + { + monthName = "m" + raw[1]; + } + + dateLabel = monthName + " " + raw[2]; + } + catch + { + dateLabel = ""; + } } private static string PairKey(long a, long b) diff --git a/IdleSpectator/ChronicleHud.cs b/IdleSpectator/ChronicleHud.cs index ee83e87..32f3aa4 100644 --- a/IdleSpectator/ChronicleHud.cs +++ b/IdleSpectator/ChronicleHud.cs @@ -11,10 +11,10 @@ namespace IdleSpectator; /// public static class ChronicleHud { - private const float PanelWidth = 260f; - private const float PanelHeight = 186f; - private const int MaxVisibleRows = 5; - private const float RowHeight = 24f; + 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; @@ -129,11 +129,11 @@ public static class ChronicleHud VerticalLayoutGroup layout = contentGo.GetComponent(); layout.childAlignment = TextAnchor.UpperCenter; - layout.childControlHeight = false; + layout.childControlHeight = true; layout.childControlWidth = true; layout.childForceExpandHeight = false; layout.childForceExpandWidth = true; - layout.spacing = 2f; + layout.spacing = 3f; layout.padding = new RectOffset(0, 0, 0, 0); ContentSizeFitter fitter = contentGo.GetComponent(); @@ -378,46 +378,69 @@ public static class ChronicleHud { GameObject go = new GameObject("Empty", typeof(RectTransform), typeof(LayoutElement)); RectTransform rt = go.GetComponent(); - rt.sizeDelta = new Vector2(PanelWidth - 16f, RowHeight); + rt.sizeDelta = new Vector2(PanelWidth - 16f, RowHeightMin); LayoutElement le = go.GetComponent(); - le.minHeight = RowHeight; - le.preferredHeight = RowHeight; + le.minHeight = RowHeightMin; + le.preferredHeight = RowHeightMin; Text label = HudCanvas.MakeText(go.transform, "Text", text, 9); Stretch(label.GetComponent(), 4f); label.alignment = TextAnchor.MiddleLeft; label.color = new Color(0.65f, 0.68f, 0.72f, 0.95f); + label.resizeTextForBestFit = false; + label.horizontalOverflow = HorizontalWrapMode.Wrap; + label.verticalOverflow = VerticalWrapMode.Overflow; return go; } private static GameObject BuildClickableRow(ChronicleEntry entry, int index) { - GameObject go = new GameObject($"Row_{index}", typeof(RectTransform), typeof(Image), typeof(Button), typeof(LayoutElement)); + GameObject go = new GameObject( + $"Row_{index}", + typeof(RectTransform), + typeof(Image), + typeof(Button), + typeof(LayoutElement), + typeof(ContentSizeFitter)); RectTransform rt = go.GetComponent(); - rt.sizeDelta = new Vector2(PanelWidth - 16f, RowHeight); + rt.sizeDelta = new Vector2(PanelWidth - 16f, RowHeightMin); + LayoutElement le = go.GetComponent(); - le.minHeight = RowHeight; - le.preferredHeight = RowHeight; + le.minHeight = RowHeightMin; + le.preferredHeight = -1f; + + ContentSizeFitter rowFit = go.GetComponent(); + rowFit.verticalFit = ContentSizeFitter.FitMode.PreferredSize; + rowFit.horizontalFit = ContentSizeFitter.FitMode.Unconstrained; Image bg = go.GetComponent(); bg.color = new Color(1f, 1f, 1f, 0.03f); Image kindIcon = HudCanvas.MakeIcon(go.transform, "Kind", KindIconSize); RectTransform kindRt = kindIcon.GetComponent(); - kindRt.anchorMin = new Vector2(0f, 0.5f); - kindRt.anchorMax = new Vector2(0f, 0.5f); - kindRt.pivot = new Vector2(0.5f, 0.5f); - kindRt.anchoredPosition = new Vector2(10f, 0f); + kindRt.anchorMin = new Vector2(0f, 1f); + kindRt.anchorMax = new Vector2(0f, 1f); + kindRt.pivot = new Vector2(0.5f, 1f); + kindRt.anchoredPosition = new Vector2(10f, -7f); HudIcons.Apply(kindIcon, HudIcons.ForChronicleKind(entry.Kind)); - Text label = HudCanvas.MakeText(go.transform, "Text", Truncate(entry.Line, 40), 9); + Text label = HudCanvas.MakeText(go.transform, "Text", entry.DisplayLineRich, 9); RectTransform labelRt = label.GetComponent(); - labelRt.anchorMin = Vector2.zero; - labelRt.anchorMax = Vector2.one; - labelRt.offsetMin = new Vector2(22f, 1f); - labelRt.offsetMax = new Vector2(-4f, -1f); - label.alignment = TextAnchor.MiddleLeft; + labelRt.anchorMin = new Vector2(0f, 0f); + labelRt.anchorMax = new Vector2(1f, 1f); + labelRt.offsetMin = new Vector2(22f, 4f); + labelRt.offsetMax = new Vector2(-6f, -4f); + label.alignment = TextAnchor.UpperLeft; label.color = ColorForKind(entry.Kind); + label.supportRichText = true; + label.resizeTextForBestFit = false; + label.horizontalOverflow = HorizontalWrapMode.Wrap; + label.verticalOverflow = VerticalWrapMode.Overflow; + + // Drive row height from wrapped text preferred height. + ContentSizeFitter textFit = label.gameObject.AddComponent(); + textFit.verticalFit = ContentSizeFitter.FitMode.PreferredSize; + textFit.horizontalFit = ContentSizeFitter.FitMode.Unconstrained; Button button = go.GetComponent