Ports retail's SendTurbineChat (@0x0057db10) local pre-send membership gate so Roleplay/Society/Olthoi stop silently swallowing outbound chat: a new TurbineChatMembershipGate checks Turbine availability and the player's own Hear*Chat option before sending, raising "Turbine chat is not available." or the 0x0551 YouAreNotListeningTo_Channel refusal through the CH2 AddText chokepoint instead. Wired into both the graphical (LiveSessionCommandRouter) and headless (DirectGameRuntimeCommandAdapter) send paths so they can't diverge. Retracts the 26-day-old false "ACE doesn't run a TurbineChat server" claim from ISSUES.md, the roadmap, and project_chat_pipeline.md — ACE's TurbineChat implementation is complete and on by default; the real bug was treating Hear*Chat as a display filter instead of room membership. Also: implements SetSingleCharacterOption (0x0005), the only wire message that actually joins/leaves a Turbine room, and wires the five Settings Chat toggles to it (publish on Save, changed bits only) plus seeds ChatSettings from the server's own CharacterOptions2 on every PlayerDescription. Fixes the legacy-channel double-print (Fellow/Vassals/Patron/Monarch/CoVassals skip the local echo now that ChatChannelInfo.IsSelfEchoChannel is finally consulted). Routes /a to Turbine unconditionally (retail's @a never falls back to the legacy bitflag) and adds /ab for the legacy AllegianceBroadcast verb retail actually has. Surfaces a nonzero TurbineChat ack HResult instead of discarding it silently. Deletes the malformed, callerless SetCharacterOptions (0x01A1) and AddChannel/RemoveChannel (0x0145/0x0146) builders. Files every AC-specific algorithm change cites the named retail decomp (SendTurbineChat 0x0057db10, StartupTurbineChatSystem 0x0057EFB0, GameActionSetSingleCharacterOption) plus ACE/holtburger cross-checks. Register rows AP-181 (no client-side spam throttle) and UN-9 (an incidentally-discovered CharacterOptions1.Default literal mismatch, not investigated further) filed per the divergence-register rule. 11,957 passed / 4 skipped / 0 failed (full Release suite, up from the 11,916/4/0 baseline). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
699 lines
26 KiB
C#
699 lines
26 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();
|
|
}
|
|
|
|
// ── 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 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;
|
|
}
|
|
}
|