acdream/tests/AcDream.Core.Tests/Physics/RemoteDeOverlapMechanismTests.cs
Erik 2d611b2b01 fix(physics): #265 landing-bounce family - retail check_contact seed + velocity-free landing commit
Retail jump landings BOUNCE: the floor touch records both a contact plane
(grounding) AND a collision normal (collided_with_environment), and
handle_all_collisions reflects the unmodified impact velocity off it at
5% elasticity (v += -(v.n)(elasticity+1).n, DEFAULT_ELASTICITY 0.05
@0x007c6a7c). Our transition already recorded both facts; the bounce was
suppressed by the AD-25 adaptation stack in the per-tick commit: a
Velocity.Z<=0 landing gate (needed because the resolver glued ascending
movers to the ground) plus a landing Velocity.Z=0 hand-zero whose stated
purpose was making the reflect a no-op. Downhill glided instead of
bouncing, flat-ground landings had no pop, and uphill jumps flapped
between grounded/airborne against the animation machine.

Three retail mechanisms replace the stack:
- check_contact (0x0050f5b0) seeding in ResolveWithTransition: a body in
  CONTACT seeds the transition's contact only while v.contactPlane.N <=
  0.0002; moving away seeds the last-known plane alone (get_object_info
  0x00511cc0). Ascending jumps therefore run contact-free (ballistic, no
  glue) - the gate's reason-for-being is gone. The plane requirement is
  strict: Contact-without-plane is unrepresentable in retail.
- SetPositionInternal-shaped commit (0x00515330, byte-read end-to-end,
  velocity-sign-FREE): contact purely from the transition's contact
  plane, HitGround on the airborne->walkable edge, HandleAllCollisions
  with unmodified impact velocity. Whole commit gated on Ok &&
  candidateMoved (retail pc:283657 skips SetPositionInternal entirely
  when the candidate did not move) - a standing body's contact state is
  never re-derived, which is what keeps rest bit-stable (AD-41 updated).
- Byte decodes: gate override state&0x800000=Sledding, zero branch
  state&0x20000=Inelastic, reflect strictly dot<0 - our port already had
  all three correct.

Settle: real landings (>=0.25 m/s) bounce and decay geometrically;
smaller impacts are consumed by retail's unconditional small-velocity
zero, so standing never micro-bounces. Re-baselines documented in place:
landing-survival pin measures decay post-settle; LiveCompare_Tick0/376
pin the new IsOnGround=false on zero-move ticks (captured true was the
retired seed echo; tick 376's captured body carries an 11.8 m/s grounded
velocity from the deleted get_state_velocity-overwrite era); de-overlap
fixture now carries the plane real grounded bodies always have. New
pins: LandingBounceSeedingTests (ascent no-seed, rest keeps contact,
strict plane, slope 5% reversal + tangential preservation, Sledding
override).

Investigation + implementation record:
docs/research/2026-07-30-landing-bounce-family.md. Complete Release
suite: 10,031 passed / 5 skips / 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 20:12:06 +02:00

472 lines
27 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,
// #265 bounce rework (2026-07-30): a Contact body without a stored
// plane is unrepresentable in retail (init_contact_plane always
// accompanies the CONTACT seed), and the check_contact seeding now
// requires the plane. Give the fixture the state a real grounded body
// carries: the flat Z=0 terrain plane under its feet.
ContactPlaneValid = true,
ContactPlane = new System.Numerics.Plane(Vector3.UnitZ, 0f),
GroundNormal = Vector3.UnitZ,
};
/// <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})");
// #265 bounce rework (2026-07-30): the old -0.20 slack was calibrated
// against a fixture body carrying Contact WITHOUT a stored plane — a
// state production never generates (the engine always commits flags +
// plane together, and the check_contact seed now requires the pair).
// With the production-representative fixture (plane seeded) the pair
// settles at 1.58 m — the value production always produced for this
// radius; the bound now brackets that with the same ~2-step spirit.
Assert.True(sep >= bigContact - 0.30f,
$"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)");
}
}