refactor(world): canonicalize live physics host ownership
This commit is contained in:
parent
5882b308c1
commit
fcb66198fc
21 changed files with 1546 additions and 323 deletions
|
|
@ -242,7 +242,6 @@ public sealed class LiveSessionResetPlanTests
|
|||
AnimationHookFrames = Stage("animation hook frames"),
|
||||
LivePresentation = Stage("live presentation"),
|
||||
RemoteMovementDiagnostics = Stage("remote movement diagnostics"),
|
||||
PhysicsHostIndex = Stage("physics host index"),
|
||||
});
|
||||
|
||||
Assert.Equal(ExpectedManifestNames(), plan.StageNames);
|
||||
|
|
@ -255,7 +254,6 @@ public sealed class LiveSessionResetPlanTests
|
|||
.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);
|
||||
|
||||
|
|
@ -319,7 +317,6 @@ public sealed class LiveSessionResetPlanTests
|
|||
"animation hook frames",
|
||||
"live presentation",
|
||||
"remote movement diagnostics",
|
||||
"physics host index",
|
||||
];
|
||||
|
||||
private static WorldEntity Entity(uint id, uint guid) => new()
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Input;
|
||||
using AcDream.App.Physics;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Rendering.Vfx;
|
||||
using AcDream.Core.Physics;
|
||||
|
|
|
|||
|
|
@ -1202,7 +1202,10 @@ public sealed class ProjectileControllerTests
|
|||
fixture.Live.SetRemoteMotionRuntime(Guid, replacement);
|
||||
remote.CellId = CellB;
|
||||
Assert.Equal(CellA, record.FullCellId);
|
||||
Assert.Equal(0u, remote.CellId);
|
||||
// A displaced component retains read-only access to its exact
|
||||
// incarnation through exit_world; its guarded write cannot mutate the
|
||||
// current canonical owner.
|
||||
Assert.Equal(CellA, remote.CellId);
|
||||
replacement.CellId = CellB;
|
||||
Assert.Equal(CellB, record.FullCellId);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Physics;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Physics.Motion;
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,745 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Physics;
|
||||
using AcDream.App.Streaming;
|
||||
using AcDream.App.World;
|
||||
using AcDream.Core.Net;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Physics.Motion;
|
||||
|
||||
namespace AcDream.App.Tests.World;
|
||||
|
||||
public sealed class LiveEntityPhysicsHostOwnershipTests
|
||||
{
|
||||
[Fact]
|
||||
public void Host_IsOwnedByExactRecordAndStaleRemoteReaderCannotSeeReplacement()
|
||||
{
|
||||
const uint guid = 0x70000010u;
|
||||
var runtime = Runtime();
|
||||
LiveEntityRecord first = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!;
|
||||
var firstMotion = new HostConsumerMotion();
|
||||
runtime.SetRemoteMotionRuntime(guid, firstMotion);
|
||||
EntityPhysicsHost firstHost = Host(guid);
|
||||
|
||||
runtime.InstallPhysicsHost(first, firstHost);
|
||||
Assert.Same(firstHost, firstMotion.Host);
|
||||
Assert.True(runtime.TryGetPhysicsHost(guid, out IPhysicsObjHost resolved));
|
||||
Assert.Same(firstHost, resolved);
|
||||
|
||||
LiveEntityRecord second = runtime.RegisterLiveEntity(Spawn(guid, 2)).Record!;
|
||||
var secondMotion = new HostConsumerMotion();
|
||||
runtime.SetRemoteMotionRuntime(guid, secondMotion);
|
||||
EntityPhysicsHost replacement = Host(guid);
|
||||
runtime.InstallPhysicsHost(second, replacement);
|
||||
|
||||
Assert.Null(firstMotion.Host);
|
||||
Assert.Same(replacement, secondMotion.Host);
|
||||
Assert.Same(replacement, second.PhysicsHost);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Install_RejectsWrongGuidDuplicateAndStaleRecord()
|
||||
{
|
||||
const uint guid = 0x70000011u;
|
||||
var runtime = Runtime();
|
||||
LiveEntityRecord first = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!;
|
||||
EntityPhysicsHost original = Host(guid);
|
||||
runtime.InstallPhysicsHost(first, original);
|
||||
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
runtime.InstallPhysicsHost(first, Host(guid + 1)));
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
runtime.InstallPhysicsHost(first, Host(guid)));
|
||||
|
||||
LiveEntityRecord second = runtime.RegisterLiveEntity(Spawn(guid, 2)).Record!;
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
runtime.InstallPhysicsHost(first, Host(guid)));
|
||||
Assert.Null(second.PhysicsHost);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InstallOrRebind_PreservesHostManagersAndLiveTrackingState()
|
||||
{
|
||||
const uint watcherGuid = 0x70000020u;
|
||||
const uint targetGuid = 0x70000021u;
|
||||
var runtime = Runtime();
|
||||
LiveEntityRecord watcherRecord = runtime.RegisterLiveEntity(Spawn(watcherGuid, 1)).Record!;
|
||||
LiveEntityRecord targetRecord = runtime.RegisterLiveEntity(Spawn(targetGuid, 1)).Record!;
|
||||
IPhysicsObjHost? Resolve(uint id) =>
|
||||
runtime.TryGetPhysicsHost(id, out IPhysicsObjHost host)
|
||||
? host
|
||||
: null;
|
||||
|
||||
EntityPhysicsHost watcher = Host(
|
||||
watcherGuid,
|
||||
resolve: Resolve,
|
||||
position: new Vector3(1f, 0f, 0f));
|
||||
EntityPhysicsHost target = Host(
|
||||
targetGuid,
|
||||
resolve: Resolve,
|
||||
position: new Vector3(4f, 0f, 0f));
|
||||
runtime.InstallPhysicsHost(watcherRecord, watcher);
|
||||
runtime.InstallPhysicsHost(targetRecord, target);
|
||||
|
||||
watcher.PositionManager.StickTo(targetGuid, 0.5f, 1f);
|
||||
watcher.PositionManager.ConstrainTo(
|
||||
new Position(0x01010001u, Vector3.Zero, Quaternion.Identity),
|
||||
1f,
|
||||
5f);
|
||||
TargetManager targetManager = watcher.TargetManager;
|
||||
PositionManager positionManager = watcher.PositionManager;
|
||||
StickyManager sticky = Assert.IsType<StickyManager>(positionManager.Sticky);
|
||||
ConstraintManager constraint = Assert.IsType<ConstraintManager>(positionManager.Constraint);
|
||||
Assert.Equal(targetGuid, sticky.TargetId);
|
||||
Assert.NotNull(watcher.TargetManager.TargetInfo);
|
||||
Assert.True(target.TargetManager.VoyeurTable!.ContainsKey(watcherGuid));
|
||||
|
||||
EntityPhysicsHost configured = Host(
|
||||
watcherGuid,
|
||||
resolve: Resolve,
|
||||
position: new Vector3(9f, 0f, 0f));
|
||||
EntityPhysicsHost rebound = EntityPhysicsHost.InstallOrRebind(
|
||||
runtime,
|
||||
watcherRecord,
|
||||
configured);
|
||||
|
||||
Assert.Same(watcher, rebound);
|
||||
Assert.Same(targetManager, rebound.TargetManager);
|
||||
Assert.Same(positionManager, rebound.PositionManager);
|
||||
Assert.Same(sticky, rebound.PositionManager.Sticky);
|
||||
Assert.Same(constraint, rebound.PositionManager.Constraint);
|
||||
Assert.Equal(targetGuid, rebound.PositionManager.GetStickyObjectId());
|
||||
Assert.Equal(9f, rebound.Position.Frame.Origin.X);
|
||||
Assert.True(target.TargetManager.VoyeurTable!.ContainsKey(watcherGuid));
|
||||
|
||||
EntityPhysicsHost secondConfiguration = Host(
|
||||
watcherGuid,
|
||||
resolve: Resolve,
|
||||
position: new Vector3(12f, 0f, 0f));
|
||||
Assert.Same(
|
||||
watcher,
|
||||
EntityPhysicsHost.InstallOrRebind(runtime, watcherRecord, secondConfiguration));
|
||||
Assert.Same(targetManager, watcher.TargetManager);
|
||||
Assert.Same(positionManager, watcher.PositionManager);
|
||||
Assert.Equal(12f, watcher.Position.Frame.Origin.X);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SameGuidReplacement_HostCallbacksRemainIncarnationLocal()
|
||||
{
|
||||
const uint guid = 0x70000022u;
|
||||
var runtime = Runtime();
|
||||
LiveEntityRecord first = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!;
|
||||
int firstUpdates = 0;
|
||||
int firstInterrupts = 0;
|
||||
var firstHost = CallbackHost(
|
||||
guid,
|
||||
_ => firstUpdates++,
|
||||
() => firstInterrupts++);
|
||||
runtime.InstallPhysicsHost(first, firstHost);
|
||||
|
||||
LiveEntityRecord replacement = runtime.RegisterLiveEntity(Spawn(guid, 2)).Record!;
|
||||
int replacementUpdates = 0;
|
||||
int replacementInterrupts = 0;
|
||||
var replacementHost = CallbackHost(
|
||||
guid,
|
||||
_ => replacementUpdates++,
|
||||
() => replacementInterrupts++);
|
||||
runtime.InstallPhysicsHost(replacement, replacementHost);
|
||||
|
||||
firstHost.HandleUpdateTarget(default);
|
||||
firstHost.InterruptCurrentMovement();
|
||||
|
||||
Assert.Equal(1, firstUpdates);
|
||||
Assert.Equal(1, firstInterrupts);
|
||||
Assert.Equal(0, replacementUpdates);
|
||||
Assert.Equal(0, replacementInterrupts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoteMotion_FullHostGateUsesCanonicalStableHost()
|
||||
{
|
||||
const uint guid = 0x70000030u;
|
||||
var runtime = Runtime();
|
||||
LiveEntityRecord record = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!;
|
||||
var motion = new RemoteMotion(new PhysicsBody());
|
||||
runtime.SetRemoteMotionRuntime(guid, motion);
|
||||
EntityPhysicsHost minimal = Host(guid);
|
||||
runtime.InstallPhysicsHost(record, minimal);
|
||||
|
||||
Assert.Null(motion.Host);
|
||||
motion.MarkFullPhysicsHostBound();
|
||||
Assert.Same(minimal, motion.Host);
|
||||
|
||||
EntityPhysicsHost configured = Host(guid, position: new Vector3(7f, 0f, 0f));
|
||||
Assert.Same(
|
||||
minimal,
|
||||
EntityPhysicsHost.InstallOrRebind(runtime, record, configured));
|
||||
Assert.Same(minimal, motion.Host);
|
||||
Assert.Equal(7f, motion.Host!.Position.Frame.Origin.X);
|
||||
|
||||
runtime.SetRemoteMotionRuntime(guid, motion);
|
||||
Assert.Same(minimal, motion.Host);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoteMotionBindingFailure_DoesNotPublishPartialCanonicalState()
|
||||
{
|
||||
const uint guid = 0x70000031u;
|
||||
var runtime = Runtime();
|
||||
LiveEntityRecord record = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!;
|
||||
var failing = new ThrowingCellConsumerMotion();
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
runtime.SetRemoteMotionRuntime(guid, failing));
|
||||
Assert.Null(record.RemoteMotionRuntime);
|
||||
Assert.Null(record.PhysicsBody);
|
||||
Assert.False(runtime.TryGetRemoteMotionRuntime(guid, out _));
|
||||
|
||||
runtime.SetRemoteMotionRuntime(guid, failing);
|
||||
Assert.Same(failing, record.RemoteMotionRuntime);
|
||||
Assert.Same(failing.Body, record.PhysicsBody);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlreadyBoundRemoteFromOldGeneration_CannotPoisonReplacement()
|
||||
{
|
||||
const uint guid = 0x70000032u;
|
||||
var runtime = Runtime();
|
||||
runtime.RegisterLiveEntity(Spawn(guid, 1));
|
||||
var stale = new RemoteMotion(new PhysicsBody());
|
||||
runtime.SetRemoteMotionRuntime(guid, stale);
|
||||
|
||||
LiveEntityRecord replacement = runtime.RegisterLiveEntity(Spawn(guid, 2)).Record!;
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
runtime.SetRemoteMotionRuntime(guid, stale));
|
||||
Assert.Null(replacement.RemoteMotionRuntime);
|
||||
Assert.Null(replacement.PhysicsBody);
|
||||
Assert.False(runtime.TryGetRemoteMotionRuntime(guid, out _));
|
||||
|
||||
var fresh = new RemoteMotion(new PhysicsBody());
|
||||
runtime.SetRemoteMotionRuntime(guid, fresh);
|
||||
Assert.Same(fresh, replacement.RemoteMotionRuntime);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SameRemoteRuntime_CannotChangeCanonicalBodyOnIdempotentBind()
|
||||
{
|
||||
const uint guid = 0x70000033u;
|
||||
var runtime = Runtime();
|
||||
LiveEntityRecord record = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!;
|
||||
var firstBody = new PhysicsBody();
|
||||
var secondBody = new PhysicsBody();
|
||||
var alternating = new AlternatingBodyMotion(firstBody, secondBody);
|
||||
|
||||
runtime.SetRemoteMotionRuntime(guid, alternating);
|
||||
Assert.Same(firstBody, record.PhysicsBody);
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
runtime.SetRemoteMotionRuntime(guid, alternating));
|
||||
Assert.Same(firstBody, record.PhysicsBody);
|
||||
Assert.Same(alternating, record.RemoteMotionRuntime);
|
||||
Assert.True(runtime.TryGetRemoteMotionRuntime(guid, out var indexed));
|
||||
Assert.Same(alternating, indexed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IdempotentRemoteBind_ReadsBodyOnceAndMutatesOnlyCanonicalBody()
|
||||
{
|
||||
const uint guid = 0x70000036u;
|
||||
var runtime = Runtime();
|
||||
LiveEntityRecord record = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!;
|
||||
var canonical = new PhysicsBody();
|
||||
var unrelated = new PhysicsBody();
|
||||
PhysicsStateFlags unrelatedInitialState = unrelated.State;
|
||||
var scripted = new SequenceBodyMotion(canonical, canonical, unrelated);
|
||||
|
||||
runtime.SetRemoteMotionRuntime(guid, scripted);
|
||||
runtime.SetRemoteMotionRuntime(guid, scripted);
|
||||
|
||||
Assert.Same(canonical, record.PhysicsBody);
|
||||
Assert.Equal(record.FinalPhysicsState, canonical.State);
|
||||
Assert.Equal(unrelatedInitialState, unrelated.State);
|
||||
Assert.Equal(2, scripted.ReadCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReentrantBind_SameGuidReplacementPreventsOuterPublication()
|
||||
{
|
||||
const uint guid = 0x70000034u;
|
||||
var runtime = Runtime();
|
||||
LiveEntityRecord retired = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!;
|
||||
var motion = new CallbackCanonicalMotion(() =>
|
||||
runtime.RegisterLiveEntity(Spawn(guid, 2)));
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
runtime.SetRemoteMotionRuntime(guid, motion));
|
||||
|
||||
Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord replacement));
|
||||
Assert.Equal((ushort)2, replacement.Generation);
|
||||
Assert.Null(replacement.RemoteMotionRuntime);
|
||||
Assert.Null(replacement.PhysicsBody);
|
||||
Assert.Null(retired.RemoteMotionRuntime);
|
||||
Assert.Null(retired.PhysicsBody);
|
||||
Assert.False(runtime.TryGetRemoteMotionRuntime(guid, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReentrantBind_SessionClearPreventsOuterPublication()
|
||||
{
|
||||
const uint guid = 0x70000035u;
|
||||
var runtime = Runtime();
|
||||
LiveEntityRecord retired = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!;
|
||||
var motion = new CallbackCanonicalMotion(runtime.Clear);
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
runtime.SetRemoteMotionRuntime(guid, motion));
|
||||
|
||||
Assert.Equal(0, runtime.Count);
|
||||
Assert.Equal(0, runtime.PendingTeardownCount);
|
||||
Assert.Null(retired.RemoteMotionRuntime);
|
||||
Assert.Null(retired.PhysicsBody);
|
||||
Assert.False(runtime.TryGetRemoteMotionRuntime(guid, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Delete_TeardownHostsRemainPeerResolvableUntilExitWorldConverges()
|
||||
{
|
||||
const uint leftGuid = 0x70000040u;
|
||||
const uint rightGuid = 0x70000041u;
|
||||
LiveEntityRuntime runtime = null!;
|
||||
runtime = Runtime(record => CleanPhysicsHost(record));
|
||||
LiveEntityRecord leftRecord = runtime.RegisterLiveEntity(Spawn(leftGuid, 1)).Record!;
|
||||
LiveEntityRecord rightRecord = runtime.RegisterLiveEntity(Spawn(rightGuid, 1)).Record!;
|
||||
IPhysicsObjHost? Resolve(uint id) =>
|
||||
runtime.TryGetPhysicsHost(id, out IPhysicsObjHost host)
|
||||
? host
|
||||
: null;
|
||||
EntityPhysicsHost left = Host(leftGuid, Resolve, new Vector3(0f, 0f, 0f));
|
||||
EntityPhysicsHost right = Host(rightGuid, Resolve, new Vector3(2f, 0f, 0f));
|
||||
runtime.InstallPhysicsHost(leftRecord, left);
|
||||
runtime.InstallPhysicsHost(rightRecord, right);
|
||||
left.PositionManager.StickTo(rightGuid, 0.5f, 1f);
|
||||
right.PositionManager.StickTo(leftGuid, 0.5f, 1f);
|
||||
|
||||
Assert.True(runtime.UnregisterLiveEntity(
|
||||
new DeleteObject.Parsed(leftGuid, InstanceSequence: 1),
|
||||
isLocalPlayer: false));
|
||||
|
||||
Assert.Null(leftRecord.PhysicsHost);
|
||||
Assert.Equal(0u, right.PositionManager.GetStickyObjectId());
|
||||
Assert.Null(right.TargetManager.TargetInfo);
|
||||
Assert.False(right.TargetManager.VoyeurTable?.ContainsKey(leftGuid) ?? false);
|
||||
Assert.False(runtime.TryGetPhysicsHost(leftGuid, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SessionClear_TeardownHostsResolveAcrossTombstones()
|
||||
{
|
||||
const uint leftGuid = 0x70000042u;
|
||||
const uint rightGuid = 0x70000043u;
|
||||
LiveEntityRuntime runtime = null!;
|
||||
runtime = Runtime(record => CleanPhysicsHost(record));
|
||||
LiveEntityRecord leftRecord = runtime.RegisterLiveEntity(Spawn(leftGuid, 1)).Record!;
|
||||
LiveEntityRecord rightRecord = runtime.RegisterLiveEntity(Spawn(rightGuid, 1)).Record!;
|
||||
IPhysicsObjHost? Resolve(uint id) =>
|
||||
runtime.TryGetPhysicsHost(id, out IPhysicsObjHost host)
|
||||
? host
|
||||
: null;
|
||||
EntityPhysicsHost left = Host(leftGuid, Resolve, Vector3.Zero);
|
||||
EntityPhysicsHost right = Host(rightGuid, Resolve, new Vector3(2f, 0f, 0f));
|
||||
runtime.InstallPhysicsHost(leftRecord, left);
|
||||
runtime.InstallPhysicsHost(rightRecord, right);
|
||||
left.PositionManager.StickTo(rightGuid, 0.5f, 1f);
|
||||
right.PositionManager.StickTo(leftGuid, 0.5f, 1f);
|
||||
|
||||
runtime.Clear();
|
||||
|
||||
Assert.Equal(0, runtime.PendingTeardownCount);
|
||||
Assert.Null(leftRecord.PhysicsHost);
|
||||
Assert.Null(rightRecord.PhysicsHost);
|
||||
Assert.Equal(0u, left.PositionManager.GetStickyObjectId());
|
||||
Assert.Equal(0u, right.PositionManager.GetStickyObjectId());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FailedTeardown_RetryCannotMutateReplacementRelationship()
|
||||
{
|
||||
const uint guid = 0x70000044u;
|
||||
const uint targetGuid = 0x70000045u;
|
||||
bool failOnce = true;
|
||||
EntityPhysicsHost? replacementHost = null;
|
||||
LiveEntityRecord? replacementRecord = null;
|
||||
LiveEntityRuntime runtime = null!;
|
||||
runtime = Runtime(record =>
|
||||
{
|
||||
if (failOnce)
|
||||
{
|
||||
failOnce = false;
|
||||
replacementRecord = runtime.RegisterLiveEntity(Spawn(guid, 2)).Record!;
|
||||
IPhysicsObjHost? Resolve(uint id) =>
|
||||
runtime.TryGetPhysicsHost(id, out IPhysicsObjHost host)
|
||||
? host
|
||||
: null;
|
||||
replacementHost = Host(guid, Resolve);
|
||||
runtime.InstallPhysicsHost(replacementRecord, replacementHost);
|
||||
replacementHost.PositionManager.StickTo(targetGuid, 0.5f, 1f);
|
||||
throw new InvalidOperationException("fixture failure before old host cleanup");
|
||||
}
|
||||
|
||||
CleanPhysicsHost(record);
|
||||
});
|
||||
LiveEntityRecord oldRecord = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!;
|
||||
LiveEntityRecord targetRecord = runtime.RegisterLiveEntity(Spawn(targetGuid, 1)).Record!;
|
||||
IPhysicsObjHost? InitialResolve(uint id) =>
|
||||
runtime.TryGetPhysicsHost(id, out IPhysicsObjHost host)
|
||||
? host
|
||||
: null;
|
||||
EntityPhysicsHost oldHost = Host(guid, InitialResolve);
|
||||
EntityPhysicsHost targetHost = Host(targetGuid, InitialResolve, new Vector3(2f, 0f, 0f));
|
||||
runtime.InstallPhysicsHost(oldRecord, oldHost);
|
||||
runtime.InstallPhysicsHost(targetRecord, targetHost);
|
||||
oldHost.PositionManager.StickTo(targetGuid, 0.5f, 1f);
|
||||
|
||||
Assert.Throws<AggregateException>(() => runtime.UnregisterLiveEntity(
|
||||
new DeleteObject.Parsed(guid, InstanceSequence: 1),
|
||||
isLocalPlayer: false));
|
||||
Assert.Same(oldHost, oldRecord.PhysicsHost);
|
||||
Assert.Same(replacementHost, replacementRecord!.PhysicsHost);
|
||||
Assert.Equal(targetGuid, replacementHost!.PositionManager.GetStickyObjectId());
|
||||
Assert.True(targetHost.TargetManager.VoyeurTable!.ContainsKey(guid));
|
||||
|
||||
Assert.Equal(1, runtime.RetryPendingTeardowns());
|
||||
Assert.Null(oldRecord.PhysicsHost);
|
||||
Assert.Same(replacementHost, replacementRecord.PhysicsHost);
|
||||
Assert.Equal(targetGuid, replacementHost.PositionManager.GetStickyObjectId());
|
||||
Assert.True(targetHost.TargetManager.VoyeurTable!.ContainsKey(guid));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FailedTeardown_TombstoneCannotAcceptNewRelationshipsOutsideTeardown()
|
||||
{
|
||||
const uint retiredGuid = 0x70000046u;
|
||||
const uint watcherGuid = 0x70000047u;
|
||||
bool fail = true;
|
||||
LiveEntityRuntime runtime = null!;
|
||||
runtime = Runtime(record =>
|
||||
{
|
||||
CleanPhysicsHost(record);
|
||||
if (fail)
|
||||
{
|
||||
fail = false;
|
||||
throw new InvalidOperationException("fixture post-exit failure");
|
||||
}
|
||||
});
|
||||
LiveEntityRecord retiredRecord = runtime.RegisterLiveEntity(Spawn(retiredGuid, 1)).Record!;
|
||||
LiveEntityRecord watcherRecord = runtime.RegisterLiveEntity(Spawn(watcherGuid, 1)).Record!;
|
||||
IPhysicsObjHost? Resolve(uint id) =>
|
||||
runtime.TryGetPhysicsHost(id, out IPhysicsObjHost host)
|
||||
? host
|
||||
: null;
|
||||
EntityPhysicsHost retired = Host(retiredGuid, Resolve);
|
||||
EntityPhysicsHost watcher = Host(watcherGuid, Resolve);
|
||||
runtime.InstallPhysicsHost(retiredRecord, retired);
|
||||
runtime.InstallPhysicsHost(watcherRecord, watcher);
|
||||
|
||||
Assert.Throws<AggregateException>(() => runtime.UnregisterLiveEntity(
|
||||
new DeleteObject.Parsed(retiredGuid, InstanceSequence: 1),
|
||||
isLocalPlayer: false));
|
||||
watcher.SetTarget(0, retiredGuid, 0.5f, 0.5);
|
||||
|
||||
Assert.Null(retired.TargetManager.VoyeurTable);
|
||||
Assert.Equal(TargetStatus.Undefined, watcher.TargetManager.TargetInfo!.Value.Status);
|
||||
Assert.False(runtime.TryGetPhysicsHost(retiredGuid, out _));
|
||||
Assert.Equal(1, runtime.RetryPendingTeardowns());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MinimalHost_ExitPositionRetainsExactRecordFrame()
|
||||
{
|
||||
const uint guid = 0x70000048u;
|
||||
var runtime = Runtime();
|
||||
LiveEntityRecord record = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!;
|
||||
record.FullCellId = 0x01010123u;
|
||||
record.WorldEntity = new AcDream.Core.World.WorldEntity
|
||||
{
|
||||
Id = 1_000_123u,
|
||||
ServerGuid = guid,
|
||||
SourceGfxObjOrSetupId = 0x02000001u,
|
||||
Position = new Vector3(12f, 34f, 56f),
|
||||
Rotation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 0.5f),
|
||||
MeshRefs = Array.Empty<AcDream.Core.World.MeshRef>(),
|
||||
};
|
||||
EntityPhysicsHost host = EntityPhysicsHost.CreateMinimal(record, _ => null, () => 0);
|
||||
|
||||
Assert.Equal(record.FullCellId, host.Position.ObjCellId);
|
||||
Assert.Equal(record.WorldEntity.Position, host.Position.Frame.Origin);
|
||||
Assert.Equal(record.WorldEntity.Rotation, host.Position.Frame.Orientation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FullHost_ExitSnapshotRetainsOldFrameAcrossSameGuidReplacement()
|
||||
{
|
||||
const uint departingGuid = 0x70000049u;
|
||||
const uint watcherGuid = 0x7000004Au;
|
||||
var delivered = new List<TargetInfo>();
|
||||
LiveEntityRuntime runtime = null!;
|
||||
runtime = Runtime(record =>
|
||||
{
|
||||
if (record.ServerGuid == departingGuid)
|
||||
{
|
||||
LiveEntityRecord replacement = runtime.RegisterLiveEntity(
|
||||
Spawn(departingGuid, 2)).Record!;
|
||||
replacement.FullCellId = 0x02020202u;
|
||||
replacement.WorldEntity = Entity(
|
||||
departingGuid,
|
||||
new Vector3(999f, 999f, 999f),
|
||||
Quaternion.Identity);
|
||||
runtime.InstallPhysicsHost(replacement, Host(departingGuid));
|
||||
}
|
||||
CleanPhysicsHost(record);
|
||||
});
|
||||
LiveEntityRecord departing = runtime.RegisterLiveEntity(Spawn(departingGuid, 1)).Record!;
|
||||
LiveEntityRecord watcherRecord = runtime.RegisterLiveEntity(Spawn(watcherGuid, 1)).Record!;
|
||||
departing.FullCellId = 0x01010123u;
|
||||
Quaternion departingRotation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 0.75f);
|
||||
departing.WorldEntity = Entity(
|
||||
departingGuid,
|
||||
new Vector3(12f, 34f, 56f),
|
||||
departingRotation);
|
||||
watcherRecord.WorldEntity = Entity(watcherGuid, Vector3.Zero, Quaternion.Identity);
|
||||
IPhysicsObjHost? Resolve(uint id) =>
|
||||
runtime.TryGetPhysicsHost(id, out IPhysicsObjHost host) ? host : null;
|
||||
var body = new PhysicsBody
|
||||
{
|
||||
Position = departing.WorldEntity.Position,
|
||||
Orientation = departingRotation,
|
||||
};
|
||||
var remote = new RemoteMotion(body);
|
||||
runtime.SetRemoteMotionRuntime(departingGuid, remote);
|
||||
var departingHost = new EntityPhysicsHost(
|
||||
departingGuid,
|
||||
getPosition: () => new Position(
|
||||
departing.FullCellId,
|
||||
departing.WorldEntity?.Position ?? body.Position,
|
||||
body.Orientation),
|
||||
getVelocity: () => body.Velocity,
|
||||
getRadius: () => 0.5f,
|
||||
inContact: () => true,
|
||||
minterpMaxSpeed: () => 1f,
|
||||
curTime: () => 0,
|
||||
physicsTimerTime: () => 0,
|
||||
getObjectA: Resolve,
|
||||
handleUpdateTarget: _ => { },
|
||||
interruptCurrentMovement: () => { });
|
||||
var watcher = new EntityPhysicsHost(
|
||||
watcherGuid,
|
||||
getPosition: () => new Position(0x01010001u, Vector3.Zero, Quaternion.Identity),
|
||||
getVelocity: () => Vector3.Zero,
|
||||
getRadius: () => 0.5f,
|
||||
inContact: () => true,
|
||||
minterpMaxSpeed: () => 1f,
|
||||
curTime: () => 0,
|
||||
physicsTimerTime: () => 0,
|
||||
getObjectA: Resolve,
|
||||
handleUpdateTarget: delivered.Add,
|
||||
interruptCurrentMovement: () => { });
|
||||
runtime.InstallPhysicsHost(departing, departingHost);
|
||||
runtime.InstallPhysicsHost(watcherRecord, watcher);
|
||||
watcher.SetTarget(0, departingGuid, 0.5f, 0.5);
|
||||
delivered.Clear();
|
||||
|
||||
Assert.True(runtime.UnregisterLiveEntity(
|
||||
new DeleteObject.Parsed(departingGuid, InstanceSequence: 1),
|
||||
isLocalPlayer: false));
|
||||
|
||||
TargetInfo exit = Assert.Single(delivered);
|
||||
Assert.Equal(TargetStatus.ExitWorld, exit.Status);
|
||||
Assert.Equal(0x01010123u, exit.TargetPosition.ObjCellId);
|
||||
Assert.Equal(new Vector3(12f, 34f, 56f), exit.TargetPosition.Frame.Origin);
|
||||
Assert.Equal(departingRotation, exit.TargetPosition.Frame.Orientation);
|
||||
Assert.Equal(0x01010123u, remote.CellId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ActiveNonVisibleRecord_CanOwnResolvablePhysicsHost()
|
||||
{
|
||||
const uint guid = 0x7000004Bu;
|
||||
var runtime = Runtime();
|
||||
LiveEntityRecord record = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!;
|
||||
EntityPhysicsHost host = EntityPhysicsHost.CreateMinimal(record, _ => null, () => 0);
|
||||
runtime.InstallPhysicsHost(record, host);
|
||||
|
||||
Assert.Empty(runtime.WorldEntities);
|
||||
Assert.True(runtime.TryGetPhysicsHost(guid, out IPhysicsObjHost resolved));
|
||||
Assert.Same(host, resolved);
|
||||
}
|
||||
|
||||
private static void CleanPhysicsHost(LiveEntityRecord record)
|
||||
{
|
||||
if (record.PhysicsHost is not EntityPhysicsHost host)
|
||||
return;
|
||||
host.PositionManager.UnStick();
|
||||
host.ClearTarget();
|
||||
host.NotifyExitWorld();
|
||||
}
|
||||
|
||||
private static LiveEntityRuntime Runtime(Action<LiveEntityRecord>? teardown = null) =>
|
||||
teardown is null
|
||||
? new LiveEntityRuntime(
|
||||
new GpuWorldState(),
|
||||
new DelegateLiveEntityResourceLifecycle(_ => { }, _ => { }))
|
||||
: new LiveEntityRuntime(
|
||||
new GpuWorldState(),
|
||||
new DelegateLiveEntityResourceLifecycle(_ => { }, _ => { }),
|
||||
teardown);
|
||||
|
||||
private static WorldSession.EntitySpawn Spawn(uint guid, ushort instance) =>
|
||||
new(
|
||||
Guid: guid,
|
||||
Position: null,
|
||||
SetupTableId: null,
|
||||
AnimPartChanges: Array.Empty<CreateObject.AnimPartChange>(),
|
||||
TextureChanges: Array.Empty<CreateObject.TextureChange>(),
|
||||
SubPalettes: Array.Empty<CreateObject.SubPaletteSwap>(),
|
||||
BasePaletteId: null,
|
||||
ObjScale: null,
|
||||
Name: "fixture",
|
||||
ItemType: null,
|
||||
MotionState: null,
|
||||
MotionTableId: null,
|
||||
InstanceSequence: instance);
|
||||
|
||||
private static AcDream.Core.World.WorldEntity Entity(
|
||||
uint guid,
|
||||
Vector3 position,
|
||||
Quaternion rotation) =>
|
||||
new()
|
||||
{
|
||||
Id = 1_000_000u + (guid & 0xFFFFu),
|
||||
ServerGuid = guid,
|
||||
SourceGfxObjOrSetupId = 0x02000001u,
|
||||
Position = position,
|
||||
Rotation = rotation,
|
||||
MeshRefs = Array.Empty<AcDream.Core.World.MeshRef>(),
|
||||
};
|
||||
|
||||
private static EntityPhysicsHost Host(
|
||||
uint guid,
|
||||
Func<uint, IPhysicsObjHost?>? resolve = null,
|
||||
Vector3? position = null) =>
|
||||
new(
|
||||
guid,
|
||||
getPosition: () => new Position(
|
||||
0x01010001u,
|
||||
position ?? Vector3.Zero,
|
||||
Quaternion.Identity),
|
||||
getVelocity: () => Vector3.Zero,
|
||||
getRadius: () => 0.5f,
|
||||
inContact: () => true,
|
||||
minterpMaxSpeed: () => 1f,
|
||||
curTime: () => 0,
|
||||
physicsTimerTime: () => 0,
|
||||
getObjectA: resolve ?? (_ => null),
|
||||
handleUpdateTarget: _ => { },
|
||||
interruptCurrentMovement: () => { });
|
||||
|
||||
private static EntityPhysicsHost CallbackHost(
|
||||
uint guid,
|
||||
Action<TargetInfo> handleUpdateTarget,
|
||||
Action interruptCurrentMovement) =>
|
||||
new(
|
||||
guid,
|
||||
getPosition: () => new Position(
|
||||
0x01010001u,
|
||||
Vector3.Zero,
|
||||
Quaternion.Identity),
|
||||
getVelocity: () => Vector3.Zero,
|
||||
getRadius: () => 0.5f,
|
||||
inContact: () => true,
|
||||
minterpMaxSpeed: () => 1f,
|
||||
curTime: () => 0,
|
||||
physicsTimerTime: () => 0,
|
||||
getObjectA: _ => null,
|
||||
handleUpdateTarget,
|
||||
interruptCurrentMovement);
|
||||
|
||||
private sealed class HostConsumerMotion :
|
||||
ILiveEntityRemoteMotionRuntime,
|
||||
ILiveEntityPhysicsHostConsumer
|
||||
{
|
||||
private Func<IPhysicsObjHost?>? _read;
|
||||
public PhysicsBody Body { get; } = new();
|
||||
public IPhysicsObjHost? Host => _read?.Invoke();
|
||||
public void BindPhysicsHost(Func<IPhysicsObjHost?> read)
|
||||
{
|
||||
if (_read is not null)
|
||||
throw new InvalidOperationException("already bound");
|
||||
_read = read;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ThrowingCellConsumerMotion :
|
||||
ILiveEntityRemoteMotionRuntime,
|
||||
ILiveEntityCanonicalRuntimeConsumer
|
||||
{
|
||||
private bool _failOnce = true;
|
||||
public PhysicsBody Body { get; } = new();
|
||||
public void BindCanonicalRuntime(
|
||||
Func<IPhysicsObjHost?> readPhysicsHost,
|
||||
Func<uint> readCell,
|
||||
Action<uint> writeCell)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(readPhysicsHost);
|
||||
ArgumentNullException.ThrowIfNull(readCell);
|
||||
ArgumentNullException.ThrowIfNull(writeCell);
|
||||
if (_failOnce)
|
||||
{
|
||||
_failOnce = false;
|
||||
throw new InvalidOperationException("fixture context bind failure");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class AlternatingBodyMotion(
|
||||
PhysicsBody first,
|
||||
PhysicsBody second) : ILiveEntityRemoteMotionRuntime
|
||||
{
|
||||
private int _reads;
|
||||
public PhysicsBody Body => _reads++ == 0 ? first : second;
|
||||
}
|
||||
|
||||
private sealed class SequenceBodyMotion(params PhysicsBody[] bodies) :
|
||||
ILiveEntityRemoteMotionRuntime
|
||||
{
|
||||
public int ReadCount { get; private set; }
|
||||
|
||||
public PhysicsBody Body
|
||||
{
|
||||
get
|
||||
{
|
||||
int index = Math.Min(ReadCount, bodies.Length - 1);
|
||||
ReadCount++;
|
||||
return bodies[index];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CallbackCanonicalMotion(Action callback) :
|
||||
ILiveEntityRemoteMotionRuntime,
|
||||
ILiveEntityCanonicalRuntimeConsumer
|
||||
{
|
||||
public PhysicsBody Body { get; } = new();
|
||||
|
||||
public void BindCanonicalRuntime(
|
||||
Func<IPhysicsObjHost?> readPhysicsHost,
|
||||
Func<uint> readCell,
|
||||
Action<uint> writeCell)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(readPhysicsHost);
|
||||
ArgumentNullException.ThrowIfNull(readCell);
|
||||
ArgumentNullException.ThrowIfNull(writeCell);
|
||||
callback();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
using System;
|
||||
using System.Numerics;
|
||||
using AcDream.App.Input;
|
||||
using AcDream.App.Physics;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Physics.Motion;
|
||||
|
|
|
|||
|
|
@ -62,6 +62,9 @@ internal sealed class R5Host : IPhysicsObjHost
|
|||
public IPhysicsObjHost? GetObjectA(uint id)
|
||||
=> World.TryGetValue(id, out var h) && h.Resolvable ? h : null;
|
||||
|
||||
public IPhysicsObjHost? GetRelationshipTarget(uint objectId) =>
|
||||
_targetManager?.GetRelationshipTarget(objectId);
|
||||
|
||||
public void HandleUpdateTarget(TargetInfo info)
|
||||
{
|
||||
HandleUpdateTargetCalls.Add(info);
|
||||
|
|
@ -79,12 +82,14 @@ internal sealed class R5Host : IPhysicsObjHost
|
|||
|
||||
public void ClearTarget() => _targetManager?.ClearTarget();
|
||||
|
||||
public void ReceiveTargetUpdate(TargetInfo info) => _targetManager?.ReceiveUpdate(info);
|
||||
public void ReceiveTargetUpdate(TargetInfo info, IPhysicsObjHost sender) =>
|
||||
_targetManager?.ReceiveUpdate(info, sender);
|
||||
|
||||
public void AddVoyeur(uint watcherId, float radius, double quantum)
|
||||
=> TargetManager.AddVoyeur(watcherId, radius, quantum);
|
||||
public void AddVoyeur(IPhysicsObjHost watcher, float radius, double quantum)
|
||||
=> TargetManager.AddVoyeur(watcher, radius, quantum);
|
||||
|
||||
public void RemoveVoyeur(uint watcherId) => _targetManager?.RemoveVoyeur(watcherId);
|
||||
public void RemoveVoyeur(uint watcherId, IPhysicsObjHost expectedWatcher) =>
|
||||
_targetManager?.RemoveVoyeur(watcherId, expectedWatcher);
|
||||
|
||||
// ── test helpers ───────────────────────────────────────────────────────
|
||||
public void SetOrigin(Vector3 origin)
|
||||
|
|
|
|||
|
|
@ -352,6 +352,8 @@ internal sealed class RemoteChaseHarness
|
|||
public double PhysicsTimerTime => _h.Now;
|
||||
public IPhysicsObjHost? GetObjectA(uint id)
|
||||
=> id == PlayerGuid ? _h._playerHost : null;
|
||||
public IPhysicsObjHost? GetRelationshipTarget(uint objectId)
|
||||
=> GetObjectA(objectId);
|
||||
|
||||
public void HandleUpdateTarget(TargetInfo info)
|
||||
{
|
||||
|
|
@ -373,9 +375,9 @@ internal sealed class RemoteChaseHarness
|
|||
}
|
||||
|
||||
public void ClearTarget() => _h._targetArmed = false;
|
||||
public void ReceiveTargetUpdate(TargetInfo info) { }
|
||||
public void AddVoyeur(uint watcherId, float radius, double quantum) { }
|
||||
public void RemoveVoyeur(uint watcherId) { }
|
||||
public void ReceiveTargetUpdate(TargetInfo info, IPhysicsObjHost sender) { }
|
||||
public void AddVoyeur(IPhysicsObjHost watcher, float radius, double quantum) { }
|
||||
public void RemoveVoyeur(uint watcherId, IPhysicsObjHost expectedWatcher) { }
|
||||
}
|
||||
|
||||
private sealed class TargetHost : IPhysicsObjHost
|
||||
|
|
@ -391,13 +393,14 @@ internal sealed class RemoteChaseHarness
|
|||
public double CurTime => _h.Now;
|
||||
public double PhysicsTimerTime => _h.Now;
|
||||
public IPhysicsObjHost? GetObjectA(uint id) => null;
|
||||
public IPhysicsObjHost? GetRelationshipTarget(uint objectId) => null;
|
||||
public void HandleUpdateTarget(TargetInfo info) { }
|
||||
public void InterruptCurrentMovement() { }
|
||||
public void SetTarget(uint contextId, uint objectId, float radius, double quantum) { }
|
||||
public void ClearTarget() { }
|
||||
public void ReceiveTargetUpdate(TargetInfo info) { }
|
||||
public void AddVoyeur(uint watcherId, float radius, double quantum) { }
|
||||
public void RemoveVoyeur(uint watcherId) { }
|
||||
public void ReceiveTargetUpdate(TargetInfo info, IPhysicsObjHost sender) { }
|
||||
public void AddVoyeur(IPhysicsObjHost watcher, float radius, double quantum) { }
|
||||
public void RemoveVoyeur(uint watcherId, IPhysicsObjHost expectedWatcher) { }
|
||||
}
|
||||
|
||||
// ── The per-tick pipeline (GameWindow.TickAnimations order) ────────────
|
||||
|
|
|
|||
|
|
@ -218,11 +218,11 @@ public sealed class StickyManagerTests
|
|||
var self = new R5Host(10u, world) { Radius = 0.5f, MinterpMaxSpeed = 1.0f };
|
||||
var target = new R5Host(20u, world);
|
||||
var sticky = new StickyManager(self);
|
||||
target.Resolvable = false;
|
||||
sticky.StickTo(target.Id, 0.5f, 1.0f);
|
||||
// Cache a position via HandleUpdateTarget, then make the target vanish.
|
||||
// Cache a position without ever establishing an exact target token.
|
||||
var tp = new Position(1u, new Vector3(4f, 0f, 0f), Quaternion.Identity);
|
||||
sticky.HandleUpdateTarget(new TargetInfo(target.Id, TargetStatus.Ok, tp, tp));
|
||||
target.Resolvable = false; // GetObjectA(target) → null → fall back to cached
|
||||
|
||||
var frame = new MotionDeltaFrame();
|
||||
sticky.AdjustOffset(frame, quantum: 0.1);
|
||||
|
|
|
|||
|
|
@ -76,7 +76,9 @@ public sealed class TargetManagerTests
|
|||
int before = self.HandleUpdateTargetCalls.Count;
|
||||
|
||||
var p = new Position(1u, Vector3.Zero, Quaternion.Identity);
|
||||
self.TargetManager.ReceiveUpdate(new TargetInfo(999u, TargetStatus.Ok, p, p));
|
||||
self.TargetManager.ReceiveUpdate(
|
||||
new TargetInfo(999u, TargetStatus.Ok, p, p),
|
||||
target);
|
||||
|
||||
Assert.Equal(before, self.HandleUpdateTargetCalls.Count);
|
||||
}
|
||||
|
|
@ -88,7 +90,9 @@ public sealed class TargetManagerTests
|
|||
self.TargetManager.SetTarget(0, target.Id, 1.0f, 0.0);
|
||||
|
||||
var p = new Position(1u, Vector3.Zero, Quaternion.Identity);
|
||||
self.TargetManager.ReceiveUpdate(new TargetInfo(target.Id, TargetStatus.ExitWorld, p, p));
|
||||
self.TargetManager.ReceiveUpdate(
|
||||
new TargetInfo(target.Id, TargetStatus.ExitWorld, p, p),
|
||||
target);
|
||||
|
||||
Assert.Null(self.TargetManager.TargetInfo); // cleared
|
||||
Assert.False(target.TargetManager.VoyeurTable!.ContainsKey(self.Id)); // unsubscribed
|
||||
|
|
@ -102,7 +106,9 @@ public sealed class TargetManagerTests
|
|||
self.TargetManager.SetTarget(0, target.Id, 1.0f, 0.0);
|
||||
|
||||
var tp = new Position(1u, new Vector3(0f, 5f, 0f), Quaternion.Identity);
|
||||
self.TargetManager.ReceiveUpdate(new TargetInfo(target.Id, TargetStatus.Ok, tp, tp));
|
||||
self.TargetManager.ReceiveUpdate(
|
||||
new TargetInfo(target.Id, TargetStatus.Ok, tp, tp),
|
||||
target);
|
||||
|
||||
// self→target interp position (0,5,0) normalized = +Y.
|
||||
var heading = self.TargetManager.TargetInfo!.Value.InterpolatedHeading;
|
||||
|
|
@ -110,6 +116,46 @@ public sealed class TargetManagerTests
|
|||
Assert.Equal(1f, heading.Y, 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReceiveUpdate_SameGuidReplacementSender_IsIgnored()
|
||||
{
|
||||
var (self, originalTarget, world) = TwoHosts();
|
||||
self.TargetManager.SetTarget(0, originalTarget.Id, 1.0f, 0.0);
|
||||
self.HandleUpdateTargetCalls.Clear();
|
||||
var replacement = new R5Host(originalTarget.Id, world);
|
||||
var p = new Position(1u, new Vector3(99f, 0f, 0f), Quaternion.Identity);
|
||||
|
||||
self.ReceiveTargetUpdate(
|
||||
new TargetInfo(originalTarget.Id, TargetStatus.ExitWorld, p, p),
|
||||
replacement);
|
||||
|
||||
Assert.Empty(self.HandleUpdateTargetCalls);
|
||||
Assert.NotNull(self.TargetManager.TargetInfo);
|
||||
Assert.Same(
|
||||
originalTarget,
|
||||
self.TargetManager.GetRelationshipTarget(originalTarget.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StickyAdjustOffset_UsesExactTargetInsteadOfSameGuidReplacement()
|
||||
{
|
||||
var (self, originalTarget, world) = TwoHosts();
|
||||
self.SetOrigin(Vector3.Zero);
|
||||
originalTarget.SetOrigin(new Vector3(5f, 0f, 0f));
|
||||
self.PositionManager.StickTo(originalTarget.Id, 0.5f, 1f);
|
||||
var replacement = new R5Host(originalTarget.Id, world);
|
||||
replacement.SetOrigin(new Vector3(10f, 0f, 0f));
|
||||
originalTarget.SetOrigin(new Vector3(-5f, 0f, 0f));
|
||||
var delta = new MotionDeltaFrame();
|
||||
|
||||
self.PositionManager.AdjustOffset(delta, 0.1);
|
||||
|
||||
Assert.True(delta.Origin.X < 0f);
|
||||
Assert.Same(
|
||||
originalTarget,
|
||||
self.TargetManager.GetRelationshipTarget(originalTarget.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HandleTargetting_UndefinedTarget_TimesOutAfter10Seconds()
|
||||
{
|
||||
|
|
@ -133,9 +179,8 @@ public sealed class TargetManagerTests
|
|||
public void HandleTargetting_Throttles_Within500ms()
|
||||
{
|
||||
var (self, target, _) = TwoHosts();
|
||||
var w = new TargettedVoyeurInfo(self.Id, radius: 0.1f, quantum: 0.0);
|
||||
// seed a voyeur directly + advance so a sweep WOULD send if not throttled.
|
||||
target.TargetManager.AddVoyeur(self.Id, 0.1f, 0.0);
|
||||
target.TargetManager.AddVoyeur(self, 0.1f, 0.0);
|
||||
target.SetOrigin(new Vector3(10f, 0f, 0f)); // far past radius
|
||||
|
||||
target.PhysicsTimerTime = 0.3; // < 0.5 throttle
|
||||
|
|
@ -186,12 +231,12 @@ public sealed class TargetManagerTests
|
|||
{
|
||||
var (self, target, _) = TwoHosts();
|
||||
target.SetOrigin(Vector3.Zero);
|
||||
target.TargetManager.AddVoyeur(self.Id, radius: 1.0f, quantum: 0.0);
|
||||
target.TargetManager.AddVoyeur(self, radius: 1.0f, quantum: 0.0);
|
||||
var voyeur = target.TargetManager.VoyeurTable![self.Id];
|
||||
Assert.Equal(Vector3.Zero, voyeur.LastSentPosition.Frame.Origin);
|
||||
|
||||
target.SetOrigin(new Vector3(5f, 0f, 0f));
|
||||
target.TargetManager.AddVoyeur(self.Id, radius: 2.5f, quantum: 0.3); // existing → update only
|
||||
target.TargetManager.AddVoyeur(self, radius: 2.5f, quantum: 0.3); // existing → update only
|
||||
|
||||
Assert.Equal(2.5f, voyeur.Radius);
|
||||
Assert.Equal(0.3, voyeur.Quantum);
|
||||
|
|
@ -202,10 +247,27 @@ public sealed class TargetManagerTests
|
|||
public void RemoveVoyeur_RemovesEntry()
|
||||
{
|
||||
var (self, target, _) = TwoHosts();
|
||||
target.TargetManager.AddVoyeur(self.Id, 1.0f, 0.0);
|
||||
Assert.True(target.TargetManager.RemoveVoyeur(self.Id));
|
||||
target.TargetManager.AddVoyeur(self, 1.0f, 0.0);
|
||||
Assert.True(target.TargetManager.RemoveVoyeur(self.Id, self));
|
||||
Assert.False(target.TargetManager.VoyeurTable!.ContainsKey(self.Id));
|
||||
Assert.False(target.TargetManager.RemoveVoyeur(self.Id)); // already gone
|
||||
Assert.False(target.TargetManager.RemoveVoyeur(self.Id, self)); // already gone
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HiddenWatcher_GuidReuseCannotRedirectOrRemoveExactSubscription()
|
||||
{
|
||||
var (watcher, target, world) = TwoHosts();
|
||||
watcher.TargetManager.SetTarget(0, target.Id, 1.0f, 0.0);
|
||||
watcher.HandleUpdateTargetCalls.Clear();
|
||||
watcher.Resolvable = false;
|
||||
var replacement = new R5Host(watcher.Id, world);
|
||||
|
||||
target.TargetManager.NotifyVoyeurOfEvent(TargetStatus.Teleported);
|
||||
|
||||
Assert.Single(watcher.HandleUpdateTargetCalls);
|
||||
Assert.Empty(replacement.HandleUpdateTargetCalls);
|
||||
Assert.False(target.TargetManager.RemoveVoyeur(watcher.Id, replacement));
|
||||
Assert.True(target.TargetManager.RemoveVoyeur(watcher.Id, watcher));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue