Applies the Opus review of Campaign CH slice CH3 (614a1e05):
- B1: UN-9 was a phantom divergence — ACE's CharacterOptions1.cs:47
OR-sum is 0x50C4A54A (its own comment confirms 1355064650), identical
to acdream's literal. The wrong 0x50C48D4A existed only in the research
doc. Row deleted, register §5 reverted to 4 rows, research doc corrected
with dated notes.
- S1/S4: AllegianceBroadcast (0x02000000) is a server-echoing channel —
ACE's GameActionChatChannel handler includes the sender in its real-name
Allegiance.Members broadcast (retail's DoAllegianceBroadcast has no
AddTextToScroll), so the client must skip its local optimistic echo, not
keep it. ChatChannelInfo.Legacy.IsSelfEchoChannel() now returns true for
it; RouteLegacyChannel's comment corrected; Turbine.IsSelfEchoChannel()'s
backwards comment rewritten truthfully.
- S3: retail's /a stays on the legacy AllegianceBroadcast bitflag until
StartupTurbineChatSystem successfully starts Turbine chat — "never
started" (TurbineChatState.Enabled == false) now falls back to legacy in
both LiveSessionCommandRouter.RouteChat and
DirectGameRuntimeCommandAdapter.TrySendChannel, while "enabled but no
allegiance room" still correctly refuses locally.
- S5: added a LiveSessionEventRouter test proving the Options.Replace ->
OnCharacterOptionsChanged seeding order, and RuntimeSettingsTargets /
GameWindowLiveSessionOwnershipTests tests proving the concrete
ICommandBus.Publish wiring and the single LiveSessionCommandSurface
construction site.
- S6: AP-181 rewritten to name both of retail's omitted pre-send checks
(IsMessageSafe silent-drop, then IsMessageSpam) and stop misattributing
either to RouteLegacyChannel, which has no such gates.
- N1-N7: CharacterOptionId moved below SocialActions so its doc comment
re-attaches; TurbineChatMembershipGate reuses TurbineChatDisplayNames
instead of a duplicate table; the gate-to-refusal-text mapping is now
shared via TurbineChatMembershipGate.ResolveRefusalText instead of
duplicated in both hosts; ChatSettings.Default now matches ACE's real
CharacterOptions2.Default (Roleplay/Society start off); a doc-comment
clarifies only the five Hear toggles are server-backed; the register's
§3 header recounted 129 -> 128.
Suite: 11,964 passed / 4 skipped / 0 failed (baseline 11,957/4/0 + 7 new
tests). Campaign ledger CH3 review column updated to APPROVE-WITH-FIXES.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
783 lines
30 KiB
C#
783 lines
30 KiB
C#
using System.Buffers.Binary;
|
|
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.Net.Messages;
|
|
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();
|
|
}
|
|
|
|
// ── Campaign CH slice CH3: TurbineChat ack HResult surfacing ──
|
|
|
|
[Fact]
|
|
public void TurbineChat_ResponseWithNonZeroHResult_SurfacesAsSystemMessage()
|
|
{
|
|
using var session = NewSession();
|
|
var chat = new ChatLog();
|
|
var router = NewRouter(session, new Counters(), chat: chat);
|
|
|
|
EventDelegate<Action<AcDream.Core.Net.Messages.TurbineChat.Parsed>>(
|
|
session, nameof(session.TurbineChatReceived))(
|
|
new AcDream.Core.Net.Messages.TurbineChat.Parsed(
|
|
AcDream.Core.Net.Messages.TurbineChat.BlobType.ResponseBinary,
|
|
AcDream.Core.Net.Messages.TurbineChat.DispatchType.Unknown,
|
|
0u, 0u, 0u, 0u, 0u,
|
|
new AcDream.Core.Net.Messages.TurbineChat.Payload.Response(
|
|
ContextId: 5u,
|
|
ResponseId: 2u,
|
|
MethodId: 2u,
|
|
HResult: -1)));
|
|
|
|
Assert.Equal(1, chat.Count);
|
|
Assert.Contains(
|
|
"rejected",
|
|
chat.Snapshot()[0].Text,
|
|
StringComparison.OrdinalIgnoreCase);
|
|
|
|
router.Dispose();
|
|
}
|
|
|
|
[Fact]
|
|
public void TurbineChat_ResponseWithZeroHResult_StaysSilent()
|
|
{
|
|
// Retail's ack is silent on ordinary success — only a genuine
|
|
// server-side rejection should surface.
|
|
using var session = NewSession();
|
|
var chat = new ChatLog();
|
|
var router = NewRouter(session, new Counters(), chat: chat);
|
|
|
|
EventDelegate<Action<AcDream.Core.Net.Messages.TurbineChat.Parsed>>(
|
|
session, nameof(session.TurbineChatReceived))(
|
|
new AcDream.Core.Net.Messages.TurbineChat.Parsed(
|
|
AcDream.Core.Net.Messages.TurbineChat.BlobType.ResponseBinary,
|
|
AcDream.Core.Net.Messages.TurbineChat.DispatchType.Unknown,
|
|
0u, 0u, 0u, 0u, 0u,
|
|
new AcDream.Core.Net.Messages.TurbineChat.Payload.Response(
|
|
ContextId: 5u,
|
|
ResponseId: 2u,
|
|
MethodId: 2u,
|
|
HResult: 0)));
|
|
|
|
Assert.Equal(0, chat.Count);
|
|
|
|
router.Dispose();
|
|
}
|
|
|
|
[Fact]
|
|
public void TurbineChat_EventSendToRoom_StillRoutesToChannelBroadcast()
|
|
{
|
|
using var session = NewSession();
|
|
var chat = new ChatLog();
|
|
var router = NewRouter(session, new Counters(), chat: chat);
|
|
|
|
EventDelegate<Action<AcDream.Core.Net.Messages.TurbineChat.Parsed>>(
|
|
session, nameof(session.TurbineChatReceived))(
|
|
new AcDream.Core.Net.Messages.TurbineChat.Parsed(
|
|
AcDream.Core.Net.Messages.TurbineChat.BlobType.EventBinary,
|
|
AcDream.Core.Net.Messages.TurbineChat.DispatchType.SendToRoomByName,
|
|
0u, 0u, 0u, 0u, 0u,
|
|
new AcDream.Core.Net.Messages.TurbineChat.Payload.EventSendToRoom(
|
|
RoomId: 2u,
|
|
SenderName: "Someone",
|
|
Message: "hi",
|
|
ExtraDataSize: 0x0Cu,
|
|
SenderId: 0x50000001u,
|
|
HResult: 0,
|
|
ChatType: 2u)));
|
|
|
|
Assert.Equal(1, chat.Count);
|
|
Assert.Equal("hi", chat.Snapshot()[0].Text);
|
|
|
|
router.Dispose();
|
|
}
|
|
|
|
[Fact]
|
|
public void PlayerDescription_ReplacesOptionsBeforeInvokingOnCharacterOptionsChanged()
|
|
{
|
|
// CH3 review S5(a): the §6.4 seeding chain
|
|
// (LiveSessionEventRouter.cs:208-212) is
|
|
// `character.Character.Options.Replace(...)` THEN
|
|
// `character.OnCharacterOptionsChanged?.Invoke(...)`. Nothing
|
|
// previously asserted this glue lambda itself — only
|
|
// GameEventWiring.WireAll's own onCharacterOptions?.Invoke — so a
|
|
// silent unwiring of either half would go unnoticed by the suite.
|
|
using var session = NewSession();
|
|
var character = new RuntimeCharacterState();
|
|
var observed = new List<(uint Options1, uint Options2, uint LiveOptions1AtCallback)>();
|
|
|
|
var router = new LiveSessionEventRouter(
|
|
session,
|
|
NoOpEntitySink(),
|
|
NoOpEnvironmentSink(),
|
|
NewInventoryBindings(),
|
|
new LiveCharacterSessionBindings(
|
|
new CombatState(),
|
|
character,
|
|
ResolveSkillFormulaBonus: null,
|
|
OnSkillsUpdated: null,
|
|
OnConfirmationRequest: null,
|
|
OnConfirmationDone: null,
|
|
ClientTime: () => 0d,
|
|
OnCharacterOptionsChanged: (options1, options2) =>
|
|
observed.Add((options1, options2, character.Options.Options1))),
|
|
NewSocialBindings());
|
|
router.Attach();
|
|
|
|
session.GameEvents.Dispatch(
|
|
GameEventEnvelope.TryParse(
|
|
WrapPlayerDescriptionEnvelope(0x50C4A54Au, 0x00948700u))!.Value);
|
|
|
|
var (options1, options2, liveOptions1AtCallback) = Assert.Single(observed);
|
|
Assert.Equal(0x50C4A54Au, options1);
|
|
Assert.Equal(0x00948700u, options2);
|
|
// If Replace ran AFTER the callback (or not at all), a listener
|
|
// reading character.Options from inside the callback — exactly
|
|
// what a Settings-panel seed handler does — would observe the
|
|
// stale default instead of the just-received value.
|
|
Assert.Equal(0x50C4A54Au, liveOptions1AtCallback);
|
|
Assert.Equal(0x50C4A54Au, character.Options.Options1);
|
|
Assert.Equal(0x00948700u, character.Options.Options2);
|
|
|
|
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 System.IO.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());
|
|
|
|
// Minimal PlayerDescription (0x0013) body carrying only the
|
|
// CharacterOptions1/2 trailer fields — mirrors
|
|
// GameEventWiringTests.WireAll_PlayerDescription_PublishesCharacterOptions's
|
|
// fixture layout.
|
|
private static byte[] WrapPlayerDescriptionEnvelope(uint options1, uint options2)
|
|
{
|
|
var stream = new MemoryStream();
|
|
using (var writer = new BinaryWriter(stream, System.Text.Encoding.UTF8, leaveOpen: true))
|
|
{
|
|
writer.Write(0u); // property flags
|
|
writer.Write(0x52u); // player weenie type
|
|
writer.Write(0u); // vector flags
|
|
writer.Write(0u); // has health
|
|
writer.Write(0x40u); // option flags: CharacterOptions2
|
|
writer.Write(options1);
|
|
writer.Write(0u); // legacy hotbar count
|
|
writer.Write(0u); // spellbook filters
|
|
writer.Write(options2);
|
|
writer.Write(0u); // inventory count
|
|
writer.Write(0u); // equipped count
|
|
}
|
|
|
|
byte[] payload = stream.ToArray();
|
|
byte[] body = new byte[GameEventEnvelope.HeaderSize + payload.Length];
|
|
BinaryPrimitives.WriteUInt32LittleEndian(body, GameEventEnvelope.Opcode);
|
|
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), 0u);
|
|
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), 0u);
|
|
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), (uint)GameEventType.PlayerDescription);
|
|
Array.Copy(payload, 0, body, GameEventEnvelope.HeaderSize, payload.Length);
|
|
return body;
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|