From 1bbe642e5b81b063a1eb57233d8f2e8b407f22af Mon Sep 17 00:00:00 2001 From: DazedAnon Date: Fri, 24 Jul 2026 15:39:33 -0500 Subject: [PATCH] feat(saga): enhance hover functionality and ownership management - Introduce new hover command for second unit in saga scrubbing - Implement logic to manage hover ownership and prevent visual conflicts - Update WatchCaption to track blocked dossier writes during hover - Refactor LifeSagaViewController to maintain hover session integrity - Enhance harness scenarios to validate new hover behaviors and ownership checks --- IdleSpectator/AgentHarness.cs | 98 +++++++++++++++ IdleSpectator/HarnessScenarios.cs | 3 + IdleSpectator/Story/LifeSagaViewController.cs | 12 +- IdleSpectator/WatchCaption.cs | 115 ++++++++++++++---- docs/five-mc-ensemble-plan.md | 2 + docs/life-saga.md | 4 + 6 files changed, 210 insertions(+), 24 deletions(-) diff --git a/IdleSpectator/AgentHarness.cs b/IdleSpectator/AgentHarness.cs index 8864c69..c61a0f5 100644 --- a/IdleSpectator/AgentHarness.cs +++ b/IdleSpectator/AgentHarness.cs @@ -4280,6 +4280,99 @@ public static class AgentHarness break; } + case "saga_scrub_hover_second": + { + LifeSagaRail.Refresh(); + if (!LifeSagaViewController.IsHoverPreview) + { + LifeSagaRail.ShowHover(0); + WatchCaption.RequestRelayout(); + } + + long firstId = LifeSagaViewController.HoverPreviewId; + string firstName = WatchCaption.VisibleNametagText; + int secondIndex = -1; + for (int i = 0; i < LifeSagaRoster.Cap; i++) + { + long id = LifeSagaRail.UnitIdAt(i); + if (id != 0 && id != firstId) + { + secondIndex = i; + break; + } + } + + LifeSagaRail.HideHover(); + bool deferredHeld = LifeSagaViewController.IsHoverPreview + && LifeSagaViewController.HoverPreviewId == firstId + && InterestDirector.ReadPauseActive + && string.Equals( + WatchCaption.VisibleNametagText, + firstName, + StringComparison.Ordinal); + + if (secondIndex >= 0) + { + LifeSagaRail.ShowHover(secondIndex); + WatchCaption.RequestRelayout(); + } + + LifeSagaViewModel model = LifeSagaViewController.BuildEffectivePresentation(); + string expected = (model?.Name ?? "").Replace("★ ", "").Trim(); + string beforeWriter = WatchCaption.VisibleNametagText; + int beforeFont = WatchCaption.VisibleNametagFontSize; + Vector2 beforeAvatar = DossierAvatar.HostAnchoredPosition; + int blockedBefore = WatchCaption.SagaHoverBlockedDossierWrites; + + // Reproduce the real contention: the camera dossier receives a normal bind + // while a different Saga glyph owns the shared header. + Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null; + if (focus != null && focus.isAlive()) + { + WatchCaption.SetFromActor(focus); + } + WatchCaption.Update(); + WatchCaption.RequestRelayout(); + + string afterWriter = WatchCaption.VisibleNametagText; + int afterFont = WatchCaption.VisibleNametagFontSize; + Vector2 afterAvatar = DossierAvatar.HostAnchoredPosition; + long secondId = LifeSagaViewController.HoverPreviewId; + bool correctSecond = secondIndex >= 0 + && secondId != 0 + && secondId != firstId + && !string.IsNullOrEmpty(expected) + && afterWriter.IndexOf(expected, StringComparison.Ordinal) >= 0; + bool stable = string.Equals(beforeWriter, afterWriter, StringComparison.Ordinal) + && beforeFont == afterFont + && Vector2.Distance(beforeAvatar, afterAvatar) < 0.1f; + bool blocked = WatchCaption.SagaHoverBlockedDossierWrites > blockedBefore; + bool ok = deferredHeld + && correctSecond + && stable + && blocked + && InterestDirector.ReadPauseActive; + if (ok) + { + _cmdOk++; + } + else + { + _cmdFail++; + } + + Emit( + cmd, + ok, + detail: + $"first={firstId} second={secondId} index={secondIndex} held={deferredHeld} " + + $"name='{firstName}'/'{beforeWriter}'/'{afterWriter}' expected='{expected}' " + + $"font={beforeFont}/{afterFont} avatar={beforeAvatar}/{afterAvatar} " + + $"blocked={blockedBefore}/{WatchCaption.SagaHoverBlockedDossierWrites} " + + $"pause={InterestDirector.ReadPauseActive}"); + break; + } + case "saga_hide_hover": { LifeSagaRail.HideHover(); @@ -10403,6 +10496,11 @@ public static class AgentHarness + $"visible={DossierAvatar.HostVisible} pass={pass}"; break; } + case "saga_hover_nametag_ownership": + { + pass = WatchCaption.HarnessProbeSagaHoverNametagOwnership(out detail); + break; + } case "caption_mc_identity": { string idLine = WatchCaption.LastHeadline ?? ""; diff --git a/IdleSpectator/HarnessScenarios.cs b/IdleSpectator/HarnessScenarios.cs index 2a21350..9a2fbb7 100644 --- a/IdleSpectator/HarnessScenarios.cs +++ b/IdleSpectator/HarnessScenarios.cs @@ -3978,6 +3978,7 @@ internal static class HarnessScenarios Step("sad8b", "pick_unit", asset: "human", value: "other"), Step("sad9", "focus", asset: "human"), Step("sad10", "saga_force_admit_focus"), + Step("sad10p", "saga_force_admit_partner"), Step("sad10a", "saga_memory_record_focus", asset: "War", value: "opposing commander", expect: "harness_cast_relation"), Step("sad11", "assert", expect: "saga_tab_selected", value: "dossier"), @@ -4010,6 +4011,8 @@ internal static class HarnessScenarios Step("sad19", "assert", expect: "saga_tab_effective", value: "saga"), Step("sad20", "assert", expect: "saga_hover_preview", value: "true"), Step("sad21", "assert", expect: "saga_read_pause", value: "true"), + Step("sad21a", "saga_scrub_hover_second"), + Step("sad21a2", "assert", expect: "saga_hover_nametag_ownership"), Step("sad21b", "saga_hide_hover"), Step("sad21d", "saga_hover_tick_away"), Step("sad21h", "assert", expect: "saga_hover_preview", value: "false"), diff --git a/IdleSpectator/Story/LifeSagaViewController.cs b/IdleSpectator/Story/LifeSagaViewController.cs index 522deb0..d84fe81 100644 --- a/IdleSpectator/Story/LifeSagaViewController.cs +++ b/IdleSpectator/Story/LifeSagaViewController.cs @@ -122,7 +122,10 @@ public static class LifeSagaViewController return; } - FinishHoverExit(); + // PointerExit is delivered before PointerEnter when scrubbing directly from one + // glyph to another. Keep Saga ownership alive across that event handoff so the + // dossier cannot paint one intermediate header frame. + _hoverExitAt = Time.unscaledTime + HoverExitGraceSeconds; } public static void Tick() @@ -155,8 +158,14 @@ public static class LifeSagaViewController public static void CancelPreviewAndPause() { + bool hadPreview = _hoverPreviewId != 0; _hoverExitAt = -1f; _hoverPreviewId = 0; + if (hadPreview) + { + WatchCaption.EndSagaHoverSession(); + } + if (_pauseHeld) { InterestDirector.ReleaseReadPause(ReadPauseOwner); @@ -192,6 +201,7 @@ public static class LifeSagaViewController { _hoverExitAt = -1f; _hoverPreviewId = 0; + WatchCaption.EndSagaHoverSession(); if (_pauseHeld) { InterestDirector.ReleaseReadPause(ReadPauseOwner); diff --git a/IdleSpectator/WatchCaption.cs b/IdleSpectator/WatchCaption.cs index 7243eaa..0879792 100644 --- a/IdleSpectator/WatchCaption.cs +++ b/IdleSpectator/WatchCaption.cs @@ -131,6 +131,7 @@ public static class WatchCaption // LastHeadline. Keep its display text and entry typography separate from that cache. private static string _sagaHoverHeadline = ""; private static int _sagaHoverFontCeiling; + private static int _sagaHoverBlockedDossierWrites; // 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. @@ -181,6 +182,7 @@ public static class WatchCaption public static string VisibleNametagText => _nameText != null ? (_nameText.text ?? "") : ""; public static int VisibleNametagFontSize => _nameText != null ? _nameText.fontSize : 0; public static int SagaHoverFontCeiling => _sagaHoverFontCeiling; + public static int SagaHoverBlockedDossierWrites => _sagaHoverBlockedDossierWrites; public static string LastDetail { get; private set; } = ""; @@ -572,7 +574,7 @@ public static class WatchCaption if (_nameText != null && (string.IsNullOrEmpty(_nameText.text) || _current == null)) { - _nameText.text = "Idle Spectator"; + PaintDossierHeadline("Idle Spectator"); LastHeadline = "Idle Spectator"; } @@ -661,10 +663,7 @@ public static class WatchCaption _boundActor = null; EnsureBuilt(); ApplyVisual(null, null); - if (_nameText != null) - { - _nameText.text = LastHeadline; - } + PaintDossierHeadline(LastHeadline); Relayout(false, 0, 0, false, false, 0); SetVisible(ModSettings.ShowDossierCaption && SpectatorMode.Active); @@ -804,9 +803,9 @@ public static class WatchCaption { ApplyVisual(focus ?? _boundActor, _current); } - else if (_nameText != null) + else { - _nameText.text = LastHeadline; + PaintDossierHeadline(LastHeadline); Relayout(false, 0, 0, false, false, 0); } @@ -815,10 +814,13 @@ public static class WatchCaption // 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. + // Camera dossier state tracks focus, but Saga hover exclusively owns painted chrome. long perfStarted = StartPerfSample(); LifeSagaViewController.Tick(); - ReconcileDossierToFocus(); + if (!LifeSagaViewController.IsHoverPreview) + { + ReconcileDossierToFocus(); + } LastReconcileMs = EndPerfSample(perfStarted); float now = Time.unscaledTime; if (now < _nextRoutineLiveRefreshAt) @@ -959,11 +961,26 @@ public static class WatchCaption public static void BeginSagaHoverSession() { _sagaHoverHeadline = ""; + _sagaHoverBlockedDossierWrites = 0; _sagaHoverFontCeiling = _nameText != null ? Mathf.Clamp(_nameText.fontSize, 7, 10) : 10; } + /// Return nametag ownership to the camera dossier after a true rail exit. + public static void EndSagaHoverSession() + { + _sagaHoverHeadline = ""; + _sagaHoverFontCeiling = 0; + string shown = LastHeadline ?? ""; + if (shown.Length > 48) + { + shown = CaptionComposer.TruncateIdentity(shown, 48); + } + + PaintDossierHeadline(shown); + } + /// /// Swap the hovered identity before building Cast/Legacy. This keeps pointer feedback /// immediate even when the deeper saga model has several relationships to resolve. @@ -995,6 +1012,47 @@ public static class WatchCaption ApplySagaHoverHeader(model, deferPortraitReveal: true); } + /// Harness: prove a camera-dossier write cannot replace a hovered identity. + public static bool HarnessProbeSagaHoverNametagOwnership(out string detail) + { + string before = VisibleNametagText; + int blockedBefore = _sagaHoverBlockedDossierWrites; + PaintDossierHeadline("__dossier_writer_probe__"); + string after = VisibleNametagText; + bool pass = LifeSagaViewController.IsHoverPreview + && !string.IsNullOrEmpty(before) + && string.Equals(before, after, StringComparison.Ordinal) + && _sagaHoverBlockedDossierWrites == blockedBefore + 1; + detail = + $"hover={LifeSagaViewController.HoverPreviewId} before='{before}' after='{after}' " + + $"blocked={blockedBefore}/{_sagaHoverBlockedDossierWrites} pass={pass}"; + return pass; + } + + /// + /// Single camera-dossier nametag write gate. Saga hover is the exclusive visual + /// owner even while camera callbacks continue updating the underlying dossier model. + /// + private static void PaintDossierHeadline(string text) + { + if (_nameText == null) + { + return; + } + + if (LifeSagaViewController.IsHoverPreview) + { + _sagaHoverBlockedDossierWrites++; + return; + } + + string next = text ?? ""; + if (!string.Equals(_nameText.text, next, StringComparison.Ordinal)) + { + _nameText.text = next; + } + } + /// /// 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. @@ -1401,20 +1459,14 @@ public static class WatchCaption shown = CaptionComposer.TruncateIdentity(shown, 48); } - if (!string.Equals(_nameText.text, shown, StringComparison.Ordinal)) - { - _nameText.text = shown; - } + PaintDossierHeadline(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; - } + PaintDossierHeadline(LastHeadline); } } @@ -1674,6 +1726,13 @@ public static class WatchCaption return; } + // A delayed model from the previous glyph must never repaint a newer hover. + if (!LifeSagaViewController.IsHoverPreview + || model.UnitId != LifeSagaViewController.HoverPreviewId) + { + return; + } + Actor actor = EventFeedUtil.FindAliveById(model.UnitId); string name = (model.Name ?? "").Trim(); if (name.StartsWith("★ ", StringComparison.Ordinal)) @@ -1905,7 +1964,10 @@ public static class WatchCaption /// Keep nametag Identity in sync via CaptionComposer (MC title or species/job). private static void RefreshLiveIdentity() { - if (!_visible || _current == null || _headlineLocked) + if (!_visible + || _current == null + || _headlineLocked + || LifeSagaViewController.IsHoverPreview) { return; } @@ -2282,6 +2344,15 @@ public static class WatchCaption && actor.getID() == dossier.UnitId; _boundActor = hasLive ? actor : null; + // Saga hover owns the complete header/body presentation. SetFromActor and other + // camera callbacks may still update the dossier snapshot, but they cannot repaint + // shared UI until the hover session ends. + if (LifeSagaViewController.IsHoverPreview) + { + _sagaHoverBlockedDossierWrites++; + return; + } + if (_speciesIcon != null) { Sprite species = null; @@ -2318,12 +2389,10 @@ public static class WatchCaption _sexIcon.gameObject.SetActive(hasSex); } - if (_nameText != null) - { - _nameText.text = dossier != null && !string.IsNullOrEmpty(dossier.Headline) + PaintDossierHeadline( + dossier != null && !string.IsNullOrEmpty(dossier.Headline) ? dossier.Headline - : ""; - } + : ""); bool hasBody = dossier != null && dossier.UnitId != 0; bool hoverDepth = LifeSagaViewController.IsHoverPreview; diff --git a/docs/five-mc-ensemble-plan.md b/docs/five-mc-ensemble-plan.md index 6aac167..dc0cca8 100644 --- a/docs/five-mc-ensemble-plan.md +++ b/docs/five-mc-ensemble-plan.md @@ -201,6 +201,8 @@ Recognition should come from repeated, stable signals rather than extra prose on - Identity remains `Name · real title`, otherwise `Name (species/job)`. - Hover remains the place for Cast and Legacy depth; the live caption stays Identity + Beat + Context. +- Treat a glyph-to-glyph scrub as one hover ownership session: defer exit briefly, block all + camera-dossier writes to shared header chrome, and restore the camera name only after a true exit. ### MC connection in the camera dossier diff --git a/docs/life-saga.md b/docs/life-saga.md index 63136da..0b6a679 100644 --- a/docs/life-saga.md +++ b/docs/life-saga.md @@ -219,6 +219,10 @@ Quiet can refresh briefly when a soft crumb ends (bounded). not call the population-counting dossier builder. - Hover fitting reads its own display headline rather than the camera dossier's cached identity; repeated relayouts therefore cannot briefly restore the wrong name or enlarge the font. +- Saga owns the shared nametag/species/sex/portrait chrome exclusively for the whole hover session. + Camera dossier callbacks may update their semantic snapshot but cannot repaint that chrome. +- Glyph exit is deferred for 50 ms; entering another glyph cancels the exit, producing an atomic + cross-glyph handoff with no intermediate dossier name or typography frame. - Rail face animation refreshes the Active glyph ~1/s; other glyphs stay static between structure changes. - Missing live rail frames retry at most one inactive slot per ~1s rail tick; successful captures replace the temporary species icon and unsuccessful attempts never discard an older live frame.