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>
This commit is contained in:
parent
e1ac56cce9
commit
ddb5a96799
4 changed files with 303 additions and 309 deletions
|
|
@ -64,6 +64,16 @@ public class RemoteDeOverlapMechanismTests
|
|||
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,
|
||||
|
|
@ -303,4 +313,145 @@ public class RemoteDeOverlapMechanismTests
|
|||
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)");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue