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