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