refactor(net): own session wiring subscriptions
This commit is contained in:
parent
88fe1db37b
commit
7d452aa6a2
8 changed files with 577 additions and 61 deletions
|
|
@ -15,9 +15,9 @@ public sealed class GameEventDispatcherTests
|
|||
{
|
||||
byte[] body = new byte[GameEventEnvelope.HeaderSize + payload.Length];
|
||||
var span = body.AsSpan();
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(span, GameEventEnvelope.Opcode);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(span.Slice(4), playerGuid);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(span.Slice(8), sequence);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(span, GameEventEnvelope.Opcode);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(span.Slice(4), playerGuid);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(span.Slice(8), sequence);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(span.Slice(12), (uint)type);
|
||||
payload.CopyTo(span.Slice(GameEventEnvelope.HeaderSize));
|
||||
return body;
|
||||
|
|
@ -106,6 +106,162 @@ public sealed class GameEventDispatcherTests
|
|||
Assert.Equal(1, dispatcher.GetUnhandledCount(GameEventType.Tell));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OwnedRegistration_DisposeRestoresUnownedPredecessor()
|
||||
{
|
||||
var dispatcher = new GameEventDispatcher();
|
||||
var calls = new List<string>();
|
||||
dispatcher.Register(GameEventType.Tell, _ => calls.Add("baseline"));
|
||||
IDisposable owned = dispatcher.RegisterOwned(
|
||||
GameEventType.Tell,
|
||||
_ => calls.Add("owned"));
|
||||
|
||||
dispatcher.Dispatch(new GameEventEnvelope(0, 0, GameEventType.Tell, default));
|
||||
owned.Dispose();
|
||||
dispatcher.Dispatch(new GameEventEnvelope(0, 0, GameEventType.Tell, default));
|
||||
|
||||
Assert.Equal(["owned", "baseline"], calls);
|
||||
Assert.Equal(1, dispatcher.RegisteredHandlerCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NestedOwnedRegistrations_DisposeOlderFirst_SkipsRetiredPredecessor()
|
||||
{
|
||||
var dispatcher = new GameEventDispatcher();
|
||||
var calls = new List<string>();
|
||||
dispatcher.Register(GameEventType.Tell, _ => calls.Add("baseline"));
|
||||
IDisposable ownerA = dispatcher.RegisterOwned(
|
||||
GameEventType.Tell,
|
||||
_ => calls.Add("A"));
|
||||
IDisposable ownerB = dispatcher.RegisterOwned(
|
||||
GameEventType.Tell,
|
||||
_ => calls.Add("B"));
|
||||
|
||||
ownerA.Dispose();
|
||||
dispatcher.Dispatch(new GameEventEnvelope(0, 0, GameEventType.Tell, default));
|
||||
ownerB.Dispose();
|
||||
dispatcher.Dispatch(new GameEventEnvelope(0, 0, GameEventType.Tell, default));
|
||||
|
||||
Assert.Equal(["B", "baseline"], calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NestedOwnedRegistrations_DisposeNewestFirst_RestoresLiveOlderOwner()
|
||||
{
|
||||
var dispatcher = new GameEventDispatcher();
|
||||
var calls = new List<string>();
|
||||
dispatcher.Register(GameEventType.Tell, _ => calls.Add("baseline"));
|
||||
IDisposable ownerA = dispatcher.RegisterOwned(
|
||||
GameEventType.Tell,
|
||||
_ => calls.Add("A"));
|
||||
IDisposable ownerB = dispatcher.RegisterOwned(
|
||||
GameEventType.Tell,
|
||||
_ => calls.Add("B"));
|
||||
|
||||
ownerB.Dispose();
|
||||
dispatcher.Dispatch(new GameEventEnvelope(0, 0, GameEventType.Tell, default));
|
||||
ownerA.Dispose();
|
||||
ownerA.Dispose();
|
||||
ownerB.Dispose();
|
||||
dispatcher.Dispatch(new GameEventEnvelope(0, 0, GameEventType.Tell, default));
|
||||
|
||||
Assert.Equal(["A", "baseline"], calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LaterUnownedReplacement_SurvivesOwnedTokenDisposal()
|
||||
{
|
||||
var dispatcher = new GameEventDispatcher();
|
||||
var calls = new List<string>();
|
||||
IDisposable owned = dispatcher.RegisterOwned(
|
||||
GameEventType.Tell,
|
||||
_ => calls.Add("owned"));
|
||||
dispatcher.Register(GameEventType.Tell, _ => calls.Add("replacement"));
|
||||
|
||||
owned.Dispose();
|
||||
dispatcher.Dispatch(new GameEventEnvelope(0, 0, GameEventType.Tell, default));
|
||||
|
||||
Assert.Equal(["replacement"], calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OwnedRegistrationWithoutPredecessor_BecomesUnhandledAfterDispose()
|
||||
{
|
||||
var dispatcher = new GameEventDispatcher();
|
||||
IDisposable owned = dispatcher.RegisterOwned(GameEventType.Tell, _ => { });
|
||||
|
||||
owned.Dispose();
|
||||
owned.Dispose();
|
||||
dispatcher.Dispatch(new GameEventEnvelope(0, 0, GameEventType.Tell, default));
|
||||
|
||||
Assert.Equal(0, dispatcher.RegisteredHandlerCount);
|
||||
Assert.Equal(1, dispatcher.GetUnhandledCount(GameEventType.Tell));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExplicitUnregister_RetiresCurrentChainWithoutResurrection()
|
||||
{
|
||||
var dispatcher = new GameEventDispatcher();
|
||||
dispatcher.Register(GameEventType.Tell, _ => { });
|
||||
IDisposable ownerA = dispatcher.RegisterOwned(GameEventType.Tell, _ => { });
|
||||
IDisposable ownerB = dispatcher.RegisterOwned(GameEventType.Tell, _ => { });
|
||||
|
||||
dispatcher.Unregister(GameEventType.Tell);
|
||||
ownerA.Dispose();
|
||||
ownerB.Dispose();
|
||||
dispatcher.Dispatch(new GameEventEnvelope(0, 0, GameEventType.Tell, default));
|
||||
|
||||
Assert.Equal(0, dispatcher.RegisteredHandlerCount);
|
||||
Assert.Equal(1, dispatcher.GetUnhandledCount(GameEventType.Tell));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HandlerCanDisposeItselfAndInstallRawReplacementDuringDispatch()
|
||||
{
|
||||
var dispatcher = new GameEventDispatcher();
|
||||
var calls = new List<string>();
|
||||
IDisposable? owned = null;
|
||||
owned = dispatcher.RegisterOwned(GameEventType.Tell, _ =>
|
||||
{
|
||||
calls.Add("owned");
|
||||
owned!.Dispose();
|
||||
dispatcher.Register(GameEventType.Tell, _ => calls.Add("replacement"));
|
||||
});
|
||||
|
||||
dispatcher.Dispatch(new GameEventEnvelope(0, 0, GameEventType.Tell, default));
|
||||
dispatcher.Dispatch(new GameEventEnvelope(0, 0, GameEventType.Tell, default));
|
||||
|
||||
Assert.Equal(["owned", "replacement"], calls);
|
||||
Assert.Equal(1, dispatcher.RegisteredHandlerCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RawReplacementAfterNestedOwners_SurvivesEveryOwnedDisposeOrder()
|
||||
{
|
||||
var dispatcher = new GameEventDispatcher();
|
||||
var calls = new List<string>();
|
||||
IDisposable ownerA = dispatcher.RegisterOwned(GameEventType.Tell, _ => calls.Add("A"));
|
||||
IDisposable ownerB = dispatcher.RegisterOwned(GameEventType.Tell, _ => calls.Add("B"));
|
||||
dispatcher.Register(GameEventType.Tell, _ => calls.Add("raw"));
|
||||
|
||||
ownerA.Dispose();
|
||||
ownerB.Dispose();
|
||||
dispatcher.Dispatch(new GameEventEnvelope(0, 0, GameEventType.Tell, default));
|
||||
|
||||
Assert.Equal(["raw"], calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OwnedRegistration_ConcurrentDisposeRetiresExactlyOnce()
|
||||
{
|
||||
var dispatcher = new GameEventDispatcher();
|
||||
IDisposable owned = dispatcher.RegisterOwned(GameEventType.Tell, _ => { });
|
||||
|
||||
Parallel.For(0, 64, _ => owned.Dispose());
|
||||
|
||||
Assert.Equal(0, dispatcher.RegisteredHandlerCount);
|
||||
}
|
||||
|
||||
// ── Per-event parser tests ───────────────────────────────────────────────
|
||||
|
||||
private static byte[] MakeString16L(string s)
|
||||
|
|
|
|||
47
tests/AcDream.Core.Net.Tests/SubscriptionSetTests.cs
Normal file
47
tests/AcDream.Core.Net.Tests/SubscriptionSetTests.cs
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
namespace AcDream.Core.Net.Tests;
|
||||
|
||||
public sealed class SubscriptionSetTests
|
||||
{
|
||||
[Fact]
|
||||
public void Dispose_ReleasesInReverseOrderAndIsIdempotent()
|
||||
{
|
||||
var order = new List<int>();
|
||||
var subscriptions = new SubscriptionSet();
|
||||
subscriptions.Add(() => order.Add(1));
|
||||
subscriptions.Add(() => order.Add(2));
|
||||
subscriptions.Add(() => order.Add(3));
|
||||
|
||||
subscriptions.Dispose();
|
||||
subscriptions.Dispose();
|
||||
|
||||
Assert.Equal([3, 2, 1], order);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_AttemptsEveryReleaseAndAggregatesFailures()
|
||||
{
|
||||
var order = new List<int>();
|
||||
var subscriptions = new SubscriptionSet();
|
||||
subscriptions.Add(() => order.Add(1));
|
||||
subscriptions.Add(() =>
|
||||
{
|
||||
order.Add(2);
|
||||
throw new InvalidOperationException("second failed");
|
||||
});
|
||||
subscriptions.Add(() =>
|
||||
{
|
||||
order.Add(3);
|
||||
throw new IOException("third failed");
|
||||
});
|
||||
|
||||
AggregateException error = Assert.Throws<AggregateException>(
|
||||
subscriptions.Dispose);
|
||||
|
||||
Assert.Equal([3, 2, 1], order);
|
||||
Assert.Collection(
|
||||
error.InnerExceptions,
|
||||
exception => Assert.IsType<IOException>(exception),
|
||||
exception => Assert.IsType<InvalidOperationException>(exception));
|
||||
subscriptions.Dispose();
|
||||
}
|
||||
}
|
||||
131
tests/AcDream.Core.Net.Tests/WorldSessionWiringOwnershipTests.cs
Normal file
131
tests/AcDream.Core.Net.Tests/WorldSessionWiringOwnershipTests.cs
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
using System.Net;
|
||||
using System.Reflection;
|
||||
using AcDream.Core.Chat;
|
||||
using AcDream.Core.Combat;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Core.Player;
|
||||
using AcDream.Core.Spells;
|
||||
|
||||
namespace AcDream.Core.Net.Tests;
|
||||
|
||||
public sealed class WorldSessionWiringOwnershipTests
|
||||
{
|
||||
[Fact]
|
||||
public void ObjectTableWiring_DisposeDetachesEveryExactSessionDelegate()
|
||||
{
|
||||
using var session = NewSession();
|
||||
var table = new ClientObjectTable();
|
||||
var player = new LocalPlayerState();
|
||||
|
||||
IDisposable registration = ObjectTableWiring.Wire(
|
||||
session,
|
||||
table,
|
||||
playerGuid: () => 0x50000001u,
|
||||
localPlayer: player);
|
||||
|
||||
Assert.Equal(1, HandlerCount(session, nameof(session.ObjectIntPropertyUpdated)));
|
||||
Assert.Equal(1, HandlerCount(session, nameof(session.PlayerIntPropertyUpdated)));
|
||||
Assert.Equal(1, HandlerCount(session, nameof(session.PlayerInt64PropertyUpdated)));
|
||||
Assert.Equal(1, HandlerCount(session, nameof(session.StackSizeUpdated)));
|
||||
Assert.Equal(1, HandlerCount(session, nameof(session.InventoryObjectRemoved)));
|
||||
|
||||
registration.Dispose();
|
||||
registration.Dispose();
|
||||
|
||||
Assert.Equal(0, HandlerCount(session, nameof(session.ObjectIntPropertyUpdated)));
|
||||
Assert.Equal(0, HandlerCount(session, nameof(session.PlayerIntPropertyUpdated)));
|
||||
Assert.Equal(0, HandlerCount(session, nameof(session.PlayerInt64PropertyUpdated)));
|
||||
Assert.Equal(0, HandlerCount(session, nameof(session.StackSizeUpdated)));
|
||||
Assert.Equal(0, HandlerCount(session, nameof(session.InventoryObjectRemoved)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CombatStateWiring_DisposeDetachesOnlyItsExactDelegate()
|
||||
{
|
||||
using var session = NewSession();
|
||||
var combat = new CombatState();
|
||||
Action<WorldSession.PlayerIntPropertyUpdate> predecessor = _ => { };
|
||||
session.PlayerIntPropertyUpdated += predecessor;
|
||||
IDisposable registration = CombatStateWiring.Wire(session, combat);
|
||||
|
||||
Assert.Equal(2, HandlerCount(session, nameof(session.PlayerIntPropertyUpdated)));
|
||||
|
||||
registration.Dispose();
|
||||
registration.Dispose();
|
||||
|
||||
Assert.Equal(1, HandlerCount(session, nameof(session.PlayerIntPropertyUpdated)));
|
||||
session.PlayerIntPropertyUpdated -= predecessor;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CombatStateWiring_ConcurrentDisposeDetachesExactlyOnce()
|
||||
{
|
||||
using var session = NewSession();
|
||||
IDisposable registration = CombatStateWiring.Wire(session, new CombatState());
|
||||
|
||||
Parallel.For(0, 64, _ => registration.Dispose());
|
||||
|
||||
Assert.Equal(0, HandlerCount(session, nameof(session.PlayerIntPropertyUpdated)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GameEventWiring_DisposeRestoresPreexistingHandlerAndRemovesOwnedSet()
|
||||
{
|
||||
var dispatcher = new GameEventDispatcher();
|
||||
int predecessorCalls = 0;
|
||||
dispatcher.Register(GameEventType.Tell, _ => predecessorCalls++);
|
||||
|
||||
IDisposable registration = GameEventWiring.WireAll(
|
||||
dispatcher,
|
||||
new ClientObjectTable(),
|
||||
new CombatState(),
|
||||
new Spellbook(),
|
||||
new ChatLog());
|
||||
int wiredCount = dispatcher.RegisteredHandlerCount;
|
||||
dispatcher.Dispatch(new GameEventEnvelope(0, 0, GameEventType.Tell, default));
|
||||
Assert.Equal(0, predecessorCalls);
|
||||
|
||||
registration.Dispose();
|
||||
registration.Dispose();
|
||||
dispatcher.Dispatch(new GameEventEnvelope(0, 0, GameEventType.Tell, default));
|
||||
|
||||
Assert.True(wiredCount > 1);
|
||||
Assert.Equal(1, dispatcher.RegisteredHandlerCount);
|
||||
Assert.Equal(1, predecessorCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GameEventWiring_OnWorldSession_RestoresInternalRegistrations()
|
||||
{
|
||||
using var session = NewSession();
|
||||
int baselineCount = session.GameEvents.RegisteredHandlerCount;
|
||||
|
||||
IDisposable registration = GameEventWiring.WireAll(
|
||||
session.GameEvents,
|
||||
new ClientObjectTable(),
|
||||
new CombatState(),
|
||||
new Spellbook(),
|
||||
new ChatLog(),
|
||||
turbineChat: new TurbineChatState());
|
||||
|
||||
Assert.True(session.GameEvents.RegisteredHandlerCount > baselineCount);
|
||||
registration.Dispose();
|
||||
|
||||
Assert.Equal(baselineCount, session.GameEvents.RegisteredHandlerCount);
|
||||
}
|
||||
|
||||
private static WorldSession NewSession() =>
|
||||
new(new IPEndPoint(IPAddress.Loopback, 9));
|
||||
|
||||
private static int HandlerCount(WorldSession session, string eventName)
|
||||
{
|
||||
FieldInfo field = typeof(WorldSession).GetField(
|
||||
eventName,
|
||||
BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
?? throw new InvalidOperationException($"event field {eventName} not found");
|
||||
return (field.GetValue(session) as MulticastDelegate)?
|
||||
.GetInvocationList()
|
||||
.Length ?? 0;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue