feat(runtime) Campaign CA CA3 #431: live derived-stat recompute — a raise is visible without a relog
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:
parent
65430d4c7c
commit
5781895977
5 changed files with 251 additions and 21 deletions
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -779,6 +779,90 @@ public sealed class LiveSessionEventRouterTests
|
|||
router.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AttributeAndSkillUpdates_RouteToPlayerStateAndMovementSeam()
|
||||
{
|
||||
// Campaign CA CA2/CA3 (#431): the authoritative post-raise records
|
||||
// must (a) land in LocalPlayerState, (b) re-derive the cached
|
||||
// formula contributions through the SAME resolver shape the
|
||||
// PlayerDescription path uses, and (c) re-apply movement skills
|
||||
// down the SAME seam (UpdateMovementSkillBase -> OnSkillsUpdated ->
|
||||
// OnMovementStatsUpdated) — that chain is what turns a Quickness
|
||||
// raise into visible run speed without a relog.
|
||||
using var session = NewSession();
|
||||
var character = new RuntimeCharacterState();
|
||||
var skillsPushed = new List<(int Run, int Jump)>();
|
||||
int movementStatsUpdated = 0;
|
||||
|
||||
var router = new LiveSessionEventRouter(
|
||||
session,
|
||||
NoOpEntitySink(),
|
||||
NoOpEnvironmentSink(),
|
||||
NewInventoryBindings(),
|
||||
new LiveCharacterSessionBindings(
|
||||
new CombatState(),
|
||||
character,
|
||||
// Fake SkillTable formula: Run (24) derives from Quickness
|
||||
// (id 3) / 2; everything else contributes nothing.
|
||||
ResolveSkillFormulaBonus: (skillId, attrs) =>
|
||||
skillId == 24u && attrs.TryGetValue(3u, out uint quickness)
|
||||
? quickness / 2u
|
||||
: 0u,
|
||||
OnSkillsUpdated: (run, jump) => skillsPushed.Add((run, jump)),
|
||||
OnConfirmationRequest: null,
|
||||
OnConfirmationDone: null,
|
||||
ClientTime: () => 0d,
|
||||
OnMovementStatsUpdated: () => movementStatsUpdated++),
|
||||
NewSocialBindings());
|
||||
router.Attach();
|
||||
|
||||
// Login-time skill snapshot: Run trained, formula bonus from the
|
||||
// pre-raise Quickness current of 100 (100/2 = 50).
|
||||
character.LocalPlayer.OnAttributeUpdate(
|
||||
atType: 3u, ranks: 0u, start: 100u, xp: 0u);
|
||||
character.LocalPlayer.OnSkillUpdate(
|
||||
skillId: 24u, ranks: 10u, status: 2u, xp: 0u,
|
||||
init: 5u, resistance: 0u, lastUsed: 0d, formulaBonus: 50u);
|
||||
skillsPushed.Clear();
|
||||
movementStatsUpdated = 0;
|
||||
|
||||
// The server's answer to a Quickness raise: current 100 -> 160.
|
||||
EventDelegate<Action<PrivateUpdateAttribute.Parsed>>(
|
||||
session, nameof(session.AttributeUpdated))
|
||||
.Invoke(new PrivateUpdateAttribute.Parsed(
|
||||
Sequence: 1, AttributeId: 3u, Ranks: 60u, Start: 100u, Xp: 500u));
|
||||
|
||||
Assert.Equal(160u,
|
||||
character.LocalPlayer.GetAttribute(
|
||||
LocalPlayerState.AttributeKind.Quickness)?.Current);
|
||||
// Formula contribution re-derived live: 160/2 = 80.
|
||||
Assert.Equal(80u, character.LocalPlayer.Skills[24u].FormulaBonus);
|
||||
// Movement re-applied with the new total: 80 formula + 5 init + 10 ranks.
|
||||
Assert.Equal((95, -1), Assert.Single(skillsPushed));
|
||||
Assert.Equal(1, movementStatsUpdated);
|
||||
|
||||
// The server's answer to a Run skill raise: ranks 10 -> 11.
|
||||
EventDelegate<Action<PrivateUpdateSkill.Parsed>>(
|
||||
session, nameof(session.SkillUpdated))
|
||||
.Invoke(new PrivateUpdateSkill.Parsed(
|
||||
Sequence: 2, SkillId: 24u, Ranks: 11u, AdjustPP: 1,
|
||||
AdvancementClass: 2u, Xp: 1000u, Init: 5u,
|
||||
Resistance: 0u, LastUsed: 0d));
|
||||
Assert.Equal((96, -1), skillsPushed[^1]);
|
||||
Assert.Equal(2, movementStatsUpdated);
|
||||
|
||||
// A non-movement skill update must not push movement.
|
||||
EventDelegate<Action<PrivateUpdateSkill.Parsed>>(
|
||||
session, nameof(session.SkillUpdated))
|
||||
.Invoke(new PrivateUpdateSkill.Parsed(
|
||||
Sequence: 3, SkillId: 6u, Ranks: 1u, AdjustPP: 1,
|
||||
AdvancementClass: 2u, Xp: 0u, Init: 0u,
|
||||
Resistance: 0u, LastUsed: 0d));
|
||||
Assert.Equal(2, movementStatsUpdated);
|
||||
|
||||
router.Dispose();
|
||||
}
|
||||
|
||||
private static LiveEntitySessionSink NoOpEntitySink() => new(
|
||||
Spawned: _ => { },
|
||||
Deleted: _ => { },
|
||||
|
|
@ -964,6 +1048,8 @@ public sealed class LiveSessionEventRouterTests
|
|||
nameof(session.TurbineChatReceived),
|
||||
nameof(session.VitalUpdated),
|
||||
nameof(session.VitalCurrentUpdated),
|
||||
nameof(session.AttributeUpdated),
|
||||
nameof(session.SkillUpdated),
|
||||
];
|
||||
foreach (string eventName in directEvents)
|
||||
Assert.Equal(multiplier, HandlerCount(session, eventName));
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue