using AcDream.App.Net; using AcDream.App.Rendering; using AcDream.App.Streaming; using AcDream.App.World; using AcDream.Core.Chat; using AcDream.Core.Combat; using AcDream.Core.Items; using AcDream.Core.Net; using AcDream.Core.Net.Messages; using AcDream.Core.Physics; using AcDream.Core.Player; using AcDream.Core.Selection; using AcDream.Core.Social; using AcDream.Core.Spells; using AcDream.Core.World; using DatReaderWriter.DBObjs; namespace AcDream.App.Tests.Net; public sealed class LiveSessionResetPlanTests { private sealed class FailingOnceResources : ILiveEntityResourceLifecycle { private bool _failurePending = true; public void Register(WorldEntity entity) { } public void Unregister(WorldEntity entity) { if (_failurePending) { _failurePending = false; throw new InvalidOperationException("transient teardown"); } } } [Fact] public void Execute_EmptyPlanConverges() { var plan = new LiveSessionResetPlan([]); plan.Execute(); plan.Execute(); Assert.Empty(plan.StageNames); } [Fact] public void Execute_AttemptsEveryStageAndAggregatesNamedFailures() { var calls = new List(); var plan = new LiveSessionResetPlan( [ new("first", () => { calls.Add("first"); throw new InvalidOperationException("one"); }), new("second", () => calls.Add("second")), new("third", () => { calls.Add("third"); throw new ArgumentException("three"); }), ]); AggregateException error = Assert.Throws(plan.Execute); Assert.Equal(["first", "second", "third"], calls); Assert.Collection( error.InnerExceptions, first => Assert.Equal( "first", Assert.IsType(first).StageName), third => Assert.Equal( "third", Assert.IsType(third).StageName)); } [Fact] public void Execute_AfterFailedAttemptCanConvergeWithoutSkippingStages() { int firstCalls = 0; int secondCalls = 0; var plan = new LiveSessionResetPlan( [ new("transient", () => { firstCalls++; if (firstCalls == 1) throw new InvalidOperationException("transient"); }), new("always", () => secondCalls++), ]); Assert.Throws(plan.Execute); plan.Execute(); Assert.Equal(2, firstCalls); Assert.Equal(2, secondCalls); } [Fact] public void Execute_ReentrantAttemptIsReportedButLaterStagesStillRun() { LiveSessionResetPlan? plan = null; bool tailRan = false; plan = new LiveSessionResetPlan( [ new("reentrant", () => plan!.Execute()), new("tail", () => tailRan = true), ]); AggregateException error = Assert.Throws(plan.Execute); Assert.True(tailRan); LiveSessionResetStageException stage = Assert.IsType( Assert.Single(error.InnerExceptions)); Assert.Equal("reentrant", stage.StageName); Assert.IsType(stage.InnerException); } [Fact] public void Constructor_RejectsDuplicateStageNames() { Assert.Throws(() => new LiveSessionResetPlan( [ new("duplicate", () => { }), new("duplicate", () => { }), ])); } [Fact] public void Manifest_MutatedA_FailedDrain_RetryConvergesWithoutClearingRetainedWorld() { const uint playerA = 0x50000001u; const uint objectA = 0x70000001u; const uint landblock = 0x0101FFFFu; var staticEntity = Entity(42u, 0u); var spatial = new GpuWorldState(); spatial.AddLandblock(new LoadedLandblock( landblock, new LandBlock(), new List { staticEntity })); var live = new LiveEntityRuntime(spatial, new FailingOnceResources()); WorldSession.EntitySpawn spawn = Spawn(playerA, 1, 1, 0x01010001u); live.RegisterLiveEntity(spawn); live.MaterializeLiveEntity( playerA, 0x01010001u, id => Entity(id, playerA)); spatial.MarkPersistent(playerA); var objects = new ClientObjectTable(); objects.AddOrUpdate(new ClientObject { ObjectId = objectA, Name = "A" }); var spells = new Spellbook(); spells.OnSpellLearned(123u, 1f); var combat = new CombatState(); combat.OnUpdateHealth(objectA, 0.5f); combat.SetCombatMode(CombatMode.Melee); var mana = new ItemManaState(); mana.OnQueryItemManaResponse(objectA, 0.5f, valid: true); var localPlayer = new LocalPlayerState(spells); localPlayer.OnVitalUpdate(7u, 1u, 100u, 5u, 80u); var friends = new FriendsState(); friends.Apply(new FriendsUpdate( FriendsUpdateType.Full, [new FriendEntry(objectA, "A", true, false, [], [])])); var squelch = new SquelchState(); squelch.Replace(new SquelchDatabase( new Dictionary { ["A"] = 1u }, new Dictionary(), new SquelchInfo("A", false, new HashSet()))); var turbine = new TurbineChatState(); turbine.OnChannelsReceived(1u, 2u, 3u, 4u, 5u, 6u, 7u, 8u, 9u, 10u); var selection = new SelectionState(); selection.Select(objectA, SelectionChangeSource.World); var external = new ExternalContainerState(); external.RequestOpen(objectA); external.ApplyViewContents(objectA); var chat = new ChatLog(); chat.SetLocalPlayerGuid(playerA); chat.OnSystemMessage("retained transcript", 1u); uint sessionIdentity = playerA; int savedDefaultSetting = 73; int activeSetting = savedDefaultSetting; var calls = new Dictionary(StringComparer.Ordinal); var dirty = ExpectedManifestNames().ToDictionary( static name => name, static _ => true, StringComparer.Ordinal); Action Stage(string name, Action? reset = null) => () => { calls[name] = calls.GetValueOrDefault(name) + 1; reset?.Invoke(); dirty[name] = false; }; LiveSessionResetPlan plan = LiveSessionResetManifest.Create(new() { MouseCapture = Stage("mouse capture"), PlayerPresentation = Stage("player presentation"), TeleportTransit = Stage("teleport transit"), SessionDialogs = Stage("session dialogs"), ChatCommandTargets = Stage("chat command targets"), SettingsCharacterContext = Stage( "settings character context", () => activeSetting = savedDefaultSetting), EquippedChildren = Stage("equipped children"), ExternalContainer = Stage("external container", () => external.Reset()), InteractionAndSelection = Stage( "interaction and selection", () => selection.Reset()), SelectionPresentation = Stage("selection presentation"), ObjectTable = Stage("object table", objects.Clear), Spellbook = Stage("spellbook", spells.Clear), MagicRuntime = Stage("magic runtime"), CombatAttack = Stage("combat attack"), CombatState = Stage("combat state", combat.Clear), ItemMana = Stage("item mana", mana.Clear), LocalPlayer = Stage("local player", localPlayer.Clear), Friends = Stage("friends", friends.Clear), Squelch = Stage("squelch", squelch.Clear), TurbineChat = Stage("turbine chat", turbine.Reset), ParticleVisibility = Stage("particle visibility"), InboundEventFifo = Stage("inbound event fifo"), LiveLiveness = Stage("live liveness"), LiveRuntime = Stage( "live runtime", () => LiveSessionEntityRuntimeReset.ClearAndRequireConvergence(live)), SessionIdentity = Stage("session identity", () => { LiveSessionEntityRuntimeReset.RequireConvergence(live); sessionIdentity = 0u; chat.ResetSessionIdentity(); }), RemoteTeleport = Stage("remote teleport"), NetworkEffects = Stage("network effects"), AnimationHookFrames = Stage("animation hook frames"), LivePresentation = Stage("live presentation"), RemoteMovementDiagnostics = Stage("remote movement diagnostics"), }); Assert.Equal(ExpectedManifestNames(), plan.StageNames); AggregateException first = Assert.Throws(plan.Execute); Assert.Equal( ["live runtime", "session identity"], first.InnerExceptions .Cast() .Select(static error => error.StageName)); Assert.Equal(playerA, sessionIdentity); Assert.Equal(1, live.PendingTeardownCount); Assert.Contains(staticEntity, spatial.Entities); Assert.Equal(1, spatial.PersistentGuidCount); plan.Execute(); Assert.All(ExpectedManifestNames(), name => Assert.Equal(2, calls[name])); Assert.DoesNotContain(dirty, static pair => pair.Value); Assert.Equal(0u, sessionIdentity); Assert.Equal(savedDefaultSetting, activeSetting); Assert.Equal(0, live.Count); Assert.Equal(0, live.PendingTeardownCount); Assert.Equal(0, live.MaterializedCount); Assert.Equal(0, spatial.PersistentGuidCount); Assert.Contains(staticEntity, spatial.Entities); Assert.True(spatial.IsLoaded(landblock)); Assert.Null(objects.Get(objectA)); Assert.Equal(0, spells.LearnedCount); Assert.Equal(CombatMode.NonCombat, combat.CurrentMode); Assert.False(combat.HasHealth(objectA)); Assert.False(mana.HasMana(objectA)); Assert.Null(localPlayer.Get(LocalPlayerState.VitalKind.Health)); Assert.Empty(friends.Snapshot()); Assert.Same(SquelchDatabase.Empty, squelch.Snapshot()); Assert.False(turbine.Enabled); Assert.Null(selection.SelectedObjectId); Assert.Equal(0u, external.CurrentContainerId); Assert.Equal(1, chat.Count); chat.OnLocalSpeech("A", "after", playerA, isRanged: false); Assert.Equal("A", chat.Snapshot()[1].Sender); } private static string[] ExpectedManifestNames() => [ "mouse capture", "player presentation", "teleport transit", "session dialogs", "chat command targets", "settings character context", "equipped children", "external container", "interaction and selection", "selection presentation", "object table", "spellbook", "magic runtime", "combat attack", "combat state", "item mana", "local player", "friends", "squelch", "turbine chat", "particle visibility", "inbound event fifo", "live liveness", "live runtime", "session identity", "remote teleport", "network effects", "animation hook frames", "live presentation", "remote movement diagnostics", ]; private static WorldEntity Entity(uint id, uint guid) => new() { Id = id, ServerGuid = guid, SourceGfxObjOrSetupId = 0x02000001u, Position = System.Numerics.Vector3.Zero, Rotation = System.Numerics.Quaternion.Identity, MeshRefs = Array.Empty(), }; private static WorldSession.EntitySpawn Spawn( uint guid, ushort instance, ushort positionSequence, uint cell) { const PhysicsStateFlags state = PhysicsStateFlags.ReportCollisions; var position = new CreateObject.ServerPosition( cell, 10f, 10f, 5f, 1f, 0f, 0f, 0f); var timestamps = new PhysicsTimestamps( positionSequence, 1, 1, 1, 0, 1, 0, 1, instance); var physics = new PhysicsSpawnData( RawState: (uint)state, Position: position, Movement: null, AnimationFrame: null, SetupTableId: 0x02000001u, MotionTableId: 0x09000001u, SoundTableId: null, PhysicsScriptTableId: null, Parent: null, Children: null, Scale: null, Friction: null, Elasticity: null, Translucency: null, Velocity: null, Acceleration: null, AngularVelocity: null, DefaultScriptType: null, DefaultScriptIntensity: null, Timestamps: timestamps); return new WorldSession.EntitySpawn( guid, position, 0x02000001u, Array.Empty(), Array.Empty(), Array.Empty(), null, null, "fixture", null, null, 0x09000001u, PhysicsState: (uint)state, InstanceSequence: instance, MovementSequence: 1, ServerControlSequence: 1, PositionSequence: positionSequence, Physics: physics); } }