feat(ui) Campaign CA CA4 #431: server-authoritative raises — the optimistic layer is deleted
Retail sends a raise and WAITS: one request in flight, the raise controls ghost, and displayed state changes only when the authoritative quality-change record lands (gmStatManagementUI @ 0x004F03F0 family, pinned in docs/research/2026-07-10-retail-panel-behavior-pseudocode.md §5, whose own conclusion names ApplyLocalRaise as the thing to remove). The optimistic layer predates the inbound parsers — it existed so the panel showed anything at all — and with CA2 delivering server truth it became strictly harmful: against ACE, a wrong TrainSkill cost fails SILENTLY, so the optimistic promote-and-debit could show a trained skill the server refused with nothing to ever correct it. Deleted: CharacterSheetProvider.ApplyLocalRaise + both spend helpers, and LocalPlayerState's six optimistic mutators (ApplyAttributeRaise, ApplyVitalRaise, ApplySkillRaise, ApplySkillTraining, DebitIntProperty, DebitInt64Property) with their tests. Added: the one-in-flight latch in HandleRaiseRequest, CharacterSheet.AwaitingRaise ghosting all raise controls, and gate release on every authoritative quality signal (attribute/character/player-property events unconditionally; vital events only release-and-refresh while a raise is in flight, so regen ticks stay out of the sheet-rebuild path). Panel unmount resets the gate — retail's awaiting flag lives on the panel instance. AP-73 NARROWS rather than retires: retail's release on a rejection that produces NO quality change is statically unverifiable, and ACE sends chat-only (Raise*) or nothing (RaiseSkill/TrainSkill) on failure; until the CA5 live check, a silently-rejected request leaves the controls ghosted until panel reopen — recorded with its observable symptom. Also verified for CA4: the train button sends the DAT-exact TrainedCost (ACE's silent exact-match rule), and there is correctly NO panel specialize send — retail/ACE specialize only via the SkillAlterationDevice item-use + confirmation round-trip, whose client seams (SendConfirmationResponse 0x0275, the 0x028B WeenieErrorWithString chat routing) already exist. Provider tests now pin the retail contract: send-without-mutation, one-in-flight, release-on-record, release-on- unmount, and the regen-tick rebuild guard. Full hermetic suite 15,327 passed / 0 failed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
5781895977
commit
08b77e20a9
8 changed files with 152 additions and 339 deletions
|
|
@ -142,6 +142,12 @@ public sealed class CharacterSheet
|
|||
/// </summary>
|
||||
public int SkillCredits { get; init; }
|
||||
|
||||
/// <summary>Campaign CA CA4 (retired AP-73): true while a raise/train
|
||||
/// request is awaiting its authoritative server record — retail permits
|
||||
/// one in flight and ghosts the raise controls until the quality-change
|
||||
/// message lands (gmStatManagementUI's awaiting flag).</summary>
|
||||
public bool AwaitingRaise { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Unassigned (banked) experience points.
|
||||
/// Retail InqInt64(2) — shown in footer line-2 in State-A display.
|
||||
|
|
|
|||
|
|
@ -10,22 +10,21 @@ namespace AcDream.App.UI.Layout;
|
|||
|
||||
/// <summary>
|
||||
/// Assembles the live <see cref="CharacterSheet"/> for the retail Character
|
||||
/// window and owns the raise-request flow (wire send + optimistic local
|
||||
/// apply). Extracted from <c>GameWindow</c> (Code Structure Rule 1: sheet
|
||||
/// assembly + XP-curve math is feature logic, not wiring).
|
||||
/// window and owns the raise-request flow. Extracted from
|
||||
/// <c>GameWindow</c> (Code Structure Rule 1: sheet assembly + XP-curve math
|
||||
/// is feature logic, not wiring).
|
||||
///
|
||||
/// <para>Retail property ids and the decomp anchors for every field are
|
||||
/// documented on <see cref="CharacterSheet"/>; the raise-cost formulas are
|
||||
/// cited there too (gmAttributeUI::GetCostToRaise 0x0049cb80 family).</para>
|
||||
///
|
||||
/// <para><b>State ownership:</b> optimistic debits go through the owning
|
||||
/// store's eventful APIs — <see cref="ClientObjectTable.UpdateIntProperty"/> /
|
||||
/// <see cref="ClientObjectTable.UpdateInt64Property"/> (fires ObjectUpdated)
|
||||
/// when the player object is in the table, else
|
||||
/// <see cref="LocalPlayerState.DebitIntProperty"/> /
|
||||
/// <see cref="LocalPlayerState.DebitInt64Property"/> (fires CharacterChanged).
|
||||
/// Never write the raw property dictionaries from UI code. The next server
|
||||
/// snapshot remains authoritative over every optimistic value.</para>
|
||||
/// <para><b>Raise flow (Campaign CA CA4, retired AP-73):</b>
|
||||
/// server-authoritative, exactly retail's
|
||||
/// (<c>gmStatManagementUI</c> — one request in flight, controls ghost while
|
||||
/// awaiting, no local mutation). Displayed state changes only when the
|
||||
/// authoritative quality records land (<c>PrivateUpdateAttribute /
|
||||
/// PrivateUpdateVital / PrivateUpdateSkill</c> plus the XP/credit property
|
||||
/// updates), which also release the in-flight gate.</para>
|
||||
/// </summary>
|
||||
public sealed class CharacterSheetProvider
|
||||
{
|
||||
|
|
@ -177,6 +176,7 @@ public sealed class CharacterSheetProvider
|
|||
: null,
|
||||
Deaths = props.GetInt(0x2Bu),
|
||||
SkillCredits = skillCredits,
|
||||
AwaitingRaise = _awaitingRaise,
|
||||
UnassignedXp = unassignedXp,
|
||||
AttributeRaiseCosts = BuildAttributeRaiseCosts(amount: 1),
|
||||
AttributeRaise10Costs = BuildAttributeRaiseCosts(amount: 10),
|
||||
|
|
@ -233,6 +233,10 @@ public sealed class CharacterSheetProvider
|
|||
owner._objects.Cleared += OnCleared;
|
||||
owner._localPlayer.AttributeChanged += OnAttributeChanged;
|
||||
owner._localPlayer.CharacterChanged += OnCharacterChanged;
|
||||
// CA4: vital events participate ONLY in the raise-gate release
|
||||
// (see OnVitalChanged) — regen ticks fire this constantly and
|
||||
// must not rebuild the sheet outside an awaited raise.
|
||||
owner._localPlayer.Changed += OnVitalChanged;
|
||||
// Issue #267: skills/attributes are now vitae + buff aware, so the
|
||||
// sheet must refresh whenever the active-enchantment set changes
|
||||
// (vitae applied/removed on death/lifestone, a buff cast/expired),
|
||||
|
|
@ -245,15 +249,41 @@ public sealed class CharacterSheetProvider
|
|||
{
|
||||
CharacterSheetProvider? owner = _owner;
|
||||
if (owner is not null && value.ObjectId == owner._playerGuid())
|
||||
{
|
||||
// An authoritative player-property record (XP, credits, …)
|
||||
// is a quality change — retail releases the raise gate on
|
||||
// any of them (ListenToElementMessage @ 0x004EFBE0).
|
||||
owner.ReleaseAwaitingRaise();
|
||||
_changed();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnCleared() => _changed();
|
||||
|
||||
private void OnAttributeChanged(LocalPlayerState.AttributeKind _) =>
|
||||
private void OnAttributeChanged(LocalPlayerState.AttributeKind _)
|
||||
{
|
||||
_owner?.ReleaseAwaitingRaise();
|
||||
_changed();
|
||||
}
|
||||
|
||||
private void OnCharacterChanged() => _changed();
|
||||
private void OnCharacterChanged()
|
||||
{
|
||||
_owner?.ReleaseAwaitingRaise();
|
||||
_changed();
|
||||
}
|
||||
|
||||
private void OnVitalChanged(LocalPlayerState.VitalKind _)
|
||||
{
|
||||
// Release-and-refresh only while a raise is in flight: the full
|
||||
// vital record answering a RaiseVital (or the Endurance/Self
|
||||
// side-push) must un-ghost the controls, but ordinary regen
|
||||
// ticks outside a raise stay out of the sheet-rebuild path.
|
||||
CharacterSheetProvider? owner = _owner;
|
||||
if (owner is null || !owner._awaitingRaise)
|
||||
return;
|
||||
owner.ReleaseAwaitingRaise();
|
||||
_changed();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
|
|
@ -267,8 +297,12 @@ public sealed class CharacterSheetProvider
|
|||
owner._objects.Cleared -= OnCleared;
|
||||
owner._localPlayer.AttributeChanged -= OnAttributeChanged;
|
||||
owner._localPlayer.CharacterChanged -= OnCharacterChanged;
|
||||
owner._localPlayer.Changed -= OnVitalChanged;
|
||||
if (owner._localPlayer.Spellbook is { } spellbook)
|
||||
spellbook.EnchantmentsChanged -= OnCleared;
|
||||
// Panel unmount resets the one-in-flight raise gate — retail's
|
||||
// awaiting flag lives on the panel instance and dies with it.
|
||||
owner.ReleaseAwaitingRaise();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -497,14 +531,22 @@ public sealed class CharacterSheetProvider
|
|||
// ── Raise-request flow ─────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Send a raise/train action to the server and, when a send delegate
|
||||
/// fired, optimistically apply the local effect so the sheet stays
|
||||
/// current during the round trip. The next server snapshot remains
|
||||
/// authoritative (a rejected raise is corrected by the property echo —
|
||||
/// pending/rollback ledger tracked as a follow-up issue).
|
||||
/// Campaign CA CA4 (#431, retires AP-73): send a raise/train action and
|
||||
/// wait for the server's authoritative record — retail's exact flow
|
||||
/// (<c>gmAttributeUI::RaiseSelection @ 0x0049D020</c> /
|
||||
/// <c>gmSkillUI::RaiseSelection @ 0x0049C8C0</c>, pinned in
|
||||
/// docs/research/2026-07-10-retail-panel-behavior-pseudocode.md §5):
|
||||
/// one request in flight, the raise controls ghost while awaiting, and
|
||||
/// NO local mutation of ranks/XP/credits — displayed state changes only
|
||||
/// when the quality-change record lands (which CA2's inbound parsers
|
||||
/// now deliver). The former optimistic <c>ApplyLocalRaise</c> layer is
|
||||
/// deleted: against ACE, a wrong TrainSkill cost fails SILENTLY, so an
|
||||
/// optimistic apply could show a trained skill the server refused with
|
||||
/// nothing to ever correct it.
|
||||
/// </summary>
|
||||
public void HandleRaiseRequest(CharacterStatController.RaiseRequest request)
|
||||
{
|
||||
if (_awaitingRaise) return;
|
||||
if (request.Cost <= 0) return;
|
||||
if (_canSendRaise is not null && !_canSendRaise()) return;
|
||||
|
||||
|
|
@ -542,71 +584,25 @@ public sealed class CharacterSheetProvider
|
|||
}
|
||||
|
||||
if (sent)
|
||||
ApplyLocalRaise(request);
|
||||
_awaitingRaise = true;
|
||||
}
|
||||
|
||||
private void ApplyLocalRaise(CharacterStatController.RaiseRequest request)
|
||||
{
|
||||
uint amount = request.Amount <= 0 ? 1u : (uint)request.Amount;
|
||||
ulong cost = (ulong)request.Cost;
|
||||
/// <summary>
|
||||
/// Retail releases the one-in-flight raise gate on ANY authoritative
|
||||
/// quality-change element message
|
||||
/// (<c>gmStatManagementUI::ListenToElementMessage @ 0x004EFBE0</c>) —
|
||||
/// the LocalPlayerState change events are our equivalent. Rejections
|
||||
/// that produce NO quality change (ACE sends chat-only for a failed
|
||||
/// Raise*, and nothing at all for a rejected RaiseSkill/TrainSkill)
|
||||
/// leave the gate held exactly as the static retail evidence leaves it
|
||||
/// unverified (the pseudocode doc's own caveat); panel remount resets
|
||||
/// it, matching retail's per-instance field lifetime. Verify live at
|
||||
/// CA5 before hardening further — see the narrowed AP-73 row.
|
||||
/// </summary>
|
||||
internal void ReleaseAwaitingRaise() => _awaitingRaise = false;
|
||||
|
||||
switch (request.Kind)
|
||||
{
|
||||
case CharacterStatController.RaiseTargetKind.Attribute:
|
||||
if (_localPlayer.ApplyAttributeRaise(request.StatId, amount, cost))
|
||||
SpendUnassignedExperience(request.Cost);
|
||||
break;
|
||||
case CharacterStatController.RaiseTargetKind.Vital:
|
||||
if (_localPlayer.ApplyVitalRaise(request.StatId, amount, cost))
|
||||
SpendUnassignedExperience(request.Cost);
|
||||
break;
|
||||
case CharacterStatController.RaiseTargetKind.Skill:
|
||||
if (_localPlayer.ApplySkillRaise(request.StatId, amount, cost))
|
||||
SpendUnassignedExperience(request.Cost);
|
||||
break;
|
||||
case CharacterStatController.RaiseTargetKind.TrainSkill:
|
||||
if (_localPlayer.ApplySkillTraining(request.StatId))
|
||||
SpendSkillCredits(request.Cost);
|
||||
break;
|
||||
}
|
||||
}
|
||||
/// <summary>One raise/train request in flight — retail permits exactly
|
||||
/// one (gmStatManagementUI's awaiting flag).</summary>
|
||||
private bool _awaitingRaise;
|
||||
|
||||
private void SpendUnassignedExperience(long cost)
|
||||
{
|
||||
if (cost <= 0) return;
|
||||
uint guid = _playerGuid();
|
||||
if (guid != 0u && _objects.Get(guid) is { } player)
|
||||
{
|
||||
long current = player.Properties.GetInt64(UnassignedXpPropertyId);
|
||||
if (current <= 0) return;
|
||||
_objects.UpdateInt64Property(guid, UnassignedXpPropertyId,
|
||||
current > cost ? current - cost : 0L);
|
||||
return;
|
||||
}
|
||||
_localPlayer.DebitInt64Property(UnassignedXpPropertyId, cost);
|
||||
}
|
||||
|
||||
private void SpendSkillCredits(long cost)
|
||||
{
|
||||
if (cost <= 0) return;
|
||||
int debit = cost > int.MaxValue ? int.MaxValue : (int)cost;
|
||||
uint guid = _playerGuid();
|
||||
if (guid != 0u && _objects.Get(guid) is { } player)
|
||||
{
|
||||
foreach (uint propertyId in SkillCreditPropertyIds)
|
||||
{
|
||||
if (!player.Properties.Ints.TryGetValue(propertyId, out int current))
|
||||
continue;
|
||||
_objects.UpdateIntProperty(guid, propertyId,
|
||||
current > debit ? current - debit : 0);
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
foreach (uint propertyId in SkillCreditPropertyIds)
|
||||
{
|
||||
if (_localPlayer.DebitIntProperty(propertyId, debit))
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1112,8 +1112,10 @@ public static class CharacterStatController
|
|||
var sheet = data();
|
||||
long cost1 = GetRaiseCost(sheet, selectedIndex, amount: 1);
|
||||
long cost10 = GetRaiseCost(sheet, selectedIndex, amount: 10);
|
||||
bool affordable1 = cost1 > 0 && sheet.UnassignedXp >= cost1;
|
||||
bool affordable10 = cost10 > 0 && sheet.UnassignedXp >= cost10;
|
||||
// CA4 (retired AP-73): while a raise awaits its authoritative
|
||||
// record, retail ghosts the raise controls (one request in flight).
|
||||
bool affordable1 = !sheet.AwaitingRaise && cost1 > 0 && sheet.UnassignedXp >= cost1;
|
||||
bool affordable10 = !sheet.AwaitingRaise && cost10 > 0 && sheet.UnassignedXp >= cost10;
|
||||
|
||||
foreach (var b in allRaise1)
|
||||
{
|
||||
|
|
@ -1146,9 +1148,9 @@ public static class CharacterStatController
|
|||
|
||||
bool trained = selectedSkill.AdvancementClass >= CharacterSkillAdvancementClass.Trained;
|
||||
long cost = trained ? selectedSkill.RaiseCost : selectedSkill.TrainedCost;
|
||||
bool affordable = trained
|
||||
bool affordable = !sheet.AwaitingRaise && (trained
|
||||
? cost > 0 && sheet.UnassignedXp >= cost
|
||||
: cost > 0 && sheet.SkillCredits >= cost;
|
||||
: cost > 0 && sheet.SkillCredits >= cost);
|
||||
foreach (var b in allRaise1)
|
||||
{
|
||||
b.Visible = true;
|
||||
|
|
@ -1163,7 +1165,7 @@ public static class CharacterStatController
|
|||
if (trained)
|
||||
{
|
||||
long cost10 = selectedSkill.Raise10Cost;
|
||||
bool affordable10 = cost10 > 0 && sheet.UnassignedXp >= cost10;
|
||||
bool affordable10 = !sheet.AwaitingRaise && cost10 > 0 && sheet.UnassignedXp >= cost10;
|
||||
b.TrySetRetailState(affordable10
|
||||
? UiButtonStateMachine.Normal
|
||||
: UiButtonStateMachine.Ghosted);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue