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
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue