acdream/tests/AcDream.Runtime.Tests/Session/LiveSessionEventRouterTests.cs
Erik ded23067aa fix(net,runtime): FA2 fix-round SHOULD-FIX -- fellowship mechanism parity, lookup reuse, router test, checkpoint defaults
Remaining SHOULD-FIX findings from the FA2 mechanism/blast reviews:

Mechanism SF-3/SF-4 -- RuntimeFellowshipState.ApplyUpdateFellow now ports
Fellowship::RecalculateEvenXPSplitting @0x005B92E0 (called from retail's
AddFellow/UpdateFellow/RemoveFellow on every upsert/removal, but never
from a full update -- that carries the server's own authoritative flag
verbatim, lane B 6.2) and Fellowship::AddFellow @0x005B9480's
locked/departed admission gate (a brand-new guid is refused while
_locked unless it appears in the 0x02BE field-8 _fellows_departed table
within 900s, @0x005B94A5). ApplyFullUpdate now stores update.Departed
instead of discarding it. A TimeProvider dependency (defaulting to
TimeProvider.System, matching the RuntimeCharacterOptionsState precedent)
makes the 900s grace window testable.

Mechanism SF-5 -- RuntimeAllegianceState's TryGetMember/TryGetPatron/
GetVassals now reuse ClientCommandResponses.AllegianceProfileLookups
(promoted private -> internal, AcDream.Runtime added to Core.Net's
InternalsVisibleTo) instead of re-implementing the retail walk a second
time.

Mechanism SF-6 -- RuntimeStateCheckpoint's Fellowship/Allegiance
parameters are no longer trailing-optional. `default(RuntimeFellowshipSnapshot)`/
`default(RuntimeAllegianceSnapshot)` zero-init Name/AllegianceName to
null, and C# does not allow a non-constant `new(...)` as an optional
parameter's default value (CS1736) even when the struct declares an
explicit parameterless constructor -- so the only way to guarantee a
non-null default was to make the parameters required. Both snapshot types
still gained an explicit parameterless constructor for callers that want
an empty-but-safe `new()`.

Blast SF-4 -- LiveSessionEventRouterTests gains
FellowshipQuit_RoutesSelfGuidToClearAndOtherGuidToRemove, wiring real
RuntimeFellowshipState/RuntimeAllegianceState owners through the one
production registration site and dispatching a real 0x00A3 envelope for
both a self-quit and an other-quit -- the one non-trivial lambda in the
slice (the self-guid source that decides "remove one member" vs "clear
the whole snapshot") was previously untested; every other router test
defaults Fellowship/Allegiance to null.

Blast SF-5 -- RuntimeFellowshipState.ResetSession dropped its disposed
guard to match the precedent its own doc comment names
(RuntimeInventoryState.ResetExternalContainer,
RuntimeCommunicationState.ResetNegotiatedChannels -- both bare delegations
with no disposal guard); the reset transaction is retryable and disposal
is terminal, so a throwing guard could never converge on retry.
RuntimeAllegianceState.ResetSession (new this fix round) matches the same
shape from the start.

Blast SF-7 -- IRuntimeAllegianceView.GetVassals' per-call List<> allocation
is now documented as an intentional exception to the file's "Snapshot +
TryGet*, no allocation" view convention (C# cannot yield-return from
inside a lock).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 02:17:56 +02:00

