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
This commit is contained in:
DazedAnon 2026-07-24 15:39:33 -05:00
parent 529d18dfa4
commit 1bbe642e5b
6 changed files with 210 additions and 24 deletions

View file

@ -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 ?? "";

View file

@ -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"),

View file

@ -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);

View file

@ -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;
}
/// <summary>Return nametag ownership to the camera dossier after a true rail exit.</summary>
public static void EndSagaHoverSession()
{
_sagaHoverHeadline = "";
_sagaHoverFontCeiling = 0;
string shown = LastHeadline ?? "";
if (shown.Length > 48)
{
shown = CaptionComposer.TruncateIdentity(shown, 48);
}
PaintDossierHeadline(shown);
}
/// <summary>
/// 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);
}
/// <summary>Harness: prove a camera-dossier write cannot replace a hovered identity.</summary>
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;
}
/// <summary>
/// Single camera-dossier nametag write gate. Saga hover is the exclusive visual
/// owner even while camera callbacks continue updating the underlying dossier model.
/// </summary>
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;
}
}
/// <summary>
/// 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
/// <summary>Keep nametag Identity in sync via CaptionComposer (MC title or species/job).</summary>
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;

View file

@ -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

View file

@ -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.