Two defects the owner found at the CA5 drive, one shared theme: the data was right and the presentation seam was dead. #430 (tooltips): the TS-85 Batch-B port set runtime TooltipText on the runtime-built attribute/vital/skill rows but never gave them a popup locator, and RetailTooltipPresenter.OnTooltipShow refuses any widget with AuthoredTooltipRootElementId == 0 — the tooltip could never mount, on any row, ever. (The register's 'live-verified on the Character tab' was the OPTIONS panel's Character tab — authored elements with authored locators; a different surface.) Rows now carry the shared popup skin 0x10000395/0x21000041 — live-DAT probed as the ONLY locator pair the character layout references, and the same inference UiItemSlot already ships for runtime-built widgets. TS-85's row carries the dated correction. #440 (train row stuck): training a skill debited credits on screen but left the row in the untrained section until the NEXT click — because the sheet-changed subscription only refreshed the captured sheet, and row STRUCTURE rebuilt exclusively in click handlers (the raise 'completed' callback runs after SEND, before the server answers; the owner's second click was simply the first rebuild after the record landed, and ACE's rejection of that second train — 'Failed to train', no credit change — matches the owner's report exactly). The same gap kept CA4's awaiting-ghost from visually releasing. CharacterStatController.Bind now returns the data-changed refresh and MountCharacter invokes it on every authoritative sheet change, mirroring retail's quality-change broadcast (InfoRegion::OnQualityChanged @ 0x004F0EB0). Pinned by DataChangedRefresh_MovesATrainedSkillToItsSection_WithoutAClick and Rows_CarryTheSharedTooltipPopupLocatorAndDescriptionText. Owner visual re-check owed next session (hover-dwell a row; train a skill and watch it move immediately). Full hermetic suite 15,329 passed / 0 failed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
bd943849b1
commit
bce17b3cfb
5 changed files with 177 additions and 7 deletions
|
|
@ -24,6 +24,34 @@ What does NOT go here:
|
|||
- Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending.
|
||||
- Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed.
|
||||
|
||||
## #440 — CLOSED: Trained skill did not move to its section (and raise buttons never visually un-ghosted) until the next click
|
||||
|
||||
**Status:** CLOSED 2026-08-24, found by the owner during the CA5 drive.
|
||||
**Component:** character panel / row refresh
|
||||
|
||||
**Symptom (owner):** training an untrained skill consumed credits (visible
|
||||
immediately) and the confirmation text appeared, but the skill stayed in
|
||||
the untrained section; clicking Train AGAIN (which the server rejects —
|
||||
"Failed to train", no credit change) made it move.
|
||||
|
||||
**Root cause:** the panel's sheet-changed subscription only refreshed the
|
||||
captured `currentSheet` — per-frame text pulls (credits, values) updated,
|
||||
but the ROW STRUCTURE (section buckets, selection, raise-button states)
|
||||
rebuilt only on clicks (`RefreshAfterRaise` ran as the raise's `completed`
|
||||
callback — synchronously after SEND, before the server's answer). The
|
||||
second click was simply the first row rebuild after the record landed.
|
||||
The same gap kept CA4's awaiting-ghost from visually releasing.
|
||||
|
||||
**Fix:** `CharacterStatController.Bind` now returns the data-changed
|
||||
refresh (`RefreshAfterRaise(null)` — rebuild + reselect + re-evaluate
|
||||
buttons), and `RetailUiRuntime.MountCharacter`'s subscription invokes it on
|
||||
every authoritative sheet change — mirroring retail's quality-change
|
||||
broadcast (`InfoRegion::OnQualityChanged @ 0x004F0EB0` →
|
||||
`ListenToElementMessage @ 0x004EFBE0`). Pinned by
|
||||
`DataChangedRefresh_MovesATrainedSkillToItsSection_WithoutAClick`.
|
||||
|
||||
---
|
||||
|
||||
## #439 — Flake candidate: LossySession_FivePercentSeeded_ZeroMessageLoss_Headroom256 fails under full parallel suite load
|
||||
|
||||
**Status:** OPEN (observation filed; do NOT chase individually per docs/release-gate.md)
|
||||
|
|
@ -419,9 +447,21 @@ immediately (F2 overlay shows guids).
|
|||
|
||||
---
|
||||
|
||||
## #430 — No tooltips on skills and attributes in the character panel
|
||||
## #430 — CLOSED: No tooltips on skills and attributes in the character panel
|
||||
|
||||
**Status:** OPEN
|
||||
**Status:** CLOSED 2026-08-24 (found during the CA5 gate follow-up).
|
||||
Root cause: the TS-85 Batch-B port set runtime `TooltipText` on the
|
||||
runtime-built rows but never gave them a popup locator, and
|
||||
`RetailTooltipPresenter.OnTooltipShow` refuses any widget with
|
||||
`AuthoredTooltipRootElementId == 0` — the tooltip could NEVER mount.
|
||||
(TS-85's "live-verified on the Character tab" was the OPTIONS panel's
|
||||
Character tab — authored elements with authored locators.) Fix: rows now
|
||||
carry the shared popup skin `0x10000395`/`0x21000041` — live-DAT probed as
|
||||
the ONLY locator pair layout `0x2100002E` references, and the same
|
||||
inference `UiItemSlot` already ships for runtime-built widgets. Pinned by
|
||||
`Rows_CarryTheSharedTooltipPopupLocatorAndDescriptionText`. Owner visual
|
||||
re-check owed at the next session (hover a row, hold the mouse still for
|
||||
the 0.25 s dwell).
|
||||
**Severity:** LOW (information affordance missing)
|
||||
**Filed:** 2026-08-23 (owner report)
|
||||
**Component:** retail UI / character panel
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -247,7 +247,17 @@ public static class CharacterStatController
|
|||
/// icon rendering is not asserted.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static void Bind(
|
||||
/// <returns>
|
||||
/// #431-CA5 gate fix (2026-08-24): the data-changed refresh. Retail's
|
||||
/// panel refreshes rows from qualities on every authoritative
|
||||
/// quality-change element message (InfoRegion::OnQualityChanged @
|
||||
/// 0x004F0EB0 → the 0x10000004 broadcast); before this, rows only
|
||||
/// rebuilt on CLICKS, so a trained skill stayed in the untrained
|
||||
/// section (and CA4's awaiting-ghost never visually released) until the
|
||||
/// next click. The caller invokes this from the sheet-changed
|
||||
/// subscription.
|
||||
/// </returns>
|
||||
public static Action Bind(
|
||||
ImportedLayout layout,
|
||||
Func<CharacterSheet> data,
|
||||
UiDatFont? datFont = null,
|
||||
|
|
@ -601,6 +611,8 @@ public static class CharacterStatController
|
|||
|
||||
RefreshActiveRaiseButtons();
|
||||
}
|
||||
|
||||
return () => RefreshAfterRaise(null);
|
||||
}
|
||||
|
||||
private static UiScrollbar? PrepareSkillScrollbar(
|
||||
|
|
@ -1502,6 +1514,16 @@ public static class CharacterStatController
|
|||
{
|
||||
var row = new UiClickablePanel
|
||||
{
|
||||
// #430: runtime-built rows author no P0x47/P0x48 of their own, and
|
||||
// OnTooltipShow refuses a widget without a popup locator — so the
|
||||
// Batch-B TooltipText never mounted. Use the shared popup skin,
|
||||
// the ONLY locator pair the character layout (0x2100002E) itself
|
||||
// references (live-DAT probed 2026-08-24) and the same inference
|
||||
// UiItemSlot already ships for runtime-built widgets.
|
||||
AuthoredTooltipRootElementId =
|
||||
RetailTooltipPresenter.SharedPopupSkinRootElementId,
|
||||
AuthoredTooltipLayoutDid =
|
||||
RetailTooltipPresenter.SharedPopupSkinLayoutDid,
|
||||
Left = left,
|
||||
Top = top,
|
||||
Width = width,
|
||||
|
|
|
|||
|
|
@ -4017,9 +4017,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
}
|
||||
CharacterSheetProvider provider = _bindings.Character.Provider;
|
||||
CharacterSheet currentSheet = provider.BuildSheet();
|
||||
_characterSheetSubscription = provider.SubscribeChanged(
|
||||
() => currentSheet = provider.BuildSheet());
|
||||
CharacterStatController.Bind(
|
||||
Action refreshRows = CharacterStatController.Bind(
|
||||
layout,
|
||||
() => currentSheet,
|
||||
_bindings.Assets.DefaultFont,
|
||||
|
|
@ -4027,6 +4025,16 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
_bindings.Assets.ResolveSprite,
|
||||
(request, completed) => HandleCharacterRaise(provider, request, completed),
|
||||
() => CloseWindow(WindowNames.Character));
|
||||
// #431-CA5 gate fix: rebuild the ROWS on every authoritative sheet
|
||||
// change, not only on clicks — a train's skill record must move the
|
||||
// row to the trained section (and un-ghost the raise controls) the
|
||||
// moment it lands, mirroring retail's quality-change broadcast
|
||||
// (InfoRegion::OnQualityChanged @ 0x004F0EB0).
|
||||
_characterSheetSubscription = provider.SubscribeChanged(() =>
|
||||
{
|
||||
currentSheet = provider.BuildSheet();
|
||||
refreshRows();
|
||||
});
|
||||
RetailWindowHandle handle = RetailWindowFrame.Mount(
|
||||
Host.Root,
|
||||
layout.Root,
|
||||
|
|
|
|||
|
|
@ -883,6 +883,106 @@ public class CharacterStatControllerTests
|
|||
Assert.Equal(Vector4.One, healingTexts[2].LinesProvider()[0].Color);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DataChangedRefresh_MovesATrainedSkillToItsSection_WithoutAClick()
|
||||
{
|
||||
// #431 CA5-gate bug: a TrainSkill's authoritative record updated the
|
||||
// sheet, but rows only rebuilt on CLICKS — the skill sat in the
|
||||
// untrained section (credits visibly debited) until the user clicked
|
||||
// train AGAIN. Bind now returns the data-changed refresh; invoking
|
||||
// it must re-bucket the rows, mirroring retail's quality-change
|
||||
// broadcast (InfoRegion::OnQualityChanged @ 0x004F0EB0).
|
||||
var list = new UiPanel { Width = 300 };
|
||||
var layout = Fake((CharacterStatController.ListBoxId, list));
|
||||
|
||||
CharacterSheet sheet = SampleData.SampleCharacter();
|
||||
Action refresh = CharacterStatController.Bind(layout, () => sheet,
|
||||
spriteResolve: id => (id, 16, 16));
|
||||
|
||||
ClickTab(layout, left: 92f);
|
||||
var untrained = sheet.Skills.First(
|
||||
s => s.AdvancementClass == CharacterSkillAdvancementClass.Untrained);
|
||||
int trainedBefore = sheet.Skills.Count(
|
||||
s => s.AdvancementClass == CharacterSkillAdvancementClass.Trained);
|
||||
Assert.Equal(trainedBefore, RowsUnderHeader(list, "Trained Skills"));
|
||||
|
||||
// The server record lands: the skill is now Trained. CharacterSheet
|
||||
// is a plain class (init-only), so rebuild it like the provider does.
|
||||
CharacterSheet updated = SampleData.SampleCharacter();
|
||||
updated.GetType(); // (no-op guard for clarity)
|
||||
sheet = new CharacterSheet
|
||||
{
|
||||
Name = sheet.Name,
|
||||
UnassignedXp = sheet.UnassignedXp,
|
||||
SkillCredits = sheet.SkillCredits,
|
||||
Skills = sheet.Skills
|
||||
.Select(s => s.Id == untrained.Id
|
||||
? s with { AdvancementClass = CharacterSkillAdvancementClass.Trained }
|
||||
: s)
|
||||
.ToList(),
|
||||
};
|
||||
|
||||
refresh();
|
||||
|
||||
Assert.Equal(trainedBefore + 1, RowsUnderHeader(list, "Trained Skills"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rows_CarryTheSharedTooltipPopupLocatorAndDescriptionText()
|
||||
{
|
||||
// #430: runtime-built rows author no popup locator, and the tooltip
|
||||
// presenter refuses a widget without one — so the row descriptions
|
||||
// could never mount. Rows must carry the shared popup skin (the only
|
||||
// locator pair the character layout itself references; the same
|
||||
// inference UiItemSlot ships) alongside their runtime text.
|
||||
var list = new UiPanel { Width = 300 };
|
||||
var layout = Fake((CharacterStatController.ListBoxId, list));
|
||||
|
||||
CharacterStatController.Bind(layout, SampleData.SampleCharacter,
|
||||
spriteResolve: id => (id, 16, 16));
|
||||
|
||||
var attributeRows = list.Children.OfType<UiClickablePanel>().ToList();
|
||||
Assert.NotEmpty(attributeRows);
|
||||
Assert.All(attributeRows, row =>
|
||||
{
|
||||
Assert.Equal(
|
||||
RetailTooltipPresenter.SharedPopupSkinRootElementId,
|
||||
row.AuthoredTooltipRootElementId);
|
||||
Assert.Equal(
|
||||
RetailTooltipPresenter.SharedPopupSkinLayoutDid,
|
||||
row.AuthoredTooltipLayoutDid);
|
||||
});
|
||||
// The six attribute rows carry the retail descriptions.
|
||||
Assert.All(
|
||||
attributeRows.Take(6),
|
||||
row => Assert.False(string.IsNullOrEmpty(row.GetTooltipText())));
|
||||
}
|
||||
|
||||
private static int RowsUnderHeader(UiElement list, string header)
|
||||
{
|
||||
// The rows live inside a nested content container; find the
|
||||
// container whose ordered children interleave section headers and
|
||||
// rows, then count rows between the named header and the next one.
|
||||
static bool IsHeader(UiElement c, string text) =>
|
||||
c is UiPanel and not UiClickablePanel
|
||||
&& c.Children.OfType<UiText>().Any(
|
||||
x => x.LinesProvider()[0].Text == text);
|
||||
UiElement? container = new[] { list }.Concat(Descendants(list))
|
||||
.FirstOrDefault(e => e.Children.Any(c => IsHeader(c, header)));
|
||||
Assert.NotNull(container);
|
||||
var children = container!.Children.ToList();
|
||||
int start = children.FindIndex(c => IsHeader(c, header));
|
||||
Assert.True(start >= 0, $"header '{header}' not found");
|
||||
int count = 0;
|
||||
for (int i = start + 1; i < children.Count; i++)
|
||||
{
|
||||
if (children[i] is UiClickablePanel) count++;
|
||||
else if (children[i] is UiPanel p
|
||||
&& p.Children.OfType<UiText>().Any()) break;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SkillsTab_ClickThenAttributesTab_RestoresAttributeRows()
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue