fix #FA4-mechanism-MUST-FIX-1,4: truncate (not round) the XP-share percentage; port the world->panel fellow-selection sync
MUST-FIX 1 -- D5's percentage conversion rounds where retail truncates. gmFellowshipUI::UpdateFellowStats @0x0048ECC9 forms pct*100.0f on the x87 stack then calls _ftol2 (MSVC's round-to-truncate helper), never MathF.Round. The stored floats for 6 and 8 fellows are 0.44999998807907104 and 0.3499999940395355 (byte-read from the PDB-paired binary), so retail's own products truncate to 44/34, not 45/35 -- and (int)(pct*100f) alone does not fix it, since 0.45f*100f already rounds UP to exactly 45.0f in single precision. Fixed as (int)((double)pct * 100.0), forming the product the same wider-than-single-precision way retail's x87 does. Pinned with new [InlineData] cases for both sizes. MUST-FIX 4 -- gmFellowshipUI::UpdateFellowSelection @0x0048F0F0 (the world->panel arm of retail's two-directional selection coupling) was never ported; only the panel->world arm (SelectFellow) shipped. Selecting a fellow in the WORLD left Dismiss/Assign-Leader disabled and showed no row highlight. SyncSelectionFromWorld/SetSelectedFellow reproduce the observable contract (button-enable + a row tint) against this controller's own guid-keyed row dictionary instead of porting retail's generic ListBox SetAttribute_InstanceID/SetSelectedItem primitive (scoped disposition recorded at register row AD-82). Also in this pass over the controller: - SF-1: cache the fellowship-name LinesProvider; only reassign on an actual name change (was allocating once per Tick, even while hidden). - SF-2/SF-3: track true membership in _memberGuids, independent of which rows finished building. Fixes an unbounded DAT-locked rebuild retry when a row template permanently fails to build, and fixes Recruit's "already a fellow" check reading render rows instead of membership. - N-0: the Open/Close caption now flips optimistically on click, matching retail's pre-toggle-before-server-echo (lane B feature 11). - N-1/N-2/N-3: doc-only notes on the meter-child-text gap, the max>0 guard, and Tick's two-read non-atomicity. - ResetPageVisibleLatch: the fellowship-controller half of MUST-FIX 3 (see the SocialPanelController commit for the panel-level half). Per docs/research/2026-08-12-fa4-review-mechanism.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
290f9b584a
commit
5499f0581f
2 changed files with 424 additions and 25 deletions
|
|
@ -61,7 +61,26 @@ namespace AcDream.App.UI.Layout;
|
|||
/// leadership in the row at all. Lacking a decompiled anchor either way,
|
||||
/// this controller tints the leader's name text a distinct gold
|
||||
/// (<see cref="LeaderNameColor"/>) as a minimal, clearly-adaptive visual
|
||||
/// cue rather than inventing a DAT mechanism that was not found.
|
||||
/// cue rather than inventing a DAT mechanism that was not found — register
|
||||
/// row AD-82.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>World→panel selection (fix-round MUST-FIX 4).</b>
|
||||
/// <c>gmFellowshipUI::UpdateFellowSelection @0x0048F0F0</c> is retail's
|
||||
/// REVERSE arm of <see cref="SelectFellow"/>'s panel-to-world direction:
|
||||
/// selecting a fellow in the 3D world (or by any other selection origin)
|
||||
/// selects their panel row too, enabling Dismiss/Assign-Leader. Retail
|
||||
/// keys row identity via a generic ListBox primitive
|
||||
/// (<c>SetAttribute_InstanceID(row, 0x1000000D, fellowIid)</c> +
|
||||
/// <c>UIElement_ListBox::SetSelectedItem</c>) that acdream's
|
||||
/// <see cref="UiTemplateListBox"/> does not port (register row AD-82 —
|
||||
/// "no Flush, no selection model, no per-row instance-id",
|
||||
/// docs/research/2026-08-11-fa-panel-structure.md §6.6). <see cref="Tick"/>
|
||||
/// reproduces the OBSERVABLE behavior — button-enable + a visual row
|
||||
/// highlight — against this controller's OWN guid-keyed row dictionary
|
||||
/// (<see cref="_rows"/>) via <see cref="SyncSelectionFromWorld"/> instead of
|
||||
/// a generic ListBox selection model; see AD-82 for what's deferred.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class SocialFellowshipPageController
|
||||
|
|
@ -110,6 +129,23 @@ public sealed class SocialFellowshipPageController
|
|||
private static readonly Vector4 LeaderNameColor = new(1f, 0.84f, 0f, 1f);
|
||||
private static readonly Vector4 MemberNameColor = Vector4.One;
|
||||
|
||||
/// <summary>Fix-round MUST-FIX 4 — the panel-local "this row is selected"
|
||||
/// visual cue (see the class doc's world→panel selection note).
|
||||
/// Same disposition as <see cref="LeaderNameColor"/>: no decompiled
|
||||
/// anchor for a per-row selection sprite/marker was found, so this is a
|
||||
/// minimal, clearly-adaptive tint rather than an invented DAT mechanism
|
||||
/// — register row AD-82. Takes priority over <see cref="LeaderNameColor"/>
|
||||
/// when a row is both the leader and the current selection.</summary>
|
||||
private static readonly Vector4 SelectedNameColor = new(0.45f, 0.85f, 1f, 1f);
|
||||
|
||||
/// <summary>Cached per <see cref="RefreshFellowshipName"/> — only
|
||||
/// reassigned when the fellowship name actually changes (fix-round SF-1,
|
||||
/// the same zero-allocation-while-idle discipline
|
||||
/// <c>SocialAllegiancePageController</c>'s <c>BlankLineProvider</c>/
|
||||
/// <c>NoLinesProvider</c> statics established for the sibling page).</summary>
|
||||
private string? _lastFellowshipName;
|
||||
private Func<IReadOnlyList<UiText.Line>>? _fellowshipNameLinesProvider;
|
||||
|
||||
/// <summary>The live read/write seam this page binds to — Runtime's
|
||||
/// FA2 fellowship owner (snapshot + member enumeration), the seven
|
||||
/// generation-gated commands routed through
|
||||
|
|
@ -164,6 +200,22 @@ public sealed class SocialFellowshipPageController
|
|||
private readonly UiButton? _shareLootCheckbox;
|
||||
|
||||
private readonly Dictionary<uint, FellowRowWidgets> _rows = new();
|
||||
|
||||
/// <summary>Fix-round SF-2/SF-3: the TRUE member-guid set as of the last
|
||||
/// rebuild attempt, independent of which rows actually finished
|
||||
/// building. <see cref="_rows"/> alone under-counts when a row template
|
||||
/// permanently fails to build (<see cref="RebuildRoster"/>'s own
|
||||
/// <c>continue</c>) — comparing against <see cref="_rows"/>.Count would
|
||||
/// make <see cref="RefreshRoster"/>'s membership-changed check true
|
||||
/// FOREVER for that guid (every vitals tick re-attempts a full
|
||||
/// DAT-locked <see cref="UiTemplateListBox.FlushPreservingScroll"/>
|
||||
/// rebuild instead of only on a real join/leave/disband), and would let
|
||||
/// Recruit's "already a fellow" check
|
||||
/// (<see cref="RefreshButtonStates"/>) light up for someone who IS
|
||||
/// already a fellow but whose row failed to build. This set is the
|
||||
/// single source of truth for both.</summary>
|
||||
private readonly HashSet<uint> _memberGuids = new();
|
||||
|
||||
private uint _selectedFellowGuid;
|
||||
private long _lastRosterRevision = long.MinValue;
|
||||
private bool? _lastOpenState;
|
||||
|
|
@ -359,7 +411,21 @@ public sealed class SocialFellowshipPageController
|
|||
_openButton.OnClick = () =>
|
||||
{
|
||||
RuntimeFellowshipSnapshot snapshot = _bindings.Snapshot();
|
||||
_bindings.SetOpen(!snapshot.IsOpen);
|
||||
bool newOpenState = !snapshot.IsOpen;
|
||||
_bindings.SetOpen(newOpenState);
|
||||
|
||||
// N-0 (fix-round NIT): lane B feature 11 — retail's Open
|
||||
// button handler pre-toggles its own _open_fellow BEFORE
|
||||
// Event_ChangeFellowOpeness, so the caption flips
|
||||
// immediately rather than waiting for the 0x02BE echo.
|
||||
// RefreshOpenCaption's own _lastOpenState latch means this
|
||||
// optimistic set is a no-op (already matches) once the real
|
||||
// snapshot catches up, and is silently corrected on the
|
||||
// next Tick if the server ever disagrees.
|
||||
_lastOpenState = newOpenState;
|
||||
string? label = newOpenState ? _closeCaption : _openCaption;
|
||||
if (label is not null)
|
||||
_openButton.Label = label;
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -407,6 +473,11 @@ public sealed class SocialFellowshipPageController
|
|||
/// </summary>
|
||||
public void Tick()
|
||||
{
|
||||
// N-3 (fix-round NIT): Snapshot() and Members() below each take
|
||||
// RuntimeFellowshipState._gate separately, so Revision/MemberCount
|
||||
// can momentarily disagree with the roster RefreshRoster reads a
|
||||
// few lines later. Self-correcting on the next revision bump
|
||||
// (single-threaded tick today) — not assumed atomic.
|
||||
RuntimeFellowshipSnapshot snapshot = _bindings.Snapshot();
|
||||
bool inFellowship = snapshot.IsInFellowship;
|
||||
_notInFellowshipFrame.Visible = !inFellowship;
|
||||
|
|
@ -422,9 +493,12 @@ public sealed class SocialFellowshipPageController
|
|||
_listBox?.Flush();
|
||||
_rows.Clear();
|
||||
}
|
||||
_memberGuids.Clear();
|
||||
_selectedFellowGuid = 0u;
|
||||
_lastRosterRevision = long.MinValue;
|
||||
_lastOpenState = null;
|
||||
_lastFellowshipName = null;
|
||||
_fellowshipNameLinesProvider = null;
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -436,6 +510,12 @@ public sealed class SocialFellowshipPageController
|
|||
RefreshRoster(snapshot);
|
||||
}
|
||||
|
||||
// MUST-FIX 4 (fix round): retail's UpdateFellowSelection runs from
|
||||
// Update EVERY frame (not just on a selection-changed notice) —
|
||||
// this call site mirrors that placement. See the class doc's
|
||||
// world->panel selection note.
|
||||
SyncSelectionFromWorld();
|
||||
|
||||
RefreshButtonStates(snapshot);
|
||||
RefreshOpenCaption(snapshot);
|
||||
}
|
||||
|
|
@ -457,6 +537,18 @@ public sealed class SocialFellowshipPageController
|
|||
_bindings.SetPanelOpen(visible);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MUST-FIX 3 (FA4 fix round) — clears the edge-trigger latch WITHOUT
|
||||
/// sending anything, so the next <see cref="SetPageVisible"/> call
|
||||
/// re-declares <c>0x00A6</c> even if the caller's own visibility
|
||||
/// hasn't changed from ITS perspective. Called by
|
||||
/// <see cref="SocialPanelController.ResetSessionDeclaration"/> on every
|
||||
/// generation reset — the page itself is process-lifetime and survives
|
||||
/// a reconnect, but the fresh session's server has no memory of the
|
||||
/// previous session's <c>0x00A6</c> declaration.
|
||||
/// </summary>
|
||||
public void ResetPageVisibleLatch() => _pageVisible = false;
|
||||
|
||||
private void RefreshCreateButtonState()
|
||||
{
|
||||
if (_createButton is null) return;
|
||||
|
|
@ -486,10 +578,20 @@ public sealed class SocialFellowshipPageController
|
|||
_bindings.CurrentCharacterOption(CharacterOptionId.FellowshipShareLoot);
|
||||
}
|
||||
|
||||
/// <summary>Fix-round SF-1: reassigns <see cref="UiText.LinesProvider"/>
|
||||
/// (a display-class + delegate allocation) only when the name actually
|
||||
/// changed, instead of on every <see cref="Tick"/> — this method used to
|
||||
/// allocate once per frame even while the panel is hidden, exactly the
|
||||
/// bug <c>35c40a9b</c> fixed on the sibling
|
||||
/// <c>SocialAllegiancePageController</c>.</summary>
|
||||
private void RefreshFellowshipName(string name)
|
||||
{
|
||||
if (_fellowshipNameText is null) return;
|
||||
_fellowshipNameText.LinesProvider = () => [new UiText.Line(name, MemberNameColor)];
|
||||
if (_fellowshipNameLinesProvider is not null && _lastFellowshipName == name) return;
|
||||
|
||||
_lastFellowshipName = name;
|
||||
_fellowshipNameLinesProvider = () => [new UiText.Line(name, MemberNameColor)];
|
||||
_fellowshipNameText.LinesProvider = _fellowshipNameLinesProvider;
|
||||
}
|
||||
|
||||
private void RefreshOpenCaption(RuntimeFellowshipSnapshot snapshot)
|
||||
|
|
@ -524,7 +626,12 @@ public sealed class SocialFellowshipPageController
|
|||
if (_recruitButton is not null)
|
||||
{
|
||||
uint? targetGuid = _bindings.Selection.SelectedObjectId;
|
||||
bool targetValid = targetGuid is { } id && id != selfGuid && !_rows.ContainsKey(id);
|
||||
// Fix-round SF-3: reads TRUE membership (_memberGuids), not
|
||||
// which rows happen to have finished building (_rows) — a row
|
||||
// that failed to build for a permanently-unresolvable template
|
||||
// must not let Recruit re-invite someone who IS already a
|
||||
// fellow.
|
||||
bool targetValid = targetGuid is { } id && id != selfGuid && !_memberGuids.Contains(id);
|
||||
bool notFull = snapshot.MemberCount < MaxFellowshipSize;
|
||||
// Retail additionally requires the target to be a player
|
||||
// (ACCWeenieObject::IsPlayer) — acdream's UI layer has no cheap
|
||||
|
|
@ -537,10 +644,12 @@ public sealed class SocialFellowshipPageController
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Diffs the live member GUID set against the currently-rendered rows.
|
||||
/// Unchanged set → update every row's bound widgets in place (no ListBox
|
||||
/// mutation, scroll untouched). Changed set → full rebuild via
|
||||
/// <see cref="UiTemplateListBox.FlushPreservingScroll"/> (carry-forward 1).
|
||||
/// Diffs the live member GUID set (<see cref="_memberGuids"/> — fix-round
|
||||
/// SF-2, NOT <see cref="_rows"/>.Count, see that field's own doc) against
|
||||
/// the current roster. Unchanged set → update every row's bound widgets
|
||||
/// in place (no ListBox mutation, scroll untouched). Changed set → full
|
||||
/// rebuild via <see cref="UiTemplateListBox.FlushPreservingScroll"/>
|
||||
/// (carry-forward 1).
|
||||
/// </summary>
|
||||
private void RefreshRoster(RuntimeFellowshipSnapshot snapshot)
|
||||
{
|
||||
|
|
@ -548,12 +657,12 @@ public sealed class SocialFellowshipPageController
|
|||
|
||||
var members = new List<RuntimeFellowMemberSnapshot>(_bindings.Members());
|
||||
|
||||
bool membershipChanged = members.Count != _rows.Count;
|
||||
bool membershipChanged = members.Count != _memberGuids.Count;
|
||||
if (!membershipChanged)
|
||||
{
|
||||
foreach (RuntimeFellowMemberSnapshot member in members)
|
||||
{
|
||||
if (_rows.ContainsKey(member.Guid)) continue;
|
||||
if (_memberGuids.Contains(member.Guid)) continue;
|
||||
membershipChanged = true;
|
||||
break;
|
||||
}
|
||||
|
|
@ -565,7 +674,7 @@ public sealed class SocialFellowshipPageController
|
|||
foreach (RuntimeFellowMemberSnapshot member in members)
|
||||
UpdateRow(member, snapshot);
|
||||
|
||||
if (_selectedFellowGuid != 0u && !_rows.ContainsKey(_selectedFellowGuid))
|
||||
if (_selectedFellowGuid != 0u && !_memberGuids.Contains(_selectedFellowGuid))
|
||||
_selectedFellowGuid = 0u;
|
||||
}
|
||||
|
||||
|
|
@ -574,6 +683,14 @@ public sealed class SocialFellowshipPageController
|
|||
_listBox!.FlushPreservingScroll();
|
||||
_rows.Clear();
|
||||
|
||||
// Fix-round SF-2: record the FULL attempted member set up front,
|
||||
// regardless of which rows below actually finish building — a
|
||||
// permanently-unbuildable template is attempted once per real
|
||||
// membership change, not once per vitals tick forever.
|
||||
_memberGuids.Clear();
|
||||
foreach (RuntimeFellowMemberSnapshot member in members)
|
||||
_memberGuids.Add(member.Guid);
|
||||
|
||||
foreach (RuntimeFellowMemberSnapshot member in members)
|
||||
{
|
||||
UiElement? row = _listBox.AddItemFromTemplateList(0);
|
||||
|
|
@ -611,7 +728,12 @@ public sealed class SocialFellowshipPageController
|
|||
if (widgets.Name is { } nameText)
|
||||
{
|
||||
string name = member.Name;
|
||||
Vector4 color = snapshot.LeaderGuid == member.Guid ? LeaderNameColor : MemberNameColor;
|
||||
// MUST-FIX 4 (fix round): selection takes priority over the
|
||||
// leader tint when a row is both — it's the more immediate,
|
||||
// user-driven state (see SelectedNameColor's own doc).
|
||||
Vector4 color = _selectedFellowGuid == member.Guid
|
||||
? SelectedNameColor
|
||||
: snapshot.LeaderGuid == member.Guid ? LeaderNameColor : MemberNameColor;
|
||||
nameText.LinesProvider = () => [new UiText.Line(name, color)];
|
||||
}
|
||||
|
||||
|
|
@ -639,6 +761,24 @@ public sealed class SocialFellowshipPageController
|
|||
/// acdream ExperienceSystem exists yet); guessing a formula here is
|
||||
/// exactly what CLAUDE.md's workflow forbids, so this is a scoped,
|
||||
/// documented gap rather than an invented number.
|
||||
///
|
||||
/// <para>
|
||||
/// <b>MUST-FIX 1 (fix round) — retail TRUNCATES, it does not round.</b>
|
||||
/// <c>gmFellowshipUI::UpdateFellowStats @0x0048ECC9</c> forms
|
||||
/// <c>pct * 100.0f</c> on the x87 stack (exact — both operands are
|
||||
/// floats) then calls <c>_ftol2 @0x005DE394</c> (MSVC's helper that
|
||||
/// turns the FPU's round-to-nearest <c>fistp</c> into C's
|
||||
/// truncate-toward-zero), never <c>MathF.Round</c>. This matters ONLY
|
||||
/// at 6 and 8 fellows: the stored constants are
|
||||
/// <c>0.44999998807907104</c> and <c>0.3499999940395355</c> (byte-read
|
||||
/// from the PDB-paired binary), so retail's own products are
|
||||
/// <c>44.999998…</c> / <c>34.999999…</c> and truncate to <b>44</b> /
|
||||
/// <b>34</b> — a naive <c>(int)MathF.Round(pct * 100f)</c> (or even
|
||||
/// <c>(int)(pct * 100f)</c>, since <c>0.45f * 100f</c> already rounds UP
|
||||
/// to exactly <c>45.0f</c> in SINGLE precision) yields the wrong 45/35.
|
||||
/// Forming the product in <c>double</c> before truncating reproduces
|
||||
/// retail's wider-than-single-precision x87 product exactly.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private static string FormatStatsText(RuntimeFellowMemberSnapshot member, RuntimeFellowshipSnapshot snapshot)
|
||||
{
|
||||
|
|
@ -647,7 +787,7 @@ public sealed class SocialFellowshipPageController
|
|||
if (snapshot.EvenXpSplit)
|
||||
{
|
||||
float pct = EvenSplitPercent(snapshot.MemberCount);
|
||||
return $"{member.Level} {(int)MathF.Round(pct * 100f)}%";
|
||||
return $"{member.Level} {(int)((double)pct * 100.0)}%";
|
||||
}
|
||||
return member.Level.ToString();
|
||||
}
|
||||
|
|
@ -655,6 +795,21 @@ public sealed class SocialFellowshipPageController
|
|||
private static float EvenSplitPercent(int memberCount) =>
|
||||
memberCount is >= 1 and <= 10 ? EvenSplitPercentTable[memberCount - 1] : 0f;
|
||||
|
||||
/// <summary>
|
||||
/// N-1 (fix-round NIT): retail's <c>UpdateFellowVitals @0x0048ED60</c>
|
||||
/// sets meter attribute <c>0x69</c> to <c>cur/max</c> AND writes the
|
||||
/// cur/max ints into the meter's authored CHILD text elements
|
||||
/// (<c>0x10000286</c> under health, <c>0x10000288</c> under stamina) —
|
||||
/// acdream instead uses <see cref="UiMeter.Fill"/>/
|
||||
/// <see cref="UiMeter.Label"/> because <see cref="UiMeter.ConsumesDatChildren"/>
|
||||
/// is true (a pre-existing widget decision, not this slice's). Noted
|
||||
/// here so a future reader does not go hunting for the unbound
|
||||
/// <c>0x10000286</c>/<c>0x10000288</c>/<c>0x1000028A</c> children.
|
||||
/// N-2: the <c>max > 0</c> guard below is an acdream-side engineering
|
||||
/// call (retail divides unconditionally) — the right one, since a
|
||||
/// pre-population zero-max member should not render a divide-by-zero
|
||||
/// fill, but not itself a "verbatim port" claim.
|
||||
/// </summary>
|
||||
private static void SetVitals(UiMeter? meter, uint current, uint max)
|
||||
{
|
||||
if (meter is null) return;
|
||||
|
|
@ -671,11 +826,49 @@ public sealed class SocialFellowshipPageController
|
|||
/// panel selects them in the world too. acdream has no generic
|
||||
/// per-row-element click primitive on an imported template subtree, so
|
||||
/// the row's own name text (always present per the row inventory) is
|
||||
/// the click target.
|
||||
/// the click target (register row AD-82 — retail selects on the whole
|
||||
/// row, not just its name text).
|
||||
/// </summary>
|
||||
private void SelectFellow(uint guid)
|
||||
{
|
||||
_selectedFellowGuid = guid;
|
||||
SetSelectedFellow(guid);
|
||||
_bindings.Selection.Select(guid, SelectionChangeSource.Social);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MUST-FIX 4 (fix round) — the world→panel arm of retail's
|
||||
/// two-directional selection coupling (see the class doc). Retail's
|
||||
/// <c>UpdateFellowSelection</c> loop has two arms: a world selection
|
||||
/// that matches a fellow row becomes the panel selection (this
|
||||
/// method's job — the "found" arm); otherwise the CURRENT panel
|
||||
/// selection is kept as a fallback as long as that fellow is still on
|
||||
/// the roster (retail's "remember row as the fallback" arm — no code
|
||||
/// needed here, since this method simply does nothing when the world
|
||||
/// selection doesn't match a member, leaving <see cref="_selectedFellowGuid"/>
|
||||
/// untouched), and <see cref="RefreshRoster"/> already clears the
|
||||
/// selection when its fellow leaves the roster (retail's
|
||||
/// fallback-is-null case).
|
||||
/// </summary>
|
||||
private void SyncSelectionFromWorld()
|
||||
{
|
||||
if (_bindings.Selection.SelectedObjectId is { } id && _memberGuids.Contains(id))
|
||||
SetSelectedFellow(id);
|
||||
}
|
||||
|
||||
/// <summary>Sets the panel's own selection (idempotent) and immediately
|
||||
/// refreshes every row's visual selection cue — selection changes are
|
||||
/// rare (a click, or a world-selection change), so re-running
|
||||
/// <see cref="UpdateRow"/> for the bounded (≤9) roster on a change
|
||||
/// is cheap and keeps the highlight in sync without waiting for the
|
||||
/// next vitals-driven revision bump.</summary>
|
||||
private void SetSelectedFellow(uint guid)
|
||||
{
|
||||
if (_selectedFellowGuid == guid) return;
|
||||
_selectedFellowGuid = guid;
|
||||
|
||||
if (_rows.Count == 0) return;
|
||||
RuntimeFellowshipSnapshot snapshot = _bindings.Snapshot();
|
||||
foreach (RuntimeFellowMemberSnapshot member in _bindings.Members())
|
||||
UpdateRow(member, snapshot);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -126,6 +126,7 @@ public sealed class SocialFellowshipPageControllerTests
|
|||
public SelectionState Selection = new();
|
||||
public uint LocalPlayerGuid;
|
||||
public Func<uint, uint, UiElement?> TemplateResolver = FakeRowResolver;
|
||||
public Func<uint, uint, string?> ResolveString = static (_, _) => null;
|
||||
|
||||
public SocialFellowshipPageController.Bindings Build() => new(
|
||||
Snapshot: () => Snapshot,
|
||||
|
|
@ -142,7 +143,7 @@ public sealed class SocialFellowshipPageControllerTests
|
|||
LocalPlayerGuid: () => LocalPlayerGuid,
|
||||
CurrentCharacterOption: id => Options.TryGetValue(id, out bool v) && v,
|
||||
SetCharacterOption: (id, value) => { Options[id] = value; Calls.Add($"set-option:{id}:{value}"); },
|
||||
ResolveString: (_, _) => null);
|
||||
ResolveString: ResolveString);
|
||||
}
|
||||
|
||||
// ── Empty/full frame swap (still correct after the FA4 rewrite) ────────
|
||||
|
|
@ -240,8 +241,21 @@ public sealed class SocialFellowshipPageControllerTests
|
|||
Assert.Equal(0.10f, health.Fill()!.Value, 3);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fix-round SF-5: RENAMED from
|
||||
/// <c>..._ButPreservesScrollPosition</c> — with only one short row this
|
||||
/// test cannot observe a NONZERO preserved offset (its own prior
|
||||
/// comment admitted as much), so the "preserves scroll position" half
|
||||
/// of the old name was aspirational, not verified here. The actual
|
||||
/// shrink/clamp math IS covered, correctly, at the widget level
|
||||
/// (<c>UiTemplateListBoxFlushPreservingScrollTests.FlushPreservingScroll_ClampsToTheNewShorterContent</c>).
|
||||
/// This test's real job — proven below — is that a genuine roster
|
||||
/// CHANGE (Bob joins) goes through <see cref="UiTemplateListBox.FlushPreservingScroll"/>
|
||||
/// (not the scroll-resetting <see cref="UiTemplateListBox.Flush"/>) and
|
||||
/// produces the right row count.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Tick_MembershipChange_RebuildsRoster_ButPreservesScrollPosition()
|
||||
public void Tick_MembershipChange_RebuildsRoster()
|
||||
{
|
||||
UiElement root = BuildPageRoot(out UiTemplateListBox listBox, out _);
|
||||
var alice = new RuntimeFellowMemberSnapshot(0x50000001u, "Alice", 12, 100, 80, 60, 100, 80, 60, false);
|
||||
|
|
@ -256,13 +270,6 @@ public sealed class SocialFellowshipPageControllerTests
|
|||
listBox.ViewportForTest!.LayoutScrollableChildren();
|
||||
listBox.Scroll.SetScrollY(0); // only one short row — nothing to scroll, but exercise the path
|
||||
|
||||
// Carry-forward 1: a genuine roster change (Bob joins) must not
|
||||
// silently desync — verified via row count — AND must go through
|
||||
// the scroll-preserving flush, not the scroll-resetting one. We
|
||||
// can't observe a NONZERO preserved offset with only one short
|
||||
// row, so this test's decisive assertion is closer to the wiring:
|
||||
// FlushPreservingScroll leaves Scroll.ScrollY untouched immediately
|
||||
// after the clear, unlike Flush.
|
||||
var bob = new RuntimeFellowMemberSnapshot(0x50000002u, "Bob", 10, 90, 70, 50, 90, 70, 50, false);
|
||||
b.Snapshot = b.Snapshot with { Revision = 2, MemberCount = 2 };
|
||||
b.Members = [alice, bob];
|
||||
|
|
@ -271,6 +278,196 @@ public sealed class SocialFellowshipPageControllerTests
|
|||
Assert.Equal(2, listBox.ViewportForTest!.Children.Count);
|
||||
}
|
||||
|
||||
/// <summary>Fix-round SF-5: the shrink counterpart of the join test
|
||||
/// above — a member LEAVING must also rebuild (not silently desync),
|
||||
/// and a departed fellow's panel selection must clear rather than stay
|
||||
/// stale (so a subsequent Dismiss/Leader click can't re-target a guid
|
||||
/// that is no longer on the roster).</summary>
|
||||
[Fact]
|
||||
public void Tick_MemberLeaves_ShrinksRoster_AndClearsSelectionIfTheyWereSelected()
|
||||
{
|
||||
UiElement root = BuildPageRoot(out UiTemplateListBox listBox, out _);
|
||||
var alice = new RuntimeFellowMemberSnapshot(0x50000001u, "Alice", 12, 100, 80, 60, 100, 80, 60, false);
|
||||
var bob = new RuntimeFellowMemberSnapshot(0x50000002u, "Bob", 10, 90, 70, 50, 90, 70, 50, false);
|
||||
var b = new FellowshipBindingsBuilder
|
||||
{
|
||||
Snapshot = new RuntimeFellowshipSnapshot
|
||||
{
|
||||
IsInFellowship = true, Revision = 1, MemberCount = 2, LeaderGuid = 0x50000001u,
|
||||
},
|
||||
Members = [alice, bob],
|
||||
LocalPlayerGuid = 0x50000001u, // Alice, the leader
|
||||
};
|
||||
|
||||
SocialFellowshipPageController controller = SocialFellowshipPageController.Bind(root, b.Build())!;
|
||||
Assert.Equal(2, listBox.ViewportForTest!.Children.Count);
|
||||
|
||||
UiElement bobRow = listBox.ViewportForTest!.Children[1];
|
||||
UiText bobName = Assert.IsType<UiText>(UiElement.FindDescendant(bobRow, RowNameTextId));
|
||||
bobName.OnClick!(); // selects Bob
|
||||
((UiButton)UiElement.FindDescendant(root, DismissButtonId)!).OnClick!();
|
||||
Assert.Contains("dismiss:50000002", b.Calls);
|
||||
b.Calls.Clear();
|
||||
|
||||
// Bob leaves (independently of the click above — e.g. he quit).
|
||||
b.Snapshot = b.Snapshot with { Revision = 2, MemberCount = 1 };
|
||||
b.Members = [alice];
|
||||
controller.Tick();
|
||||
|
||||
Assert.Single(listBox.ViewportForTest!.Children);
|
||||
|
||||
// The stale selection must be gone — Dismiss must not re-send
|
||||
// Bob's guid.
|
||||
((UiButton)UiElement.FindDescendant(root, DismissButtonId)!).OnClick!();
|
||||
Assert.DoesNotContain(b.Calls, c => c.StartsWith("dismiss:"));
|
||||
}
|
||||
|
||||
/// <summary>Fix-round SF-2: a permanently-unbuildable row template must
|
||||
/// be attempted once, at the real membership change, not re-attempted
|
||||
/// (a full DAT-locked <see cref="UiTemplateListBox.FlushPreservingScroll"/>
|
||||
/// rebuild) on every subsequent pure-vitals revision bump. Without the
|
||||
/// fix, comparing against <c>_rows.Count</c> (which stays 0 forever
|
||||
/// here) makes every Tick see a "membership change" that never actually
|
||||
/// happened.</summary>
|
||||
[Fact]
|
||||
public void Tick_RowTemplatePermanentlyFailsToBuild_DoesNotRetryOnEveryVitalsTick()
|
||||
{
|
||||
UiElement root = BuildPageRoot(out UiTemplateListBox listBox, out _);
|
||||
var member = new RuntimeFellowMemberSnapshot(0x50000001u, "Alice", 12, 100, 80, 60, 100, 80, 60, false);
|
||||
int resolverCalls = 0;
|
||||
var b = new FellowshipBindingsBuilder
|
||||
{
|
||||
Snapshot = new RuntimeFellowshipSnapshot { IsInFellowship = true, Revision = 1, MemberCount = 1 },
|
||||
Members = [member],
|
||||
TemplateResolver = (_, _) => { resolverCalls++; return null; }, // permanently unbuildable
|
||||
};
|
||||
|
||||
SocialFellowshipPageController controller = SocialFellowshipPageController.Bind(root, b.Build())!;
|
||||
Assert.Empty(listBox.ViewportForTest?.Children ?? []);
|
||||
int callsAfterFirstAttempt = resolverCalls;
|
||||
Assert.True(callsAfterFirstAttempt >= 1);
|
||||
|
||||
// Two pure vitals ticks: SAME member set, revision bumps (the exact
|
||||
// shape a 0x02C0 vitals refresh produces).
|
||||
b.Snapshot = b.Snapshot with { Revision = 2 };
|
||||
controller.Tick();
|
||||
b.Snapshot = b.Snapshot with { Revision = 3 };
|
||||
controller.Tick();
|
||||
|
||||
Assert.Equal(callsAfterFirstAttempt, resolverCalls);
|
||||
}
|
||||
|
||||
/// <summary>Fix-round SF-3: Recruit's "already a fellow" check must read
|
||||
/// TRUE membership, not which rows happen to have finished building —
|
||||
/// otherwise a permanently-unbuildable row lets Recruit light up for
|
||||
/// someone who IS already a fellow.</summary>
|
||||
[Fact]
|
||||
public void RecruitButton_TargetIsAnExistingFellowWhoseRowFailedToBuild_StaysDisabled()
|
||||
{
|
||||
UiElement root = BuildPageRoot(out _, out _);
|
||||
var member = new RuntimeFellowMemberSnapshot(0x50000001u, "Alice", 12, 100, 80, 60, 100, 80, 60, false);
|
||||
var selection = new SelectionState();
|
||||
selection.Select(0x50000001u, SelectionChangeSource.World); // targeting the (already-a-fellow) guid
|
||||
var b = new FellowshipBindingsBuilder
|
||||
{
|
||||
Snapshot = new RuntimeFellowshipSnapshot { IsInFellowship = true, Revision = 1, MemberCount = 1, LeaderGuid = 1u },
|
||||
Members = [member],
|
||||
LocalPlayerGuid = 1u,
|
||||
Selection = selection,
|
||||
TemplateResolver = (_, _) => null, // Alice's row never builds -- but she IS still a fellow
|
||||
};
|
||||
|
||||
SocialFellowshipPageController.Bind(root, b.Build());
|
||||
|
||||
Assert.False(((UiButton)UiElement.FindDescendant(root, RecruitButtonId)!).Enabled);
|
||||
}
|
||||
|
||||
// ── MUST-FIX 4: world→panel selection sync ──────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void WorldSelectionOfAFellow_EnablesDismissAndLeader_WithoutClickingTheirRow()
|
||||
{
|
||||
UiElement root = BuildPageRoot(out _, out _);
|
||||
var member = new RuntimeFellowMemberSnapshot(0x50000001u, "Alice", 12, 100, 80, 60, 100, 80, 60, false);
|
||||
var selection = new SelectionState();
|
||||
var b = new FellowshipBindingsBuilder
|
||||
{
|
||||
Snapshot = new RuntimeFellowshipSnapshot { IsInFellowship = true, Revision = 1, MemberCount = 1, LeaderGuid = 0xFFu },
|
||||
Members = [member],
|
||||
LocalPlayerGuid = 0xFFu, // leader
|
||||
Selection = selection,
|
||||
};
|
||||
SocialFellowshipPageController controller = SocialFellowshipPageController.Bind(root, b.Build())!;
|
||||
Assert.False(((UiButton)UiElement.FindDescendant(root, DismissButtonId)!).Enabled);
|
||||
Assert.False(((UiButton)UiElement.FindDescendant(root, LeaderButtonId)!).Enabled);
|
||||
|
||||
// MUST-FIX 4 — selecting Alice in the WORLD (not clicking her panel
|
||||
// row) must still enable Dismiss/Leader, matching retail's
|
||||
// gmFellowshipUI::UpdateFellowSelection reverse arm.
|
||||
selection.Select(0x50000001u, SelectionChangeSource.World);
|
||||
controller.Tick();
|
||||
|
||||
Assert.True(((UiButton)UiElement.FindDescendant(root, DismissButtonId)!).Enabled);
|
||||
Assert.True(((UiButton)UiElement.FindDescendant(root, LeaderButtonId)!).Enabled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WorldSelectionOfANonFellow_DoesNotClearAnExistingPanelSelection()
|
||||
{
|
||||
UiElement root = BuildPageRoot(out UiTemplateListBox listBox, out _);
|
||||
var member = new RuntimeFellowMemberSnapshot(0x50000001u, "Alice", 12, 100, 80, 60, 100, 80, 60, false);
|
||||
var selection = new SelectionState();
|
||||
var b = new FellowshipBindingsBuilder
|
||||
{
|
||||
Snapshot = new RuntimeFellowshipSnapshot { IsInFellowship = true, Revision = 1, MemberCount = 1, LeaderGuid = 0xFFu },
|
||||
Members = [member],
|
||||
LocalPlayerGuid = 0xFFu,
|
||||
Selection = selection,
|
||||
};
|
||||
SocialFellowshipPageController controller = SocialFellowshipPageController.Bind(root, b.Build())!;
|
||||
|
||||
UiElement row = Assert.Single(listBox.ViewportForTest!.Children);
|
||||
UiText name = Assert.IsType<UiText>(UiElement.FindDescendant(row, RowNameTextId));
|
||||
name.OnClick!(); // selects Alice via her row
|
||||
controller.Tick(); // Enabled is refreshed by RefreshButtonStates, which only runs in Tick
|
||||
Assert.True(((UiButton)UiElement.FindDescendant(root, DismissButtonId)!).Enabled);
|
||||
|
||||
// Retail's fallback arm: a world selection that is NOT a fellow
|
||||
// must not clear the panel's own selection while that fellow is
|
||||
// still on the roster.
|
||||
selection.Select(0x99999999u, SelectionChangeSource.World);
|
||||
controller.Tick();
|
||||
|
||||
Assert.True(((UiButton)UiElement.FindDescendant(root, DismissButtonId)!).Enabled);
|
||||
}
|
||||
|
||||
// ── N-0: optimistic Open/Close caption ──────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void OpenButton_Click_FlipsCaptionImmediately_NotWaitingForTheNextTick()
|
||||
{
|
||||
UiElement root = BuildPageRoot(out _, out _);
|
||||
var b = new FellowshipBindingsBuilder
|
||||
{
|
||||
Snapshot = new RuntimeFellowshipSnapshot { IsInFellowship = true, IsOpen = false, LeaderGuid = 1u },
|
||||
LocalPlayerGuid = 1u,
|
||||
ResolveString = (_, hash) =>
|
||||
hash == DatStringResolver.ComputeHash("ID_Fellowship_OpenFellowshipButtonText") ? "Open"
|
||||
: hash == DatStringResolver.ComputeHash("ID_Fellowship_CloseFellowshipButtonText") ? "Close"
|
||||
: null,
|
||||
};
|
||||
SocialFellowshipPageController.Bind(root, b.Build());
|
||||
var openButton = (UiButton)UiElement.FindDescendant(root, OpenButtonId)!;
|
||||
Assert.Equal("Open", openButton.Label);
|
||||
|
||||
openButton.OnClick!();
|
||||
|
||||
// N-0 (fix round): retail's Open button handler pre-toggles its own
|
||||
// state before the server echo (0x02BE) lands (lane B feature 11)
|
||||
// -- the caption flips on click, not on the next Tick.
|
||||
Assert.Equal("Close", openButton.Label);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tick_NotInFellowship_ClearsAnyStaleRoster()
|
||||
{
|
||||
|
|
@ -294,8 +491,17 @@ public sealed class SocialFellowshipPageControllerTests
|
|||
|
||||
[Theory]
|
||||
[InlineData(false, true, 1, "12 0%")] // ShareXp off -> retail's literal 0.0
|
||||
[InlineData(true, true, 9, "12 31%")] // even split, 9 fellows -> 0.3111111 -> round(31.11) = 31
|
||||
[InlineData(true, true, 9, "12 31%")] // even split, 9 fellows -> 0.3111111 -> truncate(31.11) = 31
|
||||
[InlineData(true, false, 3, "12")] // proportional -> no acdream XP table -> level only, no invented %
|
||||
// MUST-FIX 1 (fix round): retail TRUNCATES (_ftol2 @0x0048ECC9), it
|
||||
// does not round. The stored float constants for 6 and 8 fellows are
|
||||
// 0.44999998807907104 and 0.3499999940395355 (byte-read from the
|
||||
// PDB-paired binary) -- ×100 = 44.999998.../34.999999..., which
|
||||
// truncate to 44/34, NOT 45/35 (what MathF.Round -- or even a naive
|
||||
// single-precision (int)(pct*100f) cast, since 0.45f*100f already
|
||||
// rounds UP to exactly 45.0f in single precision -- would produce).
|
||||
[InlineData(true, true, 6, "12 44%")]
|
||||
[InlineData(true, true, 8, "12 34%")]
|
||||
public void FormatStatsText_MatchesD5Rules(bool shareXp, bool evenSplit, int memberCount, string expected)
|
||||
{
|
||||
UiElement root = BuildPageRoot(out UiTemplateListBox listBox, out _);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue