refactor(world): canonicalize live physics host ownership

This commit is contained in:
Erik 2026-07-21 14:05:34 +02:00
parent 5882b308c1
commit fcb66198fc
21 changed files with 1546 additions and 323 deletions

View file

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

View file

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

View file

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

View file

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

View file

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