refactor(net): converge live session state reset
This commit is contained in:
parent
78a9223b65
commit
4f31a5085f
35 changed files with 1460 additions and 83 deletions
|
|
@ -215,6 +215,34 @@ public sealed class CombatAttackControllerTests
|
|||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResetSession_RestoresRetailBeginDefaultsWithoutSendingCancel()
|
||||
{
|
||||
double now = 1d;
|
||||
int cancels = 0;
|
||||
var combat = new CombatState();
|
||||
using var controller = new CombatAttackController(
|
||||
combat,
|
||||
canStartAttack: () => true,
|
||||
sendAttack: (_, _) => true,
|
||||
sendCancelAttack: () => cancels++,
|
||||
now: () => now);
|
||||
combat.SetCombatMode(CombatMode.Melee);
|
||||
controller.SetDesiredPower(1f);
|
||||
controller.PressAttack(AttackHeight.High);
|
||||
now = 1.5d;
|
||||
|
||||
controller.ResetSession();
|
||||
|
||||
Assert.False(controller.AttackRequestInProgress);
|
||||
Assert.False(controller.BuildInProgress);
|
||||
Assert.Equal(0f, controller.RequestedAttackPower);
|
||||
Assert.Equal(0f, controller.PowerBarLevel);
|
||||
Assert.Equal(AttackHeight.Medium, controller.RequestedHeight);
|
||||
Assert.Equal(CombatAttackController.InitialDesiredPower, controller.DesiredPower);
|
||||
Assert.Equal(0, cancels);
|
||||
}
|
||||
|
||||
private static CombatAttackController Create(
|
||||
CombatState combat,
|
||||
Func<double> now,
|
||||
|
|
|
|||
387
tests/AcDream.App.Tests/Net/LiveSessionResetPlanTests.cs
Normal file
387
tests/AcDream.App.Tests/Net/LiveSessionResetPlanTests.cs
Normal file
|
|
@ -0,0 +1,387 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -92,4 +92,33 @@ public class CameraControllerTests
|
|||
CameraDiagnostics.UseRetailChaseCamera = saved;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExitChaseMode_AlreadyOfflinePreservesOrbitCamera()
|
||||
{
|
||||
var orbit = new OrbitCamera();
|
||||
var ctl = new CameraController(orbit, new FlyCamera());
|
||||
|
||||
ctl.ExitChaseMode();
|
||||
|
||||
Assert.Same(orbit, ctl.Active);
|
||||
Assert.False(ctl.IsFlyMode);
|
||||
Assert.False(ctl.IsChaseMode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExitChaseMode_AfterChaseToFlyReleasesRetainedSessionCameras()
|
||||
{
|
||||
var (ctl, _, _) = MakeChaseFixture();
|
||||
ctl.ToggleFly();
|
||||
Assert.True(ctl.IsFlyMode);
|
||||
Assert.NotNull(ctl.Chase);
|
||||
Assert.NotNull(ctl.RetailChase);
|
||||
|
||||
ctl.ExitChaseMode();
|
||||
|
||||
Assert.True(ctl.IsFlyMode);
|
||||
Assert.Null(ctl.Chase);
|
||||
Assert.Null(ctl.RetailChase);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,4 +55,25 @@ public sealed class GameplayConfirmationControllerTests
|
|||
Assert.Equal([(7u, 99u, false)], responses);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FactoryReset_CompletesResponseBeforeSessionTupleIsForgotten()
|
||||
{
|
||||
var root = new UiRoot { Width = 800f, Height = 600f };
|
||||
var factory = new RetailDialogFactory(root, _ =>
|
||||
FixtureLoader.LoadConfirmationDialog());
|
||||
var responses = new List<(uint Type, uint Context, bool Accepted)>();
|
||||
using var controller = new GameplayConfirmationController(
|
||||
factory,
|
||||
(type, context, accepted) => responses.Add((type, context, accepted)));
|
||||
controller.HandleRequest(
|
||||
new GameEvents.CharacterConfirmationRequest(7u, 99u, "Proceed?"));
|
||||
|
||||
factory.Reset();
|
||||
controller.ResetSession();
|
||||
|
||||
Assert.Equal(0u, controller.ActiveDialogContext);
|
||||
Assert.Equal([(7u, 99u, false)], responses);
|
||||
Assert.False(factory.IsOpen);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,4 +29,31 @@ public sealed class InteractionStateTests
|
|||
Assert.Throws<ArgumentOutOfRangeException>(() => state.EnterUseItemOnTarget(0));
|
||||
Assert.Equal(InteractionMode.None, state.Current);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResetSession_RetryRepublishesAndOneObserverCannotStarveAnother()
|
||||
{
|
||||
var state = new InteractionState();
|
||||
state.EnterExamine();
|
||||
bool fail = true;
|
||||
int delivered = 0;
|
||||
state.Changed += _ =>
|
||||
{
|
||||
if (fail)
|
||||
{
|
||||
fail = false;
|
||||
throw new InvalidOperationException("transient");
|
||||
}
|
||||
};
|
||||
state.Changed += transition =>
|
||||
{
|
||||
Assert.Equal(InteractionMode.None, transition.Current);
|
||||
delivered++;
|
||||
};
|
||||
|
||||
Assert.Throws<AggregateException>(state.ResetSession);
|
||||
Assert.Equal(1, delivered);
|
||||
state.ResetSession();
|
||||
Assert.Equal(2, delivered);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -122,6 +122,33 @@ public sealed class ItemInteractionControllerTests
|
|||
Assert.Empty(h.UseWithTarget);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResetSession_RetryNotifiesEveryStateObserver()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.Controller.InteractionState.EnterExamine();
|
||||
h.Controller.IncrementBusyCount();
|
||||
bool fail = true;
|
||||
int delivered = 0;
|
||||
h.Controller.StateChanged += () =>
|
||||
{
|
||||
if (fail)
|
||||
{
|
||||
fail = false;
|
||||
throw new InvalidOperationException("transient");
|
||||
}
|
||||
};
|
||||
h.Controller.StateChanged += () => delivered++;
|
||||
|
||||
Assert.Throws<AggregateException>(h.Controller.ResetSession);
|
||||
Assert.Equal(1, delivered);
|
||||
Assert.Equal(0, h.Controller.BusyCount);
|
||||
Assert.Equal(InteractionMode.None, h.Controller.InteractionState.Current);
|
||||
|
||||
h.Controller.ResetSession();
|
||||
Assert.Equal(2, delivered);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExternalContainerUse_RequestsGroundObjectAndSendsUse()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -187,6 +187,74 @@ public sealed class RetailDialogFactoryTests
|
|||
Assert.Null(root.Modal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reset_CompletesEveryDialogAndKeepsContextSequenceMonotonic()
|
||||
{
|
||||
var root = new UiRoot { Width = 800f, Height = 600f };
|
||||
var layouts = new List<ImportedLayout>();
|
||||
var factory = CreateFactory(root, layouts);
|
||||
int callbacks = 0;
|
||||
int notices = 0;
|
||||
factory.DialogClosed += (_, _) => notices++;
|
||||
|
||||
uint first = factory.MakeConfirmation("active", _ => callbacks++);
|
||||
factory.MakeConfirmation("pending", _ => callbacks++);
|
||||
factory.Reset();
|
||||
|
||||
Assert.Equal(1u, first);
|
||||
Assert.Equal(2, callbacks);
|
||||
Assert.Equal(2, notices);
|
||||
Assert.False(factory.IsOpen);
|
||||
Assert.Equal(0, factory.PendingCount);
|
||||
Assert.Null(root.Modal);
|
||||
Assert.Empty(root.Children);
|
||||
Assert.Equal(3u, factory.MakeConfirmation("new session"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reset_AttemptsEveryDialogWhenOneCallbackThrows()
|
||||
{
|
||||
var root = new UiRoot { Width = 800f, Height = 600f };
|
||||
var layouts = new List<ImportedLayout>();
|
||||
var factory = CreateFactory(root, layouts);
|
||||
int completed = 0;
|
||||
|
||||
factory.MakeConfirmation("active", _ => throw new InvalidOperationException("boom"));
|
||||
factory.MakeConfirmation("pending", _ => completed++);
|
||||
|
||||
AggregateException error = Assert.Throws<AggregateException>(factory.Reset);
|
||||
|
||||
Assert.Contains("boom", error.ToString());
|
||||
Assert.Equal(1, completed);
|
||||
Assert.False(factory.IsOpen);
|
||||
Assert.Equal(0, factory.PendingCount);
|
||||
Assert.Null(root.Modal);
|
||||
Assert.Empty(root.Children);
|
||||
factory.Reset();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reset_DrainsDialogCreatedReentrantlyByCompletionCallback()
|
||||
{
|
||||
var root = new UiRoot { Width = 800f, Height = 600f };
|
||||
var layouts = new List<ImportedLayout>();
|
||||
var factory = CreateFactory(root, layouts);
|
||||
int completed = 0;
|
||||
factory.MakeConfirmation("first", _ =>
|
||||
{
|
||||
completed++;
|
||||
factory.MakeConfirmation("reentrant", _ => completed++);
|
||||
});
|
||||
|
||||
factory.Reset();
|
||||
|
||||
Assert.Equal(2, completed);
|
||||
Assert.False(factory.IsOpen);
|
||||
Assert.Equal(0, factory.PendingCount);
|
||||
Assert.Null(root.Modal);
|
||||
Assert.Empty(root.Children);
|
||||
}
|
||||
|
||||
private static RetailDialogFactory CreateFactory(
|
||||
UiRoot root,
|
||||
List<ImportedLayout> layouts)
|
||||
|
|
|
|||
|
|
@ -53,4 +53,27 @@ public sealed class ChatLogLocalGuidTests
|
|||
Assert.Equal("You", log.Snapshot()[0].Sender);
|
||||
Assert.Equal(ChatKind.RangedSpeech, log.Snapshot()[0].Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResetSessionIdentity_RetainsTranscriptButClearsGuidAndDedupeWindow()
|
||||
{
|
||||
var log = new ChatLog();
|
||||
const uint oldGuid = 0x50000001u;
|
||||
const uint newGuid = 0x50000002u;
|
||||
log.SetLocalPlayerGuid(oldGuid);
|
||||
log.OnSystemMessage("session boundary", 1u);
|
||||
log.OnLocalSpeech("Old", "before", oldGuid, isRanged: false);
|
||||
|
||||
log.ResetSessionIdentity();
|
||||
log.OnSystemMessage("session boundary", 1u);
|
||||
log.OnLocalSpeech("Old", "after", oldGuid, isRanged: false);
|
||||
log.SetLocalPlayerGuid(newGuid);
|
||||
log.OnLocalSpeech("New", "new", newGuid, isRanged: false);
|
||||
|
||||
ChatEntry[] entries = log.Snapshot();
|
||||
Assert.Equal(5, entries.Length);
|
||||
Assert.Equal("You", entries[1].Sender);
|
||||
Assert.Equal("Old", entries[3].Sender);
|
||||
Assert.Equal("You", entries[4].Sender);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,32 @@ namespace AcDream.Core.Tests.Chat;
|
|||
/// </summary>
|
||||
public sealed class TurbineChatStateTests
|
||||
{
|
||||
[Fact]
|
||||
public void Reset_ClearsRoomsAndRestartsSessionCookieAtOne()
|
||||
{
|
||||
var state = new TurbineChatState();
|
||||
state.OnChannelsReceived(1u, 2u, 3u, 4u, 5u, 6u, 7u, 8u, 9u, 10u);
|
||||
Assert.Equal(1u, state.NextContextId());
|
||||
Assert.Equal(2u, state.NextContextId());
|
||||
|
||||
state.Reset();
|
||||
|
||||
Assert.False(state.Enabled);
|
||||
Assert.Equal(0u, state.AllegianceRoom);
|
||||
Assert.Equal(0u, state.GeneralRoom);
|
||||
Assert.Equal(0u, state.TradeRoom);
|
||||
Assert.Equal(0u, state.LfgRoom);
|
||||
Assert.Equal(0u, state.RoleplayRoom);
|
||||
Assert.Equal(0u, state.OlthoiRoom);
|
||||
Assert.Equal(0u, state.SocietyRoom);
|
||||
Assert.Equal(0u, state.SocietyCelestialHandRoom);
|
||||
Assert.Equal(0u, state.SocietyEldrytchWebRoom);
|
||||
Assert.Equal(0u, state.SocietyRadiantBloodRoom);
|
||||
Assert.Equal(1u, state.NextContextId());
|
||||
state.Reset();
|
||||
Assert.Equal(1u, state.NextContextId());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InitialState_DisabledAndZeroRooms_NextContextIdStartsAt1()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -141,4 +141,31 @@ public sealed class CombatStateTests
|
|||
Assert.Equal(0, state.TrackedTargetCount);
|
||||
Assert.Equal(CombatMode.NonCombat, state.CurrentMode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Clear_RetryRepublishesAndOneObserverCannotStarveAnother()
|
||||
{
|
||||
var state = new CombatState();
|
||||
state.SetCombatMode(CombatMode.Melee);
|
||||
bool fail = true;
|
||||
int delivered = 0;
|
||||
state.CombatModeChanged += _ =>
|
||||
{
|
||||
if (fail)
|
||||
{
|
||||
fail = false;
|
||||
throw new InvalidOperationException("transient");
|
||||
}
|
||||
};
|
||||
state.CombatModeChanged += mode =>
|
||||
{
|
||||
Assert.Equal(CombatMode.NonCombat, mode);
|
||||
delivered++;
|
||||
};
|
||||
|
||||
Assert.Throws<AggregateException>(state.Clear);
|
||||
Assert.Equal(1, delivered);
|
||||
state.Clear();
|
||||
Assert.Equal(2, delivered);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -68,6 +68,32 @@ public sealed class ExternalContainerStateTests
|
|||
Assert.False(state.ApplyViewContents(2u));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reset_RetryRepublishesAndOneObserverCannotStarveAnother()
|
||||
{
|
||||
var state = Open(1u);
|
||||
bool fail = true;
|
||||
int delivered = 0;
|
||||
state.Changed += _ =>
|
||||
{
|
||||
if (fail)
|
||||
{
|
||||
fail = false;
|
||||
throw new InvalidOperationException("transient");
|
||||
}
|
||||
};
|
||||
state.Changed += transition =>
|
||||
{
|
||||
Assert.Equal(ExternalContainerTransitionKind.Reset, transition.Kind);
|
||||
delivered++;
|
||||
};
|
||||
|
||||
Assert.Throws<AggregateException>(() => state.Reset());
|
||||
Assert.Equal(1, delivered);
|
||||
Assert.False(state.Reset());
|
||||
Assert.Equal(2, delivered);
|
||||
}
|
||||
|
||||
private static ExternalContainerState Open(uint id)
|
||||
{
|
||||
var state = new ExternalContainerState();
|
||||
|
|
|
|||
|
|
@ -436,4 +436,45 @@ public sealed class LocalPlayerStateTests
|
|||
s.OnProperties(props);
|
||||
Assert.False(s.DebitInt64Property(2u, 100L)); // already 0 — no negative banking
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Clear_ReturnsEveryCharacterSnapshotToPreLoginState()
|
||||
{
|
||||
var s = new LocalPlayerState();
|
||||
var properties = new PropertyBundle();
|
||||
properties.Ints[1u] = 42;
|
||||
s.OnVitalUpdate(7u, 1u, 2u, 3u, 4u);
|
||||
s.OnAttributeUpdate(1u, 5u, 6u, 7u);
|
||||
s.OnSkillUpdate(8u, 9u, 2u, 10u, 11u, 12u, 13d, 14u);
|
||||
s.OnPositions(new Dictionary<uint, AcDream.Core.Physics.Position>
|
||||
{
|
||||
[1u] = new AcDream.Core.Physics.Position(
|
||||
0xA9B40001u,
|
||||
new System.Numerics.Vector3(1f, 2f, 3f),
|
||||
System.Numerics.Quaternion.Identity),
|
||||
});
|
||||
s.OnProperties(properties);
|
||||
int characterChanges = 0;
|
||||
var vitalChanges = new List<LocalPlayerState.VitalKind>();
|
||||
var attributeChanges = new List<LocalPlayerState.AttributeKind>();
|
||||
s.CharacterChanged += () => characterChanges++;
|
||||
s.Changed += vitalChanges.Add;
|
||||
s.AttributeChanged += attributeChanges.Add;
|
||||
|
||||
s.Clear();
|
||||
|
||||
Assert.Null(s.Get(LocalPlayerState.VitalKind.Health));
|
||||
Assert.Null(s.GetAttribute(LocalPlayerState.AttributeKind.Strength));
|
||||
Assert.Empty(s.Skills);
|
||||
Assert.Empty(s.Positions);
|
||||
Assert.Empty(s.Properties.Ints);
|
||||
Assert.Equal(Enum.GetValues<LocalPlayerState.VitalKind>(), vitalChanges);
|
||||
Assert.Equal(Enum.GetValues<LocalPlayerState.AttributeKind>(), attributeChanges);
|
||||
Assert.Equal(1, characterChanges);
|
||||
|
||||
s.Clear();
|
||||
Assert.Null(s.Get(LocalPlayerState.VitalKind.Health));
|
||||
Assert.Empty(s.Skills);
|
||||
Assert.Empty(s.Properties.Ints);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,33 @@ public sealed class SelectionStateTests
|
|||
Assert.False(state.SelectPrevious());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reset_RetryRepublishesAndOneObserverCannotStarveAnother()
|
||||
{
|
||||
var state = new SelectionState();
|
||||
state.Select(0x50000001u, SelectionChangeSource.World);
|
||||
bool fail = true;
|
||||
int delivered = 0;
|
||||
state.Changed += _ =>
|
||||
{
|
||||
if (fail)
|
||||
{
|
||||
fail = false;
|
||||
throw new InvalidOperationException("transient");
|
||||
}
|
||||
};
|
||||
state.Changed += transition =>
|
||||
{
|
||||
Assert.Equal(SelectionChangeReason.SessionReset, transition.Reason);
|
||||
delivered++;
|
||||
};
|
||||
|
||||
Assert.Throws<AggregateException>(() => state.Reset());
|
||||
Assert.Equal(1, delivered);
|
||||
Assert.False(state.Reset());
|
||||
Assert.Equal(2, delivered);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Select_CommitsOneTransitionAndTracksPreviousLikeRetail()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -4,6 +4,30 @@ namespace AcDream.Core.Tests.Social;
|
|||
|
||||
public sealed class SocialStateTests
|
||||
{
|
||||
[Fact]
|
||||
public void SquelchState_Clear_ReturnsEmptyDatabase()
|
||||
{
|
||||
var state = new SquelchState();
|
||||
state.Replace(new SquelchDatabase(
|
||||
new Dictionary<string, uint> { ["Account"] = 1u },
|
||||
new Dictionary<uint, SquelchInfo>
|
||||
{
|
||||
[2u] = new("Character", false, new HashSet<uint> { 3u }),
|
||||
},
|
||||
new SquelchInfo("Global", true, new HashSet<uint> { 4u })));
|
||||
|
||||
state.Clear();
|
||||
|
||||
SquelchDatabase snapshot = state.Snapshot();
|
||||
Assert.Empty(snapshot.Accounts);
|
||||
Assert.Empty(snapshot.Characters);
|
||||
Assert.Empty(snapshot.Global.MessageTypes);
|
||||
Assert.Equal(string.Empty, snapshot.Global.Name);
|
||||
Assert.False(snapshot.Global.AccountWide);
|
||||
state.Clear();
|
||||
Assert.Empty(state.Snapshot().Accounts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FriendsState_AppliesRetailIncrementalUpdateKindsByObjectId()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -13,6 +13,23 @@ namespace AcDream.UI.Abstractions.Tests.Panels.Chat;
|
|||
/// </summary>
|
||||
public sealed class ChatVMRetellAndProvidersTests
|
||||
{
|
||||
[Fact]
|
||||
public void ResetSessionTargets_ClearsReplyAndRetellWithoutClearingTranscript()
|
||||
{
|
||||
var log = new ChatLog();
|
||||
var vm = new ChatVM(log);
|
||||
log.OnTellReceived("Bestie", "incoming", 0x50000001u);
|
||||
log.OnSelfSent(ChatKind.Tell, "outgoing", targetOrChannel: "Caith");
|
||||
Assert.NotNull(vm.LastIncomingTellSender);
|
||||
Assert.NotNull(vm.LastOutgoingTellTarget);
|
||||
|
||||
vm.ResetSessionTargets();
|
||||
|
||||
Assert.Null(vm.LastIncomingTellSender);
|
||||
Assert.Null(vm.LastOutgoingTellTarget);
|
||||
Assert.Equal(2, log.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LastOutgoingTellTarget_StartsNull()
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue