using System;
using System.Collections.Generic;
using NeoModLoader.services;
using UnityEngine;
using UnityEngine.Events;
using UnityEngine.UI;
namespace IdleSpectator;
///
/// Compact dossier: character beat caption (Identity + Beat + Context), avatar + mini History,
/// statuses, traits; life-saga rail + hover Cast/Legacy depth.
///
public static class WatchCaption
{
///
/// Locked outer width whenever the rail is shown so hover depth cannot slide
/// right-anchored glyphs out from under the hover cursor.
///
private const float PanelWidthMax = 240f;
private const float SpeciesSize = 14f;
private const float SexSize = 12f;
private const float LiveMax = 40f;
private const float ChipIcon = 11f;
/// Max nametag task label slot; PlaceHeader may shrink further to clear sex/favorite.
private const float TaskLabelW = 72f;
private const float TraitIcon = 11f;
private const float HistoryIcon = 9f;
private const float HistoryColMinW = 118f;
private const float NameMinW = 24f;
/// Hard cap so long "Name (Species/Job)" lines cannot paint under the level chip.
private const float NameMaxW = 160f;
private const float PadX = 4f;
/// Inset from the canvas top-right corner (grows left via pivot).
private const float ScreenInset = 12f;
private const float PadTop = 3f;
private const float HeaderH = 15f;
private const float BodyH = 40f;
private const float TraitsH = 13f;
private const float StatusesH = 13f;
private const float ReasonH = 13f;
private const float ConnectionH = 11f;
private const float StorySpineH = 11f;
private const float HistoryLineMinH = 12f;
private const float HistoryLineMaxH = 100f;
private const float Gap = 1f;
private const int HistoryPeekMax = 3;
private const float BtnSize = 14f;
private const float StatusBannerSeconds = 5f;
/// History column grows with content until the dossier hits .
private static float HistoryColMaxW =>
Mathf.Max(HistoryColMinW, PanelWidthMax - PadX * 2f - LiveMax - 4f);
/// Inner width available to dossier/saga body under the rail.
private static float BodyColumnInnerWidth() =>
Mathf.Max(120f, PanelWidthMax - PadX * 2f);
private static float _panelWidth = LiveMax + PadX * 2f;
private static readonly Color NameColor = HudTheme.Name;
private static readonly Color StatValueColor = HudTheme.ValueOrange;
private static readonly Color TaskColor = HudTheme.TaskGreen;
private static readonly Color ReasonColor = HudTheme.ValueOrange;
private static readonly Color StorySpineColor = HudTheme.Muted;
private static readonly Color ConnectionColor = HudTheme.Muted;
private static readonly Color TraitNameColor = HudTheme.Body;
private static readonly Color StatusNameColor = HudTheme.StatusBlue;
private static readonly Color HistoryTextColor = HudTheme.Body;
private static readonly Color StatusBannerColor = HudTheme.ValueOrange;
private static GameObject _root;
private static RectTransform _rootRt;
private static Image _speciesIcon;
private static Image _sexIcon;
private static Text _nameText;
private static Text _reasonText;
private static Text _connectionText;
private static Text _storySpineText;
private static long _reasonOtherId;
private static Image _levelIcon;
private static Text _levelValue;
private static Image _taskIcon;
private static Text _taskText;
private static readonly TraitSlot[] _traitSlots = new TraitSlot[UnitDossier.MaxTraitChips];
private static GameObject _traitsRow;
private static readonly TraitSlot[] _statusSlots = new TraitSlot[UnitDossier.MaxStatusChips];
private static readonly UnitDossier StatusProbe = new UnitDossier();
private static GameObject _statusesRow;
private static int _lastStatusesFingerprint = int.MinValue;
private static GameObject _historyCol;
private static Image _activityBoxBg;
private static RectTransform _lifeSep;
private static readonly HistorySlot[] _historySlots = new HistorySlot[HistoryPeekMax];
private static readonly float[] _historySlotHeights = new float[HistoryPeekMax];
private static readonly bool[] _historySlotIsActivity = new bool[HistoryPeekMax];
private static float _historyColW = HistoryColMinW;
private static int _lastHistoryCount = -1;
private static long _lastHistorySubjectId;
private static int _lastLiveHistFingerprint = int.MinValue;
private const float LifeSepH = 3f;
private static Button _favoriteBtn;
private static Image _favoriteIcon;
private static bool _pinnedWhilePaused;
/// Harness ForceNametagHeadline lock - skip live identity overwrite until rebuild.
private static bool _headlineLocked;
private static UnitDossier _current;
private static Actor _boundActor;
private static bool _visible;
private static bool _layoutDirty = true;
private static string _layoutKey = "";
private static string _lastAppliedCaptionFingerprint = "";
private static int _lastIdentityMemoryRevision = int.MinValue;
private static float _nextRoutineLiveRefreshAt;
private static float _nextRoutineSpineAt;
private const float RoutineLiveRefreshSeconds = 0.2f;
private const float RoutineSpineRefreshSeconds = 0.15f;
private static bool _dossierRowsNeedRestore;
private static float _nextPortraitAt;
private static long _portraitActorId;
private const float PortraitRefreshSeconds = 1f;
private static string _statusBanner = "";
private static float _statusBannerUntil;
private static string _lastLoggedCaption = "";
// Saga hover owns the painted header without changing the camera dossier's semantic
// LastHeadline. Keep its display text and entry typography separate from that cache.
private static string _sagaHoverHeadline = "";
private static int _sagaHoverFontCeiling;
// OwnedEventReason performs stranger-name presentability checks against the living
// population. A developed world makes that expensive, while the active director
// candidate is normally unchanged for many 0.2-second caption refreshes.
private static bool _ownedReasonCacheValid;
private static long _ownedReasonActorId;
private static InterestCandidate _ownedReasonScene;
private static string _ownedReasonSceneKey = "";
private static string _ownedReasonSceneLabel = "";
private static long _ownedReasonSubjectId;
private static long _ownedReasonRelatedId;
private static bool _ownedReasonQuietGrace;
private static string _ownedReasonHarnessOverride = "";
private static long _ownedReasonHarnessRelatedId;
private static string _ownedReasonHarnessRelatedName = "";
public static float LastReconcileMs { get; private set; }
public static float LastPortraitMs { get; private set; }
public static float LastTaskMs { get; private set; }
public static float LastIdentityMs { get; private set; }
public static float LastStatusesMs { get; private set; }
public static float LastHistoryMs { get; private set; }
public static float LastReasonMs { get; private set; }
public static float LastSpineMs { get; private set; }
private sealed class TraitSlot
{
public GameObject Root;
public Image Icon;
public Text Label;
public DossierChipTip Tip;
}
private sealed class HistorySlot
{
public GameObject Root;
public Image Icon;
public Text Label;
public Button Button;
public Image Hit;
public long OtherId;
}
public static UnitDossier Current => _current;
/// Living actor currently bound to the dossier (null for fallen archive).
public static Actor BoundActor => _boundActor;
public static string LastHeadline { get; private set; } = "";
public static string VisibleNametagText => _nameText != null ? (_nameText.text ?? "") : "";
public static int VisibleNametagFontSize => _nameText != null ? _nameText.fontSize : 0;
public static int SagaHoverFontCeiling => _sagaHoverFontCeiling;
public static string LastDetail { get; private set; } = "";
public static string LastCaptionText { get; private set; } = "";
/// Harness: composed beat line (may include relation clause).
public static string LastBeatLine => CaptionComposer.LastBeatLine;
/// Actual Beat painted this frame; may lead composer state during a handoff.
internal static string VisibleBeatLine =>
_reasonText != null && _reasonText.gameObject.activeSelf
? (_reasonText.text ?? "")
: "";
/// Structured unit id named by the currently composed camera Beat.
internal static long VisibleReasonRelatedId =>
_reasonText != null && _reasonText.gameObject.activeSelf && _current != null
? _current.ReasonRelatedId
: 0;
/// Harness: composed context line (spine or stake).
public static string LastContextLine => CaptionComposer.LastContextLine;
public static string LastConnectionLine { get; private set; } = "";
/// Harness/UI: active context line under the beat (spine or stake), empty when none.
public static string LastStorySpine { get; private set; } = "";
/// Harness: newest mini-history line currently shown (if any).
public static string LastHistoryPreview { get; private set; } = "";
/// Harness: all visible peek lines joined (activity + life).
public static string LastHistoryJoined { get; private set; } = "";
/// Harness: how many history lines are currently visible on the dossier.
public static int LastHistoryShown { get; private set; }
/// Harness: activity feed lines currently visible.
public static int LastActivityShown { get; private set; }
/// Harness: Chronicle Life lines currently visible in the dossier column.
public static int LastLifeShown { get; private set; }
/// Current dossier subject id (0 if none).
public static long CurrentUnitId => _current != null ? _current.UnitId : 0;
/// True when dossier is shown while idle is paused for Lore browsing.
public static bool PinnedWhilePaused => _pinnedWhilePaused;
/// True when the history column fills the body width (no empty strip beside it).
public static bool LastHistoryFillsBody { get; private set; }
/// Legacy alias; dossier history is no longer scrollable.
public static bool LastHistoryScrollable => false;
/// Legacy alias for harness compatibility.
public static bool HistoryExpanded => false;
/// Harness: comma-joined top trait labels currently shown.
public static string LastTraitsPreview { get; private set; } = "";
/// Harness: comma-joined top status labels currently shown.
public static string LastStatusesPreview { get; private set; } = "";
public static Vector2 LastPanelSize { get; private set; }
/// Exact outer height of rail + nametag + fixed Saga body.
public static float SagaHoverHeightFixed =>
PadTop + LifeSagaRail.SlotSize + 2f + HeaderH + Gap
+ LifeSagaPanel.BodyHeightFixed + 2f + PadTop - Gap;
public static bool LastLayoutOk { get; private set; }
/// Harness/UI: top anchor shared by the dossier and Saga portrait.
public static float LastBodyAnchorY { get; private set; }
/// Harness/UI: first bottom narrative row after body/status/trait details.
public static float LastNarrativeAnchorY { get; private set; }
/// True when the dossier card GameObject is active.
public static bool Visible => _visible && _root != null && _root.activeSelf;
/// Screen-pixel rect of the dossier card (Unity bottom-left origin), if visible.
public static bool TryGetScreenPixelRect(out Rect pixelRect)
{
pixelRect = default;
if (_rootRt == null || _root == null || !_root.activeInHierarchy)
{
return false;
}
Vector3[] corners = new Vector3[4];
_rootRt.GetWorldCorners(corners);
float xMin = float.MaxValue;
float yMin = float.MaxValue;
float xMax = float.MinValue;
float yMax = float.MinValue;
for (int i = 0; i < 4; i++)
{
Vector2 sp = RectTransformUtility.WorldToScreenPoint(null, corners[i]);
xMin = Mathf.Min(xMin, sp.x);
yMin = Mathf.Min(yMin, sp.y);
xMax = Mathf.Max(xMax, sp.x);
yMax = Mathf.Max(yMax, sp.y);
}
float pad = 4f;
pixelRect = Rect.MinMaxRect(xMin - pad, yMin - pad, xMax + pad, yMax + pad);
return pixelRect.width > 8f && pixelRect.height > 8f;
}
/// True if the mouse is over the dossier panel (including the live sprite).
public static bool IsPointerOverPanel()
{
if (_rootRt == null || _root == null || !_root.activeInHierarchy)
{
return false;
}
return RectTransformUtility.RectangleContainsScreenPoint(_rootRt, Input.mousePosition, null);
}
/// True if the mouse is over the dossier live sprite only.
public static bool IsPointerOverAvatar()
{
return DossierAvatar.ContainsScreenPoint(Input.mousePosition);
}
/// True if the mouse is over a trait/status chip (vanilla tip host).
public static bool IsPointerOverAssetChip()
{
if (!_visible || _root == null || !_root.activeInHierarchy)
{
return false;
}
Vector2 mouse = Input.mousePosition;
for (int i = 0; i < _traitSlots.Length; i++)
{
if (IsPointerOverChipRoot(_traitSlots[i]?.Root, mouse))
{
return true;
}
}
for (int i = 0; i < _statusSlots.Length; i++)
{
if (IsPointerOverChipRoot(_statusSlots[i]?.Root, mouse))
{
return true;
}
}
return false;
}
private static bool IsPointerOverChipRoot(GameObject root, Vector2 mouse)
{
if (root == null || !root.activeInHierarchy)
{
return false;
}
RectTransform rt = root.GetComponent();
return rt != null && RectTransformUtility.RectangleContainsScreenPoint(rt, mouse, null);
}
///
/// Favorite control (and the panel chrome around it).
/// Used so idle does not treat those clicks as "manual pause".
///
public static bool IsPointerOverInteractive()
{
if (!IsPointerOverPanel())
{
return false;
}
if (IsPointerOverButton(_favoriteBtn))
{
return true;
}
// Whole dossier counts as UI while visible so history/trait chrome is safe to click.
return _root != null && _root.activeInHierarchy;
}
private static bool IsPointerOverButton(Button button)
{
if (button == null || !button.gameObject.activeInHierarchy)
{
return false;
}
RectTransform rt = button.GetComponent();
return rt != null
&& RectTransformUtility.RectangleContainsScreenPoint(rt, Input.mousePosition, null);
}
public static void ToggleFavorite()
{
long hoverId = LifeSagaViewController.HoverPreviewId;
if (hoverId != 0)
{
Actor hovered = EventFeedUtil.FindAliveById(hoverId);
if (hovered != null && hovered.isAlive())
{
try
{
hovered.switchFavorite();
}
catch
{
return;
}
Chronicle.SyncFavoriteFromActor(hovered);
}
else
{
Chronicle.SetFavoriteSubject(hoverId, !Chronicle.IsFavoriteSubject(hoverId));
}
RequestRelayout();
return;
}
Actor actor = _boundActor;
if (actor == null || !actor.isAlive())
{
if (MoveCamera.hasFocusUnit())
{
actor = MoveCamera._focus_unit;
}
}
if (actor != null && actor.isAlive())
{
try
{
actor.switchFavorite();
}
catch
{
return;
}
Chronicle.SyncFavoriteFromActor(actor);
RefreshFavoriteVisual(actor);
LogService.LogInfo(
$"[IdleSpectator] Favorite toggled name={SafeActorName(actor)} favorite={actor.isFavorite()}");
return;
}
// Fallen / archive: track in Chronicle so the Fallen filter stays consistent.
long id = _current != null ? _current.UnitId : 0;
if (id == 0)
{
id = Chronicle.CurrentHistorySubjectId();
}
if (id == 0)
{
return;
}
bool next = !Chronicle.IsFavoriteSubject(id);
Chronicle.SetFavoriteSubject(id, next);
if (_current != null && _current.UnitId == id)
{
_current.IsFavorite = next;
}
RefreshFavoriteVisual(null);
LogService.LogInfo($"[IdleSpectator] Favorite toggled fallen id={id} favorite={next}");
}
public static void PinWhilePaused()
{
_pinnedWhilePaused = true;
if (ModSettings.ShowDossierCaption)
{
SetVisible(true);
}
}
/// Hide dossier after outside-click / inspect dismiss (idle already off).
public static void HideForDismiss()
{
_pinnedWhilePaused = false;
ClearStatusBanner();
SetVisible(false);
}
public static void ClearPausePin()
{
_pinnedWhilePaused = false;
}
/// Open this unit's full history in the Lore panel (L / harness); pause idle auto-follow.
public static void OpenFullHistoryInLore()
{
// Prefer the live camera focus so a pinned dossier cannot open the wrong unit's lore.
long id = Chronicle.CurrentHistorySubjectId();
if (id == 0 && _current != null)
{
id = _current.UnitId;
}
if (id == 0)
{
return;
}
ChronicleHud.OpenUnitHistory(id, pauseIdle: true, followFocus: true);
LogService.LogInfo($"[IdleSpectator] Open full history in Lore unitId={id}");
}
private static string SafeActorName(Actor actor)
{
try
{
string n = actor.getName();
if (!string.IsNullOrEmpty(n))
{
return n;
}
}
catch
{
// ignore
}
return "unit";
}
public static void Clear()
{
InvalidateOwnedReasonCache();
_current = null;
_boundActor = null;
LastHeadline = "";
LastDetail = "";
LastCaptionText = "";
LastStorySpine = "";
LastConnectionLine = "";
CaptionComposer.Clear();
LifeSagaViewController.Clear();
LifeSagaPanel.Clear();
LifeSagaRail.Clear();
LastHistoryPreview = "";
LastHistoryJoined = "";
LastHistoryShown = 0;
LastActivityShown = 0;
LastLifeShown = 0;
LastHistoryFillsBody = false;
_pinnedWhilePaused = false;
LastTraitsPreview = "";
LastStatusesPreview = "";
LastPanelSize = Vector2.zero;
LastLayoutOk = false;
_lastHistoryCount = -1;
_lastHistorySubjectId = 0;
_lastStatusesFingerprint = int.MinValue;
_lastAppliedCaptionFingerprint = "";
_lastIdentityMemoryRevision = int.MinValue;
_nextRoutineLiveRefreshAt = 0f;
_nextRoutineSpineAt = 0f;
_headlineLocked = false;
_sagaHoverHeadline = "";
_sagaHoverFontCeiling = 0;
_historyColW = HistoryColMinW;
_statusBanner = "";
_statusBannerUntil = 0f;
ApplyVisual(null, null);
SetVisible(false);
}
///
/// Show a short status line on the dossier (replaces the top WorldTip for pause exits).
///
public static void ShowStatusBanner(string message, float seconds = StatusBannerSeconds)
{
if (!ModSettings.ShowDossierCaption || string.IsNullOrEmpty(message))
{
return;
}
EnsureBuilt();
_statusBanner = message;
_statusBannerUntil = Time.unscaledTime + Mathf.Max(0.5f, seconds);
if (_nameText != null && (string.IsNullOrEmpty(_nameText.text) || _current == null))
{
_nameText.text = "Idle Spectator";
LastHeadline = "Idle Spectator";
}
if (_reasonText != null)
{
_reasonText.text = message;
_reasonText.color = StatusBannerColor;
_reasonText.supportRichText = false;
_reasonText.resizeTextForBestFit = false;
_reasonText.horizontalOverflow = HorizontalWrapMode.Wrap;
_reasonText.verticalOverflow = VerticalWrapMode.Overflow;
_reasonText.gameObject.SetActive(true);
WireReasonClick(0);
}
// Banner owns Beat only. Keep Identity + Context composed from the live
// camera subject so a multi-second pause banner cannot freeze the dossier.
Actor focus = ResolveBoundLiveActor();
if (focus == null && MoveCamera.hasFocusUnit())
{
focus = MoveCamera._focus_unit;
}
ApplyComposedCaption(
focus,
_current != null ? (_current.ReasonLine ?? "") : "",
_current != null ? _current.ReasonRelatedId : 0,
statusBannerOwnsBeat: true);
LastCaptionText = (LastHeadline ?? "Idle Spectator") + " | " + message;
// Re-fill from the current subject so Relayout cannot resurrect stale history slots
// from a previously focused unit (activeSelf stays true while the column is hidden).
int hist = _current != null ? FillHistory(_current.UnitId) : 0;
bool hasTask = _taskText != null && _taskText.gameObject.activeSelf;
bool hasBody = _boundActor != null || hist > 0 || _current != null;
Relayout(
hasBody,
CountActiveTraitSlots(),
CountActiveStatusSlots(),
hasTask,
hasReason: true,
hist);
SetVisible(true);
LogCaptionIfChanged("status=" + message);
}
public static void SetFromInterest(InterestEvent interest)
{
Actor unit = interest != null && interest.HasFollowUnit
? interest.FollowUnit
: (MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null);
if (unit == null || !unit.isAlive())
{
if (interest != null)
{
SetFromLocationInterest(interest);
}
return;
}
// Reason ownership comes from InterestDirector.TryGetOwnedReasonCandidate
// (active dwell + owned unit), not from a sticky InterestEvent.Label.
SetFromActor(unit, label: null);
}
///
/// Present an event as a location shot even when the previous camera focus is still alive.
/// Used when a supplied follow is rejected or a world event has no character subject.
///
public static void SetFromLocationInterest(InterestEvent interest)
{
if (interest == null)
{
return;
}
LastHeadline = CameraDirector.FormatWatchTip(interest);
LastDetail = "";
LastCaptionText = LastHeadline;
LastHistoryPreview = "";
LastHistoryJoined = "";
InvalidateOwnedReasonCache();
_current = null;
_boundActor = null;
EnsureBuilt();
ApplyVisual(null, null);
if (_nameText != null)
{
_nameText.text = LastHeadline;
}
Relayout(false, 0, 0, false, false, 0);
SetVisible(ModSettings.ShowDossierCaption && SpectatorMode.Active);
}
public static void SetFromActor(Actor actor, string label = null)
{
InvalidateOwnedReasonCache();
if (!_pinnedWhilePaused)
{
ClearStatusBanner();
}
UnitDossier dossier = UnitDossier.FromActor(actor, label);
_current = dossier;
_boundActor = actor;
LastHeadline = dossier.Headline ?? "";
LastDetail = dossier.DetailLine ?? "";
LastCaptionText = dossier.CaptionText ?? "";
EnsureBuilt();
ApplyVisual(actor, dossier);
_nextRoutineLiveRefreshAt = Time.unscaledTime + RoutineLiveRefreshSeconds;
SetVisible(ModSettings.ShowDossierCaption && (SpectatorMode.Active || _pinnedWhilePaused));
LogCaptionIfChanged(LastCaptionText);
}
///
/// Show a stored dossier with no living actor (Fallen archive last living state).
///
public static void SetFromDossier(UnitDossier dossier)
{
if (dossier == null || dossier.UnitId == 0)
{
return;
}
InvalidateOwnedReasonCache();
if (!_pinnedWhilePaused)
{
ClearStatusBanner();
}
_current = dossier;
_boundActor = null;
LastHeadline = dossier.Headline ?? "";
LastDetail = dossier.DetailLine ?? "";
LastCaptionText = dossier.CaptionText ?? "";
EnsureBuilt();
ApplyVisual(null, dossier);
SetVisible(ModSettings.ShowDossierCaption && (SpectatorMode.Active || _pinnedWhilePaused));
LogCaptionIfChanged("fallen dossier " + LastCaptionText);
}
///
/// CAPTION logs only when the text changes - busy Mass tip ticks used to flood Player.log
/// by rebuilding the dossier (and logging) on every sticky Watch refresh.
///
private static void LogCaptionIfChanged(string caption)
{
string text = caption ?? "";
if (string.Equals(text, _lastLoggedCaption, StringComparison.Ordinal))
{
return;
}
_lastLoggedCaption = text;
LogService.LogInfo("[IdleSpectator][CAPTION] " + text.Replace("\n", " | "));
}
public static void Update()
{
ResetUpdateTelemetry();
// History / manual pause: keep dossier pinned even after the banner timer expires.
if (_pinnedWhilePaused && ModSettings.ShowDossierCaption)
{
if (SpectatorMode.Active)
{
ClearPausePin();
ClearStatusBanner();
}
else
{
if (!_visible)
{
SetVisible(true);
}
// Keep the pause reason visible while browsing.
if (!string.IsNullOrEmpty(_statusBanner))
{
_statusBannerUntil = Time.unscaledTime + 1f;
}
RefreshLivePortrait();
RefreshLiveTask();
RefreshLiveIdentity();
RefreshLiveStatuses();
RefreshOwnedReason();
RefreshStorySpine();
RefreshHistoryIfChanged();
return;
}
}
if (HasStatusBanner())
{
if (!_visible && ModSettings.ShowDossierCaption)
{
SetVisible(true);
}
if (!SpectatorMode.Active)
{
return;
}
// Idle resumed - drop banner and restore normal reason styling next ApplyVisual.
ClearStatusBanner();
}
if (!SpectatorMode.Active || !ModSettings.ShowDossierCaption)
{
if (_visible)
{
SetVisible(false);
}
return;
}
if (_root == null && !string.IsNullOrEmpty(LastCaptionText))
{
EnsureBuilt();
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
if (_current != null)
{
ApplyVisual(focus ?? _boundActor, _current);
}
else if (_nameText != null)
{
_nameText.text = LastHeadline;
Relayout(false, 0, 0, false, false, 0);
}
SetVisible(true);
}
// Safety net: any focus change that skipped SetFromActor (or rematched follow)
// must not leave the nametag on the previous person for a frame.
// Caption Identity/Beat/Context always track camera focus; hover only adds panel depth.
long perfStarted = StartPerfSample();
LifeSagaViewController.Tick();
ReconcileDossierToFocus();
LastReconcileMs = EndPerfSample(perfStarted);
float now = Time.unscaledTime;
if (now < _nextRoutineLiveRefreshAt)
{
return;
}
_nextRoutineLiveRefreshAt = now + RoutineLiveRefreshSeconds;
if (!LifeSagaViewController.IsHoverPreview)
{
perfStarted = StartPerfSample();
RefreshLivePortrait();
LastPortraitMs = EndPerfSample(perfStarted);
perfStarted = StartPerfSample();
RefreshLiveTask();
LastTaskMs = EndPerfSample(perfStarted);
}
perfStarted = StartPerfSample();
RefreshLiveIdentity();
LastIdentityMs = EndPerfSample(perfStarted);
if (!LifeSagaViewController.IsHoverPreview)
{
perfStarted = StartPerfSample();
RefreshLiveStatuses();
LastStatusesMs = EndPerfSample(perfStarted);
perfStarted = StartPerfSample();
RefreshHistoryIfChanged();
LastHistoryMs = EndPerfSample(perfStarted);
}
perfStarted = StartPerfSample();
RefreshOwnedReason();
LastReasonMs = EndPerfSample(perfStarted);
perfStarted = StartPerfSample();
RefreshStorySpine();
LastSpineMs = EndPerfSample(perfStarted);
}
private static void ResetUpdateTelemetry()
{
LastReconcileMs = 0f;
LastPortraitMs = 0f;
LastTaskMs = 0f;
LastIdentityMs = 0f;
LastStatusesMs = 0f;
LastHistoryMs = 0f;
LastReasonMs = 0f;
LastSpineMs = 0f;
}
private static long StartPerfSample()
{
return IdleHitchProbe.Enabled
? System.Diagnostics.Stopwatch.GetTimestamp()
: 0;
}
private static float EndPerfSample(long started)
{
if (started == 0)
{
return 0f;
}
long elapsed = System.Diagnostics.Stopwatch.GetTimestamp() - started;
return (float)(elapsed * 1000.0 / System.Diagnostics.Stopwatch.Frequency);
}
///
/// While Idle Spectator is live, dossier nametag must track the camera focus unit.
/// Skips pause/fallen pins where the archive subject intentionally differs from focus.
///
private static void ReconcileDossierToFocus()
{
if (_pinnedWhilePaused || !SpectatorMode.Active)
{
return;
}
if (!MoveCamera.hasFocusUnit() || MoveCamera._focus_unit == null)
{
return;
}
Actor focus = MoveCamera._focus_unit;
if (!focus.isAlive())
{
return;
}
long focusId = 0;
try
{
focusId = focus.getID();
}
catch
{
return;
}
if (focusId == 0)
{
return;
}
if (_current != null && _current.UnitId == focusId)
{
return;
}
SetFromActor(focus);
}
/// True while a temporary status banner owns the reason row.
public static bool HasStatusBannerActive() => HasStatusBanner();
/// Storyline rail hover/commit asks for a soft relayout without a full rebuild.
public static void RequestRelayout()
{
if (!_visible)
{
return;
}
_layoutDirty = true;
// Route through spine/rail refresh so caption compose and hover panel stay consistent.
RefreshStorySpine(force: true);
}
///
/// Capture stable typography before Saga hover takes temporary ownership of the
/// painted header. Switching between glyphs must not make the name pulse larger.
///
public static void BeginSagaHoverSession()
{
_sagaHoverHeadline = "";
_sagaHoverFontCeiling = _nameText != null
? Mathf.Clamp(_nameText.fontSize, 7, 10)
: 10;
}
///
/// Swap the hovered identity before building Cast/Legacy. This keeps pointer feedback
/// immediate even when the deeper saga model has several relationships to resolve.
///
public static void PrimeSagaHoverHeader(long unitId)
{
if (!_visible || unitId == 0)
{
return;
}
LifeSagaSlot slot = LifeSagaRoster.Get(unitId);
Actor actor = EventFeedUtil.FindAliveById(unitId);
LifeSagaLifeMemory memory = LifeSagaMemory.Get(unitId);
string name = slot?.DisplayName ?? "";
if (string.IsNullOrEmpty(name))
{
name = EventFeedUtil.SafeName(actor);
}
var model = new LifeSagaViewModel
{
UnitId = unitId,
Name = (slot != null && (slot.Prefer || slot.GameFavorite) ? "★ " : "") + name,
Title = SagaProse.Title(slot, actor, memory),
SpeciesId = slot?.SpeciesId
?? (actor != null && actor.asset != null ? actor.asset.id : "")
};
ApplySagaHoverHeader(model, deferPortraitReveal: true);
}
///
/// A same-subject camera event can change its orange reason without rebuilding the
/// dossier. Refresh only the owned reason/spine so UI and camera telemetry commit together.
///
public static void RefreshOwnedReasonNow()
{
if (!_visible || _current == null)
{
return;
}
InvalidateOwnedReasonCache();
RefreshOwnedReason();
}
///
/// Harness: exercise the periodic same-hover refresh path without marking geometry dirty.
/// Cast/Legacy and portrait must remain visible when Relayout correctly has no work.
///
public static void HarnessRefreshSagaSteadyState()
{
RefreshStorySpine(force: true);
}
private static bool HasStatusBanner()
{
return !string.IsNullOrEmpty(_statusBanner) && Time.unscaledTime < _statusBannerUntil;
}
public static void ClearStatusBanner()
{
if (!string.IsNullOrEmpty(_statusBanner) || _statusBannerUntil != 0f)
{
InvalidateOwnedReasonCache();
}
_statusBanner = "";
_statusBannerUntil = 0f;
if (_reasonText != null)
{
_reasonText.color = ReasonColor;
}
}
private static int CountActiveHistorySlots()
{
if (_historyCol == null || !_historyCol.activeSelf)
{
return 0;
}
int n = 0;
for (int i = 0; i < _historySlots.Length; i++)
{
if (_historySlots[i]?.Root != null && _historySlots[i].Root.activeSelf)
{
n++;
}
}
return n;
}
private static int CountActiveTraitSlots()
{
int n = 0;
for (int i = 0; i < _traitSlots.Length; i++)
{
if (_traitSlots[i]?.Root != null && _traitSlots[i].Root.activeSelf)
{
n++;
}
}
return n;
}
private static int CountActiveStatusSlots()
{
int n = 0;
for (int i = 0; i < _statusSlots.Length; i++)
{
if (_statusSlots[i]?.Root != null && _statusSlots[i].Root.activeSelf)
{
n++;
}
}
return n;
}
private static void RefreshLivePortrait()
{
if (!_visible)
{
return;
}
Actor actor = ResolveBoundLiveActor();
if (actor == null)
{
return;
}
long id = EventFeedUtil.SafeId(actor);
float now = Time.unscaledTime;
// The vanilla tile refresh can trigger an expensive UI/layout pass. A portrait is
// supporting context, not animation-critical, so refresh it at 1 Hz while held.
// Focus change always applies immediately.
if (id == _portraitActorId && now < _nextPortraitAt)
{
return;
}
_portraitActorId = id;
_nextPortraitAt = now + PortraitRefreshSeconds;
DossierAvatar.Show(actor);
BringHeaderFront();
}
///
/// Keep orange reason in sync with director active dwell (clears in quiet_grace).
///
private static void RefreshOwnedReason()
{
if (!_visible || _current == null || HasStatusBanner())
{
return;
}
Actor actor = ResolveBoundLiveActor();
if (actor == null)
{
return;
}
InterestCandidate scene = InterestDirector.CurrentCandidate;
long actorId = EventFeedUtil.SafeId(actor);
string sceneKey = scene != null ? (scene.Key ?? "") : "";
string sceneLabel = scene != null ? (scene.Label ?? "") : "";
long subjectId = scene != null ? scene.SubjectId : 0;
long relatedId = scene != null ? scene.RelatedId : 0;
bool quietGrace = InterestDirector.InQuietGrace;
string harnessOverride = UnitDossier.HarnessReasonOverride ?? "";
long harnessRelatedId = UnitDossier.HarnessReasonRelatedId;
string harnessRelatedName = UnitDossier.HarnessReasonRelatedName ?? "";
if (_ownedReasonCacheValid
&& _ownedReasonActorId == actorId
&& ReferenceEquals(_ownedReasonScene, scene)
&& string.Equals(_ownedReasonSceneKey, sceneKey, StringComparison.Ordinal)
&& string.Equals(_ownedReasonSceneLabel, sceneLabel, StringComparison.Ordinal)
&& _ownedReasonSubjectId == subjectId
&& _ownedReasonRelatedId == relatedId
&& _ownedReasonQuietGrace == quietGrace
&& string.Equals(_ownedReasonHarnessOverride, harnessOverride, StringComparison.Ordinal)
&& _ownedReasonHarnessRelatedId == harnessRelatedId
&& string.Equals(_ownedReasonHarnessRelatedName, harnessRelatedName, StringComparison.Ordinal))
{
return;
}
// Honor harness reason override the same way FromActor/BuildStoryBeat does.
string next;
if (!string.IsNullOrEmpty(harnessOverride))
{
string subject = _current.Name ?? "";
if (string.IsNullOrEmpty(subject))
{
subject = EventFeedUtil.SafeName(actor);
}
string relatedName = harnessRelatedName;
_current.ReasonRelatedId = harnessRelatedId != 0
? harnessRelatedId
: ActivityLog.ResolveLivingUnitIdByName(relatedName, _current.UnitId);
next = ActivityProse.ColorizePersonNames(
harnessOverride.Trim(),
subject,
relatedName);
}
else
{
next = UnitDossier.OwnedEventReason(actor, _current) ?? "";
}
_ownedReasonCacheValid = true;
_ownedReasonActorId = actorId;
_ownedReasonScene = scene;
_ownedReasonSceneKey = sceneKey;
_ownedReasonSceneLabel = sceneLabel;
_ownedReasonSubjectId = subjectId;
_ownedReasonRelatedId = relatedId;
_ownedReasonQuietGrace = quietGrace;
_ownedReasonHarnessOverride = harnessOverride;
_ownedReasonHarnessRelatedId = harnessRelatedId;
_ownedReasonHarnessRelatedName = harnessRelatedName;
string prev = _current.ReasonLine ?? "";
if (next == prev)
{
return;
}
// Transient ownership miss mid-handoff: keep the last orange reason while a
// labeled event tip is still active (avoids a blank frame during Love Follow swaps).
// Do not keep across character-fill / empty-Label vignettes.
if (string.IsNullOrEmpty(next)
&& !string.IsNullOrEmpty(prev)
&& scene != null
&& !InterestDirector.InQuietGrace
&& scene.LeadKind != InterestLeadKind.CharacterLed
&& !string.IsNullOrEmpty(scene.Label))
{
return;
}
bool headcountOnly = EventReason.IsHeadcountOnlyChange(prev, next);
_current.ReasonLine = next;
LastDetail = _current.DetailLine ?? "";
// Mass tip headcount ticks must not Relayout the whole dossier every second.
// RefreshStorySpine recomposes Beat+Context and Relayouts when chrome changes.
RefreshStorySpine(force: true);
if (headcountOnly)
{
return;
}
LastCaptionText = JoinCaptionLines(
LastHeadline,
LastBeatLine,
detail: null,
LastContextLine);
}
private static void InvalidateOwnedReasonCache()
{
_ownedReasonCacheValid = false;
_ownedReasonActorId = 0;
_ownedReasonScene = null;
_ownedReasonSceneKey = "";
_ownedReasonSceneLabel = "";
_ownedReasonSubjectId = 0;
_ownedReasonRelatedId = 0;
_ownedReasonQuietGrace = false;
_ownedReasonHarnessOverride = "";
_ownedReasonHarnessRelatedId = 0;
_ownedReasonHarnessRelatedName = "";
}
///
/// Quiet context under the orange beat (story spine or stake).
/// Recompose Identity/Beat/Context; show Cast/Legacy panel only while hovering.
///
private static void RefreshStorySpine(bool force = false)
{
if (!_visible)
{
return;
}
float now = Time.unscaledTime;
if (!force && now < _nextRoutineSpineAt)
{
return;
}
_nextRoutineSpineAt = now + RoutineSpineRefreshSeconds;
bool statusBanner = HasStatusBanner();
if (statusBanner)
{
LifeSagaViewController.CancelPreviewAndPause();
LifeSagaPanel.Clear();
LifeSagaRail.Clear();
_layoutDirty = true;
_dossierRowsNeedRestore = true;
}
Actor focus = ResolveBoundLiveActor();
if (focus == null && MoveCamera.hasFocusUnit())
{
focus = MoveCamera._focus_unit;
}
string presentable = _current != null ? (_current.ReasonLine ?? "") : "";
long related = _current != null ? _current.ReasonRelatedId : 0;
ApplyComposedCaption(focus, presentable, related, statusBannerOwnsBeat: statusBanner);
if (!statusBanner)
{
LifeSagaRail.Refresh();
}
bool panelMode = !statusBanner && LifeSagaViewController.IsHoverPreview;
int traitCount = 0;
int statusCount = 0;
int historyCount = 0;
bool hasTask = false;
bool hasReason = false;
if (panelMode)
{
RefreshSagaBody();
ApplyReasonText("", 0);
ApplyConnection("");
ApplyStorySpine("");
_dossierRowsNeedRestore = true;
}
else
{
LifeSagaPanel.SetVisible(false);
RestoreCameraHeaderChrome();
// Hover force-hides dossier rows. Restore once when leaving hover so exit does
// not wait for a tip change (not every frame).
if (_dossierRowsNeedRestore)
{
RestoreDossierRowsAfterSaga();
_dossierRowsNeedRestore = false;
_layoutDirty = true;
}
traitCount = CountActiveTraitSlots();
statusCount = CountActiveStatusSlots();
historyCount = CountActiveHistorySlots();
hasTask = _taskText != null && !string.IsNullOrEmpty(_taskText.text);
hasReason = !string.IsNullOrEmpty(LastBeatLine)
|| (_reasonText != null && !string.IsNullOrEmpty(_reasonText.text));
}
if (panelMode)
{
// Hover depth prioritizes the full Identity. The live task is already represented
// by the camera dossier and returns with its rows as soon as hover ends. Beat and
// Context are hidden, so neither may reserve phantom vertical space here.
hasTask = false;
hasReason = false;
}
bool hasBody = !panelMode
&& (_boundActor != null
|| historyCount > 0
|| _current != null);
string layoutKey =
$"{(panelMode ? 1 : 0)}|{LastConnectionLine}|{LastContextLine}|{traitCount}|{statusCount}|{historyCount}"
+ $"|{(hasTask ? 1 : 0)}|{(hasReason ? 1 : 0)}|{hasBody}"
+ $"|{LifeSagaPanel.BodyHeight:F0}|{LifeSagaRail.LastShownCount}|{LifeSagaRail.Visible}"
+ $"|{LastBeatLine}|{LastHeadline}";
if (_layoutDirty || !string.Equals(layoutKey, _layoutKey, StringComparison.Ordinal))
{
_layoutKey = layoutKey;
_layoutDirty = false;
Relayout(hasBody || panelMode, traitCount, statusCount, hasTask, hasReason, historyCount);
}
}
///
/// Apply CaptionComposer Identity + Beat + Context to nametag / reason / spine.
/// Filters spine against the presentable beat before EnrichBeat appends clauses.
///
private static void ApplyComposedCaption(
Actor focus,
string presentableBeat,
long reasonRelatedId,
bool statusBannerOwnsBeat)
{
string spineRaw = (SpectatorMode.Active || SpectatorMode.BrowsePaused) && ModSettings.ShowDossierCaption
? StoryPlanner.FormatSpineLabel()
: "";
string spineFiltered = FilterRedundantStorySpine(spineRaw, presentableBeat ?? "");
InterestCandidate tip = InterestDirector.CurrentCandidate;
CaptionModel model = CaptionComposer.Compose(
focus,
tip,
presentableBeat ?? "",
reasonRelatedId,
spineFiltered,
statusBannerOwnsBeat);
ApplyConnection(model.ConnectionLine ?? "");
if (string.Equals(
model.Fingerprint,
_lastAppliedCaptionFingerprint,
StringComparison.Ordinal))
{
return;
}
_lastAppliedCaptionFingerprint = model.Fingerprint;
if (!_headlineLocked)
{
if (!string.IsNullOrEmpty(model.IdentityLine))
{
LastHeadline = model.IdentityLine;
if (_current != null)
{
_current.Headline = LastHeadline;
}
if (_nameText != null)
{
string shown = LastHeadline;
if (shown.Length > 48)
{
shown = CaptionComposer.TruncateIdentity(shown, 48);
}
if (!string.Equals(_nameText.text, shown, StringComparison.Ordinal))
{
_nameText.text = shown;
}
}
}
else if (_current != null && !string.IsNullOrEmpty(_current.Headline))
{
// Fallen/archive: Compose has no live focus - keep dossier headline.
LastHeadline = _current.Headline;
if (_nameText != null)
{
_nameText.text = LastHeadline;
}
}
}
if (!statusBannerOwnsBeat)
{
bool hasBeat = !string.IsNullOrEmpty(model.BeatLine);
ApplyReasonText(hasBeat ? model.BeatLine : "", hasBeat ? model.ReasonRelatedId : 0);
if (_current != null && model.ReasonRelatedId != 0)
{
_current.ReasonRelatedId = model.ReasonRelatedId;
}
}
ApplyStorySpine(model.ContextLine ?? "");
// Character beat CAPTION fingerprint: Identity / Beat / Context only.
// DetailLine is the live task chip - keep it out of the joined caption log.
LastCaptionText = JoinCaptionLines(
LastHeadline,
statusBannerOwnsBeat ? (_reasonText != null ? _reasonText.text : "") : model.BeatLine,
detail: null,
model.ContextLine);
}
private static void ApplyConnection(string connection)
{
string next = connection ?? "";
bool show = !string.IsNullOrEmpty(next)
&& !LifeSagaViewController.IsHoverPreview;
LastConnectionLine = show ? next : "";
if (_connectionText == null)
{
return;
}
if (!string.Equals(_connectionText.text, LastConnectionLine, StringComparison.Ordinal))
{
_connectionText.text = LastConnectionLine;
EllipsisFitLabel(_connectionText, BodyColumnInnerWidth());
}
_connectionText.color = ConnectionColor;
_connectionText.gameObject.SetActive(show);
}
///
/// Re-show trait/status/history chrome after hover hid them.
/// Uses the bound dossier snapshot so hover-exit is instant; recomposes caption.
///
private static void RestoreDossierRowsAfterSaga()
{
if (_current == null)
{
return;
}
ApplyTraitChips(_current);
ApplyStatusChips(_current);
FillHistory(_current.UnitId);
bool hasLevel = _current.UnitId != 0
&& ((_boundActor != null && _boundActor.isAlive()) || _current.Level > 0);
if (_levelIcon != null)
{
HudIcons.Apply(_levelIcon, hasLevel ? HudIcons.Level() : null);
_levelIcon.gameObject.SetActive(hasLevel);
}
if (_levelValue != null)
{
_levelValue.text = hasLevel ? _current.Level.ToString() : "";
_levelValue.gameObject.SetActive(hasLevel);
}
if (!string.IsNullOrEmpty(_current.TaskText))
{
ApplyTaskChip(_current.TaskText);
}
Actor focus = ResolveBoundLiveActor();
ApplyComposedCaption(
focus,
_current.ReasonLine ?? "",
_current.ReasonRelatedId,
statusBannerOwnsBeat: HasStatusBanner());
}
/// Harness: dossier nametag/reason visible after leaving Saga hover.
public static bool HarnessDossierChromeRestored()
{
if (_nameText == null || !_nameText.gameObject.activeSelf)
{
return false;
}
string line = _current != null ? (_current.ReasonLine ?? "") : "";
if (string.IsNullOrEmpty(line))
{
return true;
}
return _reasonText != null
&& _reasonText.gameObject.activeSelf
&& !string.IsNullOrEmpty(_reasonText.text);
}
///
/// Drop Kind · Climax when the presentable beat already leads with that kind (e.g. "Duel - A vs B").
/// Keep Aftermath / Epilogue and unmatched kinds. Combat Climax is also dropped when the
/// tip already leads with any combat scale word (covers brief Kind sync lag).
/// Filter against the pre-enrichment beat so trailing ` · clause` does not matter;
/// Kind token detection already uses the start of the tip.
///
private static string FilterRedundantStorySpine(string spine, string reason)
{
if (string.IsNullOrEmpty(spine))
{
return "";
}
int sep = spine.IndexOf('·');
if (sep < 0)
{
return spine;
}
string kind = spine.Substring(0, sep).Trim();
string phase = spine.Substring(sep + 1).Trim();
if (string.IsNullOrEmpty(kind) || string.IsNullOrEmpty(phase))
{
return spine;
}
if (!string.Equals(phase, "Climax", StringComparison.OrdinalIgnoreCase))
{
return spine;
}
if (string.IsNullOrEmpty(reason))
{
return spine;
}
if (ReasonLeadsWithToken(reason, kind)
|| ReasonSemanticallyMatchesStoryKind(reason, kind))
{
return "";
}
// Tip already carries combat scale; hide Kind · Climax even if spine Kind lags SyncActiveClimax.
if (IsCombatSpineKind(kind) && ReasonLeadsWithCombatScale(reason))
{
return "";
}
return spine;
}
private static bool ReasonSemanticallyMatchesStoryKind(string reason, string kind)
{
string text = (reason ?? "").Trim().ToLowerInvariant();
string story = (kind ?? "").Trim().ToLowerInvariant();
if (string.IsNullOrEmpty(text) || string.IsNullOrEmpty(story))
{
return false;
}
switch (story)
{
case "love":
return text.IndexOf("love", StringComparison.Ordinal) >= 0
|| text.IndexOf("smitten", StringComparison.Ordinal) >= 0
|| text.IndexOf("mates with", StringComparison.Ordinal) >= 0
|| text.IndexOf("intimacy", StringComparison.Ordinal) >= 0;
case "grief":
return text.IndexOf("mourn", StringComparison.Ordinal) >= 0
|| text.IndexOf("despair", StringComparison.Ordinal) >= 0
|| text.IndexOf("grief", StringComparison.Ordinal) >= 0;
case "duel":
case "skirmish":
case "battle":
case "mass":
return ReasonLeadsWithCombatScale(reason)
|| text.IndexOf(" is fighting", StringComparison.Ordinal) >= 0;
default:
return false;
}
}
public static bool HarnessProbeStorySpineRelevance(out string detail)
{
string love = FilterRedundantStorySpine("Love · Climax", "Falls in love with Omyahel");
string duel = FilterRedundantStorySpine("Duel · Climax", "Joon is fighting");
string pregnancy = FilterRedundantStorySpine("Love · Climax", "Becomes pregnant");
string aftermath = FilterRedundantStorySpine("Love · Aftermath", "Finds love with Omyahel");
bool ok = string.IsNullOrEmpty(love)
&& string.IsNullOrEmpty(duel)
&& pregnancy == "Love · Climax"
&& aftermath == "Love · Aftermath";
detail = "love='" + love + "' duel='" + duel + "' pregnancy='" + pregnancy
+ "' aftermath='" + aftermath + "' pass=" + ok;
return ok;
}
private static bool IsCombatSpineKind(string kind)
{
return string.Equals(kind, "Duel", StringComparison.OrdinalIgnoreCase)
|| string.Equals(kind, "Skirmish", StringComparison.OrdinalIgnoreCase)
|| string.Equals(kind, "Battle", StringComparison.OrdinalIgnoreCase)
|| string.Equals(kind, "Mass", StringComparison.OrdinalIgnoreCase);
}
private static bool ReasonLeadsWithCombatScale(string reason)
{
return ReasonLeadsWithToken(reason, "Duel")
|| ReasonLeadsWithToken(reason, "Skirmish")
|| ReasonLeadsWithToken(reason, "Battle")
|| ReasonLeadsWithToken(reason, "Mass");
}
/// True when reason starts with the token before " - " / " · " / whitespace.
private static bool ReasonLeadsWithToken(string reason, string token)
{
if (string.IsNullOrEmpty(reason) || string.IsNullOrEmpty(token))
{
return false;
}
string lead = reason.TrimStart();
int cut = lead.Length;
for (int i = 0; i < lead.Length; i++)
{
char c = lead[i];
if (c == '-' || c == '·' || char.IsWhiteSpace(c))
{
cut = i;
break;
}
}
string head = lead.Substring(0, cut).Trim();
return string.Equals(head, token, StringComparison.OrdinalIgnoreCase);
}
private static void RefreshSagaBody()
{
EnsureBuilt();
LifeSagaPanel.EnsureBuilt(_root.transform);
LifeSagaViewModel model = LifeSagaViewController.BuildEffectivePresentation();
ApplySagaHoverHeader(model);
LifeSagaPanel.Bind(model);
}
private static void ApplySagaHoverHeader(
LifeSagaViewModel model,
bool deferPortraitReveal = false)
{
if (model == null || model.UnitId == 0)
{
return;
}
Actor actor = EventFeedUtil.FindAliveById(model.UnitId);
string name = (model.Name ?? "").Trim();
if (name.StartsWith("★ ", StringComparison.Ordinal))
{
name = name.Substring(2).TrimStart();
}
if (string.IsNullOrEmpty(name))
{
name = "Someone";
}
string title = (model.Title ?? "").Trim();
string species = ActivityAssetCatalog.SpeciesDisplayLabel(model.SpeciesId);
string headline = !string.IsNullOrEmpty(title)
? name + " · " + title
: (!string.IsNullOrEmpty(species) ? name + " (" + species + ")" : name);
_sagaHoverHeadline = headline.Length > 48
? CaptionComposer.TruncateIdentity(headline, 48)
: headline;
if (_nameText != null)
{
_nameText.text = _sagaHoverHeadline;
}
if (_speciesIcon != null)
{
Sprite sprite = actor != null && actor.isAlive()
? HudIcons.FromActor(actor)
: HudIcons.FromSpeciesId(model.SpeciesId);
HudIcons.Apply(_speciesIcon, sprite);
}
Sprite sexSprite = null;
if (actor != null && actor.isAlive())
{
try
{
sexSprite = actor.isSexMale()
? HudIcons.SexMale()
: (actor.isSexFemale() ? HudIcons.SexFemale() : null);
}
catch
{
sexSprite = null;
}
}
if (_sexIcon != null)
{
HudIcons.Apply(_sexIcon, sexSprite);
_sexIcon.gameObject.SetActive(sexSprite != null);
}
// The pointer-enter prime stages the new portrait while hidden. Periodic same-hover
// refreshes must preserve its current visibility because unchanged geometry correctly
// skips Relayout.
if (deferPortraitReveal)
{
DossierAvatar.SetHostVisible(false);
}
if (actor != null && actor.isAlive())
{
DossierAvatar.Show(actor);
}
else
{
DossierAvatar.ShowSpecies(model.SpeciesId);
}
RefreshFavoriteVisualFor(model.UnitId, actor, fallbackFavorite: false);
}
private static void RestoreCameraHeaderChrome()
{
if (_current == null)
{
return;
}
Actor actor = ResolveBoundLiveActor();
if (_speciesIcon != null)
{
Sprite sprite = actor != null && actor.isAlive()
? HudIcons.FromActor(actor)
: HudIcons.FromSpeciesId(_current.SpeciesId);
HudIcons.Apply(_speciesIcon, sprite);
}
if (_sexIcon != null)
{
Sprite sexSprite = _current.IsMale
? HudIcons.SexMale()
: (_current.IsFemale ? HudIcons.SexFemale() : null);
HudIcons.Apply(_sexIcon, sexSprite);
_sexIcon.gameObject.SetActive(sexSprite != null);
}
if (actor != null && actor.isAlive())
{
DossierAvatar.Show(actor);
}
else
{
DossierAvatar.ShowSpecies(_current.SpeciesId);
}
DossierAvatar.SetHostVisible(_current.UnitId != 0);
RefreshFavoriteVisual(actor);
}
private static void ApplyStorySpine(string spine)
{
string next = spine ?? "";
bool show = !string.IsNullOrEmpty(next);
if (string.Equals(LastStorySpine, next, StringComparison.Ordinal)
&& (_storySpineText == null
|| (_storySpineText.gameObject.activeSelf == show
&& string.Equals(_storySpineText.text, next, StringComparison.Ordinal))))
{
return;
}
LastStorySpine = next;
if (_storySpineText == null)
{
return;
}
// This row is Caption Context (short-arc spine or durable stake). A status
// banner replaces Beat only, so Context remains visible and live.
_storySpineText.text = show ? LastStorySpine : "";
_storySpineText.color = StorySpineColor;
_storySpineText.gameObject.SetActive(show);
}
private static string JoinCaptionLines(string headline, string reason, string detail, string storySpine = null)
{
var sb = new System.Text.StringBuilder();
if (!string.IsNullOrEmpty(headline))
{
sb.Append(headline);
}
if (!string.IsNullOrEmpty(reason))
{
if (sb.Length > 0)
{
sb.Append('\n');
}
sb.Append(reason);
}
if (!string.IsNullOrEmpty(storySpine))
{
if (sb.Length > 0)
{
sb.Append('\n');
}
sb.Append(storySpine);
}
if (!string.IsNullOrEmpty(detail))
{
if (sb.Length > 0)
{
sb.Append('\n');
}
sb.Append(detail);
}
return sb.ToString();
}
///
/// Keep the nametag task chip in sync with live AI (focus snapshot can miss brief no-task gaps).
/// Never clears the chip on empty AI gaps - only replaces text when a new task is present.
///
private static void RefreshLiveTask()
{
if (!_visible || _current == null)
{
return;
}
Actor actor = ResolveBoundLiveActor();
if (actor == null)
{
return;
}
string live = UnitDossier.ReadLiveTask(actor);
// Hold the last shown label through brief no-task gaps so autofit does not collapse.
if (string.IsNullOrEmpty(live))
{
return;
}
string prev = _current.TaskText ?? "";
if (live == prev)
{
return;
}
bool wasShowing = _taskText != null && _taskText.gameObject.activeSelf;
_current.TaskText = live;
ReplaceTaskChip(live);
// First appearance needs layout; later swaps only change text inside a fixed-width slot.
if (!wasShowing)
{
bool hasBody = _current.UnitId != 0;
bool hasReason = _reasonText != null && _reasonText.gameObject.activeSelf;
Relayout(
hasBody,
CountActiveTraitSlots(),
CountActiveStatusSlots(),
hasTask: true,
hasReason,
CountActiveHistorySlots());
}
BringHeaderFront();
}
/// Keep nametag Identity in sync via CaptionComposer (MC title or species/job).
private static void RefreshLiveIdentity()
{
if (!_visible || _current == null || _headlineLocked)
{
return;
}
Actor actor = ResolveBoundLiveActor();
if (actor == null)
{
return;
}
string job = UnitDossier.ReadLiveJobLabel(actor);
string tag = UnitDossier.BuildIdentityTag(_current.SpeciesId, job);
string prevHeadline = LastHeadline ?? "";
string prevJob = _current.JobLabel ?? "";
string prevTag = _current.IdentityTag ?? "";
int memoryRevision = SagaProse.MemoryRevision;
if (string.Equals(job, prevJob, StringComparison.Ordinal)
&& string.Equals(tag, prevTag, StringComparison.Ordinal)
&& memoryRevision == _lastIdentityMemoryRevision)
{
return;
}
_lastIdentityMemoryRevision = memoryRevision;
ApplyComposedCaption(
actor,
_current.ReasonLine ?? "",
_current.ReasonRelatedId,
statusBannerOwnsBeat: HasStatusBanner());
_current.JobLabel = job;
_current.IdentityTag = tag;
if (string.Equals(LastHeadline, prevHeadline, StringComparison.Ordinal)
&& string.Equals(job, prevJob, StringComparison.Ordinal)
&& string.Equals(tag, prevTag, StringComparison.Ordinal))
{
return;
}
bool hasTask = _taskText != null && _taskText.gameObject.activeSelf;
bool hasReason = _reasonText != null && _reasonText.gameObject.activeSelf;
Relayout(
_current.UnitId != 0,
CountActiveTraitSlots(),
CountActiveStatusSlots(),
hasTask,
hasReason,
CountActiveHistorySlots());
BringHeaderFront();
}
/// Keep status chips in sync with live status set (fingerprint on top-4 ids).
private static void RefreshLiveStatuses()
{
if (!_visible || _current == null)
{
return;
}
Actor actor = ResolveBoundLiveActor();
if (actor == null)
{
return;
}
UnitDossier.RefreshTopStatuses(StatusProbe, actor);
int fp = StatusFingerprint(StatusProbe);
if (fp == _lastStatusesFingerprint
&& StatusChipsMatch(_current.TopStatuses, StatusProbe.TopStatuses))
{
return;
}
UnitDossier.RefreshTopStatuses(_current, actor);
int statusCount = ApplyStatusChips(_current);
_lastStatusesFingerprint = fp;
bool hasTask = _taskText != null && !string.IsNullOrEmpty(_taskText.text);
bool hasReason = (_reasonText != null && !string.IsNullOrEmpty(_reasonText.text))
|| (_current != null && !string.IsNullOrEmpty(_current.ReasonLine));
Relayout(
_current.UnitId != 0,
CountActiveTraitSlots(),
statusCount,
hasTask,
hasReason,
CountActiveHistorySlots());
}
private static int StatusFingerprint(UnitDossier dossier)
{
if (dossier == null || dossier.TopStatuses == null || dossier.TopStatuses.Count == 0)
{
return 0;
}
unchecked
{
int h = 17;
for (int i = 0; i < dossier.TopStatuses.Count; i++)
{
string id = dossier.TopStatuses[i]?.Id ?? "";
h = h * 31 + StringComparer.OrdinalIgnoreCase.GetHashCode(id);
}
return h;
}
}
private static bool StatusChipsMatch(
System.Collections.Generic.List a,
System.Collections.Generic.List b)
{
int na = a != null ? a.Count : 0;
int nb = b != null ? b.Count : 0;
if (na != nb)
{
return false;
}
for (int i = 0; i < na; i++)
{
string idA = a[i]?.Id ?? "";
string idB = b[i]?.Id ?? "";
if (!idA.Equals(idB, System.StringComparison.OrdinalIgnoreCase))
{
return false;
}
}
return true;
}
private static Actor ResolveBoundLiveActor()
{
Actor actor = _boundActor;
if (actor == null || !actor.isAlive())
{
if (MoveCamera.hasFocusUnit())
{
actor = MoveCamera._focus_unit;
}
}
if (actor == null || !actor.isAlive())
{
return null;
}
if (_current != null && actor.getID() != _current.UnitId)
{
return null;
}
return actor;
}
private static string TruncateTaskLabel(string taskText)
{
if (string.IsNullOrEmpty(taskText))
{
return "";
}
return taskText.Length > 14 ? taskText.Substring(0, 13) + "..." : taskText;
}
/// Show or replace the task chip without ever blanking mid-update.
private static void ReplaceTaskChip(string taskText)
{
if (string.IsNullOrEmpty(taskText))
{
return;
}
if (_taskIcon != null)
{
HudIcons.Apply(_taskIcon, HudIcons.Task());
_taskIcon.gameObject.SetActive(true);
}
if (_taskText != null)
{
_taskText.text = TruncateTaskLabel(taskText);
_taskText.gameObject.SetActive(true);
}
}
private static void ApplyTaskChip(string taskText)
{
if (!string.IsNullOrEmpty(taskText))
{
ReplaceTaskChip(taskText);
return;
}
if (_taskIcon != null)
{
HudIcons.Apply(_taskIcon, null);
_taskIcon.gameObject.SetActive(false);
}
if (_taskText != null)
{
_taskText.text = "";
_taskText.gameObject.SetActive(false);
}
}
///
/// Harness: simulate a stale empty snapshot, then sync from live AI (replace, never stay blank if live has a task).
///
public static string HarnessBlankAndRefreshTask()
{
if (_current != null)
{
_current.TaskText = "";
}
// Do not hide the chip here - RefreshLiveTask must replace in place when live task exists.
RefreshLiveTask();
return _current != null ? (_current.TaskText ?? "") : "";
}
/// Harness: text currently shown on the nametag (may be ellipsis-truncated).
public static string ShownNameChipText =>
_nameText != null && _nameText.gameObject.activeSelf ? (_nameText.text ?? "") : "";
/// Harness: text currently shown on the nametag task chip.
public static string ShownTaskChipText =>
_taskText != null && _taskText.gameObject.activeSelf ? (_taskText.text ?? "") : "";
/// Harness: force a long nametag headline and relayout (layout overlap tests).
public static void ForceNametagHeadline(string headline)
{
EnsureBuilt();
string text = string.IsNullOrEmpty(headline) ? "Nobody" : headline.Trim();
_headlineLocked = true;
LastHeadline = text;
if (_nameText != null)
{
_nameText.text = text;
}
if (_current != null)
{
_current.Headline = text;
}
bool hasBody = _current != null && _current.UnitId != 0;
bool hasTask = _taskText != null && _taskText.gameObject.activeSelf;
bool hasReason = _reasonText != null && _reasonText.gameObject.activeSelf;
Relayout(
hasBody,
CountActiveTraitSlots(),
CountActiveStatusSlots(),
hasTask,
hasReason,
CountActiveHistorySlots());
}
private static void BringHeaderFront()
{
// Draw nametag above vanilla avatar chrome (which can paint outside its host).
void Front(Component c)
{
if (c != null)
{
c.transform.SetAsLastSibling();
}
}
Front(_speciesIcon);
Front(_nameText);
Front(_levelIcon);
Front(_levelValue);
Front(_taskIcon);
Front(_taskText);
Front(_sexIcon);
Front(_favoriteBtn);
}
/// Harness: rebuild the dossier peek column after activity injects.
public static void ForceRefreshHistory()
{
_lastHistoryCount = -1;
_lastLiveHistFingerprint = int.MinValue;
if (_current == null && MoveCamera.hasFocusUnit() && MoveCamera._focus_unit != null)
{
SetFromActor(MoveCamera._focus_unit);
}
if (!_visible || _current == null)
{
return;
}
RefreshHistoryIfChanged();
}
///
/// Harness: force JobLabel onto the nametag identity tag (and optional orange reason beat).
///
public static bool ForceJobLabelOnFocus(
string jobLabel,
string reasonOverride = "",
long reasonRelatedId = 0,
string reasonRelatedName = "")
{
if (!MoveCamera.hasFocusUnit() || MoveCamera._focus_unit == null)
{
return false;
}
UnitDossier.HarnessJobLabelOverride = jobLabel ?? "";
UnitDossier.HarnessReasonOverride = reasonOverride ?? "";
UnitDossier.HarnessReasonRelatedId = reasonRelatedId;
UnitDossier.HarnessReasonRelatedName = reasonRelatedName ?? "";
SetFromActor(MoveCamera._focus_unit);
return _current != null
&& !string.IsNullOrEmpty(_current.JobLabel)
&& (_current.Headline ?? "").IndexOf(_current.JobLabel, System.StringComparison.OrdinalIgnoreCase) >= 0;
}
public static void ClearHarnessJobOverrides()
{
UnitDossier.ClearHarnessOverrides();
}
private static void RefreshHistoryIfChanged()
{
if (!_visible || _current == null)
{
return;
}
long id = _current.UnitId;
int count = ActivityLog.CountFor(id) + Chronicle.HistoryCountFor(id);
Actor live = ResolveBoundLiveActor();
int fingerprint = ActivityRelevance.LiveFingerprint(live, Time.unscaledTime);
if (id == _lastHistorySubjectId
&& count == _lastHistoryCount
&& fingerprint == _lastLiveHistFingerprint)
{
return;
}
int shown = FillHistory(id);
// Re-run layout only when history presence flips or line count changes.
bool hasBody = _current != null;
bool hasTask = _taskText != null && !string.IsNullOrEmpty(_taskText.text);
bool hasReason = (_reasonText != null && !string.IsNullOrEmpty(_reasonText.text))
|| (_current != null && !string.IsNullOrEmpty(_current.ReasonLine));
Relayout(
hasBody,
CountActiveTraitSlots(),
CountActiveStatusSlots(),
hasTask,
hasReason,
shown);
}
private static void ApplyVisual(Actor actor, UnitDossier dossier)
{
EnsureBuilt();
if (_root == null)
{
return;
}
_headlineLocked = false;
bool hasLive = actor != null
&& actor.isAlive()
&& dossier != null
&& actor.getID() == dossier.UnitId;
_boundActor = hasLive ? actor : null;
if (_speciesIcon != null)
{
Sprite species = null;
if (hasLive)
{
species = HudIcons.FromActor(actor);
}
else if (dossier != null)
{
species = HudIcons.FromSpeciesId(dossier.SpeciesId);
}
HudIcons.Apply(_speciesIcon, species);
}
bool hasSex = false;
if (_sexIcon != null)
{
Sprite sexSprite = null;
if (dossier != null)
{
if (dossier.IsMale)
{
sexSprite = HudIcons.SexMale();
}
else if (dossier.IsFemale)
{
sexSprite = HudIcons.SexFemale();
}
}
HudIcons.Apply(_sexIcon, sexSprite);
hasSex = sexSprite != null;
_sexIcon.gameObject.SetActive(hasSex);
}
if (_nameText != null)
{
_nameText.text = dossier != null && !string.IsNullOrEmpty(dossier.Headline)
? dossier.Headline
: "";
}
bool hasBody = dossier != null && dossier.UnitId != 0;
bool hoverDepth = LifeSagaViewController.IsHoverPreview;
// Hover Cast/Legacy owns the body band - keep the large portrait off.
DossierAvatar.SetActive(hasBody && !hoverDepth);
// Live units always show level; archive snapshots hide a blank 0.
bool hasLevel = hasBody && (hasLive || dossier.Level > 0);
if (_levelIcon != null)
{
HudIcons.Apply(_levelIcon, hasLevel ? HudIcons.Level() : null);
_levelIcon.gameObject.SetActive(hasLevel);
}
if (_levelValue != null)
{
_levelValue.text = hasLevel ? dossier.Level.ToString() : "";
_levelValue.gameObject.SetActive(hasLevel);
}
if (hoverDepth)
{
DossierAvatar.SetHostVisible(false);
}
else if (hasLive)
{
DossierAvatar.Show(actor);
ActivityLog.EnsureCurrentTask(actor);
}
else if (hasBody)
{
DossierAvatar.ShowSpecies(dossier.SpeciesId);
}
else
{
DossierAvatar.ClearActor();
}
bool hasTask = dossier != null && !string.IsNullOrEmpty(dossier.TaskText);
ApplyTaskChip(hasTask ? dossier.TaskText : "");
int traitCount = ApplyTraitChips(dossier);
int statusCount = ApplyStatusChips(dossier);
_lastStatusesFingerprint = StatusFingerprint(dossier);
int historyCount = 0;
if (dossier != null)
{
historyCount = FillHistory(dossier.UnitId);
}
else
{
LastHistoryPreview = "";
LastHistoryJoined = "";
LastHistoryShown = 0;
LastActivityShown = 0;
LastLifeShown = 0;
LastHistoryFillsBody = false;
if (_historyCol != null)
{
_historyCol.SetActive(false);
}
}
string presentable = dossier != null ? (dossier.ReasonLine ?? "") : "";
long related = dossier != null ? dossier.ReasonRelatedId : 0;
ApplyComposedCaption(hasLive ? actor : null, presentable, related, statusBannerOwnsBeat: false);
bool hasReason = !string.IsNullOrEmpty(LastBeatLine)
|| !string.IsNullOrEmpty(presentable);
Relayout(hasBody, traitCount, statusCount, hasTask, hasReason, historyCount);
RefreshFavoriteVisual(hasLive ? actor : null);
}
/// Orange story beat with rich-text gold names; empty hides the row.
private static void ApplyReasonText(string reasonRichOrPlain, long otherId = 0)
{
if (_reasonText == null)
{
return;
}
bool hasReason = !string.IsNullOrEmpty(reasonRichOrPlain);
string next = hasReason ? reasonRichOrPlain : "";
long subjectId = CurrentUnitId;
long nextOtherId = otherId != 0 && otherId != subjectId ? otherId : 0;
if (_reasonText.gameObject.activeSelf == hasReason
&& string.Equals(_reasonText.text, next, StringComparison.Ordinal)
&& _reasonOtherId == nextOtherId)
{
return;
}
_reasonText.supportRichText = true;
_reasonText.color = ReasonColor;
_reasonText.horizontalOverflow = HorizontalWrapMode.Wrap;
_reasonText.verticalOverflow = VerticalWrapMode.Overflow;
_reasonText.resizeTextForBestFit = false;
_reasonText.text = next;
_reasonText.gameObject.SetActive(hasReason);
WireReasonClick(hasReason ? otherId : 0);
}
private static void WireReasonClick(long otherId)
{
long subjectId = CurrentUnitId;
_reasonOtherId = otherId != 0 && otherId != subjectId ? otherId : 0;
if (_reasonText == null)
{
return;
}
var entry = _reasonText.gameObject.GetComponent();
if (entry == null)
{
entry = _reasonText.gameObject.AddComponent();
}
entry.triggers.Clear();
bool clickable = _reasonOtherId != 0 && _reasonText.gameObject.activeSelf;
_reasonText.raycastTarget = clickable;
if (!clickable)
{
return;
}
long captured = _reasonOtherId;
var down = new UnityEngine.EventSystems.EventTrigger.Entry
{
eventID = UnityEngine.EventSystems.EventTriggerType.PointerDown
};
down.callback.AddListener(_ => OpenHistoryOtherInLore(captured));
entry.triggers.Add(down);
}
/// Harness: orange reason names another living unit.
public static bool ReasonOtherClickable => _reasonOtherId != 0;
/// Harness: click the orange reason to open the related unit in Lore.
public static bool ClickReasonOther(out long otherId)
{
otherId = _reasonOtherId;
if (otherId == 0)
{
return false;
}
OpenHistoryOtherInLore(otherId);
return true;
}
private static int ApplyTraitChips(UnitDossier dossier)
{
int traitCount = 0;
LastTraitsPreview = "";
if (_traitsRow == null)
{
return 0;
}
if (dossier == null || dossier.TopTraits.Count <= 0)
{
_traitsRow.SetActive(false);
return 0;
}
_traitsRow.SetActive(true);
System.Text.StringBuilder traitPreview = new System.Text.StringBuilder();
for (int i = 0; i < _traitSlots.Length; i++)
{
TraitSlot slot = _traitSlots[i];
if (slot == null || slot.Root == null)
{
continue;
}
if (i < dossier.TopTraits.Count)
{
UnitDossier.TraitChip chip = dossier.TopTraits[i];
slot.Root.SetActive(true);
Sprite traitSprite = chip != null ? HudIcons.FromTrait(chip.Trait) : null;
if (traitSprite == null && chip != null && !string.IsNullOrEmpty(chip.Id))
{
traitSprite = HudIcons.FromUiIcon(chip.Id) ?? HudIcons.FromUiIcon("icon" + chip.Id);
}
HudIcons.Apply(slot.Icon, traitSprite);
string name = chip != null ? (chip.Name ?? "") : "";
if (slot.Label != null)
{
slot.Label.text = name;
}
if (slot.Tip != null)
{
if (chip != null && (chip.Trait != null || !string.IsNullOrEmpty(chip.Id)))
{
slot.Tip.BindTrait(chip.Trait, chip.Id);
slot.Tip.enabled = true;
}
else
{
slot.Tip.Clear();
slot.Tip.enabled = false;
}
}
if (traitPreview.Length > 0)
{
traitPreview.Append(", ");
}
traitPreview.Append(name);
traitCount++;
}
else
{
if (slot.Tip != null)
{
slot.Tip.Clear();
slot.Tip.enabled = false;
}
slot.Root.SetActive(false);
}
}
LastTraitsPreview = traitPreview.ToString();
return traitCount;
}
private static int ApplyStatusChips(UnitDossier dossier)
{
int statusCount = 0;
LastStatusesPreview = "";
if (_statusesRow == null)
{
return 0;
}
if (dossier == null || dossier.TopStatuses == null || dossier.TopStatuses.Count <= 0)
{
_statusesRow.SetActive(false);
for (int i = 0; i < _statusSlots.Length; i++)
{
if (_statusSlots[i]?.Root != null)
{
_statusSlots[i].Root.SetActive(false);
}
}
return 0;
}
_statusesRow.SetActive(true);
System.Text.StringBuilder preview = new System.Text.StringBuilder();
for (int i = 0; i < _statusSlots.Length; i++)
{
TraitSlot slot = _statusSlots[i];
if (slot == null || slot.Root == null)
{
continue;
}
if (i < dossier.TopStatuses.Count)
{
UnitDossier.StatusChip chip = dossier.TopStatuses[i];
slot.Root.SetActive(true);
Sprite sprite = chip != null
? (HudIcons.FromStatus(chip.Status) ?? HudIcons.FromStatusId(chip.Id))
: null;
HudIcons.Apply(slot.Icon, sprite);
string name = chip != null ? (chip.Name ?? "") : "";
if (slot.Label != null)
{
slot.Label.text = name;
slot.Label.color = StatusNameColor;
}
if (slot.Tip != null)
{
if (chip != null && (chip.Status != null || !string.IsNullOrEmpty(chip.Id)))
{
slot.Tip.BindStatus(chip.Status, chip.Id);
slot.Tip.enabled = true;
}
else
{
slot.Tip.Clear();
slot.Tip.enabled = false;
}
}
if (preview.Length > 0)
{
preview.Append(", ");
}
preview.Append(name);
statusCount++;
}
else
{
if (slot.Tip != null)
{
slot.Tip.Clear();
slot.Tip.enabled = false;
}
slot.Root.SetActive(false);
}
}
LastStatusesPreview = preview.ToString();
return statusCount;
}
private static int FillHistory(long unitId)
{
// Activity first (prominent), then Chronicle Life fills remaining peek slots.
// Always reserve one Life slot when the subject has chronicle history so Life
// does not vanish under a busy activity ring (and chronicle smoke stays valid).
int chronicleCount = Chronicle.HistoryCountFor(unitId);
int actCap = chronicleCount > 0 ? HistoryPeekMax - 1 : HistoryPeekMax;
Actor live = null;
try
{
live = ResolveBoundLiveActor();
if (live != null && live.isAlive() && live.getID() != unitId)
{
live = null;
}
if (live == null && MoveCamera.hasFocusUnit() && MoveCamera._focus_unit != null
&& MoveCamera._focus_unit.isAlive()
&& MoveCamera._focus_unit.getID() == unitId)
{
live = MoveCamera._focus_unit;
}
}
catch
{
live = null;
}
IReadOnlyList activity = live != null
? ActivityLog.LatestRelevantForSubject(live, unitId, actCap)
: ActivityLog.LatestForSubject(unitId, actCap);
int lifeNeed = Mathf.Max(0, HistoryPeekMax - (activity?.Count ?? 0));
IReadOnlyList life =
lifeNeed > 0
? Chronicle.LatestForSubject(unitId, lifeNeed)
: System.Array.Empty();
_lastHistorySubjectId = unitId;
_lastHistoryCount = ActivityLog.CountFor(unitId) + chronicleCount;
_lastLiveHistFingerprint = ActivityRelevance.LiveFingerprint(live, Time.unscaledTime);
LastHistoryPreview = "";
LastHistoryJoined = "";
LastHistoryShown = 0;
LastActivityShown = 0;
LastLifeShown = 0;
_historyColW = HistoryColMinW;
for (int i = 0; i < _historySlotHeights.Length; i++)
{
_historySlotHeights[i] = HistoryLineMinH;
}
if (_historyCol == null)
{
return 0;
}
int total = (activity?.Count ?? 0) + (life?.Count ?? 0);
if (total == 0)
{
// Soft fallback: show current task as a single activity line when the ring is empty.
string taskFallback = "";
try
{
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
if (focus != null && focus.isAlive() && focus.getID() == unitId && focus.hasTask()
&& focus.ai?.task != null)
{
taskFallback = focus.ai.task.getLocalizedText() ?? "";
}
}
catch
{
taskFallback = "";
}
if (string.IsNullOrEmpty(taskFallback))
{
for (int i = 0; i < _historySlots.Length; i++)
{
HistorySlot slot = _historySlots[i];
if (slot?.Root != null)
{
slot.Root.SetActive(false);
}
if (slot?.Label != null)
{
slot.Label.text = "";
}
_historySlotHeights[i] = 0f;
}
_historyCol.SetActive(false);
LastHistoryShown = 0;
LastActivityShown = 0;
LastLifeShown = 0;
LastHistoryFillsBody = false;
LastHistoryPreview = "";
LastHistoryJoined = "";
return 0;
}
activity = new[]
{
new ActivityEntry
{
DisplayLine = taskFallback,
Line = taskFallback,
Kind = ActivityKind.TaskStart
}
};
total = 1;
}
_historyCol.SetActive(true);
var lines = new List<(string rich, string plain, bool isActivity, ChronicleKind? kind, string activityKey, long otherId)>(
HistoryPeekMax);
if (activity != null)
{
for (int i = 0; i < activity.Count && lines.Count < HistoryPeekMax; i++)
{
ActivityEntry e = activity[i];
if (e == null)
{
continue;
}
string plain = !string.IsNullOrEmpty(e.DisplayLine) ? e.DisplayLine : e.Line;
if (string.IsNullOrEmpty(plain))
{
continue;
}
string rich = !string.IsNullOrEmpty(e.DisplayLineRich) ? e.DisplayLineRich : plain;
long otherId = ResolveHistoryOtherId(unitId, e.RelatedId, e.TargetLabel);
lines.Add((rich, plain, true, null, e.TaskId ?? "", otherId));
}
}
if (life != null)
{
for (int i = 0; i < life.Count && lines.Count < HistoryPeekMax; i++)
{
ChronicleEntry e = life[i];
if (e == null)
{
continue;
}
string plain = e.DisplayLine ?? e.HudLine ?? "";
string rich = e.DisplayLineRich ?? plain;
if (string.IsNullOrEmpty(plain) && string.IsNullOrEmpty(rich))
{
continue;
}
long otherId = e.OtherId != 0 && e.OtherId != unitId ? e.OtherId : 0;
lines.Add((rich, plain, false, e.Kind, "", otherId));
}
}
float widestLabel = 0f;
for (int i = 0; i < lines.Count && i < _historySlots.Length; i++)
{
HistorySlot slot = _historySlots[i];
if (slot?.Label == null)
{
continue;
}
Text label = slot.Label;
label.supportRichText = true;
label.resizeTextForBestFit = false;
label.horizontalOverflow = HorizontalWrapMode.Overflow;
label.verticalOverflow = VerticalWrapMode.Overflow;
label.alignment = TextAnchor.UpperLeft;
label.text = lines[i].plain;
Canvas.ForceUpdateCanvases();
try
{
widestLabel = Mathf.Max(widestLabel, label.preferredWidth);
}
catch
{
widestLabel = Mathf.Max(widestLabel, lines[i].plain.Length * 6.2f);
}
}
_historyColW = Mathf.Clamp(
widestLabel + HistoryIcon + 4f,
HistoryColMinW,
HistoryColMaxW);
ApplyCombinedSlotContents(lines, _historyColW);
LastHistoryShown = CountActiveHistorySlots();
return LastHistoryShown;
}
private static void ApplyCombinedSlotContents(
List<(string rich, string plain, bool isActivity, ChronicleKind? kind, string activityKey, long otherId)> lines,
float colW)
{
float labelW = Mathf.Max(24f, colW - HistoryIcon - 2f);
LastHistoryPreview = "";
LastHistoryJoined = "";
LastActivityShown = 0;
LastLifeShown = 0;
var joined = new List(HistoryPeekMax);
for (int i = 0; i < _historySlots.Length; i++)
{
HistorySlot slot = _historySlots[i];
if (slot?.Root == null)
{
continue;
}
if (i >= lines.Count)
{
slot.Root.SetActive(false);
slot.OtherId = 0;
WireHistoryRowClick(slot, 0);
_historySlotHeights[i] = 0f;
_historySlotIsActivity[i] = false;
continue;
}
var row = lines[i];
_historySlotIsActivity[i] = row.isActivity;
slot.Root.SetActive(true);
slot.OtherId = row.otherId;
Sprite icon = row.isActivity
? (HudIcons.ForActivityKey(row.activityKey)
?? HudIcons.FromUiIcon("iconClock")
?? HudIcons.ForChronicleKind(ChronicleKind.Other))
: HudIcons.ForChronicleKind(row.kind ?? ChronicleKind.Other);
HudIcons.Apply(slot.Icon, icon);
if (slot.Label != null)
{
slot.Label.supportRichText = true;
slot.Label.horizontalOverflow = HorizontalWrapMode.Wrap;
slot.Label.verticalOverflow = VerticalWrapMode.Overflow;
slot.Label.color = row.isActivity
? HudTheme.Body
: HistoryTextColor;
slot.Label.text = row.rich ?? row.plain ?? "";
RectTransform lrt = slot.Label.GetComponent();
lrt.SetSizeWithCurrentAnchors(RectTransform.Axis.Horizontal, labelW);
Canvas.ForceUpdateCanvases();
float needed = HistoryLineMinH;
try
{
needed = Mathf.Max(HistoryLineMinH, slot.Label.preferredHeight + 4f);
}
catch
{
int chars = (row.plain ?? "").Length;
int perLine = Mathf.Max(12, (int)(labelW / 6.2f));
int wrapLines = Mathf.Max(1, (chars + perLine - 1) / perLine);
needed = HistoryLineMinH * wrapLines;
}
_historySlotHeights[i] = Mathf.Clamp(needed, HistoryLineMinH, HistoryLineMaxH);
}
else
{
_historySlotHeights[i] = HistoryLineMinH;
}
WireHistoryRowClick(slot, row.otherId);
if (row.isActivity)
{
LastActivityShown++;
}
else
{
LastLifeShown++;
}
string plain = (row.plain ?? "").Replace("\n", " ");
joined.Add(plain);
if (string.IsNullOrEmpty(LastHistoryPreview))
{
LastHistoryPreview = plain;
}
}
LastHistoryJoined = string.Join(" | ", joined);
}
private static void WireHistoryRowClick(HistorySlot slot, long otherId)
{
if (slot?.Root == null)
{
return;
}
slot.OtherId = otherId;
if (slot.Hit == null)
{
slot.Hit = slot.Root.GetComponent();
if (slot.Hit == null)
{
slot.Hit = slot.Root.AddComponent();
}
slot.Hit.color = new Color(1f, 1f, 1f, 0.001f);
}
if (slot.Button == null)
{
slot.Button = slot.Root.GetComponent