feat(runtime) Campaign CA CA3 #431: live derived-stat recompute — a raise is visible without a relog
Some checks are pending
CI / release (push) Blocked by required conditions
CI / windows-gate (push) Has started running
CI / linux-portable (push) Successful in 3m28s

The recompute half of #431, on the CA1 verdict that retail computes
derived values LIVE at inquiry (Set* writes raw; InqSkillBaseLevel
0x00592140 -> SkillFormula::Calculate 0x00591960 re-derive per call;
InqRunRate 0x00592800 runs every motion tick; UI notifications carry no
value and widgets re-pull):

- LocalPlayerState gains the SkillTable formula resolver — the same
  delegate shape (and App-side implementation, RetailSkillFormula over
  the loaded SkillTable) the PlayerDescription path already uses. An
  attribute write re-derives every skill snapshot's cached formula
  contribution; recomputing at the only write that changes the inputs
  yields values identical to retail's compute-on-read at every read. A
  freshly TRAINED skill unseen at login derives its contribution live
  instead of defaulting to zero forever.
- The router pushes movement-skill totals down the SAME seam
  PlayerDescription uses (UpdateMovementSkillBase -> vitae/enchantment
  recompute -> OnSkillsUpdated -> the App stats applier) after an
  attribute update, and after a skill update for Run (24) / Jump (22)
  only. This is what turns a Quickness raise into visible run speed
  mid-session; the server's own movement-packet echo
  (HandleRunRateUpdate -> ApplyServerRunRate) remains the correcting
  authority.
- Vitals maxima needed no new plumbing: GetMaxApprox reads attribute
  currents live and the vitals window binds getter lambdas re-read per
  frame, so CA2's attribute fan-out completes that path. The character
  panel already subscribes to AttributeChanged/CharacterChanged.

Tests: router behavior test drives the real WorldSession events through
the real router and asserts the full chain (state write, live 160/2=80
re-derivation, movement push totals, and that a non-movement skill does
NOT push); the subscription-count contract now includes the two new
events; Core tests cover the fresh-train resolver derivation. Full
hermetic suite 15,335 passed / 0 failed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-24 13:46:00 +02:00
parent 65430d4c7c
commit 5781895977
5 changed files with 251 additions and 21 deletions

View file

@ -764,4 +764,25 @@ public sealed class LocalPlayerStateTests
Assert.Equal(120u, snap.FormulaBonus);
Assert.Equal(131u, snap.BaseLevel); // 120 formula + 0 init + 11 ranks
}
[Fact]
public void FreshlyTrainedSkillDerivesItsFormulaBonusThroughTheResolver()
{
// CA3: a skill first seen via the wire (a fresh TrainSkill) was
// never in PlayerDescription, so its attribute contribution must
// come from the live resolver, not default to zero forever.
var s = new LocalPlayerState
{
SkillFormulaBonusResolver = (skillId, attrs) =>
skillId == 33u && attrs.TryGetValue(4u, out uint coordination)
? coordination / 4u
: 0u,
};
s.OnAttributeUpdate(atType: 4u /* Coordination */, ranks: 0u, start: 80u, xp: 0u);
s.OnSkillWireUpdate(skillId: 33u, ranks: 0u, status: 2u, xp: 0u,
init: 0u, resistance: 0u, lastUsed: 0d);
Assert.Equal(20u, s.Skills[33u].FormulaBonus); // 80 / 4
}
}