Ports the retail CACQualities/EncumbranceSystem/MovementSystem chain (named-retail decomp pc 256393/412901-414050/416169-416320/695958+) so PlayerWeenie's run rate, jump height, jump permission, and jump stamina cost are real functions of burden, current stamina, and vitae/skill enchantments instead of stubs. Core: - New EncumbranceSystem.cs (delegates to the already-verified BurdenMath formulas — one source of truth for the burden HUD and movement physics) and MovementSystem.cs (GetRunRate/GetJumpHeight/ JumpStaminaCost/GetJumpPower, decomp-cited; ACE cross-referenced where BN dropped the general-case arithmetic entirely). - PlayerWeenie rewritten as the CACQualities-shaped composition: CanJump gates on burden (<2.0 load, UN-8 — x87 polarity resolved by plausibility, Ghidra MCP unavailable this slice), JumpStaminaCost returns the real ceil((load+0.5)*power*8+2) cost and always affords it (matches decomp — retail's own function never refuses; "weak" jump comes entirely from the stamina==0 skill-zeroing gate inside InqRunRate/InqJumpVelocity, not a hard refusal), SetStamina wires a null="unknown, don't gate" sentinel preserving every pre-P1 test. - EnchantmentMath.GetMod gained an optional StatModType flag filter (GetSkillMod convenience wrapper) so the SAME vitae/family-stacking machinery already used for vital-max buffs now also answers "what's the vitae+skill-enchantment-adjusted Run/Jump skill" — reusing the M3 active-enchantment state, not a new engine. Runtime: - RuntimeCharacterState now stores the pre-EnchantSkill base run/jump skill and recomputes the adjusted value (vitae first, then matching Skill-flagged buffs, floor 0.5, truncate) on every base push AND on every Spellbook.EnchantmentsChanged notification — a vitae change alone moves the produced rate without a fresh PlayerDescription. - RuntimeMovementSkillState extended with Burden/CurrentStamina (RuntimeMovementSkillProjection.ApplyTo pushes both through the existing seam); LiveSessionEventRouter recomputes burden from the same Strength+aug-property+EncumbranceVal inputs the burden HUD already assembles (reacting to the same ClientObjectTable events) and pushes current stamina from LocalPlayerState vital updates. - Wires the previously dead-lettered ReportExhaustion() R3-W4 seam: LiveSessionRuntimeFactory's OnMovementStatsUpdated callback re- applies the current snapshot to the live controller and forces an immediate movement re-evaluation on any skill/burden/stamina change. Register: retires TS-5 (CanJump/JumpStaminaCost stubs) and AP-25 (no vitae in pushed skill). Adds AP-127 (two minor unmodeled retail bonus properties + the stamina-buff-adjusts-local-copy nuance, deliberately out of the bounded "run/jump query path only" scope) and UN-8 (the CanJump x87 polarity call, flagged for a future Ghidra MCP confirmation pass). Extends TS-23 (PlayerKillerStatus not parsed) to cover JumpStaminaCost's new pk parameter, hardcoded false pending P3. Full pseudocode + retail citations + the vitae/skill-level finding in docs/research/2026-07-30-stat-coupled-movement-pseudocode.md. Release suite: Core.Tests 3977/2 skips, Runtime.Tests 425/0 skips, App.Tests 3968/3 skips — all green. (One pre-existing, unrelated Debug- only flake in LandblockBuildOriginTests reproduces on the pre-P1 baseline and passes in Release; not touched here.) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
435 lines
16 KiB
C#
435 lines
16 KiB
C#
using System.Net;
|
|
using System.Reflection;
|
|
using AcDream.Core.Chat;
|
|
using AcDream.Core.Combat;
|
|
using AcDream.Core.Items;
|
|
using AcDream.Core.Net;
|
|
using AcDream.Core.Player;
|
|
using AcDream.Core.Properties;
|
|
using AcDream.Core.Social;
|
|
using AcDream.Core.Spells;
|
|
using AcDream.Runtime.Session;
|
|
using AcDream.Runtime.Gameplay;
|
|
|
|
namespace AcDream.Runtime.Tests.Session;
|
|
|
|
public sealed class LiveSessionEventRouterTests
|
|
{
|
|
[Fact]
|
|
public void CreatedRouterPublishesNoHandlersUntilExplicitAttach()
|
|
{
|
|
using var session = NewSession();
|
|
int baselineGameEvents = session.GameEvents.RegisteredHandlerCount;
|
|
var router = new LiveSessionEventRouter(
|
|
session,
|
|
new LiveEntitySessionSink(
|
|
_ => { }, _ => { }, _ => { }, _ => { }, _ => { }, _ => { },
|
|
_ => { }, _ => { }, _ => { }, _ => { }, _ => { }, _ => { }),
|
|
new LiveEnvironmentSessionSink(_ => { }, _ => { }),
|
|
NewInventoryBindings(),
|
|
NewCharacterBindings(),
|
|
NewSocialBindings());
|
|
|
|
AssertSessionHandlerCounts(session, multiplier: 0);
|
|
Assert.Equal(baselineGameEvents, session.GameEvents.RegisteredHandlerCount);
|
|
|
|
router.Attach();
|
|
AssertSessionHandlerCounts(session, multiplier: 1);
|
|
Assert.True(session.GameEvents.RegisteredHandlerCount > baselineGameEvents);
|
|
|
|
router.Dispose();
|
|
AssertSessionHandlerCounts(session, multiplier: 0);
|
|
Assert.Equal(baselineGameEvents, session.GameEvents.RegisteredHandlerCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void Dispose_DetachesExactHandlersAndSilencesCopiedDelegates()
|
|
{
|
|
using var session = NewSession();
|
|
int baselineGameEvents = session.GameEvents.RegisteredHandlerCount;
|
|
var counters = new Counters();
|
|
var combat = new CombatState();
|
|
var chat = new ChatLog();
|
|
var router = NewRouter(session, counters, combat: combat, chat: chat);
|
|
|
|
AssertSessionHandlerCounts(session, multiplier: 1);
|
|
Assert.True(session.GameEvents.RegisteredHandlerCount > baselineGameEvents);
|
|
Action<double> copiedTime = EventDelegate<Action<double>>(
|
|
session,
|
|
nameof(session.ServerTimeUpdated));
|
|
Action<uint> copiedTeleport = EventDelegate<Action<uint>>(
|
|
session,
|
|
nameof(session.TeleportStarted));
|
|
Action<WorldSession.PlayerIntPropertyUpdate> copiedPlayerInt =
|
|
EventDelegate<Action<WorldSession.PlayerIntPropertyUpdate>>(
|
|
session,
|
|
nameof(session.PlayerIntPropertyUpdated));
|
|
Action<CombatState.DamageDealt> copiedDamage =
|
|
EventDelegate<CombatState, Action<CombatState.DamageDealt>>(
|
|
combat,
|
|
nameof(combat.DamageDealtAccepted));
|
|
copiedTime(123d);
|
|
copiedTeleport(0x50000001u);
|
|
copiedPlayerInt(new WorldSession.PlayerIntPropertyUpdate(
|
|
CombatStateWiring.CombatModePropertyId,
|
|
(int)CombatMode.Melee));
|
|
copiedDamage(new CombatState.DamageDealt("Drudge", 1u, 5u, 0.1f));
|
|
Assert.Equal(1, counters.ServerTime);
|
|
Assert.Equal(1, counters.Teleport);
|
|
Assert.Equal(CombatMode.Melee, combat.CurrentMode);
|
|
Assert.Equal(1, chat.Count);
|
|
|
|
router.Dispose();
|
|
router.Dispose();
|
|
AssertSessionHandlerCounts(session, multiplier: 0);
|
|
Assert.Equal(baselineGameEvents, session.GameEvents.RegisteredHandlerCount);
|
|
|
|
copiedTime(456d);
|
|
copiedTeleport(0x50000002u);
|
|
copiedPlayerInt(new WorldSession.PlayerIntPropertyUpdate(
|
|
CombatStateWiring.CombatModePropertyId,
|
|
(int)CombatMode.Magic));
|
|
copiedDamage(new CombatState.DamageDealt("Drudge", 1u, 7u, 0.2f));
|
|
Assert.Equal(1, counters.ServerTime);
|
|
Assert.Equal(1, counters.Teleport);
|
|
Assert.Equal(CombatMode.Melee, combat.CurrentMode);
|
|
Assert.Equal(1, chat.Count);
|
|
}
|
|
|
|
[Fact]
|
|
public void NestedRouters_DisposeOlderFirstLeavesOnlyNewerRouter()
|
|
{
|
|
using var session = NewSession();
|
|
int baselineGameEvents = session.GameEvents.RegisteredHandlerCount;
|
|
var first = new Counters();
|
|
var second = new Counters();
|
|
var routerA = NewRouter(session, first);
|
|
var routerB = NewRouter(session, second);
|
|
|
|
AssertSessionHandlerCounts(session, multiplier: 2);
|
|
EventDelegate<Action<double>>(session, nameof(session.ServerTimeUpdated))(1d);
|
|
Assert.Equal(1, first.ServerTime);
|
|
Assert.Equal(1, second.ServerTime);
|
|
|
|
routerA.Dispose();
|
|
AssertSessionHandlerCounts(session, multiplier: 1);
|
|
EventDelegate<Action<double>>(session, nameof(session.ServerTimeUpdated))(2d);
|
|
Assert.Equal(1, first.ServerTime);
|
|
Assert.Equal(2, second.ServerTime);
|
|
|
|
routerB.Dispose();
|
|
AssertSessionHandlerCounts(session, multiplier: 0);
|
|
Assert.Equal(baselineGameEvents, session.GameEvents.RegisteredHandlerCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void NestedRouters_DisposeNewerFirstLeavesOnlyOlderRouter()
|
|
{
|
|
using var session = NewSession();
|
|
int baselineGameEvents = session.GameEvents.RegisteredHandlerCount;
|
|
var first = new Counters();
|
|
var second = new Counters();
|
|
var routerA = NewRouter(session, first);
|
|
var routerB = NewRouter(session, second);
|
|
|
|
routerB.Dispose();
|
|
AssertSessionHandlerCounts(session, multiplier: 1);
|
|
EventDelegate<Action<double>>(session, nameof(session.ServerTimeUpdated))(1d);
|
|
Assert.Equal(1, first.ServerTime);
|
|
Assert.Equal(0, second.ServerTime);
|
|
|
|
routerA.Dispose();
|
|
AssertSessionHandlerCounts(session, multiplier: 0);
|
|
Assert.Equal(baselineGameEvents, session.GameEvents.RegisteredHandlerCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void ThrowingSink_DoesNotCorruptLaterTeardown()
|
|
{
|
|
using var session = NewSession();
|
|
int baselineGameEvents = session.GameEvents.RegisteredHandlerCount;
|
|
var router = NewRouter(
|
|
session,
|
|
new Counters { ThrowOnServerTime = true });
|
|
|
|
Assert.Throws<InvalidOperationException>(() =>
|
|
EventDelegate<Action<double>>(
|
|
session,
|
|
nameof(session.ServerTimeUpdated))(1d));
|
|
|
|
router.Dispose();
|
|
AssertSessionHandlerCounts(session, multiplier: 0);
|
|
Assert.Equal(baselineGameEvents, session.GameEvents.RegisteredHandlerCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void ConstructionFailure_UnwindsEveryPriorRegistration()
|
|
{
|
|
using var session = NewSession();
|
|
int baselineGameEvents = session.GameEvents.RegisteredHandlerCount;
|
|
|
|
Assert.Throws<InvalidOperationException>(() => NewRouter(
|
|
session,
|
|
new Counters(),
|
|
step =>
|
|
{
|
|
if (step == 20)
|
|
throw new InvalidOperationException("injected construction failure");
|
|
}));
|
|
|
|
AssertSessionHandlerCounts(session, multiplier: 0);
|
|
Assert.Equal(baselineGameEvents, session.GameEvents.RegisteredHandlerCount);
|
|
}
|
|
|
|
// ── Campaign P Slice P1 (2026-07-30): burden/stamina recompute wiring ─
|
|
|
|
[Fact]
|
|
public void ObjectTablePropertyChange_RecomputesAndPushesBurden()
|
|
{
|
|
using var session = NewSession();
|
|
const uint playerGuid = 0x50000001u;
|
|
var objects = new ClientObjectTable();
|
|
var character = new RuntimeCharacterState();
|
|
int movementStatsUpdated = 0;
|
|
|
|
var router = new LiveSessionEventRouter(
|
|
session,
|
|
NoOpEntitySink(),
|
|
NoOpEnvironmentSink(),
|
|
new LiveInventorySessionBindings(
|
|
objects,
|
|
PlayerGuid: () => playerGuid,
|
|
OnShortcuts: null,
|
|
OnUseDone: null,
|
|
ItemMana: new ItemManaState(),
|
|
ExternalContainers: new ExternalContainerState()),
|
|
new LiveCharacterSessionBindings(
|
|
new CombatState(),
|
|
character,
|
|
ResolveSkillFormulaBonus: null,
|
|
OnSkillsUpdated: null,
|
|
OnConfirmationRequest: null,
|
|
OnConfirmationDone: null,
|
|
ClientTime: () => 0d,
|
|
OnMovementStatsUpdated: () => movementStatsUpdated++),
|
|
NewSocialBindings());
|
|
|
|
router.Attach();
|
|
|
|
// Strength=100, no augmentation -> capacity=15000 (BurdenMath).
|
|
character.LocalPlayer.OnAttributeUpdate(
|
|
atType: 1u, ranks: 90u, start: 10u, xp: 0u);
|
|
Assert.Equal(1, movementStatsUpdated);
|
|
Assert.Equal(0f, character.MovementSkills.Burden, precision: 4);
|
|
|
|
// EncumbranceVal (property 5) = 7500 -> load = 7500/15000 = 0.5.
|
|
var props = new PropertyBundle();
|
|
props.Ints[(uint)PropertyInt.EncumbranceVal] = 7500;
|
|
objects.UpsertProperties(playerGuid, props);
|
|
|
|
Assert.True(movementStatsUpdated >= 2);
|
|
Assert.Equal(0.5f, character.MovementSkills.Burden, precision: 4);
|
|
|
|
router.Dispose();
|
|
}
|
|
|
|
[Fact]
|
|
public void StaminaVitalChange_PushesCurrentStamina()
|
|
{
|
|
using var session = NewSession();
|
|
var character = new RuntimeCharacterState();
|
|
int movementStatsUpdated = 0;
|
|
|
|
var router = new LiveSessionEventRouter(
|
|
session,
|
|
NoOpEntitySink(),
|
|
NoOpEnvironmentSink(),
|
|
NewInventoryBindings(),
|
|
new LiveCharacterSessionBindings(
|
|
new CombatState(),
|
|
character,
|
|
ResolveSkillFormulaBonus: null,
|
|
OnSkillsUpdated: null,
|
|
OnConfirmationRequest: null,
|
|
OnConfirmationDone: null,
|
|
ClientTime: () => 0d,
|
|
OnMovementStatsUpdated: () => movementStatsUpdated++),
|
|
NewSocialBindings());
|
|
|
|
router.Attach();
|
|
Assert.Equal(-1, character.MovementSkills.CurrentStamina);
|
|
|
|
character.LocalPlayer.OnVitalUpdate(
|
|
vitalId: 8u, ranks: 40u, start: 20u, xp: 0u, current: 45u);
|
|
|
|
Assert.Equal(1, movementStatsUpdated);
|
|
Assert.Equal(45, character.MovementSkills.CurrentStamina);
|
|
|
|
router.Dispose();
|
|
}
|
|
|
|
private static LiveEntitySessionSink NoOpEntitySink() => new(
|
|
Spawned: _ => { },
|
|
Deleted: _ => { },
|
|
PickedUp: _ => { },
|
|
MotionUpdated: _ => { },
|
|
PositionUpdated: _ => { },
|
|
VectorUpdated: _ => { },
|
|
StateUpdated: _ => { },
|
|
ParentUpdated: _ => { },
|
|
TeleportStarted: _ => { },
|
|
AppearanceUpdated: _ => { },
|
|
PlayPhysicsScript: _ => { },
|
|
PlayPhysicsScriptType: _ => { });
|
|
|
|
private static LiveEnvironmentSessionSink NoOpEnvironmentSink() => new(
|
|
EnvironChanged: _ => { },
|
|
ServerTimeUpdated: _ => { });
|
|
|
|
private static LiveSessionEventRouter NewRouter(
|
|
WorldSession session,
|
|
Counters counters,
|
|
Action<int>? constructionCheckpoint = null,
|
|
CombatState? combat = null,
|
|
ChatLog? chat = null)
|
|
{
|
|
var router = new LiveSessionEventRouter(
|
|
session,
|
|
new LiveEntitySessionSink(
|
|
Spawned: _ => { },
|
|
Deleted: _ => { },
|
|
PickedUp: _ => { },
|
|
MotionUpdated: _ => { },
|
|
PositionUpdated: _ => { },
|
|
VectorUpdated: _ => { },
|
|
StateUpdated: _ => { },
|
|
ParentUpdated: _ => { },
|
|
TeleportStarted: _ => counters.Teleport++,
|
|
AppearanceUpdated: _ => { },
|
|
PlayPhysicsScript: _ => { },
|
|
PlayPhysicsScriptType: _ => { }),
|
|
new LiveEnvironmentSessionSink(
|
|
EnvironChanged: _ => { },
|
|
ServerTimeUpdated: _ =>
|
|
{
|
|
if (counters.ThrowOnServerTime)
|
|
throw new InvalidOperationException("injected sink failure");
|
|
counters.ServerTime++;
|
|
}),
|
|
NewInventoryBindings(),
|
|
NewCharacterBindings(combat),
|
|
NewSocialBindings(chat),
|
|
constructionCheckpoint);
|
|
try
|
|
{
|
|
router.Attach();
|
|
return router;
|
|
}
|
|
catch
|
|
{
|
|
router.Dispose();
|
|
throw;
|
|
}
|
|
}
|
|
|
|
private static LiveInventorySessionBindings NewInventoryBindings() => new(
|
|
new ClientObjectTable(),
|
|
PlayerGuid: () => 0x50000001u,
|
|
OnShortcuts: null,
|
|
OnUseDone: null,
|
|
ItemMana: new ItemManaState(),
|
|
ExternalContainers: new ExternalContainerState());
|
|
|
|
private static LiveCharacterSessionBindings NewCharacterBindings(
|
|
CombatState? combat = null) => new(
|
|
combat ?? new CombatState(),
|
|
new RuntimeCharacterState(),
|
|
ResolveSkillFormulaBonus: null,
|
|
OnSkillsUpdated: null,
|
|
OnConfirmationRequest: null,
|
|
OnConfirmationDone: null,
|
|
ClientTime: () => 0d);
|
|
|
|
private static LiveSocialSessionBindings NewSocialBindings(
|
|
ChatLog? chat = null) => new(
|
|
chat ?? new ChatLog(),
|
|
new TurbineChatState(),
|
|
new FriendsState(),
|
|
new SquelchState());
|
|
|
|
private static WorldSession NewSession() =>
|
|
new(new IPEndPoint(IPAddress.Loopback, 9));
|
|
|
|
private static void AssertSessionHandlerCounts(
|
|
WorldSession session,
|
|
int multiplier)
|
|
{
|
|
string[] directEvents =
|
|
[
|
|
nameof(session.EntitySpawned),
|
|
nameof(session.EntityDeleted),
|
|
nameof(session.EntityPickedUp),
|
|
nameof(session.MotionUpdated),
|
|
nameof(session.PositionUpdated),
|
|
nameof(session.VectorUpdated),
|
|
nameof(session.StateUpdated),
|
|
nameof(session.ParentUpdated),
|
|
nameof(session.TeleportStarted),
|
|
nameof(session.AppearanceUpdated),
|
|
nameof(session.PlayPhysicsScriptReceived),
|
|
nameof(session.PlayPhysicsScriptTypeReceived),
|
|
nameof(session.EnvironChanged),
|
|
nameof(session.ServerTimeUpdated),
|
|
nameof(session.SpeechHeard),
|
|
nameof(session.ServerMessageReceived),
|
|
nameof(session.EmoteHeard),
|
|
nameof(session.SoulEmoteHeard),
|
|
nameof(session.PlayerKilledReceived),
|
|
nameof(session.TurbineChatReceived),
|
|
nameof(session.VitalUpdated),
|
|
nameof(session.VitalCurrentUpdated),
|
|
];
|
|
foreach (string eventName in directEvents)
|
|
Assert.Equal(multiplier, HandlerCount(session, eventName));
|
|
|
|
Assert.Equal(multiplier, HandlerCount(session, nameof(session.ObjectIntPropertyUpdated)));
|
|
Assert.Equal(multiplier * 2, HandlerCount(session, nameof(session.PlayerIntPropertyUpdated)));
|
|
Assert.Equal(multiplier, HandlerCount(session, nameof(session.PlayerInt64PropertyUpdated)));
|
|
Assert.Equal(multiplier, HandlerCount(session, nameof(session.StackSizeUpdated)));
|
|
Assert.Equal(multiplier, HandlerCount(session, nameof(session.InventoryObjectRemoved)));
|
|
}
|
|
|
|
private static int HandlerCount(WorldSession session, string eventName) =>
|
|
(EventField(session, eventName).GetValue(session) as MulticastDelegate)?
|
|
.GetInvocationList()
|
|
.Length ?? 0;
|
|
|
|
private static TDelegate EventDelegate<TDelegate>(
|
|
WorldSession session,
|
|
string eventName)
|
|
where TDelegate : Delegate =>
|
|
Assert.IsType<TDelegate>(EventField(session, eventName).GetValue(session));
|
|
|
|
private static TDelegate EventDelegate<TOwner, TDelegate>(
|
|
TOwner owner,
|
|
string eventName)
|
|
where TOwner : class
|
|
where TDelegate : Delegate =>
|
|
Assert.IsType<TDelegate>(
|
|
typeof(TOwner).GetField(
|
|
eventName,
|
|
BindingFlags.Instance | BindingFlags.NonPublic)?.GetValue(owner));
|
|
|
|
private static FieldInfo EventField(WorldSession session, string eventName) =>
|
|
typeof(WorldSession).GetField(
|
|
eventName,
|
|
BindingFlags.Instance | BindingFlags.NonPublic)
|
|
?? throw new InvalidOperationException($"event field {eventName} not found");
|
|
|
|
private sealed class Counters
|
|
{
|
|
public int ServerTime;
|
|
public int Teleport;
|
|
public bool ThrowOnServerTime;
|
|
}
|
|
}
|