refactor(net): own live session composition
Extract reset, selection, entered-world, and route construction behind LiveSessionHost while preserving the sole LiveSessionController authority. Retain partial route and subscription cleanup for retry, and replace the embedded ACE-only shortcut with the exact named-retail unsigned skill formula. Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
parent
18d4b999de
commit
557eb7ef6b
22 changed files with 1430 additions and 236 deletions
|
|
@ -11,7 +11,7 @@ public sealed class GameWindowLiveSessionOwnershipTests
|
|||
BindingFlags.Instance | BindingFlags.NonPublic;
|
||||
|
||||
[Fact]
|
||||
public void GameWindowRetainsOnlyTheLifecycleControllerSessionOwner()
|
||||
public void GameWindowRetainsControllerAndFocusedHostButNoMirroredSession()
|
||||
{
|
||||
FieldInfo[] fields = typeof(GameWindow).GetFields(PrivateInstance);
|
||||
|
||||
|
|
@ -19,8 +19,13 @@ public sealed class GameWindowLiveSessionOwnershipTests
|
|||
fields,
|
||||
field => field.Name == "_liveSessionController"
|
||||
&& field.FieldType == typeof(LiveSessionController));
|
||||
Assert.Contains(
|
||||
fields,
|
||||
field => field.Name == "_liveSessionHost"
|
||||
&& field.FieldType == typeof(LiveSessionHost));
|
||||
Assert.DoesNotContain(fields, field => field.Name == "_liveSession");
|
||||
Assert.DoesNotContain(fields, field => field.FieldType == typeof(WorldSession));
|
||||
Assert.DoesNotContain(fields, field => field.FieldType == typeof(LiveSessionResetPlan));
|
||||
Assert.DoesNotContain(fields, field => field.Name == "_liveSessionEvents");
|
||||
Assert.DoesNotContain(fields, field => field.Name == "_liveSessionCommands");
|
||||
}
|
||||
|
|
@ -30,6 +35,9 @@ public sealed class GameWindowLiveSessionOwnershipTests
|
|||
[InlineData("ClearInboundEntityState")]
|
||||
[InlineData("WireLiveSessionEvents")]
|
||||
[InlineData("DisposeLiveSessionRouting")]
|
||||
[InlineData("CreateLiveSessionBinding")]
|
||||
[InlineData("ApplyLiveSessionSelection")]
|
||||
[InlineData("ApplyLiveSessionEnteredWorld")]
|
||||
public void DisplacedLifecycleBodiesAreAbsent(string methodName)
|
||||
{
|
||||
Assert.Null(typeof(GameWindow).GetMethod(methodName, PrivateInstance));
|
||||
|
|
|
|||
|
|
@ -13,6 +13,33 @@ namespace AcDream.App.Tests.Net;
|
|||
|
||||
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()
|
||||
{
|
||||
|
|
@ -157,33 +184,46 @@ public sealed class LiveSessionEventRouterTests
|
|||
Counters counters,
|
||||
Action<int>? constructionCheckpoint = null,
|
||||
CombatState? combat = null,
|
||||
ChatLog? chat = null) => new(
|
||||
session,
|
||||
new LiveEntitySessionSink(
|
||||
Spawned: _ => { },
|
||||
Deleted: _ => { },
|
||||
PickedUp: _ => { },
|
||||
MotionUpdated: _ => { },
|
||||
PositionUpdated: _ => { },
|
||||
VectorUpdated: _ => { },
|
||||
StateUpdated: _ => { },
|
||||
ParentUpdated: _ => { },
|
||||
TeleportStarted: _ => counters.Teleport++,
|
||||
AppearanceUpdated: _ => { },
|
||||
PlayPhysicsScript: _ => { },
|
||||
PlayPhysicsScriptType: _ => { }),
|
||||
new LiveEnvironmentSessionSink(
|
||||
EnvironChanged: _ => { },
|
||||
ServerTimeUpdated: _ =>
|
||||
{
|
||||
if (counters.ThrowOnServerTime)
|
||||
throw new InvalidOperationException("injected sink failure");
|
||||
counters.ServerTime++;
|
||||
}),
|
||||
NewInventoryBindings(),
|
||||
NewCharacterBindings(combat),
|
||||
NewSocialBindings(chat),
|
||||
constructionCheckpoint);
|
||||
ChatLog? chat = null)
|
||||
{
|
||||
var router = new LiveSessionEventRouter(
|
||||
session,
|
||||
new LiveEntitySessionSink(
|
||||
Spawned: _ => { },
|
||||
Deleted: _ => { },
|
||||
PickedUp: _ => { },
|
||||
MotionUpdated: _ => { },
|
||||
PositionUpdated: _ => { },
|
||||
VectorUpdated: _ => { },
|
||||
StateUpdated: _ => { },
|
||||
ParentUpdated: _ => { },
|
||||
TeleportStarted: _ => counters.Teleport++,
|
||||
AppearanceUpdated: _ => { },
|
||||
PlayPhysicsScript: _ => { },
|
||||
PlayPhysicsScriptType: _ => { }),
|
||||
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(),
|
||||
|
|
|
|||
391
tests/AcDream.App.Tests/Net/LiveSessionHostTests.cs
Normal file
391
tests/AcDream.App.Tests/Net/LiveSessionHostTests.cs
Normal file
|
|
@ -0,0 +1,391 @@
|
|||
using System.Net;
|
||||
using AcDream.App.Net;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.Core.Net;
|
||||
using AcDream.Core.Net.Messages;
|
||||
|
||||
namespace AcDream.App.Tests.Net;
|
||||
|
||||
public sealed class LiveSessionHostTests
|
||||
{
|
||||
[Fact]
|
||||
public void HostOwnsRouteSelectionAndEntryOrderingWithoutMirroringSession()
|
||||
{
|
||||
var calls = new List<string>();
|
||||
var operations = new TestOperations(calls);
|
||||
var controller = new LiveSessionController(operations);
|
||||
var events = new TestEventRouting(calls);
|
||||
var commands = new TestCommandRouting(calls);
|
||||
LiveSessionHost host = CreateHost(
|
||||
controller,
|
||||
calls,
|
||||
_ =>
|
||||
{
|
||||
calls.Add("events");
|
||||
return events;
|
||||
},
|
||||
_ =>
|
||||
{
|
||||
calls.Add("commands");
|
||||
return commands;
|
||||
});
|
||||
|
||||
LiveSessionStartResult result = host.Start(LiveOptions());
|
||||
|
||||
Assert.Equal(LiveSessionStartStatus.Connected, result.Status);
|
||||
Assert.Equal(
|
||||
[
|
||||
"reset", "resolve", "create", "events", "attach-events", "commands",
|
||||
"connecting", "connect", "connected",
|
||||
"player:1342177282", "vitals:1342177282",
|
||||
"chat:1342177282", "persistent:1342177282",
|
||||
"vanish:1342177282", "clear-combat", "enter:1",
|
||||
"activate", "active:Ready", "restore-layout",
|
||||
"sync-toolbar", "load-settings:Ready", "arm-auto-entry",
|
||||
],
|
||||
calls);
|
||||
Assert.Same(controller.CurrentSession, host.CurrentSession);
|
||||
Assert.Same(commands, host.Commands);
|
||||
Assert.True(host.IsInWorld);
|
||||
|
||||
controller.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DisabledAndMissingCredentialStartsExecuteTheCanonicalResetPlan()
|
||||
{
|
||||
var calls = new List<string>();
|
||||
var operations = new TestOperations(calls);
|
||||
var controller = new LiveSessionController(operations);
|
||||
LiveSessionHost host = CreateHost(
|
||||
controller,
|
||||
calls,
|
||||
_ => throw new InvalidOperationException("must not bind"),
|
||||
_ => throw new InvalidOperationException("must not bind"));
|
||||
|
||||
Assert.Equal(
|
||||
LiveSessionStartStatus.Disabled,
|
||||
host.Start(LiveOptions(live: false)).Status);
|
||||
Assert.Equal(
|
||||
LiveSessionStartStatus.MissingCredentials,
|
||||
host.Start(LiveOptions(user: null)).Status);
|
||||
|
||||
Assert.Equal(["reset", "reset"], calls);
|
||||
Assert.Null(host.CurrentSession);
|
||||
Assert.False(host.IsInWorld);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CommandFactoryFailureRollsBackTheAlreadyAttachedEventRoute()
|
||||
{
|
||||
var calls = new List<string>();
|
||||
var operations = new TestOperations(calls);
|
||||
var controller = new LiveSessionController(operations);
|
||||
var events = new TestEventRouting(calls);
|
||||
LiveSessionHost host = CreateHost(
|
||||
controller,
|
||||
calls,
|
||||
_ => events,
|
||||
_ => throw new InvalidOperationException("command failure"));
|
||||
|
||||
LiveSessionStartResult result = host.Start(LiveOptions());
|
||||
|
||||
Assert.Equal(LiveSessionStartStatus.Failed, result.Status);
|
||||
Assert.IsType<InvalidOperationException>(result.Error);
|
||||
Assert.Equal(1, events.DisposeCount);
|
||||
Assert.Contains("dispose-events", calls);
|
||||
Assert.Null(host.CurrentSession);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AttachFailureRetainsThePublishedRouteUntilRollbackConverges()
|
||||
{
|
||||
var calls = new List<string>();
|
||||
var operations = new TestOperations(calls);
|
||||
var controller = new LiveSessionController(operations);
|
||||
var firstEvents = new TestEventRouting(calls)
|
||||
{
|
||||
AttachFailuresRemaining = 1,
|
||||
FailuresRemaining = 1,
|
||||
};
|
||||
int eventFactoryCount = 0;
|
||||
int commandFactoryCount = 0;
|
||||
LiveSessionHost host = CreateHost(
|
||||
controller,
|
||||
calls,
|
||||
_ => eventFactoryCount++ == 0
|
||||
? firstEvents
|
||||
: new TestEventRouting(calls),
|
||||
_ =>
|
||||
{
|
||||
commandFactoryCount++;
|
||||
return new TestCommandRouting(calls);
|
||||
});
|
||||
|
||||
LiveSessionStartResult failed = host.Start(LiveOptions());
|
||||
|
||||
Assert.Equal(LiveSessionStartStatus.Failed, failed.Status);
|
||||
Assert.Equal(1, firstEvents.AttachCount);
|
||||
Assert.Equal(2, firstEvents.DisposeCount);
|
||||
Assert.Equal(0, commandFactoryCount);
|
||||
|
||||
LiveSessionStartResult retry = host.Start(LiveOptions());
|
||||
|
||||
Assert.Equal(LiveSessionStartStatus.Connected, retry.Status);
|
||||
Assert.Equal(2, eventFactoryCount);
|
||||
Assert.Equal(1, commandFactoryCount);
|
||||
controller.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TransientRollbackFailureIsReportedRetriedAndConvergesBeforeNextStart()
|
||||
{
|
||||
var calls = new List<string>();
|
||||
var operations = new TestOperations(calls);
|
||||
var controller = new LiveSessionController(operations);
|
||||
var firstEvents = new TestEventRouting(calls) { FailuresRemaining = 1 };
|
||||
int eventFactoryCount = 0;
|
||||
bool failCommands = true;
|
||||
LiveSessionHost host = CreateHost(
|
||||
controller,
|
||||
calls,
|
||||
_ => eventFactoryCount++ == 0
|
||||
? firstEvents
|
||||
: new TestEventRouting(calls),
|
||||
_ =>
|
||||
{
|
||||
if (failCommands)
|
||||
{
|
||||
failCommands = false;
|
||||
throw new InvalidOperationException("command failure");
|
||||
}
|
||||
return new TestCommandRouting(calls);
|
||||
});
|
||||
|
||||
LiveSessionStartResult failed = host.Start(LiveOptions());
|
||||
|
||||
AggregateException error = Assert.IsType<AggregateException>(failed.Error);
|
||||
Assert.Equal(2, error.InnerExceptions.Count);
|
||||
Assert.Contains(error.InnerExceptions, e => e.Message == "command failure");
|
||||
Assert.Contains(error.InnerExceptions, e => e.Message == "event cleanup failure");
|
||||
Assert.Equal(2, firstEvents.DisposeCount);
|
||||
|
||||
LiveSessionStartResult retry = host.Start(LiveOptions());
|
||||
|
||||
Assert.Equal(LiveSessionStartStatus.Connected, retry.Status);
|
||||
Assert.Equal(2, eventFactoryCount);
|
||||
Assert.Equal(2, operations.Sessions.Count);
|
||||
controller.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PersistentRollbackFailureBlocksEveryLaterGeneration()
|
||||
{
|
||||
var calls = new List<string>();
|
||||
var operations = new TestOperations(calls);
|
||||
var controller = new LiveSessionController(operations);
|
||||
var events = new TestEventRouting(calls)
|
||||
{
|
||||
FailuresRemaining = int.MaxValue,
|
||||
};
|
||||
int eventFactoryCount = 0;
|
||||
int commandFactoryCount = 0;
|
||||
LiveSessionHost host = CreateHost(
|
||||
controller,
|
||||
calls,
|
||||
_ =>
|
||||
{
|
||||
eventFactoryCount++;
|
||||
return events;
|
||||
},
|
||||
_ =>
|
||||
{
|
||||
commandFactoryCount++;
|
||||
throw new InvalidOperationException("command failure");
|
||||
});
|
||||
|
||||
LiveSessionStartResult first = host.Start(LiveOptions());
|
||||
LiveSessionStartResult second = host.Start(LiveOptions());
|
||||
|
||||
Assert.Equal(LiveSessionStartStatus.Failed, first.Status);
|
||||
Assert.Equal(LiveSessionStartStatus.Failed, second.Status);
|
||||
Assert.Equal(3, events.DisposeCount);
|
||||
Assert.Equal(1, eventFactoryCount);
|
||||
Assert.Equal(1, commandFactoryCount);
|
||||
Assert.Single(operations.Sessions);
|
||||
Assert.Null(host.CurrentSession);
|
||||
}
|
||||
|
||||
private static LiveSessionHost CreateHost(
|
||||
LiveSessionController controller,
|
||||
List<string> calls,
|
||||
Func<WorldSession, ILiveSessionEventRouting> createEvents,
|
||||
Func<WorldSession, ILiveSessionCommandRouting> createCommands) =>
|
||||
new(controller, new LiveSessionHostBindings(
|
||||
Routing: new(createEvents, createCommands),
|
||||
Reset: ResetBindings(() => calls.Add("reset")),
|
||||
Selection: new(
|
||||
id => calls.Add($"player:{id}"),
|
||||
id => calls.Add($"vitals:{id}"),
|
||||
id => calls.Add($"chat:{id}"),
|
||||
id => calls.Add($"persistent:{id}"),
|
||||
id => calls.Add($"vanish:{id}"),
|
||||
() => calls.Add("clear-combat")),
|
||||
EnteredWorld: new(
|
||||
name => calls.Add($"active:{name}"),
|
||||
() => calls.Add("restore-layout"),
|
||||
() => calls.Add("sync-toolbar"),
|
||||
name => calls.Add($"load-settings:{name}"),
|
||||
() => calls.Add("arm-auto-entry")),
|
||||
Connecting: (_, _, _) => calls.Add("connecting"),
|
||||
Connected: () => calls.Add("connected")));
|
||||
|
||||
private static LiveSessionResetBindings ResetBindings(Action reset)
|
||||
{
|
||||
Action noop = static () => { };
|
||||
return new LiveSessionResetBindings
|
||||
{
|
||||
MouseCapture = reset,
|
||||
PlayerPresentation = noop,
|
||||
TeleportTransit = noop,
|
||||
SessionDialogs = noop,
|
||||
ChatCommandTargets = noop,
|
||||
SettingsCharacterContext = noop,
|
||||
EquippedChildren = noop,
|
||||
ExternalContainer = noop,
|
||||
InteractionAndSelection = noop,
|
||||
SelectionPresentation = noop,
|
||||
ObjectTable = noop,
|
||||
Spellbook = noop,
|
||||
MagicRuntime = noop,
|
||||
CombatAttack = noop,
|
||||
CombatState = noop,
|
||||
ItemMana = noop,
|
||||
LocalPlayer = noop,
|
||||
Friends = noop,
|
||||
Squelch = noop,
|
||||
TurbineChat = noop,
|
||||
ParticleVisibility = noop,
|
||||
InboundEventFifo = noop,
|
||||
LiveLiveness = noop,
|
||||
LiveRuntime = noop,
|
||||
SessionIdentity = noop,
|
||||
RemoteTeleport = noop,
|
||||
NetworkEffects = noop,
|
||||
AnimationHookFrames = noop,
|
||||
LivePresentation = noop,
|
||||
RemoteMovementDiagnostics = noop,
|
||||
};
|
||||
}
|
||||
|
||||
private static RuntimeOptions LiveOptions(bool live = true, string? user = "user")
|
||||
{
|
||||
var environment = new Dictionary<string, string?>
|
||||
{
|
||||
["ACDREAM_LIVE"] = live ? "1" : "0",
|
||||
["ACDREAM_TEST_HOST"] = "127.0.0.1",
|
||||
["ACDREAM_TEST_PORT"] = "9000",
|
||||
["ACDREAM_TEST_USER"] = user,
|
||||
["ACDREAM_TEST_PASS"] = "password",
|
||||
};
|
||||
return RuntimeOptions.Parse("dat", environment.GetValueOrDefault);
|
||||
}
|
||||
|
||||
private sealed class TestEventRouting(List<string> calls)
|
||||
: ILiveSessionEventRouting
|
||||
{
|
||||
public int FailuresRemaining { get; set; }
|
||||
public int AttachFailuresRemaining { get; set; }
|
||||
public int AttachCount { get; private set; }
|
||||
public int DisposeCount { get; private set; }
|
||||
|
||||
public void Attach()
|
||||
{
|
||||
AttachCount++;
|
||||
calls.Add("attach-events");
|
||||
if (AttachFailuresRemaining > 0)
|
||||
{
|
||||
AttachFailuresRemaining--;
|
||||
throw new InvalidOperationException("event attach failure");
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
DisposeCount++;
|
||||
calls.Add("dispose-events");
|
||||
if (FailuresRemaining > 0)
|
||||
{
|
||||
FailuresRemaining--;
|
||||
throw new InvalidOperationException("event cleanup failure");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestCommandRouting(List<string> calls)
|
||||
: ILiveSessionCommandRouting
|
||||
{
|
||||
public void Activate() => calls.Add("activate");
|
||||
public void Publish<T>(T command) where T : notnull { }
|
||||
public void Dispose() => calls.Add("dispose-commands");
|
||||
}
|
||||
|
||||
private sealed class TestOperations(List<string> calls) : ILiveSessionOperations
|
||||
{
|
||||
public List<WorldSession> Sessions { get; } = [];
|
||||
|
||||
public IPEndPoint ResolveEndpoint(string host, int port)
|
||||
{
|
||||
calls.Add("resolve");
|
||||
return new IPEndPoint(IPAddress.Loopback, port);
|
||||
}
|
||||
|
||||
public WorldSession CreateSession(IPEndPoint endpoint)
|
||||
{
|
||||
calls.Add("create");
|
||||
var session = new WorldSession(endpoint, new TestTransport());
|
||||
Sessions.Add(session);
|
||||
return session;
|
||||
}
|
||||
|
||||
public void Connect(WorldSession session, string user, string password) =>
|
||||
calls.Add("connect");
|
||||
|
||||
public CharacterList.Parsed? GetCharacters(WorldSession session) => new(
|
||||
0u,
|
||||
[
|
||||
new CharacterList.Character(0x50000001u, "Grey", 10u),
|
||||
new CharacterList.Character(0x50000002u, "Ready", 0u),
|
||||
],
|
||||
[],
|
||||
11,
|
||||
"Canonical",
|
||||
true,
|
||||
true);
|
||||
|
||||
public void EnterWorld(WorldSession session, int activeCharacterIndex) =>
|
||||
calls.Add($"enter:{activeCharacterIndex}");
|
||||
|
||||
public void Tick(WorldSession session) { }
|
||||
|
||||
public void DisposeSession(WorldSession session)
|
||||
{
|
||||
calls.Add("dispose-session");
|
||||
session.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestTransport : IWorldSessionTransport
|
||||
{
|
||||
public void Send(ReadOnlySpan<byte> datagram) { }
|
||||
public void Send(IPEndPoint remote, ReadOnlySpan<byte> datagram) { }
|
||||
|
||||
public byte[]? Receive(TimeSpan timeout, out IPEndPoint? from)
|
||||
{
|
||||
from = null;
|
||||
return null;
|
||||
}
|
||||
|
||||
public void Dispose() { }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
using AcDream.App.Net;
|
||||
|
||||
namespace AcDream.App.Tests.Net;
|
||||
|
||||
public sealed class LiveSessionSubscriptionSetTests
|
||||
{
|
||||
[Fact]
|
||||
public void Dispose_RetriesOnlyFailedEdgesUntilTheyConverge()
|
||||
{
|
||||
var order = new List<int>();
|
||||
var subscriptions = new LiveSessionSubscriptionSet();
|
||||
subscriptions.Add(() => order.Add(1));
|
||||
int secondFailures = 1;
|
||||
subscriptions.Add(() =>
|
||||
{
|
||||
order.Add(2);
|
||||
if (secondFailures-- > 0)
|
||||
throw new InvalidOperationException("second failed");
|
||||
});
|
||||
int thirdFailures = 1;
|
||||
subscriptions.Add(() =>
|
||||
{
|
||||
order.Add(3);
|
||||
if (thirdFailures-- > 0)
|
||||
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();
|
||||
subscriptions.Dispose();
|
||||
|
||||
Assert.Equal([3, 2, 1, 3, 2], order);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_PersistentFailureNeverReplaysSuccessfulEdges()
|
||||
{
|
||||
var order = new List<int>();
|
||||
var subscriptions = new LiveSessionSubscriptionSet();
|
||||
subscriptions.Add(() => order.Add(1));
|
||||
subscriptions.Add(() =>
|
||||
{
|
||||
order.Add(2);
|
||||
throw new InvalidOperationException("persistent");
|
||||
});
|
||||
|
||||
Assert.Throws<AggregateException>(subscriptions.Dispose);
|
||||
Assert.Throws<AggregateException>(subscriptions.Dispose);
|
||||
|
||||
Assert.Equal([2, 1, 2], order);
|
||||
}
|
||||
}
|
||||
112
tests/AcDream.App.Tests/Net/RetailSkillFormulaTests.cs
Normal file
112
tests/AcDream.App.Tests/Net/RetailSkillFormulaTests.cs
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
using AcDream.App.Net;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Enums;
|
||||
using DatReaderWriter.Types;
|
||||
|
||||
namespace AcDream.App.Tests.Net;
|
||||
|
||||
public sealed class RetailSkillFormulaTests
|
||||
{
|
||||
[Fact]
|
||||
public void ZeroDivisorIsTheOnlyFormulaFailureGate()
|
||||
{
|
||||
SkillFormula invalid = Formula(w: 7, x: 1, y: 1, z: 0);
|
||||
Assert.False(RetailSkillFormula.TryCalculate(invalid, 10u, 20u, out uint invalidResult));
|
||||
Assert.Equal(0u, invalidResult);
|
||||
|
||||
SkillFormula zeroX = Formula(w: 7, x: 0, y: 2, z: 3);
|
||||
Assert.True(RetailSkillFormula.TryCalculate(zeroX, 10u, 4u, out uint validResult));
|
||||
Assert.Equal(5u, validResult);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(9u, 4u, 2u)]
|
||||
[InlineData(10u, 4u, 3u)]
|
||||
[InlineData(11u, 4u, 3u)]
|
||||
[InlineData(12u, 4u, 3u)]
|
||||
public void DivisionRoundsToNearestWithExactHalvesUp(
|
||||
uint numerator,
|
||||
uint divisor,
|
||||
uint expected)
|
||||
{
|
||||
SkillFormula formula = Formula(w: 0, x: 1, y: 0, z: divisor);
|
||||
|
||||
Assert.True(RetailSkillFormula.TryCalculate(
|
||||
formula,
|
||||
numerator,
|
||||
0u,
|
||||
out uint result));
|
||||
Assert.Equal(expected, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AdditiveWordIsInsideTheNumerator()
|
||||
{
|
||||
SkillFormula formula = Formula(w: 3, x: 1, y: 1, z: 4);
|
||||
|
||||
Assert.True(RetailSkillFormula.TryCalculate(formula, 4u, 2u, out uint result));
|
||||
|
||||
Assert.Equal(2u, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NumeratorUsesUnsignedThirtyTwoBitWrap()
|
||||
{
|
||||
SkillFormula formula = Formula(w: 1, x: 2, y: 0, z: 2);
|
||||
|
||||
Assert.True(RetailSkillFormula.TryCalculate(
|
||||
formula,
|
||||
uint.MaxValue,
|
||||
0u,
|
||||
out uint result));
|
||||
|
||||
Assert.Equal(0x80000000u, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SignedDatStorageIsReinterpretedAsRetailUnsignedWords()
|
||||
{
|
||||
SkillFormula formula = Formula(w: -1, x: 0, y: 0, z: 1);
|
||||
|
||||
Assert.True(RetailSkillFormula.TryCalculate(formula, 0u, 0u, out uint result));
|
||||
|
||||
Assert.Equal(uint.MaxValue, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LiveResolverLooksUpTheDatFormulaAndTreatsMissingAttributesAsZero()
|
||||
{
|
||||
const uint skillId = 0x345u;
|
||||
const uint firstAttributeId = 1u;
|
||||
const uint secondAttributeId = 2u;
|
||||
var skillTable = new SkillTable();
|
||||
skillTable.Skills.Add((SkillId)skillId, new SkillBase
|
||||
{
|
||||
Formula = new SkillFormula
|
||||
{
|
||||
AdditiveBonus = 1,
|
||||
Attribute1Multiplier = 1,
|
||||
Attribute2Multiplier = 2,
|
||||
Divisor = 3,
|
||||
Attribute1 = (AttributeId)firstAttributeId,
|
||||
Attribute2 = (AttributeId)secondAttributeId,
|
||||
},
|
||||
});
|
||||
var resolver = new LiveSkillCreditResolver(skillTable);
|
||||
|
||||
uint result = resolver.Resolve(
|
||||
skillId,
|
||||
new Dictionary<uint, uint> { [firstAttributeId] = 8u });
|
||||
|
||||
Assert.Equal(3u, result);
|
||||
Assert.Equal(0u, resolver.Resolve(0x999u, new Dictionary<uint, uint>()));
|
||||
}
|
||||
|
||||
private static SkillFormula Formula(int w, int x, int y, uint z) => new()
|
||||
{
|
||||
AdditiveBonus = w,
|
||||
Attribute1Multiplier = x,
|
||||
Attribute2Multiplier = y,
|
||||
Divisor = unchecked((int)z),
|
||||
};
|
||||
}
|
||||
|
|
@ -48,7 +48,7 @@ public sealed class GameWindowSlice8BoundaryTests
|
|||
{
|
||||
string body = MethodBody(
|
||||
"private void OnLoad()",
|
||||
"private AcDream.App.Net.LiveSessionLifecycleHost");
|
||||
"private AcDream.App.Net.LiveSessionHost");
|
||||
|
||||
AssertAppearsInOrder(
|
||||
body,
|
||||
|
|
@ -71,9 +71,10 @@ public sealed class GameWindowSlice8BoundaryTests
|
|||
"_liveEntities = new AcDream.App.World.LiveEntityRuntime(",
|
||||
"_renderFrameOrchestrator =",
|
||||
"_updateFrameOrchestrator = new AcDream.App.Update.UpdateFrameOrchestrator(",
|
||||
"_liveSessionController.Start(");
|
||||
Assert.Equal(1, CountOccurrences(body, "_liveSessionController.Start("));
|
||||
int start = body.IndexOf("_liveSessionController.Start(", StringComparison.Ordinal);
|
||||
"_liveSessionHost = CreateLiveSessionHost();",
|
||||
"_liveSessionHost.Start(_options)");
|
||||
Assert.Equal(1, CountOccurrences(body, "_liveSessionHost.Start(_options)"));
|
||||
int start = body.IndexOf("_liveSessionHost.Start(_options)", StringComparison.Ordinal);
|
||||
string postStart = body[start..];
|
||||
Assert.Contains("switch (liveStart.Status)", postStart, StringComparison.Ordinal);
|
||||
Assert.Empty(System.Text.RegularExpressions.Regex.Matches(
|
||||
|
|
|
|||
|
|
@ -18,20 +18,24 @@ public sealed class SubscriptionSetTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_AttemptsEveryReleaseAndAggregatesFailures()
|
||||
public void Dispose_RetriesOnlyFailedReleasesUntilTheyConverge()
|
||||
{
|
||||
var order = new List<int>();
|
||||
var subscriptions = new SubscriptionSet();
|
||||
subscriptions.Add(() => order.Add(1));
|
||||
int secondFailures = 1;
|
||||
subscriptions.Add(() =>
|
||||
{
|
||||
order.Add(2);
|
||||
throw new InvalidOperationException("second failed");
|
||||
if (secondFailures-- > 0)
|
||||
throw new InvalidOperationException("second failed");
|
||||
});
|
||||
int thirdFailures = 1;
|
||||
subscriptions.Add(() =>
|
||||
{
|
||||
order.Add(3);
|
||||
throw new IOException("third failed");
|
||||
if (thirdFailures-- > 0)
|
||||
throw new IOException("third failed");
|
||||
});
|
||||
|
||||
AggregateException error = Assert.Throws<AggregateException>(
|
||||
|
|
@ -42,6 +46,28 @@ public sealed class SubscriptionSetTests
|
|||
error.InnerExceptions,
|
||||
exception => Assert.IsType<IOException>(exception),
|
||||
exception => Assert.IsType<InvalidOperationException>(exception));
|
||||
|
||||
subscriptions.Dispose();
|
||||
subscriptions.Dispose();
|
||||
|
||||
Assert.Equal([3, 2, 1, 3, 2], order);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_PersistentFailureNeverReplaysSuccessfulReleases()
|
||||
{
|
||||
var order = new List<int>();
|
||||
var subscriptions = new SubscriptionSet();
|
||||
subscriptions.Add(() => order.Add(1));
|
||||
subscriptions.Add(() =>
|
||||
{
|
||||
order.Add(2);
|
||||
throw new InvalidOperationException("persistent");
|
||||
});
|
||||
|
||||
Assert.Throws<AggregateException>(subscriptions.Dispose);
|
||||
Assert.Throws<AggregateException>(subscriptions.Dispose);
|
||||
|
||||
Assert.Equal([2, 1, 2], order);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue