acdream/tests/AcDream.Runtime.Tests/Session/LiveSessionEventRouterTests.cs
Erik 77c8296e3f feat(chat): Campaign CH slice CH2 — retail SpewBox interface text
Retail routes on-screen refusals ("You can't jump while in the air",
"You are too encumbered to carry that!") through a SEPARATE transient
screen surface (gmSpewBoxUI, ClientSystem::AddTextToScroll @0x00563C50)
that never touches the chat scroll — type 0x1A is exactly the bit every
ChatInterface window's default filter excludes
(ChatInterface::ChatInterface @0x004F4550). acdream had no such split:
every WeenieError rendered in chat at a single stand-in LogTextType
0x00 (CH1-era approximation, register AP-176), and locally-detected
jump refusals were silently discarded.

This slice ports the full mechanism per
docs/research/2026-08-09-chat-retail-interface-text.md:

CORE (AcDream.Core/Chat):
- WeenieErrorMessages.Resolve now returns (text, RetailLogTextType) from
  a 338-row transcription of ClientCommunicationSystem::HandleFailureEvent
  @0x00571990 (Appendix A's 339 cases minus one, 0x4F8, deliberately
  excluded — its case body is a tangled decompiler artifact, not
  resolvable with confidence). Spot-checked ~20 rows directly against
  the raw decomp (case 0x2b/0x36/0x3a/0x4e/0x4ec/0x4f3/0x4f4 and the
  jump family), beyond the ~10 the brief asked for, because the first
  pass surfaced two transcription classes the research doc's markdown
  silently ate: (1) 7 ids marked "shared string global" resolved by
  reading the case bodies directly (0x24/0x48/0x49 reuse the jump-
  refusal globals; 0x4DE/0x4DF/0x55A/0x55E are pure param passthrough);
  (2) 19 "arg3 + literal" CONCATENATION ids whose leading space (and
  therefore their %s marker) the markdown table's cell-trimming ate —
  fixed by re-reading each case body, several requiring a SECOND
  non-truncated data_XXXXXXXX dump elsewhere in the same oracle file to
  recover text the ~33-char inline preview cut off. One retail typo is
  preserved verbatim: 0x4F4's second placeholder is literal "$s", not
  "%s" — only the first substitutes.
- ClientTextRefusals: the 11 process-lifetime string globals, all
  byte-recovered from the PDB-paired C:\Users\erikn\Downloads\acclient.exe
  (MATCH verified via check_exe_pdb.py) via raw UTF-16LE prefix search —
  5 were truncated in the research doc's own transcription and all 5
  turned out to end "...combat mode"/"...this position", not the
  shorter "...combat" a truncated read would suggest.
- SpewBoxState: the gmSpewBoxUI pending/visible queue port (insert-at-0,
  dedupe-against-index-0-only, MaxConcurrentItems overflow, per-entry
  expiry, one-frame enqueue/drain decoupling). Placed in Core (not
  Runtime as the brief's default) because AcDream.UI.Abstractions
  references Core but not Runtime, and SpewBoxVM needs to wrap it
  directly — the same constraint ChatVM already satisfies against
  ChatLog.
- Folded the 4-entry WeenieErrorText.cs into the full table; deleted it.

RUNTIME (AcDream.Runtime):
- RuntimeCommunicationState.AddText(text, type, windowId): the
  AddTextToScroll chokepoint. type == ClientLocal -> SpewBox only, never
  chat; everything else -> the existing transcript, tagged with type.
- GameEventWiring gains an `onInterfaceText` delegate hole (Core.Net
  cannot reference Runtime, so this follows the file's own established
  pattern for every other Runtime-owned sink). Rewires 0x028A/0x028B/
  UseDone through the full table + router; fixes 0x02EB
  CommunicationTransientString's routing type from a CH1-era 0x00
  guess to retail's hardcoded ClientLocal (Handle_Communication__
  TransientString @0x0057D460).
- LiveSessionEventRouter's 0xF7E0 ServerMessage handler now routes
  through AddText with the wire chatType verbatim instead of always
  writing ChatLog directly.
- PlayerMovementController gains OnInterfaceText, applied by
  RuntimeLocalPlayerMovementState to every controller it installs.
  Reports ChargeJump/jump refusals exactly as ClientCombatSystem::
  CommenceJump @0x0056AF90 / DoJump @0x0056B110 do — confirmed via
  their compiled dispatch that ONLY 0x24/0x48/0x49 produce text;
  0x47 (GeneralMovementFailure, fully-constrained/no-stamina) and any
  other code are retail-SILENT (DoJump's jump table has exactly 4 real
  targets), which contradicts this task's brief ("0x47 -> the
  constrained/stamina row per §4.2") — the brief's reading of §4.2
  described what jump_is_allowed COMPUTES, not what CommenceJump/DoJump
  DISPLAY for it. Implemented the decomp-verified silent behavior.

APP (AcDream.App / AcDream.UI.Abstractions):
- The 5 composition sites that already used RetailLogTextType.ClientLocal
  now call Communication.AddText instead of Chat.OnSystemMessage
  directly, so they reach the SpewBox instead of the transcript.
- SpewBoxVM (UI.Abstractions) + SpewBoxController (App), modeled
  directly on PortalWaitNoticeController. Position/font/colour/
  MaxConcurrentItems are placeholders: SpewBoxLayoutDumpDiagnostic
  exhaustively swept the installed client_portal.dat's entire LayoutDesc
  id range (0x21000000-0x21000075, 101/118 ids populated, sanity-checked
  against 3 known ids) and found ZERO elements of class 0x10000016 —
  gmSpewBoxUI is mounted from C++ code, not any authored LayoutDesc, so
  the dump cannot recover these values.

REGISTER: AP-176 retired (its WeenieError half is now the full table
port); its OnCombatLine half was never in this slice's scope and is
split out to AP-179 so that divergence keeps a row. AP-177 (invented
line lifetime) and AP-178 (invented position/font/colour/max-items)
filed for the presentation placeholders above. AP-175 (PopUpString ->
chat instead of modal) is untouched, not duplicated.

Suite: 11,890 passed / 4 skipped / 0 failed (was 11,835/4/0; +55 net
new tests, 0 regressions).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 17:04:02 +02:00

615 lines
23 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);
}
// ── 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<Action<AcDream.Core.Net.Messages.ServerMessage.Parsed>>(
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<Action<AcDream.Core.Net.Messages.ServerMessage.Parsed>>(
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<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;
}
}