refactor(net): converge live session state reset

This commit is contained in:
Erik 2026-07-21 12:00:48 +02:00
parent 78a9223b65
commit 4f31a5085f
35 changed files with 1460 additions and 83 deletions

View file

@ -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,

View 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);
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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()
{

View file

@ -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)

View file

@ -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);
}
}

View file

@ -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()
{

View file

@ -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);
}
}

View file

@ -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();

View file

@ -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);
}
}

View file

@ -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()
{

View file

@ -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()
{

View file

@ -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()
{