using System; using System.Numerics; using AcDream.Core.Physics; using Xunit; using Xunit.Abstractions; namespace AcDream.Core.Tests.Physics; /// /// Mechanism validation for the remote-creature de-overlap redo (#184). /// /// The reported symptom: packed monsters interpenetrate in acdream but barely in retail on /// the SAME ACE. Retail de-overlaps them CLIENT-side by running the collision sweep on every /// remote creature every tick against its neighbours' LIVE positions (the shadow == the /// resolved m_position), with the server position a gentle catch-up target. /// /// The first attempt (reverted commit 9c0849dd) synced the broadphase shadow to the RAW /// server position and never to the resolved body — so each creature de-overlapped against a /// STALE overlapping shadow and any separation was discarded on the next update. These tests /// prove, in Core, the two load-bearing facts BEFORE wiring GameWindow (so we don't spend /// another visual gate on an unproven mechanism): /// /// 1. catch-up (approach the target) + ResolveWithTransition sweep + shadow-follows-resolved /// => two converging creatures SETTLE at contact-distance (sum of radii), not overlapping /// — the PROACTIVE de-overlap (each is stopped short of its overlapping target by its /// neighbour) and PERSISTENCE (the stopped position holds because the shadow tracks it). /// /// 2. the SAME loop WITHOUT the shadow-follows-resolved sync (shadow left at each creature's /// START / server-truth position) => the creatures OVERLAP — proving the sync is /// load-bearing, not a residual (all three Slice-1 reviewers flagged this; it was wrongly /// deferred). /// /// Grounded creature-vs-creature, flat terrain, purely-horizontal convergence so the vertical /// axis is stable and the assertion is about XY separation. Sphere dims = the human Setup /// (R 0.48, capsule top 1.835 — TS-46). Mirrors the Path B sweep call in GameWindow /// (isOnGround, EdgeSlide, self-skip via movingEntityId). /// public class RemoteDeOverlapMechanismTests { private readonly ITestOutputHelper _out; public RemoteDeOverlapMechanismTests(ITestOutputHelper output) => _out = output; private const uint Lb = 0xA9B40000u; private const uint Cell = Lb | 0x0001u; private const float R = 0.48f, H = 1.835f, StepUp = 0.60f, StepDown = 0.60f; private const float ContactDist = 2f * R; // 0.96 m centre-to-centre when just touching private const float GroundZ = R; // foot sphere resting on the flat (Z=0) terrain private const float StepPerTick = 0.03f; // catch-up step magnitude toward the target // The pair freezes at the first-contact position, so the residual overlap at rest is ~one // catch-up step (validate_transition restores curr_pos on the step that would deepen the // overlap). Accept up to ~2 steps of slack; a finer step settles nearer ContactDist. private const float SettleSlack = 2f * StepPerTick + 0.01f; private static PhysicsEngine BuildEngine() { var engine = new PhysicsEngine { DataCache = new PhysicsDataCache() }; engine.AddLandblock(Lb, new TerrainSurface(new byte[81], new float[256]), Array.Empty(), Array.Empty(), 0f, 0f); return engine; } private static void RegisterCreatureAt(PhysicsEngine e, uint id, Vector3 c) => e.ShadowObjects.Register(id, 0u, c, Quaternion.Identity, R, 0f, 0f, Lb, ShadowCollisionType.Sphere, 0f, 1f, 0u, EntityCollisionFlags.IsCreature, isStatic: false); // #184 Slice 2b: a PLAYER remote's shadow — flagged IsPlayer (BF_PLAYER, // 0x8 → EntityCollisionFlags.IsPlayer) and IsCreature (a player IS an // ItemType.Creature). Non-PK (no IsPK/IsPKLite), so the PvP walk-through // exemption's only live disqualifier would be an IsPlayer *mover* — which // the production remote sweep never sets (it passes moverFlags: EdgeSlide). private static void RegisterPlayerAt(PhysicsEngine e, uint id, Vector3 c) => e.ShadowObjects.Register(id, 0u, c, Quaternion.Identity, R, 0f, 0f, Lb, ShadowCollisionType.Sphere, 0f, 1f, 0u, EntityCollisionFlags.IsPlayer | EntityCollisionFlags.IsCreature, isStatic: false); private static PhysicsBody GroundedBody(Vector3 pos) => new PhysicsBody { Position = pos, Orientation = Quaternion.Identity, State = PhysicsStateFlags.ReportCollisions, TransientState = TransientStateFlags.Contact | TransientStateFlags.OnWalkable | TransientStateFlags.Active, Velocity = Vector3.Zero, }; /// One catch-up-step + sweep for one creature (human dims); returns the resolved position. private Vector3 StepToward(PhysicsEngine engine, uint id, PhysicsBody body, ref uint cell, Vector3 target) => StepToward(engine, id, body, ref cell, target, R, H); /// One catch-up-step + sweep with an explicit mover radius/height (Slice 3). private Vector3 StepToward(PhysicsEngine engine, uint id, PhysicsBody body, ref uint cell, Vector3 target, float radius, float height) { Vector3 pre = body.Position; Vector3 flatTarget = new Vector3(target.X, target.Y, pre.Z); Vector3 delta = flatTarget - pre; float dist = delta.Length(); Vector3 post = dist <= StepPerTick ? flatTarget : pre + delta / dist * StepPerTick; var r = engine.ResolveWithTransition(pre, post, cell, radius, height, StepUp, StepDown, isOnGround: true, body: body, moverFlags: ObjectInfoState.EdgeSlide, movingEntityId: id); body.Position = r.Position; if (r.CellId != 0) cell = r.CellId; return body.Position; } [Fact] public void ConvergingCreatures_WithShadowFollowingResolved_SettleAtContactDistance() { var engine = BuildEngine(); // Two creatures 2 m apart (well clear), each catching up toward the SAME centre point // (10,10) — if either reached it they would coincide (full overlap). The sweep must stop // each short at contact-distance from the other. uint idA = 0xA1u, idB = 0xB2u; var target = new Vector3(10f, 10f, GroundZ); var a = GroundedBody(new Vector3(9f, 10f, GroundZ)); var b = GroundedBody(new Vector3(11f, 10f, GroundZ)); RegisterCreatureAt(engine, idA, a.Position); RegisterCreatureAt(engine, idB, b.Position); uint cellA = Cell, cellB = Cell; float sepAt = 0f; for (int tick = 0; tick < 250; tick++) { // Creature A: catch-up + sweep against B's CURRENT shadow, then sync A's shadow to // the resolved body (retail: change_cell from the resolved m_position). var pa = StepToward(engine, idA, a, ref cellA, target); engine.ShadowObjects.UpdatePosition(idA, pa, Quaternion.Identity, 0f, 0f, Lb, seedCellId: cellA); var pb = StepToward(engine, idB, b, ref cellB, target); engine.ShadowObjects.UpdatePosition(idB, pb, Quaternion.Identity, 0f, 0f, Lb, seedCellId: cellB); float s = Vector2.Distance(new(a.Position.X, a.Position.Y), new(b.Position.X, b.Position.Y)); if (tick == 200) sepAt = s; // separation snapshot 50 ticks before the end if (tick % 50 == 0 || tick == 249) _out.WriteLine($"tick{tick,3}: A=({a.Position.X:F2},{a.Position.Y:F2}) " + $"B=({b.Position.X:F2},{b.Position.Y:F2}) sep={s:F3}"); } float sep = Vector2.Distance(new(a.Position.X, a.Position.Y), new(b.Position.X, b.Position.Y)); _out.WriteLine($"with-sync final sep={sep:F3} m (contact-distance = {ContactDist:F2} m)"); // The sweep de-overlaps them to a stable equilibrium ~0.10 m inside touching-distance // ("barely overlapping" — the retail look), from a start where, without the sync, they // fully coincide. Assert: clearly de-overlapped (>= 0.80 m, i.e. within ~0.16 of contact) ... Assert.True(sep >= ContactDist - 0.16f, $"converging creatures must de-overlap to near contact-distance; got {sep:F3} m (contact {ContactDist:F2})"); // ... and STABLE (not collapsing back into overlap over the last 50 ticks). Assert.True(MathF.Abs(sep - sepAt) < 0.02f, $"the de-overlapped separation must be stable; drifted {sepAt:F3} -> {sep:F3}"); } [Fact] public void ConvergingCreatures_WithoutShadowSync_Overlap_ProvingTheSyncIsLoadBearing() { var engine = BuildEngine(); uint idA = 0xA1u, idB = 0xB2u; var target = new Vector3(10f, 10f, GroundZ); var a = GroundedBody(new Vector3(9f, 10f, GroundZ)); var b = GroundedBody(new Vector3(11f, 10f, GroundZ)); RegisterCreatureAt(engine, idA, a.Position); RegisterCreatureAt(engine, idB, b.Position); uint cellA = Cell, cellB = Cell; // SAME convergence, but the shadow is NEVER re-synced to the resolved body — it stays at // each creature's START position (the "shadow at server/stale truth" bug). Each creature // sweeps against where its neighbour WAS, not where it IS, so nothing stops them reaching // the shared centre => they end up overlapping. for (int tick = 0; tick < 250; tick++) { StepToward(engine, idA, a, ref cellA, target); StepToward(engine, idB, b, ref cellB, target); } float sep = Vector2.Distance(new(a.Position.X, a.Position.Y), new(b.Position.X, b.Position.Y)); _out.WriteLine($"no-sync final sep={sep:F3} m (contact-distance = {ContactDist:F2} m)"); // Without the sync each creature sweeps against its neighbour's STALE start-position // shadow, so nothing stops them reaching the shared centre => they pack well inside // contact-distance. This is the load-bearing contrast with the synced test above. Assert.True(sep < 0.40f, $"without the shadow-follows-resolved sync the creatures should heavily OVERLAP (< 0.40 m — both reach " + $"the shared centre because each sweeps only its neighbour's stale start-shadow); got {sep:F3} m — " + $"if this fails the sync may not be the mechanism, rethink before wiring"); } /// /// Integration test through the PRODUCTION catch-up driver (the #184 review's /// finding-1 gap): the earlier mechanism tests use a synthetic fixed step, but /// production drives the body from /// → , which has a STALL-BLIP: when /// the body is blocked short of an OVERLAPPING server waypoint (exactly the /// de-overlap equilibrium) it makes no progress, node_fail_counter climbs past 3 /// over ~5-frame windows, and it fires a blip-to-tail (a jump straight AT the /// overlap target) then clears the queue. The concern: if the per-tick sweep does /// not absorb that blip, the monster pops into its neighbour a few times a second. /// This drives the REAL loop (Enqueue at UP cadence + ComputeOffset + sweep + /// shadow-sync) for two creatures converging on a shared point and asserts BOTH /// that they de-overlap AND that no single tick's net move spikes (the sweep /// absorbs every blip — matching retail, which runs the same interp + collision). /// [Fact] public void ConvergingCreatures_RealInterpLoop_DeOverlapsAndAbsorbsTheStallBlip() { var engine = BuildEngine(); uint idA = 0xA1u, idB = 0xB2u; var target = new Vector3(10f, 10f, GroundZ); // shared point → coincide if reached var a = GroundedBody(new Vector3(9f, 10f, GroundZ)); var b = GroundedBody(new Vector3(11f, 10f, GroundZ)); RegisterCreatureAt(engine, idA, a.Position); RegisterCreatureAt(engine, idB, b.Position); uint cellA = Cell, cellB = Cell; var interpA = new InterpolationManager(); var interpB = new InterpolationManager(); var combA = new RemoteMotionCombiner(); var combB = new RemoteMotionCombiner(); const float maxSpeed = 4f; // motion-table max speed → catch-up ≤ 2× = 8 m/s const float dt = 1f / 60f; const int upEvery = 10; // ~6 Hz UpdatePosition cadence float maxSpikeA = 0f, maxSpikeB = 0f; for (int tick = 0; tick < 600; tick++) // 10 s — long enough for many stall-blip cycles { if (tick % upEvery == 0) { // "UpdatePosition": the server keeps reporting the (overlapping) target — // MoveOrTeleport near-branch enqueues it for the catch-up to chase. interpA.Enqueue(target, 0f, isMovingTo: false, currentBodyPosition: a.Position); interpB.Enqueue(target, 0f, isMovingTo: false, currentBodyPosition: b.Position); } var preA = a.Position; a.Position += combA.ComputeOffset(dt, a.Position, Vector3.Zero, a.Orientation, interpA, maxSpeed); var rA = engine.ResolveWithTransition(preA, a.Position, cellA, R, H, StepUp, StepDown, isOnGround: true, body: a, moverFlags: ObjectInfoState.EdgeSlide, movingEntityId: idA); a.Position = rA.Position; if (rA.CellId != 0) cellA = rA.CellId; engine.ShadowObjects.UpdatePosition(idA, a.Position, Quaternion.Identity, 0f, 0f, Lb, seedCellId: cellA); var preB = b.Position; b.Position += combB.ComputeOffset(dt, b.Position, Vector3.Zero, b.Orientation, interpB, maxSpeed); var rB = engine.ResolveWithTransition(preB, b.Position, cellB, R, H, StepUp, StepDown, isOnGround: true, body: b, moverFlags: ObjectInfoState.EdgeSlide, movingEntityId: idB); b.Position = rB.Position; if (rB.CellId != 0) cellB = rB.CellId; engine.ShadowObjects.UpdatePosition(idB, b.Position, Quaternion.Identity, 0f, 0f, Lb, seedCellId: cellB); // Ignore the initial approach (they start 2 m apart and legitimately move // ~0.13 m/tick); measure the per-tick net move only once they are near the // equilibrium where the stall-blip fires. if (tick > 120) { maxSpikeA = MathF.Max(maxSpikeA, Vector2.Distance(new(a.Position.X, a.Position.Y), new(preA.X, preA.Y))); maxSpikeB = MathF.Max(maxSpikeB, Vector2.Distance(new(b.Position.X, b.Position.Y), new(preB.X, preB.Y))); } } float sep = Vector2.Distance(new(a.Position.X, a.Position.Y), new(b.Position.X, b.Position.Y)); _out.WriteLine($"real-interp: sep={sep:F3} m, maxSpike A={maxSpikeA:F3} B={maxSpikeB:F3}"); Assert.True(sep >= ContactDist - 0.16f, $"real-loop converging creatures must de-overlap to near contact-distance; got {sep:F3} m"); // The stall-blip must be absorbed by the sweep every time — no tick jumps the body // more than a large catch-up step (8 m/s × dt ≈ 0.13 m; allow generous headroom). Assert.True(maxSpikeA < 0.30f && maxSpikeB < 0.30f, $"a stall-blip escaped the sweep (monster popped into its neighbour): " + $"maxSpike A={maxSpikeA:F3} B={maxSpikeB:F3} m (limit 0.30)"); } /// /// Slice 3 (#184): the de-overlap distance must SCALE with the mover's radius — /// a LARGE creature (bigger Setup sphere) spreads WIDER than a human, a small one /// tighter. Production now passes each creature's Setup-derived radius/height /// (GameWindow.GetSetupCylinder × ObjScale) into this exact sweep instead of the /// hardcoded human 0.48/1.835. Register + sweep two LARGE creatures (R=0.9) and /// assert they settle near 2×0.9 = 1.8 m — materially wider than the human 0.96 m /// contact — proving the sweep de-overlaps at the radius it is given (the property /// Slice 3 relies on). /// [Fact] public void ConvergingLargeCreatures_DeOverlapWiderThanHuman() { const float bigR = 0.9f, bigH = 3.2f; const float bigContact = 2f * bigR; // 1.80 m centre-to-centre when touching var engine = BuildEngine(); uint idA = 0xA1u, idB = 0xB2u; var target = new Vector3(10f, 10f, bigR); // shared centre → coincide if reached var a = GroundedBody(new Vector3(7.5f, 10f, bigR)); // start well clear (2.5 m each side) var b = GroundedBody(new Vector3(12.5f, 10f, bigR)); engine.ShadowObjects.Register(idA, 0u, a.Position, Quaternion.Identity, bigR, 0f, 0f, Lb, ShadowCollisionType.Sphere, 0f, 1f, 0u, EntityCollisionFlags.IsCreature, isStatic: false); engine.ShadowObjects.Register(idB, 0u, b.Position, Quaternion.Identity, bigR, 0f, 0f, Lb, ShadowCollisionType.Sphere, 0f, 1f, 0u, EntityCollisionFlags.IsCreature, isStatic: false); uint cellA = Cell, cellB = Cell; for (int tick = 0; tick < 300; tick++) { var pa = StepToward(engine, idA, a, ref cellA, target, bigR, bigH); engine.ShadowObjects.UpdatePosition(idA, pa, Quaternion.Identity, 0f, 0f, Lb, seedCellId: cellA); var pb = StepToward(engine, idB, b, ref cellB, target, bigR, bigH); engine.ShadowObjects.UpdatePosition(idB, pb, Quaternion.Identity, 0f, 0f, Lb, seedCellId: cellB); } float sep = Vector2.Distance(new(a.Position.X, a.Position.Y), new(b.Position.X, b.Position.Y)); _out.WriteLine($"large-creature sep={sep:F3} m (big contact {bigContact:F2}, human contact {ContactDist:F2})"); Assert.True(sep >= bigContact - 0.20f, $"large creatures must de-overlap near their 2R contact ({bigContact:F2} m); got {sep:F3} m"); Assert.True(sep > ContactDist + 0.4f, $"large creatures must spread materially WIDER than the human contact ({ContactDist:F2} m); got {sep:F3} m"); } /// /// #184 Slice 2b — RETAIL PvP: two non-PK PLAYER remotes must WALK THROUGH each /// other, NOT de-overlap. Slice 2b collapses the remote fork so grounded players /// run the sweep, but the production remote-player mover carries /// (mirroring the LOCAL player at /// PlayerMovementController), so 's PvP /// block (mover-IsPlayer AND target-IsPlayer, neither PK/PKLite/Impenetrable) /// EXEMPTS the pair — you can stand inside another non-PK player in AC. Retail /// sets IsPlayer on every object's own transition (OBJECTINFO::init 0x0050cf30 /// state |= 0x100 from its weenie IsPlayer()); FindObjCollisions /// pc:276812 exempts the non-PK pair. This test drives the real interp loop with /// two IsPlayer movers converging on a shared point and asserts they reach it /// (pass through, sep < 0.40 m) instead of stopping at contact-distance. The /// adversarial review of the first 2b draft caught the missing IsPlayer bit — the /// draft de-overlapped players (MORE solid than retail). /// [Fact] public void ConvergingPlayers_WalkThroughEachOther_PerRetailPvpExemption() { var engine = BuildEngine(); uint idA = 0x50000001u, idB = 0x50000002u; // player guids (0x50…) var target = new Vector3(10f, 10f, GroundZ); // shared point → coincide if not blocked var a = GroundedBody(new Vector3(9f, 10f, GroundZ)); var b = GroundedBody(new Vector3(11f, 10f, GroundZ)); RegisterPlayerAt(engine, idA, a.Position); RegisterPlayerAt(engine, idB, b.Position); uint cellA = Cell, cellB = Cell; var interpA = new InterpolationManager(); var interpB = new InterpolationManager(); var combA = new RemoteMotionCombiner(); var combB = new RemoteMotionCombiner(); const float maxSpeed = 4f; const float dt = 1f / 60f; const int upEvery = 10; // Production remote-PLAYER mover flags (RemotePhysicsUpdater sweep, IsPlayerGuid branch). const ObjectInfoState playerMover = ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide; for (int tick = 0; tick < 600; tick++) { if (tick % upEvery == 0) { interpA.Enqueue(target, 0f, isMovingTo: false, currentBodyPosition: a.Position); interpB.Enqueue(target, 0f, isMovingTo: false, currentBodyPosition: b.Position); } var preA = a.Position; a.Position += combA.ComputeOffset(dt, a.Position, Vector3.Zero, a.Orientation, interpA, maxSpeed); var rA = engine.ResolveWithTransition(preA, a.Position, cellA, R, H, StepUp, StepDown, isOnGround: true, body: a, moverFlags: playerMover, movingEntityId: idA); a.Position = rA.Position; if (rA.CellId != 0) cellA = rA.CellId; engine.ShadowObjects.UpdatePosition(idA, a.Position, Quaternion.Identity, 0f, 0f, Lb, seedCellId: cellA); var preB = b.Position; b.Position += combB.ComputeOffset(dt, b.Position, Vector3.Zero, b.Orientation, interpB, maxSpeed); var rB = engine.ResolveWithTransition(preB, b.Position, cellB, R, H, StepUp, StepDown, isOnGround: true, body: b, moverFlags: playerMover, movingEntityId: idB); b.Position = rB.Position; if (rB.CellId != 0) cellB = rB.CellId; engine.ShadowObjects.UpdatePosition(idB, b.Position, Quaternion.Identity, 0f, 0f, Lb, seedCellId: cellB); } float sep = Vector2.Distance(new(a.Position.X, a.Position.Y), new(b.Position.X, b.Position.Y)); _out.WriteLine($"player-vs-player (PvP exempt): sep={sep:F3} m (contact {ContactDist:F2})"); // The PvP exemption fires (both movers IsPlayer, non-PK) → nothing stops them // reaching the shared centre → they coincide, exactly like the without-sync // creature case. If this asserts >= contact-distance, the mover lost its // IsPlayer bit and players are wrongly de-overlapping (MORE solid than retail). Assert.True(sep < 0.40f, $"two non-PK player remotes must WALK THROUGH each other (retail PvP exemption); " + $"got sep={sep:F3} m — if this is near contact-distance the remote mover is missing IsPlayer"); } /// /// #184 Slice 2b — a PLAYER remote DOES collide with (and de-overlap) a MONSTER, /// and the #40 stall-blip is absorbed on the IsPlayer mover. This is the other /// half of the PvP story: the exemption only fires player-vs-player, so a player /// mover (IsPlayer) vs a creature target (IsCreature, not IsPlayer) proceeds to /// collision. Drives the real interp loop with a player converging on a monster /// and asserts (a) they de-overlap to near contact-distance and (b) no tick's net /// move spikes — proving #40 is dead for the player-mover sweep (the reason Path A /// omitted it), the property the visual gate can't measure. /// [Fact] public void PlayerVsMonster_DeOverlapsAndAbsorbsTheStallBlip() { var engine = BuildEngine(); uint idPlayer = 0x50000001u, idMonster = 0x80000002u; var target = new Vector3(10f, 10f, GroundZ); var p = GroundedBody(new Vector3(9f, 10f, GroundZ)); // player var m = GroundedBody(new Vector3(11f, 10f, GroundZ)); // monster RegisterPlayerAt(engine, idPlayer, p.Position); RegisterCreatureAt(engine, idMonster, m.Position); uint cellP = Cell, cellM = Cell; var interpP = new InterpolationManager(); var interpM = new InterpolationManager(); var combP = new RemoteMotionCombiner(); var combM = new RemoteMotionCombiner(); const float maxSpeed = 4f; const float dt = 1f / 60f; const int upEvery = 10; const ObjectInfoState playerMover = ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide; float maxSpikeP = 0f, maxSpikeM = 0f; for (int tick = 0; tick < 600; tick++) { if (tick % upEvery == 0) { interpP.Enqueue(target, 0f, isMovingTo: false, currentBodyPosition: p.Position); interpM.Enqueue(target, 0f, isMovingTo: false, currentBodyPosition: m.Position); } var preP = p.Position; p.Position += combP.ComputeOffset(dt, p.Position, Vector3.Zero, p.Orientation, interpP, maxSpeed); var rP = engine.ResolveWithTransition(preP, p.Position, cellP, R, H, StepUp, StepDown, isOnGround: true, body: p, moverFlags: playerMover, movingEntityId: idPlayer); p.Position = rP.Position; if (rP.CellId != 0) cellP = rP.CellId; engine.ShadowObjects.UpdatePosition(idPlayer, p.Position, Quaternion.Identity, 0f, 0f, Lb, seedCellId: cellP); var preM = m.Position; m.Position += combM.ComputeOffset(dt, m.Position, Vector3.Zero, m.Orientation, interpM, maxSpeed); var rM = engine.ResolveWithTransition(preM, m.Position, cellM, R, H, StepUp, StepDown, isOnGround: true, body: m, moverFlags: ObjectInfoState.EdgeSlide, movingEntityId: idMonster); m.Position = rM.Position; if (rM.CellId != 0) cellM = rM.CellId; engine.ShadowObjects.UpdatePosition(idMonster, m.Position, Quaternion.Identity, 0f, 0f, Lb, seedCellId: cellM); if (tick > 120) { maxSpikeP = MathF.Max(maxSpikeP, Vector2.Distance(new(p.Position.X, p.Position.Y), new(preP.X, preP.Y))); maxSpikeM = MathF.Max(maxSpikeM, Vector2.Distance(new(m.Position.X, m.Position.Y), new(preM.X, preM.Y))); } } float sep = Vector2.Distance(new(p.Position.X, p.Position.Y), new(m.Position.X, m.Position.Y)); _out.WriteLine($"player-vs-monster: sep={sep:F3} m, maxSpike P={maxSpikeP:F3} M={maxSpikeM:F3}"); Assert.True(sep >= ContactDist - 0.16f, $"a player converging on a monster must de-overlap to near contact-distance (no PvP exemption vs a creature); got {sep:F3} m"); Assert.True(maxSpikeP < 0.30f && maxSpikeM < 0.30f, $"#40 reintroduced for the player mover — a stall-blip escaped the sweep: maxSpike P={maxSpikeP:F3} M={maxSpikeM:F3} m (limit 0.30)"); } }