feat(net) Campaign CA CA2 #431: parse the inbound attribute/skill update family
The server's authoritative answers to a raise were dropped on the floor: only the vitals pair (0x02E7/0x02E9) had parsers, so after any RaiseAttribute/RaiseSkill/TrainSkill the client's stat model stayed frozen at login's PlayerDescription — the root cause of #431's stale derived skills and run speed. The GUI looked alive only because the panel applies optimistic local raises. New parsers with three-source-verified layouts (CA1 research doc §2.5/ §2.8): PrivateUpdateAttribute (0x02E3) and PrivateUpdateSkill (0x02DD — the wire's ushort ranks + hardcoded adjustPP=1 pair and f64 lastUsedTime preserved exactly). WorldSession dispatches both as typed events; LiveSessionEventRouter routes them into the J4 character owner's LocalPlayerState like every other private update. The vestigial PrivateUpdateSkillLevel (0x02DF) is deliberately unparsed — ACE has no producer (verified). OnAttributeUpdate now fans out to the derived-value observers, mirroring retail's live-at-inquiry model (CACQualities::InqSkill 0x00592660 — Set* writes raw, Inq* recomputes, notification carries no value): an Endurance write notifies the Health AND Stamina vital observers (ACE pushes only a Health record and its own comment says the client must refresh both), Self notifies Mana, and every attribute write notifies character-sheet consumers whose formula contributions just changed. OnSkillWireUpdate preserves the login FormulaBonus — the wire record carries no attribute contribution; CA3 replaces the cached field with the live computation. Also corrected while in the neighborhood: PropertyString.cs's comment claimed opcode 0x02DD for PrivateUpdatePropertyString; ACE's enum says 0x02D5/0x02D6 (doc-only — nothing dispatched on either). Conformance tests cover both layouts (including holtburger's golden skill fixture with adjustPP=1), truncation/wrong-opcode rejection, the Endurance/Self/Quickness fan-out contract, and FormulaBonus preservation. Full hermetic suite 15,333 passed / 0 failed (one load-sensitive transport flake observed on the first run, passed alone and on the clean re-run — filed as #439 rather than chased). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
1fc64984c9
commit
65430d4c7c
9 changed files with 474 additions and 4 deletions
|
|
@ -709,4 +709,59 @@ public sealed class LocalPlayerStateTests
|
|||
spellId, "Test", "War Magic", 0u, 0u, "", 0f, 0,
|
||||
false, false, "", 0, 0, 0u, 0, false, false, true,
|
||||
0f, 0u, 0u, 0u, 0);
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Campaign CA CA2 (#431): live post-raise updates.
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void AttributeUpdateFansOutToDerivedVitalObserversAndCharacterSheet()
|
||||
{
|
||||
// Retail pushes a full Health record after an Endurance raise but NOT
|
||||
// a Stamina one, expecting the client to refresh both (ACE
|
||||
// Player_Attributes.cs:56-58; research doc §4.1). The fan-out is the
|
||||
// client-side half of that contract.
|
||||
var s = new LocalPlayerState();
|
||||
var vitalEvents = new List<LocalPlayerState.VitalKind>();
|
||||
int attributeEvents = 0, characterEvents = 0;
|
||||
s.Changed += k => vitalEvents.Add(k);
|
||||
s.AttributeChanged += _ => attributeEvents++;
|
||||
s.CharacterChanged += () => characterEvents++;
|
||||
|
||||
s.OnAttributeUpdate(atType: 2u /* Endurance */, ranks: 10u, start: 100u, xp: 500u);
|
||||
Assert.Equal(
|
||||
[LocalPlayerState.VitalKind.Health, LocalPlayerState.VitalKind.Stamina],
|
||||
vitalEvents);
|
||||
Assert.Equal(1, attributeEvents);
|
||||
Assert.Equal(1, characterEvents);
|
||||
|
||||
vitalEvents.Clear();
|
||||
s.OnAttributeUpdate(atType: 6u /* Self */, ranks: 5u, start: 100u, xp: 250u);
|
||||
Assert.Equal([LocalPlayerState.VitalKind.Mana], vitalEvents);
|
||||
|
||||
vitalEvents.Clear();
|
||||
s.OnAttributeUpdate(atType: 3u /* Quickness */, ranks: 1u, start: 100u, xp: 10u);
|
||||
Assert.Empty(vitalEvents); // no vital derives from Quickness
|
||||
Assert.Equal(3, attributeEvents);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SkillWireUpdatePreservesTheLoginFormulaBonus()
|
||||
{
|
||||
// The 0x02DD record carries no attribute contribution (retail
|
||||
// computes it live at inquiry). Until CA3 makes the computation
|
||||
// live, the wire update must not wipe the login-derived bonus.
|
||||
var s = new LocalPlayerState();
|
||||
s.OnSkillUpdate(skillId: 6u, ranks: 10u, status: 2u, xp: 100u,
|
||||
init: 0u, resistance: 0u, lastUsed: 0d, formulaBonus: 120u);
|
||||
|
||||
s.OnSkillWireUpdate(skillId: 6u, ranks: 11u, status: 2u, xp: 2000u,
|
||||
init: 0u, resistance: 0u, lastUsed: 5.0d);
|
||||
|
||||
var snap = s.Skills[6u];
|
||||
Assert.Equal(11u, snap.Ranks);
|
||||
Assert.Equal(2000u, snap.Xp);
|
||||
Assert.Equal(120u, snap.FormulaBonus);
|
||||
Assert.Equal(131u, snap.BaseLevel); // 120 formula + 0 init + 11 ranks
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue