using System.Collections.Generic; using System.Numerics; using AcDream.Core.Physics; using AcDream.Core.Physics.Motion; using Xunit; namespace AcDream.Core.Tests.Physics.Motion; /// /// R5 conformance — voyeur system (retail /// 0x0051a370-0x0051ad90). Watcher role (SetTarget/ReceiveUpdate/timeout), /// watched role (AddVoyeur/HandleTargetting/CheckAndUpdateVoyeur), and the full /// cross-entity round-trip (port-plan §2d/§2e). Replaces the AP-79 adapter. /// public sealed class TargetManagerTests { private static (R5Host self, R5Host target, Dictionary world) TwoHosts() { var world = new Dictionary(); var self = new R5Host(10u, world); var target = new R5Host(20u, world); return (self, target, world); } // ── watcher role ─────────────────────────────────────────────────────── [Fact] public void SetTarget_SubscribesOnTarget_AndReceivesImmediateSnapshot() { var (self, target, _) = TwoHosts(); target.SetOrigin(new Vector3(3f, 0f, 0f)); self.TargetManager.SetTarget(0, target.Id, radius: 1.0f, quantum: 0.0); // Watcher subscribed on the target. Assert.True(target.TargetManager.VoyeurTable!.ContainsKey(self.Id)); // Immediate Ok snapshot fanned to the watcher's host. Assert.Single(self.HandleUpdateTargetCalls); Assert.Equal(TargetStatus.Ok, self.HandleUpdateTargetCalls[0].Status); Assert.Equal(target.Id, self.HandleUpdateTargetCalls[0].ObjectId); Assert.Equal(new Vector3(3f, 0f, 0f), self.HandleUpdateTargetCalls[0].InterpolatedPosition.Frame.Origin); Assert.Equal(TargetStatus.Ok, self.TargetManager.TargetInfo!.Value.Status); } [Fact] public void SetTarget_Zero_SynthesizesTimedOut_LeavesTargetInfoNull() { var (self, _, _) = TwoHosts(); self.TargetManager.SetTarget(contextId: 7, objectId: 0, radius: 1.0f, quantum: 0.0); Assert.Null(self.TargetManager.TargetInfo); Assert.Single(self.HandleUpdateTargetCalls); Assert.Equal(TargetStatus.TimedOut, self.HandleUpdateTargetCalls[0].Status); Assert.Equal(7u, self.HandleUpdateTargetCalls[0].ContextId); } [Fact] public void ClearTarget_UnsubscribesFromTarget() { var (self, target, _) = TwoHosts(); self.TargetManager.SetTarget(0, target.Id, 1.0f, 0.0); Assert.True(target.TargetManager.VoyeurTable!.ContainsKey(self.Id)); self.TargetManager.ClearTarget(); Assert.Null(self.TargetManager.TargetInfo); Assert.False(target.TargetManager.VoyeurTable!.ContainsKey(self.Id)); } [Fact] public void ReceiveUpdate_ForWrongObject_Ignored() { var (self, target, _) = TwoHosts(); self.TargetManager.SetTarget(0, target.Id, 1.0f, 0.0); int before = self.HandleUpdateTargetCalls.Count; var p = new Position(1u, Vector3.Zero, Quaternion.Identity); self.TargetManager.ReceiveUpdate(new TargetInfo(999u, TargetStatus.Ok, p, p)); Assert.Equal(before, self.HandleUpdateTargetCalls.Count); } [Fact] public void ReceiveUpdate_ExitWorld_ClearsTarget() { var (self, target, _) = TwoHosts(); 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)); Assert.Null(self.TargetManager.TargetInfo); // cleared Assert.False(target.TargetManager.VoyeurTable!.ContainsKey(self.Id)); // unsubscribed } [Fact] public void ReceiveUpdate_ComputesInterpolatedHeadingTowardTarget() { var (self, target, _) = TwoHosts(); self.SetOrigin(Vector3.Zero); 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→target interp position (0,5,0) normalized = +Y. var heading = self.TargetManager.TargetInfo!.Value.InterpolatedHeading; Assert.Equal(0f, heading.X, 3); Assert.Equal(1f, heading.Y, 3); } [Fact] public void HandleTargetting_UndefinedTarget_TimesOutAfter10Seconds() { var (self, target, _) = TwoHosts(); target.Resolvable = false; // no immediate snapshot → status stays Undefined self.CurTime = 0; self.PhysicsTimerTime = 0; self.TargetManager.SetTarget(0, target.Id, 1.0f, 0.0); Assert.Equal(TargetStatus.Undefined, self.TargetManager.TargetInfo!.Value.Status); self.AdvanceClocks(11.0); self.TargetManager.HandleTargetting(); Assert.Equal(TargetStatus.TimedOut, self.TargetManager.TargetInfo!.Value.Status); Assert.Contains(self.HandleUpdateTargetCalls, c => c.Status == TargetStatus.TimedOut); } // ── watched role + gates ──────────────────────────────────────────────── [Fact] 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.SetOrigin(new Vector3(10f, 0f, 0f)); // far past radius target.PhysicsTimerTime = 0.3; // < 0.5 throttle int before = self.HandleUpdateTargetCalls.Count; target.TargetManager.HandleTargetting(); Assert.Equal(before, self.HandleUpdateTargetCalls.Count); // throttled, no sweep } [Fact] public void CheckAndUpdateVoyeur_SendsOnlyPastRadius() { var (self, target, _) = TwoHosts(); // self must have a matching target_info for ReceiveUpdate to record. self.TargetManager.SetTarget(0, target.Id, radius: 1.0f, quantum: 0.0); int afterSubscribe = self.HandleUpdateTargetCalls.Count; // includes immediate snapshot // small move → within radius → no send target.SetOrigin(new Vector3(0.5f, 0f, 0f)); target.AdvanceClocks(1.0); target.TargetManager.HandleTargetting(); Assert.Equal(afterSubscribe, self.HandleUpdateTargetCalls.Count); // large move → past radius → send target.SetOrigin(new Vector3(2f, 0f, 0f)); target.AdvanceClocks(1.0); target.TargetManager.HandleTargetting(); Assert.Equal(afterSubscribe + 1, self.HandleUpdateTargetCalls.Count); var last = self.HandleUpdateTargetCalls[^1]; Assert.Equal(TargetStatus.Ok, last.Status); Assert.Equal(2f, last.InterpolatedPosition.Frame.Origin.X, 3); } [Fact] public void GetInterpolatedPosition_DeadReckonsWithVelocity() { var (_, target, _) = TwoHosts(); target.SetOrigin(new Vector3(1f, 2f, 0f)); target.Velocity = new Vector3(10f, 0f, 0f); var p = target.TargetManager.GetInterpolatedPosition(quantum: 0.5); Assert.Equal(new Vector3(6f, 2f, 0f), p.Frame.Origin); // 1 + 10*0.5 = 6 } [Fact] public void AddVoyeur_Existing_UpdatesInPlace_NoResend() { var (self, target, _) = TwoHosts(); target.SetOrigin(Vector3.Zero); target.TargetManager.AddVoyeur(self.Id, 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 Assert.Equal(2.5f, voyeur.Radius); Assert.Equal(0.3, voyeur.Quantum); Assert.Equal(Vector3.Zero, voyeur.LastSentPosition.Frame.Origin); // NOT re-sent } [Fact] public void RemoveVoyeur_RemovesEntry() { var (self, target, _) = TwoHosts(); target.TargetManager.AddVoyeur(self.Id, 1.0f, 0.0); Assert.True(target.TargetManager.RemoveVoyeur(self.Id)); Assert.False(target.TargetManager.VoyeurTable!.ContainsKey(self.Id)); Assert.False(target.TargetManager.RemoveVoyeur(self.Id)); // already gone } [Fact] public void NotifyVoyeurOfEvent_BroadcastsToAll_NoDistanceGate() { var world = new Dictionary(); var target = new R5Host(20u, world); var w1 = new R5Host(10u, world); var w2 = new R5Host(11u, world); // both watch the target (each gets a target_info via SetTarget) w1.TargetManager.SetTarget(0, target.Id, 1.0f, 0.0); w2.TargetManager.SetTarget(0, target.Id, 1.0f, 0.0); int w1Before = w1.HandleUpdateTargetCalls.Count; int w2Before = w2.HandleUpdateTargetCalls.Count; target.TargetManager.NotifyVoyeurOfEvent(TargetStatus.Teleported); Assert.Equal(w1Before + 1, w1.HandleUpdateTargetCalls.Count); Assert.Equal(w2Before + 1, w2.HandleUpdateTargetCalls.Count); Assert.Equal(TargetStatus.Teleported, w1.HandleUpdateTargetCalls[^1].Status); } // ── full round-trip ───────────────────────────────────────────────────── [Fact] public void RoundTrip_WatcherTracksMovingTarget() { var (self, target, _) = TwoHosts(); target.SetOrigin(Vector3.Zero); // Watcher sets target with a 1.0 radius (moveto-style, quantum 0). self.TargetManager.SetTarget(0, target.Id, radius: 1.0f, quantum: 0.0); Assert.Single(self.HandleUpdateTargetCalls); // immediate snapshot at (0,0,0) // Target walks to (3,0,0); its per-tick HandleTargetting notices the // drift and pushes an update the watcher receives. target.SetOrigin(new Vector3(3f, 0f, 0f)); target.AdvanceClocks(1.0); target.TargetManager.HandleTargetting(); Assert.Equal(2, self.HandleUpdateTargetCalls.Count); Assert.Equal(new Vector3(3f, 0f, 0f), self.HandleUpdateTargetCalls[^1].InterpolatedPosition.Frame.Origin); // The watcher's cached target position tracks the target. Assert.Equal(3f, self.TargetManager.TargetInfo!.Value.InterpolatedPosition.Frame.Origin.X, 3); } }