acdream never parsed retail's Sound event, so every server-driven cue was silent: melee hits and wounds, wield/unwield, pickup/drop, lockpicking, lifestone bind, spell resist, trap triggers, item mana depletion. SoundEvent parses the 16-byte message (guid, SoundType, f32 volume) whose layout three oracles agree on: retail CM_Physics::DispatchSB_SoundEvent @0x006AC760 reading buf+4/+8/+0xC, ACE's GameMessageSound at declared length 16, and holtburger's PlaySoundData. Playback reuses EntityEffectController's existing per-guid queue rather than adding a second one, because retail routes sounds through the SAME CObjectMaint blob queue as F754/F755: an event for a guid the client does not know yet is parked and drained by HandleCreateObject, so a creature that spawns and immediately grunts still grunts. Dropping it — the obvious alternative — would silently lose the cue. Sound joins Direct and Typed as a third PendingEffect kind so one readiness edge releases the whole mixed stream in order. AudioHookSink.PlayServerSound reproduces two decoded asymmetries with the animation-hook path: the sound plays at the WIRE volume and the SoundTable entry's volume is ignored (the hook path does the opposite), while the entry's probability still gates it and its priority still drives eviction. An object with no SoundTable plays nothing, matching CPhysicsObj::play_sound @0x0050F460's early return. The no-window host parses and discards, exactly as it does for F754/F755 — sound is presentation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
558 lines
21 KiB
C#
558 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: _ => { },
|
|
SoundEvent: _ => { });
|
|
|
|
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: _ => { },
|
|
SoundEvent: _ => { }),
|
|
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;
|
|
}
|
|
}
|