feat(ui) Campaign CA CA4 #431: server-authoritative raises — the optimistic layer is deleted
Some checks failed
CI / linux-portable (push) Successful in 3m30s
CI / windows-gate (push) Failing after 6m32s
CI / release (push) Has been skipped

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:
Erik 2026-08-24 13:56:31 +02:00
parent 5781895977
commit 08b77e20a9
8 changed files with 152 additions and 339 deletions

View file

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

View file

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

View file

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

View file

@ -623,105 +623,6 @@ public sealed class LocalPlayerState
return (run, jump);
}
/// <summary>
/// Optimistically apply a successful local attribute-raise action.
/// The next server snapshot remains authoritative; this keeps UI state current
/// during the round trip after sending the retail raise action.
/// </summary>
public bool ApplyAttributeRaise(uint atType, uint amount, ulong xpSpent)
{
if (AttributeIdToKind(atType) is not AttributeKind kind) return false;
if (!_attrs.TryGetValue(kind, out var prev)) return false;
_attrs[kind] = prev with
{
Ranks = SaturatingAdd(prev.Ranks, amount),
Xp = SaturatingAdd(prev.Xp, xpSpent),
};
AttributeChanged?.Invoke(kind);
return true;
}
/// <summary>Optimistically apply a successful local max-vital raise action.</summary>
public bool ApplyVitalRaise(uint vitalId, uint amount, ulong xpSpent)
{
if (VitalIdToKind(vitalId) is not VitalKind kind) return false;
VitalSnapshot? existing = Get(kind);
if (existing is not VitalSnapshot prev) return false;
var snap = prev with
{
Ranks = SaturatingAdd(prev.Ranks, amount),
Xp = SaturatingAdd(prev.Xp, xpSpent),
};
switch (kind)
{
case VitalKind.Health: _health = snap; break;
case VitalKind.Stamina: _stamina = snap; break;
case VitalKind.Mana: _mana = snap; break;
}
Changed?.Invoke(kind);
return true;
}
/// <summary>Optimistically promote an untrained skill after a successful TrainSkill action.</summary>
public bool ApplySkillTraining(uint skillId)
{
if (!_skills.TryGetValue(skillId, out var prev)) return false;
if (prev.Status >= 2u) return false;
_skills[skillId] = prev with { Status = 2u };
CharacterChanged?.Invoke();
return true;
}
/// <summary>Optimistically apply a successful local skill-raise action.</summary>
public bool ApplySkillRaise(uint skillId, uint amount, ulong xpSpent)
{
if (!_skills.TryGetValue(skillId, out var prev)) return false;
if (prev.Status < 2u) return false;
_skills[skillId] = prev with
{
Ranks = SaturatingAdd(prev.Ranks, amount),
Xp = SaturatingAdd(prev.Xp, xpSpent),
};
CharacterChanged?.Invoke();
return true;
}
/// <summary>
/// Optimistically debit an int property in the player's property bundle
/// (clamped at 0), firing <see cref="CharacterChanged"/> so bound UI
/// refreshes. False if the property is absent — callers walk their
/// fallback id chain. The next server snapshot remains authoritative.
/// All local-player property writes go through eventful APIs like this
/// one; writing <see cref="Properties"/> dictionaries directly skips the
/// change event and is a single-owner-state violation.
/// </summary>
public bool DebitIntProperty(uint propertyId, int amount)
{
if (!_properties.Ints.TryGetValue(propertyId, out int current))
return false;
_properties.Ints[propertyId] = current > amount ? current - amount : 0;
CharacterChanged?.Invoke();
return true;
}
/// <summary>
/// Optimistically debit an int64 property (clamped at 0), firing
/// <see cref="CharacterChanged"/>. False if the property is absent or
/// already ≤ 0. The next server snapshot remains authoritative.
/// </summary>
public bool DebitInt64Property(uint propertyId, long amount)
{
if (!_properties.Int64s.TryGetValue(propertyId, out long current) || current <= 0)
return false;
_properties.Int64s[propertyId] = current > amount ? current - amount : 0L;
CharacterChanged?.Invoke();
return true;
}
/// <summary>
/// Return the character snapshot to its pre-login state. The object itself
/// is process-lived because UI view models subscribe to it once; session