387 lines
14 KiB
C#
387 lines
14 KiB
C#
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<string>();
|
|
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<AggregateException>(plan.Execute);
|
|
|
|
Assert.Equal(["first", "second", "third"], calls);
|
|
Assert.Collection(
|
|
error.InnerExceptions,
|
|
first => Assert.Equal(
|
|
"first",
|
|
Assert.IsType<LiveSessionResetStageException>(first).StageName),
|
|
third => Assert.Equal(
|
|
"third",
|
|
Assert.IsType<LiveSessionResetStageException>(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<AggregateException>(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<AggregateException>(plan.Execute);
|
|
|
|
Assert.True(tailRan);
|
|
LiveSessionResetStageException stage = Assert.IsType<LiveSessionResetStageException>(
|
|
Assert.Single(error.InnerExceptions));
|
|
Assert.Equal("reentrant", stage.StageName);
|
|
Assert.IsType<InvalidOperationException>(stage.InnerException);
|
|
}
|
|
|
|
[Fact]
|
|
public void Constructor_RejectsDuplicateStageNames()
|
|
{
|
|
Assert.Throws<ArgumentException>(() => 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<WorldEntity> { 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<string, uint> { ["A"] = 1u },
|
|
new Dictionary<uint, SquelchInfo>(),
|
|
new SquelchInfo("A", false, new HashSet<uint>())));
|
|
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<string, int>(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"),
|
|
PhysicsHostIndex = Stage("physics host index"),
|
|
});
|
|
|
|
Assert.Equal(ExpectedManifestNames(), plan.StageNames);
|
|
AggregateException first = Assert.Throws<AggregateException>(plan.Execute);
|
|
|
|
Assert.Equal(
|
|
["live runtime", "session identity"],
|
|
first.InnerExceptions
|
|
.Cast<LiveSessionResetStageException>()
|
|
.Select(static error => error.StageName));
|
|
Assert.Equal(playerA, sessionIdentity);
|
|
Assert.Equal(1, live.PendingTeardownCount);
|
|
Assert.False(dirty["physics host index"]);
|
|
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",
|
|
"physics host index",
|
|
];
|
|
|
|
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<MeshRef>(),
|
|
};
|
|
|
|
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<CreateObject.AnimPartChange>(),
|
|
Array.Empty<CreateObject.TextureChange>(),
|
|
Array.Empty<CreateObject.SubPaletteSwap>(),
|
|
null,
|
|
null,
|
|
"fixture",
|
|
null,
|
|
null,
|
|
0x09000001u,
|
|
PhysicsState: (uint)state,
|
|
InstanceSequence: instance,
|
|
MovementSequence: 1,
|
|
ServerControlSequence: 1,
|
|
PositionSequence: positionSequence,
|
|
Physics: physics);
|
|
}
|
|
}
|