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>
457 lines
26 KiB
C#
457 lines
26 KiB
C#
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 < 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)");
|
||
}
|
||
}
|