acdream/tests/AcDream.Runtime.Tests/Session/LiveSessionEventRouterTests.cs

555 lines
21 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 StrengthEnchantmentChange_RecomputesBurdenWithoutBaseAttributeUpdate()
{
using var session = NewSession();
const uint playerGuid = 0x50000001u;
var objects = new ClientObjectTable();
var character = new RuntimeCharacterState();
character.InstallSpellMetadata(SpellTable.LoadFromReader(new StringReader(
"Spell ID,Name,Flags [Hex]\n42,Strength Test,0x4\n")));
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();
character.LocalPlayer.OnAttributeUpdate(
atType: 1u, ranks: 90u, start: 10u, xp: 0u);
var props = new PropertyBundle();
props.Ints[(uint)PropertyInt.EncumbranceVal] = 16500;
objects.UpsertProperties(playerGuid, props);
Assert.Equal(1.1f, character.MovementSkills.Burden, precision: 4);
int beforeBuff = movementStatsUpdated;
character.Spellbook.OnEnchantmentAdded(new ActiveEnchantmentRecord(
SpellId: 42u,
LayerId: 1u,
Duration: 60d,
CasterGuid: playerGuid,
StatModType: (uint)EnchantmentMath.EnchantmentTypeFlag.Attribute,
StatModKey: 1u,
StatModValue: 1.2f,
Bucket: 1u));
Assert.Equal(120, character.LocalPlayer.GetEffectiveAttribute(
LocalPlayerState.AttributeKind.Strength));
Assert.Equal(16500f / 18000f, character.MovementSkills.Burden, precision: 4);
Assert.Equal(beforeBuff + 1, movementStatsUpdated);
int beforePurge = movementStatsUpdated;
character.Spellbook.OnPurgeAll();
Assert.Equal(100, character.LocalPlayer.GetEffectiveAttribute(
LocalPlayerState.AttributeKind.Strength));
Assert.Equal(1.1f, character.MovementSkills.Burden, precision: 4);
Assert.Equal(beforePurge + 1, movementStatsUpdated);
router.Dispose();
}
[Fact]
public void ObjectTablePropertyChange_RecomputesMovementSkillAugmentations()
{
using var session = NewSession();
const uint playerGuid = 0x50000001u;
var objects = new ClientObjectTable();
var character = new RuntimeCharacterState();
character.LocalPlayer.OnSkillUpdate(
RuntimeCharacterState.RunSkillId,
ranks: 0u,
status: 3u,
xp: 0u,
init: 0u,
resistance: 0u,
lastUsed: 0d,
formulaBonus: 200u);
character.UpdateMovementSkillBase(
runSkillBase: 200,
jumpSkillBase: -1);
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),
NewSocialBindings());
router.Attach();
var properties = new PropertyBundle();
properties.Ints[(uint)PropertyInt.LumAugAllSkills] = 2;
properties.Ints[(uint)PropertyInt.AugmentationJackOfAllTrades] = 1;
properties.Ints[(uint)PropertyInt.LumAugSkilledSpec] = 3;
objects.UpsertProperties(playerGuid, properties);
Assert.Equal(213, character.MovementSkills.RunSkill);
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;
}
}