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 copiedTime = EventDelegate>( session, nameof(session.ServerTimeUpdated)); Action copiedTeleport = EventDelegate>( session, nameof(session.TeleportStarted)); Action copiedPlayerInt = EventDelegate>( session, nameof(session.PlayerIntPropertyUpdated)); Action copiedDamage = EventDelegate>( 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); } // ── Campaign CH slice CH2: 0xF7E0 ServerMessage routes through AddText ── [Fact] public void ServerMessage_RoutesThroughAddText_WithWireChatTypeVerbatim_WhenWired() { using var session = NewSession(); var chat = new ChatLog(); var reported = new List<(string Text, RetailLogTextType Type)>(); var social = new LiveSocialSessionBindings( chat, new TurbineChatState(), new FriendsState(), new SquelchState(), (text, type) => reported.Add((text, type))); var router = new LiveSessionEventRouter( session, new LiveEntitySessionSink( _ => { }, _ => { }, _ => { }, _ => { }, _ => { }, _ => { }, _ => { }, _ => { }, _ => { }, _ => { }, _ => { }, _ => { }, _ => { }), new LiveEnvironmentSessionSink(_ => { }, _ => { }), NewInventoryBindings(), NewCharacterBindings(), social); router.Attach(); // 0x1A carried verbatim -- retail's Handle_Communication__TextboxString // @0x0057D3A0 feeds the RAW wire chatType into AddTextToScroll. EventDelegate>( session, nameof(session.ServerMessageReceived))( new AcDream.Core.Net.Messages.ServerMessage.Parsed("You are too encumbered to carry that!", 0x1Au)); var (text, type) = Assert.Single(reported); Assert.Equal("You are too encumbered to carry that!", text); Assert.Equal(RetailLogTextType.ClientLocal, type); Assert.Equal(0, chat.Count); router.Dispose(); } [Fact] public void ServerMessage_FallsBackToChatDirectly_WhenNoRouterWired() { using var session = NewSession(); var chat = new ChatLog(); var router = NewRouter(session, new Counters(), chat: chat); EventDelegate>( session, nameof(session.ServerMessageReceived))( new AcDream.Core.Net.Messages.ServerMessage.Parsed("fallback path", 0x00u)); Assert.Equal(1, chat.Count); Assert.Equal("fallback path", chat.Snapshot()[0].Text); router.Dispose(); } [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>(session, nameof(session.ServerTimeUpdated))(1d); Assert.Equal(1, first.ServerTime); Assert.Equal(1, second.ServerTime); routerA.Dispose(); AssertSessionHandlerCounts(session, multiplier: 1); EventDelegate>(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>(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(() => EventDelegate>( 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(() => 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? 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( WorldSession session, string eventName) where TDelegate : Delegate => Assert.IsType(EventField(session, eventName).GetValue(session)); private static TDelegate EventDelegate( TOwner owner, string eventName) where TOwner : class where TDelegate : Delegate => Assert.IsType( 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; } }