diff --git a/docs/drafts/2026-07-30-recap-right-click-not-received.md b/docs/drafts/2026-07-30-recap-right-click-not-received.md index 47ff3a15..f002d4a6 100644 --- a/docs/drafts/2026-07-30-recap-right-click-not-received.md +++ b/docs/drafts/2026-07-30-recap-right-click-not-received.md @@ -153,3 +153,292 @@ not final acceptance. with zero warnings/errors. Installed Debug DLL `4.6.0.t20260802.172007.dev` matches the built SHA-256 `810e3c437992e59a3bba8d45812da923d5a98d07e25682528f769c49e52f2503`. - [ ] User explicitly accepts the final in-game visuals and interaction. PR remains Draft until then. + +## 2026-08-04 intermittent header-only orphan + +### Background + +Combat Impact requests the game's singleton Auxiliary Tooltip, conceals it while custom content is +built, and then reveals the complete paired presentation. The native request is asynchronous: the +controller writes the header before its end-of-frame positioning/fade coroutine completes. + +### Current problem + +Peng captured an intermittent white Auxiliary Tooltip containing only `Combat Impact` while the +native card Tooltip remained visible. This is the same header-only state prohibited by the +interaction acceptance criteria above. + +### Evidence and root cause + +- The screenshot is a header-only native Auxiliary Tooltip, not a partially rendered Combat Impact + content tree: no custom summary, groups, rows, frame, or background clone are present. +- `AuxiliaryTooltipController.ShowAuxiliaryTooltipController` assigns the header synchronously and + finishes positioning/fade after `WaitForEndOfFrame`. +- When a BPP-owned request becomes stale before takeover, `ResumeAfterNativeAuxiliaryShow` calls + `CancelPreparedNativeAuxiliary` and then asks the native parent to hide it. +- The current cancel path restores the prepared host's header visibility and CanvasGroup alpha + before that native hide begins. This creates a real frame window where the detached title is + visible; if the native fade coroutine is interrupted, the window can persist. +- The current Debug log contains no `post_combat_impact` shown/degraded event for this screenshot, + which is consistent with cancellation before the complete presentation reaches its reveal/log + stage. + +The first cancellation fix concealed the serialized root CanvasGroup immediately before teardown, +but runtime acceptance still reproduced the orphan. Re-reading the earlier part of the sequence +exposes a second window: `PrepareAuxiliary` gates only `auxParent`, while the native header and frame +are not proven to be descendants of that node. The native end-of-frame coroutine then starts its own +fade-in on the controller CanvasGroup before stale-request cancellation runs. Concealing only at +cancellation is therefore too late to prevent a rendered header/frame flash. + +### Candidate approaches + +1. **Retune layout/background creation** — rejected: the screenshot predates content attachment and + contains none of those nodes. +2. **Hide only the header text** — rejected: the native frame/background could still flash, and it + leaves the cancellation lifecycle split across individual children. +3. **Keep the complete prepared Auxiliary Tooltip concealed until native teardown** — selected: + cancel restores reusable geometry but does not restore native content visibility; it sets the + controller-level CanvasGroup to zero before handing teardown back to the game. +4. **Add an independent controller-root preparation gate** — selected after the first fix still + reproduced: add a BPP-owned CanvasGroup before the native `Show` body runs. The native serialized + CanvasGroup remains free to complete its own fade and `HasShown` lifecycle underneath; the BPP + gate opens only when the complete paired content is ready. A stale cancellation deliberately + keeps this outer gate closed until teardown or the next legitimate native show releases it. + +### Verification + +- Add a lifecycle regression that locks `CancelPreparedAuxiliary` to conceal-before-restore + behavior. +- Run the focused PostCombatImpact/architecture tests, format check, Debug and Release builds. +- Manual acceptance: rapidly enter/exit several Recap items and skills, including leaving before + the auxiliary tooltip settles; no standalone `Combat Impact` title may appear. Confirm ordinary + native auxiliary tooltips still render after the stale request is dismissed. + +## 2026-08-05 fast A→B switch: B's Combat Impact details never show + +### Background + +The 2026-08-04 fix added a BPP-owned controller-root shell gate created with `forceOwnedGroup: +true` (`AddComponent` on `auxiliary.gameObject`) in both `PrepareAuxiliary` and +`TryOpen` (`NativePairedTooltipHost.cs`). The native Auxiliary Tooltip is not a persistent +singleton GameObject: it is a spawned node-sequence object, and +`TooltipParentComponent.HideAuxiliaryTooltipController` tears it down by completing the node +sequence (`decompiled/TheBazaarRuntime/TheBazaar.UI.Tooltips/TooltipParentComponent.cs:339`). + +### Current problem + +Quickly moving hover from card A to card B in Recap sometimes leaves B with only the primary +tooltip; B's Combat Impact details never appear for the remainder of that hover. + +### Evidence and root cause + +- `LogOutput.log` from the 2026-08-05 00:28 session (DLL built with the shell-gate fix) contains + two `post_combat_impact.interaction.degraded reason_code=tooltip_render_exception` events with a + `NullReferenceException` at `CanvasGroupGate..ctor [0x00046]` called from `TryOpen [0x00127]`. +- IL disassembly of the installed DLL maps `TryOpen IL_0127` to the shell-gate + `CanvasGroupGate.Create(auxiliary, auxiliary.gameObject, forceNonInteractive: true, + forceOwnedGroup: true)` call, and ctor `IL_0046` to `_originalAlpha = _group.alpha`, immediately + after `AddComponent()`. The `forceOwnedGroup` path never calls `GetComponent`, so + the dereferenced group can only be the `AddComponent` result. +- Unity rejects `AddComponent` on a GameObject whose `Object.Destroy` is pending and returns null, + while `!= null` checks, `HasShown`, and property reads still pass until end of frame (fake-null + materializes at frame end). Decompiled `CanvasGroup.get_alpha` throws `NullReferenceException` + via `MarshalledUnityObject.MarshalNotNull` — matching the logged exception type exactly. +- In the fast-switch flow, A's stale-request teardown (`ResumeAfterNativeAuxiliaryShow` → + `CancelPreparedNativeAuxiliary` + `HideAuxiliaryTooltipController`, + `PostCombatImpactController.cs:826-856`) can land its pending destroy in the same frame in which + B's `ShowWhenReady` reaches `_view.Show`. Every stale/ownership check keys on the controller + reference (`ReferenceEquals`), which cannot distinguish A's request from B's when the spawned + instance is reused, and no liveness check can see a pending destroy before frame end. +- The thrown exception is caught at `PostCombatImpactController.cs:471-480` and routed to + `FailPendingShow(..., suppressUntilExit: true)`: the hover revision is suppressed, so B's details + stay hidden until the pointer exits. Nothing requeues the show afterwards — the auxiliary-hiding + recovery path does not fire because the session never became owner (`_activeAuxiliary` is set + after the gate creation that threw). This matches "sometimes never shows for the whole hover". + +### Candidate approaches + +1. **Harden `CanvasGroupGate` alone** (return null / no-throw when `AddComponent` comes back dead, + `TryOpen` returns false) — insufficient alone: the failure would then route to + `AuxiliaryTooltipContentUnavailable`, still with `suppressUntilExit: true`, so B would still + never show. +2. **Same-frame liveness re-check before `_view.Show`** (`SequenceProcessor.IsNodeActive` sampled + contiguously with the Show call) — narrows but cannot close the window: node completion and the + coroutine resume order within the frame are not guaranteed, and the pending destroy is invisible + to managed checks. +3. **Fail soft + requeue + request-token identity** — selected direction: + a. `CanvasGroupGate.Create` returns null when the owned `AddComponent` result is dead; + `TryOpen` undoes partial work and returns false (no exception on the main path). + b. Classify this failure as transient: instead of `suppressUntilExit: true`, requeue via + `StartPendingShow` while the hover revision is still current, so the still-valid hover + re-requests a fresh native auxiliary tooltip on the next frame. + c. Remove the identity ambiguity that lets A's stale cancel/hide land on B's request: capture + the hover revision (or an explicit request token) when `ResumeAfterNativeAuxiliaryShow` + starts and make its stale-discard a no-op once the pending auxiliary request has been + superseded. Controller-reference equality cannot carry request identity on a reused spawned + instance. + +### Verification + +- Unit/architecture: lock `TryOpen` returning false (not throwing) when the gate target is dying, + and lock the stale-discard no-op when the request token is superseded. +- Runtime: in Recap, sweep hover A→B→A across ≥5 cards for ≥20 fast transitions. Acceptance: + B's details visible on every settled hover, zero `tooltip_render_exception` in the Debug log, + and no header-only orphan regression (previous section's acceptance still holds). +- Post-session log audit: `stale_request_discarded` may appear only when the hover genuinely + exited; no `reason_code=tooltip_render_exception` at all. + +### 2026-08-05 implementation notes + +- Implemented 3a (gate fail-soft: `CanvasGroupGate.Create` returns null on a dead `AddComponent` + result; `TryOpen` rolls back and returns false) and 3b (bounded transient retry: + `TryConsumeTransientShowRetry`, max 2 per hover revision, requeues via `StartPendingShow` and + logs `auxiliary_tooltip_show_retried`; suppression remains the fallback). +- Dropped 3c (request token for `ResumeAfterNativeAuxiliaryShow`'s stale discard) after tracing + every A→B entry: a new hover always passes `SetHoveredSource` → + `CancelAndClearPresentation(preserveOutstandingAuxiliaryRequest: true)` → `StopPendingShow`, + which stops the resume coroutine (it *is* `_pendingShow`) before any superseding flow starts + (`PostCombatImpactController.cs:169`, `:950-953`). The `sameHover` early-return cannot switch + cards, and the unmatched branch of `OnNativeAuxiliaryTooltipShowing` also reaps it. A token + would be dead code today; revisit only if a new caller starts that coroutine outside + `_pendingShow`. +- Locked by `Gate_creation_fails_soft_and_open_reports_a_dying_controller_as_unusable` and + `Transient_show_failure_retries_before_suppressing_the_hover` in + `NativePairedTooltipArchitectureTests`. + +## 2026-08-05 (second report): orphan reappears, apparently only on Tracer Pistol + +### Current problem + +With the retry fix deployed (verified: game started 00:53, DLL deployed 00:50, hashes match), the +header-only `Combat Impact` orphan reappeared during combat-replay playback, reportedly only when +hovering Tracer Pistol (a DEADLY-enchanted weapon). The session log contains zero +`post_combat_impact` warnings for the occurrence. + +### Evidence so far + +- BepInEx `[Logging.Disk] LogLevels` excluded `Debug`, so every `LogInteraction` reason code + (hover observed, retried, stale discarded, content unavailable…) was invisible on disk across + ALL prior sessions. Debug has now been added to the disk log levels (config change; revert by + removing `, Debug` from line 104 of `BepInEx/config/BepInEx.cfg`). +- Static analysis found the exposure mechanism: every failure rollback inside `TryOpen` called + `Release(restoreNativeContent: false)` directly, and `Release` restores all gates to their + visible originals. `CompleteAnimatedHide` and `CancelPreparedAuxiliary` conceal first, but the + TryOpen rollbacks did not — so ANY TryOpen failure after the native fade-in exposed the native + shell (header-only box) until the subsequent hide finished. The gate-death rollback exposes it + with the header still active — matching the screenshot text. +- `Show`'s only silent-false sources are the locale typography check and TryOpen itself; TryOpen's + branch was not observable from logs. Both are now logged at Warning through + `InteractionDegraded` with new reason codes (`typography_unavailable`, + `pair_open_missing_auxiliary_fields`, `pair_open_dying_controller`, + `pair_open_missing_background`, `pair_open_background_clone_rejected`); TryOpen returns a + `NativePairedTooltipOpenFailure` detail to the view, keeping the host log-free. +- Fix applied: each TryOpen rollback now calls `ConcealNativeAuxiliary(auxiliary)` before + `Release`, mirroring the cancel-path precedent. Locked by the extended + `Gate_creation_fails_soft_and_open_reports_a_dying_controller_as_unusable` test. +- Session log also shows `collection_panel.dock_layout.degraded blocker=…Tooltip_CardTooltip_LockMode_P(Clone)` — + the native LOCK-MODE card tooltip prefab was alive during the session. Unverified lead: if the + hovered card's primary tooltip resolves to the lock-mode clone, its background sprite/shape may + differ and deterministically fail TryOpen's background checks for that card. + +### To verify next (requires game restart for the config + DLL) + +Reproduce on Tracer Pistol, then read `LogOutput.log`: + +1. Which Warning appears — `pair_open_missing_background` / `pair_open_background_clone_rejected` + would confirm the primary-background theory; `typography_unavailable` would point at the locale + font gate. +2. `auxiliary_tooltip_show_retried` (Debug, now visible) should appear up to 2 times followed by + `auxiliary_tooltip_content_unavailable` when the failure is persistent. +3. Visually: the header-only orphan must no longer appear even while the failure persists (conceal + now precedes rollback); the remaining defect would be "details missing on this card", to be + fixed once the reason code names the branch. + +### 2026-08-05 (third round): card identified, exposure window found + +Runtime facts from the instrumented session (Debug disk logging active, probe build verified): + +- The failing card `4f4134b8-2799-4180-b665-eb809cfb101c` is Tracer Pistol — and it is the ONLY + Legendary-tier card in the replayed battle (battle_snapshots for 63c5a5ca). The auxiliary frame + is tier-assigned (`AssignTooltipFrame(tier)`) and the Legendary frame is light/white. "Only + Tracer Pistol shows the white box" therefore most likely means "every card has the same exposure + window, but only the Legendary frame is bright enough to notice". +- The user reproduced the white box while details rendered normally, and the full trail shows + ZERO failure codes, zero retries, zero pair-open rejections, zero unmatched shows, zero + blocked/aborted probes. The exposure lives in a path with no logging and no interaction-pipeline + anomaly. +- The trail for Tracer Pistol shows rapid re-hover cycles (hover → dismissed → hover within + ~1s). On a re-request the native singleton controller is reused while still alive, and + `PrepareAuxiliary` did restore-then-recreate on the shell gate: `Restore()` sets the kept-closed + owned CanvasGroup back to alpha 1 and defers `Object.Destroy` to end of frame, so during that + frame's render the controller root is ungated with the header active — a one-frame header-only + flash, white on Legendary. + +Fix: `PrepareAuxiliary` now reuses a matching still-held gate (re-closing it explicitly) instead +of restore-then-recreate, for both the controller-root shell gate and the auxParent gate. Locked +by the existing prepare-order architecture test (assignment still precedes `auxiliary.auxParent`). + +Remaining risk: the one-frame-flash mechanism explains a screenshot-able artifact only if capture +odds are favorable; if the box is ever observed PERSISTING for multiple seconds after this fix, the +next suspect is a superseded native hide (`HideAuxiliaryTooltipController` no-op when the node is +already completing) leaving a natively-visible controller — verify by capturing frames during +reproduction (scratchpad capture script + window id via CGWindowList). + +### 2026-08-05 (fourth round): prefab ground truth — the shell gate never worked + +Static inspection of the game's `tooltips_assets_all.bundle` (UnityPy, `Tooltip_Aux_P` prefab) +settles the mechanism with asset-level evidence: + +``` +Tooltip_Aux_P ← AuxiliaryTooltipController, world-space Transform + BoxCollider +└── Tooltip_Aux_Canvas ← Canvas + CanvasGroup (= serialized tooltipCanvasGroup, DOFade target) + └── ScalerOffset + └── Tooltip_Aux_Parent_RectTransform + └── Tooltip_Aux_Content ← auxParent (serialized field, verified via typetree) + ├── Background / TitleText / Divider / BodyText +``` + +- The 2026-08-04 premise "the title and frame can be siblings of auxParent" is false for this + prefab: `auxParent` (Tooltip_Aux_Content) owns the COMPLETE visual tree. +- The controller root sits ABOVE the prefab's own nested Canvas; a CanvasGroup added there is + outside canvas alpha propagation. The controller-root shell gate was therefore inert from the + day it shipped — which is why every shell-gate-based fix "didn't work" at runtime. +- The only effective concealment is the auxParent gate — and `CancelPreparedAuxiliary`'s stale + branch RESTORED it, leaving concealment to a one-shot serialized-alpha write that the in-flight + native fade-in freely rewrites. That is the persistent white box (white = Legendary frame). + +Fix (replaces the shell-gate subsystem entirely, per the no-fallback rule): + +- Shell gate removed everywhere (field, prepare/open/release/fade paths, `forceOwnedGroup`). +- `CancelPreparedAuxiliary` stale branch now KEEPS the auxParent gate closed; it is handed back by + the next `PrepareAuxiliary` reuse, by `ReleasePrepared` when a foreign show takes the + controller, or dies with the despawned controller. +- Prepare reuses a matching held gate closed (`SetAlpha(0f)`) instead of restore-then-recreate. +- Architecture tests updated: `Preparing_an_auxiliary_gates_auxparent_and_reuses_a_held_gate`, + cancel test now asserts `RestorePreparedAuxiliaryGate` is absent from the cancel path. + +Verification: same acceptance as previous rounds (rapid re-hovers on the Legendary card, no white +box, details render; log must stay free of degradations). Known residual: if some native path +raises the auxiliary's visibility without an `AuxiliaryTooltipController.ShowAuxiliaryTooltipController` +call (which is what hands the gate back via ReleasePrepared), the native tooltip could stay blank — +no such path is known; if a blank NATIVE auxiliary tooltip is ever observed, start there. + +### 2026-08-05 (fifth round): user correction — the box is a single-frame flash + +User observation: the white box is not persistent; it is a sudden one-frame(-ish) flash. This +identifies the remaining mechanism as the deferred-destroy corpse class: + +1. On every matched show for a controller that still carries prepared state, the feature handler + first calls `ReleasePrepared` → `CanvasGroupGate.Restore()` sets the owned group back to + alpha 1 and calls `Object.Destroy` — which only takes effect at END of frame. +2. The immediately following `PrepareAuxiliary` recreates the gate via `GetComponent`, which + returns the still-attached corpse; the new gate closes it (alpha 0) for this frame. +3. At frame end the corpse is destroyed, silently taking the gate with it. From the next frame + auxParent is UNGATED, the native fade-in raises the serialized alpha, and the tooltip shell + (white Legendary frame + header) is visible until `TryOpen` takes over. + +The removed shell gate had incidentally masked the misdiagnosis of this chain; removing it exposed +the corpse-adoption hole directly. Fix: owned gates now `Object.DestroyImmediate` on restore — +the corpse never survives into a GetComponent, which eliminates the whole deferred-destroy class +(corpse adoption, end-of-frame exposure after restore, same-frame duplicate groups). Locked by +architecture assertions (`Object.DestroyImmediate(_group)` required, deferred variant forbidden). diff --git a/src/BazaarPlusPlus/Game/PostCombatImpact/Data/CombatImpactMetricFormatter.cs b/src/BazaarPlusPlus/Game/PostCombatImpact/Data/CombatImpactMetricFormatter.cs index 92382387..cbfac2ae 100644 --- a/src/BazaarPlusPlus/Game/PostCombatImpact/Data/CombatImpactMetricFormatter.cs +++ b/src/BazaarPlusPlus/Game/PostCombatImpact/Data/CombatImpactMetricFormatter.cs @@ -211,7 +211,8 @@ string nativeAttributeKey && !( surface == CombatImpactEventSurface.AppliedEffect && nativeAttributeKey - is "TempoRemoveAmount" + is "RegenApplyAmount" + or "TempoRemoveAmount" or "BurnRemoveAmount" or "PoisonRemoveAmount" or "RegenRemoveAmount" diff --git a/src/BazaarPlusPlus/Game/PostCombatImpact/PostCombatImpactController.cs b/src/BazaarPlusPlus/Game/PostCombatImpact/PostCombatImpactController.cs index 23b3070e..5bd9405e 100644 --- a/src/BazaarPlusPlus/Game/PostCombatImpact/PostCombatImpactController.cs +++ b/src/BazaarPlusPlus/Game/PostCombatImpact/PostCombatImpactController.cs @@ -20,6 +20,7 @@ internal sealed class PostCombatImpactController : MonoBehaviour private const int PositionedGeometrySettleMaxFrames = 30; private const int RequiredStableGeometrySamples = 3; private const float GeometryEpsilon = 0.5f; + private const int MaxTransientShowRetries = 2; private readonly WaitForEndOfFrame _waitForEndOfFrame = new(); private PostCombatImpactModule? _module; @@ -38,6 +39,8 @@ internal sealed class PostCombatImpactController : MonoBehaviour private string? _selectedSourceId; private int _hoverRevision; private int _suppressedHoverRevision = -1; + private int _transientShowRetryRevision = -1; + private int _transientShowRetries; private bool _nativeAuxiliaryTakeoverActive; private CombatImpactPerspective _requestedPerspective = CombatImpactPerspective.Caused; @@ -165,8 +168,18 @@ Vector3 tooltipOffset ); CancelAndClearPresentation(preserveOutstandingAuxiliaryRequest: true); if (!sameHover) - LogInteraction(PostCombatImpactReasonCode.RecapHoverObserved); + LogInteractionTrace( + PostCombatImpactReasonCode.RecapHoverObserved, + card.TemplateId, + detail: "hover" + ); StartPendingShow(); + if (_pendingShow == null && _settlePresentation == null) + LogInteractionTrace( + PostCombatImpactReasonCode.PendingShowBlocked, + card.TemplateId, + DescribePendingShowBlock() + ); TryPrepareCurrentPrimaryTooltip(card); } @@ -205,10 +218,15 @@ or PostCombatImpactHoverExitOrigin.SkillPointerExit ); return; } + var dismissedTemplateId = _hoveredRequest.Card.TemplateId; ClearHoveredSource(); CancelAndClearPresentation(preserveOutstandingAuxiliaryRequest: true); if (hadWork) - LogInteraction(PostCombatImpactReasonCode.Dismissed); + LogInteractionTrace( + PostCombatImpactReasonCode.Dismissed, + dismissedTemplateId, + detail: "pointer_exit" + ); } private System.Collections.IEnumerator DismissAfterLockedPointerExit( @@ -226,10 +244,15 @@ bool hadWork ) yield break; + var dismissedTemplateId = _hoveredRequest.Card.TemplateId; ClearHoveredSource(); CancelAndClearPresentation(preserveOutstandingAuxiliaryRequest: true); if (hadWork) - LogInteraction(PostCombatImpactReasonCode.Dismissed); + LogInteractionTrace( + PostCombatImpactReasonCode.Dismissed, + dismissedTemplateId, + detail: "locked_pointer_exit" + ); } private void CancelPendingHoverExit() @@ -262,6 +285,11 @@ private System.Collections.IEnumerator ShowWhenReady(HoverRequest request, int r if (!IsCurrentHover(request, revision) || !IsRecapOpen()) { + LogInteractionTrace( + PostCombatImpactReasonCode.PendingShowAborted, + request.Card.TemplateId, + IsRecapOpen() ? "hover_changed" : "recap_closed" + ); _pendingShow = null; yield break; } @@ -481,6 +509,28 @@ private System.Collections.IEnumerator ShowWhenReady(HoverRequest request, int r if (!shown) { + if ( + IsCurrentHover(request, revision) + && IsRecapOpen() + && TryConsumeTransientShowRetry(revision) + ) + { + // TryOpen fails soft when the native controller's Destroy is already pending — a + // state no managed liveness check can see before frame end. Discard the dying + // auxiliary and re-request a fresh one instead of suppressing the still-valid + // hover. + var deadAuxiliary = _pendingAuxiliaryController; + if (deadAuxiliary != null) + { + _view.CancelPreparedNativeAuxiliary(deadAuxiliary); + _pendingAuxiliaryController = null; + tooltipParent.HideAuxiliaryTooltipController(); + } + _pendingShow = null; + LogInteraction(PostCombatImpactReasonCode.AuxiliaryTooltipShowRetried); + StartPendingShow(); + yield break; + } FailPendingShow( revision, PostCombatImpactReasonCode.AuxiliaryTooltipContentUnavailable, @@ -646,10 +696,12 @@ bool hasAttributedImpact } _settlePresentation = null; - LogInteraction( + LogInteractionTrace( !hasAttributedImpact ? PostCombatImpactReasonCode.ShownWithoutAttributedImpact - : PostCombatImpactReasonCode.Shown + : PostCombatImpactReasonCode.Shown, + request.Card.TemplateId, + detail: "settled" ); } @@ -769,6 +821,17 @@ string header return; } + // A show carrying this feature's own header that fails the request match is the orphan + // hazard: the prepared concealment was just handed back and nothing re-conceals it. + if (_view != null && string.Equals(header, _view.Header, StringComparison.Ordinal)) + LogInteractionTrace( + PostCombatImpactReasonCode.NativeAuxiliaryUnmatched, + _hoveredRequest?.Card.TemplateId ?? Guid.Empty, + !_auxiliaryRequestOutstanding ? "no_outstanding_request" + : !ReferenceEquals(_pendingAuxiliaryAnchor, anchor) ? "anchor_mismatch" + : "header_mismatch" + ); + if (_pendingShow != null || _settlePresentation != null) { StopPendingShow(hidePrimary: true, preserveOutstandingAuxiliaryRequest: true); @@ -872,6 +935,29 @@ private void ClearHoveredSource() private bool IsCurrentHover(HoverRequest request, int revision) => revision == _hoverRevision && ReferenceEquals(_hoveredRequest, request); + private string DescribePendingShowBlock() => + _hoveredRequest == null ? "no_hover" + : _suppressedHoverRevision == _hoverRevision ? "suppressed" + : _auxiliaryRequestOutstanding ? "outstanding_request" + : "unknown"; + + /// + /// Grants a bounded number of same-hover re-requests after a transient show failure, so a + /// persistent failure still ends in suppression instead of a hide/show loop. + /// + private bool TryConsumeTransientShowRetry(int revision) + { + if (_transientShowRetryRevision != revision) + { + _transientShowRetryRevision = revision; + _transientShowRetries = 0; + } + if (_transientShowRetries >= MaxTransientShowRetries) + return false; + _transientShowRetries++; + return true; + } + private void TryPrepareCurrentPrimaryTooltip(Card card) { if ( @@ -1036,6 +1122,21 @@ private static void LogInteraction(PostCombatImpactReasonCode reasonCode) => () => [PostCombatImpactLogEvents.ReasonCode.Bind(reasonCode)] ); + private static void LogInteractionTrace( + PostCombatImpactReasonCode reasonCode, + Guid cardTemplateId, + string detail + ) => + BppLog.DebugEvent( + PostCombatImpactLogEvents.InteractionTraced, + () => + [ + PostCombatImpactLogEvents.ReasonCode.Bind(reasonCode), + PostCombatImpactLogEvents.Card.Bind(cardTemplateId), + PostCombatImpactLogEvents.Detail.Bind(detail), + ] + ); + private static void LogInteractionFailure( PostCombatImpactReasonCode reasonCode, Exception exception diff --git a/src/BazaarPlusPlus/Game/PostCombatImpact/PostCombatImpactLogEvents.cs b/src/BazaarPlusPlus/Game/PostCombatImpact/PostCombatImpactLogEvents.cs index a928d7c8..3bac82e6 100644 --- a/src/BazaarPlusPlus/Game/PostCombatImpact/PostCombatImpactLogEvents.cs +++ b/src/BazaarPlusPlus/Game/PostCombatImpact/PostCombatImpactLogEvents.cs @@ -14,7 +14,13 @@ internal enum PostCombatImpactReasonCode PrimaryTooltipCreateTimedOut, AuxiliaryTooltipCreateTimedOut, AuxiliaryTooltipContentUnavailable, + AuxiliaryTooltipShowRetried, AuxiliaryTooltipPositionUnavailable, + TypographyUnavailable, + PairOpenMissingAuxiliaryFields, + PairOpenDyingController, + PairOpenMissingBackground, + PairOpenBackgroundCloneRejected, NativeAuxiliaryDisplaced, NativeAuxiliaryHidden, NativeAuxiliaryRequeued, @@ -22,6 +28,9 @@ internal enum PostCombatImpactReasonCode Dismissed, RecapHoverObserved, StaleRequestDiscarded, + PendingShowBlocked, + PendingShowAborted, + NativeAuxiliaryUnmatched, PairPlacementOverflowed, PairPlacementTooNarrow, PairTopAlignmentAdjusted, @@ -64,6 +73,33 @@ internal static class PostCombatImpactLogEvents [ReasonCode] ); + internal static readonly BppLogFieldDefinition Card = new( + 1, + "card", + BppLogFieldPrivacy.Public, + BppLogCorrelationPolicy.None, + BppLogCardinality.High + ); + + internal static readonly BppLogFieldDefinition Detail = new( + 2, + "detail", + BppLogFieldPrivacy.Public, + BppLogCorrelationPolicy.None, + BppLogCardinality.Low + ); + + /// + /// Card-identified interaction trail. Exists because the plain observed trail proved unable + /// to attribute silent per-card failures: it shows a hover with no outcome but not which card + /// or which silent gate swallowed it. + /// + internal static readonly BppLogEventDefinition InteractionTraced = new( + BppLogFeatureScope.PostCombatImpact, + "post_combat_impact.interaction.traced", + [ReasonCode, Card, Detail] + ); + internal static readonly BppLogEventDefinition InteractionDegraded = new( BppLogFeatureScope.PostCombatImpact, "post_combat_impact.interaction.degraded", diff --git a/src/BazaarPlusPlus/GameInterop/Tooltips/NativePairedTooltipContracts.cs b/src/BazaarPlusPlus/GameInterop/Tooltips/NativePairedTooltipContracts.cs index 6d17e753..7d3635c3 100644 --- a/src/BazaarPlusPlus/GameInterop/Tooltips/NativePairedTooltipContracts.cs +++ b/src/BazaarPlusPlus/GameInterop/Tooltips/NativePairedTooltipContracts.cs @@ -13,6 +13,27 @@ internal enum PairSide Left, } +/// +/// Why the session's TryOpen reported the native pair as unusable. The host stays log-free; the +/// consuming feature owns turning these into its own reason codes. +/// +internal enum NativePairedTooltipOpenFailure +{ + None, + + /// The auxiliary controller is missing auxParent/header/body fields. + MissingAuxiliaryFields, + + /// Gate creation failed — the controller's Destroy is already pending. + DyingController, + + /// The auxiliary or primary background image/sprite is unavailable. + MissingBackground, + + /// The native background clone could not be built from the primary. + BackgroundCloneRejected, +} + /// /// Shared tolerance for the paired-tooltip host. /// diff --git a/src/BazaarPlusPlus/GameInterop/Tooltips/NativePairedTooltipHost.cs b/src/BazaarPlusPlus/GameInterop/Tooltips/NativePairedTooltipHost.cs index bdc4147b..ca7512c5 100644 --- a/src/BazaarPlusPlus/GameInterop/Tooltips/NativePairedTooltipHost.cs +++ b/src/BazaarPlusPlus/GameInterop/Tooltips/NativePairedTooltipHost.cs @@ -156,15 +156,35 @@ internal void PrepareAuxiliary(AuxiliaryTooltipController auxiliary) if (_activeAuxiliary != null || _contentRoot != null) Release(restoreNativeContent: false); RestorePreparedNativeHost(); - RestorePreparedAuxiliaryGate(); _preparedNativeHost = NativeAuxiliaryHostState.Capture(auxiliary); - if (auxiliary.auxParent != null) + // auxParent (Tooltip_Aux_Content in the Tooltip_Aux_P prefab) owns the COMPLETE visual + // tree — Background, TitleText, BodyText and Divider are its children — so this gate is + // the one effective concealment. Do not gate the controller root instead: the root is a + // world-space Transform above the prefab's own nested Canvas (Tooltip_Aux_Canvas), and a + // CanvasGroup there does not propagate into the canvas at all. + // + // When the native controller is reused for a rapid re-request, the previous cancellation + // deliberately left this gate closed. Reuse it as-is: restore-then-recreate briefly puts + // the restored group (alpha 1) back on the object during the same frame — Destroy is + // deferred to frame end — which renders the shell for one frame. + if ( + _preparedAuxiliaryGate != null + && ReferenceEquals(_preparedAuxiliaryGate.Controller, auxiliary) + ) { - _preparedAuxiliaryGate = CanvasGroupGate.Create( - auxiliary, - auxiliary.auxParent.gameObject, - forceNonInteractive: true - ); + _preparedAuxiliaryGate.SetAlpha(0f); + } + else + { + RestorePreparedAuxiliaryGate(); + if (auxiliary.auxParent != null) + { + _preparedAuxiliaryGate = CanvasGroupGate.Create( + auxiliary, + auxiliary.auxParent.gameObject, + forceNonInteractive: true + ); + } } } @@ -176,13 +196,18 @@ internal void CancelPreparedAuxiliary(AuxiliaryTooltipController auxiliary) ) return; + // Every current caller hands this BPP-owned request straight back to the native hide + // sequence. Keep the auxParent gate CLOSED through that teardown: it is the only + // concealment the native fade cannot rewrite (the serialized CanvasGroup set to zero here + // is re-raised whenever the native fade-in is still in flight), and every visual child — + // Background, TitleText, BodyText, Divider — lives under auxParent. The gate is handed + // back by the next PrepareAuxiliary reuse, by ReleasePrepared when a foreign show takes + // the controller, or it dies with the despawned controller. + ConcealNativeAuxiliary(auxiliary); if (ReferenceEquals(_activeAuxiliary, auxiliary)) Release(restoreNativeContent: false); else - { - RestorePreparedNativeHost(); - RestorePreparedAuxiliaryGate(); - } + RestorePreparedNativeHost(restoreContentVisibility: false); } /// @@ -193,8 +218,11 @@ internal void CancelPreparedAuxiliary(AuxiliaryTooltipController auxiliary) /// only prepared. Skipping this leaves the native layout's padding and anchors permanently /// rewritten. /// - internal void ReleasePrepared(AuxiliaryTooltipController controller) => + internal void ReleasePrepared(AuxiliaryTooltipController controller) + { RestorePreparedNativeHost(restoreContentVisibility: true, expectedController: controller); + RestorePreparedAuxiliaryGate(expectedController: controller); + } // ── Open / content ───────────────────────────────────────────────────────────────────── @@ -206,15 +234,20 @@ internal void ReleasePrepared(AuxiliaryTooltipController controller) => internal bool TryOpen( AuxiliaryTooltipController auxiliary, CardTooltipController primary, - NativePairedTooltipOptions options + NativePairedTooltipOptions options, + out NativePairedTooltipOpenFailure failure ) { + failure = NativePairedTooltipOpenFailure.None; if ( auxiliary.auxParent == null || auxiliary.headerText == null || auxiliary.bodyText == null ) + { + failure = NativePairedTooltipOpenFailure.MissingAuxiliaryFields; return false; + } if (_activeAuxiliary != null || _contentRoot != null) Release(restoreNativeContent: false); @@ -244,6 +277,18 @@ NativePairedTooltipOptions options forceNonInteractive: true ); } + if (_preparedAuxiliaryGate == null) + { + // Gate creation only fails on a controller whose Destroy is already pending; this + // takeover cannot be concealed, so report an unusable native shape and let the caller + // re-request a live controller. Conceal before Release: Release restores the gates to + // their visible originals while the native header is still active, which is exactly + // the header-only orphan this presentation must never expose. + ConcealNativeAuxiliary(auxiliary); + Release(restoreNativeContent: false); + failure = NativePairedTooltipOpenFailure.DyingController; + return false; + } _generation++; _hidePending = false; @@ -258,7 +303,9 @@ NativePairedTooltipOptions options || primary.backgroundImage.sprite == null ) { + ConcealNativeAuxiliary(auxiliary); Release(restoreNativeContent: false); + failure = NativePairedTooltipOpenFailure.MissingBackground; return false; } @@ -268,7 +315,9 @@ NativePairedTooltipOptions options ApplyContentWidth(auxiliary, options.PreferredContentWidth); if (!TryCreateNativeBackground(auxiliary, primary)) { + ConcealNativeAuxiliary(auxiliary); Release(restoreNativeContent: false); + failure = NativePairedTooltipOpenFailure.BackgroundCloneRejected; return false; } @@ -672,8 +721,14 @@ private void RestorePreparedNativeHost( _preparedNativeHost = null; } - private void RestorePreparedAuxiliaryGate() + private void RestorePreparedAuxiliaryGate(AuxiliaryTooltipController? expectedController = null) { + if ( + expectedController != null + && _preparedAuxiliaryGate != null + && !ReferenceEquals(_preparedAuxiliaryGate.Controller, expectedController) + ) + return; _preparedAuxiliaryGate?.Restore(); _preparedAuxiliaryGate = null; } @@ -1086,16 +1141,17 @@ private sealed class CanvasGroupGate private readonly bool _forceNonInteractive; private bool _restored; - private CanvasGroupGate(object controller, GameObject target, bool forceNonInteractive) + private CanvasGroupGate( + object controller, + CanvasGroup group, + bool forceNonInteractive, + bool ownedGroup + ) { Controller = controller; _forceNonInteractive = forceNonInteractive; - _group = target.GetComponent(); - if (_group == null) - { - _group = target.AddComponent(); - _ownedGroup = true; - } + _ownedGroup = ownedGroup; + _group = group; _originalAlpha = _group.alpha; _originalInteractable = _group.interactable; _originalBlocksRaycasts = _group.blocksRaycasts; @@ -1107,11 +1163,27 @@ private CanvasGroupGate(object controller, GameObject target, bool forceNonInter internal float Alpha => _group == null ? 0f : _group.alpha; - internal static CanvasGroupGate Create( + internal static CanvasGroupGate? Create( object controller, GameObject target, bool forceNonInteractive = false - ) => new(controller, target, forceNonInteractive); + ) + { + var ownedGroup = false; + var group = target.GetComponent(); + if (group == null) + { + group = target.AddComponent(); + ownedGroup = true; + } + // AddComponent refuses a target whose Destroy is pending and hands back a dead + // reference, while every other liveness check on the same object still passes until + // frame end. A dead gate cannot conceal anything, so fail creation instead of letting + // the first property read throw. + if (group == null) + return null; + return new CanvasGroupGate(controller, group, forceNonInteractive, ownedGroup); + } internal void SetAlpha(float alpha) { @@ -1134,8 +1206,13 @@ internal void Restore() _group.interactable = _originalInteractable; _group.blocksRaycasts = _originalBlocksRaycasts; _group.ignoreParentGroups = _originalIgnoreParentGroups; + // DestroyImmediate, not Destroy: a deferred destroy leaves the corpse attached until + // frame end, so a same-frame re-prepare adopts it via GetComponent and loses its gate + // when the corpse dies — the ungated native fade-in then flashes the tooltip shell. + // The restore-to-original alpha above is also only safe when the component leaves the + // hierarchy within the same frame. if (_ownedGroup) - Object.Destroy(_group); + Object.DestroyImmediate(_group); } } diff --git a/src/BazaarPlusPlus/Patches/PostCombatImpact/NativePostCombatImpactTooltipView.cs b/src/BazaarPlusPlus/Patches/PostCombatImpact/NativePostCombatImpactTooltipView.cs index d82eadec..1421da1a 100644 --- a/src/BazaarPlusPlus/Patches/PostCombatImpact/NativePostCombatImpactTooltipView.cs +++ b/src/BazaarPlusPlus/Patches/PostCombatImpact/NativePostCombatImpactTooltipView.cs @@ -126,13 +126,20 @@ CombatImpactPerspective perspective ) { if (!IsTypographyReadyForCurrentLocale()) + { + LogShowRejected(PostCombatImpactReasonCode.TypographyUnavailable); return false; + } // The host owns taking over the native pair; it self-cleans and returns false (never // throws) so the caller keeps reporting AuxiliaryTooltipContentUnavailable rather than - // TooltipRenderException. - if (!_session.TryOpen(auxiliary, primary, PairedOptions)) + // TooltipRenderException. The failure detail is logged here because these were the only + // silent dismissal paths in the whole presentation. + if (!_session.TryOpen(auxiliary, primary, PairedOptions, out var openFailure)) + { + LogShowRejected(MapOpenFailure(openFailure)); return false; + } var generation = _session.Generation; _receivedPerspectiveAvailable = received != null; @@ -1365,6 +1372,28 @@ private static LayoutElement AddLayout( return layout; } + private static void LogShowRejected(PostCombatImpactReasonCode reasonCode) => + BppLog.WarnEvent( + PostCombatImpactLogEvents.InteractionDegraded, + PostCombatImpactLogEvents.ReasonCode.Bind(reasonCode) + ); + + private static PostCombatImpactReasonCode MapOpenFailure( + NativePairedTooltipOpenFailure failure + ) => + failure switch + { + NativePairedTooltipOpenFailure.MissingAuxiliaryFields => + PostCombatImpactReasonCode.PairOpenMissingAuxiliaryFields, + NativePairedTooltipOpenFailure.DyingController => + PostCombatImpactReasonCode.PairOpenDyingController, + NativePairedTooltipOpenFailure.MissingBackground => + PostCombatImpactReasonCode.PairOpenMissingBackground, + NativePairedTooltipOpenFailure.BackgroundCloneRejected => + PostCombatImpactReasonCode.PairOpenBackgroundCloneRejected, + _ => PostCombatImpactReasonCode.AuxiliaryTooltipContentUnavailable, + }; + private static void LogPlacementDegradationOnce( bool degraded, ref bool wasLogged, diff --git a/tests/Architecture.Tests/NativePairedTooltipArchitectureTests.cs b/tests/Architecture.Tests/NativePairedTooltipArchitectureTests.cs index e47834ae..1fd78316 100644 --- a/tests/Architecture.Tests/NativePairedTooltipArchitectureTests.cs +++ b/tests/Architecture.Tests/NativePairedTooltipArchitectureTests.cs @@ -118,6 +118,214 @@ public void Combat_impact_tooltip_view_consumes_the_shared_session() Assert.DoesNotContain("CleanupCustomContent", view, StringComparison.Ordinal); } + [Fact] + public void Cancelling_a_prepared_auxiliary_keeps_it_concealed_until_native_teardown() + { + var host = File.ReadAllText( + Path.Combine( + MainSourceRoot(RepoRoot()), + "GameInterop", + "Tooltips", + "NativePairedTooltipHost.cs" + ) + ); + var methodStart = host.IndexOf( + "internal void CancelPreparedAuxiliary", + StringComparison.Ordinal + ); + var methodEnd = host.IndexOf( + "internal void ReleasePrepared", + methodStart, + StringComparison.Ordinal + ); + + Assert.True(methodStart >= 0 && methodEnd > methodStart); + var method = host[methodStart..methodEnd]; + Assert.Contains("ConcealNativeAuxiliary(auxiliary);", method, StringComparison.Ordinal); + Assert.Contains( + "RestorePreparedNativeHost(restoreContentVisibility: false);", + method, + StringComparison.Ordinal + ); + // The auxParent gate is the only concealment the native fade cannot rewrite; the stale + // cancel path must hand it back via reuse/ReleasePrepared/despawn, never restore it here. + Assert.DoesNotContain("RestorePreparedAuxiliaryGate", method, StringComparison.Ordinal); + Assert.True( + method.IndexOf("ConcealNativeAuxiliary(auxiliary);", StringComparison.Ordinal) + < method.IndexOf( + "RestorePreparedNativeHost(restoreContentVisibility: false);", + StringComparison.Ordinal + ) + ); + } + + /// + /// In the Tooltip_Aux_P prefab, auxParent (Tooltip_Aux_Content) owns the complete visual tree + /// (Background, TitleText, BodyText, Divider), while the controller root is a world-space + /// Transform ABOVE the prefab's nested Canvas — a CanvasGroup there does not propagate into + /// the canvas. The prepare gate must therefore target auxParent, never the controller root, + /// and a matching still-held gate must be reused closed instead of restore-then-recreate + /// (Destroy is deferred to frame end, so restoring first renders the shell for one frame). + /// + [Fact] + public void Preparing_an_auxiliary_gates_auxparent_and_reuses_a_held_gate() + { + var host = File.ReadAllText( + Path.Combine( + MainSourceRoot(RepoRoot()), + "GameInterop", + "Tooltips", + "NativePairedTooltipHost.cs" + ) + ); + var methodStart = host.IndexOf("internal void PrepareAuxiliary", StringComparison.Ordinal); + var methodEnd = host.IndexOf( + "internal void CancelPreparedAuxiliary", + methodStart, + StringComparison.Ordinal + ); + + Assert.True(methodStart >= 0 && methodEnd > methodStart); + var method = host[methodStart..methodEnd]; + Assert.Contains("auxiliary.auxParent.gameObject", method, StringComparison.Ordinal); + Assert.DoesNotContain("auxiliary.gameObject,", method, StringComparison.Ordinal); + var reuseCheck = method.IndexOf( + "ReferenceEquals(_preparedAuxiliaryGate.Controller, auxiliary)", + StringComparison.Ordinal + ); + var reuseClose = method.IndexOf( + "_preparedAuxiliaryGate.SetAlpha(0f);", + StringComparison.Ordinal + ); + var recreate = method.IndexOf("RestorePreparedAuxiliaryGate();", StringComparison.Ordinal); + Assert.True( + reuseCheck >= 0 && reuseClose > reuseCheck && recreate > reuseClose, + "a matching held gate must be reused closed before the restore-then-recreate fallback" + ); + } + + /// + /// AddComponent on a controller whose Destroy is already pending hands back a dead reference + /// while every other liveness check still passes that frame. Gate creation must fail soft and + /// TryOpen must report an unusable shape instead of throwing into the feature's catch-all. + /// + [Fact] + public void Gate_creation_fails_soft_and_open_reports_a_dying_controller_as_unusable() + { + var host = File.ReadAllText( + Path.Combine( + MainSourceRoot(RepoRoot()), + "GameInterop", + "Tooltips", + "NativePairedTooltipHost.cs" + ) + ); + + var createStart = host.IndexOf( + "internal static CanvasGroupGate? Create(", + StringComparison.Ordinal + ); + Assert.True(createStart >= 0, "gate creation must be able to report failure as null"); + + // Owned gates must die immediately on restore: a deferred Destroy leaves the corpse + // attached until frame end, a same-frame re-prepare adopts it via GetComponent, and the + // gate silently disappears with the corpse — the ungated native fade then flashes the + // tooltip shell. + Assert.Contains("Object.DestroyImmediate(_group);", host, StringComparison.Ordinal); + Assert.DoesNotContain("Object.Destroy(_group);", host, StringComparison.Ordinal); + var addComponent = host.IndexOf( + "target.AddComponent();", + createStart, + StringComparison.Ordinal + ); + var failSoft = host.IndexOf("return null;", addComponent, StringComparison.Ordinal); + Assert.True(addComponent > createStart && failSoft > addComponent); + + var openStart = host.IndexOf("internal bool TryOpen(", StringComparison.Ordinal); + var openEnd = host.IndexOf( + "internal bool AttachContent(", + openStart, + StringComparison.Ordinal + ); + Assert.True(openStart >= 0 && openEnd > openStart); + var open = host[openStart..openEnd]; + var nullGateCheck = open.IndexOf( + "if (_preparedAuxiliaryGate == null)", + StringComparison.Ordinal + ); + Assert.True(nullGateCheck >= 0, "TryOpen must check the auxiliary gate for dead targets"); + var conceal = open.IndexOf( + "ConcealNativeAuxiliary(auxiliary);", + nullGateCheck, + StringComparison.Ordinal + ); + var rollback = open.IndexOf( + "Release(restoreNativeContent: false);", + nullGateCheck, + StringComparison.Ordinal + ); + var reportUnusable = open.IndexOf("return false;", nullGateCheck, StringComparison.Ordinal); + Assert.True( + conceal > nullGateCheck && rollback > conceal && reportUnusable > rollback, + "a failed open must conceal the native auxiliary before Release restores the gates" + ); + + // Every rollback branch in TryOpen (gate death, missing background, background clone) + // must conceal immediately before Release: Release restores the gates to their visible + // originals, which would otherwise expose the header-only native shell. + var concealThenRelease = + "ConcealNativeAuxiliary(auxiliary);\n Release(restoreNativeContent: false);"; + var pairCount = 0; + for ( + var index = open.IndexOf(concealThenRelease, StringComparison.Ordinal); + index >= 0; + index = open.IndexOf(concealThenRelease, index + 1, StringComparison.Ordinal) + ) + pairCount++; + Assert.True( + pairCount >= 3, + "each TryOpen rollback must conceal the native auxiliary before Release" + ); + } + + /// + /// A transient open failure (dying native controller) must re-request the auxiliary tooltip + /// while the hover is still valid; suppressing until pointer exit is reserved for the retry + /// budget running out. + /// + [Fact] + public void Transient_show_failure_retries_before_suppressing_the_hover() + { + var controller = File.ReadAllText( + Path.Combine( + MainSourceRoot(RepoRoot()), + "Game", + "PostCombatImpact", + "PostCombatImpactController.cs" + ) + ); + + var notShown = controller.IndexOf("if (!shown)", StringComparison.Ordinal); + Assert.True(notShown >= 0); + var retry = controller.IndexOf( + "TryConsumeTransientShowRetry(revision)", + notShown, + StringComparison.Ordinal + ); + var requeue = controller.IndexOf("StartPendingShow();", notShown, StringComparison.Ordinal); + var suppress = controller.IndexOf( + "PostCombatImpactReasonCode.AuxiliaryTooltipContentUnavailable", + notShown, + StringComparison.Ordinal + ); + Assert.True(retry > notShown, "the not-shown path must consult the transient retry budget"); + Assert.True(requeue > retry, "a granted retry must requeue the pending show"); + Assert.True( + suppress > requeue, + "suppression must remain the fallback after the retry budget" + ); + } + /// /// Group titles use the generic text clone, whose safe default is Ellipsis. These controlled /// product labels must override that default so vertical pressure cannot replace them with an diff --git a/tests/PostCombatImpact.Tests/CombatImpactMetricFormatterTests.cs b/tests/PostCombatImpact.Tests/CombatImpactMetricFormatterTests.cs index d6202610..46e64e08 100644 --- a/tests/PostCombatImpact.Tests/CombatImpactMetricFormatterTests.cs +++ b/tests/PostCombatImpact.Tests/CombatImpactMetricFormatterTests.cs @@ -134,11 +134,11 @@ public void Critical_counts_use_the_native_icon_inside_the_event_count() }; Assert.Equal( - "×2 (1) · +12 total", + "×2 (1) · 12 total", CombatImpactMetricFormatter.Group(appliedRegen, chinese: false, CritIcon) ); Assert.Equal( - "×2(1) · 总计 +12", + "×2(1) · 总计 12", CombatImpactMetricFormatter.Group(appliedRegen, chinese: true, CritIcon) ); Assert.Equal(