1011 lines
40 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 FA slice FA2 fix-round SHOULD-FIX 4 (blast review) ────────
// The single production registration site (LiveSessionEventRouter.cs)
// was untested — both router-test factories default Fellowship/
// Allegiance to null, so every FA2 lambda no-ops through `?.` in every
// OTHER test in this file. The self-vs-other guid routing
// (onFellowshipQuit/onFellowshipDismiss supply inventory.PlayerGuid()
// as the self-guid, which selects "remove one member" vs "clear the
// whole snapshot") is the one non-trivial lambda in the whole slice —
// a transposed argument or wrong guid source there was invisible to
// every test until now.
[Fact]
public void FellowshipQuit_RoutesSelfGuidToClearAndOtherGuidToRemove()
{
const uint self = 0x50000001u;
const uint other = 0x50000002u;
using var session = NewSession();
var fellowship = new RuntimeFellowshipState();
var allegiance = new RuntimeAllegianceState();
try
{
fellowship.ApplyFullUpdate(new GameEvents.FellowshipFullUpdate(
[
new GameEvents.FellowMember(
self, 0u, 0u, 1u, 100u, 100u, 100u, 100u, 100u, 100u, 0u, "Self"),
new GameEvents.FellowMember(
other, 0u, 0u, 1u, 100u, 100u, 100u, 100u, 100u, 100u, 0u, "Other"),
],
"The Fellows",
LeaderGuid: self,
ShareXp: true,
EvenXpSplit: false,
OpenFellow: true,
Locked: false,
Departed: []));
var router = new LiveSessionEventRouter(
session,
NoOpEntitySink(),
NoOpEnvironmentSink(),
new LiveInventorySessionBindings(
new ClientObjectTable(),
PlayerGuid: () => self,
OnShortcuts: null,
OnUseDone: null,
ItemMana: new ItemManaState(),
ExternalContainers: new ExternalContainerState()),
NewCharacterBindings(),
new LiveSocialSessionBindings(
new ChatLog(),
new TurbineChatState(),
new FriendsState(),
new SquelchState(),
Fellowship: fellowship,
Allegiance: allegiance));
router.Attach();
// Someone ELSE quits -- removes exactly that one member.
session.GameEvents.Dispatch(
GameEventEnvelope.TryParse(WrapFellowshipQuitEnvelope(other))!.Value);
Assert.True(fellowship.View.Snapshot.IsInFellowship);
Assert.Equal(1, fellowship.View.Snapshot.MemberCount);
Assert.False(fellowship.View.TryGetMember(other, out _));
Assert.True(fellowship.View.TryGetMember(self, out _));
// The LOCAL PLAYER quits -- clears the whole snapshot, not just
// one member.
session.GameEvents.Dispatch(
GameEventEnvelope.TryParse(WrapFellowshipQuitEnvelope(self))!.Value);
Assert.False(fellowship.View.Snapshot.IsInFellowship);
Assert.Equal(0, fellowship.View.Snapshot.MemberCount);
router.Dispose();
}
finally
{
fellowship.Dispose();
allegiance.Dispose();
}
}
private static byte[] WrapFellowshipQuitEnvelope(uint quitterGuid)
{
byte[] payload = new byte[4];
BinaryPrimitives.WriteUInt32LittleEndian(payload, quitterGuid);
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.FellowshipQuit);
Array.Copy(payload, 0, body, GameEventEnvelope.HeaderSize, payload.Length);
return body;
}
// ── 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 PlayerDescription_TrailerTruncatedReSeed_LeavesWordsAndLatchUnchangedAndDoesNotNotify()
{
// MF-2 (Campaign OP OP7 review fix, 2026-08-11): a re-seed whose
// trailer truncates mid-parse must install NOTHING and notify NO
// ONE — the placeholder zero words are not server truth, and the
// prior good seed (real words, HasServerSeed already armed) must
// survive untouched. Previously the router installed the zeroed
// options2 AND fired OnCharacterOptionsChanged unconditionally,
// letting a downstream seeder diff against zero and flush it.
using var session = NewSession();
var character = new RuntimeCharacterState();
var observed = new List<(uint Options1, uint Options2)>();
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))),
NewSocialBindings());
router.Attach();
// Complete seed first — real words, latch arms.
session.GameEvents.Dispatch(
GameEventEnvelope.TryParse(
WrapPlayerDescriptionEnvelope(0x50C4A54Au, 0x00948700u))!.Value);
Assert.Single(observed);
Assert.True(character.Options.HasServerSeed);
Assert.Equal(0x50C4A54Au, character.Options.Options1);
Assert.Equal(0x00948700u, character.Options.Options2);
// Truncated re-seed — options1 reads early (real-looking value),
// the trailer then throws before options2 is ever read.
session.GameEvents.Dispatch(
GameEventEnvelope.TryParse(
WrapTruncatedPlayerDescriptionEnvelope(0xDEADBEEFu))!.Value);
// No second notification, words untouched, latch still armed
// (from the earlier GOOD seed, not from this truncated one).
Assert.Single(observed);
Assert.True(character.Options.HasServerSeed);
Assert.Equal(0x50C4A54Au, character.Options.Options1);
Assert.Equal(0x00948700u, character.Options.Options2);
router.Dispose();
}
[Fact]
public void PlayerDescription_TrailerTruncatedFirstSeed_LeavesDefaultsAndNeverArmsLatch()
{
// MF-2 companion case: a truncated PlayerDescription that is the
// FIRST one a session ever sees must leave the client-constructor
// defaults in place and never arm HasServerSeed — a later flush
// stays refused exactly as if no PlayerDescription had arrived.
using var session = NewSession();
var character = new RuntimeCharacterState();
var observed = new List<(uint Options1, uint Options2)>();
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))),
NewSocialBindings());
router.Attach();
uint defaultOptions1 = character.Options.Options1;
uint defaultOptions2 = character.Options.Options2;
session.GameEvents.Dispatch(
GameEventEnvelope.TryParse(
WrapTruncatedPlayerDescriptionEnvelope(0xDEADBEEFu))!.Value);
Assert.Empty(observed);
Assert.False(character.Options.HasServerSeed);
Assert.Equal(defaultOptions1, character.Options.Options1);
Assert.Equal(defaultOptions2, 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;
}
// MF-2 fixture: a PlayerDescription whose trailer reads options1 (the
// early field) then throws before ever reaching options2 — mirrors
// PlayerDescriptionParserTests' truncated-shortcut-list fixture. An
// unreasonable declared shortcut count trips the parser's own
// FormatException guard immediately after options1 is read, so
// TrailerTruncated comes back true with a real-looking options1 and a
// never-populated (zero) options2 — exactly the shape MF-2 closes.
private static byte[] WrapTruncatedPlayerDescriptionEnvelope(uint options1)
{
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(0x01u); // option flags: Shortcut
writer.Write(options1);
writer.Write(1_000_000u); // claimed shortcut count — trips the >10_000 guard
}
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;
}
}