acdream/tests/AcDream.Core.Tests/Physics/RemoteDeOverlapMechanismTests.cs
Erik ddb5a96799 feat(#184): Slice 2b — unify the remote player/NPC fork (players collide faithfully)
Collapse the two-path fork in RemotePhysicsUpdater.Tick: the former Path A
(grounded PLAYER remotes advanced by the interp catch-up with ResolveWithTransition
deliberately OMITTED, per the now-retired issue-#40 premise) is gone. Every remote --
player and NPC -- now runs the SAME per-tick catch-up + sweep + shadow-follows-resolved.
Retail's UpdateObjectInternal (0x005156b0) has no player/remote fork; this is the
faithful shape. Player remotes now get terrain-Z snap (no slope staircase), wall
collision, and MONSTER collision -- previously (Path A) they skipped ALL collision.

RETAIL PvP (adversarial review caught this): two non-PK players WALK THROUGH each
other in AC (you can stand inside another non-PK player) -- they do NOT de-overlap.
The remote-player mover now carries IsPlayer|EdgeSlide (mirroring the LOCAL player at
PlayerMovementController), so CollisionExemption's PvP block exempts a non-PK pair,
exactly as retail sets IsPlayer on every object's own transition (OBJECTINFO::init
0x0050cf30) and FindObjCollisions (pc:276812) exempts it. The first 2b draft passed
bare EdgeSlide and de-overlapped players (MORE solid than retail); the 3-lens review
flagged it. PK/PKLite/Impenetrable are not plumbed onto the remote mover yet -- the
same M1.5 gap the local player carries (TS-23, extended).

#40 proven dead in code (before the gate): PlayerVsMonster_DeOverlapsAndAbsorbsTheStallBlip
drives the real ComputeOffset -> InterpolationManager catch-up (incl. the fail_count
blip-to-tail) for a player mover converging on a monster and asserts de-overlap +
maxSpike<0.30 -- the sweep absorbs the stall-blip. ConvergingPlayers_WalkThroughEachOther
proves the PvP exemption (non-PK players pass through). Path B already ran the
#40-feared config stably; #40 (May 2026) predates the CSphere/#137/#170/#171 rebuild.

Placement (HIGH, review finding 3): the player UP routing gains the SAME placement-snap
backstop Slice 1 gave NPCs (AP-87). Without it a UM-first player (RemoteMotion seeded
to the spawn pos, then a first UP in a different cell) would sweep from a stale cell ->
garbage -> the digest's invisible/misplaced-player bug. The 4 m bodyToTarget guard +
!willBeDrTicked + dist>96 snap; near placed corrections still enqueue for smooth
catch-up. Also seed Body.Position=worldPos at UP-handler RemoteMotion creation
(mirrors the UM handler :5176) for the UP-first case.

Coupled shadow edits (research finding 9): RETIRED the players-only raw-worldPos shadow
sync -- now that players run the sweep + shadow-follows-resolved, the raw sync would
re-snap a packed player's shadow into overlap each UP. Player shadows follow the
RESOLVED body via the DR-tick loop + a new player UP-branch-tail SyncRemoteShadowToBody.

Surviving player/NPC split (AP-88): the omega -- grounded PLAYERS keep the
ObservedOmega-or-seqOmega world-frame (Concatenate) fallback ("rectangle when running
circles"); NPCs + airborne keep ObservedOmega-only body-frame (Multiply). They commute
for an upright body + yaw omega, so the fork is faithful.

Register: TS-23 extended (remote-player mover PK gap); AP-86 updated (raw sync retired
for players too, Where column fixed); AP-88 added (omega fork + eval-order note).

Tests: Core 2623 / App 741 green, 0 warnings. 3-lens adversarial review + per-finding
verification (10 agents); all 6 confirmed findings addressed (2 substantive: PvP mover
flags + player placement-snap; 4 doc/cosmetic).

VISUAL GATE (acceptance test) owed by the user -- NOTE the corrected expectation:
  (a) a player remote on a hill -- no slope staircase;
  (b) two packed player remotes -- they WALK THROUGH each other (retail PvP), NOT
      de-overlap (this corrects the design's original "players de-overlap" gate);
  (c) a player remote cannot stand inside a MONSTER (new: player-vs-monster collision);
  (d) remote walk/run/jump/land/turn UNCHANGED.

Handoff: docs/research/2026-07-07-184-slice2-unify-extract-handoff.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 00:45:44 +02:00

457 lines
26 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Numerics;
using AcDream.Core.Physics;
using Xunit;
using Xunit.Abstractions;
namespace AcDream.Core.Tests.Physics;
/// <summary>
/// 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).
/// </summary>
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<CellSurface>(), Array.Empty<PortalPlane>(), 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,
};
/// <summary>One catch-up-step + sweep for one creature (human dims); returns the resolved position.</summary>
private Vector3 StepToward(PhysicsEngine engine, uint id, PhysicsBody body, ref uint cell,
Vector3 target)
=> StepToward(engine, id, body, ref cell, target, R, H);
/// <summary>One catch-up-step + sweep with an explicit mover radius/height (Slice 3).</summary>
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");
}
/// <summary>
/// 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 <see cref="RemoteMotionCombiner.ComputeOffset"/>
/// → <see cref="InterpolationManager.AdjustOffset"/>, 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).
/// </summary>
[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)");
}
/// <summary>
/// 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).
/// </summary>
[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");
}
/// <summary>
/// #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
/// <see cref="ObjectInfoState.IsPlayer"/> (mirroring the LOCAL player at
/// <c>PlayerMovementController</c>), so <see cref="CollisionExemption"/>'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
/// <c>state |= 0x100</c> from its weenie <c>IsPlayer()</c>); 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 &lt; 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).
/// </summary>
[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");
}
/// <summary>
/// #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.
/// </summary>
[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)");
}
}