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
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue