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

@ -1,4 +1,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using AcDream.Core.Items;
using AcDream.Core.Physics;
using AcDream.Core.Properties;
@ -462,11 +464,19 @@ public sealed class LocalPlayerState
/// record after an Endurance raise but NOT a Stamina one, with the
/// explicit comment that the client is expected to refresh both; this
/// local fan-out is that expectation (research doc §4.1).
/// Skill snapshots cache their attribute-formula contribution
/// (<see cref="SkillSnapshot.FormulaBonus"/>), so an attribute write
/// re-derives every cached bonus through
/// <see cref="SkillFormulaBonusResolver"/> before observers re-pull —
/// the compute-on-write equivalent of retail's compute-on-read; the
/// values agree at every read because the ONLY input that changes
/// between reads is exactly what this method writes.
/// </remarks>
public void OnAttributeUpdate(uint atType, uint ranks, uint start, uint xp)
{
if (AttributeIdToKind(atType) is not AttributeKind kind) return;
_attrs[kind] = new AttributeSnapshot(ranks, start, xp);
RecomputeSkillFormulaBonuses();
AttributeChanged?.Invoke(kind);
switch (kind)
{
@ -537,14 +547,82 @@ public sealed class LocalPlayerState
uint resistance,
double lastUsed)
{
uint formulaBonus = _skills.TryGetValue(skillId, out var prev)
? prev.FormulaBonus
: 0u;
// The wire record carries no attribute contribution (retail computes
// it live at inquiry): re-derive through the resolver when present —
// this also covers a freshly TRAINED skill unseen at login — else
// preserve the login-derived value.
uint formulaBonus = SkillFormulaBonusResolver is { } resolver
? resolver(skillId, AttributeCurrentsById())
: _skills.TryGetValue(skillId, out var prev)
? prev.FormulaBonus
: 0u;
_skills[skillId] = new SkillSnapshot(
skillId, ranks, status, xp, init, resistance, lastUsed, formulaBonus);
CharacterChanged?.Invoke();
}
/// <summary>
/// Campaign CA CA3 (#431): the SkillTable attribute-formula resolver —
/// the same delegate shape <c>GameEventWiring</c> uses at
/// PlayerDescription parse (App supplies
/// <c>LiveSkillCreditResolver.Resolve</c> over the loaded SkillTable;
/// headless/no-dat hosts leave it null and keep login-cached bonuses).
/// </summary>
public Func<uint /*skillId*/, IReadOnlyDictionary<uint, uint> /*attrCurrents*/, uint>?
SkillFormulaBonusResolver { get; set; }
/// <summary>
/// Campaign CA CA3 (#431): re-derive every skill snapshot's cached
/// attribute-formula contribution from the current attributes. Retail
/// re-derives at every inquiry (<c>InqSkillBaseLevel @ 0x00592140</c> →
/// <c>SkillFormula::Calculate @ 0x00591960</c>); recomputing at the
/// only write that changes the inputs yields identical values at every
/// read. No-op without a resolver.
/// </summary>
public void RecomputeSkillFormulaBonuses()
{
if (SkillFormulaBonusResolver is not { } resolver || _skills.Count == 0)
return;
IReadOnlyDictionary<uint, uint> currents = AttributeCurrentsById();
foreach (uint skillId in _skills.Keys.ToArray())
{
SkillSnapshot snap = _skills[skillId];
uint bonus = resolver(skillId, currents);
if (bonus != snap.FormulaBonus)
_skills[skillId] = snap with { FormulaBonus = bonus };
}
}
/// <summary>
/// Current attribute values keyed by wire id (1=Strength..6=Self) — the
/// dictionary shape <see cref="SkillFormulaBonusResolver"/> and
/// <c>GameEventWiring</c>'s PlayerDescription path share.
/// </summary>
public IReadOnlyDictionary<uint, uint> AttributeCurrentsById()
{
var currents = new Dictionary<uint, uint>(_attrs.Count);
foreach ((AttributeKind kind, AttributeSnapshot snap) in _attrs)
currents[(uint)kind + 1u] = snap.Current;
return currents;
}
/// <summary>
/// Campaign CA CA3 (#431): the movement-skill totals
/// (<c>formulaBonus + init + ranks</c>, ACE Skill ordinals Run=24 /
/// Jump=22) in exactly the shape <c>GameEventWiring</c> computes at
/// PlayerDescription parse — so live raises push the SAME numbers down
/// the SAME movement seam. 1 = skill unknown (keep the previous value).
/// </summary>
public (int RunSkill, int JumpSkill) MovementSkillTotals()
{
int run = -1, jump = -1;
if (_skills.TryGetValue(24u, out var runSnap))
run = (int)(runSnap.FormulaBonus + runSnap.Init + runSnap.Ranks);
if (_skills.TryGetValue(22u, out var jumpSnap))
jump = (int)(jumpSnap.FormulaBonus + jumpSnap.Init + jumpSnap.Ranks);
return (run, jump);
}
/// <summary>
/// Optimistically apply a successful local attribute-raise action.
/// The next server snapshot remains authoritative; this keeps UI state current