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

@ -460,28 +460,48 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting
vital => character.Character.LocalPlayer.OnVitalCurrent(
vital.VitalId,
vital.Current));
// Campaign CA CA2 (#431): the authoritative post-raise records.
// Until these were routed, attributes and skills were stale
// from login's PlayerDescription until the next login.
// Campaign CA CA2/CA3 (#431): the authoritative post-raise
// records. Until these were routed, attributes and skills were
// stale from login's PlayerDescription until the next login.
// CA3 gives the player state the same SkillTable formula
// resolver the PlayerDescription path uses, so an attribute
// write re-derives every cached formula contribution (retail
// computes live at inquiry — InqSkillBaseLevel @ 0x00592140);
// movement then re-applies through the SAME seam as PD
// (UpdateMovementSkillBase -> vitae/enchant recompute ->
// OnSkillsUpdated -> stats application), which is how a
// Quickness raise becomes visible run speed without a relog.
character.Character.LocalPlayer.SkillFormulaBonusResolver =
character.ResolveSkillFormulaBonus;
Subscribe<PrivateUpdateAttribute.Parsed>(
h => session.AttributeUpdated += h,
h => session.AttributeUpdated -= h,
attr => character.Character.LocalPlayer.OnAttributeUpdate(
attr.AttributeId,
attr.Ranks,
attr.Start,
attr.Xp));
attr =>
{
character.Character.LocalPlayer.OnAttributeUpdate(
attr.AttributeId,
attr.Ranks,
attr.Start,
attr.Xp);
PushMovementSkillTotals(character);
});
Subscribe<PrivateUpdateSkill.Parsed>(
h => session.SkillUpdated += h,
h => session.SkillUpdated -= h,
skill => character.Character.LocalPlayer.OnSkillWireUpdate(
skill.SkillId,
skill.Ranks,
skill.AdvancementClass,
skill.Xp,
skill.Init,
skill.Resistance,
skill.LastUsed));
skill =>
{
character.Character.LocalPlayer.OnSkillWireUpdate(
skill.SkillId,
skill.Ranks,
skill.AdvancementClass,
skill.Xp,
skill.Init,
skill.Resistance,
skill.LastUsed);
// Run=24 / Jump=22 are the only movement inputs.
if (skill.SkillId is 22u or 24u)
PushMovementSkillTotals(character);
});
if (Interlocked.CompareExchange(ref _lifecycleState, 2, 1) != 1)
throw new ObjectDisposedException(nameof(LiveSessionEventRouter));
@ -556,6 +576,31 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting
ConstructionCheckpoint();
}
/// <summary>
/// Campaign CA CA3 (#431): re-apply the movement-skill bases after a
/// live attribute/skill update, down the SAME chain the
/// PlayerDescription path uses (GameEventWiring's onSkillsUpdated →
/// UpdateMovementSkillBase → vitae/enchantment recompute →
/// OnSkillsUpdated/OnMovementStatsUpdated → the App stats applier).
/// Retail re-inquires run rate every motion tick
/// (<c>CACQualities::InqRunRate @ 0x00592800</c> from
/// <c>CMotionInterp</c>); re-applying at the only writes that change
/// the inputs is our event-driven equivalent, and the server's own
/// re-broadcast echo (<c>HandleRunRateUpdate</c> →
/// <c>ApplyServerRunRate</c>) remains the correcting authority.
/// </summary>
private static void PushMovementSkillTotals(
LiveCharacterSessionBindings character)
{
(int runSkill, int jumpSkill) =
character.Character.LocalPlayer.MovementSkillTotals();
if (runSkill < 0 && jumpSkill < 0)
return;
character.Character.UpdateMovementSkillBase(runSkill, jumpSkill);
character.OnSkillsUpdated?.Invoke(runSkill, jumpSkill);
character.OnMovementStatsUpdated?.Invoke();
}
/// <summary>
/// Campaign P Slice P1 (2026-07-30): retail <c>CACQualities::InqLoad</c>
/// equivalent (Strength + augmentation property 0xE6 + EncumbranceVal